Skip to content

New vs Returning users toggle#722

Merged
goldflag merged 11 commits intorybbit-io:returning-usersfrom
rockinrimmer:new-returning
Nov 29, 2025
Merged

New vs Returning users toggle#722
goldflag merged 11 commits intorybbit-io:returning-usersfrom
rockinrimmer:new-returning

Conversation

@rockinrimmer
Copy link
Copy Markdown
Contributor

@rockinrimmer rockinrimmer commented Nov 23, 2025

Adds a "New vs Returning" users toggle to the main chart, when enabled this shows the split between new and returning users.

Desktop:
image

Mobile:
image

Summary by CodeRabbit

Release Notes

  • New Features
    • Added toggle to switch between single user metric and breakdown view showing new users vs. returning users
    • Enhanced analytics dashboard with new metrics: sessions, pages per session, bounce rate, and session duration
    • Improved chart visualization to support multi-series data display for better user analytics insights

✏️ Tip: You can customize this high-level summary in your review settings.

- 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.
@vercel
Copy link
Copy Markdown

vercel bot commented Nov 23, 2025

@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.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Nov 23, 2025

Walkthrough

The 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

Cohort / File(s) Summary
API Response & Type Definitions
client/src/api/analytics/useGetOverviewBucketed.ts, server/src/api/analytics/getOverviewBucketed.ts
Added new_users and returning_users fields to GetOverviewBucketedResponse and extended backend query output type to include sessions, pages_per_session, bounce_rate, session_duration, users, new_users, and returning_users metrics.
State Management
client/src/lib/store.ts
Added showUsersSplit boolean state and setShowUsersSplit action to toggle user breakdown visualization mode.
Chart Visualization Components
client/src/app/[site]/main/components/MainSection/Chart.tsx, client/src/app/[site]/main/components/MainSection/PreviousChart.tsx
Refactored both charts to support multi-series rendering with per-series data processing, gradient-based fills, custom SVG layers (StackedLines, DashedOverlay), conditional stacking, and dual-mode tooltips (single-series vs. two-series aggregation with breakdown stats).
Main Section Container
client/src/app/[site]/main/components/MainSection/MainSection.tsx
Added user-breakdown toggle UI, computed showUserBreakdown flag, introduced getMaxValue helper for summing new_users and returning_users when breakdown is active, restructured header layout with centered stat and right-aligned controls, and added legend for user cohorts.
UI Polish
client/src/components/ui/card.tsx
Added overflow-hidden and pointer-events-none classes to CardLoader wrapper for improved overflow containment.

Sequence Diagram

sequenceDiagram
    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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

  • Chart.tsx & PreviousChart.tsx: Substantial refactoring with multi-series logic, custom SVG rendering layers, conditional stacking, and dual tooltip modes. Requires careful validation of data transformation, series mapping, and visual correctness across single and split modes.
  • Data aggregation logic: The getMaxValue helper in MainSection.tsx computes stacked maximums; verify correctness for edge cases (zero values, missing data).
  • Backend CTE changes: Ensure UserFirstVisits and EventsWithFirstVisit CTEs correctly compute cohort attribution and maintain proper bucketing alignment.
  • State integration: Verify showUsersSplit propagation through store, components, and conditional rendering logic across MainSection, Chart, and PreviousChart.

Poem

🐰 Two lines now dance where once was one,
New users and returning ones run,
A toggle flips the view so bright,
Cohorts split in stacked delight!
Analytics bloom, the breakdown's done! 🌱✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'New vs Returning users toggle' accurately summarizes the main change—adding a toggle feature to display the split between new and returning users on the main chart.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (9)
client/src/components/ui/card.tsx (1)

28-28: LGTM - Good loader improvements.

The additions of overflow-hidden and pointer-events-none are appropriate:

  • overflow-hidden prevents the large Zoomies animation (size 1400) from creating visual artifacts
  • pointer-events-none ensures the loader doesn't intercept user interactions with the underlying content

The 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 users

Adding new_users and returning_users here 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 for showUsersSplit is consistent; minor duplication only

