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 = ;
+ chipClass =
+ "border-dracula-green/30 bg-dracula-green/15 text-dracula-green";
+ break;
+ case "error":
+ label = t("settings.integrationPill.errorReconnect");
+ icon = ;
+ chipClass = "";
+ break;
+ case "disconnected":
+ label = t("settings.integrationPill.notConnected");
+ icon = ;
+ chipClass = "";
+ break;
+ }
+
+ const reference = now ?? new Date();
+ const relative =
+ state === "connected" && lastSyncAt
+ ? formatRelative(
+ Math.max(0, reference.getTime() - toDate(lastSyncAt).getTime()),
+ t,
+ )
+ : null;
+
+ return (
+
+ {icon}
+
+ {label}
+ {relative ? · {relative} : null}
+
+ {relative ? {relative} : null}
+
+ );
+}