Skip to content

Dashboard & Navigation — Feature & Testing Guide

Overview

The Dashboard is the central hub of Objectuve, designed for quick check-ins (~10 min/day). It adapts its layout based on the user's state — empty for first-timers, sparse for users with 1–2 active goals, and a populated layout for users with 3+ active goals. Navigation uses a bottom tab bar on mobile and a side menu on desktop.

v4.15 — Focus / Standard / Detailed / Auto. The populated layout (Branch D) ships as one hierarchy with two densities, plus the pre-existing card-stack view, selectable from a single 4-option Settings control or, since OBJ-1770, the dashboard's own in-context mode switcher (tap the header's mode chip):

  • Focus — the streamlined card-stack view (StreamlinedDashboard.vue), unchanged from before v4.15.
  • Standard (default for new users) — a lean primary → secondary → tertiary hierarchy in one centered column; the tertiary layer (week shape, upcoming events, AI insight) is collapsed behind a "More on your week" disclosure.
  • Detailed — the same primary/secondary content, with the tertiary layer expanded inline everywhere (a real full-width mobile home, not just a desktop-only rail). Existing full-mode users are backfilled to Detailed once; see Migration behavior below.
  • Auto — shows Focus when every habit is checked in today and no goal needs attention; otherwise shows the Standard/Detailed hierarchy (whichever density is persisted).

This ships unconditionally — the dashboard_hierarchy PostHog flag that originally gated it reached 100% rollout and was retired in Phase 5 (OBJ-1747). See Dashboard Hierarchy — Standard & Detailed (v4.15) for the full model.

The current page has been pixel-tuned against the populated-state screenshots in OBJ-64 — Dashboard Overhaul (Phase A) and the first-week empty-state screenshot in Phase B.


Page composition

The Dashboard renders one of four mutually exclusive branches based on activeGoals.length and a few derived flags. All branches live in src/views/Dashboard.vue and share the same header chrome.

BranchConditionWhat renders
LoadinguserLoading || goalsLoadingPulsing skeleton tiles + 6 LoadingSkeletonCards
Branch A — emptyactiveGoals.length === 0 (hasNoGoals)FirstWeekInvitation (Sage eyebrow + serif-italic-blue-accent headline + body + dual CTA) composing SuggestedPracticeList of three SuggestedPracticeRows — see Branch A — first-week empty state
Branch B — first goalactiveGoals.length === 1 && hasNewGoal && !setupDismissedGettingStartedCard with autoExpand: true
Branch C — sparseactiveGoals.length 1–2, not newDashboardHero (4-tile momentum strip) + CoachDockCard + CriticalPathTile + CheckInPromptCard + TodayActions + (CoachContextCard when urgentGoal) + (PartnerWidget) + GoalsSection (grid layout) + WelcomePrompt — see DashboardSparseState.vue. Out of scope for the v4.15 hierarchy (folding Branch C into it is a flagged follow-up).
Branch D — populatedactiveGoals.length >= 3The Standard/Detailed hierarchy (DashboardHierarchy.vue)

Branch A (empty) and Branch D (populated, the v4.15 hierarchy) are documented in detail below. Branch C (sparse) gets its own pixel-pass in a subsequent dashboard phase — the contract here describes its current shipped composition only at a high level.

The streamlined "focus mode" view is an orthogonal experience activated through useFocusMode and rendered by StreamlinedDashboard.vue. It replaces the entire Branch A–D tree when resolvedView === 'streamlined'. On session completion, the DoneCard displays four stat chips: habits checked in, mood logged, current streak, and XP earned during this session. The XP chip (calculated as habitsCheckedInSession × 25, sourced from subscribers.rb:57) appears conditional on xpEarned > 0, providing immediate gamification feedback to mobile focus-mode users. Documentation for that surface lives with its phase.


Branch A — first-week empty state

Replaces the prior centered "What do you want to work on?" placeholder card. Renders when activeGoals.length === 0 (hasNoGoals === true).

Visual reference: dashboard-desktop-empty.png (see OBJ-64 issue body). Canonical design contract: ionic_frontend/.planning/phases/dashboard-empty/UI-SPEC.md.

Composition

<div v-if="hasNoGoals"> in Dashboard.vue mounts a single component:

vue
<FirstWeekInvitation
  v-if="hasNoGoals"
  :user-name="user.firstName || ''"
  @create-goal="openGoalModal"
  @open-coach="openCoach"
  @quick-add="handleQuickAddPractice"
/>
ComponentSourceRole
FirstWeekInvitationsrc/components/dashboard/FirstWeekInvitation.vueHero (eyebrow + headline + body) + dual CTA, mounts SuggestedPracticeList
SuggestedPracticeListsrc/components/dashboard/SuggestedPracticeList.vueglass-card panel with OR BEGIN WITH A SMALL PRACTICE eyebrow and three rows; owns the static SUGGESTED_PRACTICES constant
SuggestedPracticeRowsrc/components/dashboard/SuggestedPracticeRow.vueSingle full-row <button>: + disc + title + subtitle + chevron-right

Component contracts

FirstWeekInvitation props + events:

ts
defineProps<{ userName?: string }>()  // default: ''

defineEmits<{
  (e: 'create-goal'): void
  (e: 'open-coach'): void
  (e: 'quick-add', payload: { title: string; subtitle: string; slug: string }): void
}>()

SuggestedPracticeList re-emits quick-add from any row click. SuggestedPracticeRow props are title, subtitle, slug and emits select on click or Enter keyup.

Event wiring in Dashboard.vue

Three handlers route the events to existing surfaces. None of these handlers were introduced in Phase B besides handleQuickAddPractice; openGoalModal and useAiCoach().open() already shipped in Phase A.

EventHandlerBehavior
create-goalopenGoalModalrouter.push('/goals/create')
open-coachopenCoach (alias of useAiCoach().open)Opens the existing AiCoachModal (mounted globally in App.vue, carries data-testid="ai-coach-modal")
quick-addhandleQuickAddPractice(payload)router.push({ path: '/goals/create', query: { title, description: subtitle, kind: 'habit', recurrence: 'daily' } })

Prefill follow-up. The quick-add route format is shipped, and OBJ-77 landed title, kind, and recurrence query-param consumption in GoalCreate.vue (alongside the pre-existing coachPersona and category handling). The description query param is still not read — clicking a suggested practice prefills the title, habit type, and daily recurrence, but the description text must still be entered manually.

Microcopy

Every user-facing string is fixed and lives in source. The body greeting templates userName with friend (lowercase) as the fallback when the prop is empty.

