split meds_taken into morning/evening doses
All checks were successful
Build & Deploy / deploy (push) Successful in 1m30s
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:
parent
810575221f
commit
a1eeca1236
@ -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.
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.`;
|
||||
|
||||
|
||||
@ -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(),
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
15
packages/db/migrations/0008_meds_morning_evening.sql
Normal file
15
packages/db/migrations/0008_meds_morning_evening.sql
Normal 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;
|
||||
@ -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(),
|
||||
});
|
||||
|
||||
@ -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>;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user