209 lines
6.6 KiB
TypeScript
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>
|
|
);
|
|
}
|