add components
This commit is contained in:
parent
d55a552d2e
commit
22e5ed1a15
39
apps/pwa/src/components/health/MedsSection.tsx
Normal file
39
apps/pwa/src/components/health/MedsSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
apps/pwa/src/components/health/MedsSlider.tsx
Normal file
108
apps/pwa/src/components/health/MedsSlider.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,168 +1,23 @@
|
|||||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useRef, useState, useEffect } from "react";
|
import type { Todo } from "@ordinarthur-os/shared";
|
||||||
import type { Todo, DailyCheckin } from "@ordinarthur-os/shared";
|
|
||||||
import { api } from "@/api/client";
|
import { api } from "@/api/client";
|
||||||
import { BigHeading, Label } from "@/design";
|
import { BigHeading, Label } from "@/design";
|
||||||
import { MagicButton } from "@/components/ai/MagicButton";
|
import { MagicButton } from "@/components/ai/MagicButton";
|
||||||
|
import { MedsSection } from "@/components/health/MedsSection";
|
||||||
|
|
||||||
export const Route = createFileRoute("/")({ component: Dashboard });
|
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() {
|
function Dashboard() {
|
||||||
const qc = useQueryClient();
|
const { data, isLoading, isError } = useQuery({
|
||||||
|
|
||||||
const todos = useQuery({
|
|
||||||
queryKey: ["todos", false],
|
queryKey: ["todos", false],
|
||||||
queryFn: () => api<Todo[]>("/todos"),
|
queryFn: () => api<Todo[]>("/todos"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const checkin = useQuery({
|
const active = (data ?? []).filter(
|
||||||
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",
|
(t) => t.status !== "done" && t.status !== "archived",
|
||||||
);
|
);
|
||||||
|
|
||||||
const medsTaken = checkin.data?.meds_taken ?? false;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-16">
|
<div className="space-y-16">
|
||||||
<section className="space-y-6">
|
<section className="space-y-6">
|
||||||
@ -180,29 +35,8 @@ function Dashboard() {
|
|||||||
<MagicButton />
|
<MagicButton />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── Médocs ──────────────────────────────────────────────────── */}
|
<MedsSection />
|
||||||
<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">
|
<section className="space-y-4">
|
||||||
<div className="flex items-center justify-between border-b border-ink pb-3">
|
<div className="flex items-center justify-between border-b border-ink pb-3">
|
||||||
<Label prefix="[ 01 ]">EN COURS</Label>
|
<Label prefix="[ 01 ]">EN COURS</Label>
|
||||||
@ -214,9 +48,9 @@ function Dashboard() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{todos.isLoading ? (
|
{isLoading ? (
|
||||||
<EmptyRow text="Chargement…" />
|
<EmptyRow text="Chargement…" />
|
||||||
) : todos.isError ? (
|
) : isError ? (
|
||||||
<EmptyRow text="Impossible de charger les todos. Vérifie le bearer token." />
|
<EmptyRow text="Impossible de charger les todos. Vérifie le bearer token." />
|
||||||
) : active.length === 0 ? (
|
) : active.length === 0 ? (
|
||||||
<EmptyRow text="Aucune tâche en cours. Dicte-en une." />
|
<EmptyRow text="Aucune tâche en cours. Dicte-en une." />
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user