Personal Analytics Dashboard
Overview
/analytics is a private, user-facing view of a person's own patterns across all goals: a Month in review summary card, Completion by category, an Activity heatmap, Streak history, and XP over time. It's a "see your own progress and act" surface — not a leaderboard, not social, no cross-user comparison, no algorithmic feed.
This is distinct from the operator-facing admin analytics dashboard at admin_dashboard/src/views/AnalyticsView.vue, which shows aggregate platform metrics to staff. The two share a route name (/analytics) only because they live in separate apps (ionic_frontend vs admin_dashboard) — there is no user-visible overlap.
Gated behind the personal_analytics_enabled feature flag, fail-closed on both frontend and backend. Shipped in v4.6 Phase 2 (Intelligence & Analytics), roadmap item 34, OBJ-1508.
The five surfaces
Month in review
The hero card, always full-width, at the top of the page.
- Headline: "{n} of {days} days" — distinct days with activity this month-to-date, out of the days elapsed in the current month. Shown honestly even with very little data (e.g. "3 of 31 days") — no guilt copy for a slow start.
- Stat row: Best streak (peak streak length achieved this month-to-date) · XP earned (this month-to-date) · Goals done (completed this month-to-date).
- Best-category callout: "{Category} led the month — {pct}% done." when a leading category exists, or "Early days — check in to fill this out." when it doesn't. Picks the top category from the same not-tracked-excluded rates
PersonalAnalytics::CalculateCompletionByCategorycomputes (see Completion by category), so an untracked goal in a category can no longer drag itspctdown and wrongly change which category wins (OBJ-1978). - CTA: "Set next month's focus →", routes to
/goals/create.
Completion by category
Horizontal bar chart (Chart.js) — one bar per goal category, valued as the average completion % of that category's goals for the current month-to-date, via the same habit → roadmap → target-amount precedence documented in goals.md — Progress Tracking (PersonalAnalytics::CalculateCompletionByCategory, rails_api/app/interactions/personal_analytics/calculate_completion_by_category.rb). A goal with no progress signal is excluded from its category's average rather than counted as 0% — a category whose goals are all not-tracked is dropped from the chart entirely, never shown at a fabricated 0% bar (OBJ-1978). Sorted descending; caps at 6 bars, folding any overflow into an "Other" bucket (its value is the overflow categories' average). Percent is printed as text at the end of each bar, not conveyed by color alone.
Activity heatmap
Calendar-grid heatmap (custom ActivityHeatmap.vue, not a Chart.js plugin — kept the bundle lean) — one cell per day, trailing 90 days, 5-step color ramp from "no activity" to "peak activity". "Activity" is habit check-ins, goal events, and mood logs — the same definition User#streak_activity_dates uses, day-bucketed with per-day counts. Each cell carries a title tooltip ("{n} actions · {Day, Mon D}"), and the grid as a whole has role="img" with a summarizing aria-label, so the counts aren't color-only for accessibility.
PersonalAnalytics::CalculateActivityHeatmap (rails_api/app/interactions/personal_analytics/calculate_activity_heatmap.rb) computes this independently rather than calling User#streak_activity_dates, but is now kept in parity with it (OBJ-1727): a soft-deleted goal's still-live HabitCompletion/GoalEvent rows are queried through goals.with_deleted and still counted, while a GoalEvent's own deleted_at — independent of its parent goal's state — is still respected. This closes a known gap where the heatmap could undercount days whose activity's parent goal had been soft-deleted, even though the streak counter and "Last 30 Days" modal (both backed by streak_activity_dates, fixed per gamification-engine.md) already handled it correctly. Deleting a goal only removes it from goal lists, not from either surface's activity history.
Streak history
Line/area chart (Chart.js, AreaChart.vue pattern) — one point per week over the trailing 26 weeks, valued as the streak length as of that week's end (today, for the current/incomplete week), via the same User#streak_length_on logic the live streak uses — so history matches the exact soft-grace rules described in CLAUDE.md's streak-grace gotcha.
XP over time
Line/area chart (Chart.js, AreaChart.vue pattern) — cumulative XP over the trailing 26 weeks, one point per week-start. There's no per-event XP ledger in the database, so XP here is a badge-unlock proxy: every badge counted (Achievements::BadgeCatalog::BADGE_KEYS) is worth 100 XP, matching the frontend's existing "1 badge = 100 XP" model used elsewhere. Same proxy powers the Month-in-review "XP earned" stat.
Empty states
Two tiers, to avoid showing a wall of five identical empty cards to a brand-new user:
- Whole-page empty (zero top-level goals): all five cards are replaced by a single hero empty state — glyph, "Your story starts with one goal.", "Add a goal and check in — your patterns show up here within a day.", and an Add a goal button routing to
/goals/create. Zero goals is the authoritative "brand-new user" signal here, since Completion by category is goal-scoped rather than period-scoped — no goals means nothing else on the page can have data either. - Per-card empty (has goals, but that specific series has no data yet): each card shows its own glyph + title + one-line forward copy + inline text CTA. The Activity heatmap, Streak history, and XP-over-time empties link to
/goalsunder the CTA text "Go to your goals" — this string was Codi's own addition; the UI-SPEC's microcopy table only specified CTA copy for the whole-page-empty and Completion-by-category empties, so this is a shipped deviation, not yet blessed by Desi (flagged by Roy's review as a MINOR follow-up, non-blocking).
Every card also has its own loading (skeleton, no spinner) and error ("Couldn't load this. Tap to retry.") state — a single failed query degrades only its own card, not the whole page.
Feature flag
Flag: personal_analytics_enabled — registered in ionic_frontend/src/lib/featureFlags.ts, synced to PostHog project 368400 (created disabled, 0% rollout).
Fail-closed on both sides:
- Frontend: the
/analyticsroute'sbeforeEnterguard (ionic_frontend/src/router/index.ts) redirects to/dashboardwhen the flag is off, matching the existingteams_leaderboards/ally-request-flow-enabledgating pattern.AnalyticsView.vuealso re-checks the flag on mount and callsrouter.replace('/dashboard')as defense-in-depth against a stale flag evaluation mid-session. - Backend: every
personalAnalytics*GraphQL query returnsnullwhen the flag is off for the current user (Resolvers::PersonalAnalyticsQueries#personal_analytics_enabled?), and every field is declarednull: trueinTypes::QueryTypeto support this. The resolvers are unreachable server-side even if a client bypassed the frontend route guard.
See Feature Flags for the full lifecycle (introduce → rollout → GA → retire).
Backend: GraphQL surface
Five owner-scoped queries, each reading only context[:current_user]'s own data — no admin override, no other-user lookup path exists on this surface. Full field-level reference: GraphQL API Reference § Queries.
| Query | Returns | Backing interaction |
|---|---|---|
personalAnalyticsMonthInReview | AnalyticsMonthInReview | PersonalAnalytics::CalculateMonthInReview |
personalAnalyticsCompletionByCategory | AnalyticsCompletionByCategory | PersonalAnalytics::CalculateCompletionByCategory |
personalAnalyticsActivityHeatmap | AnalyticsActivityHeatmap | PersonalAnalytics::CalculateActivityHeatmap |
personalAnalyticsStreakHistory | AnalyticsStreakHistory | PersonalAnalytics::CalculateStreakHistory |
personalAnalyticsXpOverTime | AnalyticsXpOverTime | PersonalAnalytics::CalculateXpOverTime |
Period windows (PersonalAnalytics::AnalyticsPeriod, no client-supplied range yet — see Known limitations):
- Month in review / Completion by category: month-to-date (
beginning_of_month..today). - Activity heatmap: trailing 90 days.
- Streak history / XP over time: trailing 26 weeks, one point per week-start, with the current/incomplete week valued as of today (not undercounted for a partial week).
N+1 safety: the activity heatmap uses grouped SQL + two .pluck calls (3 total queries, not per-day); completion-by-category preloads :goal_category and :habit_completions; User#streak_length_on reuses the memoized streak_activity_dates, so calling it up to 31 times inside CalculateMonthInReview#best_streak doesn't re-hit the database.
Frontend implementation
AnalyticsView.vue(views/) — the/analyticsroute. Fires five independentuseQuerycalls (one per surface); each card's loading/empty/error state derives from its own query, so cards fail independently.MonthInReviewCard.vue,CategoryCompletionChart.vue,ActivityHeatmap.vue,StreakHistoryChart.vue,XpOverTimeChart.vue— the five card components, each with a Storybook story (*.stories.ts) covering Default/Empty/Loading/Error in both themes.AnalyticsCard.vue— shared glass-card wrapper standardizing the eyebrow/title header plus#empty/#defaultslots and loading/error rendering across all five cards.- GraphQL queries live in
ionic_frontend/src/constants/graphql/analytics.js.
No inline hex colors — charts resolve design tokens at runtime via getComputedStyle (the resolveToken() helper in AreaChart.vue), so both themes and future token edits stay correct without a rebuild.
Known limitations
- No custom date range yet. Period windows (month-to-date, trailing 90d, trailing 26w) are fixed; a month selector for browsing prior months is a planned v-next enhancement noted in the UI-SPEC's Open Questions, not implemented in this phase.
- XP is a badge-unlock proxy, not a ledger. There's no per-event XP ledger in the database (see
Achievements::ComputeUserRankService); "XP earned" reflects badges unlocked in the period at a flat 100 XP each, which can undercount XP sources that don't unlock a badge. - Completion-by-category window edge case:
CalculateCompletionByCategory#period_ratepassesdays_elapsed(days since the start of the month) intoGoal#completion_rate, which computes its own lookback window fromDate.currentrather than from the actual month boundary. For most of the month this is equivalent; right after a long month rolls into a new one, the very start of the new month could briefly read a couple of trailing days from the wrong window. Self-corrects the next day; flagged as a cosmetic nit in code review, not fixed in this phase.
Last updated: 2026-07-24