mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
ad350fe68e
commit
a242047765
4 files changed
+36
-10
No files matched your search
@@ -167,11 +167,7 @@ function AchievementCard({ achievement, t }: AchievementCardProps) {
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("achievements.criterionHint", {
|
||||
current: formatMetric(
|
||||
achievement.format,
|
||||
achievement.current,
|
||||
t,
|
||||
),
|
||||
current: formatMetric(achievement.format, achievement.current, t),
|
||||
target: formatMetric(achievement.format, achievement.target, t),
|
||||
})}
|
||||
</p>
|
||||
|
||||
@@ -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: <MedicationComplianceChart key="medications" />,
|
||||
});
|
||||
}
|
||||
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: <RecentAchievementsCard key="achievements" />,
|
||||
});
|
||||
}
|
||||
|
||||
charts.sort((a, b) => a.order - b.order);
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user