add medications

This commit is contained in:
ordinarthur 2026-04-16 12:40:40 +02:00
parent b71d5c8f47
commit d55a552d2e
11 changed files with 343 additions and 4 deletions

View File

@ -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)"
]
}
}

View File

@ -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) {

View File

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

View File

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

View File

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

View File

@ -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<HTMLDivElement>(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<HTMLDivElement>) {
if (disabled || medsTaken) return;
e.currentTarget.setPointerCapture(e.pointerId);
dragging.current = true;
startX.current = e.clientX - dragX;
}
function onPointerMove(e: React.PointerEvent<HTMLDivElement>) {
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 (
<div
ref={trackRef}
onClick={onTrackClick}
className="relative h-16 border border-ink overflow-hidden select-none"
style={{ cursor: medsTaken ? "pointer" : "default" }}
>
{/* Fill de couleur qui suit la progression */}
<div
className="absolute inset-y-0 left-0 bg-accent transition-all duration-200"
style={{ width: `${progress * 100}%` }}
/>
{/* Texte centré dans la piste */}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
{!medsTaken && (
<span
className="font-mono text-[11px] uppercase tracking-label text-ink transition-opacity duration-150"
style={{ opacity: Math.max(0, 1 - progress * 2.5) }}
>
Glisse médicaments pris
</span>
)}
{medsTaken && (
<span className="font-mono text-[11px] uppercase tracking-label text-bg">
Médicaments pris tap pour annuler
</span>
)}
{!medsTaken && progress > 0.3 && (
<span
className="absolute font-mono text-[11px] uppercase tracking-label text-bg transition-opacity duration-150"
style={{ opacity: Math.min(1, (progress - 0.3) * 3) }}
>
Lâche pour confirmer
</span>
)}
</div>
{/* Thumb */}
<div
className="absolute top-0 bottom-0 flex items-center justify-center bg-ink z-10"
style={{
width: THUMB_W,
left: medsTaken ? `calc(100% - ${THUMB_W}px)` : dragX,
transition: isDragging || medsTaken ? "left 0.15s ease" : "left 0.2s ease",
cursor: medsTaken ? "pointer" : disabled ? "not-allowed" : "grab",
opacity: disabled && !medsTaken ? 0.4 : 1,
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
<span
className="font-mono text-bg text-lg select-none transition-transform duration-200"
style={{ transform: medsTaken ? "rotate(0deg)" : "rotate(0deg)" }}
>
{medsTaken ? "✓" : "→"}
</span>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Dashboard
// ---------------------------------------------------------------------------
function Dashboard() {
const qc = useQueryClient();
const todos = useQuery({
queryKey: ["todos", false],
queryFn: () => api<Todo[]>("/todos"),
});
const checkin = useQuery({
queryKey: ["health-tab", "today"],
queryFn: () => api<DailyCheckin>("/health-tab/today"),
});
const toggleMeds = useMutation({
mutationFn: () =>
api<DailyCheckin>("/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 (
<div className="space-y-16">
<section className="space-y-6">
@ -34,6 +180,29 @@ function Dashboard() {
<MagicButton />
</section>
{/* ── Médocs ──────────────────────────────────────────────────── */}
<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"
}`}
>
{checkin.isLoading ? "…" : medsTaken ? "Pris" : "Non pris"}
</span>
</div>
<div className="p-0">
<MedsSlider
medsTaken={medsTaken}
onToggle={() => toggleMeds.mutate()}
disabled={toggleMeds.isPending || checkin.isLoading}
/>
</div>
</section>
{/* ── En cours ──────────────────────────────────────────────── */}
<section className="space-y-4">
<div className="flex items-center justify-between border-b border-ink pb-3">
<Label prefix="[ 01 ]">EN COURS</Label>

View File

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

View File

@ -29,6 +29,13 @@
"when": 1745020800000,
"tag": "0003_ai_actions",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1745107200000,
"tag": "0004_daily_checkins",
"breakpoints": true
}
]
}

View File

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

View File

@ -2,3 +2,4 @@ export { appSchema } from "./_schema";
export * from "./jobs";
export * from "./todos";
export * from "./ai_actions";
export * from "./daily_checkins";

View File

@ -306,3 +306,21 @@ export const AiConfirmResponse = z.object({
results: z.array(AiActionResult),
});
export type AiConfirmResponse = z.infer<typeof AiConfirmResponse>;
// ---------------------------------------------------------------------------
// 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<typeof DailyCheckin>;
export const DailyCheckinToggleRequest = z.object({
meds_taken: z.boolean().optional(),
note: z.string().nullable().optional(),
});
export type DailyCheckinToggleRequest = z.infer<typeof DailyCheckinToggleRequest>;