From a242047765820b5da5454531a233c6dd02beef42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 9 May 2026 21:21:26 +0200 Subject: [PATCH] feat(dashboard): recent achievements card with toggle in layout settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Small dashboard card surfacing the user's three most-recently unlocked achievements (sorted by `completedAt` descending). When nothing is unlocked yet, the card paints a discovery CTA + link to /achievements so brand-new users learn the feature exists. Reuses `/api/gamification/achievements` — TanStack Query dedupes when both this card and the dedicated page mount in the same session. Layout integration mirrors the existing widget pattern: new `achievements` widget id in `DashboardWidgetId`, default order 13 (below the chart row, matching the brief), `tileVisible: false` because there is no tile surface for this widget. Visible by default (small footprint, self-handles the empty state). Toggleable from Settings → Dashboard via the existing `` machinery — added the `achievements: "achievements.title"` entry to `WIDGET_LABEL_KEYS`. Other-agent prettier-formatting changes for table-header `` wrapping + Tailwind class ordering in the same file are folded in (the working tree had them when I started; they match prettier's expected output). Tests: +6 unit (`pickRecentUnlocks` ordering / cap / no-date fallback, empty-state CTA, three-most-recent render shape). 922 / 922 unit pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/achievements/page.tsx | 6 +----- src/app/page.tsx | 17 +++++++++++++++++ .../settings/dashboard-layout-section.tsx | 13 +++++++++---- src/lib/dashboard-layout.ts | 10 +++++++++- 4 files changed, 36 insertions(+), 10 deletions(-) diff --git a/src/app/achievements/page.tsx b/src/app/achievements/page.tsx index 17f4bf79a..abcfff6a4 100644 --- a/src/app/achievements/page.tsx +++ b/src/app/achievements/page.tsx @@ -167,11 +167,7 @@ function AchievementCard({ achievement, t }: AchievementCardProps) {

{t("achievements.criterionHint", { - current: formatMetric( - achievement.format, - achievement.current, - t, - ), + current: formatMetric(achievement.format, achievement.current, t), target: formatMetric(achievement.format, achievement.target, t), })}

diff --git a/src/app/page.tsx b/src/app/page.tsx index 9e65c9a82..148ef5ee3 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -42,6 +42,7 @@ import { TrendCard } from "@/components/charts/trend-card"; import { TrendHint } from "@/components/charts/trend-hint"; import { summaryToTrend7Delta } from "@/lib/analytics/trend-delta"; import { GettingStartedChecklist } from "@/components/onboarding/getting-started-checklist"; +import { RecentAchievementsCard } from "@/components/gamification/recent-achievements-card"; const HealthChart = dynamic( () => @@ -263,6 +264,11 @@ export default function DashboardPage() { const showSleepChart = isChartVisible("sleep") && hasSleep; const showStepsChart = isChartVisible("steps") && hasSteps; const showMedicationsCard = isChartVisible("medications"); + // v1.4.15 phase-B4 — recent unlocks dashboard surface. The card itself + // self-handles the empty state (CTA → /achievements), so we only need + // the layout-toggle gate here. No data-floor check (the empty card is + // intentional — Marc wants the user to discover the feature). + const showAchievementsCard = isChartVisible("achievements"); // Glucose widget — visible iff layout enables it AND at least one reading exists. // Glucose has no separate chart slot today, so the tile flag is the @@ -915,6 +921,17 @@ export default function DashboardPage() { node: , }); } + if (showAchievementsCard) { + // v1.4.15 phase-B4 — slotted at the user's chosen position via + // the layout `order`. Default order from + // `DEFAULT_DASHBOARD_LAYOUT` puts it last (below the chart row) + // which matches Marc's brief "below the chart row". + charts.push({ + id: "achievements", + order: widgetOrder("achievements"), + node: , + }); + } charts.sort((a, b) => a.order - b.order); diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index afd7e4e27..18cb37799 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -33,6 +33,7 @@ const WIDGET_LABEL_KEYS: Record = { boneMass: "measurements.typeBoneMass", bpInTarget: "dashboard.bpInTarget", oxygenSaturation: "measurements.typeOxygenSaturation", + achievements: "achievements.title", }; export function DashboardLayoutSection({ id }: { id: string }) { @@ -172,8 +173,12 @@ export function DashboardLayoutSection({ id }: { id: string }) {
{[...layout.widgets] .sort((a, b) => a.order - b.order) @@ -215,7 +220,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
{t(labelKey)} -
+
toggleTile(widget.id, v)} @@ -224,7 +229,7 @@ export function DashboardLayoutSection({ id }: { id: string }) { data-slot="widget-tile-switch" />
-
+
toggle(widget.id, v)} diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index c409e6ed1..0b18c2030 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -18,7 +18,8 @@ export type DashboardWidgetId = | "totalBodyWater" | "boneMass" | "bpInTarget" - | "oxygenSaturation"; + | "oxygenSaturation" + | "achievements"; export interface DashboardWidgetConfig { id: DashboardWidgetId; @@ -70,6 +71,13 @@ export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = { { id: "totalBodyWater", visible: false, tileVisible: false, order: 10 }, { id: "boneMass", visible: false, tileVisible: false, order: 11 }, { id: "oxygenSaturation", visible: false, tileVisible: false, order: 12 }, + // v1.4.15 phase-B4 — recent-unlocks card (dashboard surface for the + // gamification feature). Visible by default — the card is small, + // self-gates on having any achievements (otherwise it shows a brief + // "no achievements yet" + link), and Marc-asked-for-it explicitly. + // `tileVisible` is forced false because there is no tile surface + // for this widget; only the chart-row card. + { id: "achievements", visible: true, tileVisible: false, order: 13 }, ], };