New vs Returning users toggle#722
Conversation
- Implements a toggle to display user stats split into "New" and "Returning" users. - Updates charts and tooltips to reflect new breakdown dynamically. - Adjusts server query to calculate and return new user segmentation.
- Modify `getMaxValue` logic to correctly calculate totals when splitting "New" and "Returning" users. - Enable stacked y-axis for charts when breakdown is active.
- Replace "pink" color variants with "accent" palette for consistency. - Adjust chart and tooltip styles to reflect updated colors.
|
@rockinrimmer is attempting to deploy a commit to the goldflag's projects Team on Vercel. A member of the Team first needs to authorize it. |
WalkthroughThe changes introduce user cohort analytics by extending the backend to compute first-visit timestamps and derive new_users and returning_users metrics per time bucket. The frontend integrates a toggle to switch between single-series and dual-series chart visualizations, updates state management, and refactors chart components to support multi-series rendering with stacked Y values and conditional tooltips. Changes
Sequence DiagramsequenceDiagram
participant User
participant UI as Main Section UI
participant Store
participant Chart
participant API as Backend API
User->>UI: Toggle "Show User Breakdown"
UI->>Store: setShowUsersSplit(true)
Store->>Store: Update showUsersSplit state
Store->>UI: Notify subscribers
API->>API: Compute first_visit_bucket per user
API->>API: Aggregate new_users & returning_users per bucket
API->>Chart: Provide bucketed data with cohort split
Chart->>Chart: showUserBreakdown = true
Chart->>Chart: Create two series: new_users, returning_users
Chart->>Chart: Stack Y values per timestamp
Chart->>Chart: Render StackedLines + DashedOverlay layers
Chart->>User: Display dual-series chart with legend
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (9)
client/src/components/ui/card.tsx (1)
28-28: LGTM - Good loader improvements.The additions of
overflow-hiddenandpointer-events-noneare appropriate:
overflow-hiddenprevents the large Zoomies animation (size 1400) from creating visual artifactspointer-events-noneensures the loader doesn't intercept user interactions with the underlying contentThe negative margin
mt-[-15px]appears to be for visual alignment, though it's a magic number that might benefit from a comment explaining the offset purpose.client/src/api/analytics/useGetOverviewBucketed.ts (1)
17-18: Client response type correctly extended for new/returning usersAdding
new_usersandreturning_usershere matches the server-side shape and enables typed access in charts; no issues from a typing or usage standpoint. You may later want to centralize this response type in a shared module to avoid divergence between client and server definitions, but that’s optional.client/src/lib/store.ts (1)
20-21: Store flag wiring forshowUsersSplitis consistent; minor duplication onlyThe new
showUsersSplitstate andsetShowUsersSplitaction are wired consistently across:
- Store type (Lines 20-21)
setSitedefaults (Line 59)- Initial state (Lines 149-150)
resetStore(Lines 156-161)Semantics look good: toggling is per-site, and reset paths correctly clear the flag. The only nit is that
setSiteand the initial state both hardcodeshowUsersSplit: false, andresetStorealso callssetShowUsersSplit(false), which is slightly redundant but harmless. If you want to reduce duplication, you could rely on eithersetSiteorsetShowUsersSplit(false)inresetStore, not both.Also applies to: 59-60, 149-151, 156-161
server/src/api/analytics/getOverviewBucketed.ts (1)
177-187: Server-side response type matches client expectations; consider sharing via a common typeThe
getOverviewBucketedtype alias (Lines 177-187) now includesnew_usersandreturning_users, mirroring the SQL select list and the client’sGetOverviewBucketedResponse. The call toprocessResults<getOverviewBucketed[number]>(Lines 225-226) remains consistent with that alias.To avoid drift between server and client, it may be worth lifting this shape into a shared
@rybbit/sharedtype and importing it on both sides, but the current implementation is functionally correct.Also applies to: 225-226
client/src/app/[site]/main/components/MainSection/MainSection.tsx (1)
84-111: User breakdown toggle + legend UX is solid; minor nit on duplicate control IDsThe toggle and legend integration read cleanly:
- Desktop:
Switch+ label shown only whenselectedStat === "users"(Lines 101-109).- Mobile: a separate
Switch+ label version for small screens (Lines 113-121).- Both bind
checked={showUsersSplit}andonCheckedChange={setShowUsersSplit}, ensuring state is shared.- When
showUserBreakdownis active, the legend below the header clearly labels “New users” vs “Returning users” with appropriate colors (Lines 122-133), aligning with the chart series.One small nit: both desktop and mobile switches use the same
id="toggle-user-breakdown". While functionally this still works, it means:
- There are duplicate IDs in the DOM.
- Each
<label htmlFor="toggle-user-breakdown">may associate with whichever control the browser picks first.If you want to tighten this up, consider giving each switch a unique id and matching label, or rendering a single switch whose layout is controlled purely by responsive classes.
Also applies to: 113-121, 122-133
client/src/app/[site]/main/components/MainSection/Chart.tsx (4)
122-139: Series labels and config abstraction are clear; consider tighteningSeriesConfigtypingThe
SERIES_LABELS+SeriesConfigpattern nicely centralizes series metadata and keeps the “new vs returning” additions localized. To make this even safer long‑term, you could narrowSeriesConfig["id"](and possiblydataKey) to a union of known keys ("pageviews" | "sessions" | "users" | "new_users" | "returning_users" | …) instead of plainstring. That would surface mismatches at compile time if future metrics are added or renamed.
196-255: Multi‑series data shaping and custom layers are sound; small opportunities to simplify and optimizeThe pipeline from
seriesConfig → seriesData → chartPropsData/defs/fill → StackedLines/DashedOverlayreads well and generalizes the previous single‑series logic to multiple series without obvious correctness issues. A couple of small tweaks you might consider:
- Cache
DateTime.now()once per render instead of inside the.mapwhen filtering out future timestamps to avoid tiny time skews and redundant calls:- const seriesData = seriesConfig.map(config => { - const points = - data?.data - ?.map((e, i) => { - // Parse timestamp properly - const timestamp = DateTime.fromSQL(e.time).toUTC(); - - // filter out dates from the future - if (timestamp > DateTime.now()) { + const now = DateTime.now(); + const seriesData = seriesConfig.map(config => { + const points = + data?.data + ?.map((e, i) => { + // Parse timestamp properly + const timestamp = DateTime.fromSQL(e.time).toUTC(); + + // filter out dates from the future + if (timestamp > now) { return null; }
- If you ever see perf pressure here,
seriesDataand the derivedchartPropsData/defs/fillarrays would be good candidates foruseMemokeyed on(data?.data, previousData?.data, selectedStat, showUsersSplit)to avoid recomputing on every render.Overall, the use of
yScale.stacked = showUserBreakdownand theyStacked ?? yfallback in both custom layers matches the intended “stack only when showing new vs returning” behaviour.Also applies to: 257-300, 318-318
366-427: Single‑series tooltip logic is consistent; minor leftover normalization can be simplifiedThe single‑series tooltip correctly pulls
currentTime,previousTime, and Y values from the underlying point data and formats them using the existing helpers, so behaviour should match the old implementation for non‑split modes.The only small nit is the normalization step:
const normalizedPoints = slice.points.map((point: any) => ({ ...point, originalSerieId: String(point.serieId), serieId: String(point.serieId).replace(/-dashed$/, "-base"), }));Given that the actual series ids you feed into Nivo are already of the form
${series.id}-baseand the dashed overlay is drawn as a separate SVG path (not as its own series), this-dashed→-basereplacement no longer seems necessary. You could drop it and just useslice.pointsdirectly for a tiny simplification, unless you plan to reintroduce separate dashed series ids later.
429-511: Two‑series tooltip for new vs returning is well‑structured; optional extra guard for emptyrowsThe two‑series tooltip correctly uses the slice’s timestamp to look up matching points in
seriesDatafor bothnew_usersandreturning_users, and presents current/previous values plus percentage deltas with appropriate colours per series. This looks aligned with the new toggle’s intent.You might add a small defensive guard before rendering, in case
rowsends up empty due to any future data inconsistencies, to avoid an empty tooltip shell:- const rows = seriesConfig + const rows = seriesConfig .map(series => { // ... }) .filter(Boolean); - - return ( + if (!rows.length) return null; + + return ( <ChartTooltip> {rows.map((row: any, idx: number) => ( {/* ... */}Not required for current behaviour (your data pipeline should always yield at least one row), but it makes the tooltip more robust to edge cases.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (7)
client/src/api/analytics/useGetOverviewBucketed.ts(1 hunks)client/src/app/[site]/main/components/MainSection/Chart.tsx(7 hunks)client/src/app/[site]/main/components/MainSection/MainSection.tsx(4 hunks)client/src/app/[site]/main/components/MainSection/PreviousChart.tsx(4 hunks)client/src/components/ui/card.tsx(1 hunks)client/src/lib/store.ts(3 hunks)server/src/api/analytics/getOverviewBucketed.ts(4 hunks)
🧰 Additional context used
🧬 Code graph analysis (3)
client/src/app/[site]/main/components/MainSection/Chart.tsx (5)
client/src/lib/store.ts (1)
StatType(6-6)client/src/api/analytics/useGetOverviewBucketed.ts (1)
GetOverviewBucketedResponse(9-19)client/src/lib/nivo.ts (2)
nivoTheme(59-59)useNivoTheme(53-56)client/src/components/charts/ChartTooltip.tsx (1)
ChartTooltip(7-13)client/src/lib/dateTimeUtils.ts (1)
formatChartDateTime(157-178)
server/src/api/analytics/getOverviewBucketed.ts (1)
server/src/api/analytics/utils.ts (2)
TimeBucketToFn(315-325)getTimeStatement(7-65)
client/src/app/[site]/main/components/MainSection/MainSection.tsx (3)
client/src/lib/store.ts (1)
useStore(26-153)client/src/components/ui/switch.tsx (1)
Switch(29-29)client/src/components/BucketSelection.tsx (1)
BucketSelection(155-166)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
- GitHub Check: Build Client Images (ubuntu-latest, linux/amd64)
- GitHub Check: Build Client Images (ubuntu-24.04-arm, linux/arm64)
🔇 Additional comments (5)
client/src/app/[site]/main/components/MainSection/PreviousChart.tsx (1)
46-58: Multi-series previous-period chart wiring looks correct
showUserBreakdownis derived correctly fromselectedStat === "users" && showUsersSplitand is then used consistently for:
- Choosing between single-series (
selectedStat) vs. dual-series (new_users/returning_users) inseriesConfig(Lines 59-66).- Selecting appropriate per-series colors and mapping them via
colorMap(Lines 50-58, 91-95, 160-162).- Enabling
yScale.stackedonly when showing the breakdown (Line 118), which matches howmaxis computed upstream.
chartDataconstruction (Lines 68-89) correctly:
- Buckets timestamps with
DateTime.fromSQL(e.time).toUTC()and formats x-values to match the Nivotimescale.- Filters out future timestamps.
- Reads the right numeric field per series via
series.dataKey.Overall, the previous-period chart is now aligned with the new API fields and the breakdown toggle; I don’t see correctness issues in the series wiring or stacking logic.
To be safe, verify visually that:
- Toggling “New vs returning” switches between one and two previous lines.
- The stacked total height matches the sum of new + returning for a given bucket.
Also applies to: 59-66, 68-89, 91-95, 104-105, 118-121, 160-162
server/src/api/analytics/getOverviewBucketed.ts (1)
115-138: New vs returning user aggregation logic is coherent with bucketed time semanticsThe new CTEs and page_stats calculation look logically sound:
UserFirstVisitscomputes afirst_visit_bucketperuser_idusing the same bucket function and time zone as the main query (Lines 117-124), so comparisons againstbucket_timeare in a consistent time domain.EventsWithFirstVisitre-buckets the filtered events intobucket_timeand carriesfirst_visit_bucketalongside each event (Lines 126-138).- In
page_stats(Lines 162-169):
uniqExact(user_id)gives total active users per bucket.uniqExactIf(user_id, first_visit_bucket = bucket_time)counts users whose first-ever bucket falls exactly in that bucket (new_users).uniqExactIf(user_id, first_visit_bucket < bucket_time)counts those whose first visit was earlier (returning_users).Given the bucketing, this yields:
- Each user counted as
new_usersonce at their first bucket.- Counted as
returning_userson subsequent buckets where they have events.That matches typical “new vs returning” definitions for bucketed analytics. Just ensure this shift doesn’t unintentionally change your existing
usersmetric semantics (e.g., that you still wantuniqExact(user_id)over all event types rather than just pageviews).Also applies to: 162-169
client/src/app/[site]/main/components/MainSection/MainSection.tsx (1)
35-37: Max-value calculation correctly accounts for stacked new+returning vs single-seriesThe new state usage and max logic look good:
showUserBreakdownis properly derived fromselectedStat === "users" && showUsersSplit(Line 36).
activeKeyscorrectly switches between["new_users", "returning_users"]and[selectedStat](Line 61).
getMaxValue(Lines 63-72) handles:
- Breakdown mode: using
(new_users ?? 0) + (returning_users ?? 0)per point, which aligns with the stacked Y-axis.- Non-breakdown mode: taking the max across the active stat key(s) per point.
- Empty/undefined datasets by falling back to
0.
maxOfDataAndPreviousData(Line 74) then uses the greater of current and previous, keeping both charts aligned on the same Y-scale.This matches the new stacked behavior in
PreviousChartand (presumably)Chartand should avoid clipping stacked totals.A quick visual check that:
- The top of the stacked bars/lines never exceeds the grid in breakdown mode.
- Switching breakdown on/off causes sensible rescaling without jumps.
Also applies to: 61-75
client/src/app/[site]/main/components/MainSection/Chart.tsx (2)
149-178: Store wiring andshowUserBreakdowngating look correctDeriving
showUserBreakdownfromselectedStat === "users" && showUsersSplitis a clean way to ensure the toggle only affects the users metric, and theseriesConfigbranches for single vs split mode are straightforward. This should preserve existing behaviour for all otherStatTypevalues while enabling the new toggle.
8-8: Theme‑aware colouring and layer ordering are sensibleUsing
useTheme’sresolvedThemeto adjust the “previous” series colour in the two‑series tooltip keeps contrast acceptable in both light and dark modes, and the fallback neutral colours are reasonable.The custom layers ordering (
grid → axes → areas → crosshair → StackedLines → optional DashedOverlay → slices → points → mesh → legends) also looks good: lines and dashed overlays sit above areas and below interactive layers, which matches expected UX for this chart.Also applies to: 152-153, 432-440, 515-523
| UserFirstVisits AS ( | ||
| SELECT | ||
| user_id, | ||
| toDateTime(${TimeBucketToFn[bucket]}(toTimeZone(min(timestamp), ${SqlString.escape(time_zone)}))) AS first_visit_bucket | ||
| FROM events | ||
| WHERE site_id = {siteId:Int32} | ||
| GROUP BY user_id | ||
| ), |
There was a problem hiding this comment.
Potential performance hotspot: UserFirstVisits scans all events for the site
UserFirstVisits currently does:
FROM events
WHERE site_id = {siteId:Int32}
GROUP BY user_idfor every /overview-bucketed request, regardless of the requested time window. On large sites, this full-table (or full-partition) scan per query can become expensive, especially for short time ranges or dashboards that auto-refresh.
Consider one of:
- Precomputing and storing a per-user
first_visit_bucketin a dimension/summary table and joining that instead of recomputing. - At minimum, gating this logic behind certain buckets or time modes (e.g., only for larger windows) if you know your load patterns, or adding ClickHouse-side projections/materialized views to mitigate the cost.
Right now this is a major scalability concern rather than a correctness one.
🤖 Prompt for AI Agents
In server/src/api/analytics/getOverviewBucketed.ts around lines 117 to 124, the
CTE UserFirstVisits does an unbounded scan of events for the site (GROUP BY
user_id) on every request; change this by either (1) joining against a
precomputed per-user first_visit_bucket dimension/summary table (populate via
ETL or materialized view) instead of recomputing, or (2) gate the CTE so it only
runs for large windows/buckets (check requested time range/bucket and skip or
fallback), or (3) implement a ClickHouse projection/materialized view that
maintains per-user first_visit_bucket and query that instead; update the query
to join that table/view (or conditionalize the CTE) so runtime scans are avoided
for short/auto-refresh requests.
|
Nice feature |
Adds a "New vs Returning" users toggle to the main chart, when enabled this shows the split between new and returning users.
Desktop:

Mobile:

Summary by CodeRabbit
Release Notes
✏️ Tip: You can customize this high-level summary in your review settings.