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:
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);
|
||||
|
||||
Reference in New Issue
Block a user