add medications
This commit is contained in:
parent
b71d5c8f47
commit
d55a552d2e
@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
21
apps/api/src/modules/health-tab/health-tab.controller.ts
Normal file
21
apps/api/src/modules/health-tab/health-tab.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
9
apps/api/src/modules/health-tab/health-tab.module.ts
Normal file
9
apps/api/src/modules/health-tab/health-tab.module.ts
Normal 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 {}
|
||||
80
apps/api/src/modules/health-tab/health-tab.service.ts
Normal file
80
apps/api/src/modules/health-tab/health-tab.service.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
@ -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>
|
||||
|
||||
10
packages/db/migrations/0004_daily_checkins.sql
Normal file
10
packages/db/migrations/0004_daily_checkins.sql
Normal 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()
|
||||
);
|
||||
@ -29,6 +29,13 @@
|
||||
"when": 1745020800000,
|
||||
"tag": "0003_ai_actions",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1745107200000,
|
||||
"tag": "0004_daily_checkins",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
12
packages/db/src/schema/daily_checkins.ts
Normal file
12
packages/db/src/schema/daily_checkins.ts
Normal 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;
|
||||
@ -2,3 +2,4 @@ export { appSchema } from "./_schema";
|
||||
export * from "./jobs";
|
||||
export * from "./todos";
|
||||
export * from "./ai_actions";
|
||||
export * from "./daily_checkins";
|
||||
|
||||
@ -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>;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user