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");