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:
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
BIN
src/assets/apple-touch-icon.png
Normal file
BIN
src/assets/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
BIN
src/assets/favicon.png
Normal file
BIN
src/assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
@@ -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> :
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
47
src/components/ThemeProvider.tsx
Normal file
47
src/components/ThemeProvider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
50
src/habits/permanent-delete.test.ts
Normal file
50
src/habits/permanent-delete.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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)) }));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
26
src/lib/habit-palette.test.ts
Normal file
26
src/lib/habit-palette.test.ts
Normal 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
49
src/lib/habit-palette.ts
Normal 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);
|
||||
}
|
||||
24
src/lib/progress-card.test.ts
Normal file
24
src/lib/progress-card.test.ts
Normal 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();
|
||||
});
|
||||
@@ -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
19
src/lib/theme.test.ts
Normal 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
9
src/lib/theme.ts
Normal 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;
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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" }); }
|
||||
|
||||
Reference in New Issue
Block a user