diff --git a/src/App.test.tsx b/src/App.test.tsx index 3ba3f08..f6f88b7 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -126,7 +126,7 @@ describe("homepage", () => { await f.request(`/habits/${h.id}`, "DELETE"); await render("/"); await act(async () => buttonNamed("Archived habits").click()); - expect(container.textContent).toContain("Restore Reading"); + expect(buttonNamed("Restore Reading")).toBeDefined(); await act(async () => buttonNamed("View history").click()); expect(container.querySelector('[aria-label="2026-09-03: 1 of 1 completion · Complete"]')).not.toBeNull(); await act(async () => buttonNamed("Restore Reading").click()); diff --git a/src/components/AccountSettings.tsx b/src/components/AccountSettings.tsx index f9b97d4..33b58dc 100644 --- a/src/components/AccountSettings.tsx +++ b/src/components/AccountSettings.tsx @@ -3,7 +3,8 @@ import { useId, useRef, useState } from "react"; import type { PublicUser } from "../shared/user"; import { habitRequest } from "../lib/dashboard"; import { Button, ButtonLink } from "./design-system/primitives"; -import { Card } from "./design-system/Card"; +import { Download, Trash2 } from "lucide-react"; +import { FormMessage, SettingRow, WorkspacePanel } from "./design-system/WorkspacePanel"; import { Field } from "./design-system/Field"; export function AccountSettings({ user, onChanged, onClose }: { user: PublicUser; onChanged: () => void; onClose: () => void }) { @@ -21,28 +22,29 @@ export function AccountSettings({ user, onChanged, onClose }: { user: PublicUser catch (error) { setError(error instanceof Error ? error.message : "Could not save. Try again."); } finally { saving.current = false; setBusy(false); } } - return
- -
{ event.preventDefault(); void run(() => habitRequest("/me", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ timezone }) })); }}> - - {(id, hintId) => setTimezone(event.target.value)} />} + return + Your timezone sets the start and end of each day. Earlier deadlines stay as recorded.

}> + { event.preventDefault(); void run(() => habitRequest("/me", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ timezone }) })); }}> + + {id => setTimezone(event.target.value)} />} {["UTC", ...Intl.supportedValuesOf("timeZone")].map(zone => - +
- -
- Export my data - - -
-

Export includes your profile, habits, history, and sharing records. Sessions and credentials are excluded.

- {deleting &&
{ event.preventDefault(); void run(() => habitRequest("/account", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ confirmation }) })); }}> -

This permanently deletes your account and habit history and signs out every device. Download an export first if you want a copy. Images already posted to Discord remain there; server backups expire under the operator’s retention policy.

- {id => setConfirmation(event.target.value)} />} -
-
} - {error &&

{error}

} -
-
; + + + Take your profile, habits, history, and sharing records with you. Sessions and credentials are excluded.

}> +
JSON file
+
+ Permanently remove your account and habit history, and sign out every device.

}> + {!deleting ?
: +
{ event.preventDefault(); void run(() => habitRequest("/account", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ confirmation }) })); }}> +

This cannot be undone.

+

Download an export first if you want a copy. Images already posted to Discord remain there; server backups expire under the operator’s retention policy.

+ {id => setConfirmation(event.target.value)} />} +
+
} +
+ {error} + ; } diff --git a/src/components/ArchivedHabits.tsx b/src/components/ArchivedHabits.tsx index e835e16..e4b75eb 100644 --- a/src/components/ArchivedHabits.tsx +++ b/src/components/ArchivedHabits.tsx @@ -1,11 +1,12 @@ import { useEffect, useRef, useState } from "react"; -import { habitRequest, type TodayHabit } from "../lib/dashboard"; -import { Button, SectionHeading } from "./design-system/primitives"; -import { Card } from "./design-system/Card"; +import { habitRequest, scheduleLabel, type TodayHabit } from "../lib/dashboard"; +import { Button } from "./design-system/primitives"; +import { Archive, ChevronDown, RotateCcw } from "lucide-react"; +import { FormMessage, WorkspacePanel } from "./design-system/WorkspacePanel"; import { HabitHistory } from "./HabitHistory"; -export function ArchivedHabits({ date, revision, onChanged, onExpired }: { - date: string; revision: number; onChanged: () => void; onExpired: () => void; +export function ArchivedHabits({ date, revision, onChanged, onExpired, onClose }: { + date: string; revision: number; onChanged: () => void; onExpired: () => void; onClose?: () => void; }) { const [habits, setHabits] = useState([]); const [selected, setSelected] = useState(); @@ -36,18 +37,22 @@ export function ArchivedHabits({ date, revision, onChanged, onExpired }: { setError(message); if (message.includes("session has expired")) expired.current(); } finally { saving.current = false; setBusy(false); } } - return
- -

Restore a habit to resume its schedule today. Earlier history stays available.

- {error &&

{error}

} - {loading ?

Loading archive…

: !habits.length &&

No archived habits.

} -

{notice}

- {habits.map(habit => -
- - -
- {selected === habit.habitId && setAttempt(value => value + 1)} />} -
)} -
; + return + {error} + {error && } + {loading ?

Loading archive…

: !habits.length &&
} + {notice} +
+ {habits.map(habit =>
+
+

{habit.name ?? "Habit"}

{scheduleLabel(habit.requirements?.schedule)} · Archived

+
+ + +
+
+ {selected === habit.habitId &&
setAttempt(value => value + 1)} />
} +
)} +
+
; } diff --git a/src/components/CheckInEditor.tsx b/src/components/CheckInEditor.tsx index 1740095..62c303e 100644 --- a/src/components/CheckInEditor.tsx +++ b/src/components/CheckInEditor.tsx @@ -1,5 +1,6 @@ +import { FormMessage } from "./design-system/WorkspacePanel"; import { useEffect, useRef, useState } from "react"; -import { habitRequest, type TodayHabit } from "../lib/dashboard"; +import { habitRequest, formatTrackingDate, type TodayHabit } from "../lib/dashboard"; import { Button, Checkbox } from "./design-system/primitives"; import { Field } from "./design-system/Field"; @@ -38,23 +39,23 @@ export function CheckInEditor({ habitId, date, disabled, onSaved, onExpired }: { setError(message); if (message.includes("session has expired")) expired.current?.(); } finally { saving.current = false; setBusy(false); } } - return
-

Check-in · {date}

- {error &&

{error}

} + return
+

EDITING CHECK-IN

{formatTrackingDate(date)}

+ {error} {!day ? error ? :

Loading check-in…

: day.future ?

Future check-ins cannot be edited.

: !day.due ?

Nothing was scheduled for this date.

: <> -

{day.name} · {day.timezone}. Changes use the requirements saved for this date.

- {day.method === "count" ?
{ event.preventDefault(); void save({ count: Number(count) }); }}> +

{day.timezone} · Using this day’s saved target and schedule.

+ {day.method === "count" ? { event.preventDefault(); void save({ count: Number(count) }); }}> {id => setCount(event.target.value)} />} {day.carriedFrom &&

Includes carryover from {day.carriedFrom}. Saving sets this day's total.

} - +
: day.method === "manual" ? void save({ done: event.target.checked })} /> : day.tasks.map(task => void save({ done: event.target.checked }, task.taskId)} />)} } -

{notice}

+ {notice}
; } diff --git a/src/components/HabitHistory.tsx b/src/components/HabitHistory.tsx index dc60d62..05ab10f 100644 --- a/src/components/HabitHistory.tsx +++ b/src/components/HabitHistory.tsx @@ -23,6 +23,7 @@ export function HabitHistory({ editor, onExpired, onHistorySaved, + calendarOnly = false, }: { habit: TodayHabit; date: string; @@ -35,6 +36,7 @@ export function HabitHistory({ editor?: ReactNode; onExpired?: () => void; onHistorySaved?: () => void; + calendarOnly?: boolean; }) { const [selectedDate, setSelectedDate] = useState(); const [editingDay, setEditingDay] = useState(false); @@ -132,18 +134,19 @@ export function HabitHistory({ /> } /> -
+
- {editingDay && <> - {id => { if (event.target.value) setSelectedDate(event.target.value); }} />} + {editingDay &&
+
{id => { if (event.target.value) setSelectedDate(event.target.value); }} />}

Update the progress recorded for this day.

{ setAttempt(value => value + 1); onHistorySaved?.(); }} /> - } +
} ); + if (calendarOnly) return chart; return ( { if (!controller.signal.aborted) setError(error.message); }); return () => controller.abort(); }, [attempt]); - return
-

Daily reminders

-

One private Discord message when you still have habits left today. Times follow {timezone}. No habit names are sent.

- {!settings ? :
{ + return

A private Discord nudge when you still have habits left today. No habit names are sent.

Times in {timezone}

}> + {!settings ? error ? :

Loading reminders…

: { event.preventDefault(); if (saving.current) return; const form = event.currentTarget; const submitted = { ...draft, ...Object.fromEntries((["time", "quietStart", "quietEnd"] as const).map(key => [key, (form.elements.namedItem(key) as HTMLInputElement).value])) }; @@ -38,19 +37,19 @@ export function ReminderSettings({ timezone }: { timezone: string }) { } catch (error) { setError(error instanceof Error ? error.message : "Could not save reminders. Try again."); } finally { saving.current = false; setBusy(false); } }}> - {!settings.available &&

Discord reminders are not configured on this server.

} -
- setDraft(value => ({ ...value, enabled: event.target.checked }))} /> -
+ {!settings.available &&

Discord reminders are not configured on this server.

} +
+ setDraft(value => ({ ...value, enabled: event.target.checked }))} /> +
{([['time', 'Remind me at'], ['quietStart', 'Quiet hours start'], ['quietEnd', 'Quiet hours end']] as const).map(([key, label]) => {id => })}
-

During quiet hours we wait until they end. Equal start and end times turn quiet hours off. Missed reminders never carry into the next day.

- +

During quiet hours we wait until they end. Equal start and end times turn quiet hours off. Missed reminders never carry into the next day.

+
- {settings.lastDelivery &&

{settings.lastDelivery.date}: {deliveryLabels[settings.lastDelivery.status] ?? "Unknown delivery status"}

} + {settings.lastDelivery &&

LAST REMINDER{settings.lastDelivery.date}: {deliveryLabels[settings.lastDelivery.status] ?? "Unknown delivery status"}

} } - {error &&

{error}

} -

{notice}

-
; + {error} + {notice} + ; } diff --git a/src/components/design-system/WorkspacePanel.tsx b/src/components/design-system/WorkspacePanel.tsx new file mode 100644 index 0000000..ab066c9 --- /dev/null +++ b/src/components/design-system/WorkspacePanel.tsx @@ -0,0 +1,29 @@ +import { useId, type ReactNode } from "react"; +import { X } from "lucide-react"; +import { Button } from "./primitives"; + +/** Shared flat panel for secondary workspace flows. */ +export function WorkspacePanel({ id, eyebrow, title, description, children, onClose, closeLabel = "Close panel" }: { + id: string; eyebrow: string; title: string; description?: string; children: ReactNode; onClose?: () => void; closeLabel?: string; +}) { + return
+
+

{eyebrow}

{title}

{description &&

{description}

}
+ {onClose && } +
+ {children} +
; +} + +/** Consistent label, explanation and control columns for preference forms. */ +export function SettingRow({ title, description, children }: { title: string; description: ReactNode; children: ReactNode }) { + const id = useId(); + return
+

{title}

{description}
+
{children}
+
; +} + +export function FormMessage({ children, error = false }: { children: ReactNode; error?: boolean }) { + return

{children}

; +} diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 74d3e21..6c55f86 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -1,3 +1,4 @@ +import { Archive, Settings2 } from "lucide-react"; import { ArchivedHabits } from "../components/ArchivedHabits"; import { AccountSettings } from "../components/AccountSettings"; import { useCallback, useEffect, useRef, useState } from "react"; @@ -357,9 +358,7 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi

)}
-
- - +
+
+ + +
)} - {archive && today && setAttempt(value => value + 1)} />} + {archive && today && setArchive(false)} date={today.date} revision={revision} onExpired={onExpired} onChanged={() => setAttempt(value => value + 1)} />} {settings && setSettings(false)} />} {sharing && today && { setSharing(false); requestAnimationFrame(() => document.getElementById("open-sharing")?.focus()); }} />} {error && ( diff --git a/styles/design-system.css b/styles/design-system.css index 737dfc5..2c47742 100644 --- a/styles/design-system.css +++ b/styles/design-system.css @@ -1435,3 +1435,73 @@ .ds-editable-task-row { gap: 4px; } .ds-inline-item-form { padding: 16px; } } + +/* Secondary workspace flows share the same flat surfaces and editorial hierarchy. */ +.ds-action-row { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; min-width: 0; } +.ds-action-row > .ds-button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; max-width: 100%; overflow-wrap: anywhere; } +.ds-action-row > .ds-button svg { flex: 0 0 auto; } +.ds-primary-actions { margin-top: 24px; } +.ds-utility-row { border-top: 1px solid var(--ds-rule); padding-top: 12px; margin-top: 20px; gap: 4px; } +.ds-utility-row .ds-button { color: var(--ds-secondary); @apply type-small; } +.ds-utility-row .ds-button[aria-expanded="true"] { color: var(--ds-ink); background: var(--ds-paper); } +.ds-workspace-panel { border: 1px solid var(--ds-rule); padding: 32px; margin: 32px 0 40px; min-width: 0; } +.ds-panel-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 32px; } +.ds-panel-heading > div { min-width: 0; } +.ds-panel-heading h2 { margin-top: 10px; @apply type-section; overflow-wrap: anywhere; } +.ds-panel-description { margin-top: 12px !important; color: var(--ds-secondary); max-width: 52ch; } +.ds-panel-close { flex: 0 0 44px; display: grid; place-items: center; width: 44px; height: 44px; padding: 0; border: 1px solid var(--ds-rule); } +.ds-setting-row { display: grid; grid-template-columns: minmax(180px, 0.8fr) minmax(0, 1.6fr); gap: 48px; padding: 28px 0; border-top: 1px solid var(--ds-rule); } +.ds-setting-row:last-of-type { padding-bottom: 0; } +.ds-setting-heading h3 { @apply type-ui-heading; } +.ds-setting-heading > div { display: grid; gap: 12px; margin-top: 10px; max-width: 32ch; color: var(--ds-secondary); @apply type-small; } +.ds-setting-controls { display: grid; gap: 16px; min-width: 0; align-content: start; } +.ds-form-stack { display: grid; gap: 20px; min-width: 0; align-content: start; } +.ds-form-stack .ds-field { margin-bottom: 0; } +.ds-supporting-copy, .ds-meta-label { color: var(--ds-secondary); @apply type-small; overflow-wrap: anywhere; } +.ds-meta-label { font-variant-numeric: tabular-nums; } +.ds-clock-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; } +.ds-clock-fields input { font-variant-numeric: tabular-nums; } +.ds-preference-toggle { padding: 16px; background: var(--ds-surface); border: 1px solid var(--ds-rule); } +.ds-preference-toggle .ds-checkbox-description { margin-top: 4px; } +.ds-delivery-note { display: grid; gap: 6px; padding: 14px 16px; border-left: 2px solid var(--ds-control-border); background: var(--ds-surface); @apply type-small; } +.ds-confirmation { padding: 24px; background: var(--ds-surface); border-left: 2px solid var(--ds-ink); } +.ds-form-message { @apply type-small; color: var(--ds-secondary); overflow-wrap: anywhere; } +.ds-form-message:empty { display: none; } +.ds-form-message:not(:empty) { padding: 12px 0; } +.ds-form-message--error:not(:empty) { padding: 12px 16px; border-left: 2px solid var(--ds-ink); background: var(--ds-surface); color: var(--ds-ink); } +.ds-record { border-top: 1px solid var(--ds-rule); min-width: 0; } +.ds-record-heading { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 24px 0; } +.ds-record-copy { min-width: 0; } +.ds-record-copy h3 { @apply type-title; overflow-wrap: anywhere; } +.ds-record-copy p { margin-top: 6px; color: var(--ds-secondary); @apply type-small; } +.ds-record-heading > .ds-action-row { flex-shrink: 0; } +.ds-disclosure-icon { transition: transform 160ms ease; } +[aria-expanded="true"] > .ds-disclosure-icon { transform: rotate(180deg); } +.ds-record-detail { padding: 8px 0 24px; } +.ds-empty-panel { display: flex; align-items: flex-start; gap: 18px; padding: 28px; background: var(--ds-surface); } +.ds-empty-panel > svg { flex-shrink: 0; color: var(--ds-secondary); } +.ds-empty-panel .ds-supporting-copy { margin-top: 8px; max-width: 48ch; } +.ds-history-actions { justify-content: flex-end; margin-top: 12px; } +.ds-history-editor { display: grid; grid-template-columns: minmax(180px, 0.8fr) minmax(0, 1.6fr); gap: 32px; padding: 24px; margin-top: 12px; border: 1px solid var(--ds-rule); background: var(--ds-surface); } +.ds-check-in-heading { display: grid; gap: 6px; } +.ds-check-in-controls { padding-left: 28px; border-left: 1px solid var(--ds-rule); } +@media (max-width: 900px) { + .ds-setting-row { gap: 28px; grid-template-columns: minmax(160px, 0.8fr) minmax(0, 1.6fr); } + .ds-clock-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .ds-clock-fields > .ds-field:first-child { grid-column: 1 / -1; } + .ds-record-heading { align-items: flex-start; flex-direction: column; gap: 16px; } +} +@media (max-width: 640px) { + .ds-workspace-panel { padding: 20px; margin: 24px 0 32px; } + .ds-panel-heading { gap: 16px; margin-bottom: 24px; } + .ds-panel-heading h2 { @apply type-title; } + .ds-panel-description { @apply type-small; } + .ds-setting-row { grid-template-columns: minmax(0, 1fr); gap: 20px; padding: 24px 0; } + .ds-setting-heading > div { max-width: none; } + .ds-confirmation, .ds-empty-panel { padding: 18px; } + .ds-history-editor { grid-template-columns: minmax(0, 1fr); padding: 20px; gap: 24px; } + .ds-check-in-controls { padding: 24px 0 0; border-left: 0; border-top: 1px solid var(--ds-rule); } + .ds-history-actions { justify-content: flex-start; } + .ds-record-heading > .ds-action-row { flex-shrink: 1; gap: 8px; } +} +@media (prefers-reduced-motion: reduce) { .ds-disclosure-icon { transition: none; } }