mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
feat(achievements): dedicated /achievements page with locked/unlocked list
Pre-v1.4.15 the page silently dropped locked achievements, so the user
only saw what they had already earned. Now every badge renders — locked
ones are grayed out with a "Locked" badge, criterion hint
({current} / {target}), and a progress bar. Unlocked entries keep the
existing primary-gradient card with the "Completed on …" footer.
Achievements are grouped by a new `category` axis (medication, vitals,
security, engagement) — derived in `getAchievementCategory()` so a
metric is always in exactly one bucket. Inside a category, unlocked
items sort first; among locked items the closest-to-unlock one ranks
first so the user always sees their immediate next goal at the top.
The sidebar link to /achievements was already in place under the
user-area group; this commit only adds the new locked/grouped render
plus an e2e smoke spec that walks the desktop sidebar → click →
heading visible path.
i18n: 9 new keys under `achievements.*` (locked, criterionHint,
progressPercent, categories.{medication,vitals,security,engagement},
dashboardCard.{title,viewAll,empty}) — EN + DE. C4's parity audit will
sweep later.
Tests: +9 unit (groupByCategory ordering + page render in EN/DE) +
1 e2e (sidebar → /achievements). 909 / 909 unit pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
db5a49d593
commit
34c967c14a
9 files changed
+773
-55
No files matched your search
@@ -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=""
|
||||
@@ -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."
|
||||
@@ -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://<coolify-fqdn>/api/v1/deploy?uuid=<resource-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_<base62>`.
|
||||
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: <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:
|
||||
<https://coolify.io/docs/applications/ci-cd/github/actions>
|
||||
- Coolify Authorization API docs:
|
||||
<https://coolify.io/docs/api-reference/authorization>
|
||||
- v1.4.14 manual force-pull recipe: `.planning/phase-8-report.md`
|
||||
- C3 docker-publish reliability fix: `docs/audit/v1415-ci-reliability.md`
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
+15
-1
@@ -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",
|
||||
|
||||
+15
-1
@@ -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",
|
||||
|
||||
@@ -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 `<AchievementCard>`; 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(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<AchievementsPage />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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("<AchievementsPage>", () => {
|
||||
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"
|
||||
});
|
||||
});
|
||||
+183
-53
@@ -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<string, LucideIcon> = {
|
||||
SkipForward,
|
||||
};
|
||||
|
||||
const CATEGORY_LABEL_KEY: Record<AchievementCategory, string> = {
|
||||
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<AchievementCategory, AchievementProgress[]>();
|
||||
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<typeof useTranslations>["t"];
|
||||
}
|
||||
|
||||
function AchievementCard({ achievement, t }: AchievementCardProps) {
|
||||
const Icon = iconMap[achievement.icon] ?? Star;
|
||||
const unlocked = achievement.unlocked;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot={
|
||||
unlocked ? "achievement-card-unlocked" : "achievement-card-locked"
|
||||
}
|
||||
className={
|
||||
unlocked
|
||||
? "border-primary/30 from-primary/8 to-primary/0 rounded-xl border bg-gradient-to-br p-3"
|
||||
: "border-border bg-card/50 rounded-xl border p-3 opacity-70"
|
||||
}
|
||||
>
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={
|
||||
unlocked
|
||||
? "bg-primary/15 text-primary rounded-lg p-2"
|
||||
: "bg-muted text-muted-foreground rounded-lg p-2"
|
||||
}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold">{t(achievement.titleKey)}</h3>
|
||||
<p className="text-muted-foreground line-clamp-2 text-xs">
|
||||
{t(achievement.descriptionKey)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
variant={unlocked ? "default" : "secondary"}
|
||||
className="shrink-0"
|
||||
>
|
||||
{unlocked ? (
|
||||
t("achievements.completed")
|
||||
) : (
|
||||
<>
|
||||
<Lock className="mr-1 h-3 w-3" aria-hidden="true" />
|
||||
{t("achievements.locked")}
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{!unlocked && (
|
||||
<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,
|
||||
),
|
||||
target: formatMetric(achievement.format, achievement.target, t),
|
||||
})}
|
||||
</p>
|
||||
<Progress value={achievement.progressPercent} className="h-1.5" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-muted-foreground mt-3 flex items-center justify-between text-xs">
|
||||
<span className="font-medium">
|
||||
{t("achievements.pointsValue", {
|
||||
points: achievement.points,
|
||||
})}
|
||||
</span>
|
||||
{unlocked ? (
|
||||
<span>
|
||||
{achievement.completedAt
|
||||
? t("achievements.completedOn", {
|
||||
date: formatDate(achievement.completedAt),
|
||||
})
|
||||
: t("achievements.goalReached")}
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
{t("achievements.progressPercent", {
|
||||
percent: achievement.progressPercent,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{unlockedAchievements.map((achievement) => {
|
||||
const Icon = iconMap[achievement.icon] ?? Star;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={achievement.id}
|
||||
className="border-primary/30 from-primary/8 to-primary/0 rounded-xl border bg-gradient-to-br p-3"
|
||||
>
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-primary/15 text-primary rounded-lg p-2">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold">
|
||||
{t(achievement.titleKey)}
|
||||
</h2>
|
||||
<p className="text-muted-foreground line-clamp-2 text-xs">
|
||||
{t(achievement.descriptionKey)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="default" className="shrink-0">
|
||||
{t("achievements.completed")}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground mt-3 flex items-center justify-between text-xs">
|
||||
<span className="font-medium">
|
||||
{t("achievements.pointsValue", {
|
||||
points: achievement.points,
|
||||
})}
|
||||
</span>
|
||||
<span>
|
||||
{achievement.completedAt
|
||||
? t("achievements.completedOn", {
|
||||
date: formatDate(achievement.completedAt),
|
||||
})
|
||||
: t("achievements.goalReached")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{unlockedAchievements.length === 0 && (
|
||||
{grouped.length === 0 ? (
|
||||
<div className="bg-card border-border rounded-xl border p-6 text-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("achievements.noneUnlockedYet")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{grouped.map(({ category, items }) => {
|
||||
const unlockedInCategory = items.filter(
|
||||
(item) => item.unlocked,
|
||||
).length;
|
||||
return (
|
||||
<section
|
||||
key={category}
|
||||
aria-labelledby={`achievements-category-${category}`}
|
||||
data-slot="achievements-category"
|
||||
data-category={category}
|
||||
>
|
||||
<div className="mb-3 flex items-baseline justify-between gap-3">
|
||||
<h2
|
||||
id={`achievements-category-${category}`}
|
||||
className="text-base font-semibold tracking-tight"
|
||||
>
|
||||
{t(CATEGORY_LABEL_KEY[category])}
|
||||
</h2>
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{unlockedInCategory} / {items.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((achievement) => (
|
||||
<AchievementCard
|
||||
key={achievement.id}
|
||||
achievement={achievement}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user