SurfaceString
EyebrowTHE SAGE · JUST FOR YOU (text-coach)
Headline (regular clause)A blank week is (Outfit, font-display)
Headline (accent clause)a kind invitation. (font-display italic text-primary — same vocabulary as the header greeting accent)
BodyHello, {{ userName || 'friend' }}. Let's not start with a grand plan. Let's start with one small practice you'd like to keep — and we'll grow from there.
Primary CTASet your first goal with Lucide Plus icon, bg-gradient-to-r from-primary to-secondary (brand blue gradient #177bbb → #05cbfc)
Secondary CTATalk to The Sage with Lucide MessageCircle icon, bg-card border border-border/60
Practice card eyebrowOR BEGIN WITH A SMALL PRACTICE (text-muted-foreground)
Practice 1A 10-minute morning walk · For energy that shows up later · slug morning-walk
Practice 2Two pages of writing · For the thoughts that stay otherwise · slug pages-writing
Practice 3Lights out by 10:30 · For the day after this one · slug lights-out

The Coach is referred to as The Sage (proper noun) per the in-app naming for the coach persona — never as "AI" in user-facing copy.

Layout + responsive behavior

  • Desktop (md+): flex flex-col items-start gap-5 md:gap-6 max-w-2xl mx-auto py-6 md:py-8. CTAs sit side-by-side. Headline scales text-3xl md:text-4xl lg:text-5xl.
  • Mobile (<md): Same wrapper. CTAs stack vertically full-width. Headline stays at text-3xl. Body uses text-base; desktop bumps to lg:text-lg.
  • Practice rows: stack vertically at every breakpoint inside the glass-card wrapper, dividers between (not above first or below last).

Accessibility

  • Headline is <h2 id="empty-headline"> (the page already carries the <h1> greeting in the header chrome).
  • Both CTAs are real <button type="button"> elements; tab order is primary → secondary → first practice row → second → third.
  • Each SuggestedPracticeRow is the button itself, with aria-label="Add practice: {title}". Decorative + disc, chat icon, and chevron all carry aria-hidden="true".
  • Focus rings: browser default on the CTAs; explicit focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 on the practice rows.
  • The empty branch inherits the parent <div aria-live="polite" aria-atomic="false"> wrapper from Dashboard.vue, so the surface announces on first render.
  • Scoped @media (prefers-reduced-motion: reduce) guards on FirstWeekInvitation and SuggestedPracticeRow disable hover transforms and transitions.

Tokens used

No new tokens. Reuses text-coach, text-foreground, text-primary, text-muted-foreground, bg-gradient-to-r from-primary to-secondary, bg-card, border-border/60, and glass-card — all shipped in Phase A.

Stories + tests

  • Storybook: FirstWeekInvitation.stories.ts (Default, Mobile, Dark, LongName, NoName), SuggestedPracticeList.stories.ts (Default, Mobile, Dark), SuggestedPracticeRow.stories.ts (Default, Hover, Focus, LongTitle, LongSubtitle).
  • Vitest specs: FirstWeekInvitation.spec.ts, SuggestedPracticeList.spec.ts, SuggestedPracticeRow.spec.ts, plus updated branch-A coverage in Dashboard.spec.ts.
  • Cypress: none — the original tests/e2e/specs/dashboard/empty.cy.ts spec was retired under OBJ-568 (19 stale Cypress specs rescoped to Vitest/Playwright); its assertions were backfilled into Vitest. Coverage for the eyebrow + hero render, CTA emits, and suggested-practice routing now lives in:
    • FirstWeekInvitation.spec.ts — hero render, primary CTA emits create-goal, secondary CTA emits open-coach
    • SuggestedPracticeList.spec.ts / SuggestedPracticeRow.spec.ts — row render, quick-add payload emit
    • Dashboard.spec.ts (describe('Dashboard.vue — Branch A (no goals)'), lines 285+) — integration wiring: create-goalrouter.push('/goals/create'), open-coach → opens AI coach, quick-addrouter.push('/goals/create', { query: {...} })
    • Dashboard.render.spec.ts — renders FirstWeekInvitation when there are no active goals

Shared populated-state components

The components below are used by both Branch C (sparse, DashboardSparseState.vue) and Branch D (populated, the Standard/Detailed hierarchy, DashboardHierarchy.vue) — each renders a subset in its own composition. Branch C's composition is listed at a high level in the Page composition table above; Branch D's full primary/secondary/tertiary composition is documented under Dashboard Hierarchy — Standard & Detailed (v4.15) below.

Header chrome

The <header> row sits above every branch and never re-flows.

ElementComponent / sourceBehavior
Eyebrow dateDashboard.vue eyebrowDate / eyebrowDateShort computedstext-xs font-semibold tracking-[0.16em] uppercase text-primary. Mobile (<lg) renders short form THU · APR 24, desktop (lg+) renders long form THURSDAY · APRIL 24. Two spans, one lg:hidden, one hidden lg:inline.
Greeting + name accentDashboard.vue greeting computedGood morning/afternoon/evening, followed by <span class="text-primary italic">{firstName}.</span>. Falls back to Friend when firstName is missing. The trailing period sits inside the italic-blue span.
Daily quoteuseDailyQuote() composableItalic muted text below the greeting, line-clamp-2. Hidden when showStreamlined === true. Rendered as "<text>" — <author> when dailyQuote.author is present.
Dashboard mode chipinline <button> + DashboardModeSwitcher.vue, showHierarchy onlyReads Standard/Detailed/Auto. Tapping it opens an in-context picker (OBJ-1770) in place — no more navigating to Settings; see In-context mode switcher. Only ever shown on Branch D (the hierarchy) — replaces the streak pill below whenever it renders; the momentum strip's day streak tile is the sole place the streak number renders while the hierarchy is active.
Streak pillinline <button>!hasNoGoals && !showStreamlined && !showHierarchy — i.e. shown only on Branch C (sparse), since Branch D always shows the mode chip instead. Opens StreakDetailsModal.
Focus mode pillinline <button>lg: only, conditional on !hasNoGoals && resolvedView !== 'streamlined'. Opens Focus mode via setDashboardMode('focus'). Uses locateOutline icon. Hidden on mobile and on the empty-state dashboard.
Notification bellinline <ion-button> triggering NotificationsPanelBadge <span> only renders when notificationCount > 0 — at zero the entire badge node is omitted from the DOM, not just visually hidden. Badge is visually rendered red with white border + aria-live="polite".
Theme toggleuseTheme()Round 44×44 icon button (.header-icon-btn). Swaps between sunnyOutline and moonOutline; persists via the composable's setTheme.

Per-component contracts

DashboardHero

src/components/dashboard/DashboardHero.vue — the momentum strip. Superseded and replaced the standalone MomentumBar.vue component; MomentumBar.vue is now deleted (OBJ-3016, retargeting the UI-SPEC's Concept Legibility explanation grammar onto the live tile per docs/ui-specs/v4.47-concept-affordance-grammar.md §7a), not merely unused on disk.

Two variants, chosen by the honest prop:

  • 4-tile (honest: false — Branch C sparse only): this week x/7 · completed {completedCount}/{completedCount + activeCount} · active {activeCount} · total progress {totalProgress}% (a MiniRing). The total progress tile is Math.round((completedGoals.length / goals.length) * 100)structurally 0% for habit-only users, since habit goals never flip completed. This tile is dropped entirely in the hierarchy's honest variant below rather than fixed, per the UI-SPEC's metric decision.
  • "Honest" 3-tile (honest: truehierarchy, Branch D only): this week x/7 · day streak · active goals. Drops completed and total progress entirely; the day streak tile is the only place the streak number renders while the hierarchy is active (the header's streak pill is replaced by the mode chip).

Both variants are a glass-card-style panel using MomentumStat tiles; grid is grid-cols-2 md:grid-cols-4 (4-tile) or grid-cols-3 (3-tile, "honest"). Each tile sets MomentumStat's action prop to one of three states (UI-SPEC §7a.2, replacing the old interactive boolean):

  • action="modal" — the day streak tile only, in both variants. Raised plinth badge + corner chevron, opens a modal. Emits streakClicked, which Dashboard.vue wires to the existing handleStreakPillClick() — opening StreakDetailsModal (or calling retryStreak() when the streak state is unavailable). No tooltip, no ⓘ marker, in any of the tile's three states — this restores the entry point the v4.15 hierarchy launch briefly dropped (OBJ-1918) — see Known behaviors.
  • action="explain" — every non-streak tile in both variants (this week, active/active goals, and, sparse-only, completed/total progress). Flat tint chip (visually identical to action="none" at rest) plus a 12px ⓘ hint marker at the tile's top-right corner, wrapped in a Tooltip that opens on tap and carries per-tile microcopy (e.g. total progress's copy states the habit-only 0%-forever case explicitly rather than papering over it). Drops the press transform other buttons use, since the tooltip bubble anchors to the tile itself.
  • action="none" (default, unused on this component today) — inert, non-interactive tile.

The chevron and ⓘ marker are mutually exclusive per tile by construction — one action per tile maps to at most one corner glyph. Every modal/explain button carries min-h-11 so the 44px tap-target floor is assertable by computed dimension, not just a class check.

TodayActions

src/components/dashboard/TodayActions.vue

glass-card panel labeled "Today's practice — Three small things. Pick one to begin."

  • Counter{checkedInCount} / {habits.length} DONE in muted uppercase tracking. Counter binds to habits.length directly so the denominator always matches the rendered row count (a 0 / 2 denominator can't show next to three rows).
  • Empty state — when habits.length === 0, renders a compact CTA card with "Nothing scheduled today. Take the win — or add a habit you've been putting off." and an Add a habit button that emits checkIn(null).
  • Practice rows — one row per habit. Each row is a clickable role="button" that opens the goal detail page (openGoal emit). Rows expose:
    • HabitCompletionRing (44×44, 3px stroke) on the left, filled with a green check when checkedInToday.
    • Habit name (text-base font-semibold text-foreground truncate).
    • Meta line: {Xd} · {N min} · {Category} joined with · (per UI-SPEC §6.3). Each part is conditional — streak is omitted when habitStreak <= 0, duration is omitted when durationMinutes is null or 0, category is omitted when habit.category?.name is missing. When all three are absent the meta <p> is not rendered at all (the v-else-if="metaLine(habit)" guard handles it). The eight edge cases — every combination of present/absent for the three parts — are exercised by the MetaLineEdgeCases Storybook story and TodayActions.spec.ts.
    • 44×44 round Check button on the right with three states: outline-blue idle / primary hover-border / bg-success border-success text-white when checkedInToday. Spinner replaces the check while the CHECK_IN_HABIT_MUTATION is in flight.
  • Mood prompt row — when habits.length > 0 and hasTodaysMood === false, a mood prompt row is rendered between the habit list and the footer. The row has a primary-tinted mood icon disc on the left, the label "How's today feeling?" and a chevron-right. Clicking the row or pressing Enter/Space emits openMoodCheckIn which opens MoodCheckIn.vue with no goal preselected, allowing the user to log a general daily mood. When a mood has already been logged (hasTodaysMood === true), the prompt is replaced with a confirmation row showing a success icon and "Mood logged today" in muted text. The mood prompt does NOT count toward the X / Y DONE tally.
  • AI insight footer — when an insight prop is non-null, an Insight eyebrow + plain-text body is rendered below the divider. Used as a single quiet line under the practice rows, not a major card.

Rows source from Dashboard.vue's todaysHabits computed:

ts
const todaysHabits = computed(() =>
  activeGoals.value.filter((goal: any) => goal.recurrenceType && goal.dueToday)
)

The list is fed by GOALS_QUERY (fetchPolicy: 'cache-and-network') — a Goal becomes a Habit when its recurrenceType is set and the backend marks it dueToday. There is no separate "habits" query; Habits are a view on Goals.

CheckInPromptCard

src/components/dashboard/CheckInPromptCard.vue — Branch C (sparse) only. The hierarchy (Branch D) does not render this component standalone; its AI check-in nudge is merged into TodayActions' coach-nudge line instead — see Composition: primary → secondary → tertiary.

glass-card panel for the latest unacknowledged ai_check_in notification. The component takes no props — it reads from useNotifications() directly and finds the first unacknowledged AI prompt.

Composition:

  • Coach-purple Sparkles disc (bg-coach/15 text-coach) on the left.
  • Eyebrow FROM THE SAGE · TODAY in text-coach.
  • Italic insight quote with aria-live="polite".
  • Optional View goal → button when the prompt's detailsJson.goal_public_id is set.
  • Dismiss button (top-right) acknowledges the notification on the server and hides the card locally.

The card disappears entirely when there is no unacknowledged AI check-in.

CoachContextCard

src/components/dashboard/CoachContextCard.vue — renders in the dashboard slot StaleGoalAlert.vue used to occupy (same trigger: urgentGoal, the most overdue goal, non-null), in the Coach's voice instead of a plain alert pill. StaleGoalAlert.vue is now dead code (still on disk, unused anywhere in the app).

  • Coach-purple gradient card, dismissible (X button, top-right) — hides for the session only, no server-side ack.
  • Eyebrow {personaName || 'Coach'} · noticed something.
  • Body: Your {goalName} goal hasn't moved in {daysSinceUpdate} day(s). A quick check-in keeps it from stalling.
  • Two actions: Talk it through (emits talk — opens the Coach chat pre-loaded with the stalled goal's context) and Log progress (emits log — opens the goal-update form).

Used identically across Branch C (sparse) and the hierarchy's Detailed density (Standard has no dedicated urgent-goal slot outside the merged coach-nudge line — see the tertiary tier table) — only its position in the layout changes.

WeekShapeChart

src/components/dashboard/WeekShapeChart.vue — hierarchy (Branch D) only; not used by Branch C. Renders at every breakpoint in Detailed density and inside the "More on your week" disclosure in Standard — see Tertiary & the mobile home.

7-bar mini-chart wrapped on a glass-card with role="img" and an aria label that mirrors the caption.

  • Eyebrow SHAPE OF YOUR WEEK in standard tracking-[0.16em] uppercase muted form.
  • Bars: width 16px, today is 20px, min height 8px, max height 64px. Today's bar uses a primary gradient (linear-gradient(to top, hsl(var(--primary)), hsl(var(--primary-light, var(--primary) / 0.7)))); past completed bars use bg-primary/80; past incomplete use bg-muted/40 dark:bg-muted/30; future days are bg-muted/20.
  • Day labels M T W T F S S, today's label is text-primary, the rest are muted.
  • Caption: You're {weeklyCheckIns} of 7. {strongestDay} is your strongest day — keep it light. The value is the number of distinct calendar days the user was active (0–7), not a raw event count. The denominator is always /7 (fixed) — weeklyCheckIns is plumbed from Dashboard.vue so the user-facing number matches the momentum strip's this week tile. When the chart has no data, the caption falls back to "A blank week. The first bar's the smallest hill — and it's right in front of you.".

Dashboard.vue plumbs dailyPercents, todayIndex, weeklyCheckIns, and strongestDayLabel from corresponding computeds (activityDays, weeklyCheckIns, strongestDayLabel).

UpcomingEventsList

src/components/dashboard/UpcomingEventsList.vue — hierarchy (Branch D) only; not used by Branch C. Renders at every breakpoint in Detailed density and inside the "More on your week" disclosure in Standard — see Tertiary & the mobile home.

glass-card panel listing up to 3 upcoming milestones, sorted by date.

  • Eyebrow UPCOMING.
  • Each row uses a two-line date pill (FRI / Apr 28) on the left + name + optional category on the right.
  • Empty state: italic "Nothing on the calendar yet." with an Add a milestone → link emitting addMilestone.
  • Error state: muted "Calendar didn't load — try again." + retry button emitting retry.

Events are aggregated client-side in Dashboard.vue from goals.flatMap(g => g.milestones), filtered to future dates and sliced to 3.

GoalGridCard + "Your goals" section

src/components/dashboard/GoalsSection.vue wraps GoalGridCard.vue (and, in featured layout, GoalFocusCard.vue for the hero slot).

The "Your goals" heading row shows {N} active, a + New goal button, and a link to /goalsView all → in the hierarchy's condensed treatment, See all → otherwise. Branch C passes a fixed layout="grid" (DashboardSparseState.vue); the hierarchy always renders condensed (grid-only, top 2–3 goals by urgency — see Composition: primary → secondary → tertiary). GoalsSection.vue also supports a segmented Feature / Grid toggle (seg-toggle, hidden below sm, gated on !condensed) between featured (top goal large via GoalFocusCard, md:col-span-2, next two beside it, rest in a row below) and grid (equal-weighted md:grid-cols-2 xl:grid-cols-3) — this toggle is present in the component but neither current dashboard consumer wires up an @update:layout handler, so it is not reachable from either Branch C or Branch D today. A carousel layout value also exists in GoalsSection.vue's type but is not selectable from the toggle either.

Each card is an <article data-testid="goal-card"> (fm-glass-sub fm-goal-tile), composed of a cover strip (an image or GoalCoverPlaceholder fallback, tinted by --g-tint), a title, a meta line (category + a fm-chip status pill in Title Case — On track, Needs attention, Paused, Completed), a progress row, and a footer with Last update / {daysLeft}d left and a Check in button. The card root is not role="button" — the title is a real <button class="gc-open"> with a stretched ::after overlay (WCAG 2.5.3-compliant accessible name carrying title, status, and progress); the <article> keeps a bare @click for mouse convenience and data-testid="goal-card" in every state. See Goal card view states below for why.

The progress row renders a filled bar + percentage when the goal has a progress signal (see goals.md — Progress Tracking for the habit → roadmap → target-amount precedence), or a dashed empty rail + "Not tracked" when it doesn't — never a 0% fill for a goal that has no target amount. The row's box is identical across both states so a card never reflows when its state resolves, and the accessible name (carried on the title button, not the rail) drops the percentage entirely when not tracked rather than reading "0 percent." See docs/ui-specs/honest-goal-progress.md for the full visual contract, light/dark and loading vs. not-tracked.

Card tint is driven by --g-tint: categoryTintVar(category) when a category is set, else a color-prop fallback map (blue/coral/gold/emerald, back-compat aliases for older 6-tone values) — both resolve to a design_system/css/tokens.css CSS variable, not a hardcoded Tailwind colour utility. Dashboard.vue sets color via categoryColorFor(goal.category?.title), not an index-based cycle.

Goal card view states — standard / expanded / minimized (OBJ-1945)

Every goal card in the condensed dashboard column (GoalsSection.vue's condensed branch) has a per-goal, user-controlled view statestandard, expanded, or minimized — set by a single 44×44 size-cycle button on the card. This restores the "hero" card the v4.15 hierarchy made unreachable (GoalFocusCard's expanded content set was rendered only by the featured layout, and no dashboard branch reached it once DashboardHierarchy GA'd — see Known behaviors item 9 below) and generalizes it so any goal, not just the top one, can take the room. Design contract: docs/ui-specs/goal-card-view-states.md.

All three states render from one GoalGridCard.vue instance via a view prop — never three separate components — so toggling a card never unmounts/remounts it inside GoalsSection's TransitionGroup name="goal-list" and never re-fires its enter transition. GoalGridCard's pre-existing featured boolean and prop surface are unchanged, so DashboardSparseState.vue's non-condensed usage is unaffected.

StateWhat it shows
StandardToday's card, unchanged: cover, title, meta, progress, footer with Check in.
ExpandedAdds a streak chip (when streak > 0), a milestone callout, and a 3-item recent-activity timeline. Both blocks always render — a goal with no milestones or no check-ins gets a designed empty state ("Break this into steps — open the goal to add one." / "Log your first one — takes 10 seconds.") rather than omitting the block, so an expanded card never reads as broken.
MinimizedA 56px row: status glyph (dot / alert-triangle / pause-circle, colour is never the only signal), title, progress % (or an aria-hidden when not tracked — the words live in the title button's accessible name instead), a 44×44 check-in button, the size-cycle button, and a 2px tinted progress rule on the bottom edge. No cover, category, status chip, or footer.

The size-cycle button's accessible name changes with state ("Show more of <title>" growing, "Collapse <title>" from expanded straight to minimized) and a single shared aria-live="polite" region in GoalsSection (not one per card) announces the landed state ("<title> — expanded."). Cycle order: minimized → standard → expanded → minimized.

Persistence. useGoalCardView.ts stores each goal's chosen view in localStorage, keyed per goal publicId (goalCardView:<id>) — per-device only; a toggle does not follow the user to another browser or device. There is no server-side field or migration for this preference (contrast with the dashboard mode/density, which does sync server-side). Reads and writes are wrapped in try/catch; a missing, corrupted, or invalid stored value falls back to the default below rather than erroring.

Default (no stored value yet): the first card in the condensed column — the most urgent goal, since the list is already urgency-sorted — defaults to expanded; every other card defaults to standard. Once a user toggles a card, that choice persists and overrides the default from then on.

Out of scope for this change: the carousel / grid / featured layouts, DashboardSparseState.vue, the 3-goal condensedGoals slice, and the "7 active" totalCount / View all → affordance.

FAB stack

The floating FAB pair (BrandFabStack.vue) was retired in v3.12.0. The Coach is now a permanent, scrollable surface on the dashboard (CoachDockCard); the Goal FAB was removed entirely. See docs/development/fab-stack.md for the new Coach dock, contextual card, and Trail mark surfaces that replaced it.

Branch C's responsive behavior is out of scope for this page (see Page composition); Branch D's responsive behavior is documented under Composition: primary → secondary → tertiary and Tertiary & the mobile home below.


Dashboard Hierarchy — Standard & Detailed (v4.15)

Restructures the populated dashboard (Branch D, activeGoals.length >= 3) into one layout with two densities. This ships unconditionally — see the Overview note on the retired dashboard_hierarchy flag. Branches A (empty), B (first goal), and C (sparse) are unaffected — Branch C folding into this hierarchy is a flagged follow-up, not part of this milestone. Focus mode (StreamlinedDashboard.vue) is untouched.

Rendered unconditionally by DashboardHierarchy.vue whenever showHierarchy is true — Dashboard.vue's showHierarchy computed is !showStreamlined && !hasNoGoals && !hasNewGoal && !isSparseState. There is no flag-gated fallback component; DashboardPopulatedState.vue was deleted in Phase 5 (OBJ-1747) when dashboard_hierarchy retired.

The four modes: Focus / Standard / Detailed / Auto

Selected from a single 4-option control in Settings (DashboardModeControl.vue) and resolved by useFocusMode.ts:

ModeWhat it rendersNotes
FocusThe StreamlinedDashboard card stackUnchanged from before v4.15. One card at a time.
Standard (default for new users)The hierarchy below, density: 'standard'The essentials.
DetailedThe hierarchy below, density: 'detailed'Everything, expanded. Legacy full-mode users are backfilled here once — see Migration behavior.
AutoFocus when shouldShowStreamlined() is true (every today's habit is checked in and no goal needs attention); otherwise the hierarchy at the persisted densityAdapts to your day. Settings copy: "Auto shows the Focus card stack when you're all caught up, and the Standard dashboard when there's work to do."

dashboardMode ('focus' | 'full' | 'standard' | 'detailed' | 'auto') and dashboardDensity ('standard' | 'detailed') are separate axes. 'full' is the pre-v4.15 legacy mode value, kept only so an unmigrated saved preference still resolves correctly. resolvedDensity is the explicit standard/detailed mode when picked; for focus/auto/full it falls back to the persisted dashboardDensity (defaulting standard). Both axes read through the server on load (syncFromServer) and persist via UPDATE_DASHBOARD_PREFERENCES_MUTATION on every change (Settings.vue's selectDashboardMode). DashboardModeControl.vue always shows the 4-option control — there is no flag-gated fallback.

Composition: primary → secondary → tertiary

TierStandardDetailed
PrimaryMode chip in header (no streak chip) · daily quote · DashboardHero (3 "honest" tiles: this week x/7, day streak, active goals — no total progress % tile) · TodayActions (habit rows, remaining sorted above completed, mood check-in row, merged coach-nudge line)Identical to Standard
SecondaryCriticalPathTile · CoachDockCard (single instance) · PartnerRowCompact (one-line "{name} checked in…" + Nudge, v-if="partner") · GoalsSection condensed (top 2–3 goals by urgency, compact + New goal, View all →)Identical to Standard (no PartnerRowCompact — the full PartnerWidget lives in Detailed's tertiary instead)
TertiaryCollapsed behind a "More on your week" disclosure (DashboardTertiaryDisclosure.vue, closed by default): WeekShapeChart, UpcomingEventsList, AiInsightCard (mood/goal insight)Expanded inline everywhere: CoachContextCard (v-if="urgentGoal") in the main column; WeekShapeChart + UpcomingEventsList + AiInsightCard in a lg:grid-cols-[minmax(0,1fr)_320px] right rail at lg+, stacking full-width below lg; PartnerWidget (full) below the grid, v-if="partner"
(both)SupporterStreakNudge at the very bottom — demoted out of the prime slot, frequency-capped (unchanged internal logic)Same
(both)RelocationHint rows for Community/Achieve when not yet dismissed — see Relocation hintsSame

The DashboardHero "honest" variant drops the total progress % tile rather than fixing its underlying math — that metric is completedGoals.length / goals.length, which is structurally 0% for habit-only users (habits never flip completed) and stays that way in the legacy layout and Branch C.

Tertiary & the mobile home

Detailed's tertiary layer previously only existed as a hidden lg:flex right rail with no mobile equivalent — on mobile it simply didn't render. The v4.15 hierarchy fixes this: the same tertiary components render full-width in the mobile feed instead of a lg:*-only wrapper.

ComponentStandardDetailed — mobileDetailed — desktop (lg+)
WeekShapeChartInside the "More on your week" disclosureInline in the feedRight rail
UpcomingEventsListInside the disclosureInline in the feedRight rail
AiInsightCard (mood/goal)Inside the disclosureInline in the feedRight rail
CoachContextCardHidden (Standard has no dedicated urgent-goal slot outside the merged coach-nudge line)Inline, main columnMain column
PartnerWidget (full)Hidden (PartnerRowCompact instead)Full-width below the gridFull-width below the grid

Header changes

Both densities show a mode chip (Standard/Detailed/Auto) next to the wordmark. Tapping it opens the in-context mode switcher in place — it no longer navigates to Settings (OBJ-1770). It replaces the streak pill that Branch C and the legacy Branch D layout show — the header no longer carries a streak number while the hierarchy is active; DashboardHero's day streak tile is the sole place it renders, and (OBJ-1918) the sole place it's tappable — the tile itself opens StreakDetailsModal. The Focus mode pill (lg:-only, opens Focus mode) and notification bell/theme toggle are unchanged.

In-context mode switcher (OBJ-1770)

Tapping the header's mode chip opens DashboardModeSwitcher.vue — a second entry point to the same preference as the Settings control below, not a replacement for it.

  • Surface: an anchored ion-popover (≥768px, side="bottom" alignment="end") or a draggable ion-modal bottom sheet (<768px, breakpoints: [0, 1]). Dashboard.vue toggles modeSwitcherOpen; the switcher is v-if="showHierarchy", so it only exists alongside the chip.
  • Options: all four modes — Focus / Standard / Detailed / Auto — using the same labels, icons, and descriptions as DashboardModeControl.vue in Settings, so the two entry points never drift. Each option is role="menuitemradio" with aria-checked on the current mode; roving arrow-key focus and a check icon mark the selection.
  • Apply behavior: selecting an option calls Dashboard.vue's selectDashboardMode, which applies immediately through useFocusMode's setDashboardMode/setDashboardDensity and persists via the same UPDATE_DASHBOARD_PREFERENCES_MUTATION the Settings control uses — no new GraphQL surface. On mutation failure, it reverts to the previous mode/density and shows a toast ("Couldn't save that view. Tap to retry.") with a working retry action.
  • Chip label: reads Auto, Detailed, or Standard (modeChipLabel) — never Focus, since the chip and switcher only render while showHierarchy is true, and selecting Focus enters the StreamlinedDashboard card stack, unmounting the chip/switcher along with the rest of the hierarchy.
  • Motion + a11y: popover entrance is 200ms opacity + translateY + scale, sheet entrance is a 300ms slide-up; both drop to a 160ms opacity-only fade under prefers-reduced-motion. Focus returns to the chip on dismiss (Esc, backdrop tap, or re-tapping the chip).
  • Known nit (non-blocking): selecting Focus from the switcher skips the picker's normal close animation — the chip and switcher unmount immediately (both are v-if="showHierarchy", which flips to false in the same reactive flush as the dismiss) instead of playing the leave transition. Not a functional bug — the view still switches to Focus correctly. Flagged in code review (OBJ-1770, PR #1800) as a follow-up, not yet scheduled.
  • Settings control is unchanged. DashboardModeControl.vue in Settings.vue still works exactly as before — the chip switcher and Settings write through the same useFocusMode refs and mutation, so a change made from either surface is reflected in the other.

Migration behavior (full → Detailed backfill)

A one-time, per-browser backfill (useFocusMode.ts's resolveBackfill / syncFromServer, guarded by the localStorage flag dashboard_hierarchy_migrated_v1): the first time syncFromServer runs on a browser whose raw (undefaulted) saved dashboardMode was the legacy 'full', it sets mode/density to 'detailed' and persists migratedFromFull: true via UPDATE_DASHBOARD_PREFERENCES_MUTATION. A brand-new browser with no saved mode at all is not treated as a legacy full user — it defaults to Standard. Once the migration flag is set (regardless of outcome), it never fires again for that browser; after that, the server's persisted dashboard_preferences is authoritative.

Migrated users see a one-time, dismissible DashboardMigrationNote banner in the feed ("Your dashboard got a cleanup." / "Want every section back? Switch to Detailed in Settings." / Take me there →), and an echo line in the Settings mode control ("We led with today and tucked the extras away. You were on the full layout — that's Detailed now. Switch back any time above."). Dismissal is acknowledged server-side (UserDetail#dashboard_migration_note_dismissed, via DISMISS_DASHBOARD_HINT_MUTATION), not localStorage, so it stays dismissed across devices.

Relocation hints

SocialSection (encouragements + ally feed) and ShowcasedAchievements are not rendered inline in the hierarchy at all — they've moved to the Community and Achieve pages respectively. In their place, a one-time dismissible RelocationHint row points the user to each new home ("Your circle lives in Community now." / "Find your badges in Achieve.", both with a Visit → link). Each dismissal is acknowledged server-side (UserDetail#dashboard_relocation_hint_community_dismissed / #dashboard_relocation_hint_achieve_dismissed, same DISMISS_DASHBOARD_HINT_MUTATION).

Settings control

Settings.vue renders DashboardModeControl as a role="radiogroup" of 2×2 (mobile) / 4-across (lg+) cards when the flag is on (3-across, no grid breakpoint change, when it's off). Selecting a mode calls setDashboardMode (updates local state + localStorage) and, for standard/detailed, also calls setDashboardDensity, then persists both via UPDATE_DASHBOARD_PREFERENCES_MUTATION.

GraphQL surface

graphql
type DashboardPreferences {
  mode: String!    # focus | standard | detailed | auto
  density: String! # standard | detailed
}

# On User:
dashboardPreferences: DashboardPreferences!
details: UserDetailType  # dashboardMigratedFromFull, dashboardMigrationNoteDismissed,
                         # dashboardRelocationHintCommunityDismissed, dashboardRelocationHintAchieveDismissed

mutation UpdateDashboardPreferences($mode: String, $density: String, $migratedFromFull: Boolean) {
  updateDashboardPreferences(mode: $mode, density: $density, migratedFromFull: $migratedFromFull) {
    dashboardPreferences { mode density }
    errors
  }
}

mutation DismissDashboardHint($hint: String!) {
  # hint: migration_note | relocation_community | relocation_achieve
  dismissDashboardHint(hint: $hint) { success }
}

Server-side, both preferences and the one-time surface acks live on UserDetail#data (JSONB) via store_accessordashboard_mode, dashboard_density, dashboard_migrated_from_full, dashboard_migration_note_dismissed, dashboard_relocation_hint_community_dismissed, dashboard_relocation_hint_achieve_dismissed (rails_api/app/models/user_detail.rb). User#dashboard_preferences defaults both fields to 'standard' when unset. The UserIdentity::UpdateDashboardPreferences interaction validates mode/density against fixed allow-lists and rejects anything else.


Bottom tab bar (mobile)

Fixed bottom navigation with 5 tabs (src/components/BottomTabBar.vue):

TabIconRoute
DashboardHome/dashboard
GoalsTarget/goals
CommunitiesUsers/community
BadgesTrophy/achievements
MenuMenuOpens side menu

Side menu (desktop / Menu tab)

Slide-out navigation panel:

  • User profile section (avatar, name)
  • Navigation links: Dashboard, My Goals, Communities, Achievements, Settings
  • Admin link (if user.admin === true)
  • Theme toggle
  • Help/Support links
  • Sign out

App header

src/components/ui/AppHeader.vue mounts below 768px only (md:hidden) and carries the app's role="banner" landmark there: a centered wordmark plus an <ion-menu-button> that renders empty, since the app has no <ion-menu> for it to open — the toolbar is effectively a brand bar. At 768px and up, AppHeader unmounts and the side menu's .nav-header takes over as role="banner", with its .logo-section as the sole logo/home affordance — so the rail never stacks a second wordmark above itself. The Dashboard's own page-level header (eyebrow + greeting + quote + actions) sits inside the content area, below whichever chrome is active.


Theme toggle

  • Light/dark switch via useTheme() composable.
  • Button in header (44×44 round) and in Settings.
  • State is persisted in localStorage and reflected on document.documentElement as the dark class.
  • All Phase A tokens have explicit dark-mode counterparts in tailwind.css .dark.

Modals accessible from the dashboard

ModalTrigger
BadgeModalClick a showcased badge
MoodCheckInStreak click → mood log; or TodayActions' mood prompt row
GoalEventFormCoachContextCard's Log progress button (goal-update form, scoped to the stalled goal)
StreakDetailsModalClick the header streak pill (Branch C); click the DashboardHero day streak tile while the v4.15 hierarchy is active (Branch D, OBJ-1918) — see Known behaviors below.
NotificationsPanelClick the notification bell
PushPermissionPromptFirst-time native install with permission prompt
AcceptPartnershipModalTriggered by partner_request notifications (mounted in App.vue)
AiCoachModalCoach FAB click (mounted in App.vue)

GraphQL API reference

Queries

graphql
query GoalsQuery {
  goals {
    publicId name description currentAmount targetAmount targetDateTime
    completed updatedAtTime daysToUpdate imageUrl
    recurrenceType habitStreak checkedInToday dueToday completionRate
    kind { id name }
    category { id name title }
    allEvents { publicId content createdAtTime }
    milestones { publicId name title targetDateTime completed }
    encouragements { id }
  }
}

query UserQuery($id: ID!) {
  user(id: $id) {
    firstName lastName username email emailVerified admin
    photo { imageUrl }
    streak longestStreak signInDates
    actions { id action acknowledged createdAtTime }
    notifications { id kind content detailsJson acknowledged createdAtTime }
    feedItems { content createdAtTime }
    showcasedAchievements
    isSupporter supporterTier
    coachingPreferences { configured persona }
  }
}

query AllyActivityFeed($userId: ID!, $limit: Int) {
  allyActivityFeed(userId: $userId, limit: $limit) {
    id allyId allyName allyPhoto action target timestamp
  }
}

Subscriptions

graphql
subscription NotificationUpdate($userId: ID!) {
  notificationUpdate(userId: $userId) {
    id kind content detailsJson acknowledged createdAtTime
  }
}

Mutations

  • CHECK_IN_HABIT_MUTATION — used by TodayActions per-row Check button.
  • UPDATE_GOAL_MUTATION — used by GettingStartedCard (Branch B) and goal-detail flows.
  • UPDATE_DASHBOARD_PREFERENCES_MUTATION / DISMISS_DASHBOARD_HINT_MUTATION (v4.15) — dashboard mode/density persistence and one-time surface acks; see GraphQL surface under Dashboard Hierarchy.

Verify the canonical SDL in ionic_frontend/src/constants/graphql/ before depending on any field.


Testing the feature

Manual checklist — Dashboard Hierarchy (Branch D, v4.15)

  • [ ] With ≥3 active goals, Settings shows the 4-option control (Focus/Standard/Detailed/Auto).
  • [ ] Header shows a Standard/Detailed/Auto mode chip instead of the streak pill; the streak number renders only in the DashboardHero day streak tile, and tapping that tile opens StreakDetailsModal (OBJ-1918).
  • [ ] Tapping the mode chip opens the in-context switcher in place (popover ≥768px / bottom sheet <768px) — no navigation to Settings; selecting a mode applies immediately and stays in sync with the Settings control both directions.
  • [ ] Force a mutation failure (e.g. go offline) and select a mode from the switcher — it reverts to the previous mode/density and shows "Couldn't save that view. Tap to retry."; tapping Retry re-applies successfully once back online.
  • [ ] Standard renders one centered column (max-w-2xl) with no right rail at any breakpoint; DashboardHero shows exactly 3 tiles (no total-progress tile).
  • [ ] Standard's tertiary layer ("More on your week") is collapsed by default; expanding it reveals WeekShapeChart + UpcomingEventsList + the mood/goal AiInsightCard inline.
  • [ ] Detailed renders the same primary/secondary content, plus the tertiary layer expanded inline on every breakpoint — confirm WeekShapeChart/UpcomingEventsList are visible in the mobile feed (not just hidden in a desktop-only rail).
  • [ ] GoalsSection in both densities is condensed to the top 2–3 goals by urgency, with a compact + New goal and View all → (not See all →).
  • [ ] SocialSection and ShowcasedAchievements do not render inline; a dismissible relocation hint points to Community/Achieve instead, and dismissing one is remembered after a refresh (server-side ack).
  • [ ] A user whose saved mode was legacy full sees the migration note once, and the Settings echo line, after their first load post-flag; dismissing the note is remembered across devices (or at least after clearing localStorage, since the ack is server-side).
  • [ ] A brand-new user (no saved mode) defaults to Standard, not Detailed, and never sees the migration note.
  • [ ] Switching Settings between Focus/Standard/Detailed/Auto updates the dashboard immediately and survives a refresh.
  • [ ] CoachDockCard renders exactly once (not duplicated between mobile/desktop).
  • [ ] No standalone CheckInPromptCard; its coach-nudge line renders inside TodayActions only when an unacknowledged ai_check_in notification exists.
  • [ ] Light + dark mode parity for every surface above.

Manual checklist — goal card view states (OBJ-1945)

  • [ ] On first load with no stored preference, the first card in the condensed column renders expanded; the rest render standard.
  • [ ] Tapping a card's size-cycle button cycles minimized → standard → expanded → minimized; the accessible name reads "Show more of <title>" while growing and "Collapse <title>" from expanded.
  • [ ] Toggling a card's view never navigates to goal detail, and never remounts the card (no flicker / re-fired entrance animation).
  • [ ] Reload the page after toggling a card — its view state persists; toggling one card does not change any other card's state.
  • [ ] Clear localStorage (or use a fresh browser) — the default resolution above re-applies.
  • [ ] Expanded renders both the milestone callout and the recent-activity block for every goal, including one with no milestones and no check-ins (verify the designed empty copy, not a blank block).
  • [ ] Focus order per card is title → check-in → size-cycle, in every state.
  • [ ] Light + dark mode parity for all three states.

Manual checklist — first-week empty state (Branch A)

  • [ ] Sign in as a user with zero active goals (hasNoGoals === true).
  • [ ] Eyebrow THE SAGE · JUST FOR YOU renders in text-coach.
  • [ ] Headline reads A blank week is a kind invitation. with the trailing clause in italic blue (font-display italic text-primary).
  • [ ] Body greeting renders the user's first name; falls back to Hello, friend. when firstName is empty.
  • [ ] Primary CTA Set your first goal (brand-blue gradient, white text) navigates to /goals/create.
  • [ ] Secondary CTA Talk to The Sage opens AiCoachModal (verify with [data-testid="ai-coach-modal"]).
  • [ ] Three suggested practices render in the glass-card panel below the CTAs, with dividers between rows only.
  • [ ] Clicking any practice routes to /goals/create?title=…&description=…&kind=habit&recurrence=daily; the form prefills title, kind, and recurrence (per OBJ-77) but the description param is not yet consumed.
  • [ ] At 375px: CTAs stack vertically full-width; no horizontal scroll.
  • [ ] At ≥768px: CTAs sit side-by-side; headline scales up to text-4xl lg:text-5xl.
  • [ ] Light + dark mode parity for the hero, both CTAs, and the practice card.
  • [ ] prefers-reduced-motion disables CTA hover transforms and the row chevron-shift animation.
  • [ ] Focus-mode pill is not rendered in the header (Branch A condition !hasNoGoals excludes it).

Smoke + unit suites

bash
cd ionic_frontend

npm run test:unit -- --run -t "Dashboard"
npm run test:unit -- --run -t "DashboardHero"
npm run test:unit -- --run -t "TodayActions"
npm run test:unit -- --run -t "CheckInPromptCard"
npm run test:unit -- --run -t "WeekShapeChart"
npm run test:unit -- --run -t "UpcomingEventsList"
npm run test:unit -- --run -t "GoalGridCard"
npm run test:unit -- --run -t "GoalsSection"
npm run test:unit -- --run -t "CoachDockCard"
npm run test:unit -- --run -t "CoachContextCard"
npm run test:unit -- --run -t "FirstWeekInvitation"
npm run test:unit -- --run -t "SuggestedPracticeList"
npm run test:unit -- --run -t "SuggestedPracticeRow"
npm run test:unit -- --run -t "Dashboard.vue — Branch A"

# v4.15 Dashboard Hierarchy
npm run test:unit -- --run -t "DashboardHierarchy"
npm run test:unit -- --run -t "DashboardModeControl"
npm run test:unit -- --run -t "DashboardMigrationNote"
npm run test:unit -- --run -t "DashboardTertiaryDisclosure"
npm run test:unit -- --run -t "RelocationHint"
npm run test:unit -- --run -t "PartnerRowCompact"
npm run test:unit -- --run -t "useFocusMode"

# In-context mode switcher (OBJ-1770)
npm run test:unit -- --run -t "DashboardModeSwitcher"

MomentumBar.vue and its spec/story files are deleted outright (OBJ-3016) — it isn't dead code on disk anymore, it's gone. StaleGoalAlert (still dead code, see above) keeps its own spec/story files even though it isn't reachable from Dashboard.vue's render tree.

No Cypress spec covers Branch A — the empty-state assertions live in the Vitest specs above (see testing.md for why: 19 stale Cypress specs, including dashboard/empty.cy.ts, were retired under OBJ-568 and backfilled into Vitest/Playwright).

Storybook stories cover every Phase A surface and the three Phase B components — light + dark variants, plus the populated Dashboard.stories.ts PopulatedDesktop and PopulatedMobile fixtures and the FirstWeekInvitation Default / Mobile / Dark / LongName / NoName stories. The v4.15 hierarchy adds DashboardHero.stories.ts (Default, HabitOnlyUser, Loading), DashboardTertiaryDisclosure.stories.ts (Collapsed, Expanded), DashboardModeControl.stories.ts (FocusSelected, StandardSelected, DetailedSelected, AutoSelected), DashboardMigrationNote.stories.ts (Default, Dismissed), and RelocationHint.stories.ts (Community, Achieve). The in-context mode switcher (OBJ-1770) adds DashboardModeSwitcher.stories.ts (PopoverStandard, PopoverDetailed, PopoverAuto, SheetMobile, ReducedMotion, ApplyError, Dark). The goal card view states (OBJ-1945) extend GoalGridCard.stories.ts with Standard, Expanded, ExpandedEmpty, Minimized, MinimizedLoggedToday, MinimizedNeedsAttention, MinimizedPaused, and Featured (proves the pre-existing featured-prop path still renders unchanged).

bash
cd ionic_frontend && npm run storybook

Known deferrals

These were called out and intentionally left for follow-up phases or backend work — they are not Phase A regressions.

  • WCAG AA axe verification on Phase A surfaces. Token contrast was set with WCAG AA in mind, but full axe-core sweeps and dark-mode visual verification are scheduled for the release-prep hop.
  • Cypress populated.cy.ts. End-to-end populated-state spec is deferred — populated assertions are covered by component-level Vitest specs in this phase.
  • Quick-add prefill consumption. Branch A's quick-add event routes to /goals/create with title/description/kind/recurrence query params. OBJ-77 landed consumption of title, kind, and recurrence in the goal-create form; only the description param still goes unread.
  • Focus-mode pixel pass. Phase C deliverable; this page describes the existing Focus mode entry points only at a high level.
  • Branch C folding into the v4.15 hierarchy. Flagged as a follow-up in OBJ-1719 §7 — Branch C (sparse) keeps its own composition for now.

Known behaviors & edge cases

  1. Branch boundaries are computed on every render. Adding a 3rd active goal flips the page from sparse (Branch C) to populated (Branch D) on next render — no manual refresh needed.
  2. Greeting time zones. Greeting uses the browser's local time — Good morning (<12), Good afternoon (12–17), Good evening (18–23).
  3. Setup-dismissed persistence. Branch B's "skip" writes to localStorage keyed by the goal's publicId (setup_dismissed_<publicId>), so the card stays dismissed even after a refresh.
  4. DashboardHero streak tile. Counts the user's sign-in streak (consecutive days visiting the app), not a habit streak. Habit streaks are per-goal and surface in the TodayActions meta line as {habitStreak}d.
  5. urgentGoal selection. Dashboard.vue picks the goal with the highest daysSince among goals that are either isFallingBehind or isAtRisk. If nothing qualifies, CoachContextCard does not render.
  6. Real-time notifications. Notifications arrive over the notificationUpdate subscription. That subscription has no reconnect catch-up of its own — cache-and-network alone doesn't refetch on a WebSocket reconnect, and ActionCableLink only resubscribes the channel, recovering the stream but not events pushed while it was down. The actual catch-up is a side effect of AchievementWatcher.vue's useLiveQuery reconnect refetch (OBJ-2879, see notifications.md § Real-Time Delivery): it re-runs USER_QUERY, which also selects notifications. This is incidental, not a notifications-owned mechanism — if AchievementWatcher.vue is ever removed or its query narrowed, notifications lose reconnect catch-up with it.
  7. The full→Detailed migration is per-browser, not per-account. The one-time backfill (see Migration behavior) reads localStorage, so a legacy full user signing in on a second, never-before-used browser is treated as a new user (defaults Standard) on that browser, even though their account already has a server-side preference from the first backfill. This is intentional — syncFromServer makes the server authoritative once any preference exists, so the second browser picks up the already-migrated server value instead of re-running the backfill.
  8. StreakDetailsModal's hierarchy entry point (fixed, OBJ-1918). The v4.15 hierarchy launch (Phase 1–4) hid the header streak pill whenever showHierarchy is true (replaced by the mode chip) without wiring an alternate entry point on DashboardHero's streak tile — a same-milestone gap, not an intentional removal, live for a few hours on 2026-07-30 before being caught from a user report and fixed same-day. The day streak tile is now itself a tap target: it emits streakClicked, which Dashboard.vue routes through the same handleStreakPillClick() the header pill used (streakDetailsOpen = true, or retryStreak() when the streak state is unavailable).
  9. The dashboard's expanded goal card went unreachable, then got restored and generalized (fixed, OBJ-1945). GoalFocusCard's expanded content set only ever rendered inside GoalsSection's featured layout. Once the v4.15 hierarchy GA'd and its flag retired (Phase 5, OBJ-1747), DashboardHierarchy became the unconditional Branch D render, and its condensed branch is grid-only with the Feature/Grid toggle suppressed — so GoalFocusCard became Storybook-only dead code with no wiring regression to point at. The fix didn't just restore the old hero card; it generalized the idea into the per-goal standard/expanded/minimized view state described above, so any goal (not just the first) can take the room.

Last updated: 2026-08-30 — MomentumBar.vue deleted (superseded by DashboardHero.vue/MomentumStat.vue); documented the action (none/modal/explain) tile-explanation contract (OBJ-3016).

Loading…