split meds_taken into morning/evening doses
All checks were successful
Build & Deploy / deploy (push) Successful in 1m30s

Arthur has to take his meds once in the morning and once in the evening,
so the daily check-in now tracks both doses independently. The dashboard
shows two sliders (Matin / Soir), the API toggle accepts a slot, and the
AI toggle_daily_checkin function takes an optional slot argument so the
LLM can target the right dose when the user specifies a moment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-04-16 18:24:36 +02:00
parent 810575221f
commit a1eeca1236
10 changed files with 160 additions and 39 deletions

View File

@ -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.

View File

@ -231,20 +231,50 @@ export class AiService {
case "toggle_daily_checkin": {
const today = new Date().toLocaleDateString("fr-CA", { timeZone: "Europe/Paris" });
const [row] = await this.db
.insert(dailyCheckins)
.values({ day: today, medsTaken: true, note: action.args.note ?? null })
.onConflictDoUpdate({
target: dailyCheckins.day,
set: {
medsTaken: sql`NOT ${dailyCheckins.medsTaken}`,
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,
medsMorning: insertMorning,
medsEvening: insertEvening,
note: action.args.note ?? null,
})
.onConflictDoUpdate({
target: dailyCheckins.day,
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 };
}
}
}

View File

@ -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.`;

View File

@ -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<DailyCheckin> {
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(),
};

View File

@ -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;
}

View File

@ -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<DailyCheckin>("/health-tab/today/toggle", { method: "POST", body: "{}" }),
mutationFn: (slot: MedsSlot) =>
api<DailyCheckin>("/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 (
<section className="border border-ink">
<div className="flex items-center justify-between px-4 py-3 border-b border-ink">
<Label prefix="[ 02 ]">MÉDOCS DU JOUR</Label>
<span className={`font-mono text-[11px] uppercase tracking-label ${medsTaken ? "text-accent" : "text-muted"}`}>
{isLoading ? "…" : medsTaken ? "Pris" : "Non pris"}
<span
className={`font-mono text-[11px] uppercase tracking-label ${bothTaken ? "text-accent" : "text-muted"}`}
>
{statusText}
</span>
</div>
<div className="divide-y divide-ink">
<div className="px-4 py-3 space-y-2">
<Label className="block">MATIN</Label>
<MedsSlider
medsTaken={medsTaken}
onToggle={() => toggle.mutate()}
disabled={toggle.isPending || isLoading}
medsTaken={morningTaken}
onToggle={() => toggle.mutate("morning")}
disabled={(toggle.isPending && pendingSlot === "morning") || isLoading}
slotLabel="matin"
/>
</div>
<div className="px-4 py-3 space-y-2">
<Label className="block">SOIR</Label>
<MedsSlider
medsTaken={eveningTaken}
onToggle={() => toggle.mutate("evening")}
disabled={(toggle.isPending && pendingSlot === "evening") || isLoading}
slotLabel="soir"
/>
</div>
</div>
</section>
);
}

View File

@ -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<HTMLDivElement>(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"}
</span>
)}
{!medsTaken && progress > 0.3 && (
@ -81,7 +82,7 @@ export function MedsSlider({ medsTaken, onToggle, disabled = false }: Props) {
)}
{medsTaken && (
<span className="font-mono text-[11px] uppercase tracking-label text-bg">
Médicaments pris tap pour annuler
Médocs {slotLabel ?? ""} pris tap pour annuler
</span>
)}
</div>

View File

@ -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;

View File

@ -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(),
});

View File

@ -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<typeof GoogleSyncResponse>;
// Phase 7 — Daily check-in (médocs + note)
// ---------------------------------------------------------------------------
export const MedsSlot = z.enum(["morning", "evening"]);
export type MedsSlot = z.infer<typeof MedsSlot>;
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<typeof DailyCheckin>;
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<typeof DailyCheckinToggleRequest>;