From d55a552d2e487fdccf686c909437719f5381109d Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Thu, 16 Apr 2026 12:40:40 +0200 Subject: [PATCH] add medications --- .claude/settings.local.json | 4 +- apps/api/src/app.module.ts | 12 +- .../health-tab/health-tab.controller.ts | 21 +++ .../modules/health-tab/health-tab.module.ts | 9 + .../modules/health-tab/health-tab.service.ts | 80 ++++++++ apps/pwa/src/routes/index.tsx | 173 +++++++++++++++++- .../db/migrations/0004_daily_checkins.sql | 10 + packages/db/migrations/meta/_journal.json | 7 + packages/db/src/schema/daily_checkins.ts | 12 ++ packages/db/src/schema/index.ts | 1 + packages/shared/src/index.ts | 18 ++ 11 files changed, 343 insertions(+), 4 deletions(-) create mode 100644 apps/api/src/modules/health-tab/health-tab.controller.ts create mode 100644 apps/api/src/modules/health-tab/health-tab.module.ts create mode 100644 apps/api/src/modules/health-tab/health-tab.service.ts create mode 100644 packages/db/migrations/0004_daily_checkins.sql create mode 100644 packages/db/src/schema/daily_checkins.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 1a74e06..5999219 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -11,7 +11,9 @@ "Bash(pnpm --filter @ordinarthur-os/shared typecheck)", "Bash(pnpm --filter @ordinarthur-os/api typecheck)", "Bash(pnpm --filter @ordinarthur-os/pwa typecheck)", - "Bash(pnpm -r typecheck)" + "Bash(pnpm -r typecheck)", + "Bash(pnpm --filter pwa build)", + "Bash(pnpm --filter api build)" ] } } diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 2fab59d..b721f1a 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -7,9 +7,19 @@ import { BearerMiddleware } from "./modules/auth/bearer.middleware"; import { JobsModule } from "./modules/jobs/jobs.module"; import { TodosModule } from "./modules/todos/todos.module"; import { AiModule } from "./modules/ai/ai.module"; +import { HealthTabModule } from "./modules/health-tab/health-tab.module"; @Module({ - imports: [ConfigModule, DbModule, HealthModule, AuthModule, JobsModule, TodosModule, AiModule], + imports: [ + ConfigModule, + DbModule, + HealthModule, + AuthModule, + JobsModule, + TodosModule, + AiModule, + HealthTabModule, + ], }) export class AppModule implements NestModule { configure(consumer: MiddlewareConsumer) { diff --git a/apps/api/src/modules/health-tab/health-tab.controller.ts b/apps/api/src/modules/health-tab/health-tab.controller.ts new file mode 100644 index 0000000..c207ecd --- /dev/null +++ b/apps/api/src/modules/health-tab/health-tab.controller.ts @@ -0,0 +1,21 @@ +import { Body, Controller, Get, Post } from "@nestjs/common"; +import { DailyCheckinToggleRequest } from "@ordinarthur-os/shared"; +import { ZodPipe } from "@/lib/zod-pipe"; +import { HealthTabService } from "./health-tab.service"; + +@Controller("health-tab") +export class HealthTabController { + constructor(private readonly health: HealthTabService) {} + + @Get("today") + today() { + return this.health.today(); + } + + @Post("today/toggle") + toggle( + @Body(new ZodPipe(DailyCheckinToggleRequest)) body: DailyCheckinToggleRequest, + ) { + return this.health.toggle(body); + } +} diff --git a/apps/api/src/modules/health-tab/health-tab.module.ts b/apps/api/src/modules/health-tab/health-tab.module.ts new file mode 100644 index 0000000..63bbbe8 --- /dev/null +++ b/apps/api/src/modules/health-tab/health-tab.module.ts @@ -0,0 +1,9 @@ +import { Module } from "@nestjs/common"; +import { HealthTabController } from "./health-tab.controller"; +import { HealthTabService } from "./health-tab.service"; + +@Module({ + controllers: [HealthTabController], + providers: [HealthTabService], +}) +export class HealthTabModule {} diff --git a/apps/api/src/modules/health-tab/health-tab.service.ts b/apps/api/src/modules/health-tab/health-tab.service.ts new file mode 100644 index 0000000..24c4fdd --- /dev/null +++ b/apps/api/src/modules/health-tab/health-tab.service.ts @@ -0,0 +1,80 @@ +import { Injectable } from "@nestjs/common"; +import { schema } from "@ordinarthur-os/db"; +import type { DailyCheckin, DailyCheckinToggleRequest } from "@ordinarthur-os/shared"; +import { eq } from "drizzle-orm"; +import { InjectDb, type Db } from "@/db/db.module"; + +const { dailyCheckins } = schema; + +/** + * `day` est stocké en `date` (pas timestamptz) — la clé de regroupement + * doit donc être calculée dans le fuseau d'Arthur (Europe/Paris) pour que + * "aujourd'hui" ne flippe pas à 02h du matin UTC. + */ +function todayInParis(): string { + // fr-CA → YYYY-MM-DD + return new Date().toLocaleDateString("fr-CA", { timeZone: "Europe/Paris" }); +} + +@Injectable() +export class HealthTabService { + constructor(@InjectDb() private readonly db: Db) {} + + async today(): Promise { + const day = todayInParis(); + const [row] = await this.db + .select() + .from(dailyCheckins) + .where(eq(dailyCheckins.day, day)); + + if (row) return rowToCheckin(row); + + // Pas encore de ligne → renvoyer l'état par défaut, sans insérer + // tant qu'Arthur ne coche rien. + return { + day, + meds_taken: false, + note: null, + updated_at: new Date().toISOString(), + }; + } + + /** + * Upsert sur la ligne du jour. Sans `meds_taken` explicite, flip la valeur. + */ + async toggle(patch: DailyCheckinToggleRequest): Promise { + const day = todayInParis(); + const existing = await this.db + .select() + .from(dailyCheckins) + .where(eq(dailyCheckins.day, day)); + + const currentMeds = existing[0]?.medsTaken ?? false; + const currentNote = existing[0]?.note ?? null; + + const nextMeds = patch.meds_taken ?? !currentMeds; + 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 }) + .onConflictDoUpdate({ + target: dailyCheckins.day, + set: { medsTaken: nextMeds, note: nextNote, updatedAt: now }, + }) + .returning(); + + if (!row) throw new Error("daily_checkin upsert returned no row"); + return rowToCheckin(row); + } +} + +function rowToCheckin(row: typeof dailyCheckins.$inferSelect): DailyCheckin { + return { + day: row.day, + meds_taken: row.medsTaken, + note: row.note ?? null, + updated_at: row.updatedAt.toISOString(), + }; +} diff --git a/apps/pwa/src/routes/index.tsx b/apps/pwa/src/routes/index.tsx index ec86437..c3c0d45 100644 --- a/apps/pwa/src/routes/index.tsx +++ b/apps/pwa/src/routes/index.tsx @@ -1,22 +1,168 @@ import { createFileRoute, Link } from "@tanstack/react-router"; -import { useQuery } from "@tanstack/react-query"; -import type { Todo } from "@ordinarthur-os/shared"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useRef, useState, useEffect } from "react"; +import type { Todo, DailyCheckin } from "@ordinarthur-os/shared"; import { api } from "@/api/client"; import { BigHeading, Label } from "@/design"; import { MagicButton } from "@/components/ai/MagicButton"; export const Route = createFileRoute("/")({ component: Dashboard }); +// --------------------------------------------------------------------------- +// Slide-to-confirm — inspiré du "slide to unlock" iOS, style Swiss brutalist +// --------------------------------------------------------------------------- + +const THUMB_W = 64; // px + +function MedsSlider({ + medsTaken, + onToggle, + disabled, +}: { + medsTaken: boolean; + onToggle: () => void; + disabled: boolean; +}) { + const trackRef = useRef(null); + const [dragX, setDragX] = useState(0); + const dragging = useRef(false); + const startX = useRef(0); + + // Reset thumb when state flips back to false + useEffect(() => { + if (!medsTaken) setDragX(0); + }, [medsTaken]); + + function maxX() { + return (trackRef.current?.clientWidth ?? 300) - THUMB_W; + } + + function onPointerDown(e: React.PointerEvent) { + if (disabled || medsTaken) return; + e.currentTarget.setPointerCapture(e.pointerId); + dragging.current = true; + startX.current = e.clientX - dragX; + } + + function onPointerMove(e: React.PointerEvent) { + if (!dragging.current) return; + const x = Math.max(0, Math.min(e.clientX - startX.current, maxX())); + setDragX(x); + } + + function onPointerUp() { + if (!dragging.current) return; + dragging.current = false; + if (dragX >= maxX() * 0.65) { + onToggle(); + } else { + setDragX(0); + } + } + + // Quand c'est pris : clic sur la piste entière pour annuler + function onTrackClick() { + if (medsTaken && !disabled) onToggle(); + } + + const progress = medsTaken ? 1 : dragX / (maxX() || 1); + const isDragging = dragX > 0; + + return ( +
+ {/* Fill de couleur qui suit la progression */} +
+ + {/* Texte centré dans la piste */} +
+ {!medsTaken && ( + + Glisse → médicaments pris + + )} + {medsTaken && ( + + ✓ Médicaments pris — tap pour annuler + + )} + {!medsTaken && progress > 0.3 && ( + + Lâche pour confirmer + + )} +
+ + {/* Thumb */} +
+ + {medsTaken ? "✓" : "→"} + +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Dashboard +// --------------------------------------------------------------------------- + function Dashboard() { + const qc = useQueryClient(); + const todos = useQuery({ queryKey: ["todos", false], queryFn: () => api("/todos"), }); + const checkin = useQuery({ + queryKey: ["health-tab", "today"], + queryFn: () => api("/health-tab/today"), + }); + + const toggleMeds = useMutation({ + mutationFn: () => + api("/health-tab/today/toggle", { method: "POST", body: "{}" }), + onSuccess: (data) => { + qc.setQueryData(["health-tab", "today"], data); + }, + }); + const active = (todos.data ?? []).filter( (t) => t.status !== "done" && t.status !== "archived", ); + const medsTaken = checkin.data?.meds_taken ?? false; + return (
@@ -34,6 +180,29 @@ function Dashboard() {
+ {/* ── Médocs ──────────────────────────────────────────────────── */} +
+
+ + + {checkin.isLoading ? "…" : medsTaken ? "Pris" : "Non pris"} + +
+ +
+ toggleMeds.mutate()} + disabled={toggleMeds.isPending || checkin.isLoading} + /> +
+
+ + {/* ── En cours ──────────────────────────────────────────────── */}
diff --git a/packages/db/migrations/0004_daily_checkins.sql b/packages/db/migrations/0004_daily_checkins.sql new file mode 100644 index 0000000..c21a6e4 --- /dev/null +++ b/packages/db/migrations/0004_daily_checkins.sql @@ -0,0 +1,10 @@ +-- 0004_daily_checkins.sql — Phase 7 (partiel, avancé pour le toggle médocs home) +-- 1 ligne par jour : médicaments pris + note libre. +set search_path to ordinarthur_os, public; + +create table if not exists ordinarthur_os.daily_checkins ( + day date primary key, + meds_taken boolean not null default false, + note text, + updated_at timestamptz not null default now() +); diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index f83f5b9..4135833 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1745020800000, "tag": "0003_ai_actions", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1745107200000, + "tag": "0004_daily_checkins", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/daily_checkins.ts b/packages/db/src/schema/daily_checkins.ts new file mode 100644 index 0000000..b951e68 --- /dev/null +++ b/packages/db/src/schema/daily_checkins.ts @@ -0,0 +1,12 @@ +import { boolean, date, text, timestamp } from "drizzle-orm/pg-core"; +import { appSchema } from "./_schema"; + +export const dailyCheckins = appSchema.table("daily_checkins", { + day: date("day").primaryKey(), + medsTaken: boolean("meds_taken").notNull().default(false), + note: text("note"), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export type DailyCheckinRow = typeof dailyCheckins.$inferSelect; +export type DailyCheckinInsert = typeof dailyCheckins.$inferInsert; diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 001bee2..29e4439 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -2,3 +2,4 @@ export { appSchema } from "./_schema"; export * from "./jobs"; export * from "./todos"; export * from "./ai_actions"; +export * from "./daily_checkins"; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0d83429..bcc8394 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -306,3 +306,21 @@ export const AiConfirmResponse = z.object({ results: z.array(AiActionResult), }); export type AiConfirmResponse = z.infer; + +// --------------------------------------------------------------------------- +// Phase 7 — Daily check-in (médocs + note) +// --------------------------------------------------------------------------- + +export const DailyCheckin = z.object({ + day: z.string(), // YYYY-MM-DD + meds_taken: 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(), + note: z.string().nullable().optional(), +}); +export type DailyCheckinToggleRequest = z.infer;