feat: edit historical check-ins from habit calendars

This commit is contained in:
syntaxbullet
2026-09-04 18:20:38 +02:00
parent ecc2620a4c
commit e88ffceb22
5 changed files with 105 additions and 2 deletions

View File

@@ -0,0 +1,60 @@
import { useEffect, useRef, useState } from "react";
import { habitRequest, type TodayHabit } from "../lib/dashboard";
import { Button, Checkbox } from "./design-system/primitives";
import { Field } from "./design-system/Field";
export function CheckInEditor({ habitId, date, disabled, onSaved, onExpired }: {
habitId: string; date: string; disabled?: boolean; onSaved: () => void; onExpired?: () => void;
}) {
const [day, setDay] = useState<TodayHabit | null>(null);
const [count, setCount] = useState("");
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [busy, setBusy] = useState(false);
const [attempt, setAttempt] = useState(0);
const saving = useRef(false);
const expired = useRef(onExpired); expired.current = onExpired;
useEffect(() => {
const controller = new AbortController();
setDay(null); setError("");
habitRequest<TodayHabit>(`/habits/${habitId}/days/${date}`, { signal: controller.signal })
.then(value => { if (!controller.signal.aborted) { setDay(value); setCount(String(value.value)); } })
.catch(error => { if (!controller.signal.aborted) {
if (error.message.includes("session has expired")) expired.current?.();
setError(error.message);
} });
return () => controller.abort();
}, [habitId, date, attempt]);
async function save(body: { count: number } | { done: boolean }, taskId?: string) {
if (saving.current || disabled) return;
saving.current = true; setBusy(true); setError(""); setNotice("");
try {
const result = await habitRequest<TodayHabit>(`/habits/${habitId}/days/${date}/${taskId ? `tasks/${taskId}` : "progress"}`, {
method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
});
setDay(result); setCount(String(result.value)); setNotice(`Saved check-in for ${date}.`); onSaved();
} catch (error) {
const message = error instanceof Error ? error.message : "Could not save. Try again.";
setError(message); if (message.includes("session has expired")) expired.current?.();
} finally { saving.current = false; setBusy(false); }
}
return <div className="ds-inline-item-form" aria-label={`Check-in for ${date}`}>
<p className="type-ui-heading">Check-in · {date}</p>
{error && <p role="alert">{error}</p>}
{!day ? error ? <Button onClick={() => setAttempt(value => value + 1)}>Retry check-in</Button> : <p role="status">Loading check-in</p>
: day.future ? <p>Future check-ins cannot be edited.</p>
: !day.due ? <p>Nothing was scheduled for this date.</p>
: <>
<p className="type-small">{day.name} · {day.timezone}. Changes use the requirements saved for this date.</p>
{day.method === "count" ? <form onSubmit={event => { event.preventDefault(); void save({ count: Number(count) }); }}>
<Field label={`Count (${day.unit}, target ${day.target})`}>
{id => <input id={id} type="number" min={0} max={1000000000} step={1} required value={count} disabled={busy || disabled} onChange={event => setCount(event.target.value)} />}
</Field>
{day.carriedFrom && <p className="type-small">Includes carryover from {day.carriedFrom}. Saving sets this day's total.</p>}
<Button type="submit" disabled={busy || disabled}>{busy ? "Saving…" : "Save check-in"}</Button>
</form> : day.method === "manual" ? <Checkbox label="Completed on this date" checked={day.complete} disabled={busy || disabled} onChange={event => void save({ done: event.target.checked })} />
: day.tasks.map(task => <Checkbox key={task.taskId} label={task.name} checked={task.done} disabled={busy || disabled} onChange={event => void save({ done: event.target.checked }, task.taskId)} />)}
</>}
<p role="status">{notice}</p>
</div>;
}

View File

@@ -7,6 +7,8 @@ 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({
@@ -20,6 +22,7 @@ export function HabitHistory({
tasks,
editor,
onExpired,
onHistorySaved,
}: {
habit: TodayHabit;
date: string;
@@ -31,7 +34,10 @@ export function HabitHistory({
tasks?: ReactNode;
editor?: ReactNode;
onExpired?: () => void;
onHistorySaved?: () => void;
}) {
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);
@@ -106,7 +112,10 @@ export function HabitHistory({
Loading your habit history
</p>
) : (
<>
<CalendarHeatmap
onSelectDate={setSelectedDate}
selectedDate={selectedDate}
compact
historyLabel="Your recorded progress"
label={`${habit.name} progress calendar`}
@@ -123,6 +132,17 @@ export function HabitHistory({
/>
}
/>
<div className="ds-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 && <>
<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>
<CheckInEditor key={`${habit.habitId}:${selectedDate ?? date}`} habitId={habit.habitId} date={selectedDate ?? date} disabled={disabled} onExpired={onExpired}
onSaved={() => { setAttempt(value => value + 1); onHistorySaved?.(); }} />
</>}
</>
);
return (
<HabitChart