feat(coach): close the plan check-in loop on the Today rail

Every accepted coach plan now earns a gentle weekly check-in. Confirming a
plan (proposed → active) without an explicit review date defaults one to a
week out, so a plan is always reviewed, not only the coach's own experiments.

The daily digest emits a single `coach_checkin` rail item when a standing
plan's review has come due — its pinned review date, or a defaulted
activation + 7 days for plans that predate it, or the read-back state a
reviewed plan carries. Capped at one check-in a day across all plans
(earliest-due wins); the rest wait for a following day. It reads existing
plan state only — no fresh generation, nothing warms on mount, and it is
gated on the coach module.

The card offers three one-tap actions through the existing plan-lifecycle
route: keep it (re-arm for another week), adjust it (open the coach), or let
it go (a guilt-free, respected end — the calm inversion of a streak). An
ignored check-in sits quiet and stops resurfacing after about two cycles;
its status is never changed behind the user's back.

Copy lands in all six locales.
This commit is contained in:
Marc-André Bombeck committed 2026-07-16 22:30:46 +02:00
1 parent 7c96abc8ec
commit b7a6b4c898
15 files changed
+643 -32

No files matched your search

@@ -1,14 +1,20 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { TodayHero } from "../today-hero";
import type { DailyDigest } from "@/lib/daily/digest";
import type { PriorityItem } from "@/lib/daily/priority-item";
// The hero now wires the coach check-in card's keep / let-go taps through
// `useCoachCheckinAction`, so it needs a QueryClient in the tree.
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
const client = new QueryClient();
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
<QueryClientProvider client={client}>
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
</QueryClientProvider>,
);
}
+26 -2
View File
@@ -36,9 +36,14 @@ import { ScoreRing } from "@/components/insights/derived/score-ring";
import type { ScoreBand } from "@/components/insights/derived/band-tokens";
import { ProseBlocks } from "@/components/insights/prose-blocks";
import { PriorityCard } from "@/components/daily/priority-card";
import { useCoachCheckinAction } from "@/hooks/use-coach-checkin";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { DailyDigest } from "@/lib/daily/digest";
import {
COACH_CHECKIN_KEEP_INTENT,
COACH_CHECKIN_LETGO_INTENT,
type DailyDigest,
} from "@/lib/daily/digest";
/** Format the server-computed score delta as a signed, muted chip string. */
function formatDelta(
@@ -53,6 +58,21 @@ function formatDelta(
export function TodayHero({ digest }: { digest: DailyDigest }) {
const { t } = useTranslations();
const { keep, letGo } = useCoachCheckinAction();
// The coach check-in card's keep / let-go intents carry the plan id after the
// ":" (a closed two-intent allowlist); adjust is an href handled by the card
// as a <Link>, so it never lands here. Every other rail kind is pure
// navigation (dose / sync / preventive), so their taps never call this.
const handleAction = (intent: string) => {
const keepPrefix = `${COACH_CHECKIN_KEEP_INTENT}:`;
const letGoPrefix = `${COACH_CHECKIN_LETGO_INTENT}:`;
if (intent.startsWith(keepPrefix)) {
keep.mutate(intent.slice(keepPrefix.length));
} else if (intent.startsWith(letGoPrefix)) {
letGo.mutate(intent.slice(letGoPrefix.length));
}
};
const hasScore = digest.score !== null;
const hasItems = digest.worthALook.length > 0;
@@ -174,7 +194,11 @@ export function TodayHero({ digest }: { digest: DailyDigest }) {
</h2>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{digest.worthALook.map((item, i) => (
<PriorityCard key={`${item.kind}-${i}`} item={item} />
<PriorityCard
key={`${item.kind}-${i}`}
item={item}
onAction={handleAction}
/>
))}
</div>
</div>