feat: add theme provider and control components for light/dark mode
- Implemented ThemeProvider to manage theme state and preferences. - Added ThemeControl component for users to switch between system, light, and dark themes. - Integrated localStorage for theme preference persistence. - Updated document styles based on theme changes. test: add tests for permanent deletion of habits and related records - Created tests to ensure permanent deletion of habits removes all dependent records. - Verified that deletion requires ownership, authentication, and checks for archived habits. - Added tests to confirm rollback behavior on deletion failures. test: add tests for habit palette and progress card functionality - Implemented tests for habit color progression and theme-based color shading. - Added tests for progress card calculations and calendar display logic. feat: define theme types and preferences - Introduced Theme and ThemePreference types for better type safety. - Created utility functions for resolving theme based on user preference and system settings. - Defined theme palettes for light and dark modes to ensure adequate contrast.
This commit is contained in:
@@ -1,14 +1,18 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { habitRequest, scheduleLabel, type TodayHabit } from "../lib/dashboard";
|
||||
import { Button } from "./design-system/primitives";
|
||||
import { Archive, ChevronDown, RotateCcw } from "lucide-react";
|
||||
import { Archive, ChevronDown, RotateCcw, Trash2 } from "lucide-react";
|
||||
import { FormMessage, WorkspacePanel } from "./design-system/WorkspacePanel";
|
||||
import { Modal } from "./design-system/Modal";
|
||||
import { HabitHistory } from "./HabitHistory";
|
||||
|
||||
export function ArchivedHabits({ date, revision, onChanged, onExpired, onClose }: {
|
||||
date: string; revision: number; onChanged: () => void; onExpired: () => void; onClose?: () => void;
|
||||
}) {
|
||||
const [habits, setHabits] = useState<TodayHabit[]>([]);
|
||||
const [deleting, setDeleting] = useState<TodayHabit | null>(null);
|
||||
const [deleteError, setDeleteError] = useState("");
|
||||
const archiveFocus = useRef<HTMLDivElement>(null);
|
||||
const [selected, setSelected] = useState<string>();
|
||||
const [error, setError] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
@@ -37,11 +41,26 @@ export function ArchivedHabits({ date, revision, onChanged, onExpired, onClose }
|
||||
setError(message); if (message.includes("session has expired")) expired.current();
|
||||
} finally { saving.current = false; setBusy(false); }
|
||||
}
|
||||
async function permanentlyDelete(habit: TodayHabit) {
|
||||
if (saving.current) return;
|
||||
saving.current = true; setBusy(true); setDeleteError(""); setNotice("");
|
||||
try {
|
||||
await habitRequest(`/habits/${habit.habitId}/permanent`, { method: "DELETE" });
|
||||
setHabits(current => current.filter(item => item.habitId !== habit.habitId));
|
||||
setSelected(current => current === habit.habitId ? undefined : current);
|
||||
setDeleting(null);
|
||||
setNotice(`${habit.name} permanently deleted.`);
|
||||
onChanged();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Could not delete. Try again.";
|
||||
setDeleteError(message); if (message.includes("session has expired")) expired.current();
|
||||
} finally { saving.current = false; setBusy(false); }
|
||||
}
|
||||
return <WorkspacePanel id="archived-habits" eyebrow="SAVED FOR LATER" title="Archived habits" description="Earlier progress stays here. Restore a habit whenever you’re ready." onClose={onClose} busy={busy} closeLabel="Close archive">
|
||||
<div ref={archiveFocus} tabIndex={-1} aria-label="Archive status"><FormMessage>{notice}</FormMessage></div>
|
||||
<FormMessage error>{error}</FormMessage>
|
||||
{error && <Button variant="secondary" onClick={() => setAttempt(value => value + 1)}>Retry archive</Button>}
|
||||
{loading ? <p className="ds-supporting-copy" role="status">Loading archive…</p> : !habits.length && <div className="ds-empty-panel"><Archive size={24} aria-hidden="true" /><div><p className="type-ui-heading">No archived habits.</p><p className="ds-supporting-copy">When you put a habit aside, its history will be waiting here.</p></div></div>}
|
||||
<FormMessage>{notice}</FormMessage>
|
||||
<div className="ds-record-list">
|
||||
{habits.map(habit => <article key={habit.habitId} className="ds-record" aria-labelledby={`archive-title-${habit.habitId}`}>
|
||||
<header className="ds-record-heading">
|
||||
@@ -49,10 +68,18 @@ export function ArchivedHabits({ date, revision, onChanged, onExpired, onClose }
|
||||
<div className="ds-action-row">
|
||||
<Button variant="text" onClick={() => setSelected(selected === habit.habitId ? undefined : habit.habitId)} aria-expanded={selected === habit.habitId} aria-controls={selected === habit.habitId ? `archive-history-${habit.habitId}` : undefined}>{selected === habit.habitId ? "Hide history" : "View history"}<ChevronDown className="ds-disclosure-icon" size={16} aria-hidden="true" /></Button>
|
||||
<Button variant="secondary" disabled={busy || loading} aria-label={`Restore ${habit.name}`} onClick={() => void restore(habit)}><RotateCcw size={16} aria-hidden="true" />Restore</Button>
|
||||
<Button variant="text" disabled={busy || loading} aria-label={`Delete permanently ${habit.name}`} aria-haspopup="dialog" onClick={() => { setDeleteError(""); setDeleting(habit); }}><Trash2 size={16} aria-hidden="true" />Delete permanently</Button>
|
||||
</div>
|
||||
</header>
|
||||
{selected === habit.habitId && <div className="ds-record-detail" id={`archive-history-${habit.habitId}`}><HabitHistory calendarOnly habit={habit} date={date} revision={revision + attempt} disabled={busy || loading} onExpired={onExpired} onHistorySaved={() => setAttempt(value => value + 1)} /></div>}
|
||||
</article>)}
|
||||
</div>
|
||||
{deleting && <Modal id="delete-archived-habit" size="compact" eyebrow="PERMANENT DELETION" title="Delete this habit?" description={`“${deleting.name ?? "Habit"}” and all its check-ins, tasks, and progress history will be permanently deleted. It will be removed from combined charts; charts with no remaining habits will also be deleted. This cannot be undone.`} busy={busy} initialFocus="[data-cancel-delete]" returnFocus={() => archiveFocus.current} onClose={() => setDeleting(null)}>
|
||||
<FormMessage error>{deleteError}</FormMessage>
|
||||
<div className="ds-action-row">
|
||||
<Button variant="secondary" data-cancel-delete disabled={busy} onClick={() => setDeleting(null)}>Keep habit</Button>
|
||||
<Button disabled={busy} onClick={() => void permanentlyDelete(deleting)}>{busy ? "Deleting…" : "Delete permanently"}</Button>
|
||||
</div>
|
||||
</Modal>}
|
||||
</WorkspacePanel>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user