feat(dashboard): recent achievements card with toggle in layout settings

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 `<DashboardLayoutSection>`
machinery — added the `achievements: "achievements.title"` entry to
`WIDGET_LABEL_KEYS`. Other-agent prettier-formatting changes for
table-header `<span>` 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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-09 21:21:26 +02:00
1 parent ad350fe68e
commit a242047765
4 files changed
+36 -10

No files matched your search

@@ -33,6 +33,7 @@ const WIDGET_LABEL_KEYS: Record<DashboardWidgetId, string> = {
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 }) {
<div className="text-muted-foreground flex items-center gap-3 px-3 pb-1 text-[10px] font-medium tracking-wide uppercase">
<span className="w-5" aria-hidden="true" />
<span className="flex-1" />
<span className="w-12 text-center">{t("dashboard.layoutTileColumn")}</span>
<span className="w-12 text-center">{t("dashboard.layoutChartColumn")}</span>
<span className="w-12 text-center">
{t("dashboard.layoutTileColumn")}
</span>
<span className="w-12 text-center">
{t("dashboard.layoutChartColumn")}
</span>
</div>
{[...layout.widgets]
.sort((a, b) => a.order - b.order)
@@ -215,7 +220,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
</Button>
</div>
<span className="flex-1 text-sm">{t(labelKey)}</span>
<div className="w-12 flex justify-center">
<div className="flex w-12 justify-center">
<Switch
checked={tileChecked}
onCheckedChange={(v) => toggleTile(widget.id, v)}
@@ -224,7 +229,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
data-slot="widget-tile-switch"
/>
</div>
<div className="w-12 flex justify-center">
<div className="flex w-12 justify-center">
<Switch
checked={widget.visible}
onCheckedChange={(v) => toggle(widget.id, v)}