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 { 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." />
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user