diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 303081d..550de37 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -314,7 +314,8 @@ create table google_oauth_tokens ( -- single-row pour Arthur -- ============================================================ create table daily_checkins ( day date primary key, - meds_taken boolean default false, + meds_morning boolean default false, -- prise du matin + meds_evening boolean default false, -- prise du soir note text, updated_at timestamptz default now() ); @@ -395,8 +396,8 @@ POST /agenda/google/sync GET /agenda/ical/:secret.ics → text/calendar # HEALTH -GET /health-tab/today { day, meds_taken, note } -POST /health-tab/today/toggle → flips meds_taken, returns row +GET /health-tab/today { day, meds_morning, meds_evening, note } +POST /health-tab/today/toggle { slot?: 'morning'|'evening', value?: bool, note? } → row mise à jour GET /health-tab/history?days=30 # AI @@ -420,7 +421,7 @@ type ProposedAction = | { fn: 'add_project_idea', args: { project_id, content } } | { fn: 'add_project_step', args: { project_id, title, status? } } | { fn: 'create_calendar_event', args: { title, starts_at, ends_at, location?, description? } } - | { fn: 'toggle_daily_checkin', args: { note? } }; + | { fn: 'toggle_daily_checkin', args: { slot?: 'morning'|'evening'|'both', note? } }; ``` Flow garanti : l'API **ne jamais** exécute une action directement. Elle renvoie un `ProposedAction[]` à la PWA, qui affiche une modal de confirmation. Seul `/ai/command/confirm` écrit en DB. diff --git a/apps/api/src/modules/ai/ai.service.ts b/apps/api/src/modules/ai/ai.service.ts index a0d38e7..b39293d 100644 --- a/apps/api/src/modules/ai/ai.service.ts +++ b/apps/api/src/modules/ai/ai.service.ts @@ -231,20 +231,50 @@ export class AiService { case "toggle_daily_checkin": { const today = new Date().toLocaleDateString("fr-CA", { timeZone: "Europe/Paris" }); + const slot = action.args.slot ?? "both"; + + // INSERT : on part de false/false puis on coche le(s) slot(s) concerné(s). + const insertMorning = slot === "morning" || slot === "both"; + const insertEvening = slot === "evening" || slot === "both"; + + // UPDATE on conflict : + // - slot "both" → force les 2 à true (sémantique "j'ai tout pris") + // - slot spécifique → flip uniquement ce slot + const updateSet = + slot === "both" + ? { + medsMorning: sql`true`, + medsEvening: sql`true`, + note: action.args.note ?? null, + updatedAt: sql`now()`, + } + : slot === "morning" + ? { + medsMorning: sql`NOT ${dailyCheckins.medsMorning}`, + note: action.args.note ?? null, + updatedAt: sql`now()`, + } + : { + medsEvening: sql`NOT ${dailyCheckins.medsEvening}`, + note: action.args.note ?? null, + updatedAt: sql`now()`, + }; + const [row] = await this.db .insert(dailyCheckins) - .values({ day: today, medsTaken: true, note: action.args.note ?? null }) + .values({ + day: today, + medsMorning: insertMorning, + medsEvening: insertEvening, + note: action.args.note ?? null, + }) .onConflictDoUpdate({ target: dailyCheckins.day, - set: { - medsTaken: sql`NOT ${dailyCheckins.medsTaken}`, - note: action.args.note ?? null, - updatedAt: sql`now()`, - }, + set: updateSet, }) .returning(); if (!row) throw new Error("Upsert daily_checkin: aucune ligne renvoyée"); - return { day: row.day, meds_taken: row.medsTaken }; + return { day: row.day, meds_morning: row.medsMorning, meds_evening: row.medsEvening }; } } } diff --git a/apps/api/src/modules/ai/mistral.client.ts b/apps/api/src/modules/ai/mistral.client.ts index c9ce5c8..fe1209d 100644 --- a/apps/api/src/modules/ai/mistral.client.ts +++ b/apps/api/src/modules/ai/mistral.client.ts @@ -95,10 +95,16 @@ export const MISTRAL_TOOLS = [ function: { name: "toggle_daily_checkin", description: - "Marque le check-in quotidien d'Arthur (médocs pris, état du jour). À utiliser sur 'j'ai pris mes médocs', 'check in'.", + "Marque le check-in quotidien d'Arthur (médocs pris matin/soir, état du jour). Arthur doit prendre ses médicaments une fois le matin et une fois le soir — précise `slot` si c'est spécifiquement la prise du matin ou celle du soir.", parameters: { type: "object", properties: { + slot: { + type: "string", + enum: ["morning", "evening", "both"], + description: + "Quelle prise valider. 'morning' = médocs du matin, 'evening' = médocs du soir, 'both' = les deux (si ambigu / formulation globale). Défaut : 'both'.", + }, note: { type: "string" }, }, }, @@ -174,7 +180,7 @@ const SYSTEM_PROMPT = `Tu es l'assistant personnel d'Arthur dans ordinarthur-os. ## Autres règles - Formulations déclaratives d'agenda : "j'ai rendez-vous", "je dois aller", "j'ai un rdv", "dans mon agenda" → create_calendar_event. -- "j'ai pris mes médocs", "médicaments pris" → toggle_daily_checkin. +- "j'ai pris mes médocs", "médicaments pris" → toggle_daily_checkin. Arthur prend ses médocs matin + soir : si la phrase précise le moment ("du matin", "ce matin", "du soir", "ce soir"), pose slot en conséquence ('morning' ou 'evening') ; sinon laisse 'both'. - Pour create_calendar_event : si la durée n'est pas précisée, suppose 1h. - Dates : convertis les expressions relatives ('demain', 'vendredi soir', 'dans 2h') en ISO 8601 avec l'offset Europe/Paris (+02:00 en été, +01:00 en hiver). Ex : "2024-04-16T17:40:00+02:00". Ne jamais utiliser UTC si l'utilisateur parle d'une heure locale.`; diff --git a/apps/api/src/modules/health-tab/health-tab.service.ts b/apps/api/src/modules/health-tab/health-tab.service.ts index 24c4fdd..514ca81 100644 --- a/apps/api/src/modules/health-tab/health-tab.service.ts +++ b/apps/api/src/modules/health-tab/health-tab.service.ts @@ -33,14 +33,17 @@ export class HealthTabService { // tant qu'Arthur ne coche rien. return { day, - meds_taken: false, + meds_morning: false, + meds_evening: false, note: null, updated_at: new Date().toISOString(), }; } /** - * Upsert sur la ligne du jour. Sans `meds_taken` explicite, flip la valeur. + * Upsert sur la ligne du jour. + * - Avec `slot` : flip (ou set à `value`) uniquement cette prise. + * - Sans `slot` : met à jour uniquement la note (rétro-compat / future extension). */ async toggle(patch: DailyCheckinToggleRequest): Promise { const day = todayInParis(); @@ -49,19 +52,37 @@ export class HealthTabService { .from(dailyCheckins) .where(eq(dailyCheckins.day, day)); - const currentMeds = existing[0]?.medsTaken ?? false; + const currentMorning = existing[0]?.medsMorning ?? false; + const currentEvening = existing[0]?.medsEvening ?? false; const currentNote = existing[0]?.note ?? null; - const nextMeds = patch.meds_taken ?? !currentMeds; + let nextMorning = currentMorning; + let nextEvening = currentEvening; + if (patch.slot === "morning") { + nextMorning = patch.value ?? !currentMorning; + } else if (patch.slot === "evening") { + nextEvening = patch.value ?? !currentEvening; + } const nextNote = patch.note !== undefined ? patch.note : currentNote; const now = new Date(); const [row] = await this.db .insert(dailyCheckins) - .values({ day, medsTaken: nextMeds, note: nextNote, updatedAt: now }) + .values({ + day, + medsMorning: nextMorning, + medsEvening: nextEvening, + note: nextNote, + updatedAt: now, + }) .onConflictDoUpdate({ target: dailyCheckins.day, - set: { medsTaken: nextMeds, note: nextNote, updatedAt: now }, + set: { + medsMorning: nextMorning, + medsEvening: nextEvening, + note: nextNote, + updatedAt: now, + }, }) .returning(); @@ -73,7 +94,8 @@ export class HealthTabService { function rowToCheckin(row: typeof dailyCheckins.$inferSelect): DailyCheckin { return { day: row.day, - meds_taken: row.medsTaken, + meds_morning: row.medsMorning, + meds_evening: row.medsEvening, note: row.note ?? null, updated_at: row.updatedAt.toISOString(), }; diff --git a/apps/pwa/src/components/ai/VoiceConfirmModal.tsx b/apps/pwa/src/components/ai/VoiceConfirmModal.tsx index fe4f8df..43ec352 100644 --- a/apps/pwa/src/components/ai/VoiceConfirmModal.tsx +++ b/apps/pwa/src/components/ai/VoiceConfirmModal.tsx @@ -280,8 +280,14 @@ function summarize(action: ProposedAction): string { return `${action.args.title}${action.args.status ? ` · ${action.args.status}` : ""}`; case "create_calendar_event": return `${action.args.title} · ${formatDatetime(action.args.starts_at)} → ${formatTime(action.args.ends_at)}`; - case "toggle_daily_checkin": - return action.args.note ?? "Check-in du jour"; + case "toggle_daily_checkin": { + const slot = action.args.slot ?? "both"; + const slotLabel = + slot === "morning" ? "médocs matin" + : slot === "evening" ? "médocs soir" + : "médocs (matin + soir)"; + return action.args.note ? `${slotLabel} · ${action.args.note}` : slotLabel; + } case "capture_idea": return action.args.content; } diff --git a/apps/pwa/src/components/health/MedsSection.tsx b/apps/pwa/src/components/health/MedsSection.tsx index dd73581..1d91f10 100644 --- a/apps/pwa/src/components/health/MedsSection.tsx +++ b/apps/pwa/src/components/health/MedsSection.tsx @@ -1,5 +1,5 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import type { DailyCheckin } from "@ordinarthur-os/shared"; +import type { DailyCheckin, MedsSlot } from "@ordinarthur-os/shared"; import { api } from "@/api/client"; import { Label } from "@/design"; import { MedsSlider } from "./MedsSlider"; @@ -13,27 +13,60 @@ export function MedsSection() { }); const toggle = useMutation({ - mutationFn: () => - api("/health-tab/today/toggle", { method: "POST", body: "{}" }), + mutationFn: (slot: MedsSlot) => + api("/health-tab/today/toggle", { + method: "POST", + body: JSON.stringify({ slot }), + }), onSuccess: (updated) => qc.setQueryData(["health-tab", "today"], updated), }); - const medsTaken = data?.meds_taken ?? false; + const morningTaken = data?.meds_morning ?? false; + const eveningTaken = data?.meds_evening ?? false; + const bothTaken = morningTaken && eveningTaken; + const pendingSlot = toggle.isPending ? toggle.variables : null; + + const statusText = isLoading + ? "…" + : bothTaken + ? "Matin + soir pris" + : morningTaken + ? "Matin pris · soir en attente" + : eveningTaken + ? "Soir pris · matin en attente" + : "Non pris"; return (
- - {isLoading ? "…" : medsTaken ? "Pris" : "Non pris"} + + {statusText}
- toggle.mutate()} - disabled={toggle.isPending || isLoading} - /> +
+
+ + toggle.mutate("morning")} + disabled={(toggle.isPending && pendingSlot === "morning") || isLoading} + slotLabel="matin" + /> +
+
+ + toggle.mutate("evening")} + disabled={(toggle.isPending && pendingSlot === "evening") || isLoading} + slotLabel="soir" + /> +
+
); } diff --git a/apps/pwa/src/components/health/MedsSlider.tsx b/apps/pwa/src/components/health/MedsSlider.tsx index fef2654..89d93ad 100644 --- a/apps/pwa/src/components/health/MedsSlider.tsx +++ b/apps/pwa/src/components/health/MedsSlider.tsx @@ -6,9 +6,10 @@ interface Props { medsTaken: boolean; onToggle: () => void; disabled?: boolean; + slotLabel?: string; // ex: "matin" / "soir" — utilisé dans les textes du slider } -export function MedsSlider({ medsTaken, onToggle, disabled = false }: Props) { +export function MedsSlider({ medsTaken, onToggle, disabled = false, slotLabel }: Props) { const trackRef = useRef(null); const [dragX, setDragX] = useState(0); const dragging = useRef(false); @@ -68,7 +69,7 @@ export function MedsSlider({ medsTaken, onToggle, disabled = false }: Props) { className="font-mono text-[11px] uppercase tracking-label text-ink" style={{ opacity: Math.max(0, 1 - progress * 2.5) }} > - Glisse → médicaments pris + Glisse → médocs {slotLabel ?? "pris"} )} {!medsTaken && progress > 0.3 && ( @@ -81,7 +82,7 @@ export function MedsSlider({ medsTaken, onToggle, disabled = false }: Props) { )} {medsTaken && ( - ✓ Médicaments pris — tap pour annuler + ✓ Médocs {slotLabel ?? ""} pris — tap pour annuler )} diff --git a/packages/db/migrations/0008_meds_morning_evening.sql b/packages/db/migrations/0008_meds_morning_evening.sql new file mode 100644 index 0000000..c632557 --- /dev/null +++ b/packages/db/migrations/0008_meds_morning_evening.sql @@ -0,0 +1,15 @@ +-- 0008_meds_morning_evening.sql — split meds_taken en matin / soir +set search_path to ordinarthur_os, public; + +alter table ordinarthur_os.daily_checkins + add column if not exists meds_morning boolean not null default false, + add column if not exists meds_evening boolean not null default false; + +-- Migrer l'ancien meds_taken : s'il était true on coche les 2 prises, +-- sinon on reste par défaut à false/false. +update ordinarthur_os.daily_checkins +set meds_morning = true, meds_evening = true +where meds_taken = true; + +alter table ordinarthur_os.daily_checkins + drop column if exists meds_taken; diff --git a/packages/db/src/schema/daily_checkins.ts b/packages/db/src/schema/daily_checkins.ts index b951e68..1eea999 100644 --- a/packages/db/src/schema/daily_checkins.ts +++ b/packages/db/src/schema/daily_checkins.ts @@ -3,7 +3,8 @@ import { appSchema } from "./_schema"; export const dailyCheckins = appSchema.table("daily_checkins", { day: date("day").primaryKey(), - medsTaken: boolean("meds_taken").notNull().default(false), + medsMorning: boolean("meds_morning").notNull().default(false), + medsEvening: boolean("meds_evening").notNull().default(false), note: text("note"), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index a54fe5e..78cda14 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -328,6 +328,7 @@ export const ProposedAction = z.discriminatedUnion("fn", [ z.object({ fn: z.literal("toggle_daily_checkin"), args: z.object({ + slot: z.enum(["morning", "evening", "both"]).optional(), note: z.string().optional(), }), }), @@ -437,16 +438,21 @@ export type GoogleSyncResponse = z.infer; // Phase 7 — Daily check-in (médocs + note) // --------------------------------------------------------------------------- +export const MedsSlot = z.enum(["morning", "evening"]); +export type MedsSlot = z.infer; + export const DailyCheckin = z.object({ day: z.string(), // YYYY-MM-DD - meds_taken: z.boolean(), + meds_morning: z.boolean(), + meds_evening: z.boolean(), note: z.string().nullable(), updated_at: z.string(), }); export type DailyCheckin = z.infer; export const DailyCheckinToggleRequest = z.object({ - meds_taken: z.boolean().optional(), + slot: MedsSlot.optional(), // si absent : bascule les deux slots (note-only) + value: z.boolean().optional(), // si absent : flip la valeur actuelle du slot note: z.string().nullable().optional(), }); export type DailyCheckinToggleRequest = z.infer;