mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge pull request #418 from MBombeck/release/v1.27.9
Release v1.27.9 — coach plans you can accept
This commit is contained in:
24 files changed
+1032
-19
No files matched your search
@@ -2,6 +2,13 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.27.9] — 2026-07-05 — Coach plans you can accept
|
||||
|
||||
### Added
|
||||
|
||||
- When the Coach works out a concrete if-then plan with you, it now appears as a card right in the conversation — accept it or decline it with one tap. Accepted plans become part of what the Coach remembers: follow-up conversations refer back to them, and a plan with a review date comes back for review on its own.
|
||||
- A new plans page in the Coach area lists your proposals, active plans, and finished ones — mark a goal as met, end a plan, or clean up past ones.
|
||||
|
||||
## [1.27.8] — 2026-07-05 — Dashboard follow-ups and design-system sweeps
|
||||
|
||||
### Changed
|
||||
|
||||
+26
-6
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.27.8
|
||||
version: 1.27.9
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
@@ -5205,10 +5205,11 @@ paths:
|
||||
description: v1.21.3 (B1) — returns the durable plans the Coach has proposed for the caller, newest first, each
|
||||
decrypted on the fly. A plan is an "if-then" implementation intention tied to one metric, with an optional
|
||||
target. The Coach extractor writes a plan as `proposed`; only `PATCH /api/coach/plans/{id}` activates it. Pass
|
||||
`?status=` to filter (proposed | active | met | abandoned); omitted returns the non-terminal set (proposed +
|
||||
active). Coach-gated (`requireModuleEnabled("coach")`); a disabled surface 403s. Auth via cookie or Bearer; the
|
||||
owner is always narrowed from the session, never the body. Undecryptable rows are omitted rather than failing
|
||||
the read.
|
||||
`?status=` to filter to one lifecycle status, or `?scope=` for a named group (open = proposed + active +
|
||||
review_due, past = met + abandoned + reviewed, all = every non-deleted plan) — mutually exclusive. Both omitted
|
||||
returns the non-terminal set (proposed + active). Coach-gated (`requireModuleEnabled("coach")`); a disabled
|
||||
surface 403s. Auth via cookie or Bearer; the owner is always narrowed from the session, never the body.
|
||||
Undecryptable rows are omitted rather than failing the read.
|
||||
parameters:
|
||||
- name: status
|
||||
in: query
|
||||
@@ -5222,7 +5223,19 @@ paths:
|
||||
- abandoned
|
||||
- review_due
|
||||
- reviewed
|
||||
description: Filter to a single lifecycle status. Omit for the non-terminal set (proposed + active).
|
||||
description: Filter to a single lifecycle status. Mutually exclusive with `scope`. Omit both for the non-terminal set
|
||||
(proposed + active).
|
||||
- name: scope
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- open
|
||||
- past
|
||||
- all
|
||||
description: "Named status group: open (proposed + active + review_due), past (met + abandoned + reviewed), or all.
|
||||
Mutually exclusive with `status`."
|
||||
responses:
|
||||
"200":
|
||||
description: The caller's plans.
|
||||
@@ -20461,6 +20474,12 @@ components:
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
- type: "null"
|
||||
description: Optional check-in checkpoint; null when none.
|
||||
sourceConversationId:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
description: Id of the conversation the proposal was extracted from; null for a plan without conversation provenance.
|
||||
Lets a chat surface show only the proposals born in the open thread.
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
@@ -20477,6 +20496,7 @@ components:
|
||||
- target
|
||||
- status
|
||||
- reviewDate
|
||||
- sourceConversationId
|
||||
- createdAt
|
||||
- updatedAt
|
||||
additionalProperties: false
|
||||
|
||||
@@ -7609,6 +7609,34 @@
|
||||
"settings": {
|
||||
"reminderSuggestionsLabel": "Kadenz-Vorschläge",
|
||||
"reminderSuggestionsDescription": "Lass den Coach gelegentlich eine evidenzbasierte Erinnerung anbieten, eine Metrik regelmäßiger zu messen."
|
||||
},
|
||||
"plans": {
|
||||
"title": "Pläne",
|
||||
"pageDescription": "Wenn-dann-Pläne aus deinen Coach-Gesprächen. Bestätige einen Vorschlag, damit der Coach ihn sich merkt.",
|
||||
"proposalTitle": "Plan-Vorschlag",
|
||||
"ifThen": "Wenn {cue}, dann {action}.",
|
||||
"targetPrefix": "Ziel: {target}",
|
||||
"reviewPrefix": "Check-in: {when}",
|
||||
"accept": "Plan übernehmen",
|
||||
"decline": "Ablehnen",
|
||||
"accepted": "Plan übernommen — der Coach behält ihn im Blick.",
|
||||
"declined": "Vorschlag abgelehnt.",
|
||||
"failed": "Das hat nicht geklappt — bitte erneut versuchen.",
|
||||
"markMet": "Ziel erreicht",
|
||||
"abandon": "Plan beenden",
|
||||
"remove": "Entfernen",
|
||||
"empty": "Noch keine Pläne. Wenn du dich im Gespräch zu einer konkreten Wenn-dann-Absicht bekennst, schlägt der Coach sie hier zur Bestätigung vor.",
|
||||
"groupProposed": "Vorschläge",
|
||||
"groupActive": "Aktiv",
|
||||
"groupPast": "Abgeschlossen",
|
||||
"status": {
|
||||
"proposed": "Vorgeschlagen",
|
||||
"active": "Aktiv",
|
||||
"met": "Erreicht",
|
||||
"abandoned": "Beendet",
|
||||
"review_due": "Check-in fällig",
|
||||
"reviewed": "Ausgewertet"
|
||||
}
|
||||
}
|
||||
},
|
||||
"safety": {
|
||||
|
||||
@@ -7609,6 +7609,34 @@
|
||||
"settings": {
|
||||
"reminderSuggestionsLabel": "Cadence suggestions",
|
||||
"reminderSuggestionsDescription": "Let the Coach offer an occasional, evidence-based reminder to measure a metric more regularly."
|
||||
},
|
||||
"plans": {
|
||||
"title": "Plans",
|
||||
"pageDescription": "If-then plans agreed in your Coach conversations. Confirm a proposal to make it part of the Coach's memory.",
|
||||
"proposalTitle": "Plan proposal",
|
||||
"ifThen": "If {cue}, then {action}.",
|
||||
"targetPrefix": "Target: {target}",
|
||||
"reviewPrefix": "Check-in: {when}",
|
||||
"accept": "Accept plan",
|
||||
"decline": "Decline",
|
||||
"accepted": "Plan accepted — the Coach will keep it in mind.",
|
||||
"declined": "Proposal declined.",
|
||||
"failed": "That didn't work — please try again.",
|
||||
"markMet": "Goal met",
|
||||
"abandon": "End plan",
|
||||
"remove": "Remove",
|
||||
"empty": "No plans yet. When you commit to a concrete if-then intention in a conversation, the Coach proposes it here for confirmation.",
|
||||
"groupProposed": "Suggestions",
|
||||
"groupActive": "Active",
|
||||
"groupPast": "Completed",
|
||||
"status": {
|
||||
"proposed": "Proposed",
|
||||
"active": "Active",
|
||||
"met": "Met",
|
||||
"abandoned": "Ended",
|
||||
"review_due": "Check-in due",
|
||||
"reviewed": "Reviewed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"safety": {
|
||||
|
||||
@@ -7609,6 +7609,34 @@
|
||||
"settings": {
|
||||
"reminderSuggestionsLabel": "Sugerencias de frecuencia",
|
||||
"reminderSuggestionsDescription": "Permite que el Coach ofrezca de vez en cuando un recordatorio basado en evidencia para medir una métrica con más regularidad."
|
||||
},
|
||||
"plans": {
|
||||
"title": "Planes",
|
||||
"pageDescription": "Planes de tipo «si… entonces» acordados en tus conversaciones con el Coach. Confirma una propuesta para que el Coach la recuerde.",
|
||||
"proposalTitle": "Propuesta de plan",
|
||||
"ifThen": "Si {cue}, entonces {action}.",
|
||||
"targetPrefix": "Objetivo: {target}",
|
||||
"reviewPrefix": "Revisión: {when}",
|
||||
"accept": "Aceptar plan",
|
||||
"decline": "Rechazar",
|
||||
"accepted": "Plan aceptado: el Coach lo tendrá en cuenta.",
|
||||
"declined": "Propuesta rechazada.",
|
||||
"failed": "No ha funcionado; inténtalo de nuevo.",
|
||||
"markMet": "Objetivo cumplido",
|
||||
"abandon": "Finalizar plan",
|
||||
"remove": "Eliminar",
|
||||
"empty": "Aún no hay planes. Cuando te comprometas con una intención concreta de tipo «si… entonces» en una conversación, el Coach la propondrá aquí para confirmarla.",
|
||||
"groupProposed": "Propuestas",
|
||||
"groupActive": "Activos",
|
||||
"groupPast": "Completados",
|
||||
"status": {
|
||||
"proposed": "Propuesto",
|
||||
"active": "Activo",
|
||||
"met": "Cumplido",
|
||||
"abandoned": "Finalizado",
|
||||
"review_due": "Revisión pendiente",
|
||||
"reviewed": "Revisado"
|
||||
}
|
||||
}
|
||||
},
|
||||
"safety": {
|
||||
|
||||
@@ -7609,6 +7609,34 @@
|
||||
"settings": {
|
||||
"reminderSuggestionsLabel": "Suggestions de fréquence",
|
||||
"reminderSuggestionsDescription": "Laissez le Coach proposer de temps en temps un rappel fondé sur des preuves pour mesurer une donnée plus régulièrement."
|
||||
},
|
||||
"plans": {
|
||||
"title": "Plans",
|
||||
"pageDescription": "Plans « si… alors » convenus dans vos conversations avec le Coach. Confirmez une proposition pour que le Coach s'en souvienne.",
|
||||
"proposalTitle": "Proposition de plan",
|
||||
"ifThen": "Si {cue}, alors {action}.",
|
||||
"targetPrefix": "Objectif : {target}",
|
||||
"reviewPrefix": "Point d'étape : {when}",
|
||||
"accept": "Adopter le plan",
|
||||
"decline": "Refuser",
|
||||
"accepted": "Plan adopté — le Coach le gardera en tête.",
|
||||
"declined": "Proposition refusée.",
|
||||
"failed": "Échec de l'action — veuillez réessayer.",
|
||||
"markMet": "Objectif atteint",
|
||||
"abandon": "Terminer le plan",
|
||||
"remove": "Supprimer",
|
||||
"empty": "Aucun plan pour l'instant. Lorsque vous vous engagez sur une intention concrète « si… alors » dans une conversation, le Coach la propose ici pour confirmation.",
|
||||
"groupProposed": "Propositions",
|
||||
"groupActive": "Actifs",
|
||||
"groupPast": "Terminés",
|
||||
"status": {
|
||||
"proposed": "Proposé",
|
||||
"active": "Actif",
|
||||
"met": "Atteint",
|
||||
"abandoned": "Terminé",
|
||||
"review_due": "Point d'étape dû",
|
||||
"reviewed": "Évalué"
|
||||
}
|
||||
}
|
||||
},
|
||||
"safety": {
|
||||
|
||||
@@ -7609,6 +7609,34 @@
|
||||
"settings": {
|
||||
"reminderSuggestionsLabel": "Suggerimenti di cadenza",
|
||||
"reminderSuggestionsDescription": "Lascia che il Coach proponga ogni tanto un promemoria basato sull'evidenza per misurare una metrica più regolarmente."
|
||||
},
|
||||
"plans": {
|
||||
"title": "Piani",
|
||||
"pageDescription": "Piani «se… allora» concordati nelle conversazioni con il Coach. Conferma una proposta perché il Coach la tenga a mente.",
|
||||
"proposalTitle": "Proposta di piano",
|
||||
"ifThen": "Se {cue}, allora {action}.",
|
||||
"targetPrefix": "Obiettivo: {target}",
|
||||
"reviewPrefix": "Verifica: {when}",
|
||||
"accept": "Adotta il piano",
|
||||
"decline": "Rifiuta",
|
||||
"accepted": "Piano adottato: il Coach lo terrà a mente.",
|
||||
"declined": "Proposta rifiutata.",
|
||||
"failed": "Operazione non riuscita: riprova.",
|
||||
"markMet": "Obiettivo raggiunto",
|
||||
"abandon": "Termina piano",
|
||||
"remove": "Rimuovi",
|
||||
"empty": "Ancora nessun piano. Quando in una conversazione ti impegni in un'intenzione concreta «se… allora», il Coach la propone qui per la conferma.",
|
||||
"groupProposed": "Proposte",
|
||||
"groupActive": "Attivi",
|
||||
"groupPast": "Conclusi",
|
||||
"status": {
|
||||
"proposed": "Proposto",
|
||||
"active": "Attivo",
|
||||
"met": "Raggiunto",
|
||||
"abandoned": "Terminato",
|
||||
"review_due": "Verifica in scadenza",
|
||||
"reviewed": "Valutato"
|
||||
}
|
||||
}
|
||||
},
|
||||
"safety": {
|
||||
|
||||
@@ -7609,6 +7609,34 @@
|
||||
"settings": {
|
||||
"reminderSuggestionsLabel": "Sugestie częstotliwości",
|
||||
"reminderSuggestionsDescription": "Pozwól Coachowi od czasu do czasu zaproponować oparte na dowodach przypomnienie, aby mierzyć daną wartość bardziej regularnie."
|
||||
},
|
||||
"plans": {
|
||||
"title": "Plany",
|
||||
"pageDescription": "Plany typu „jeśli… to” uzgodnione w rozmowach z Coachem. Potwierdź propozycję, aby Coach ją zapamiętał.",
|
||||
"proposalTitle": "Propozycja planu",
|
||||
"ifThen": "Jeśli {cue}, to {action}.",
|
||||
"targetPrefix": "Cel: {target}",
|
||||
"reviewPrefix": "Przegląd: {when}",
|
||||
"accept": "Przyjmij plan",
|
||||
"decline": "Odrzuć",
|
||||
"accepted": "Plan przyjęty — Coach będzie o nim pamiętał.",
|
||||
"declined": "Propozycja odrzucona.",
|
||||
"failed": "Nie udało się — spróbuj ponownie.",
|
||||
"markMet": "Cel osiągnięty",
|
||||
"abandon": "Zakończ plan",
|
||||
"remove": "Usuń",
|
||||
"empty": "Nie ma jeszcze planów. Gdy w rozmowie zobowiążesz się do konkretnej intencji „jeśli… to”, Coach zaproponuje ją tutaj do potwierdzenia.",
|
||||
"groupProposed": "Propozycje",
|
||||
"groupActive": "Aktywne",
|
||||
"groupPast": "Zakończone",
|
||||
"status": {
|
||||
"proposed": "Zaproponowany",
|
||||
"active": "Aktywny",
|
||||
"met": "Osiągnięty",
|
||||
"abandoned": "Zakończony",
|
||||
"review_due": "Przegląd do zrobienia",
|
||||
"reviewed": "Oceniony"
|
||||
}
|
||||
}
|
||||
},
|
||||
"safety": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.27.8",
|
||||
"version": "1.27.9",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.27.8";
|
||||
/* @sw-version-fallback */ "v1.27.9";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
|
||||
|
||||
@@ -117,6 +117,7 @@ export const PATCH = apiHandler(async (req: NextRequest, ctx: RouteCtx) => {
|
||||
targetEncrypted: true,
|
||||
status: true,
|
||||
reviewDate: true,
|
||||
sourceConversationId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
@@ -148,6 +149,7 @@ export const PATCH = apiHandler(async (req: NextRequest, ctx: RouteCtx) => {
|
||||
target: null,
|
||||
status: row.status,
|
||||
reviewDate: row.reviewDate?.toISOString() ?? null,
|
||||
sourceConversationId: row.sourceConversationId,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
},
|
||||
@@ -177,6 +179,7 @@ export const PATCH = apiHandler(async (req: NextRequest, ctx: RouteCtx) => {
|
||||
target,
|
||||
status: row.status,
|
||||
reviewDate: row.reviewDate?.toISOString() ?? null,
|
||||
sourceConversationId: row.sourceConversationId,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
},
|
||||
|
||||
@@ -175,11 +175,73 @@ describe("GET /api/coach/plans", () => {
|
||||
expect(where.status).toBe("proposed");
|
||||
});
|
||||
|
||||
it("maps ?scope=open to the standing set incl. review_due", async () => {
|
||||
vi.mocked(prisma.coachPlan.findMany).mockResolvedValue([] as never);
|
||||
await callGet("http://localhost/api/coach/plans?scope=open");
|
||||
const where = vi.mocked(prisma.coachPlan.findMany).mock.calls[0]?.[0]
|
||||
?.where as { status: unknown };
|
||||
expect(where.status).toEqual({
|
||||
in: ["proposed", "active", "review_due"],
|
||||
});
|
||||
});
|
||||
|
||||
it("maps ?scope=past to the settled set", async () => {
|
||||
vi.mocked(prisma.coachPlan.findMany).mockResolvedValue([] as never);
|
||||
await callGet("http://localhost/api/coach/plans?scope=past");
|
||||
const where = vi.mocked(prisma.coachPlan.findMany).mock.calls[0]?.[0]
|
||||
?.where as { status: unknown };
|
||||
expect(where.status).toEqual({ in: ["met", "abandoned", "reviewed"] });
|
||||
});
|
||||
|
||||
it("drops the status clause entirely for ?scope=all", async () => {
|
||||
vi.mocked(prisma.coachPlan.findMany).mockResolvedValue([] as never);
|
||||
await callGet("http://localhost/api/coach/plans?scope=all");
|
||||
const where = vi.mocked(prisma.coachPlan.findMany).mock.calls[0]?.[0]
|
||||
?.where as Record<string, unknown>;
|
||||
expect(where.userId).toBe("user-1");
|
||||
expect(where.deletedAt).toBeNull();
|
||||
expect("status" in where).toBe(false);
|
||||
});
|
||||
|
||||
it("422s when both ?status= and ?scope= are given", async () => {
|
||||
const res = await callGet(
|
||||
"http://localhost/api/coach/plans?status=active&scope=all",
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("422s on an invalid ?status=", async () => {
|
||||
const res = await callGet("http://localhost/api/coach/plans?status=bogus");
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("422s on an invalid ?scope=", async () => {
|
||||
const res = await callGet("http://localhost/api/coach/plans?scope=bogus");
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("carries the source conversation id through to the item", async () => {
|
||||
vi.mocked(prisma.coachPlan.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "p1",
|
||||
metric: "WEIGHT",
|
||||
ifCueEncrypted: bytes("every morning"),
|
||||
thenActionEncrypted: bytes("weigh in"),
|
||||
targetEncrypted: null,
|
||||
status: "proposed",
|
||||
reviewDate: null,
|
||||
sourceConversationId: "conv-9",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
] as never);
|
||||
const res = await callGet();
|
||||
const body = (await res.json()) as {
|
||||
data: { plans: Array<{ sourceConversationId: string | null }> };
|
||||
};
|
||||
expect(body.data.plans[0]?.sourceConversationId).toBe("conv-9");
|
||||
});
|
||||
|
||||
it("skips an undecryptable row rather than 500ing", async () => {
|
||||
vi.mocked(prisma.coachPlan.findMany).mockResolvedValue([
|
||||
{
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
* The owner is always narrowed from the session, never the body.
|
||||
*
|
||||
* `?status=proposed|active|met|abandoned` optionally filters the list (e.g.
|
||||
* the composer fetches `proposed` to surface confirm cards). Omitted returns
|
||||
* the chat thread fetches `proposed` to surface confirm cards). `?scope=`
|
||||
* pulls a named status group instead (`open` | `past` | `all`) so the plans
|
||||
* management page reads its whole ledger in one round-trip. Omitted returns
|
||||
* the non-terminal set (proposed + active), newest first.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
@@ -28,6 +30,12 @@ import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { coachPlansListQuerySchema } from "@/lib/validations/coach-plan";
|
||||
|
||||
/** The status sets behind the named `?scope=` groups (`all` = no filter). */
|
||||
const SCOPE_STATUSES: Record<"open" | "past", string[]> = {
|
||||
open: ["proposed", "active", "review_due"],
|
||||
past: ["met", "abandoned", "reviewed"],
|
||||
};
|
||||
|
||||
export const GET = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
// Coach module gate (operator availability + disableCoach), mirroring the
|
||||
@@ -38,16 +46,23 @@ export const GET = apiHandler(async (req: Request) => {
|
||||
const url = new URL(req.url);
|
||||
const parsed = coachPlansListQuerySchema.safeParse({
|
||||
status: url.searchParams.get("status") ?? undefined,
|
||||
scope: url.searchParams.get("scope") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
// No status filter → the non-terminal set (the actionable plans). An
|
||||
// explicit status filters to exactly that status.
|
||||
// No filter → the non-terminal set (the actionable plans). An explicit
|
||||
// status filters to exactly that status; a scope selects a named group
|
||||
// (`all` drops the status clause entirely).
|
||||
const scope = parsed.data.scope;
|
||||
const statusWhere = parsed.data.status
|
||||
? { status: parsed.data.status }
|
||||
: { status: { in: ["proposed", "active"] } };
|
||||
: scope === "all"
|
||||
? {}
|
||||
: scope
|
||||
? { status: { in: SCOPE_STATUSES[scope] } }
|
||||
: { status: { in: ["proposed", "active"] } };
|
||||
|
||||
const rows = await prisma.coachPlan.findMany({
|
||||
where: { userId: user.id, deletedAt: null, ...statusWhere },
|
||||
@@ -60,6 +75,7 @@ export const GET = apiHandler(async (req: Request) => {
|
||||
targetEncrypted: true,
|
||||
status: true,
|
||||
reviewDate: true,
|
||||
sourceConversationId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
@@ -73,6 +89,7 @@ export const GET = apiHandler(async (req: Request) => {
|
||||
target: string | null;
|
||||
status: string;
|
||||
reviewDate: string | null;
|
||||
sourceConversationId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}> = [];
|
||||
@@ -104,6 +121,9 @@ export const GET = apiHandler(async (req: Request) => {
|
||||
target,
|
||||
status: row.status,
|
||||
reviewDate: row.reviewDate?.toISOString() ?? null,
|
||||
// Provenance for the chat surface: the thread only shows proposal
|
||||
// cards born in the OPEN conversation, never strays from another one.
|
||||
sourceConversationId: row.sourceConversationId,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Check, Loader2, Target, Trash2, X } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { COACH_SCROLLBAR } from "@/components/insights/coach-panel/message-thread";
|
||||
import {
|
||||
useCoachPlans,
|
||||
useCoachPlanMutations,
|
||||
type CoachPlanDTO,
|
||||
} from "@/hooks/use-coach-plans";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useDisableCoach } from "@/hooks/use-disable-coach";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { formatDateOrRelative } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* v1.27.x — the Coach plans management page (`/coach/plans`).
|
||||
*
|
||||
* The ledger for the durable goal / if-then plans the Coach proposes and the
|
||||
* user confirms — a sibling of `/coach/conversations`, reachable from the
|
||||
* composer's `+` actions menu. Three groups over one `?scope=all` read:
|
||||
*
|
||||
* - proposed: confirm (→ active) or decline (soft-delete)
|
||||
* - standing: active + review_due — mark met or end (→ abandoned)
|
||||
* - past: met + abandoned + reviewed — remove from the ledger
|
||||
*
|
||||
* There is deliberately NO prose editor: the extractor is the only writer of
|
||||
* plan text, and the PATCH contract only moves the lifecycle. An active plan
|
||||
* is injected into the Coach's snapshot memory (top-6, newest first), so
|
||||
* follow-up conversations recall it; a proposed or declined plan never is.
|
||||
*
|
||||
* Gating mirrors `/coach`: the operator master flag OR a per-user opt-out
|
||||
* redirects back to `/insights` rather than painting a dead shell.
|
||||
*/
|
||||
|
||||
type GroupId = "proposed" | "standing" | "past";
|
||||
|
||||
const GROUPS: Array<{ id: GroupId; labelKey: string }> = [
|
||||
{ id: "proposed", labelKey: "coach.plans.groupProposed" },
|
||||
{ id: "standing", labelKey: "coach.plans.groupActive" },
|
||||
{ id: "past", labelKey: "coach.plans.groupPast" },
|
||||
];
|
||||
|
||||
function groupOf(status: string): GroupId {
|
||||
if (status === "proposed") return "proposed";
|
||||
if (status === "active" || status === "review_due") return "standing";
|
||||
return "past";
|
||||
}
|
||||
|
||||
function CoachPlansBody() {
|
||||
const { t } = useTranslations();
|
||||
const query = useCoachPlans({ filter: { scope: "all" } });
|
||||
const { setStatus, remove } = useCoachPlanMutations();
|
||||
|
||||
const plans = useMemo(() => query.data ?? [], [query.data]);
|
||||
const groups = useMemo(() => {
|
||||
const buckets: Record<GroupId, CoachPlanDTO[]> = {
|
||||
proposed: [],
|
||||
standing: [],
|
||||
past: [],
|
||||
};
|
||||
for (const p of plans) buckets[groupOf(p.status)].push(p);
|
||||
return GROUPS.map((g) => ({ ...g, plans: buckets[g.id] })).filter(
|
||||
(g) => g.plans.length > 0,
|
||||
);
|
||||
}, [plans]);
|
||||
|
||||
const pendingId =
|
||||
(setStatus.isPending && setStatus.variables?.id) ||
|
||||
(remove.isPending && remove.variables) ||
|
||||
null;
|
||||
|
||||
const row = (plan: CoachPlanDTO) => {
|
||||
const busy = pendingId === plan.id;
|
||||
const group = groupOf(plan.status);
|
||||
return (
|
||||
<li
|
||||
key={plan.id}
|
||||
data-slot="coach-plan-row"
|
||||
data-status={plan.status}
|
||||
className="border-border bg-card flex flex-col gap-2 rounded-lg border p-3"
|
||||
>
|
||||
{/* Plan prose is the user's own committed intention — content tier. */}
|
||||
<p className="text-sm leading-relaxed break-words">
|
||||
{t("coach.plans.ifThen", {
|
||||
cue: plan.ifCue ?? "",
|
||||
action: plan.thenAction ?? "",
|
||||
})}
|
||||
</p>
|
||||
{plan.target ? (
|
||||
<p className="text-xs break-words">
|
||||
{t("coach.plans.targetPrefix", { target: plan.target })}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
<span className="uppercase">{plan.metric}</span>
|
||||
<span>{t(`coach.plans.status.${plan.status}`)}</span>
|
||||
<span>{formatDateOrRelative(plan.updatedAt, t)}</span>
|
||||
{plan.reviewDate ? (
|
||||
<span>
|
||||
{t("coach.plans.reviewPrefix", {
|
||||
when: formatDateOrRelative(plan.reviewDate, t),
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{group === "proposed" && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-9"
|
||||
disabled={busy}
|
||||
data-slot="coach-plan-accept"
|
||||
onClick={() =>
|
||||
setStatus.mutate({ id: plan.id, status: "active" })
|
||||
}
|
||||
>
|
||||
{busy && setStatus.isPending ? (
|
||||
<Loader2
|
||||
className="size-3.5 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<Check className="size-3.5" aria-hidden="true" />
|
||||
)}
|
||||
{t("coach.plans.accept")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-h-9"
|
||||
disabled={busy}
|
||||
data-slot="coach-plan-decline"
|
||||
onClick={() => remove.mutate(plan.id)}
|
||||
>
|
||||
<X className="size-3.5" aria-hidden="true" />
|
||||
{t("coach.plans.decline")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{group === "standing" && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-9"
|
||||
disabled={busy}
|
||||
data-slot="coach-plan-met"
|
||||
onClick={() => setStatus.mutate({ id: plan.id, status: "met" })}
|
||||
>
|
||||
<Check className="size-3.5" aria-hidden="true" />
|
||||
{t("coach.plans.markMet")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-h-9"
|
||||
disabled={busy}
|
||||
data-slot="coach-plan-abandon"
|
||||
onClick={() =>
|
||||
setStatus.mutate({ id: plan.id, status: "abandoned" })
|
||||
}
|
||||
>
|
||||
<X className="size-3.5" aria-hidden="true" />
|
||||
{t("coach.plans.abandon")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{group === "past" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-h-9"
|
||||
disabled={busy}
|
||||
data-slot="coach-plan-delete"
|
||||
onClick={() => remove.mutate(plan.id)}
|
||||
>
|
||||
<Trash2 className="size-3.5" aria-hidden="true" />
|
||||
{t("coach.plans.remove")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full min-h-0 w-full max-w-screen-xl flex-col gap-4 px-4 pt-6 pb-4 md:px-6">
|
||||
<PageHeader
|
||||
title={
|
||||
<span data-slot="coach-plans-heading">{t("coach.plans.title")}</span>
|
||||
}
|
||||
description={t("coach.plans.pageDescription")}
|
||||
/>
|
||||
|
||||
<div
|
||||
data-slot="coach-plans-list"
|
||||
className={cn(
|
||||
"-mx-1 flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto px-1",
|
||||
COACH_SCROLLBAR,
|
||||
)}
|
||||
>
|
||||
{query.isError ? (
|
||||
<QueryErrorCard onRetry={() => query.refetch()} />
|
||||
) : query.isLoading ? (
|
||||
<p
|
||||
data-slot="coach-plans-loading"
|
||||
className="text-muted-foreground px-1 py-3 text-sm"
|
||||
>
|
||||
{t("common.loading")}
|
||||
</p>
|
||||
) : groups.length === 0 ? (
|
||||
<div
|
||||
data-slot="coach-plans-empty"
|
||||
className="flex flex-1 flex-col items-center justify-center gap-3 px-6 py-12 text-center"
|
||||
>
|
||||
<span className="bg-muted/40 text-muted-foreground flex size-12 items-center justify-center rounded-full">
|
||||
<Target className="size-6" aria-hidden="true" />
|
||||
</span>
|
||||
<p className="text-muted-foreground max-w-xs text-sm leading-relaxed">
|
||||
{t("coach.plans.empty")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
groups.map((group) => (
|
||||
<section
|
||||
key={group.id}
|
||||
data-slot="coach-plans-group"
|
||||
data-group={group.id}
|
||||
className="flex flex-col gap-1.5"
|
||||
>
|
||||
<h2 className="text-muted-foreground px-1 text-xs font-medium tracking-wide uppercase">
|
||||
{t(group.labelKey)}
|
||||
</h2>
|
||||
<ul className="flex flex-col gap-2">{group.plans.map(row)}</ul>
|
||||
</section>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CoachPlansPage() {
|
||||
const router = useRouter();
|
||||
const flags = useFeatureFlags();
|
||||
const disableCoach = useDisableCoach();
|
||||
|
||||
const coachUnavailable = !flags.coach || disableCoach;
|
||||
|
||||
// Same gating as `/coach`: operator master flag OR per-user opt-out
|
||||
// redirects back to the Insights mother page so the route is never a
|
||||
// dead-end.
|
||||
useEffect(() => {
|
||||
if (coachUnavailable) {
|
||||
router.replace("/insights");
|
||||
}
|
||||
}, [coachUnavailable, router]);
|
||||
|
||||
if (coachUnavailable) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="coach-plans-page"
|
||||
// Match `/coach/conversations`' full-bleed sizing: cancel the AuthShell
|
||||
// padding and claim the viewport height below the top bar (minus the
|
||||
// mobile-only BottomNav band).
|
||||
className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem)]"
|
||||
>
|
||||
<CoachPlansBody />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
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 { queryKeys } from "@/lib/query-keys";
|
||||
import type { CoachPlanDTO } from "@/hooks/use-coach-plans";
|
||||
import { PlanProposalCards } from "../plan-proposal-card";
|
||||
|
||||
/**
|
||||
* The plan-proposal confirm cards at the thread tail. SSR string render
|
||||
* against a pre-seeded query cache (the `status:proposed` slot the component
|
||||
* reads), so the tests pin the render contract without a network layer:
|
||||
* only proposals born in the OPEN conversation render, the prose reads in
|
||||
* the card, and both actions are present as real buttons.
|
||||
*/
|
||||
|
||||
function plan(overrides: Partial<CoachPlanDTO> = {}): CoachPlanDTO {
|
||||
return {
|
||||
id: "p1",
|
||||
metric: "WEIGHT",
|
||||
ifCue: "every morning",
|
||||
thenAction: "weigh in before breakfast",
|
||||
target: "steady trend by autumn",
|
||||
status: "proposed",
|
||||
reviewDate: null,
|
||||
sourceConversationId: "conv-1",
|
||||
createdAt: "2026-07-01T00:00:00.000Z",
|
||||
updatedAt: "2026-07-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function render(conversationId: string, plans: CoachPlanDTO[]) {
|
||||
const client = new QueryClient();
|
||||
client.setQueryData(queryKeys.coachPlans("status:proposed"), plans);
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<PlanProposalCards conversationId={conversationId} />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<PlanProposalCards>", () => {
|
||||
it("renders nothing when the conversation has no open proposal", () => {
|
||||
expect(render("conv-1", [])).toBe("");
|
||||
});
|
||||
|
||||
it("renders a confirm card for a proposal born in the open conversation", () => {
|
||||
const html = render("conv-1", [plan()]);
|
||||
expect(html).toContain('data-slot="coach-plan-proposal-card"');
|
||||
// The plan prose is content — visible in the card.
|
||||
expect(html).toContain("every morning");
|
||||
expect(html).toContain("weigh in before breakfast");
|
||||
expect(html).toContain("steady trend by autumn");
|
||||
expect(html).toContain("WEIGHT");
|
||||
// Both actions render as real buttons.
|
||||
expect(html).toContain('data-slot="coach-plan-proposal-accept"');
|
||||
expect(html).toContain('data-slot="coach-plan-proposal-decline"');
|
||||
});
|
||||
|
||||
it("hides proposals born in a different conversation", () => {
|
||||
const html = render("conv-2", [plan()]);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("skips a proposal whose prose is undecryptable (null fields)", () => {
|
||||
const html = render("conv-1", [plan({ ifCue: null, thenAction: null })]);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("renders one card per open proposal", () => {
|
||||
const html = render("conv-1", [
|
||||
plan(),
|
||||
plan({ id: "p2", ifCue: "after dinner", thenAction: "short walk" }),
|
||||
]);
|
||||
expect(html.match(/coach-plan-proposal-card/g)?.length).toBe(2);
|
||||
expect(html).toContain("after dinner");
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Send,
|
||||
Settings,
|
||||
Square,
|
||||
Target,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -517,6 +518,12 @@ export function CoachInput({
|
||||
<MessagesSquare className="size-4" aria-hidden="true" />
|
||||
{t("insights.coach.historyTitle")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild data-slot="coach-input-action-plans">
|
||||
<Link href="/coach/plans">
|
||||
<Target className="size-4" aria-hidden="true" />
|
||||
{t("coach.plans.title")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild data-slot="coach-input-action-settings">
|
||||
<Link href="/settings/ai">
|
||||
<Settings className="size-4" aria-hidden="true" />
|
||||
|
||||
@@ -46,6 +46,7 @@ import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
|
||||
import { ProseBlocks } from "@/components/insights/prose-blocks";
|
||||
|
||||
import { SourceChips } from "./source-chips";
|
||||
import { PlanProposalCards } from "./plan-proposal-card";
|
||||
import { ReminderSuggestionCard } from "./reminder-suggestion-card";
|
||||
import { SuggestedActionCard } from "./suggested-action-card";
|
||||
import { StreamedProse } from "./streamed-prose";
|
||||
@@ -1026,6 +1027,14 @@ export function MessageThread({
|
||||
{placement.tail.map((i) => (
|
||||
<Fragment key={i.key}>{i.node}</Fragment>
|
||||
))}
|
||||
{/* Plan-proposal confirm cards for THIS conversation. The extractor
|
||||
runs after the turn (memory-refresh worker), so the block appears at
|
||||
the thread tail once the proposal lands rather than under a specific
|
||||
bubble; the component itself slow-polls and renders nothing while
|
||||
the conversation has no open proposal. */}
|
||||
{conversation ? (
|
||||
<PlanProposalCards conversationId={conversation.id} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Check, Loader2, Target, X } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
useCoachPlans,
|
||||
useCoachPlanMutations,
|
||||
type CoachPlanDTO,
|
||||
} from "@/hooks/use-coach-plans";
|
||||
|
||||
/**
|
||||
* v1.27.x — the confirm cards for Coach plan proposals, rendered at the tail
|
||||
* of the open conversation's thread.
|
||||
*
|
||||
* The extractor (memory-refresh worker, runs AFTER the turn) writes a plan as
|
||||
* `status: "proposed"`; nothing becomes a standing plan until the user
|
||||
* confirms it here (or on `/coach/plans`). Because the proposal lands
|
||||
* asynchronously, the block slow-polls the proposed list while the thread is
|
||||
* mounted (TanStack pauses the interval in background tabs) and only shows
|
||||
* proposals whose `sourceConversationId` is the OPEN conversation — a
|
||||
* proposal born in an older thread stays on the management page instead of
|
||||
* popping into an unrelated chat.
|
||||
*
|
||||
* Accept → PATCH `{status:"active"}` (the plan enters the Coach's snapshot
|
||||
* memory block from the next turn on). Decline → DELETE (soft-delete; the
|
||||
* proposal never activates and stops counting against the per-user cap).
|
||||
* A settled card collapses to a one-line confirmation for the session so the
|
||||
* refetch dropping the row from the proposed list cannot double-prompt.
|
||||
*/
|
||||
|
||||
/** Slow poll (ms) — proposals arrive via the post-turn background worker. */
|
||||
const PROPOSAL_POLL_MS = 20_000;
|
||||
|
||||
type SettledOutcome = "accepted" | "declined";
|
||||
|
||||
export function PlanProposalCards({
|
||||
conversationId,
|
||||
}: {
|
||||
conversationId: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [settled, setSettled] = useState<
|
||||
Array<{ id: string; outcome: SettledOutcome }>
|
||||
>([]);
|
||||
|
||||
const query = useCoachPlans({
|
||||
filter: { status: "proposed" },
|
||||
refetchInterval: PROPOSAL_POLL_MS,
|
||||
});
|
||||
|
||||
// Quiet on error by design: the thread is the Coach's surface, and a failed
|
||||
// enhancement read must never interrupt the conversation with an error card.
|
||||
const proposals = (query.data ?? []).filter(
|
||||
(p) =>
|
||||
p.sourceConversationId === conversationId &&
|
||||
p.ifCue !== null &&
|
||||
p.thenAction !== null &&
|
||||
!settled.some((s) => s.id === p.id),
|
||||
);
|
||||
|
||||
if (proposals.length === 0 && settled.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div data-slot="coach-plan-proposals" className="flex flex-col gap-2.5">
|
||||
{proposals.map((plan) => (
|
||||
<PlanProposalCard
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
onSettled={(outcome) =>
|
||||
setSettled((prev) => [...prev, { id: plan.id, outcome }])
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{settled.map((s) => (
|
||||
<p
|
||||
key={s.id}
|
||||
role="status"
|
||||
data-slot="coach-plan-proposal-done"
|
||||
className="text-muted-foreground flex items-center gap-1.5 text-xs"
|
||||
>
|
||||
<Check className="text-success size-3.5" aria-hidden="true" />
|
||||
{t(
|
||||
s.outcome === "accepted"
|
||||
? "coach.plans.accepted"
|
||||
: "coach.plans.declined",
|
||||
)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One proposal with confirm / decline actions. */
|
||||
function PlanProposalCard({
|
||||
plan,
|
||||
onSettled,
|
||||
}: {
|
||||
plan: CoachPlanDTO;
|
||||
onSettled: (outcome: SettledOutcome) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { setStatus, remove } = useCoachPlanMutations();
|
||||
const busy = setStatus.isPending || remove.isPending;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="coach-plan-proposal-card"
|
||||
className={cn(
|
||||
"border-border/60 bg-muted/40 flex flex-col gap-2.5 rounded-xl border",
|
||||
"px-3.5 py-3 text-sm",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="bg-primary/10 mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full"
|
||||
>
|
||||
<Target className="text-primary size-3.5" />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-muted-foreground text-xs font-medium">
|
||||
{t("coach.plans.proposalTitle")}
|
||||
</span>
|
||||
{/* The plan prose is the user's own committed intention — content,
|
||||
never meta, so it reads in foreground per the text-colour rules. */}
|
||||
<span className="text-foreground leading-relaxed break-words">
|
||||
{t("coach.plans.ifThen", {
|
||||
cue: plan.ifCue ?? "",
|
||||
action: plan.thenAction ?? "",
|
||||
})}
|
||||
</span>
|
||||
{plan.target ? (
|
||||
<span className="text-foreground text-xs break-words">
|
||||
{t("coach.plans.targetPrefix", { target: plan.target })}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="text-muted-foreground text-xs uppercase">
|
||||
{plan.metric}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
data-slot="coach-plan-proposal-accept"
|
||||
onClick={() =>
|
||||
setStatus.mutate(
|
||||
{ id: plan.id, status: "active" },
|
||||
{
|
||||
onSuccess: () => onSettled("accepted"),
|
||||
onError: () => toast.error(t("coach.plans.failed")),
|
||||
},
|
||||
)
|
||||
}
|
||||
disabled={busy}
|
||||
className={cn(
|
||||
"bg-primary/90 text-primary-foreground hover:bg-primary",
|
||||
"focus-visible:ring-ring/50 inline-flex min-h-9 items-center gap-1.5",
|
||||
"rounded-md px-3 py-1.5 text-xs font-medium outline-none",
|
||||
"focus-visible:ring-2 disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
{busy && setStatus.isPending ? (
|
||||
<Loader2
|
||||
className="size-3.5 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<Check className="size-3.5" aria-hidden="true" />
|
||||
)}
|
||||
{t("coach.plans.accept")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="coach-plan-proposal-decline"
|
||||
onClick={() =>
|
||||
remove.mutate(plan.id, {
|
||||
onSuccess: () => onSettled("declined"),
|
||||
onError: () => toast.error(t("coach.plans.failed")),
|
||||
})
|
||||
}
|
||||
disabled={busy}
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex min-h-9 items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs outline-none focus-visible:ring-2 disabled:opacity-50"
|
||||
>
|
||||
<X className="size-3.5" aria-hidden="true" />
|
||||
{t("coach.plans.decline")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.27.x — read + mutate the user's Coach goal / if-then plans.
|
||||
*
|
||||
* One shared hook for the chat thread's proposal confirm cards and the
|
||||
* `/coach/plans` management page. Reads unwrap `(await res.json()).data` per
|
||||
* the envelope convention; every key routes through `queryKeys.coachPlans()`
|
||||
* so a lifecycle mutation invalidates both list slots at once. Mirrors
|
||||
* `use-coach-reminders.ts`, the sibling episodic-memory hook.
|
||||
*
|
||||
* The extractor is the only writer of plan TEXT — this surface only confirms
|
||||
* (proposed → active), marks met / abandoned, or soft-deletes. There is no
|
||||
* prose editor by design: the PATCH contract never accepts the encrypted
|
||||
* free-text fields.
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiDelete, apiGet, apiPatch } from "@/lib/api/api-fetch";
|
||||
|
||||
export interface CoachPlanDTO {
|
||||
id: string;
|
||||
metric: string;
|
||||
/** Null only when the row's encryption key id rotated out of the map. */
|
||||
ifCue: string | null;
|
||||
thenAction: string | null;
|
||||
target: string | null;
|
||||
status: string;
|
||||
reviewDate: string | null;
|
||||
sourceConversationId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface PlanFilter {
|
||||
/** Single lifecycle status (`proposed`, `active`, …). */
|
||||
status?: string;
|
||||
/** Named group: `open` | `past` | `all`. Mutually exclusive with status. */
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
function filterKey(filter?: PlanFilter): string | undefined {
|
||||
if (filter?.status) return `status:${filter.status}`;
|
||||
if (filter?.scope) return `scope:${filter.scope}`;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function fetchPlans(filter?: PlanFilter): Promise<CoachPlanDTO[]> {
|
||||
const qs = filter?.status
|
||||
? `?status=${encodeURIComponent(filter.status)}`
|
||||
: filter?.scope
|
||||
? `?scope=${encodeURIComponent(filter.scope)}`
|
||||
: "";
|
||||
const data = await apiGet<{ plans?: CoachPlanDTO[] } | undefined>(
|
||||
`/api/coach/plans${qs}`,
|
||||
);
|
||||
return data?.plans ?? [];
|
||||
}
|
||||
|
||||
/** List the caller's plans, optionally filtered by status or scope. */
|
||||
export function useCoachPlans(opts?: {
|
||||
filter?: PlanFilter;
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* Optional poll interval (ms). The chat thread uses a slow poll because
|
||||
* proposals land asynchronously (the memory-refresh worker runs after the
|
||||
* turn); the management page reads once. TanStack pauses the interval in
|
||||
* background tabs by default, so the poll never runs unwatched.
|
||||
*/
|
||||
refetchInterval?: number;
|
||||
}) {
|
||||
const filter = opts?.filter;
|
||||
return useQuery({
|
||||
queryKey: queryKeys.coachPlans(filterKey(filter)),
|
||||
queryFn: () => fetchPlans(filter),
|
||||
enabled: opts?.enabled ?? true,
|
||||
refetchInterval: opts?.refetchInterval,
|
||||
});
|
||||
}
|
||||
|
||||
/** Lifecycle + delete mutations, invalidating every plans list slot. */
|
||||
export function useCoachPlanMutations() {
|
||||
const queryClient = useQueryClient();
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.coachPlansAll() });
|
||||
|
||||
const setStatus = useMutation({
|
||||
mutationFn: async (args: { id: string; status: string }) => {
|
||||
await apiPatch(`/api/coach/plans/${encodeURIComponent(args.id)}`, {
|
||||
status: args.status,
|
||||
});
|
||||
return args.id;
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await apiDelete(`/api/coach/plans/${encodeURIComponent(id)}`);
|
||||
return id;
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return { setStatus, remove };
|
||||
}
|
||||
@@ -288,6 +288,9 @@ describe("Coach disable cascade invariant", () => {
|
||||
// `flags.coach` like the main Coach route and renders unavailable
|
||||
// when the operator master flag is off.
|
||||
"src/app/coach/conversations/page.tsx",
|
||||
// The plans management page mirrors the conversation-history page's
|
||||
// gate: operator master flag off redirects back to `/insights`.
|
||||
"src/app/coach/plans/page.tsx",
|
||||
// v1.15.20 — the proactive Coach-nudge cron short-circuits on
|
||||
// `flags.coach` (gate 1) so an operator kill-switch also silences
|
||||
// the nudges. Server-side cron, not a render path — the cascade
|
||||
|
||||
@@ -302,6 +302,9 @@ describe("Coach per-user disableCoach invariant", () => {
|
||||
// Coach route gate: operator master flag OR per-user opt-out marks
|
||||
// the page unavailable instead of painting a dead history shell.
|
||||
"src/app/coach/conversations/page.tsx",
|
||||
// The plans management page mirrors the same gate: operator master
|
||||
// flag OR per-user opt-out redirects instead of a dead plans shell.
|
||||
"src/app/coach/plans/page.tsx",
|
||||
// The hook itself + its `useAuth`-backed reader.
|
||||
"src/hooks/use-disable-coach.ts",
|
||||
// Cross-cut gates owned by sibling invariants / route tests.
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { COACH_FACT_CATEGORIES } from "@/lib/ai/coach/facts";
|
||||
import { COACH_PLAN_STATUSES } from "@/lib/ai/coach/plans";
|
||||
import {
|
||||
COACH_PLAN_SCOPES,
|
||||
coachPlanPatchSchema,
|
||||
coachPlansListQuerySchema,
|
||||
} from "@/lib/validations/coach-plan";
|
||||
@@ -283,6 +284,12 @@ const coachPlanItem = z
|
||||
.datetime({ offset: true })
|
||||
.nullable()
|
||||
.describe("Optional check-in checkpoint; null when none."),
|
||||
sourceConversationId: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
"Id of the conversation the proposal was extracted from; null for a plan without conversation provenance. Lets a chat surface show only the proposals born in the open thread.",
|
||||
),
|
||||
createdAt: z.iso.datetime({ offset: true }),
|
||||
updatedAt: z.iso.datetime({ offset: true }),
|
||||
})
|
||||
@@ -321,7 +328,7 @@ coachPlanPatchSchema.meta({
|
||||
coachPlansListQuerySchema.meta({
|
||||
id: "CoachPlansListQuery",
|
||||
description:
|
||||
"Optional `?status=` filter for the plans list (proposed | active | met | abandoned). Omitted returns the non-terminal set (proposed + active).",
|
||||
"Optional `?status=` filter for the plans list (a single lifecycle status), or `?scope=` for a named group (open = proposed + active + review_due, past = met + abandoned + reviewed, all = every non-deleted plan). Mutually exclusive. Both omitted returns the non-terminal set (proposed + active).",
|
||||
});
|
||||
|
||||
// ── Coach episodic reminders (v1.22 B2/B6) ──────────────────────────────
|
||||
@@ -1021,7 +1028,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Insights"],
|
||||
summary: "List the caller's Coach goal / if-then plans",
|
||||
description:
|
||||
'v1.21.3 (B1) — returns the durable plans the Coach has proposed for the caller, newest first, each decrypted on the fly. A plan is an "if-then" implementation intention tied to one metric, with an optional target. The Coach extractor writes a plan as `proposed`; only `PATCH /api/coach/plans/{id}` activates it. Pass `?status=` to filter (proposed | active | met | abandoned); omitted returns the non-terminal set (proposed + active). Coach-gated (`requireModuleEnabled("coach")`); a disabled surface 403s. Auth via cookie or Bearer; the owner is always narrowed from the session, never the body. Undecryptable rows are omitted rather than failing the read.',
|
||||
'v1.21.3 (B1) — returns the durable plans the Coach has proposed for the caller, newest first, each decrypted on the fly. A plan is an "if-then" implementation intention tied to one metric, with an optional target. The Coach extractor writes a plan as `proposed`; only `PATCH /api/coach/plans/{id}` activates it. Pass `?status=` to filter to one lifecycle status, or `?scope=` for a named group (open = proposed + active + review_due, past = met + abandoned + reviewed, all = every non-deleted plan) — mutually exclusive. Both omitted returns the non-terminal set (proposed + active). Coach-gated (`requireModuleEnabled("coach")`); a disabled surface 403s. Auth via cookie or Bearer; the owner is always narrowed from the session, never the body. Undecryptable rows are omitted rather than failing the read.',
|
||||
parameters: [
|
||||
{
|
||||
name: "status",
|
||||
@@ -1029,7 +1036,15 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
required: false,
|
||||
schema: { type: "string", enum: [...COACH_PLAN_STATUSES] },
|
||||
description:
|
||||
"Filter to a single lifecycle status. Omit for the non-terminal set (proposed + active).",
|
||||
"Filter to a single lifecycle status. Mutually exclusive with `scope`. Omit both for the non-terminal set (proposed + active).",
|
||||
},
|
||||
{
|
||||
name: "scope",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: { type: "string", enum: [...COACH_PLAN_SCOPES] },
|
||||
description:
|
||||
"Named status group: open (proposed + active + review_due), past (met + abandoned + reviewed), or all. Mutually exclusive with `status`.",
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
|
||||
@@ -42,4 +42,14 @@ export const coachKeys = {
|
||||
["coach-reminders", status ?? "all"] as const,
|
||||
/** Prefix key — invalidates every `coachReminders(status)` slot at once. */
|
||||
coachRemindersAll: () => ["coach-reminders"] as const,
|
||||
/**
|
||||
* v1.27.x — the user's Coach goal / if-then plans (`GET /api/coach/plans`).
|
||||
* The `filter` arg keys the chat thread's proposal read (`status:proposed`)
|
||||
* separately from the management page's ledger read (`scope:all`) so the
|
||||
* two never share a cache slot.
|
||||
*/
|
||||
coachPlans: (filter?: string) =>
|
||||
["coach-plans", filter ?? "default"] as const,
|
||||
/** Prefix key — invalidates every `coachPlans(filter)` slot at once. */
|
||||
coachPlansAll: () => ["coach-plans"] as const,
|
||||
};
|
||||
@@ -17,10 +17,26 @@ import { z } from "zod/v4";
|
||||
|
||||
import { COACH_PLAN_STATUSES } from "@/lib/ai/coach/plans";
|
||||
|
||||
/** Optional `?status=` filter on the list endpoint. */
|
||||
export const coachPlansListQuerySchema = z.object({
|
||||
status: z.enum(COACH_PLAN_STATUSES).optional(),
|
||||
});
|
||||
/**
|
||||
* Optional `?scope=` filter on the list endpoint — a named status group so
|
||||
* the plans management surface can pull its whole ledger in one read:
|
||||
* - `open`: proposed + active + review_due (everything still standing)
|
||||
* - `past`: met + abandoned + reviewed (the settled history)
|
||||
* - `all`: every non-deleted plan
|
||||
* Additive next to `?status=`; the two are mutually exclusive per request.
|
||||
*/
|
||||
export const COACH_PLAN_SCOPES = ["open", "past", "all"] as const;
|
||||
|
||||
/** Optional `?status=` / `?scope=` filters on the list endpoint. */
|
||||
export const coachPlansListQuerySchema = z
|
||||
.object({
|
||||
status: z.enum(COACH_PLAN_STATUSES).optional(),
|
||||
scope: z.enum(COACH_PLAN_SCOPES).optional(),
|
||||
})
|
||||
.refine(
|
||||
(v) => !(v.status !== undefined && v.scope !== undefined),
|
||||
"Provide either status or scope, not both",
|
||||
);
|
||||
|
||||
/**
|
||||
* The user-facing PATCH body. `status` is the only mutable lifecycle field; a
|
||||
|
||||
Reference in new issue
Block a user