diff --git a/src/components/AccountSettings.tsx b/src/components/AccountSettings.tsx index de34dfa..4ef924f 100644 --- a/src/components/AccountSettings.tsx +++ b/src/components/AccountSettings.tsx @@ -24,9 +24,9 @@ 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 - Follow your device or choose a theme for this browser.

}>
- Your timezone sets the start and end of each day. Earlier deadlines stay as recorded.

}> + return + + Sets day boundaries. Earlier deadlines stay unchanged.

}>
{ event.preventDefault(); void run(() => habitRequest("/me", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ timezone }) })); }}> {id => setTimezone(event.target.value)} />} @@ -36,7 +36,7 @@ export function AccountSettings({ user, onChanged, onClose }: { user: PublicUser
- Take your profile, habits, history, and sharing records with you. Sessions and credentials are excluded.

}> + Download your profile, habits, and history.

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

}> diff --git a/src/components/ArchivedHabits.tsx b/src/components/ArchivedHabits.tsx index 9c0cbbb..0952382 100644 --- a/src/components/ArchivedHabits.tsx +++ b/src/components/ArchivedHabits.tsx @@ -56,7 +56,7 @@ export function ArchivedHabits({ date, revision, onChanged, onExpired, onClose } setDeleteError(message); if (message.includes("session has expired")) expired.current(); } finally { saving.current = false; setBusy(false); } } - return + return
{notice}
{error} {error && } diff --git a/src/components/HabitForm.test.tsx b/src/components/HabitForm.test.tsx index 4a53466..579acd2 100644 --- a/src/components/HabitForm.test.tsx +++ b/src/components/HabitForm.test.tsx @@ -3,7 +3,7 @@ import { Window } from "happy-dom"; import { act, useState } from "react"; import type { Root } from "react-dom/client"; import { HabitForm } from "./HabitForm"; -import { HabitColorPicker } from "./design-system/HabitColorPicker"; +import { HABIT_COLOR_CHOICES, HabitColorPicker } from "./design-system/HabitColorPicker"; const dom = new Window({ url: "http://localhost:3000/design-system" }); const originalGlobals = new Map(); @@ -46,33 +46,33 @@ function button(text: string) { return [...container.querySelectorAll("button")].find((element) => element.textContent?.trim() === text)!; } -test("habit starters remain editable and custom creation resets the draft", async () => { +test("blank habit creation exposes tracking-specific fields without templates", async () => { await act(async () => root.render( {}} onCreated={() => {}} onExpired={() => {}} />)); - await act(async () => button("Reading").click()); - expect(container.querySelector('input[placeholder="e.g. Read ten pages"]')?.value).toBe("Read"); - expect(container.querySelector("select")?.value).toBe("count"); - expect(container.querySelector('input[type="number"]')?.value).toBe("10"); - await act(async () => button("Evening routine").click()); - expect(container.querySelector("select")?.value).toBe("tasks"); - expect([...container.querySelectorAll("input")].map(input => input.value)).toContain("Prepare for tomorrow"); - await act(async () => button("Start from scratch").click()); expect(container.querySelector('input[placeholder="e.g. Read ten pages"]')?.value).toBe(""); - expect(container.querySelector("select")?.value).toBe("manual"); + expect(container.querySelector('[aria-label="Start with an idea"]')).toBeNull(); + const method = container.querySelector("select")!; + expect(method.value).toBe("manual"); + await act(async () => { method.value = "count"; method.dispatchEvent(new Event("change", { bubbles: true })); }); + expect(container.querySelector('input[type="number"]')?.value).toBe("10"); + await act(async () => { method.value = "tasks"; method.dispatchEvent(new Event("change", { bubbles: true })); }); + expect(container.querySelector('input[type="number"]')).toBeNull(); + await act(async () => button("Add task +").click()); + expect([...container.querySelectorAll("label")].filter(label => /^Task \d+$/.test(label.textContent ?? ""))).toHaveLength(2); }); -test("compact palette discloses full controls and preserves custom color", async () => { +test("continuous colors disclose custom controls and preserve the selected color", async () => { function Picker() { const [color, setColor] = useState("#123456"); - return ; + return ; } await act(async () => root.render()); - expect(container.querySelector('[aria-label="Suggested colors"]')?.querySelectorAll("button").length).toBe(8); - const disclosure = button("More colors"); + expect(container.querySelector('[aria-label="Habit colors"]')?.querySelectorAll('button[aria-label^="Color "]').length).toBe(HABIT_COLOR_CHOICES.length); + const disclosure = button("Custom"); const details = document.getElementById(disclosure.getAttribute("aria-controls")!)!; expect(details.hidden).toBe(true); await act(async () => disclosure.click()); expect(details.hidden).toBe(false); expect(container.querySelector('input[type="text"]')?.value).toBe("#123456"); - await act(async () => button("Fewer colors").click()); + await act(async () => button("Custom").click()); expect(container.querySelector('[aria-label="Progress shades using #123456"]')).not.toBeNull(); }); diff --git a/src/components/HabitForm.tsx b/src/components/HabitForm.tsx index 176c53d..d0f78d6 100644 --- a/src/components/HabitForm.tsx +++ b/src/components/HabitForm.tsx @@ -7,12 +7,6 @@ import { HabitColorPicker } from "./design-system/HabitColorPicker"; import { habitInput, type Schedule } from "../habits/contracts"; import { habitRequest } from "../lib/dashboard"; -const STARTERS = [ - { label: "Reading", name: "Read", method: "count", target: 10, unit: "pages", tasks: [""], color: "#426582" }, - { label: "Get outside", name: "Get outside", method: "manual", target: 1, unit: "times", tasks: [""], color: "#58765b" }, - { label: "Evening routine", name: "Wind down", method: "tasks", target: 1, unit: "times", tasks: ["Put things away", "Prepare for tomorrow"], color: "#79618d" }, -] as const; - /** Account behavior composed from the design system's existing form components. */ export function HabitForm({ date, @@ -33,22 +27,10 @@ export function HabitForm({ const [unit, setUnit] = useState("pages"); const [tasks, setTasks] = useState([""]); const [schedule, setSchedule] = useState({ type: "daily" }); - const [color, setColor] = useState("#58765b"); + const [color, setColor] = useState("#3e805d"); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); - function applyStarter(starter: (typeof STARTERS)[number] | null) { - setName(starter?.name ?? ""); - setMethod(starter?.method ?? "manual"); - setTarget(starter?.target ?? 10); - setUnit(starter?.unit ?? "pages"); - setTasks(starter ? [...starter.tasks] : [""]); - setColor(starter?.color ?? "#58765b"); - setSchedule({ type: "daily" }); - setError(""); - form.current?.querySelector("input")?.focus(); - } - async function submit(event: FormEvent) { event.preventDefault(); if (saving.current) return; @@ -93,17 +75,9 @@ export function HabitForm({ } return ( - Make it yours.} description="Schedules follow your account’s timezone." onClose={onCancel} closeLabel="Close new habit" busy={busy} size="compact" initialFocus="input" returnFocus={() => document.getElementById("add-habit")}> + document.getElementById("add-habit")}>
-
-
-
- {STARTERS.map((starter) => ( - - ))} - -
-
+
{(id) => ( )} - + {(id) => ( Add task + -

- Tasks repeat on each scheduled day. -

)} - - +
{error && (

diff --git a/src/components/HabitHistory.tsx b/src/components/HabitHistory.tsx index b715593..93bd5f2 100644 --- a/src/components/HabitHistory.tsx +++ b/src/components/HabitHistory.tsx @@ -139,7 +139,7 @@ export function HabitHistory({ /> } /> - {editingDay && setEditingDay(false)} busy={savingDay}> + {editingDay && setEditingDay(false)} busy={savingDay}>

{id => { if (event.target.value) setSelectedDate(event.target.value); }} />}
issue.message).join(" ")); await onSave(parsed.data); }}> - + {(id) => } + {method !== config.method &&

Changing the tracking method starts today’s progress over. Earlier history is kept.{method === "tasks" ? " After saving, add tasks below." : config.method === "tasks" ? " Existing tasks will leave this habit." : ""}

} {method === "count" && (<>
@@ -44,11 +45,9 @@ export function InlineHabitEditor({ config, color: initialColor, date, mode, dis {(id) => setUnit(event.target.value)} />}
setCarryPartialProgress(event.target.checked)} /> -

Completed counts reset. Explicitly recorded counts are kept.

)} - -

Changes start today. Earlier targets and schedules stay as they were.

+

Applies from today. Earlier history is kept.

); } diff --git a/src/components/InlineItemForm.tsx b/src/components/InlineItemForm.tsx index 3f95c31..70fc60f 100644 --- a/src/components/InlineItemForm.tsx +++ b/src/components/InlineItemForm.tsx @@ -27,7 +27,7 @@ export function InlineItemForm({ name, kind, mode, disabled, children, onSave, o const action = mode === "edit" ? "Edit" : kind === "habit" ? "Archive" : "Delete"; return ( - [...document.querySelectorAll("button[aria-label]")].find(button => button.getAttribute("aria-label") === `${action} ${kind} ${name}`) ?? null} initialFocus={mode === "edit" ? "input" : 'button[type="button"]:not(.ds-panel-close)'}> + [...document.querySelectorAll("button[aria-label]")].find(button => button.getAttribute("aria-label") === `${action} ${kind} ${name}`) ?? null} initialFocus={mode === "edit" ? "input" : 'button[type="button"]:not(.ds-panel-close)'}> { event.preventDefault(); @@ -48,7 +48,7 @@ export function InlineItemForm({ name, kind, mode, disabled, children, onSave, o } }}> {mode === "edit" ? ( -
+
{(id) => setDraft(event.target.value)} />} @@ -57,7 +57,7 @@ export function InlineItemForm({ name, kind, mode, disabled, children, onSave, o ) : (

{kind === "habit" ? "Archive" : "Delete"} {name}? {kind === "habit" - ? "This removes the habit from your dashboard starting today. Earlier history is kept. You can restore it from Archived habits." + ? "History is kept. Restore it anytime from Archived habits." : "This removes the task from today and future check-ins. Earlier history is kept."}

)} diff --git a/src/components/InlineTaskEditor.tsx b/src/components/InlineTaskEditor.tsx index aa30f89..40778b1 100644 --- a/src/components/InlineTaskEditor.tsx +++ b/src/components/InlineTaskEditor.tsx @@ -26,8 +26,8 @@ export function InlineTaskEditor({ name, schedule: savedSchedule, habitSchedule, await onSave(parsed.data); }}> -

This task is due when both its recurrence and the habit’s schedule match. Habit: {scheduleLabel(habitSchedule)}.

- {!creating &&

Changes start today. Earlier check-ins keep their original schedule.

} +

Runs when both schedules match. Habit: {scheduleLabel(habitSchedule)}.

+ {!creating &&

Applies from today. Earlier check-ins stay unchanged.

} ); } diff --git a/src/components/LiveDiscordShare.tsx b/src/components/LiveDiscordShare.tsx index b49110e..e7b96b2 100644 --- a/src/components/LiveDiscordShare.tsx +++ b/src/components/LiveDiscordShare.tsx @@ -42,11 +42,11 @@ export function LiveDiscordShare({ revision }: { revision: number }) { const ready = preview?.theme === theme && preview.revision === revision ? preview : null; const active = state?.status === 'active' || state?.status === 'sending'; return
-

All your habits. One shared image.

1920 × 1080 · 16:9 · Last 30 days

+

All habits

Last 30 days

{previewError ?

{previewError}

: ready ? 16:9 Discord card containing all active habit names and their daily progress for the last 30 days. :

Creating your image…

}
-

The bot posts this image in {connection?.connected ? {connection.name} : 'the configured Discord channel'}, then replaces the attachment on that same message after changes settle for two seconds. Updates continue with this page closed.

+

Automatically updates in {connection?.connected ? {connection.name} : 'the configured Discord channel'}. Updates continue with this page closed.

Shares your display name, avatar, all active habit names, and progress—including new habits. Task details stay private. Stopping updates leaves the last image in Discord.

{state?.messageUrl && View message in Discord ↗} diff --git a/src/components/ReminderSettings.tsx b/src/components/ReminderSettings.tsx index ff58fac..51df6bc 100644 --- a/src/components/ReminderSettings.tsx +++ b/src/components/ReminderSettings.tsx @@ -25,7 +25,7 @@ export function ReminderSettings({ timezone, onBusyChange, disabled = false }: { return () => controller.abort(); }, [attempt]); const changeEntry = (index: number, entry: ReminderEntry) => setDraft(value => ({ ...value, reminders: value.reminders.map((previous, position) => position === index ? entry : previous) })); - return

A private Discord nudge when you still have habits left today. Named reminders include their name in the message. No habit names are sent.

Times in {timezone}

}> + return

Private Discord reminders for unfinished habits. Reminder names are included; habit names stay private.

Times in {timezone}

}> {!settings ? error ? :

Loading reminders…

: { event.preventDefault(); if (saving.current || disabled) return; const form = event.currentTarget; @@ -40,7 +40,7 @@ export function ReminderSettings({ timezone, onBusyChange, disabled = false }: { }}> {!settings.available &&

Discord reminders are not configured on this server.

}
- setDraft(value => ({ ...value, enabled: event.target.checked }))} /> + setDraft(value => ({ ...value, enabled: event.target.checked }))} /> {draft.reminders.map((entry, index) =>
{id => changeEntry(index, { ...entry, name: event.target.value })} />}
diff --git a/src/components/ShareProgress.tsx b/src/components/ShareProgress.tsx index f811100..25378c8 100644 --- a/src/components/ShareProgress.tsx +++ b/src/components/ShareProgress.tsx @@ -76,9 +76,9 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public finally { lock.current = false; setSending(false); } } return ( - document.getElementById("open-sharing")}> + document.getElementById("open-sharing")}>
- +
{mode === "live" ? : <> @@ -91,7 +91,7 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
{error ?
: ready ? {ready.alt} :
}
-

{thumbnail ? "A small-size preview, like an image in chat." : "A closer look at your card."} Same image in Discord and your download.

+

Your preview matches the shared image.

{ready?.avatarMissing &&

Your avatar couldn’t load. A neutral profile icon is used.

}
@@ -104,7 +104,7 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
Habits {ids.length} / 6
{today.habits.map(habit => = 6} onChange={e => setIds(values => e.target.checked ? [...values, habit.habitId] : values.filter(id => id !== habit.habitId))} />)}
-

Choose up to six. Task names stay private.

+

Task names stay private.

Show on the card @@ -118,7 +118,7 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public