The new showUsersSplit state and setShowUsersSplit action are wired consistently across:

  • Store type (Lines 20-21)
  • setSite defaults (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 setSite and the initial state both hardcode showUsersSplit: false, and resetStore also calls setShowUsersSplit(false), which is slightly redundant but harmless. If you want to reduce duplication, you could rely on either setSite or setShowUsersSplit(false) in resetStore, 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 type

The getOverviewBucketed type alias (Lines 177-187) now includes new_users and returning_users, mirroring the SQL select list and the client’s GetOverviewBucketedResponse. The call to processResults<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/shared type 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 IDs

The toggle and legend integration read cleanly:

  • Desktop: Switch + label shown only when selectedStat === "users" (Lines 101-109).
  • Mobile: a separate Switch + label version for small screens (Lines 113-121).
  • Both bind checked={showUsersSplit} and onCheckedChange={setShowUsersSplit}, ensuring state is shared.
  • When showUserBreakdown is 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 tightening SeriesConfig typing

The SERIES_LABELS + SeriesConfig pattern nicely centralizes series metadata and keeps the “new vs returning” additions localized. To make this even safer long‑term, you could narrow SeriesConfig["id"] (and possibly dataKey) to a union of known keys ("pageviews" | "sessions" | "users" | "new_users" | "returning_users" | …) instead of plain string. 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 optimize

The pipeline from seriesConfig → seriesData → chartPropsData/defs/fill → StackedLines/DashedOverlay reads 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 .map when 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, seriesData and the derived chartPropsData/defs/fill arrays would be good candidates for useMemo keyed on (data?.data, previousData?.data, selectedStat, showUsersSplit) to avoid recomputing on every render.

Overall, the use of yScale.stacked = showUserBreakdown and the yStacked ?? y fallback 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 simplified

The 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}-base and the dashed overlay is drawn as a separate SVG path (not as its own series), this -dashed-base replacement no longer seems necessary. You could drop it and just use slice.points directly 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 empty rows

The two‑series tooltip correctly uses the slice’s timestamp to look up matching points in seriesData for both new_users and returning_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 rows ends 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

📥 Commits

Reviewing files that changed from the base of the PR and between 7b5e44f and 685d57b.

📒 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

  • showUserBreakdown is derived correctly from selectedStat === "users" && showUsersSplit and is then used consistently for:

    • Choosing between single-series (selectedStat) vs. dual-series (new_users/returning_users) in seriesConfig (Lines 59-66).
    • Selecting appropriate per-series colors and mapping them via colorMap (Lines 50-58, 91-95, 160-162).
    • Enabling yScale.stacked only when showing the breakdown (Line 118), which matches how max is computed upstream.
  • chartData construction (Lines 68-89) correctly:

    • Buckets timestamps with DateTime.fromSQL(e.time).toUTC() and formats x-values to match the Nivo time scale.
    • 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 semantics

The new CTEs and page_stats calculation look logically sound:

  • UserFirstVisits computes a first_visit_bucket per user_id using the same bucket function and time zone as the main query (Lines 117-124), so comparisons against bucket_time are in a consistent time domain.
  • EventsWithFirstVisit re-buckets the filtered events into bucket_time and carries first_visit_bucket alongside 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_users once at their first bucket.
  • Counted as returning_users on 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 users metric semantics (e.g., that you still want uniqExact(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-series

The new state usage and max logic look good:

  • showUserBreakdown is properly derived from selectedStat === "users" && showUsersSplit (Line 36).

  • activeKeys correctly 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 PreviousChart and (presumably) Chart and 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 and showUserBreakdown gating look correct

Deriving showUserBreakdown from selectedStat === "users" && showUsersSplit is a clean way to ensure the toggle only affects the users metric, and the seriesConfig branches for single vs split mode are straightforward. This should preserve existing behaviour for all other StatType values while enabling the new toggle.


8-8: Theme‑aware colouring and layer ordering are sensible

Using useTheme’s resolvedTheme to 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

Comment on lines +117 to +124
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
),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Potential performance hotspot: UserFirstVisits scans all events for the site

UserFirstVisits currently does:

FROM events
WHERE site_id = {siteId:Int32}
GROUP BY user_id

for 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_bucket in 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.

@sancho1952007
Copy link
Copy Markdown
Contributor

Nice feature

@goldflag goldflag changed the base branch from master to returning-users November 29, 2025 04:16
@goldflag goldflag merged commit fc2c1da into rybbit-io:returning-users Nov 29, 2025
4 of 7 checks passed
@coderabbitai coderabbitai bot mentioned this pull request Dec 9, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants