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

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

View File

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

View File

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