diff --git a/.env.example b/.env.example index 9dfbea36f..d312b42f9 100644 --- a/.env.example +++ b/.env.example @@ -142,3 +142,14 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32" # BACKUP_S3_REGION="auto" # "auto" for Cloudflare R2; real region for AWS S3 # BACKUP_RETENTION_DAYS="30" # objects older than this are pruned (best-effort; prefer a bucket lifecycle rule) # BACKUP_ENCRYPTION_KEY="" # 64-hex-char or base64 32-byte; SEPARATE from ENCRYPTION_KEY + + +# ----------------------------------------------------------------------------- +# Auto-deploy webhook (v1.4.15 — phase C2) +# ----------------------------------------------------------------------------- +# Coolify (or any CI) pings POST /api/internal/deploy-webhook after a deploy +# attempt with success/failure. We verify the request via this shared secret +# carried in the `X-Deploy-Webhook-Secret` header (timing-safe compare). +# Generate with `openssl rand -hex 32`. Configure the SAME value in Coolify's +# Notifications → Webhook custom-headers panel. See docs/audit/v1415-auto-deploy.md. +# DEPLOY_WEBHOOK_SECRET="" diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 15969ba93..9357592dc 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -103,3 +103,45 @@ jobs: cache-to: type=gha,mode=max,scope=build-${{ github.ref_name }} provenance: true sbom: true + + # Phase C2 (v1.4.15): trigger Coolify deploy automatically once GHCR has + # the new image. Removes the manual SSH + force-pull recipe documented + # in `.planning/phase-8-report.md`. + # + # Runs only on the publish path (push to `main` or to a `v*` tag), never + # on PRs. `continue-on-error: true` because the image is *already* + # published to GHCR by the time we reach this step — a Coolify-side + # outage must not retroactively fail the build. Marc still has the + # manual recipe as fallback. + # + # Required GitHub repository secrets (set by Marc once): + # COOLIFY_WEBHOOK = full deploy URL incl. ?uuid=...&force=false + # COOLIFY_TOKEN = Bearer token from Coolify UI -> Keys & Tokens + # Both secrets are referenced exclusively here. If either is unset, the + # step short-circuits to a skip rather than calling Coolify with empty + # auth. + - name: Trigger Coolify deploy + if: github.event_name != 'pull_request' + continue-on-error: true + env: + COOLIFY_WEBHOOK: ${{ secrets.COOLIFY_WEBHOOK }} + COOLIFY_TOKEN: ${{ secrets.COOLIFY_TOKEN }} + run: | + if [ -z "$COOLIFY_WEBHOOK" ] || [ -z "$COOLIFY_TOKEN" ]; then + echo "::warning::COOLIFY_WEBHOOK or COOLIFY_TOKEN secret missing — skipping auto-deploy. Configure in repo Settings → Secrets to enable." + exit 0 + fi + echo "Triggering Coolify deploy for ref ${{ github.ref_name }} (sha ${GITHUB_SHA::7})" + response=$(curl --silent --show-error --max-time 30 \ + --write-out "\n__HTTP_STATUS__:%{http_code}" \ + --request GET "$COOLIFY_WEBHOOK" \ + --header "Authorization: Bearer $COOLIFY_TOKEN") + status="${response##*__HTTP_STATUS__:}" + body="${response%__HTTP_STATUS__:*}" + echo "Coolify response (HTTP $status):" + echo "$body" + if [ "$status" -lt 200 ] || [ "$status" -ge 300 ]; then + echo "::error::Coolify deploy webhook returned HTTP $status — image is published, but auto-deploy did not fire. Marc: deploy manually via SSH recipe in .planning/phase-8-report.md." + exit 1 + fi + echo "Coolify deploy queued — awaiting outgoing notification webhook to /api/internal/deploy-webhook for final status." diff --git a/docs/audit/v1415-auto-deploy.md b/docs/audit/v1415-auto-deploy.md new file mode 100644 index 000000000..dcdc2325e --- /dev/null +++ b/docs/audit/v1415-auto-deploy.md @@ -0,0 +1,179 @@ +# v1.4.15 — Auto-deployment audit & design + +**Phase:** C2 +**Last updated:** 2026-05-09 (v1.4.15 marathon) +**Status:** designed + shipped — see commits in `.planning/phase-C2-report.md`. + +## Background + +Through v1.4.6 → v1.4.14 the recipe to deploy a freshly-published GHCR image +to production has been: + +1. Tag a release → GHCR builds and pushes the image (`docker-publish.yml`). +2. Trigger Coolify "Deploy" via the dashboard. +3. **Manually SSH** to `apps-01` and force-pull the image because Coolify's + own deploy did not always honour the new digest: + + ```bash + ssh apps-01 'docker pull ghcr.io/mbombeck/healthlog:1.4.X && \ + docker tag ghcr.io/mbombeck/healthlog:1.4.X ghcr.io/mbombeck/healthlog:latest && \ + cd /data/coolify/applications/pg8wggwogo8c4gc4ks0kk4ss && \ + docker compose up -d app' + ``` + +4. Verify `/api/version` returned the new version + image digest changed. + +Marc's request for v1.4.15 — **eliminate step 2 and 3**. When GHCR has a new +image, the production container should redeploy by itself. + +## Investigation — what Coolify supports + +Coolify version on `apps-01.bombeck.io`: **`4.0.0-beta.470`** (verified via +`mcp__coolify-apps01__get_version`). + +The current application config (UUID `pg8wggwogo8c4gc4ks0kk4ss`, +`/api/v1/applications/pg8wggwogo8c4gc4ks0kk4ss`) shows: + +- `build_pack: dockercompose` +- `git_repository: MBombeck/HealthLog`, `git_branch: main` +- `source_type: App\Models\GithubApp` — this matters: Coolify's "auto-deploy + on git push" is wired to the GitHub App webhook, **not** to the GHCR + package webhook. There is no native "redeploy when registry has new image" + mode for `dockercompose`-typed apps. +- `manual_webhook_secret_github: null` — no webhook secret configured today. + +What Coolify *does* support natively (per the Coolify docs): + +1. **GitHub App auto-deploy**: pushes to `main` trigger an immediate redeploy. + For us this would deploy from a SHA *before* GHCR finishes building, since + GHCR runs in the same push event in parallel. Race-prone. +2. **Deploy webhook URL**: every Coolify resource exposes + `GET https:///api/v1/deploy?uuid=` with + Bearer-token auth. This is a *trigger-only* surface — it queues a + redeploy. Coolify treats this as an authenticated remote-control command; + the recipe in the docs is "GitHub Actions builds the image, pushes to + GHCR, then `curl --request GET '${{ secrets.COOLIFY_WEBHOOK }}' + --header 'Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}'`." +3. **Notification webhook (outgoing)**: Coolify can POST a deployment-event + payload to a configured URL on success and failure. This is how we get a + notification back into the app for audit logging + admin alerts. + +`(2)` solves the "trigger" half; `(3)` solves the "report" half. We use both. + +## Decision — chosen approach + +**Path A (chosen):** GitHub Actions → Coolify deploy webhook → Coolify +notifications → HealthLog `POST /api/internal/deploy-webhook`. + +Rationale: + +- Native to Coolify; no extra daemon on `apps-01`. +- Both halves of the auto-deploy contract (trigger + status) live in + configuration we already control. +- Watchtower would mean shipping a new container, an extra footprint to + audit, and (since v1.4.14 has multi-tenant prep with worker-mode + separation) a polling daemon that might wake the app off-pattern. The + Coolify webhook is event-driven and free. + +**Path B (rejected — but kept on the table for v1.4.16 if Coolify webhook +proves unreliable):** Watchtower with a per-image label +(`com.centurylinklabs.watchtower.enable=true`) gated on `ghcr.io/mbombeck/healthlog`. + +## Implementation plan (3 atomic commits) + +1. **`feat(deploy): auto-deploy on GHCR push (no more manual force-pull)`** + - `.github/workflows/docker-publish.yml`: after the `Build & push image` + step, add a final `Trigger Coolify deploy` step that runs only on the + publish path (push to `main` or to a `v*` tag — never on PRs). It uses + `secrets.COOLIFY_WEBHOOK` (the full Bearer-protected URL with `?uuid=…`) + + `secrets.COOLIFY_TOKEN`. Failure of the deploy trigger does **not** + fail the build (the image is already published; deploy is a follow-up). + - Document required secrets in `.env.example`. + +2. **`feat(deploy): admin notification + audit log on deploy success/failure`** + - New endpoint: `src/app/api/internal/deploy-webhook/route.ts` that + accepts a Coolify-format JSON payload and: + - validates `X-Deploy-Webhook-Secret` header (timing-safe) against + `process.env.DEPLOY_WEBHOOK_SECRET`; + - rate-limits by client IP; + - writes an `auditLog` row with `system.deploy.{success,failure}`; + - on failure, calls `dispatchNotification({ eventType: "SYSTEM_ALERT" })` + for every admin user (so Telegram is paged if configured) — same + pattern as `src/lib/integrations/status.ts:maybeAlertAdmins`. + - Webhook handler is a Next.js Route Handler — no new deps. + - Unit-tested with the same mock pattern as + `src/app/api/withings/webhook/__tests__/route.test.ts`. + +3. **Document everything** in this file + `phase-C2-report.md`. + +## Marc-side configuration after merge + +Since the agent does not have access to Marc's GitHub repo settings or +Coolify's UI directly, three manual steps remain after the auto-deploy code +ships: + +1. **Generate a Coolify API token** in Coolify UI → `Keys & Tokens` → + "Create New Token" with permission `*` (or `read:sensitive` plus deploy). + Token format: `n_`. +2. **Add two GitHub repository secrets**: + - `COOLIFY_WEBHOOK` = + `https://apps-01.bombeck.io/api/v1/deploy?uuid=pg8wggwogo8c4gc4ks0kk4ss&force=false` + - `COOLIFY_TOKEN` = the token from step 1. +3. **Configure Coolify outgoing notifications**: + - Settings → Notifications → Webhook + - URL: `https://healthlog.bombeck.io/api/internal/deploy-webhook` + - Add custom header: `X-Deploy-Webhook-Secret: ` + (the value placed into `apps-01:.env` for the HealthLog container) + - Enable: deployment success + deployment failure +4. **Set `DEPLOY_WEBHOOK_SECRET` env var** on `apps-01` (HealthLog + container `.env` + Coolify environment variables UI). Generate with + `openssl rand -hex 32`. + +These four manual steps are tracked in `.planning/phase-C2-report.md`'s +"Marc-side follow-up" section. The code is fully complete and tested without +them; auto-deploy simply doesn't fire until the secrets are populated. + +## Failure modes considered + +| Failure | What happens | +| --------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| Coolify deploy URL down | GH Actions step continues (`continue-on-error: true`); image stays on GHCR, Marc deploys manually. | +| Coolify notification fires before image pull | Coolify retries the pull internally; final status (success/failure) reaches our webhook regardless. | +| Webhook secret leaked | Rotate `DEPLOY_WEBHOOK_SECRET`, redeploy. Audit-log entry on every received call shows source IP. | +| Webhook handler down at deploy time | Coolify retries 3× per docs; final missed event is recoverable from Coolify's own deployment log. | +| Coolify API rejects token (permission scope) | GH Actions step prints the response body; agent's next merge surfaces it as a workflow failure. | +| GHCR push succeeds but webhook step is broken | Image still gets to GHCR; manual deploy still works as a fallback. | + +## Watchtower — why not (revisited) + +If Path A breaks (Coolify webhook flaky, beta channel regression, …), +v1.4.16 can install Watchtower: + +```yaml +# /data/coolify/applications/pg8wggwogo8c4gc4ks0kk4ss/docker-compose.yaml +# (additional services block, NOT in the main compose) +services: + watchtower: + image: containrrr/watchtower:1.7.1 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + command: + - --interval=300 + - --label-enable + restart: unless-stopped +``` + +with the existing app service labelled +`com.centurylinklabs.watchtower.enable=true`. We deliberately do not ship +this in v1.4.15 — adding a polling daemon to the production host is a +material expansion of the trusted compute base and the Coolify path is +good enough. + +## References + +- Coolify GitHub Actions guide: + +- Coolify Authorization API docs: + +- v1.4.14 manual force-pull recipe: `.planning/phase-8-report.md` +- C3 docker-publish reliability fix: `docs/audit/v1415-ci-reliability.md` diff --git a/e2e/achievements.spec.ts b/e2e/achievements.spec.ts new file mode 100644 index 000000000..219ec7cb6 --- /dev/null +++ b/e2e/achievements.spec.ts @@ -0,0 +1,74 @@ +import { expect, test } from "@playwright/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; + +/** + * v1.4.15 phase-B4 — /achievements navigation smoke. + * + * The page itself has unit-test coverage for the locked-vs-unlocked + * card render; this spec answers the higher-level "can the user reach + * the page from the global nav?" question. We mock + * `/api/gamification/achievements` so the assertion does not depend on + * the seed user actually having any achievement data — the routing + + * sidebar wiring is what matters here, not the data freshness. + */ +test.describe("achievements page is reachable from the sidebar", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test.beforeEach(async ({ page }) => { + await page.route("**/api/gamification/achievements", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { + summary: { + unlockedCount: 0, + totalCount: 38, + earnedPoints: 0, + totalPoints: 9999, + completionPercent: 0, + nextAchievement: null, + }, + achievements: [ + { + id: "intake-total-1", + metric: "totalTakenIntakes", + category: "medication", + titleKey: "achievements.badges.intakeTotal1.title", + descriptionKey: "achievements.badges.intakeTotal1.description", + icon: "Pill", + format: "count", + target: 1, + current: 0, + points: 8, + unlocked: false, + progressPercent: 0, + completedAt: null, + }, + ], + metrics: {}, + }, + error: null, + }), + }), + ); + }); + + test("desktop sidebar exposes the achievements link", async ({ page }) => { + await page.goto("/", { waitUntil: "domcontentloaded" }); + // The sidebar nav is hidden on small viewports (`md:flex`); the + // chromium-desktop project uses 1280×720 so it's visible here. Match + // by aria-label so we don't accidentally pick up a tooltip. + const sidebar = page.locator('aside[aria-label="Sidebar"]'); + await expect(sidebar).toBeVisible(); + await sidebar.getByRole("link", { name: "Achievements" }).click(); + await expect(page).toHaveURL(/\/achievements\b/); + // Page title from the loaded route. The unit test guards the + // grouped + locked-card render shape; here we only assert the route + // mounted and rendered the heading. + await expect( + page.getByRole("heading", { name: "Achievements" }), + ).toBeVisible({ timeout: 5000 }); + }); +}); diff --git a/messages/de.json b/messages/de.json index 91e6def45..7065db535 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1779,7 +1779,21 @@ } }, "nextProgressLabel": "Fortschritt bis Freischaltung", - "completedOn": "Abgeschlossen am {date}" + "completedOn": "Abgeschlossen am {date}", + "locked": "Gesperrt", + "criterionHint": "{current} / {target}", + "progressPercent": "{percent}%", + "categories": { + "medication": "Medikation", + "vitals": "Vitalwerte", + "security": "Konto & Sicherheit", + "engagement": "Engagement" + }, + "dashboardCard": { + "title": "Zuletzt freigeschaltet", + "viewAll": "Alle anzeigen", + "empty": "Noch keine Erfolge — trage weiter ein, um deinen ersten freizuschalten." + } }, "bugreport": { "title": "Bugreport", diff --git a/messages/en.json b/messages/en.json index 9e218c427..6a2fcced2 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1779,7 +1779,21 @@ } }, "nextProgressLabel": "Progress to unlock", - "completedOn": "Completed on {date}" + "completedOn": "Completed on {date}", + "locked": "Locked", + "criterionHint": "{current} / {target}", + "progressPercent": "{percent}%", + "categories": { + "medication": "Medication", + "vitals": "Vitals", + "security": "Account & security", + "engagement": "Engagement" + }, + "dashboardCard": { + "title": "Recent unlocks", + "viewAll": "View all", + "empty": "No achievements yet — keep logging to unlock your first." + } }, "bugreport": { "title": "Bug Report", diff --git a/src/app/achievements/__tests__/page.test.tsx b/src/app/achievements/__tests__/page.test.tsx new file mode 100644 index 000000000..3d67bf9eb --- /dev/null +++ b/src/app/achievements/__tests__/page.test.tsx @@ -0,0 +1,198 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { AchievementProgress } from "@/lib/gamification/achievements"; + +/** + * v1.4.15 phase-B4 — /achievements page contract. + * + * Two surfaces under test: + * + * 1. `groupByCategory` — pure helper, no provider needed. Locked items + * go below unlocked ones, and inside locked-items the closest-to- + * unlock sorts first so the user always sees their immediate next + * goal at the top of each category. + * 2. The page render itself — locked entries paint the muted card with + * a "Locked" badge + criterion hint + progress bar, unlocked ones + * paint the highlighted variant with the "Completed on …" footer. + * Both share ``; the regression we're guarding + * against is "page silently dropped locked entries" which is what + * shipped pre-v1.4.15. + * + * The page uses TanStack Query under the hood — we mock it via a small + * `useQuery` shim so the SSR test renders the loaded state directly, + * matching the `onboarding` page's existing test pattern. + */ + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + isAuthenticated: true, + isLoading: false, + user: { id: "u1", username: "test", role: "USER" }, + }), +})); + +const mockData = { + summary: { + unlockedCount: 1, + totalCount: 3, + earnedPoints: 8, + totalPoints: 100, + completionPercent: 33, + nextAchievement: null, + }, + achievements: [ + { + id: "intake-total-1", + metric: "totalTakenIntakes", + category: "medication", + titleKey: "achievements.badges.intakeTotal1.title", + descriptionKey: "achievements.badges.intakeTotal1.description", + icon: "Pill", + format: "count", + target: 1, + current: 1, + points: 8, + unlocked: true, + progressPercent: 100, + completedAt: "2026-04-15T12:00:00.000Z", + }, + { + id: "intake-total-10", + metric: "totalTakenIntakes", + category: "medication", + titleKey: "achievements.badges.intakeTotal10.title", + descriptionKey: "achievements.badges.intakeTotal10.description", + icon: "Pill", + format: "count", + target: 10, + current: 4, + points: 24, + unlocked: false, + progressPercent: 40, + completedAt: null, + }, + { + id: "passkey-created-1", + metric: "passkeyCreatedCount", + category: "security", + titleKey: "achievements.badges.passkeyCreated1.title", + descriptionKey: "achievements.badges.passkeyCreated1.description", + icon: "KeyRound", + format: "count", + target: 1, + current: 0, + points: 40, + unlocked: false, + progressPercent: 0, + completedAt: null, + }, + ] satisfies AchievementProgress[], + metrics: { + totalTakenIntakes: 1, + overIntakeCount: 0, + skippedIntakeCount: 0, + bmiGreenStreak: 0, + bpGreenStreak: 0, + pulseGreenStreak: 0, + onTimePerfectDayStreak: 0, + compliance80DayStreak: 0, + passkeyCreatedCount: 0, + passkeyLoginCount: 0, + passwordLoginCount: 0, + loginDayStreak: 0, + bugReportCount: 0, + }, +}; + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: mockData, isLoading: false }), +})); + +import AchievementsPage, { groupByCategory } from "../page"; + +function render(locale: "en" | "de" = "en") { + return renderToStaticMarkup( + + + , + ); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("groupByCategory", () => { + it("buckets achievements by category and orders unlocked first", () => { + const grouped = groupByCategory(mockData.achievements); + expect(grouped.map((g) => g.category)).toEqual(["medication", "security"]); + + const medication = grouped.find((g) => g.category === "medication"); + expect(medication?.items.map((i) => i.id)).toEqual([ + "intake-total-1", + "intake-total-10", + ]); + }); + + it("inside the locked subset, sorts closest-to-unlock first", () => { + const grouped = groupByCategory([ + { + ...mockData.achievements[1], // medication, 40% + }, + { + ...mockData.achievements[2], // security, 0% — different category + }, + { + ...mockData.achievements[1], + id: "intake-total-50", + progressPercent: 5, + }, + ]); + const medication = grouped.find((g) => g.category === "medication"); + expect(medication?.items.map((i) => i.progressPercent)).toEqual([40, 5]); + }); +}); + +describe("", () => { + it("renders an unlocked card with the completed-on footer", () => { + const html = render(); + expect(html).toContain("First intake"); + // Unlocked badge text + expect(html).toContain("Completed on"); + // Unlocked styling marker — the unlocked variant uses the primary + // gradient background; the locked variant uses opacity-70. + expect(html).toContain("achievement-card-unlocked"); + }); + + it("renders locked entries grayed out with a criterion hint and progress", () => { + const html = render(); + // Locked entry must still appear — pre-v1.4.15 the page silently + // dropped locked entries, which is what this guard prevents. + expect(html).toContain("achievement-card-locked"); + expect(html).toContain("Intake starter"); // intake-total-10 title + // "Locked" badge label + expect(html).toContain("Locked"); + // Criterion hint "{current} / {target}" — for intake-total-10 the + // mock has current=4 / target=10. Using the formatted version + // (English uses the bare number) so the assertion is precise. + expect(html).toContain("4 / 10"); + }); + + it("groups by category with localized headings and counts", () => { + const html = render(); + expect(html).toContain("Medication"); + expect(html).toContain("Account & security"); + // Category counts: medication 1/2, security 0/1 + expect(html).toContain("1 / 2"); + expect(html).toContain("0 / 1"); + }); + + it("renders the German category headings when locale is de", () => { + const html = render("de"); + expect(html).toContain("Medikation"); + expect(html).toContain("Konto & Sicherheit"); + expect(html).toContain("Gesperrt"); // German "Locked" + }); +}); diff --git a/src/app/achievements/page.tsx b/src/app/achievements/page.tsx index dcb6926f5..17f4bf79a 100644 --- a/src/app/achievements/page.tsx +++ b/src/app/achievements/page.tsx @@ -11,6 +11,7 @@ import { Heart, KeyRound, Loader2, + Lock, LogIn, Pill, ShieldCheck, @@ -25,10 +26,12 @@ import { Badge } from "@/components/ui/badge"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { formatDate } from "@/lib/format"; -import type { - AchievementMetrics, - AchievementProgress, - AchievementSummary, +import { + ACHIEVEMENT_CATEGORY_ORDER, + type AchievementCategory, + type AchievementMetrics, + type AchievementProgress, + type AchievementSummary, } from "@/lib/gamification/achievements"; interface AchievementsData { @@ -54,6 +57,13 @@ const iconMap: Record = { SkipForward, }; +const CATEGORY_LABEL_KEY: Record = { + medication: "achievements.categories.medication", + vitals: "achievements.categories.vitals", + security: "achievements.categories.security", + engagement: "achievements.categories.engagement", +}; + function formatMetric( format: AchievementProgress["format"], value: number, @@ -65,6 +75,136 @@ function formatMetric( return t("achievements.metricCount", { count: value }); } +/** + * Pure helper: bucket the achievements by their declared category in + * ACHIEVEMENT_CATEGORY_ORDER's order. Categories with no badges (a + * theoretical future state) are dropped from the output. Inside a + * category, unlocked badges sort to the top so the user's progress is + * always visible without scrolling. + */ +export function groupByCategory( + achievements: AchievementProgress[], +): Array<{ category: AchievementCategory; items: AchievementProgress[] }> { + const buckets = new Map(); + for (const item of achievements) { + const list = buckets.get(item.category) ?? []; + list.push(item); + buckets.set(item.category, list); + } + for (const list of buckets.values()) { + list.sort((a, b) => { + if (a.unlocked !== b.unlocked) return a.unlocked ? -1 : 1; + // For locked items show the closest-to-unlock first; for unlocked + // items keep them in insertion (definition) order — completedAt is + // not always populated and would push newer unlocks below older + // ones in a way that hides recent progress. + if (a.unlocked && b.unlocked) return 0; + return b.progressPercent - a.progressPercent; + }); + } + return ACHIEVEMENT_CATEGORY_ORDER.filter((category) => + buckets.has(category), + ).map((category) => ({ + category, + items: buckets.get(category) ?? [], + })); +} + +interface AchievementCardProps { + achievement: AchievementProgress; + t: ReturnType["t"]; +} + +function AchievementCard({ achievement, t }: AchievementCardProps) { + const Icon = iconMap[achievement.icon] ?? Star; + const unlocked = achievement.unlocked; + + return ( +
+
+
+
+ +
+
+

{t(achievement.titleKey)}

+

+ {t(achievement.descriptionKey)} +

+
+
+ + {unlocked ? ( + t("achievements.completed") + ) : ( + <> + +
+ + {!unlocked && ( +
+

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

+ +
+ )} + +
+ + {t("achievements.pointsValue", { + points: achievement.points, + })} + + {unlocked ? ( + + {achievement.completedAt + ? t("achievements.completedOn", { + date: formatDate(achievement.completedAt), + }) + : t("achievements.goalReached")} + + ) : ( + + {t("achievements.progressPercent", { + percent: achievement.progressPercent, + })} + + )} +
+
+ ); +} + export default function AchievementsPage() { const { isAuthenticated, isLoading: authLoading } = useAuth(); const { t } = useTranslations(); @@ -103,7 +243,7 @@ export default function AchievementsPage() { const summary = data?.summary; const achievements = data?.achievements ?? []; - const unlockedAchievements = achievements.filter((item) => item.unlocked); + const grouped = groupByCategory(achievements); const remainingUnlocks = Math.max( 0, (summary?.totalCount ?? 0) - (summary?.unlockedCount ?? 0), @@ -199,59 +339,49 @@ export default function AchievementsPage() { -
- {unlockedAchievements.map((achievement) => { - const Icon = iconMap[achievement.icon] ?? Star; - - return ( -
-
-
-
- -
-
-

- {t(achievement.titleKey)} -

-

- {t(achievement.descriptionKey)} -

-
-
- - {t("achievements.completed")} - -
- -
- - {t("achievements.pointsValue", { - points: achievement.points, - })} - - - {achievement.completedAt - ? t("achievements.completedOn", { - date: formatDate(achievement.completedAt), - }) - : t("achievements.goalReached")} - -
-
- ); - })} -
- - {unlockedAchievements.length === 0 && ( + {grouped.length === 0 ? (

{t("achievements.noneUnlockedYet")}

+ ) : ( +
+ {grouped.map(({ category, items }) => { + const unlockedInCategory = items.filter( + (item) => item.unlocked, + ).length; + return ( +
+
+

+ {t(CATEGORY_LABEL_KEY[category])} +

+ + {unlockedInCategory} / {items.length} + +
+
+ {items.map((achievement) => ( + + ))} +
+
+ ); + })} +
)} ); diff --git a/src/lib/gamification/achievements.ts b/src/lib/gamification/achievements.ts index d595b7293..f2924034c 100644 --- a/src/lib/gamification/achievements.ts +++ b/src/lib/gamification/achievements.ts @@ -17,6 +17,19 @@ export type AchievementMetricKey = | "loginDayStreak" | "bugReportCount"; +/** + * Achievement categories — used by the /achievements UI to visually group + * the badges. Pure presentation: the computation logic in this file does + * not branch on category, and the metric → category mapping is derived in + * `getAchievementCategory` below so a metric is always in exactly one + * group. + */ +export type AchievementCategory = + | "medication" + | "vitals" + | "security" + | "engagement"; + export interface AchievementDefinition { id: string; metric: AchievementMetricKey; @@ -28,6 +41,47 @@ export interface AchievementDefinition { format: "count" | "days" | "percent"; } +/** + * Stable metric → category mapping. Add a metric here when it joins the + * `AchievementMetricKey` union; the page falls back to "engagement" if + * a future definition slips through, so there is no silent drop-off. + */ +export function getAchievementCategory( + metric: AchievementMetricKey, +): AchievementCategory { + switch (metric) { + case "totalTakenIntakes": + case "overIntakeCount": + case "skippedIntakeCount": + case "onTimePerfectDayStreak": + case "compliance80DayStreak": + return "medication"; + case "bmiGreenStreak": + case "bpGreenStreak": + case "pulseGreenStreak": + return "vitals"; + case "passkeyCreatedCount": + case "passkeyLoginCount": + case "passwordLoginCount": + return "security"; + case "loginDayStreak": + case "bugReportCount": + return "engagement"; + } +} + +/** + * Stable category render order on the /achievements page. Medication is + * the densest category (16 / 38 today) so it goes first; engagement is + * the smallest, last. + */ +export const ACHIEVEMENT_CATEGORY_ORDER: readonly AchievementCategory[] = [ + "medication", + "vitals", + "security", + "engagement", +] as const; + export interface AchievementMetrics { totalTakenIntakes: number; overIntakeCount: number; @@ -47,6 +101,7 @@ export interface AchievementMetrics { export interface AchievementProgress { id: string; metric: AchievementMetricKey; + category: AchievementCategory; titleKey: string; descriptionKey: string; icon: string; @@ -335,6 +390,7 @@ export function evaluateAchievementsWithCompletionDates( return { id: definition.id, metric: definition.metric, + category: getAchievementCategory(definition.metric), titleKey: definition.tTitle, descriptionKey: definition.tDescription, icon: definition.icon,