add components

This commit is contained in:
ordinarthur 2026-04-16 12:45:20 +02:00
parent d55a552d2e
commit 22e5ed1a15
3 changed files with 155 additions and 174 deletions

View File

@ -0,0 +1,39 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import type { DailyCheckin } from "@ordinarthur-os/shared";
import { api } from "@/api/client";
import { Label } from "@/design";
import { MedsSlider } from "./MedsSlider";
export function MedsSection() {
const qc = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ["health-tab", "today"],
queryFn: () => api<DailyCheckin>("/health-tab/today"),
});
const toggle = useMutation({
mutationFn: () =>
api<DailyCheckin>("/health-tab/today/toggle", { method: "POST", body: "{}" }),
onSuccess: (updated) => qc.setQueryData(["health-tab", "today"], updated),
});
const medsTaken = data?.meds_taken ?? false;
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>
</div>
<MedsSlider
medsTaken={medsTaken}
onToggle={() => toggle.mutate()}
disabled={toggle.isPending || isLoading}
/>
</section>
);
}

View File

@ -0,0 +1,108 @@
import { useRef, useState, useEffect } from "react";
const THUMB_W = 64;
interface Props {
medsTaken: boolean;
onToggle: () => void;
disabled?: boolean;
}
export function MedsSlider({ medsTaken, onToggle, disabled = false }: Props) {
const trackRef = useRef<HTMLDivElement>(null);
const [dragX, setDragX] = useState(0);
const dragging = useRef(false);
const startX = useRef(0);
useEffect(() => {
if (!medsTaken) setDragX(0);
}, [medsTaken]);
const maxX = () => (trackRef.current?.clientWidth ?? 300) - THUMB_W;
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (disabled || medsTaken) return;
e.currentTarget.setPointerCapture(e.pointerId);
dragging.current = true;
startX.current = e.clientX - dragX;
};
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (!dragging.current) return;
setDragX(Math.max(0, Math.min(e.clientX - startX.current, maxX())));
};
const onPointerUp = () => {
if (!dragging.current) return;
dragging.current = false;
if (dragX >= maxX() * 0.65) {
onToggle();
} else {
setDragX(0);
}
};
const progress = medsTaken ? 1 : dragX / (maxX() || 1);
const isActive = dragging.current;
return (
<div
ref={trackRef}
onClick={() => medsTaken && !disabled && onToggle()}
className="relative h-16 border border-ink overflow-hidden select-none"
style={{ cursor: medsTaken ? "pointer" : "default" }}
>
{/* Fill orange qui suit le thumb */}
<div
className="absolute inset-y-0 left-0 bg-accent"
style={{
width: `${progress * 100}%`,
transition: isActive ? "none" : "width 0.2s ease",
}}
/>
{/* Labels */}
<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"
style={{ opacity: Math.max(0, 1 - progress * 2.5) }}
>
Glisse médicaments pris
</span>
)}
{!medsTaken && progress > 0.3 && (
<span
className="absolute font-mono text-[11px] uppercase tracking-label text-bg"
style={{ opacity: Math.min(1, (progress - 0.3) * 3) }}
>
Lâche pour confirmer
</span>
)}
{medsTaken && (
<span className="font-mono text-[11px] uppercase tracking-label text-bg">
Médicaments pris tap pour annuler
</span>
)}
</div>
{/* Thumb */}
<div
className="absolute top-0 bottom-0 z-10 flex items-center justify-center bg-ink"
style={{
width: THUMB_W,
left: medsTaken ? `calc(100% - ${THUMB_W}px)` : dragX,
transition: isActive ? "none" : "left 0.2s ease",
cursor: disabled ? "not-allowed" : medsTaken ? "pointer" : "grab",
opacity: disabled && !medsTaken ? 0.4 : 1,
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
<span className="font-mono text-bg text-lg">{medsTaken ? "✓" : "→"}</span>
</div>
</div>
);
}

View File

@ -1,168 +1,23 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useRef, useState, useEffect } from "react";
import type { Todo, DailyCheckin } from "@ordinarthur-os/shared";
import { useQuery } from "@tanstack/react-query";
import type { Todo } from "@ordinarthur-os/shared";
import { api } from "@/api/client";
import { BigHeading, Label } from "@/design";
import { MagicButton } from "@/components/ai/MagicButton";
import { MedsSection } from "@/components/health/MedsSection";
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({
const { data, isLoading, isError } = 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(
const active = (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">
@ -180,29 +35,8 @@ 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>
<MedsSection />
<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>
@ -214,9 +48,9 @@ function Dashboard() {
</Link>
</div>
{todos.isLoading ? (
{isLoading ? (
<EmptyRow text="Chargement…" />
) : todos.isError ? (
) : isError ? (
<EmptyRow text="Impossible de charger les todos. Vérifie le bearer token." />
) : active.length === 0 ? (
<EmptyRow text="Aucune tâche en cours. Dicte-en une." />