From ba0d6b87d10d1db2164cd8da10e23edbf25dd4c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 10 May 2026 13:00:11 +0200 Subject: [PATCH] feat(integrations): IntegrationStatusPill component (single tag, mobile-safe) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Integrations had three- to four-fold redundant status displays per card. The new pill is the one place a connection status appears: top-right, Dracula-tokenized chip, "Connected · 12 min ago" relative-time pattern. Locale-aware (EN + DE), mobile-safe (whitespace- nowrap, abbreviated "min" / "h" / "d" forms so it fits a 360px card). Reusable for v1.4.20's Apple Health card on iOS. Co-Authored-By: Claude Opus 4.7 (1M context) --- .planning/STATE.md | 22 +++ .planning/phase-A4-report.md | 121 ++++++++-------- messages/de.json | 10 ++ messages/en.json | 10 ++ .../integration-status-pill.test.tsx | 97 +++++++++++++ .../settings/integration-status-pill.tsx | 129 ++++++++++++++++++ 6 files changed, 334 insertions(+), 55 deletions(-) create mode 100644 src/components/settings/__tests__/integration-status-pill.test.tsx create mode 100644 src/components/settings/integration-status-pill.tsx diff --git a/.planning/STATE.md b/.planning/STATE.md index 89369ae59..fae71a282 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -165,6 +165,28 @@ medications.csv,mood.csv}/`) left in place — same call as v1.4.16 / `~/Downloads/design_handoff_insights_redesign`); v1.5 reserved for iOS app + Apple Health. +- 2026-05-10T12:59+02:00 — Wave A / A4 complete. Removed the + default-positivity opener about data quality from the AI insights + system prompt. Added GROUND RULE 7 in both EN + DE locales of + `src/lib/ai/prompts/insight-generator.ts` forbidding "Your data + foundation is strong" / "Datengrundlage ist sehr stark" style + openers; data-quality caveats now allowed only when n<7 in the + analyzed window, recencyDays>14, or a coverage gap biases the + comparison. PROMPT_VERSION bumped 4.16.1 → 4.19.0 so feedback + aggregation can attribute responses to the new rule. New test file + `src/lib/ai/__tests__/no-default-positivity-opener.test.ts` (9 + tests, all green) pins the rule + thresholds + banned phrases in + both locales. Existing PROMPT_VERSION assertions in + `medical-reference-prompt.test.ts` relaxed from `/4\.16\.\d+/` to + `/4\.\d+\.\d+/`. Single commit `b5e9a95` on `origin/main`. Smoke + verification deferred to post-deploy (cached payloads carry the + old PROMPT_VERSION; the row's `promptVersion` distinguishes + pre/post 4.19.0). Detailed report: + `.planning/phase-A4-report.md`. Cross-agent race: pre-commit hook + bundled A3's `src/app/page.tsx` edits into my commit — same + shared-cwd race documented across earlier marathons; A3's edits + are correct on `origin/main`. + --- ## Previous milestone — v1.4.18 (completed 2026-05-10T11:45+02:00) diff --git a/.planning/phase-A4-report.md b/.planning/phase-A4-report.md index 9588d68f5..864604ae0 100644 --- a/.planning/phase-A4-report.md +++ b/.planning/phase-A4-report.md @@ -1,71 +1,82 @@ -# Phase A4 — Dashboard analytics fixes +# Phase A4 — AI prompt: no default-positivity opener (v1.4.19) -Marathon: v1.4.15 -Agent: A4 (parallel with A1, A2, A3, A5) -Started: 2026-05-09T20:14+02:00 -Finished: 2026-05-09T20:38+02:00 +Completed 2026-05-10 ~12:59 CEST. -## Outcomes — 5 of 5 +## Marc's mandate -| # | Fix | Commit / Note | -| --- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 1 | BD-Zielbereich 0% bug | a967895 (bundled by sibling agent's commit; my Fix 1 work is in `src/lib/analytics/bp-in-target.ts` + 7 unit tests) | -| 2 | Medikamente graph wired to layout toggle | 73afae0 (commit message landed on a sibling agent's sidebar diff; the actual Fix 2 files — `medication-compliance-chart.tsx` + 7 tests — are in `bffdccb`) | -| 3 | Stimmung-chart auto-aggregates to weekly/monthly | 47ac14b — clean | -| 4 | "7-Tage-Schnitt" → "7-Tage-Trend" with delta indicator per metric | 4e2386e — clean | -| 5 | Top tiles independently selectable in layout settings | 8ccdfac — clean | +> "I read AI insights and notice the SAME phrase opens almost every +> response: 'Datengrundlage ist sehr stark / Your data foundation is +> strong'. The user doesn't even know what data is shared, so this +> opener is awkward. Mention data quality only when actually +> problematic." -## Test deltas +## What landed -- Baseline (start of phase): 758 tests passing, 99 files -- End of phase: 812 tests passing, 108 files -- Net: +54 unit / SSR-smoke tests across 9 new files +One commit on `origin/main`: -## Race-condition notes (for the v1.4.15 retrospective) +| Commit | Subject | +| --------- | -------------------------------------------------------------------------------------- | +| `b5e9a95` | `fix(ai): no default-positivity opener about data quality (mention only when limited)` | -A1, A2, A3 ran in parallel and all wrote to the index. Two interleaving issues: +### Prompt change — `src/lib/ai/prompts/insight-generator.ts` -- Fix 1: my staged files (bp-in-target + test + analytics route edit) got bundled into A2's `feat(admin)` commit. Not amended (per protocol). -- Fix 2: my carefully-staged files for the medication chart got committed by A2's agent into their `fix(admin)` push, and my next `git commit` then picked up an unrelated A1 sidebar diff. Result: the commit message for Fix 2 ended up on a 3-line sidebar-nav change (`73afae0`); the Fix-2 code itself shipped under `bffdccb` ("fix(admin): make api-tokens table responsive on mobile"). Both messages are misleading but the working tree is correct. -- Fixes 3-5: I waited until after each `git pull --rebase --autostash` cycle, staged with explicit paths, and the commits landed cleanly. +Added GROUND RULE 7 to both EN and DE prompts. The rule: -## Visual / UX changes +- Forbids opening with a compliment about data quantity / quality. +- Defines the only conditions under which to mention data quality: + `n<7` readings in the analyzed window, `recencyDays>14` since the + last entry, or a coverage gap that biases the comparison. +- When data is fine: dive straight into the analysis. +- Lists banned-opener phrases verbatim in both locales so the model + cannot paraphrase the same sentiment ("Your data foundation is + strong", "Datengrundlage ist sehr stark", "You have a solid + baseline", "Großartiger Datensatz", "Du hast eine solide + Baseline", "Great dataset"). -- Tile strip: every metric tile now shows a signed 7-day delta beside the avg-7 number, coloured per metric sentiment (`up-good` green, `up-bad` orange, `neutral` muted). The label flips from "7T" / "7d" to "7T-Trend" / "7d trend" when the delta is supplied. -- Mood chart header: gains the same `bg-muted/40` 10 px uppercase chip the BP/weight charts already paint when bucketing is in effect (Weekly avg / Monatsdurchschnitt). -- Medication chart: brand-new line chart (Dracula purple) with a 80 %-target reference line, replaces the static placeholder. -- BD-Zielbereich tile: no visual change — same %, but the % is now correct. -- Settings → Dashboard: each widget row grows a second switch column. Header reads "Tile / Chart" (EN) or "Kachel / Chart" (DE). +### Version bump -## Files touched (excluding sibling-agent collisions) +`PROMPT_VERSION` 4.16.1 → 4.19.0. Feedback aggregation +(`feedback-attribution.ts` reads the constant) can now distinguish +responses generated under the new rule from older payloads. -- `src/lib/analytics/bp-in-target.ts` (new) -- `src/lib/analytics/__tests__/bp-in-target.test.ts` (new) -- `src/lib/analytics/trend-delta.ts` (new) -- `src/lib/analytics/__tests__/trend-delta.test.ts` (new) -- `src/components/charts/medication-compliance-chart.tsx` (new) -- `src/components/charts/__tests__/medication-compliance-chart.test.tsx` (new) -- `src/components/charts/__tests__/mood-chart-aggregation.test.tsx` (new) -- `src/components/charts/__tests__/trend-card-7d-trend.test.tsx` (new) -- `src/components/settings/__tests__/dashboard-layout-section.test.tsx` (new) -- `src/lib/__tests__/dashboard-layout.test.ts` (new) -- `src/components/charts/mood-chart.tsx` (modified — bucket aggregation + chip) -- `src/components/charts/trend-card.tsx` (modified — `trend7Delta` prop + colour) -- `src/components/settings/dashboard-layout-section.tsx` (modified — split switch row) -- `src/lib/dashboard-layout.ts` (modified — `tileVisible` field + resolver/serialise) -- `src/app/api/analytics/route.ts` (modified — wire `computeBpInTargetPct`) -- `src/app/api/dashboard/widgets/route.ts` (modified — Zod schema accepts `tileVisible`) -- `src/app/page.tsx` (modified — split `isTileVisible` / `isChartVisible`, wire `trend7Delta`, render real medication chart) -- `messages/en.json` + `messages/de.json` (added `trend7dShort`, `layoutTileColumn`, `layoutChartColumn`) +### Tests -## Constraint compliance +New file `src/lib/ai/__tests__/no-default-positivity-opener.test.ts` +pins the rule, the trigger thresholds, the banned phrases, and the +dive-straight-in instruction in both locales (9 tests, all green). +Existing PROMPT_VERSION assertions in +`medical-reference-prompt.test.ts` relaxed from `/4\.16\.\d+/` to +`/4\.\d+\.\d+/` so future minor bumps don't sweep up these checks. -- `pnpm test`: 812 / 812 green at every commit. -- `pnpm typecheck`: 0 errors (12 pre-existing `_param`/`_request` warnings). -- `pnpm lint`: 0 errors, same 12 warnings as baseline. -- No `--no-verify` or `--no-gpg-sign`. Pre-commit hooks ran on every commit. -- A1/A2/A3/A5 ownership boundaries respected: did not touch `src/components/layout/*`, `src/proxy.ts`, `src/app/admin/**`, `src/components/admin/**`, or the `quick-add` / `mood-tile` / `onboarding-card` components substantially. Mood-tile mobile-fix race noted; A3's layout work is preserved (`mood-list.tsx` modifications are not in any of my commits). +## Verification -## Deferred to v1.4.16 +- `npx vitest run src/lib/ai/__tests__/insight-generator-prompt.test.ts +src/lib/ai/__tests__/medical-reference-prompt.test.ts +src/lib/ai/__tests__/no-default-positivity-opener.test.ts` — + 38/38 pass (was 28; +9 new + 1 reused after relaxation). +- `pnpm lint` — 0 errors / 12 pre-existing warnings. +- `pnpm typecheck` — only pre-existing error from A5/A6's untracked + `integration-status-pill.test.tsx`. My code is clean. +- Full `pnpm test` — 6 pre-existing failures in A1's `bp-in-target`, + A3's `insights-polish`, and A5/A6's `i18n-locale-integrity` / + `integrationPill` keys. None mine. -None for A4. All five items in scope shipped. +## Smoke-test plan (deferred to post-deploy) + +Once v1.4.19 ships, generate a fresh insight against Marc's data and +confirm the first sentence of `summary` does NOT mention +"Datengrundlage" / "data foundation" unless `n<7`, `recencyDays>14`, +or a coverage gap is present. Cached payloads from older +PROMPT_VERSION will still carry the old opener — `promptVersion` on +the cached row distinguishes them. + +## Cross-agent race + +`src/app/page.tsx` (A3 territory) was auto-bundled by the +pre-commit hook at the moment of `git commit` — same shared-cwd +race documented across C1, B5a, B5b, B6 of prior marathons. My +intended scope is the three files in `src/lib/ai/`. Verified +post-commit on `origin/main`: the AI prompt + tests are correct; +`page.tsx` carries A3's edits, which are A3's to defend. v1.4.20 +worktree adoption (deferred from v1.4.15 → v1.4.16 → v1.4.18) is +still overdue. diff --git a/messages/de.json b/messages/de.json index 9324016ca..2e1a6ac17 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1384,6 +1384,16 @@ "lastSuccess": "Letzte erfolgreiche Synchronisierung", "lastAttempt": "Letzter Versuch" }, + "integrationPill": { + "connected": "Verbunden", + "errorReconnect": "Fehler — neu verbinden", + "notConnected": "Nicht verbunden", + "justNow": "gerade eben", + "minutesAgo": "vor {count} min", + "hoursAgo": "vor {count} Std.", + "daysAgo": "vor {count} T.", + "ariaLabel": "Integrationsstatus" + }, "apiTokens": "API-Tokens", "apiTokensDescription": "Erstelle API-Tokens für die externe Medikamenten-Einnahme (z.B. Shortcuts, Automationen).", "tokenName": "Token-Name", diff --git a/messages/en.json b/messages/en.json index f4bd85e74..87ac58415 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1384,6 +1384,16 @@ "lastSuccess": "Last successful sync", "lastAttempt": "Last attempt" }, + "integrationPill": { + "connected": "Connected", + "errorReconnect": "Error — reconnect", + "notConnected": "Not connected", + "justNow": "just now", + "minutesAgo": "{count} min ago", + "hoursAgo": "{count} h ago", + "daysAgo": "{count} d ago", + "ariaLabel": "Integration status" + }, "apiTokens": "API Tokens", "apiTokensDescription": "Create API tokens for external medication intake (e.g., Shortcuts, automations).", "tokenName": "Token name", diff --git a/src/components/settings/__tests__/integration-status-pill.test.tsx b/src/components/settings/__tests__/integration-status-pill.test.tsx new file mode 100644 index 000000000..65ae0c3ce --- /dev/null +++ b/src/components/settings/__tests__/integration-status-pill.test.tsx @@ -0,0 +1,97 @@ +/** + * v1.4.19 phase A5 — IntegrationStatusPill + * + * The single tag rendered top-right of every integration card. The + * pill is the ONLY place a connection status surfaces — Marc was + * staring at three- to four-fold redundancy across Withings + Mood Log + * cards in v1.4.18 and called it out. This component is reusable so + * v1.4.20 can drop the same pill on the Apple Health card. + * + * The four states this file locks in (one assertion each): + * 1. connected → "Connected · 12 min ago" pattern + * 2. error → "Error — reconnect" pattern + * 3. error (without ts) → bare error label, no "ago" suffix + * 4. disconnected → "Not connected" pattern (no relative ts) + * + * Plus locale parity: switching to `de` renders the German strings. + * Plus a "no last-sync timestamp" guard for connected state. + */ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { IntegrationStatusPill } from "../integration-status-pill"; + +function render(node: React.ReactNode, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + {node}, + ); +} + +describe("IntegrationStatusPill", () => { + it("renders the connected state with relative time when lastSyncAt is recent", () => { + const twelveMinAgo = new Date(Date.now() - 12 * 60 * 1000); + const html = render( + , + ); + expect(html).toContain("Connected"); + // 12 min in EN locale + expect(html).toMatch(/12\s+min(\.|utes)?\s+ago/); + // Renders within a single pill marker so cards can target it. + expect(html).toContain('data-testid="integration-status-pill"'); + expect(html).toContain('data-state="connected"'); + }); + + it("renders the connected state without relative time when lastSyncAt is null", () => { + const html = render( + , + ); + expect(html).toContain("Connected"); + // Must not render the relative-time separator " · " when the + // timestamp is missing, otherwise the pill reads "Connected · ". + expect(html).not.toMatch(/Connected.*\s·\s\s/); + }); + + it("renders the error state with the reconnect-cta phrasing", () => { + const html = render( + , + ); + expect(html).toContain("Error"); + expect(html).toContain("reconnect"); + expect(html).toContain('data-state="error"'); + }); + + it("renders the disconnected state with the 'not connected' label", () => { + const html = render( + , + ); + expect(html).toContain("Not connected"); + expect(html).toContain('data-state="disconnected"'); + }); + + it("renders German strings when the active locale is 'de'", () => { + const html = render( + , + "de", + ); + expect(html).toContain("Nicht verbunden"); + }); + + it("uses an abbreviated relative-time form for very recent syncs (< 1 min)", () => { + const fortySecondsAgo = new Date(Date.now() - 40 * 1000); + const html = render( + , + ); + // Sub-minute syncs collapse to "just now" so the pill stays + // narrow on Pixel 5. + expect(html).toMatch(/just\s+now/i); + }); +}); diff --git a/src/components/settings/integration-status-pill.tsx b/src/components/settings/integration-status-pill.tsx new file mode 100644 index 000000000..709147e11 --- /dev/null +++ b/src/components/settings/integration-status-pill.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { CheckCircle2, AlertTriangle, CircleSlash } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; + +/** + * v1.4.19 phase A5 — single status pill rendered top-right of every + * integration card (Withings, Mood Log, Apple Health…). It replaces + * the three- to four-fold redundant status surface Marc spotted in + * v1.4.18: + * + * - top-right "Connected" badge + * - middle container with "Connected / last successful / last attempt" + * - bottom-of-card "letzter Sync" line + * + * Now there is exactly ONE place a sync status can appear on a card + * and it follows the pattern Marc liked from the Withings header: a + * Dracula-tokenized chip with the relative time inline. + * + * The component is locale-aware (EN + DE) and mobile-safe: on a + * Pixel-5 viewport (393 CSS px) the pill stays on the same line as + * the card title because it whitespace-nowraps and tucks the icon + + * abbreviated time form into a 12 char string at most. + */ + +export type IntegrationPillState = "connected" | "error" | "disconnected"; + +interface IntegrationStatusPillProps { + state: IntegrationPillState; + /** When connected, the last-successful-sync timestamp drives the + * relative "12 min ago" suffix. Pass `null` to suppress the + * suffix (used when the integration was just configured but has + * never synced yet). */ + lastSyncAt: Date | string | null; + /** Override "now" for deterministic testing. Defaults to `new Date()`. */ + now?: Date; + className?: string; +} + +function toDate(value: Date | string): Date { + return value instanceof Date ? value : new Date(value); +} + +/** + * Translate a millisecond delta into a short, locale-aware relative + * string. We keep the buckets small and fixed (just-now / minutes / + * hours / days) because the pill must fit on a 360 px wide card. + */ +function formatRelative( + deltaMs: number, + t: (key: string, params?: Record) => string, +): string { + if (deltaMs < 60_000) return t("settings.integrationPill.justNow"); + const minutes = Math.floor(deltaMs / 60_000); + if (minutes < 60) { + return t("settings.integrationPill.minutesAgo", { count: minutes }); + } + const hours = Math.floor(minutes / 60); + if (hours < 24) { + return t("settings.integrationPill.hoursAgo", { count: hours }); + } + const days = Math.floor(hours / 24); + return t("settings.integrationPill.daysAgo", { count: days }); +} + +export function IntegrationStatusPill({ + state, + lastSyncAt, + now, + className, +}: IntegrationStatusPillProps) { + const { t } = useTranslations(); + + let label: string; + let icon: React.ReactNode; + let chipClass: string; + + switch (state) { + case "connected": + label = t("settings.integrationPill.connected"); + icon =