Files
minabot/src/components/HabitHistory.tsx
2026-09-04 18:56:05 +02:00

209 lines
6.6 KiB
TypeScript

import { useEffect, useRef, useState, type ReactNode } from "react";
import type { CalendarResponse } from "../shared/calendar";
import { habitRequest, scheduleLabel, type TodayHabit } from "../lib/dashboard";
import { CalendarHeatmap } from "./design-system/CalendarHeatmap";
import { monthWindow } from "./design-system/calendar-model";
import { Button } from "./design-system/primitives";
import { HabitChart } from "./design-system/HabitChart";
import { CalendarLegend } from "./design-system/CalendarLegend";
import { shade } from "../habits/calendar";
import { CheckInEditor } from "./CheckInEditor";
import { Field } from "./design-system/Field";
import { ItemActions } from "./design-system/ItemActions";
export function HabitHistory({
habit,
date,
revision,
onEdit,
onDelete,
disabled = false,
children,
tasks,
editor,
onExpired,
onHistorySaved,
calendarOnly = false,
}: {
habit: TodayHabit;
date: string;
revision: number;
onEdit?: (color: string) => void;
onDelete?: () => void;
disabled?: boolean;
children?: ReactNode;
tasks?: ReactNode;
editor?: ReactNode;
onExpired?: () => void;
onHistorySaved?: () => void;
calendarOnly?: boolean;
}) {
const [selectedDate, setSelectedDate] = useState<string>();
const [editingDay, setEditingDay] = useState(false);
const [calendar, setCalendar] = useState<CalendarResponse | null>(null);
const [error, setError] = useState("");
const [attempt, setAttempt] = useState(0);
const expired = useRef(onExpired);
expired.current = onExpired;
useEffect(() => {
const controller = new AbortController();
setError("");
const range = monthWindow(date, 12);
habitRequest<CalendarResponse>(
`/habits/${habit.habitId}/calendar?${new URLSearchParams(range)}`,
{ signal: controller.signal },
)
.then((result) => {
if (!controller.signal.aborted) setCalendar(result);
})
.catch((error) => {
if (controller.signal.aborted) return;
if (error.message.includes("session has expired") && expired.current)
expired.current();
else setError(error.message);
});
return () => controller.abort();
}, [habit.habitId, date, revision, attempt]);
const days =
calendar?.days.map((day) => ({
date: day.date,
color: day.color,
unit: day.habits[0]?.unit,
value: day.habits[0]?.value ?? 0,
target: day.habits[0]?.target ?? 0,
state: day.future
? ("future" as const)
: day.due
? ("due" as const)
: ("not-due" as const),
})) ?? [];
const steps = Math.max(
1,
calendar?.days.find((day) => day.date === date)?.shadeCount ??
habit.target ??
1,
);
const shades = calendar
? [
calendar.settings.emptyColor,
...Array.from(
{ length: Math.min(8, steps) },
(_, index) =>
shade(
Math.ceil(((index + 1) * steps) / Math.min(8, steps)) / steps,
steps,
calendar.settings,
true,
).color,
),
]
: [];
const chart = error ? (
<div className="ds-form-feedback">
<p role="alert">{error}</p>
<Button
variant="secondary"
onClick={() => setAttempt((value) => value + 1)}
>
Retry history
</Button>
</div>
) : !calendar ? (
<p className="ds-form-feedback" role="status">
Loading your habit history
</p>
) : (
<>
<CalendarHeatmap
onSelectDate={setSelectedDate}
selectedDate={selectedDate}
compact
historyLabel="Your recorded progress"
label={`${habit.name} progress calendar`}
unit={habit.unit}
color={calendar.settings.mainColor}
emptyColor={calendar.settings.emptyColor}
days={days}
legend={
<CalendarLegend
label={`${habit.name} progress calendar`}
days={days}
color={calendar.settings.mainColor}
shades={shades}
/>
}
/>
<div className="ds-action-row ds-history-actions">
<Button variant="text" disabled={disabled} onClick={() => { setSelectedDate(selectedDate ?? date); setEditingDay(!editingDay); }} aria-expanded={editingDay}>
{editingDay ? "Close check-in editor" : "Edit selected check-in"}
</Button>
</div>
{editingDay && <div className="ds-history-editor">
<div className="ds-form-stack"><Field label="Check-in date">{id => <input id={id} type="date" max={date} value={selectedDate ?? date} onChange={event => { if (event.target.value) setSelectedDate(event.target.value); }} />}</Field><p className="ds-supporting-copy">Update the progress recorded for this day.</p></div>
<CheckInEditor key={`${habit.habitId}:${selectedDate ?? date}`} habitId={habit.habitId} date={selectedDate ?? date} disabled={disabled} onExpired={onExpired}
onSaved={() => { setAttempt(value => value + 1); onHistorySaved?.(); }} />
</div>}
</>
);
if (calendarOnly) return chart;
return (
<HabitChart
id={`history-${habit.habitId}`}
name={habit.name ?? "Habit"}
method={
habit.method === "count"
? "Count target"
: habit.method === "tasks"
? "Task-based"
: "Simple check-in"
}
schedule={scheduleLabel(habit.requirements?.schedule)}
due={habit.due}
value={habit.value}
target={habit.target ?? 0}
unit={habit.unit}
color={calendar?.settings.mainColor ?? "#196127"}
calendar={chart}
tasks={tasks}
tasksLabel="Tasks"
headingLevel={3}
editor={editor}
headingActions={onEdit && onDelete ? (
<ItemActions name={habit.name ?? "Habit"} kind="habit" disabled={disabled || !calendar}
onEdit={() => onEdit(calendar!.settings.mainColor)} onDelete={onDelete} />
) : undefined}
>
{children}
{(onEdit || onDelete) && !(onEdit && onDelete) && (
<div
className="ds-actions"
role="group"
aria-label={`${habit.name} actions`}
>
{onEdit && (
<Button
variant="text"
aria-label={`Edit ${habit.name}`}
disabled={disabled || !calendar}
onClick={() => onEdit(calendar!.settings.mainColor)}
>
Edit
</Button>
)}
{onDelete && (
<Button
variant="text"
aria-label={`Archive ${habit.name}`}
disabled={disabled}
onClick={onDelete}
>
Archive
</Button>
)}
</div>
)}
</HabitChart>
);
}