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:
syntaxbullet
2026-09-05 10:07:28 +02:00
parent 9e9871c08a
commit 00729096bf
47 changed files with 780 additions and 337 deletions

View File

@@ -184,6 +184,30 @@ describe("homepage", () => {
expect((await f.json(`/habits/${h.id}/days/2026-09-03`)).complete).toBe(true);
});
test("archive permanent deletion supports cancel, failure, retry, and removal", async () => {
const f = connectAccount();
const habit = await f.json("/habits", "POST", { name: "Discard me", method: "manual" }, 201);
await f.request(`/habits/${habit.id}`, "DELETE");
await render("/");
await act(async () => buttonNamed("Archived habits").click());
await act(async () => buttonNamed("Delete permanently Discard me").click());
expect(container.querySelector("#delete-archived-habit")?.textContent).toContain("This cannot be undone.");
expect(document.activeElement).toBe(buttonNamed("Keep habit"));
await act(async () => buttonNamed("Keep habit").click());
expect((await f.request(`/habits/${habit.id}`)).status).toBe(200);
await act(async () => buttonNamed("Delete permanently Discard me").click());
const confirm = () => container.querySelector<HTMLButtonElement>("#delete-archived-habit .ds-action-row button:last-child")!;
fetchMock.mockResolvedValueOnce(Response.json({ error: "Temporary failure" }, { status: 500 }));
await act(async () => confirm().click());
expect(container.querySelector("#delete-archived-habit")?.textContent).toContain("Temporary failure");
expect((await f.request(`/habits/${habit.id}`)).status).toBe(200);
await act(async () => confirm().click());
expect(container.querySelector("#delete-archived-habit")).toBeNull();
expect(container.textContent).toContain("Discard me permanently deleted.");
expect(container.textContent).toContain("No archived habits.");
expect((await f.request(`/habits/${habit.id}`)).status).toBe(404);
});
test("historical check-ins use the selected date's tracking method and preserve today", async () => {
const f = connectAccount();
f.setTime("2026-09-03T12:00:00Z");
@@ -191,10 +215,8 @@ describe("homepage", () => {
f.setTime("2026-09-04T12:00:00Z");
await f.json(`/habits/${habit.id}`, "PATCH", { method: "count", target: 10, unit: "pages" });
await render("/");
expect(container.querySelector(".ds-calendar")).toBeNull();
expect(buttonNamed("View history for Reading").getAttribute("aria-expanded")).toBe("false");
await act(async () => buttonNamed("View history for Reading").click());
expect(buttonNamed("Hide history for Reading").getAttribute("aria-expanded")).toBe("true");
expect(container.querySelector(".ds-goal-graph .ds-calendar")?.getAttribute("data-months")).toBe("3");
expect(container.querySelector(".ds-goal-graph [hidden]")).toBeNull();
await act(async () => container.querySelector<HTMLButtonElement>('[aria-label^="2026-09-03:"]')!.click());
expect(buttonNamed("Edit selected check-in").closest(".ds-date-inspector")?.textContent).toContain("September 3, 2026");
await act(async () => buttonNamed("Edit selected check-in").click());
@@ -279,17 +301,18 @@ describe("homepage", () => {
const countGoal = container.querySelector(".ds-goal--count")!;
expect(countGoal.querySelector('.ds-goal-count input[aria-label="Total Water"]')).not.toBeNull();
expect(countGoal.querySelector(".ds-goal-unit")?.textContent).toBe("glasses");
expect(countGoal.textContent).not.toContain("Count target");
expect(countGoal.textContent).toContain("Count target");
expect(countGoal.querySelector(".ds-goal-status")?.textContent).toBe("In progress");
const checkIn = container.querySelector<HTMLInputElement>('.ds-goal--manual input[type="checkbox"]')!;
expect(container.querySelector('.ds-goal--manual h3 label')?.getAttribute("for")).toBe(checkIn.id);
expect(container.querySelector('.ds-goal--manual .ds-goal-check-in > label[for]')?.getAttribute("for")).toBe(checkIn.id);
expect(checkIn.getAttribute("aria-label")).toBe("Reading done");
expect(container.querySelector('.ds-goal--tasks details')).toBeNull();
expect(container.querySelector('.ds-goal--tasks .ds-goal-tasks details')).toBeNull();
expect(container.querySelectorAll('.ds-goal--tasks .ds-routine-items > li')).toHaveLength(1);
await act(async () => buttonNamed("Increase Water").click());
expect(container.querySelector(".ds-welcome-progress")?.textContent).toContain("1 / 3");
expect(container.querySelector(`#history-${water.id} .ds-date-inspector`)).toBeNull();
await act(async () => buttonNamed("View history for Water").click());
expect(container.querySelectorAll(".ds-goal-graph .ds-calendar")).toHaveLength(3);
expect(countGoal.querySelector(".ds-goal-status")?.textContent).toBe("Complete");
expect(container.querySelector(`#history-${water.id} .ds-date-inspector`)?.textContent).toContain("8 of 8 glasses");
expect(container.textContent).not.toContain("Sunday walk");
await act(async () => buttonNamed("All habits · 4").click());
@@ -469,7 +492,7 @@ describe("homepage", () => {
await selectMethod("tasks");
await submit();
expect((await f.json(`/habits/${habit.id}`)).method).toBe("tasks");
await act(async () => buttonNamed("Add task +").click());
await act(async () => buttonNamed("Add task").click());
const form = container.querySelector<HTMLFormElement>('form[aria-label="Add task"]')!;
await act(async () => {
const input = form.querySelector("input")!;
@@ -560,11 +583,11 @@ test("unscheduled dates stay inspectable without a progress-square fill", async
);
expect(
container.querySelector<HTMLElement>(".ds-day--due")!.style.backgroundColor,
).toBe("#ebedf0");
).toBe("var(--ds-empty)");
expect(
container.querySelector<HTMLElement>(".ds-day--future")!.style
.backgroundColor,
).toBe("#ebedf0");
).toBe("var(--ds-empty)");
expect(container.querySelector(".ds-legend-swatch--not-due")).not.toBeNull();
await act(async () => restDay.click());
expect(restDay.getAttribute("aria-pressed")).toBe("true");

View File

@@ -3,9 +3,11 @@ import { DesignSystem } from "./pages/DesignSystem";
import { Home } from "./pages/Home";
import { AuthProvider } from "./components/AuthProvider";
import { ThemeProvider } from "./components/ThemeProvider";
export function App() {
return (
<Routes>
<ThemeProvider><Routes>
<Route path="/design-system" element={<DesignSystem />} />
<Route
path="/"
@@ -16,6 +18,6 @@ export function App() {
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Routes></ThemeProvider>
);
}

View File

@@ -29,7 +29,7 @@ export function createAccountRoutes(db: AppDatabase, auth: ReturnType<typeof cre
discordDeliveries: tx.select().from(schema.discordDeliveries).where(eq(schema.discordDeliveries.userId, user.id)).all(),
};
});
c.header("Content-Disposition", 'attachment; filename="minabot-export.json"');
c.header("Content-Disposition", 'attachment; filename="mina-habits-export.json"');
return c.json(data);
});
app.delete("/", auth.requireSameOrigin, bodyLimit({ maxSize: 1024 }), async c => {

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

BIN
src/assets/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -1,3 +1,4 @@
import { ThemeControl } from "./ThemeProvider";
import { ReminderSettings } from "./ReminderSettings";
import { useId, useRef, useState } from "react";
import type { PublicUser } from "../shared/user";
@@ -24,6 +25,7 @@ export function AccountSettings({ user, onChanged, onClose }: { user: PublicUser
finally { saving.current = false; setBusy(false); }
}
return <WorkspacePanel id="account-settings" eyebrow="YOUR WORKSPACE" title="Account settings" description="A few preferences to make this space yours." onClose={onClose} busy={busy || reminderBusy} closeLabel="Close settings">
<SettingRow title="Appearance" description={<p>Follow your device or choose a theme for this browser.</p>}><ThemeControl /></SettingRow>
<SettingRow title="Your daily rhythm" description={<p>Your timezone sets the start and end of each day. Earlier deadlines stay as recorded.</p>}>
<form className="ds-form-stack" onSubmit={event => { event.preventDefault(); void run(() => habitRequest("/me", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ timezone }) })); }}>
<Field label="Timezone">
@@ -35,7 +37,7 @@ export function AccountSettings({ user, onChanged, onClose }: { user: PublicUser
</SettingRow>
<ReminderSettings timezone={user.timezone} onBusyChange={setReminderBusy} disabled={busy} />
<SettingRow title="A copy for you" description={<p>Take your profile, habits, history, and sharing records with you. Sessions and credentials are excluded.</p>}>
<div className="ds-action-row"><ButtonLink variant="secondary" href="/api/account/export" download="minabot-export.json"><Download size={16} aria-hidden="true" />Export my data</ButtonLink><span className="ds-meta-label">JSON file</span></div>
<div className="ds-action-row"><ButtonLink variant="secondary" href="/api/account/export" download="mina-habits-export.json"><Download size={16} aria-hidden="true" />Export my data</ButtonLink><span className="ds-meta-label">JSON file</span></div>
</SettingRow>
<SettingRow title="Delete account" description={<p>Permanently remove your account and habit history, and sign out every device.</p>}>
{!deleting ? <div className="ds-action-row"><Button variant="text" disabled={busy || reminderBusy} onClick={() => setDeleting(true)} aria-expanded={false}><Trash2 size={16} aria-hidden="true" />Delete account</Button></div> :

View File

@@ -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>;
}

View File

@@ -1,5 +1,5 @@
import { Modal } from "./design-system/Modal";
import { ChevronDown, Pencil } from "lucide-react";
import { Pencil } from "lucide-react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import type { CalendarResponse } from "../shared/calendar";
import { habitRequest, scheduleLabel, type TodayHabit } from "../lib/dashboard";
@@ -41,7 +41,6 @@ export function HabitHistory({
calendarOnly?: boolean;
}) {
const [selectedDate, setSelectedDate] = useState<string>();
const [historyExpanded, setHistoryExpanded] = useState(false);
const [editingDay, setEditingDay] = useState(false);
const [savingDay, setSavingDay] = useState(false);
const [calendar, setCalendar] = useState<CalendarResponse | null>(null);
@@ -123,6 +122,7 @@ export function HabitHistory({
onSelectDate={setSelectedDate}
selectedDate={selectedDate}
compact
initialMonths={3}
historyLabel="Your recorded progress"
label={`${habit.name} progress calendar`}
unit={habit.unit}
@@ -167,28 +167,7 @@ export function HabitHistory({
target={habit.target ?? 0}
unit={habit.unit}
color={calendar?.settings.mainColor ?? "#196127"}
calendar={
<div className="ds-habit-history">
<Button
variant="text"
className="ds-habit-history-toggle"
aria-expanded={historyExpanded}
aria-controls={`history-panel-${habit.habitId}`}
aria-label={`${historyExpanded ? "Hide" : "View"} history for ${habit.name ?? "habit"}`}
onClick={() => setHistoryExpanded((value) => !value)}
>
<ChevronDown size={16} aria-hidden="true" />
{historyExpanded ? "Hide history" : "View history"}
</Button>
<div
id={`history-panel-${habit.habitId}`}
className="ds-habit-history-panel"
hidden={!historyExpanded}
>
{historyExpanded && chart}
</div>
</div>
}
calendar={chart}
tasks={tasks}
tasksLabel="Tasks"
headingLevel={3}

View File

@@ -1,16 +1,19 @@
import { ThemeControl, useTheme } from "./ThemeProvider";
import { Modal } from "./design-system/Modal";
import { useEffect, useRef, useState } from "react";
import { Download, Send } from "lucide-react";
import { Check, ChevronDown, Download, Hash, ImageIcon, Send, ShieldCheck } from "lucide-react";
import { Button, Checkbox } from "./design-system/primitives";
import { Field } from "./design-system/Field";
import { habitRequest, type TodayResponse } from "../lib/dashboard";
import { addDays } from "../habits/calendar";
import { addDays, dayNumber } from "../habits/calendar";
import { renderProgressCard, type CardPrivacy } from "../lib/progress-card";
import type { PublicUser } from "../shared/user";
import type { Delivery, DiscordConnection, ShareData } from "../sharing/contracts";
export function ShareProgress({ user, today, revision, onClose }: { user: PublicUser; today: TodayResponse; revision: number; onClose: () => void }) {
const { theme } = useTheme();
const [ids, setIds] = useState(() => today.habits.slice(0, 1).map(h => h.habitId));
const [thumbnail, setThumbnail] = useState(true);
const [range, setRange] = useState("30");
const [from, setFrom] = useState(addDays(today.date, -29));
const [to, setTo] = useState(today.date);
@@ -26,7 +29,7 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
const [sendError, setSendError] = useState("");
const lock = useRef(false);
const lastImage = useRef<{ hash: string; deliveryId: string } | null>(null);
const key = JSON.stringify({ ids, from, to, privacy, revision, user });
const key = JSON.stringify({ ids, from, to, privacy, revision, user, theme });
const ready = card?.key === key ? card : null;
const sent = ready && delivery?.id === ready.deliveryId ? delivery : null;
const busy = sending;
@@ -45,10 +48,11 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
setError(""); setSendError("");
const timer = setTimeout(async () => {
if (!ids.length) { setError("Choose at least one habit for your card."); return; }
if (!from || !to || from > to || to > today.date || dayNumber(to) - dayNumber(from) > 365) { setError("Choose a date range of up to 366 days, ending today or earlier."); return; }
try {
const data = await habitRequest<ShareData>("/sharing/preview", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ habitIds: ids, from, to }), signal: controller.signal });
if (controller.signal.aborted) return;
const image = await renderProgressCard(data, user, privacy);
const image = await renderProgressCard(data, user, privacy, theme);
const hash = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", await image.blob.arrayBuffer())), byte => byte.toString(16).padStart(2, "0")).join("");
if (controller.signal.aborted) return;
objectUrl = URL.createObjectURL(image.blob);
@@ -63,41 +67,55 @@ export function ShareProgress({ user, today, revision, onClose }: { user: Public
async function send() {
if (!ready || !connection?.connected || lock.current || sent) return;
lock.current = true; setSending(true); setSendError("");
const form = new FormData(); form.set("deliveryId", ready.deliveryId); form.set("image", ready.blob, "minabot-progress.png");
const form = new FormData(); form.set("deliveryId", ready.deliveryId); form.set("image", ready.blob, "mina-habits-progress.png");
try { const result = await habitRequest<Delivery>("/sharing/discord/send", { method: "POST", body: form }); setDelivery({ ...result, id: ready.deliveryId }); }
catch (e) { setSendError(e instanceof Error ? e.message : "Could not send. Retrying this card will not post it twice."); }
finally { lock.current = false; setSending(false); }
}
return (
<Modal id="share-progress" eyebrow="A LITTLE PROGRESS, WORTH SHARING" title="Your progress, in a picture." onClose={onClose} closeLabel="Close sharing" busy={busy} returnFocus={() => document.getElementById("open-sharing")}>
<Modal id="share-progress" eyebrow="SHARE A LITTLE PROGRESS" title="Made for your people." description="Choose what to include. See exactly what you’ll share." onClose={onClose} closeLabel="Close sharing" busy={busy} returnFocus={() => document.getElementById("open-sharing")}>
<div className="ds-share-layout">
<section className="ds-share-preview-column" aria-label="Card preview">
<div className="ds-share-preview-heading"><span><ImageIcon size={16} aria-hidden="true" /> Your card</span>
<div className="ds-share-size" role="group" aria-label="Preview size"><button type="button" aria-pressed={thumbnail} onClick={() => setThumbnail(true)}>Thumbnail</button><button type="button" aria-pressed={!thumbnail} onClick={() => setThumbnail(false)}>Larger</button></div>
</div>
<div className={`ds-share-preview${thumbnail ? " ds-share-preview--thumbnail" : ""}`} aria-busy={!ready && !error}>
{error ? <div className="ds-share-placeholder" role="alert"><ImageIcon size={28} aria-hidden="true" /><p>{error}</p>{ids.length > 0 && <Button variant="secondary" onClick={() => setRenderAttempt(n => n + 1)}>Retry preview</Button>}</div> : ready ? <img src={ready.url} alt={ready.alt} /> : <div className="ds-share-placeholder" role="status"><ImageIcon size={28} aria-hidden="true" /><p>Creating your card…</p></div>}
</div>
<p className="ds-share-caption">{thumbnail ? "A small-size preview, like an image in chat." : "A closer look at your card."} Same image in Discord and your download.</p>
{ready?.avatarMissing && <p role="status" className="ds-share-caption">Your avatar couldn’t load. A neutral profile icon is used.</p>}
</section>
<div className="ds-share-controls">
<ThemeControl disabled={busy} />
<fieldset className="ds-share-fieldset" disabled={busy}>
<legend className="type-ui-heading">Choose your habits</legend>
<p className="ds-muted type-small">Up to six per card. Task names are never included.</p>
<div className="ds-share-habits">{today.habits.map(habit => <Checkbox key={habit.habitId} label={habit.name ?? "Habit"} checked={ids.includes(habit.habitId)} disabled={!ids.includes(habit.habitId) && ids.length >= 6} onChange={e => setIds(values => e.target.checked ? [...values, habit.habitId] : values.filter(id => id !== habit.habitId))} />)}</div>
<Field label="Date range">{id => <select id={id} value={range} onChange={e => { const value = e.target.value; setRange(value); if (value !== "custom") { setFrom(addDays(today.date, 1 - Number(value))); setTo(today.date); } }}><option value="7">Past 7 days</option><option value="30">Past 30 days</option><option value="365">Past year</option><option value="custom">Custom dates</option></select>}</Field>
<legend>Time period</legend>
<div className="ds-share-periods" role="group" aria-label="Date range">{[["7", "7 days"], ["30", "30 days"], ["365", "1 year"], ["custom", "Custom"]].map(([value, label]) => <button key={value} type="button" aria-pressed={range === value} onClick={() => { setRange(value!); if (value !== "custom") { setFrom(addDays(today.date, 1 - Number(value))); setTo(today.date); } }}>{label}</button>)}</div>
{range === "custom" && <div className="ds-share-dates"><Field label="From">{id => <input id={id} type="date" value={from} max={to || today.date} min="1970-01-01" onChange={e => setFrom(e.target.value)} />}</Field><Field label="To">{id => <input id={id} type="date" value={to} min={from} max={today.date} onChange={e => setTo(e.target.value)} />}</Field></div>}
</fieldset>
<fieldset className="ds-share-fieldset" disabled={busy}><legend className="type-ui-heading">Show on the card</legend>
{([ ["name", "Display name"], ["avatar", "Discord avatar"], ["habitNames", "Habit names"], ["timezone", "Timezone"] ] as const).map(([key, label]) => <Checkbox key={key} label={label} checked={privacy[key]} onChange={e => setPrivacy(value => ({ ...value, [key]: e.target.checked }))} />)}
<fieldset className="ds-share-fieldset" disabled={busy}>
<legend>Habits <span className="ds-share-count">{ids.length} / 6</span></legend>
<div className="ds-share-habits">{today.habits.map(habit => <Checkbox key={habit.habitId} label={habit.name ?? "Habit"} checked={ids.includes(habit.habitId)} disabled={!ids.includes(habit.habitId) && ids.length >= 6} onChange={e => setIds(values => e.target.checked ? [...values, habit.habitId] : values.filter(id => id !== habit.habitId))} />)}</div>
<p className="ds-share-hint">Choose up to six. Task names stay private.</p>
</fieldset>
</div>
<div className="ds-share-preview-column">
<div className="ds-share-preview" aria-busy={!ready && !error}>
{error ? <div role="alert"><p>{error}</p><Button variant="secondary" onClick={() => setRenderAttempt(n => n + 1)}>Retry preview</Button></div> : ready ? <img src={ready.url} alt={ready.alt} /> : <p role="status">Creating your card…</p>}
</div>
<p className="type-small ds-muted">This exact image will be downloaded or sent. Days off are excluded from the completion rate.</p>
{ready?.avatarMissing && <p role="status" className="type-small ds-muted">Your avatar couldn’t load. The card uses a neutral profile icon.</p>}
<div className="ds-share-actions">
{ready ? <a className="ds-button ds-button--secondary" href={ready.url} download={`minabot-progress-${from}-${to}.png`}><Download size={17} aria-hidden="true" />Download PNG</a> : <Button variant="secondary" disabled>Download PNG</Button>}
<Button disabled={!ready || !connection?.connected || busy || !!sent} onClick={() => void send()}><Send size={17} aria-hidden="true" />{sending ? "Sending…" : sent?.status === "sent" ? "Sent to Discord" : sent ? "Check Discord" : "Send to Discord"}</Button>
</div>
{connectionError ? <div role="alert" className="ds-share-feedback"><p>{connectionError}</p><Button variant="text" disabled={busy} onClick={() => setConnectionAttempt(n => n + 1)}>Retry Discord</Button></div> : connection?.connected ? <p className="type-small ds-muted">The bot will post this card to <a href={connection.channelUrl} target="_blank" rel="noreferrer">{connection.name}</a>.</p> : <p className="type-small ds-muted" role="status">{connection?.message ?? "Loading the Discord sharing channel…"}</p>}
{sendError && <p role="alert">{sendError}</p>}
{sent && <p role="status">{sent.status === "sent" ? <>Your card was sent. {sent.messageUrl && <a href={sent.messageUrl} target="_blank" rel="noreferrer">View in Discord ↗</a>}</> : <>Discord didn’t confirm delivery. Check {connection?.channelUrl ? <a href={connection.channelUrl} target="_blank" rel="noreferrer">your channel</a> : "your channel"} before creating another card; this attempt won’t be resent.</>}</p>}
<details className="ds-share-privacy"><summary><ShieldCheck size={17} aria-hidden="true" /><span>Privacy & details</span><ChevronDown size={16} aria-hidden="true" /></summary>
<fieldset className="ds-share-fieldset" disabled={busy}><legend className="ds-visually-hidden">Show on the card</legend>
{([["name", "Display name"], ["avatar", "Discord avatar"], ["habitNames", "Habit names"], ["timezone", "Timezone"]] as const).map(([key, label]) => <Checkbox key={key} label={label} checked={privacy[key]} onChange={e => setPrivacy(value => ({ ...value, [key]: e.target.checked }))} />)}
</fieldset>
</details>
<div className="ds-share-destination"><span className="ds-share-destination-icon"><Hash size={20} aria-hidden="true" /></span><div><span className="ds-share-hint">Discord destination</span>
{connectionError ? <div role="alert"><p>{connectionError}</p><Button variant="text" disabled={busy} onClick={() => setConnectionAttempt(n => n + 1)}>Retry connection</Button></div> : connection?.connected ? <><a href={connection.channelUrl} target="_blank" rel="noreferrer">{connection.name}</a><p className="ds-share-hint">Posted by the Mina Habits bot.</p></> : <p className="ds-share-hint" role="status">{connection?.message ?? "Loading channel…"}</p>}
</div></div>
</div>
</div>
<footer className="ds-share-footer">
<div className="ds-share-footer-copy" aria-live="polite">
{sendError ? <p role="alert" className="ds-share-error">{sendError}</p> : sent ? <p>{sent.status === "sent" ? <>Shared successfully. {sent.messageUrl && <a href={sent.messageUrl} target="_blank" rel="noreferrer">View in Discord ↗</a>}</> : <>Delivery unconfirmed. Check {connection?.channelUrl ? <a href={connection.channelUrl} target="_blank" rel="noreferrer">your channel</a> : "your channel"}; this attempt won’t be resent.</>}</p> : <p>{sending ? "Sending your card to Discord…" : error ? "Check your card settings." : ready ? "Ready when you are." : "Your card is taking shape."}<span>{sending ? "This may take a moment." : "Only the details in your preview are shared."}</span></p>}
</div>
<div className="ds-share-actions">
{ready ? <a className="ds-button ds-button--secondary" href={ready.url} download={`mina-habits-progress-${from}-${to}.png`}><Download size={17} aria-hidden="true" />Download</a> : <Button variant="secondary" disabled><Download size={17} aria-hidden="true" />Download</Button>}
<Button disabled={!ready || !connection?.connected || busy || !!sent} onClick={() => void send()}>{sent?.status === "sent" ? <Check size={17} aria-hidden="true" /> : <Send size={17} aria-hidden="true" />}{sending ? "Sending…" : sent?.status === "sent" ? "Shared" : sent ? "Check Discord" : "Send to Discord"}</Button>
</div>
</footer>
</Modal>
);
}

View File

@@ -0,0 +1,47 @@
import { Monitor, Sun, Moon } from "lucide-react";
import { createContext, useContext, useEffect, useLayoutEffect, useId, useState, type ReactNode } from "react";
import { resolveTheme, THEME_STORAGE_KEY, themePreference, type Theme, type ThemePreference } from "../lib/theme";
const ThemeContext = createContext<{ theme: Theme; preference: ThemePreference; setPreference: (value: ThemePreference) => void }>({ theme: "light", preference: "system", setPreference: () => {} });
export const useTheme = () => useContext(ThemeContext);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [preference, setValue] = useState<ThemePreference>(() => {
try { return themePreference(localStorage.getItem(THEME_STORAGE_KEY)); } catch { return "system"; }
});
const [systemDark, setSystemDark] = useState(() => typeof matchMedia === "function" && matchMedia("(prefers-color-scheme: dark)").matches);
const theme = resolveTheme(preference, systemDark);
useLayoutEffect(() => {
document.documentElement.dataset.theme = theme;
document.documentElement.classList.toggle("dark", theme === "dark");
document.documentElement.style.colorScheme = theme;
}, [theme]);
useEffect(() => {
const media = window.matchMedia?.("(prefers-color-scheme: dark)");
const change = () => setSystemDark(media?.matches ?? false);
const storage = (event: StorageEvent) => { if (event.key === THEME_STORAGE_KEY || event.key === null) setValue(themePreference(event.newValue)); };
change(); media?.addEventListener("change", change); window.addEventListener("storage", storage);
return () => { media?.removeEventListener("change", change); window.removeEventListener("storage", storage); };
}, []);
const setPreference = (value: ThemePreference) => {
setValue(value);
try { localStorage.setItem(THEME_STORAGE_KEY, value); } catch { /* Theme still works when browser storage is unavailable. */ }
};
return <ThemeContext value={{ theme, preference, setPreference }}>{children}</ThemeContext>;
}
export function ThemeControl({ disabled = false }: { disabled?: boolean }) {
const { preference, setPreference } = useTheme();
const id = useId();
return (
<div className="ds-theme-control" role="group" aria-label="Theme">
{([
{ value: "system", label: "System", Icon: Monitor },
{ value: "light", label: "Light", Icon: Sun },
{ value: "dark", label: "Dark", Icon: Moon },
] as const).map(({ value, label, Icon }) => (
<label className="ds-theme-option" key={value}>
<input type="radio" name={`theme-${id}`} value={value} checked={preference === value} disabled={disabled} onChange={() => setPreference(value)} />
<span><Icon size={16} strokeWidth={1.75} aria-hidden="true" />{label}</span>
</label>
))}
</div>
);
}

View File

@@ -1,12 +1,13 @@
import { useTheme } from "../ThemeProvider";
import { habitProgressColor, themedHabitShade } from "../../lib/habit-palette";
import { useId, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { CalendarLegend } from "./CalendarLegend";
import { Field } from "./Field";
import { Button } from "./primitives";
import { CalendarDays, Keyboard } from "lucide-react";
import { CalendarDays } from "lucide-react";
import {
describeDay,
calendarTimeline,
progressShade,
MONTH_VIEWS,
visibleCalendarDays,
type CalendarDay,
@@ -18,8 +19,8 @@ export function CalendarHeatmap({
unit,
label,
color = "#111111",
emptyColor = "#eeeeee",
compact = false,
initialMonths = 12,
selectedDate: controlledDate,
onSelectDate,
historyLabel,
@@ -32,13 +33,15 @@ export function CalendarHeatmap({
color?: string;
emptyColor?: string;
compact?: boolean;
initialMonths?: MonthView;
selectedDate?: string;
onSelectDate?: (date: string) => void;
historyLabel?: string;
legend?: ReactNode;
selectedDayAction?: (date: string) => ReactNode;
}) {
const [months, setMonths] = useState<MonthView | "custom">(12);
const { theme } = useTheme();
const [months, setMonths] = useState<MonthView | "custom">(initialMonths);
const [customRange, setCustomRange] = useState<{ from: string; to: string } | null>(null);
const [rangeError, setRangeError] = useState("");
const latestDate =
@@ -236,8 +239,8 @@ export function CalendarHeatmap({
day.state === "not-due"
? "transparent"
: day.state === "future"
? emptyColor
: progressShade(day, color),
? "var(--ds-empty)"
: day.value === 0 ? "var(--ds-empty)" : day.color ? themedHabitShade(day.color, color, theme) : habitProgressColor(color, (Math.max(1, Math.min(8, Math.round(day.value / day.target * 8))) - 1) / 7, theme),
}}
aria-label={`${day.date}: ${describeDay(day, unit)}`}
title={`${day.date}: ${describeDay(day, unit)}`}
@@ -274,27 +277,14 @@ export function CalendarHeatmap({
</div>
</div>
{legend ?? <CalendarLegend days={days} color={color} label={label} />}
<div className="ds-calendar-caption">
<span>
{months === "custom" ? `${days.length} ${days.length === 1 ? "day" : "days"}` : `${months} months`}{" "}
<span className="ds-muted">
/{" "}
{historyLabel ??
(compact ? "Demo history" : "Illustrative history")}
</span>
</span>
<details className="ds-calendar-help">
<summary><Keyboard size={16} aria-hidden="true" /> Keyboard shortcuts</summary>
<p id={instructionsId}>
Select a day. Up/down: one day. Left/right: one week. Home/end: first/last date.
</p>
</details>
</div>
<p id={instructionsId} className="ds-visually-hidden">
Select a day. Up/down: one day. Left/right: one week. Home/end: first/last date.
</p>
{!historyLabel && <p className="ds-calendar-demo-label ds-muted">Demo history</p>}
<div id={inspectorId} className="ds-date-inspector">
<div className="ds-date-inspector-heading" aria-live="polite">
<CalendarDays size={22} aria-hidden="true" />
<div>
<span className="ds-eyebrow">SELECTED DAY</span>
<time dateTime={selected.date}>
{new Date(`${selected.date}T12:00:00Z`).toLocaleDateString("en", {
month: "long",

View File

@@ -1,3 +1,5 @@
import { useTheme } from "../ThemeProvider";
import { themedHabitShade } from "../../lib/habit-palette";
import { legendShades, type CalendarDay } from "./calendar-model";
export function CalendarLegend({
@@ -11,7 +13,8 @@ export function CalendarLegend({
label: string;
shades?: string[];
}) {
const shades = suppliedShades ?? legendShades(days, color);
const { theme } = useTheme();
const shades = suppliedShades ?? legendShades(days, color, theme);
return (
<div
className="ds-calendar-legend"
@@ -31,11 +34,11 @@ export function CalendarLegend({
: "Empty"
}
>
{shades.map((shade) => (
{shades.map((shade, index) => (
<span
key={shade}
className="ds-legend-swatch"
style={{ backgroundColor: shade }}
style={{ backgroundColor: index === 0 ? "var(--ds-empty)" : suppliedShades ? themedHabitShade(shade, color, theme) : shade }}
/>
))}
</span>

View File

@@ -1,9 +1,9 @@
import { useId, type CSSProperties, type ReactNode } from "react";
import { ChevronDown, ListChecks } from "lucide-react";
import { Check, Circle, Hash, ChevronDown, ListChecks } from "lucide-react";
import { CalendarHeatmap } from "./CalendarHeatmap";
import { demoCalendar } from "./calendar-model";
/** Completion controls define each goal surface; history stays a separate full-width disclosure. */
/** A shared habit identity, visible history, and goal-specific completion surface. */
export function HabitChart({
name,
method,
@@ -45,30 +45,35 @@ export function HabitChart({
}) {
const headingId = useId();
const Heading = `h${headingLevel}` as const;
const complete = due && target > 0 && value >= target;
const TypeIcon = trackingMethod === "count" ? Hash : trackingMethod === "tasks" ? ListChecks : Check;
if (trackingMethod) return (
<section className={`ds-habit-chart ds-goal ds-goal--${trackingMethod}`} id={id} aria-labelledby={headingId} style={{ "--habit-color": color, "--habit-tint": /^#[0-9a-f]{6}$/i.test(color) ? `${color}0d` : "var(--ds-surface)" } as CSSProperties}>
<div className="ds-goal-body">
<div className="ds-goal-overview">
<header className="ds-goal-heading">
<div className="ds-goal-title">
{trackingMethod === "manual" && children}
<Heading id={headingId} className="type-title">
{trackingMethod === "manual" ? <label htmlFor={checkInId}>{name}</label> : name}
</Heading>
</div>
{headingActions}
</header>
{(!due || schedule !== "Every day") && <p className="ds-goal-schedule">{due ? schedule : `Not scheduled today · ${schedule}`}</p>}
{trackingMethod === "tasks" && due && target > 0 && <div className="ds-goal-task-progress">
<progress aria-label={`${name} tasks completed`} value={Math.max(0, Math.min(value, target))} max={target} />
<span role="status">{value} / {target}</span>
</div>}
<section className={`ds-habit-chart ds-goal ds-goal--${trackingMethod}`} id={id} aria-labelledby={headingId} style={{ "--habit-color": color } as CSSProperties}>
<header className="ds-goal-heading">
<div className="ds-goal-identity">
<div className="ds-goal-meta"><span className="ds-goal-swatch" aria-hidden="true" /><TypeIcon size={16} aria-hidden="true" /><span>{method}</span>{schedule !== "Every day" && <span className="ds-goal-schedule">{schedule}</span>}</div>
<Heading id={headingId} className="type-title">{name}</Heading>
</div>
{trackingMethod === "count" && <div className="ds-goal-count">{children}<span className="ds-goal-unit">{unit}</span></div>}
{editor}
{trackingMethod === "tasks" && <div className="ds-goal-tasks">{tasks}</div>}
<div className="ds-goal-heading-actions">
<span className="ds-goal-status" role="status">{complete ? <Check size={16} aria-hidden="true" /> : <Circle size={14} aria-hidden="true" />}{!due ? "Day off" : complete ? "Complete" : value > 0 ? "In progress" : "Not started"}</span>
{headingActions}
</div>
</header>
{editor}
<div className="ds-goal-body">
<div className="ds-goal-completion">
{trackingMethod === "count" && <div className="ds-goal-count">
<p className="ds-goal-target"><span className="ds-goal-unit">{unit}</span></p>
{children}
</div>}
{trackingMethod === "manual" && <div className="ds-goal-check-in">{children}<label htmlFor={checkInId}>{complete ? "Done for today" : "Mark as done"}</label></div>}
{trackingMethod === "tasks" && <>
<div className="ds-goal-task-progress"><span>{value} of {target} tasks done</span><progress aria-label={`${name} tasks completed`} value={Math.max(0, Math.min(value, target))} max={Math.max(1, target)} /></div>
<div className="ds-goal-tasks" role="group" aria-label={`${name}: ${tasksLabel}`}>{tasks}</div>
</>}
</div>
<div className="ds-goal-graph">{calendar ?? <CalendarHeatmap days={demoCalendar(value, target)} label={`${name} progress calendar`} unit={unit} color={color} compact />}</div>
</div>
{calendar}
</section>
);

View File

@@ -1,5 +1,7 @@
import { useTheme } from "../ThemeProvider";
import { habitProgressColor } from "../../lib/habit-palette";
import { useId, useState } from "react";
import { HABIT_COLORS, progressShade } from "./calendar-model";
import { HABIT_COLORS } from "./calendar-model";
import { Field } from "./Field";
export type HabitColors = {
@@ -47,6 +49,7 @@ export function HabitColorPicker({
mode?: "demo" | "create" | "edit";
compact?: boolean;
}) {
const { theme } = useTheme();
const id = useId();
const [expanded, setExpanded] = useState(false);
const showDetails = !compact || expanded;
@@ -55,14 +58,14 @@ export function HabitColorPicker({
<fieldset className="ds-color-picker" aria-describedby={`${id}-hint`}>
<legend>Habit color</legend>
<p id={`${id}-hint`} className={showDetails ? "ds-footnote" : "ds-visually-hidden"}>
Custom colors use six-digit hex values, such as #426582.
Your color adapts to each theme. Custom colors use six-digit hex values, such as #426582.
</p>
{compact && (
<>
<div className="ds-color-swatches ds-color-swatches--compact" role="group" aria-label="Suggested colors">
{HABIT_PALETTES.map((palette) => (
<button key={palette.name} type="button" aria-label={`${palette.name} ${palette.colors[0]}`} aria-pressed={value.toLowerCase() === palette.colors[0]} title={palette.name} onClick={() => onChange(palette.colors[0])}>
<span style={{ backgroundColor: palette.colors[0] }}>{value.toLowerCase() === palette.colors[0] ? "✓" : ""}</span>
<span style={{ backgroundColor: habitProgressColor(palette.colors[0], 1, theme), color: theme === "dark" ? "#191e1b" : "#ffffff" }}>{value.toLowerCase() === palette.colors[0] ? "✓" : ""}</span>
</button>
))}
</div>
@@ -89,7 +92,7 @@ export function HabitColorPicker({
title={color}
onClick={() => onChange(color)}
>
<span style={{ backgroundColor: color }}>
<span style={{ backgroundColor: habitProgressColor(color, 1, theme), color: theme === "dark" ? "#191e1b" : "#ffffff" }}>
{value.toLowerCase() === color ? "✓" : ""}
</span>
</button>
@@ -144,10 +147,7 @@ export function HabitColorPicker({
<span
key={count}
style={{
backgroundColor: progressShade(
{ date: "", value: count, target: 8, state: "due" },
value,
),
backgroundColor: count === 0 ? "var(--ds-empty)" : habitProgressColor(value, (count - 1) / 7, theme),
}}
/>
))}

View File

@@ -53,7 +53,7 @@ export function Modal({ id, eyebrow, title, description, children, onClose, clos
onKeyDown={event => {
if (event.key !== "Tab") return;
event.stopPropagation();
const controls = [...event.currentTarget.querySelectorAll<HTMLElement>('button, a[href], input, select, textarea, [tabindex]')]
const controls = [...event.currentTarget.querySelectorAll<HTMLElement>('button, a[href], input, select, textarea, summary, [tabindex]')]
.filter(element => element.tabIndex >= 0 && !element.matches(':disabled') && element.getClientRects().length > 0);
const first = controls[0];
const last = controls.at(-1);

View File

@@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import { Link } from "react-router";
import { ThemeControl } from "../ThemeProvider";
import { Button } from "./primitives";
/** The existing design-system page shell, shared without page-specific styling. */
@@ -28,14 +29,14 @@ export function PageLayout({
</a>
<header className="ds-header">
<Link className="ds-wordmark" to={wordmarkTo}>
minabot<span aria-hidden="true">.</span>
Mina Habits<span aria-hidden="true">.</span>
</Link>
{header}
<div className="ds-header-actions">{header}<ThemeControl /></div>
</header>
<main id={mainId} className="ds-main" tabIndex={-1}>
{children}
<footer className="ds-footer">
<span className="ds-wordmark">minabot.</span>
<span className="ds-wordmark">Mina Habits.</span>
<span>A little, every day.</span>
<Button
variant="text"

View File

@@ -46,7 +46,6 @@ export function WelcomePanel({
<div className="ds-welcome-identity">
{avatar}
<div>
<p className="ds-eyebrow">YOUR DAILY CHECK-IN</p>
<h1 id="dashboard-title" className="type-title">Welcome back, <em>{name}.</em></h1>
</div>
</div>

View File

@@ -231,7 +231,7 @@ test("custom timeframe applies inclusive dates, supports single days and survive
expect(container.querySelectorAll(".ds-day")).toHaveLength(2);
expect(container.querySelectorAll(".ds-calendar-month-label")).toHaveLength(1);
expect(container.querySelector(".ds-calendar-range-label")?.textContent).toContain("Aug 31, 2026");
expect(container.querySelector(".ds-calendar-caption")?.textContent).toStartWith("2 days");
expect(container.querySelector(".ds-calendar-range-label")?.textContent).toContain("Sep 1, 2026");
expect(container.querySelectorAll('.ds-day[tabindex="0"]')).toHaveLength(1);
await act(async () => button("3m").click());
expect(container.querySelector("form")).toBeNull();
@@ -242,7 +242,7 @@ test("custom timeframe applies inclusive dates, supports single days and survive
setDates("2026-09-04", "2026-09-04");
await act(async () => button("Apply range").click());
expect(container.querySelectorAll(".ds-day")).toHaveLength(1);
expect(container.querySelector(".ds-calendar-caption")?.textContent).toStartWith("1 day ");
expect(container.querySelector(".ds-calendar-range-label")?.textContent).toContain("Sep 4, 2026");
expect(container.querySelector(".ds-date-inspector")?.textContent).toContain("September 4, 2026");
});

View File

@@ -1,3 +1,5 @@
import { habitProgressColor } from "../../lib/habit-palette";
import type { Theme } from "../../lib/theme";
export const PROGRESS_SHADES = [
"#eeeeee",
"#d8d8d8",
@@ -103,7 +105,7 @@ export function describeDay(day: CalendarDay, unit: string) {
}
/** Only show shades that the chart's completion targets can produce. */
export function legendShades(days: CalendarDay[], color = "#111111") {
export function legendShades(days: CalendarDay[], color = "#111111", theme?: Theme) {
const levels = new Set<number>();
for (const target of new Set(days.map((day) => day.target))) {
if (!Number.isFinite(target) || target <= 0) continue;
@@ -118,7 +120,7 @@ export function legendShades(days: CalendarDay[], color = "#111111") {
...[...levels]
.sort((a, b) => a - b)
.map((value) =>
progressShade({ date: "", value, target: 8, state: "due" }, color),
theme ? habitProgressColor(color, (value - 1) / 7, theme) : progressShade({ date: "", value, target: 8, state: "due" }, color),
),
];
}

View File

@@ -39,14 +39,14 @@ export function SectionHeading({
children,
id,
}: {
number: string;
number?: string;
title: ReactNode;
children?: ReactNode;
id?: string;
}) {
return (
<header className="ds-section-heading">
<span className="ds-eyebrow">{number}</span>
{number && <span className="ds-eyebrow">{number}</span>}
<h2 id={id} tabIndex={-1}>{title}</h2>
{children && <p>{children}</p>}
</header>

View File

@@ -0,0 +1,50 @@
import { afterEach, beforeEach, expect, test } from 'bun:test';
import { fixture } from './test-fixture';
let f: ReturnType<typeof fixture>;
beforeEach(() => { f = fixture(); });
afterEach(() => f.close());
async function seeded() {
const habit = await f.json('/habits', 'POST', { name: 'Routine', method: 'tasks', color: '#397f76', tasks: [{ name: 'Stretch' }] }, 201);
await f.json(`/habits/${habit.id}/days/2026-09-04/tasks/${habit.tasks[0].id}`, 'PUT', { done: true });
const other = await f.json('/habits', 'POST', { name: 'Keep', method: 'manual' }, 201);
const mixed = await f.json('/charts', 'POST', { name: 'Mixed', habitIds: [habit.id, other.id] }, 201);
const solo = await f.json('/charts', 'POST', { name: 'Solo', habitIds: [habit.id] }, 201);
await f.request(`/habits/${habit.id}`, 'DELETE');
return { habit, other, mixed, solo };
}
test('permanent deletion removes all dependent records and repairs combined charts', async () => {
const { habit, other, mixed, solo } = await seeded();
const dayIds = f.sqlite.query('SELECT id FROM habit_days WHERE habit_id = ?').all(habit.id) as { id: number }[];
expect(dayIds.length).toBeGreaterThan(0);
expect(f.sqlite.query('SELECT count(*) AS n FROM progress_events').get()).toEqual({ n: 1 });
expect((await f.request(`/habits/${habit.id}/permanent`, 'DELETE')).status).toBe(204);
for (const table of ['habit_days', 'habit_revisions', 'habit_calendar_settings']) {
expect(f.sqlite.query(`SELECT count(*) AS n FROM ${table} WHERE habit_id = ?`).get(habit.id)).toEqual({ n: 0 });
}
for (const day of dayIds) for (const table of ['progress_events', 'task_occurrences']) {
expect(f.sqlite.query(`SELECT count(*) AS n FROM ${table} WHERE day_id = ?`).get(day.id)).toEqual({ n: 0 });
}
expect((await f.request(`/habits/${habit.id}/history`)).status).toBe(404);
expect((await f.request(`/habits/${habit.id}`, 'PATCH', { archived: false })).status).toBe(404);
expect((await f.json(`/charts/${mixed.id}`)).habitIds).toEqual([other.id]);
expect((await f.request(`/charts/${solo.id}`)).status).toBe(404);
expect((await f.json('/habits?archived=true')).habits.map((h: { id: string }) => h.id)).toEqual([other.id]);
});
test('deletion requires ownership, same origin, authentication, and an archived habit', async () => {
const { habit, other } = await seeded();
expect((await f.request(`/habits/${habit.id}/permanent`, 'DELETE', undefined, 'b')).status).toBe(404);
expect((await f.request(`/habits/${habit.id}/permanent`, 'DELETE', undefined, 'x')).status).toBe(401);
expect((await f.request(`/habits/${habit.id}/permanent`, 'DELETE', undefined, 'a', { Origin: 'https://elsewhere.example' })).status).toBe(403);
expect((await f.request(`/habits/${other.id}/permanent`, 'DELETE')).status).toBe(409);
expect((await f.json(`/habits/${habit.id}`)).archived).toBe(true);
});
test('a failed deletion rolls back history and chart changes', async () => {
const { habit, mixed, solo } = await seeded();
const before = f.sqlite.query('SELECT * FROM progress_events').all();
f.sqlite.exec("CREATE TRIGGER reject_habit_delete BEFORE DELETE ON habits BEGIN SELECT RAISE(ABORT, 'test failure'); END");
expect((await f.request(`/habits/${habit.id}/permanent`, 'DELETE')).status).toBe(500);
expect(f.sqlite.query('SELECT * FROM progress_events').all()).toEqual(before);
expect((await f.json(`/charts/${mixed.id}`)).habitIds).toContain(habit.id);
expect((await f.request(`/charts/${solo.id}`)).status).toBe(200);
expect((await f.json(`/habits/${habit.id}`)).archived).toBe(true);
});

View File

@@ -62,6 +62,7 @@ export function createHabitRoutes(db: AppDatabase, auth: ReturnType<typeof creat
});
app.get('/habits/:id', c => c.json(service(c).current(id(c))));
app.patch('/habits/:id', c => c.json(service(c).edit(id(c), body(c, habitPatch))));
app.delete('/habits/:id/permanent', c => { service(c).permanentlyDelete(id(c)); return c.body(null, 204); });
app.delete('/habits/:id', c => { service(c).edit(id(c), { archived: true }); return c.body(null, 204); });
app.get('/habits/:id/history', c => c.json({ revisions: service(c).history(id(c)) }));
app.get('/habits/:id/tasks', c => c.json({ tasks: service(c).tasks(id(c)) }));

View File

@@ -1,4 +1,4 @@
import { and, asc, desc, eq, lte, sql } from 'drizzle-orm';
import { and, asc, desc, eq, inArray, lte, sql } from 'drizzle-orm';
import type { AppDatabase } from '../auth';
import type { PublicUser } from '../shared/user';
import { habits, habitRevisions, habitDays, taskOccurrences, progressEvents, habitCalendarSettings, combinedCharts, users } from '../db/schema';
@@ -90,6 +90,25 @@ export class HabitService {
return updated;
});
}
permanentlyDelete(id: string) {
this.db.transaction(() => {
if (!this.current(id).archived) throw new ApiError(409, 'Archive the habit before permanently deleting it');
const days = this.db.select({ id: habitDays.id }).from(habitDays).where(eq(habitDays.habitId, id));
this.db.delete(progressEvents).where(inArray(progressEvents.dayId, days)).run();
this.db.delete(taskOccurrences).where(inArray(taskOccurrences.dayId, days)).run();
this.db.delete(habitDays).where(eq(habitDays.habitId, id)).run();
this.db.delete(habitRevisions).where(eq(habitRevisions.habitId, id)).run();
this.db.delete(habitCalendarSettings).where(eq(habitCalendarSettings.habitId, id)).run();
for (const chart of this.db.select().from(combinedCharts).where(eq(combinedCharts.userId, this.user.id)).all()) {
if (!chart.habitIds.includes(id)) continue;
const habitIds = chart.habitIds.filter(habitId => habitId !== id);
if (habitIds.length) this.db.update(combinedCharts).set({ habitIds, updatedAt: this.now }).where(eq(combinedCharts.id, chart.id)).run();
else this.db.delete(combinedCharts).where(eq(combinedCharts.id, chart.id)).run();
}
this.db.delete(habits).where(eq(habits.id, id)).run();
});
this.invalidate(id);
}
revise(id: string, config: HabitConfig) {
this.invalidate(id);
this.owned(id);

View File

@@ -3,8 +3,10 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Minabot</title>
<meta name="description" content="A quiet place to track your habits and see your progress. Explore Minabot’s interactive habit calendars and connect with Discord." />
<link rel="icon" type="image/png" sizes="32x32" href="./assets/favicon.png" />
<link rel="apple-touch-icon" sizes="180x180" href="./assets/apple-touch-icon.png" />
<title>Mina Habits</title>
<meta name="description" content="A quiet place to track your habits and see your progress. Explore interactive habit calendars and connect with Discord with Mina Habits." />
<script type="module" src="./frontend.tsx" async></script>
</head>
<body>

View File

@@ -0,0 +1,26 @@
import { expect, test } from "bun:test";
import { habitProgressColor, themedHabitShade } from "./habit-palette";
import { HABIT_PALETTES } from "../components/design-system/HabitColorPicker";
import { shade } from "../habits/calendar";
const luminance = (hex: string) => [1, 3, 5].map(i => parseInt(hex.slice(i, i+2),16)/255).map(v => v <= .04045 ? v/12.92 : ((v+.055)/1.055)**2.4).reduce((n,v,i) => n+v*[.2126,.7152,.0722][i]!,0);
test("all suggested and extreme custom colors have eight distinct ordered steps in both themes", () => {
for (const base of [...HABIT_PALETTES.flatMap(p=>[...p.colors]), '#ffffff','#000000','#ffff00','#0000ff','#ff0000','#00ff00']) {
for (const theme of ['light','dark'] as const) {
const colors = Array.from({length:8},(_,i)=>habitProgressColor(base,i/7,theme));
expect(new Set(colors).size).toBe(8);
const levels=colors.map(luminance);
for(let i=1;i<8;i++) expect(theme==='dark' ? levels[i]! > levels[i-1]! : levels[i]! < levels[i-1]!).toBe(true);
const empty=luminance(theme==='dark'?'#39413b':'#eeeeee');
expect(theme==='dark' ? levels[0]! > empty : levels[0]! < empty).toBe(true);
}
}
});
test("server shade quantization maps to the same ramp as picker preview, including bright custom colors", () => {
for (const base of ['#426582','#ffffff','#ffff00','#000000']) for (const theme of ['light','dark'] as const) {
const colors=Array.from({length:8},(_,i)=>shade((i+1)/8,8,{mainColor:base} as any,true).color);
const mapped=colors.map(c=>themedHabitShade(c,base,theme));
expect(mapped[0]).toBe(habitProgressColor(base,0,theme));
expect(mapped[7]).toBe(habitProgressColor(base,1,theme));
expect(new Set(mapped).size).toBe(8);
}
});

49
src/lib/habit-palette.ts Normal file
View File

@@ -0,0 +1,49 @@
import type { Theme } from "./theme";
const rgb = (hex: string) => [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16) / 255);
const linear = (v: number) => v <= .04045 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4;
const encoded = (v: number) => v <= .0031308 ? v * 12.92 : 1.055 * v ** (1 / 2.4) - .055;
const cache = new Map<string, string>();
/** A hue-preserving OKLab ramp; equal progress steps have equal lightness steps. */
export function habitProgressColor(base: string, intensity: number, theme: Theme): string {
if (!/^#[0-9a-f]{6}$/i.test(base)) base = "#58765b";
const t = Math.max(0, Math.min(1, intensity));
const key = `${base}:${t.toFixed(5)}:${theme}`;
const cached = cache.get(key);
if (cached) return cached;
const [r, g, b] = rgb(base).map(linear) as [number, number, number];
const l = Math.cbrt(.4122214708*r + .5363325363*g + .0514459929*b);
const m = Math.cbrt(.2119034982*r + .6806995451*g + .1073969566*b);
const s = Math.cbrt(.0883024619*r + .2817188376*g + .6299787005*b);
const a = 1.9779984951*l - 2.428592205*m + .4505937099*s;
const bb = .0259040371*l + .7827717662*m - .808675766*s;
const chroma = Math.hypot(a, bb);
const L = theme === "dark" ? .43 + .37*t : .86 - .38*t;
let C = Math.min(chroma, .16) * (.5 + .5*t);
let channels: number[] = [];
// Reduce chroma instead of clipping RGB, preserving hue at the gamut boundary.
for (let attempt = 0; attempt < 40; attempt++, C *= .9) {
const A = chroma > 1e-6 ? a*C/chroma : 0;
const B = chroma > 1e-6 ? bb*C/chroma : 0;
const ll = (L + .3963377774*A + .2158037573*B) ** 3;
const mm = (L - .1055613458*A - .0638541728*B) ** 3;
const ss = (L - .0894841775*A - 1.291485548*B) ** 3;
channels = [4.0767416621*ll - 3.3077115913*mm + .2309699292*ss, -1.2684380046*ll + 2.6097574011*mm - .3413193965*ss, -.0041960863*ll - .7034186147*mm + 1.707614701*ss];
if (channels.every(v => v >= 0 && v <= 1)) break;
}
const result = "#" + channels.map(v => Math.round(Math.max(0, Math.min(1, encoded(v)))*255).toString(16).padStart(2, "0")).join("");
if (cache.size > 4096) cache.clear();
cache.set(key, result);
return result;
}
/** Recover the server's existing step intensity without changing scoring or quantization. */
export function themedHabitShade(shade: string, base: string, theme: Theme): string {
if (!/^#[0-9a-f]{6}$/i.test(shade) || !/^#[0-9a-f]{6}$/i.test(base)) return habitProgressColor(base, 1, theme);
const color = rgb(base), sample = rgb(shade);
const anchor = .2126*color[0]! + .7152*color[1]! + .0722*color[2]! > .75 ? 0 : 1;
const distance = color.reduce((sum, v) => sum + (v-anchor)**2, 0);
const intensity = distance ? color.reduce((sum, v, i) => sum + (sample[i]!-anchor)*(v-anchor), 0)/distance : 1;
return habitProgressColor(base, Math.max(0, (intensity-.2)/.8), theme);
}

View File

@@ -0,0 +1,24 @@
import { expect, test } from "bun:test";
import { cardCalendar, cardStats } from "./progress-card";
import type { ShareData } from "../sharing/contracts";
const habit = (days: ShareData["habits"][number]["days"]): ShareData["habits"][number] => ({ name: "Reading", color: "#90647e", completed: days.filter(d => d.complete).length, scheduled: days.filter(d => d.due).length, days, legend: { empty: "#eee", partial: ["#ccc"], complete: "#90647e" } });
const days = [
{ date: "2024-02-28", due: true, complete: true, ratio: 1, color: "#90647e" },
{ date: "2024-02-29", due: true, complete: false, ratio: .5, color: "#ccc" },
{ date: "2024-03-01", due: false, complete: false, ratio: null, color: "#fff" },
{ date: "2024-03-02", due: true, complete: false, ratio: 0, color: "#eee" },
{ date: "2024-03-03", due: true, complete: true, ratio: 1, color: "#90647e" },
];
test("share calendar preserves dashboard week positions, leap days, shades and days off", () => {
const chart = cardCalendar(habit(days));
expect(chart.entries.map(d => [d.column, d.row])).toEqual([[1, 4], [1, 5], [1, 6], [1, 7], [2, 1]]);
expect(chart.entries.map(d => d.day.color)).toEqual(days.map(d => d.color));
expect(chart.entries[2]!.day.state).toBe("not-due");
expect(chart.entries[1]!.day.value).toBe(.5);
});
test("period summary counts the whole selected range, not the last day's completion", () => {
const data: ShareData = { from: days[0]!.date, to: days.at(-1)!.date, timezone: "UTC", habits: [habit(days)] };
expect(cardStats(data)).toEqual({ completed: 2, scheduled: 4, percent: 50 });
expect(cardStats({ ...data, from: data.to, habits: [habit(days.slice(-1))] })).toEqual({ completed: 1, scheduled: 1, percent: 100 });
expect(cardStats({ ...data, habits: [habit([days[2]!])] }).percent).toBeNull();
});

View File

@@ -1,5 +1,8 @@
import { themedHabitShade } from "./habit-palette";
import { themePalette, type Theme } from "./theme";
import type { ShareData } from "../sharing/contracts";
import type { PublicUser } from "../shared/user";
import { calendarTimeline } from "../components/design-system/calendar-model";
import { dayNumber } from "../habits/calendar";
export type CardPrivacy = { name: boolean; avatar: boolean; habitNames: boolean; timezone: boolean };
@@ -10,6 +13,14 @@ export const cardStats = (data: ShareData) => {
return { completed, scheduled, percent: scheduled ? Math.round(completed / scheduled * 100) : null };
};
/** Reuse the dashboard's Sunday-first week geometry and preserve each server-supplied shade. */
export function cardCalendar(habit: ShareData["habits"][number]) {
return calendarTimeline(habit.days.map(day => ({
date: day.date, value: day.ratio ?? 0, target: 1,
state: day.due ? "due" as const : "not-due" as const, color: day.color,
})));
}
async function loadAvatar(url: string): Promise<HTMLImageElement | null> {
return new Promise(resolve => {
const img = new Image();
@@ -22,117 +33,105 @@ async function loadAvatar(url: string): Promise<HTMLImageElement | null> {
});
}
/** Render once: this exact PNG is previewed, downloaded, and sent to Discord. */
export async function renderProgressCard(data: ShareData, user: PublicUser, privacy: CardPrivacy): Promise<{ blob: Blob; alt: string; avatarMissing: boolean }> {
await document.fonts.load('76px "Instrument Serif"');
const [avatar, botAvatar] = await Promise.all([
privacy.avatar && user.avatarUrl ? loadAvatar(user.avatarUrl) : null,
data.botAvatarUrl ? loadAvatar(data.botAvatarUrl) : null,
]);
/** Compact summary: fixed typography stays readable when Discord scales the attachment. */
export async function renderProgressCard(data: ShareData, user: PublicUser, privacy: CardPrivacy, theme: Theme = "light"): Promise<{ blob: Blob; alt: string; avatarMissing: boolean }> {
await document.fonts.load('64px "Instrument Serif"');
const avatar = privacy.avatar && user.avatarUrl ? await loadAvatar(user.avatarUrl) : null;
const canvas = document.createElement("canvas");
const longRange = data.habits[0]!.days.length > 42;
const titleGraphGap = 12;
const rowHeight = (longRange ? 280 : 250) + titleGraphGap;
canvas.width = 1200; canvas.height = 380 + data.habits.length * rowHeight + 76;
const shortRange = dayNumber(data.to) - dayNumber(data.from) < 84;
// Reserve the full seven-row grid plus a separate legend line.
// Long ranges stay full-width so month labels and habit names never compete.
const panelHeight = shortRange ? 444 : 344;
const rows = data.habits.length;
const height = 356 + rows * panelHeight + (privacy.timezone ? 44 : 0);
// Keep the server’s 1200px PNG contract while drawing on a 960-unit grid.
canvas.width = 1200;
canvas.height = height * 1.25;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Your browser could not create the image. Try another browser.");
const left = 56;
const right = 1144;
const chartLeft = 80;
const chartWidth = right - chartLeft;
const legendColumns = [chartLeft, chartLeft + 266, chartLeft + 532, chartLeft + 798] as const;
ctx.scale(1.25, 1.25);
const { ink, muted, paper, empty, rule, avatar: avatarBackground, dot } = themePalette[theme];
const green = "#285d49";
const sans = '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
const text = (value: string, x: number, y: number, size = 22, color = "#666", serif = false, maxWidth = 1088, align: CanvasTextAlign = "left") => {
ctx.font = `${size}px ${serif ? '"Instrument Serif", Georgia, serif' : sans}`;
ctx.fillStyle = color;
ctx.textAlign = align;
const text = (value: string, x: number, y: number, size = 32, color: string = ink, weight = "400", maxWidth = 864, align: CanvasTextAlign = "left", serif = false) => {
ctx.font = `${weight} ${size}px ${serif ? '"Instrument Serif", Georgia, serif' : sans}`;
ctx.fillStyle = color; ctx.textAlign = align;
const chars = Array.from(value);
let fitted = value;
if (ctx.measureText(fitted).width > maxWidth) {
const chars = Array.from(value);
while (chars.length && ctx.measureText(`${chars.join("")}…`).width > maxWidth) chars.pop();
fitted = `${chars.join("")}…`;
}
while (chars.length && ctx.measureText(fitted).width > maxWidth) { chars.pop(); fitted = `${chars.join("")}…`; }
ctx.fillText(fitted, x, y);
};
const rect = (x: number, y: number, w: number, h: number, color: string) => { ctx.fillStyle = color; ctx.fillRect(x, y, w, h); };
rect(0, 0, 1200, canvas.height, "#fff");
rect(0, 0, 1200, 8, data.habits[0]?.color ?? "#111");
let profileX = 56;
const rect = (x: number, y: number, w: number, h: number, color: string, radius = 0) => {
if (w <= 0) return;
ctx.fillStyle = color; ctx.beginPath(); ctx.roundRect(x, y, w, h, radius); ctx.fill();
};
rect(0, 0, 960, height, paper);
rect(0, 0, 960, 8, data.habits[0]?.color ?? green);
let profileX = 48;
if (privacy.avatar) {
ctx.save(); ctx.beginPath(); ctx.arc(88, 84, 32, 0, Math.PI * 2); ctx.clip();
rect(56, 52, 64, 64, "#f1f1f1");
if (avatar) ctx.drawImage(avatar, 56, 52, 64, 64);
else { ctx.fillStyle = "#888"; ctx.beginPath(); ctx.arc(88, 77, 10, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(88, 109, 22, 0, Math.PI * 2); ctx.fill(); }
ctx.restore(); profileX = 138;
ctx.save(); ctx.beginPath(); ctx.arc(80, 78, 32, 0, Math.PI * 2); ctx.clip();
rect(48, 46, 64, 64, avatarBackground);
if (avatar) ctx.drawImage(avatar, 48, 46, 64, 64);
else { ctx.fillStyle = muted; ctx.beginPath(); ctx.arc(80, 71, 11, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(80, 105, 23, 0, Math.PI * 2); ctx.fill(); }
ctx.restore(); profileX = 128;
}
text(privacy.name ? user.displayName || user.username : "A little, every day.", profileX, 94, 26, "#111", false, 700);
if (botAvatar) {
ctx.font = '34px "Instrument Serif", Georgia, serif';
const avatarX = right - ctx.measureText("minabot.").width - 12 - 40;
ctx.save();
ctx.beginPath();
ctx.roundRect(avatarX, 64, 40, 40, 4);
ctx.clip();
ctx.drawImage(botAvatar, avatarX, 64, 40, 40);
ctx.restore();
}
text("minabot.", right, 94, 34, "#111", true, 112, "right");
text("Small steps, adding up.", left, 213, 76, "#111", true);
text(`${cardDate(data.from)} — ${cardDate(data.to)}`, left, 260, 23);
text(privacy.name ? user.displayName || user.username : "My progress", profileX, 90, 34, ink, "600", 560 - profileX);
text("Mina Habits.", 912, 92, 44, ink, "400", 254, "right", true);
const daysInRange = dayNumber(data.to) - dayNumber(data.from) + 1;
text("A little, every day.", 48, 193, 72, ink, "400", 864, "left", true);
text(`${cardDate(data.from)} – ${cardDate(data.to)}`, 48, 244, 34, muted);
const stats = cardStats(data);
text(`${stats.completed} / ${stats.scheduled} scheduled check-ins complete`, left, 317, 24, "#111", false, 800);
text(stats.percent === null ? "No days scheduled" : `${stats.percent}% complete`, right, 317, 24, "#111", false, 240, "right");
data.habits.forEach((habit, index) => {
const top = 358 + index * rowHeight;
rect(left, top, right - left, 1, "#dedede");
rect(left, top + 27, 6, 24, habit.color);
text(privacy.habitNames ? habit.name : `Habit ${index + 1}`, chartLeft, top + 48, 24, "#111", false, 760);
text(`${habit.completed} / ${habit.scheduled} days`, right, top + 48, 21, "#666", false, 185, "right");
ctx.save();
ctx.translate(0, titleGraphGap);
if (habit.days.length <= 42) {
const step = (chartWidth + 5) / habit.days.length;
habit.days.forEach((day, i) => {
const x = chartLeft + i * step;
if (day.due) rect(x, top + 78, Math.max(8, step - 5), 62, day.color);
else { ctx.fillStyle = "#aaa"; ctx.beginPath(); ctx.arc(x + (step - 5) / 2, top + 109, 3, 0, Math.PI * 2); ctx.fill(); }
if (habit.days.length <= 7 || i % 7 === 0) text(day.date.slice(8), x + (step - 5) / 2, top + 169, 17, "#666", false, step - 5, "center");
});
const top = 284 + index * panelHeight;
const chart = cardCalendar(habit);
rect(48, top, 864, 1, rule);
// Short ranges get a spacious calendar beside their period total. Longer
// ranges use the full card width, keeping a year as one unbroken timeline.
const chartOrigin = shortRange ? 490 : 96;
const chartY = top + (shortRange ? 79 : 100);
const chartWidth = shortRange ? 422 : 816;
const step = Math.min(shortRange ? 39 : 22, chartWidth / Math.max(1, chart.weeks));
const cell = step - (shortRange ? 5 : 3);
const chartX = chartOrigin + (shortRange ? (chartWidth - chart.weeks * step) / 2 : 0);
if (shortRange) {
text(privacy.habitNames ? habit.name : `Habit ${index + 1}`, 48, top + 48, 38, ink, "600", 864);
text(`${habit.completed}`, 48, top + 179, 104, ink, "600", 320);
text("check-ins complete", 48, top + 228, 34, ink, "500", 360);
text(`of ${habit.scheduled} scheduled`, 48, top + 275, 32, muted, "400", 340);
text(`over ${daysInRange} days`, 48, top + 318, 32, muted, "400", 340);
} else {
const offset = (new Date(`${data.from}T12:00:00Z`).getUTCDay() + 6) % 7;
const columns = Math.ceil((offset + habit.days.length) / 7);
const step = (chartWidth + 4) / columns;
const startX = chartLeft;
text("M", left, top + 95, 14); text("W", left, top + 135, 14); text("F", left, top + 175, 14);
let previousMonthX = -100;
habit.days.forEach((day, i) => {
const n = offset + i, x = startX + Math.floor(n / 7) * step, y = top + 82 + n % 7 * 20;
if ((i === 0 || day.date.endsWith("-01")) && x - previousMonthX > 45 && x < 1108) {
text(new Date(`${day.date}T12:00:00Z`).toLocaleDateString("en", { month: "short", timeZone: "UTC" }), x, top + 70, 14);
previousMonthX = x;
}
if (day.due) rect(x, y, step - 4, 16, day.color);
else { ctx.fillStyle = "#aaa"; ctx.beginPath(); ctx.arc(x + (step - 4) / 2, y + 8, 2, 0, Math.PI * 2); ctx.fill(); }
text(privacy.habitNames ? habit.name : `Habit ${index + 1}`, 48, top + 48, 36, ink, "600", 560);
text(`${habit.completed} / ${habit.scheduled} complete`, 912, top + 48, 32, muted, "400", 290, "right");
}
for (const row of [1, 3, 5]) text(["S", "M", "T", "W", "T", "F", "S"][row]!, chartX - 18, chartY + row * step + cell * .8, shortRange ? 26 : 18, muted, "400", 30, "right");
if (!shortRange) {
let lastLabelX = -100;
chart.months.forEach(month => {
const x = chartX + (month.column - 1) * step;
if (x - lastLabelX < 100 || x > 850) return;
text(new Date(`${month.month}-01T12:00:00Z`).toLocaleDateString("en", { month: "short", timeZone: "UTC" }), x, chartY - 16, 26, muted, "400", 90);
lastLabelX = x;
});
}
const legendY = top + (longRange ? 241 : 209);
rect(legendColumns[0], legendY, 16, 16, habit.legend.empty);
text("No progress", legendColumns[0] + 26, legendY + 15, 18);
if (habit.legend.partial.length) {
habit.legend.partial.forEach((color, i) => rect(legendColumns[1] + i * 19, legendY, 16, 16, color));
text("Partial", legendColumns[1] + habit.legend.partial.length * 19 + 10, legendY + 15, 18);
}
rect(legendColumns[2], legendY, 16, 16, habit.legend.complete);
text("Complete", legendColumns[2] + 26, legendY + 15, 18);
ctx.fillStyle = "#aaa"; ctx.beginPath(); ctx.arc(legendColumns[3] + 8, legendY + 8, 3, 0, Math.PI * 2); ctx.fill();
text("Not scheduled", legendColumns[3] + 26, legendY + 15, 18);
ctx.restore();
chart.entries.forEach(({ day, column, row }) => {
const x = chartX + (column - 1) * step, y = chartY + (row - 1) * step;
if (day.state === "not-due") {
ctx.fillStyle = dot; ctx.beginPath(); ctx.arc(x + cell / 2, y + cell / 2, Math.max(1.5, cell * .07), 0, Math.PI * 2); ctx.fill();
} else rect(x, y, cell, cell, day.value === 0 ? empty : themedHabitShade(day.color!, habit.color, theme), Math.min(3, cell / 6));
});
const legendY = top + panelHeight - 38;
text("0", 48, legendY + 8, 28, muted);
const shades = [empty, ...[...habit.legend.partial, habit.legend.complete].map(shade => themedHabitShade(shade, habit.color, theme))];
shades.forEach((color, i) => rect(76 + i * 25, legendY - 12, 20, 20, color, 2));
text("Complete", 90 + shades.length * 25, legendY + 8, 28, muted);
ctx.fillStyle = dot; ctx.beginPath(); ctx.arc(693, legendY - 2, 3, 0, Math.PI * 2); ctx.fill();
text("Not scheduled", 912, legendY + 8, 28, muted, "400", 200, "right");
});
const footerY = canvas.height - 46;
text("Days off don’t count against you.", left, footerY, 19);
text(privacy.timezone ? data.timezone : `${dayNumber(data.to) - dayNumber(data.from) + 1} days of small steps`, right, footerY, 19, "#666", false, 350, "right");
const footerY = 284 + rows * panelHeight + 36;
text("Totals cover this entire period", 48, footerY, 30, muted);
if (privacy.timezone) text(data.timezone, 48, footerY + 44, 30, muted);
const blob = await new Promise<Blob>((resolve, reject) => canvas.toBlob(value => value ? resolve(value) : reject(new Error("Could not export the card.")), "image/png"));
const names = privacy.habitNames ? data.habits.map(h => h.name).join(", ") : `${data.habits.length} habits`;
const names = data.habits.map((h, i) => `${privacy.habitNames ? h.name : `Habit ${i + 1}`}: ${h.completed} of ${h.scheduled} scheduled check-ins complete`).join(". ");
return { blob, avatarMissing: privacy.avatar && !!user.avatarUrl && !avatar,
alt: `${privacy.name ? `${user.displayName || user.username}: ` : ""}${names}. ${cardDate(data.from)} to ${cardDate(data.to)}. ${stats.completed} of ${stats.scheduled} scheduled check-ins complete. Legend: empty squares mean no progress, intermediate shades mean partial progress, full color means complete, and dots mean not scheduled.${privacy.timezone ? ` ${data.timezone}.` : ""}` };
alt: `${privacy.name ? `${user.displayName || user.username}: ` : ""}${cardDate(data.from)} to ${cardDate(data.to)}. ${stats.completed} of ${stats.scheduled} check-ins complete over ${daysInRange} days. ${names}. Calendar squares show daily progress; dots mean not scheduled. Totals cover the entire selected period; days off are excluded.${privacy.timezone ? ` ${data.timezone}.` : ""}` };
}

19
src/lib/theme.test.ts Normal file
View File

@@ -0,0 +1,19 @@
import { expect, test } from "bun:test";
import { resolveTheme, themePreference, themePalette } from "./theme";
test("system default follows the OS, while saved explicit choices take precedence", () => {
for (const value of [null, "invalid", "system"]) expect(themePreference(value)).toBe("system");
expect(resolveTheme(themePreference(null), true)).toBe("dark");
expect(resolveTheme("system", false)).toBe("light");
expect(resolveTheme("light", true)).toBe("light");
expect(resolveTheme("dark", false)).toBe("dark");
});
const luminance = (hex: string) => {
const rgb = hex.slice(1).match(/../g)!.map(value => parseInt(value, 16) / 255).map(value => value <= .04045 ? value / 12.92 : ((value + .055) / 1.055) ** 2.4);
return rgb[0]! * .2126 + rgb[1]! * .7152 + rgb[2]! * .0722;
};
test("both export palettes provide AA contrast for primary and secondary text", () => {
for (const palette of Object.values(themePalette)) for (const foreground of [palette.ink, palette.muted]) {
const values = [luminance(foreground), luminance(palette.paper)].sort((a, b) => a - b);
expect((values[1]! + .05) / (values[0]! + .05)).toBeGreaterThanOrEqual(4.5);
}
});

9
src/lib/theme.ts Normal file
View File

@@ -0,0 +1,9 @@
export type Theme = "light" | "dark";
export type ThemePreference = Theme | "system";
export const THEME_STORAGE_KEY = "minabot-theme";
export const themePreference = (value: string | null): ThemePreference => value === "light" || value === "dark" ? value : "system";
export const resolveTheme = (preference: ThemePreference, systemDark: boolean): Theme => preference === "system" ? systemDark ? "dark" : "light" : preference;
export const themePalette = {
light: { ink: "#202a26", muted: "#54635c", paper: "#f8f7f2", empty: "#eeeeee", rule: "#d5dcd3", avatar: "#e1e7df", dot: "#768174" },
dark: { ink: "#edf2ee", muted: "#b3beb7", paper: "#191e1b", empty: "#39413b", rule: "#414b44", avatar: "#38463d", dot: "#a0aca2" },
} as const;

View File

@@ -28,7 +28,7 @@ export function DesignSystem() {
<PageLayout mainId="ds-main" wordmarkTo="/design-system" header={<span className="ds-library-label">Interface library / 01</span>}>
<header className="ds-library-intro">
<div>
<p className="ds-eyebrow">MINABOT — INTERFACE LANGUAGE</p>
<p className="ds-eyebrow">MINA HABITS — INTERFACE LANGUAGE</p>
<h1 id="ds-title">Design system<span aria-hidden="true">.</span></h1>
</div>
<span className="ds-library-note">Interactive examples · nothing is saved</span>
@@ -92,7 +92,7 @@ export function DesignSystem() {
/>
<p>
{color.name}
<span className="ds-code">{color.value} · --ds-{color.token}</span>
<span className="ds-code">--ds-{color.token}</span>
</p>
</div>
))}

View File

@@ -1,4 +1,4 @@
import { Archive, Settings2 } from "lucide-react";
import { Archive, Plus, Settings2 } from "lucide-react";
import { ArchivedHabits } from "../components/ArchivedHabits";
import { AccountSettings } from "../components/AccountSettings";
import { useCallback, useEffect, useRef, useState } from "react";
@@ -442,7 +442,6 @@ function Dashboard({ user, onExpired }: { user: PublicUser; onExpired: () => voi
>
<div className="ds-section-top">
<SectionHeading
number="YOUR HABITS"
id="habits-title"
title={
<>
@@ -581,7 +580,7 @@ function SavedHabit({
disabled={disabled || habit.requirements.tasks.length >= 100}
onClick={() => setAddingTask(true)}
>
Add task +
<Plus size={20} aria-hidden="true" /><span>Add task</span>
</Button>
</div>
</>

View File

@@ -148,8 +148,8 @@ export function createSharingRoutes(db: AppDatabase, auth: ReturnType<typeof cre
return c.json({ error: `Sharing limit reached. Try again in ${retryAfter} seconds.` }, 429);
}
const attachment = new FormData();
attachment.set("payload_json", JSON.stringify({ allowed_mentions: { parse: [] }, nonce: createHash("sha256").update(`${userId}:${id.data}`).digest("hex").slice(0, 24), enforce_nonce: true, attachments: [{ id: 0, filename: "minabot-progress.png", description: "Progress card shared from minabot" }] }));
attachment.set("files[0]", image, "minabot-progress.png");
attachment.set("payload_json", JSON.stringify({ allowed_mentions: { parse: [] }, nonce: createHash("sha256").update(`${userId}:${id.data}`).digest("hex").slice(0, 24), enforce_nonce: true, attachments: [{ id: 0, filename: "mina-habits-progress.png", description: "Progress card shared from Mina Habits" }] }));
attachment.set("files[0]", image, "mina-habits-progress.png");
let response: Response;
try { response = await request(`${channelEndpoint}/messages`, { method: "POST", headers, body: attachment, redirect: "error", signal: AbortSignal.timeout(15000) }); }
catch { return c.json({ status: "uncertain" }); }