Merge backend performance improvements

This commit is contained in:
syntaxbullet
2026-09-08 16:35:45 +02:00
10 changed files with 2700 additions and 42 deletions

View File

@@ -0,0 +1 @@
ALTER TABLE habits ADD COLUMN calendar_revision integer NOT NULL DEFAULT 0;

View File

@@ -0,0 +1 @@
CREATE INDEX task_occurrences_open_idx ON task_occurrences (day_id) WHERE closed_at IS NULL;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -92,6 +92,20 @@
"when": 1788847978168, "when": 1788847978168,
"tag": "0012_discord_commands", "tag": "0012_discord_commands",
"breakpoints": true "breakpoints": true
},
{
"idx": 13,
"version": "6",
"when": 1788860000000,
"tag": "0013_habit_calendar_revision",
"breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1788860000001,
"tag": "0014_open_occurrences",
"breakpoints": true
} }
] ]
} }

View File

@@ -8,9 +8,8 @@ import { HabitService } from "../habits/service";
export function createAccountRoutes(db: AppDatabase, auth: ReturnType<typeof createAuth>, now: () => number) { export function createAccountRoutes(db: AppDatabase, auth: ReturnType<typeof createAuth>, now: () => number) {
const app = new Hono<AuthEnv>(); const app = new Hono<AuthEnv>();
app.use("*", auth.requireAuth); app.use("*", auth.requireAuth);
app.get("/export", c => { app.get("/export", c => HabitService.withPrepared(db, c.get("user"), now, () => {
const user = c.get("user"); const user = c.get("user");
const service = new HabitService(db, user, now()); service.sync();
const data = db.transaction(tx => { const data = db.transaction(tx => {
const owned = tx.select({ id: schema.habits.id }).from(schema.habits).where(eq(schema.habits.userId, user.id)); const owned = tx.select({ id: schema.habits.id }).from(schema.habits).where(eq(schema.habits.userId, user.id));
const days = tx.select({ id: schema.habitDays.id }).from(schema.habitDays).where(inArray(schema.habitDays.habitId, owned)); const days = tx.select({ id: schema.habitDays.id }).from(schema.habitDays).where(inArray(schema.habitDays.habitId, owned));
@@ -31,11 +30,12 @@ export function createAccountRoutes(db: AppDatabase, auth: ReturnType<typeof cre
}); });
c.header("Content-Disposition", 'attachment; filename="mina-habits-export.json"'); c.header("Content-Disposition", 'attachment; filename="mina-habits-export.json"');
return c.json(data); return c.json(data);
}); }));
app.delete("/", auth.requireSameOrigin, bodyLimit({ maxSize: 1024 }), async c => { app.delete("/", auth.requireSameOrigin, bodyLimit({ maxSize: 1024 }), async c => {
const body = await c.req.json().catch(() => null); const body = await c.req.json().catch(() => null);
if (body?.confirmation !== "DELETE") return c.json({ error: "Type DELETE to confirm account deletion." }, 422); if (body?.confirmation !== "DELETE") return c.json({ error: "Type DELETE to confirm account deletion." }, 422);
const userId = c.get("user").id; const userId = c.get("user").id;
return HabitService.withPrepared(db, c.get("user"), now, () => {
db.transaction(tx => { db.transaction(tx => {
const owned = tx.select({ id: schema.habits.id }).from(schema.habits).where(eq(schema.habits.userId, userId)); const owned = tx.select({ id: schema.habits.id }).from(schema.habits).where(eq(schema.habits.userId, userId));
const days = tx.select({ id: schema.habitDays.id }).from(schema.habitDays).where(inArray(schema.habitDays.habitId, owned)); const days = tx.select({ id: schema.habitDays.id }).from(schema.habitDays).where(inArray(schema.habitDays.habitId, owned));
@@ -51,5 +51,6 @@ export function createAccountRoutes(db: AppDatabase, auth: ReturnType<typeof cre
auth.clearSession(c); auth.clearSession(c);
return c.body(null, 204); return c.body(null, 204);
}); });
});
return app; return app;
} }

View File

@@ -1,3 +1,4 @@
import { sql } from 'drizzle-orm';
import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
export const users = sqliteTable("users", { export const users = sqliteTable("users", {
@@ -33,6 +34,7 @@ export const discordDeliveries = sqliteTable("discord_deliveries", {
// Revisions are append-only, including multiple edits on the same local date. // Revisions are append-only, including multiple edits on the same local date.
export const habits = sqliteTable('habits', { export const habits = sqliteTable('habits', {
id: text('id').primaryKey(), userId: text('user_id').notNull().references(() => users.id), id: text('id').primaryKey(), userId: text('user_id').notNull().references(() => users.id),
calendarRevision: integer('calendar_revision').notNull().default(0),
createdDate: text('created_date').notNull(), materializedThrough: text('materialized_through'), createdAt: integer('created_at').notNull(), createdDate: text('created_date').notNull(), materializedThrough: text('materialized_through'), createdAt: integer('created_at').notNull(),
}, t => [index('habits_user_idx').on(t.userId)]); }, t => [index('habits_user_idx').on(t.userId)]);
export const habitRevisions = sqliteTable('habit_revisions', { export const habitRevisions = sqliteTable('habit_revisions', {
@@ -47,7 +49,7 @@ export const habitDays = sqliteTable('habit_days', {
export const taskOccurrences = sqliteTable('task_occurrences', { export const taskOccurrences = sqliteTable('task_occurrences', {
id: text('id').primaryKey(), dayId: integer('day_id').notNull().references(() => habitDays.id), taskId: text('task_id').notNull(), name: text('name').notNull(), id: text('id').primaryKey(), dayId: integer('day_id').notNull().references(() => habitDays.id), taskId: text('task_id').notNull(), name: text('name').notNull(),
done: integer('done', { mode: 'boolean' }).notNull().default(false), expiredAt: integer('expired_at'), closedAt: integer('closed_at'), updatedAt: integer('updated_at').notNull(), done: integer('done', { mode: 'boolean' }).notNull().default(false), expiredAt: integer('expired_at'), closedAt: integer('closed_at'), updatedAt: integer('updated_at').notNull(),
}, t => [index('task_occurrences_day_idx').on(t.dayId)]); }, t => [index('task_occurrences_day_idx').on(t.dayId), index('task_occurrences_open_idx').on(t.dayId).where(sql`${t.closedAt} IS NULL`)]);
export const progressEvents = sqliteTable('progress_events', { export const progressEvents = sqliteTable('progress_events', {
id: integer('id').primaryKey({ autoIncrement: true }), dayId: integer('day_id').notNull().references(() => habitDays.id), occurrenceId: text('occurrence_id'), id: integer('id').primaryKey({ autoIncrement: true }), dayId: integer('day_id').notNull().references(() => habitDays.id), occurrenceId: text('occurrence_id'),
before: text('before', { mode: 'json' }).$type<unknown>().notNull(), after: text('after', { mode: 'json' }).$type<unknown>().notNull(), createdAt: integer('created_at').notNull(), before: text('before', { mode: 'json' }).$type<unknown>().notNull(), after: text('after', { mode: 'json' }).$type<unknown>().notNull(), createdAt: integer('created_at').notNull(),

View File

@@ -0,0 +1,85 @@
import { expect, test } from 'bun:test';
import { fixture } from './test-fixture';
import { HabitService } from './service';
import { publicUser } from '../auth';
import { users } from '../db/schema';
import { eq } from 'drizzle-orm';
test('single-day reads load only the selected latest snapshot and its tasks', async () => {
const f = fixture();
try {
const habit = await f.json('/habits', 'POST', { name: 'Tasks', method: 'tasks', tasks: [{name:'Read'}] }, 201);
f.setTime('2027-09-04T12:00:00Z');
await f.json('/today');
const logs: string[] = [];
(f.db as any).session.logger = { logQuery(query: string) { logs.push(query); } };
const service = new HabitService(f.db, publicUser(f.db.select().from(users).where(eq(users.id,'alice')).get()!), Date.parse('2027-09-04T12:00:00Z'));
const day = service.day(habit.id, '2027-09-04');
expect(day.tasks).toHaveLength(1);
expect((service as any).recordsCache.get(habit.id).size).toBe(1);
expect((service as any).occurrencesCache.get(habit.id).size).toBe(1);
expect(logs.find(query => query.includes('task_occurrences'))).toContain('"habit_days"."date" >=');
} finally { f.close(); }
});
test('calendar summaries omit editor payloads and mutation versions cover historical changes', async () => {
const f = fixture();
try {
const habit = await f.json('/habits', 'POST', { name: 'Tasks', method: 'tasks', tasks: [{name:'Read'}] }, 201);
const first = (await f.json('/today')).habits[0];
expect((await f.json('/today')).habits[0].calendarRevision).toBe(first.calendarRevision);
f.setTime('2026-09-05T12:00:00Z');
await f.json(`/habits/${habit.id}/days/2026-09-04/tasks/${first.tasks[0].taskId}`, 'PUT', {done:true});
const changed = (await f.json('/today')).habits[0].calendarRevision;
expect(changed).toBeGreaterThan(first.calendarRevision);
await f.json(`/habits/${habit.id}/calendar-settings`, 'PUT', {mainColor:'#123456'});
expect((await f.json('/today')).habits[0].calendarRevision).toBeGreaterThan(changed);
const url = `/habits/${habit.id}/calendar?from=2026-09-04&to=2026-09-05`;
const full = await f.json(url);
const compact = await f.json(url+'&compact=true');
const detail = full.days[0].habits[0];
const {tasks, requirements, ...summary} = detail;
expect(compact.days[0].habits[0]).toEqual(summary);
expect(compact.data).toEqual(full.data);
expect((await f.json(`/habits/${habit.id}/days/2026-09-04`)).tasks[0].done).toBe(true);
} finally { f.close(); }
});
test('unchanged dashboard and calendar reads write no rows after synchronization', async () => {
const f = fixture();
try {
const habit = await f.json('/habits', 'POST', {name:'Tasks', method:'tasks', tasks:[{name:'Read'}]}, 201);
f.setTime('2027-09-04T12:00:00Z');
await f.json('/today');
const before = (f.sqlite.query('SELECT total_changes() AS n').get() as {n:number}).n;
await f.json('/today');
await f.json(`/habits/${habit.id}/calendar?from=2027-09-01&to=2027-09-04&compact=true`);
expect((f.sqlite.query('SELECT total_changes() AS n').get() as {n:number}).n).toBe(before);
} finally {f.close();}
});
test('catch-up yields to other users and serializes same-user edits until fully ready', async () => {
const f = fixture();
try {
const habit = await f.json('/habits', 'POST', {name:'Many tasks', method:'tasks', tasks:Array.from({length:50}, (_,i)=>({name:`Task ${i}`}))}, 201);
f.setTime('2027-09-04T12:00:00Z');
let complete = false;
const dashboard = f.json('/today').then(value => {complete=true; return value;});
await new Promise(resolve=>setTimeout(resolve,0));
expect(complete).toBe(false);
// Another user's request can complete while Alice is still catching up.
expect((await f.request('/today','GET',undefined,'b')).status).toBe(200);
expect(complete).toBe(false);
// The actual date can advance while catch-up yields; readiness revalidates it.
f.setTime('2027-09-05T12:00:00Z');
const edit = f.json(`/habits/${habit.id}`, 'PATCH', {name:'Renamed'});
const [today, changed] = await Promise.all([dashboard, edit]);
expect(today.date).toBe('2027-09-05');
expect(today.habits[0].tasks).toHaveLength(50);
expect(changed.name).toBe('Renamed');
const through = f.sqlite.query('SELECT materialized_through AS date FROM habits WHERE id=?').get(habit.id) as {date:string};
expect(through.date).toBe('2027-09-05');
const day = await f.json(`/habits/${habit.id}/days/2026-09-05`);
expect(day.tasks.every((task:any)=>task.expiredAt!==null && task.closedAt!==null)).toBe(true);
} finally {f.close();}
});

View File

@@ -20,7 +20,7 @@ function date(c: Context<Env>) {
return result.data; return result.data;
} }
function range(c: Context<Env>) { function range(c: Context<Env>) {
const result = z.object({ from: dateSchema, to: dateSchema }).strict().safeParse(c.req.query()); const result = z.object({ from: dateSchema, to: dateSchema, compact: z.enum(['true', 'false']).optional() }).strict().safeParse(c.req.query());
if (!result.success || result.data.from > result.data.to || dayNumber(result.data.to) - dayNumber(result.data.from) > 1829) throw new ApiError(422, 'Supply from and to dates in order, spanning at most 1830 days'); if (!result.success || result.data.from > result.data.to || dayNumber(result.data.to) - dayNumber(result.data.from) > 1829) throw new ApiError(422, 'Supply from and to dates in order, spanning at most 1830 days');
return result.data; return result.data;
} }
@@ -41,8 +41,9 @@ export function createHabitRoutes(db: AppDatabase, auth: ReturnType<typeof creat
try { payload.value = await c.req.json(); } catch { payload.malformed = true; } try { payload.value = await c.req.json(); } catch { payload.malformed = true; }
} }
c.set('payload', payload); c.set('payload', payload);
const service = new HabitService(db, c.get('user'), now()); await HabitService.withPrepared(db, c.get('user'), now, async service => {
service.sync(); c.set('service', service); await next(); c.set('service', service); await next();
});
}); if (response) c.res = response; }; }); if (response) c.res = response; };
if (!['GET', 'HEAD', 'OPTIONS'].includes(c.req.method)) { if (!['GET', 'HEAD', 'OPTIONS'].includes(c.req.method)) {
const response = await origin(c, proceed); if (response) c.res = response; const response = await origin(c, proceed); if (response) c.res = response;
@@ -80,7 +81,7 @@ export function createHabitRoutes(db: AppDatabase, auth: ReturnType<typeof creat
app.get('/habits/:id/calendar-settings', c => c.json(service(c).settings(id(c)))); app.get('/habits/:id/calendar-settings', c => c.json(service(c).settings(id(c))));
app.put('/habits/:id/calendar-settings', c => c.json(service(c).saveSettings(id(c), body(c, calendarSettingsSchema)))); app.put('/habits/:id/calendar-settings', c => c.json(service(c).saveSettings(id(c), body(c, calendarSettingsSchema))));
app.get('/habits/:id/calendar', c => { app.get('/habits/:id/calendar', c => {
const { from, to } = range(c); return c.json(service(c).calendar([id(c)], from, to, service(c).settings(id(c)), false)); const { from, to, compact } = range(c); return c.json(service(c).calendar([id(c)], from, to, service(c).settings(id(c)), false, compact === 'true'));
}); });
app.get('/today', c => { app.get('/today', c => {
const s = service(c); const habits = s.list().map(h => s.day(h.id, s.today)); const s = service(c); const habits = s.list().map(h => s.day(h.id, s.today));
@@ -100,7 +101,7 @@ export function createHabitRoutes(db: AppDatabase, auth: ReturnType<typeof creat
}); });
app.delete('/charts/:id', c => { service(c).deleteChart(id(c)); return c.body(null, 204); }); app.delete('/charts/:id', c => { service(c).deleteChart(id(c)); return c.body(null, 204); });
app.get('/charts/:id/calendar', c => { app.get('/charts/:id/calendar', c => {
const chart = service(c).chart(id(c)); const { from, to } = range(c); return c.json(service(c).calendar(chart.habitIds, from, to, chart.settings, true)); const chart = service(c).chart(id(c)); const { from, to, compact } = range(c); return c.json(service(c).calendar(chart.habitIds, from, to, chart.settings, true, compact === 'true'));
}); });
app.get('/charts/:id/days/:date', c => { app.get('/charts/:id/days/:date', c => {
const chart = service(c).chart(id(c)); const day = date(c); return c.json(service(c).calendar(chart.habitIds, day, day, chart.settings, true).days[0]); const chart = service(c).chart(id(c)); const day = date(c); return c.json(service(c).calendar(chart.habitIds, day, day, chart.settings, true).days[0]);

View File

@@ -1,4 +1,4 @@
import { and, asc, desc, eq, inArray, lte, sql } from 'drizzle-orm'; import { and, asc, desc, eq, inArray, lte, gte, gt, lt, or, isNull, sql } from 'drizzle-orm';
import type { AppDatabase } from '../auth'; import type { AppDatabase } from '../auth';
import type { PublicUser } from '../shared/user'; import type { PublicUser } from '../shared/user';
import { habits, habitRevisions, habitDays, taskOccurrences, progressEvents, habitCalendarSettings, combinedCharts, users } from '../db/schema'; import { habits, habitRevisions, habitDays, taskOccurrences, progressEvents, habitCalendarSettings, combinedCharts, users } from '../db/schema';
@@ -13,17 +13,59 @@ export type Habit = typeof habits.$inferSelect;
export type Revision = typeof habitRevisions.$inferSelect; export type Revision = typeof habitRevisions.$inferSelect;
export type HabitDay = typeof habitDays.$inferSelect; export type HabitDay = typeof habitDays.$inferSelect;
// Each database has its own per-user readiness queue. No transaction spans a yield.
const preparationQueues = new WeakMap<AppDatabase, Map<string, Promise<void>>>();
export class HabitService { export class HabitService {
static async withPrepared<T>(db: AppDatabase, user: PublicUser, now: () => number, run: (service: HabitService) => T | Promise<T>): Promise<T> {
let queues = preparationQueues.get(db);
if (!queues) { queues = new Map(); preparationQueues.set(db, queues); }
const previous = queues.get(user.id);
let release!: () => void;
const ticket = new Promise<void>(resolve => { release = resolve; });
queues.set(user.id, ticket);
if (previous) await previous;
try {
let sliceStarted = performance.now();
while (true) {
// Re-read timezone, date and owned records after every scheduling yield.
const service = new HabitService(db, user, now());
const habit = service.pending()[0];
if (!habit) {
service.sync();
return await run(service);
}
const start = habit.materializedThrough ? addDays(habit.materializedThrough, 1) : habit.createdDate;
const through = [addDays(start, 7), service.today].sort()[0]!;
db.transaction(() => service.materialize(habit, through));
if (performance.now() - sliceStarted >= 8) {
await new Promise<void>(resolve => setTimeout(resolve, 0));
sliceStarted = performance.now();
}
}
} finally {
release();
if (queues.get(user.id) === ticket) queues.delete(user.id);
}
}
/** Ready read snapshot. Use withPrepared for read-modify-write handlers. */
static prepare(db: AppDatabase, user: PublicUser, now: () => number) {
return HabitService.withPrepared(db, user, now, service => service);
}
readonly today: string; readonly today: string;
private ownedCache = new Map<string, Habit>(); private ownedCache = new Map<string, Habit>();
private revisionCache = new Map<string, Revision[]>(); private revisionCache = new Map<string, Revision[]>();
private deadlineCache = new Map<string, number>(); private deadlineCache = new Map<string, number>();
private ranges = new Map<string, { from: string; to: string }>();
private revisionRanges = new Map<string, { from: string; to: string }>();
private recordsCache = new Map<string, Map<string, HabitDay>>(); private recordsCache = new Map<string, Map<string, HabitDay>>();
private occurrencesCache = new Map<string, Map<number, (typeof taskOccurrences.$inferSelect)[]>>(); private occurrencesCache = new Map<string, Map<number, (typeof taskOccurrences.$inferSelect)[]>>();
private countCache = new Map<string, Map<string, { value: number; carriedFrom: string | null }>>(); private countCache = new Map<string, Map<string, { value: number; carriedFrom: string | null }>>();
constructor(readonly db: AppDatabase, readonly user: PublicUser, readonly now: number) { constructor(readonly db: AppDatabase, readonly user: PublicUser, readonly now: number) {
// A different request may update the timezone while this request's body streams in. // A different request may update the timezone while this request's body streams in.
const timezone = db.select({ timezone: users.timezone }).from(users).where(eq(users.id, user.id)).get()!.timezone; const currentUser = db.select({ timezone: users.timezone }).from(users).where(eq(users.id, user.id)).get();
if (!currentUser) throw missing();
const timezone = currentUser.timezone;
this.user = { ...user, timezone }; this.user = { ...user, timezone };
// Travel west must never reopen or rewrite earlier requirements. // Travel west must never reopen or rewrite earlier requirements.
const latest = db.select({ date: habits.materializedThrough }).from(habits).where(eq(habits.userId, user.id)).orderBy(desc(habits.materializedThrough)).get()?.date; const latest = db.select({ date: habits.materializedThrough }).from(habits).where(eq(habits.userId, user.id)).orderBy(desc(habits.materializedThrough)).get()?.date;
@@ -35,10 +77,27 @@ export class HabitService {
if (!habit) throw missing(); if (!habit) throw missing();
this.ownedCache.set(id, habit); return habit; this.ownedCache.set(id, habit); return habit;
} }
private loadRevisions(id: string, from: string, to: string) {
const first = this.db.select().from(habitRevisions).where(and(eq(habitRevisions.habitId, id), lte(habitRevisions.effectiveDate, from))).orderBy(desc(habitRevisions.effectiveDate), desc(habitRevisions.id)).get();
const later = from === to ? [] : this.db.select().from(habitRevisions).where(and(eq(habitRevisions.habitId, id), gt(habitRevisions.effectiveDate, from), lte(habitRevisions.effectiveDate, to))).orderBy(desc(habitRevisions.effectiveDate), desc(habitRevisions.id)).all();
this.revisionCache.set(id, first ? [...later, first] : later);
this.revisionRanges.set(id, { from, to });
}
revision(id: string, date = this.today): Revision | undefined { revision(id: string, date = this.today): Revision | undefined {
let revisions = this.revisionCache.get(id); const range = this.revisionRanges.get(id);
if (!revisions) { revisions = this.db.select().from(habitRevisions).where(eq(habitRevisions.habitId, id)).orderBy(desc(habitRevisions.effectiveDate), desc(habitRevisions.id)).all(); this.revisionCache.set(id, revisions); } if (!range || date < range.from || date > range.to) this.loadRevisions(id, date, date);
return revisions.find(r => r.effectiveDate <= date); return this.revisionCache.get(id)!.find(r => r.effectiveDate <= date);
}
private range(id: string, from: string, to = from) {
const old = this.ranges.get(id);
if (old && from >= old.from && to <= old.to) return;
this.ranges.set(id, { from, to });
this.recordsCache.delete(id); this.occurrencesCache.delete(id); this.countCache.delete(id);
this.loadRevisions(id, from, to);
}
private changed(id: string) {
this.db.update(habits).set({ calendarRevision: sql`${habits.calendarRevision} + 1` }).where(eq(habits.id, id)).run();
this.ownedCache.delete(id);
} }
current(id: string) { current(id: string) {
const habit = this.owned(id); const revision = this.revision(id)!; const habit = this.owned(id); const revision = this.revision(id)!;
@@ -47,11 +106,11 @@ export class HabitService {
? [{ code: 'no_tasks', taskId: null, message: 'Add a task to make this habit due.' }] ? [{ code: 'no_tasks', taskId: null, message: 'Add a task to make this habit due.' }]
: config.tasks.filter(task => !schedulesOverlap(config.schedule, task.schedule)) : config.tasks.filter(task => !schedulesOverlap(config.schedule, task.schedule))
.map(task => ({ code: 'task_never_due', taskId: task.id, message: `Task \"${task.name}\" never coincides with the habit schedule.` })); .map(task => ({ code: 'task_never_due', taskId: task.id, message: `Task \"${task.name}\" never coincides with the habit schedule.` }));
return { id: habit.id, ...config, warnings, createdDate: habit.createdDate, createdAt: habit.createdAt, revisionId: revision.id, effectiveDate: revision.effectiveDate }; return { id: habit.id, calendarRevision: habit.calendarRevision, ...config, warnings, createdDate: habit.createdDate, createdAt: habit.createdAt, revisionId: revision.id, effectiveDate: revision.effectiveDate };
} }
list(includeArchived = false) { list(includeArchived = false) {
return this.db.select().from(habits).where(eq(habits.userId, this.user.id)).orderBy(asc(habits.createdAt), asc(habits.id)).all() return this.db.select().from(habits).where(eq(habits.userId, this.user.id)).orderBy(asc(habits.createdAt), asc(habits.id)).all()
.map(h => this.current(h.id)).filter(h => includeArchived || !h.archived); .map(h => { this.ownedCache.set(h.id, h); return this.current(h.id); }).filter(h => includeArchived || !h.archived);
} }
create(input: ReturnType<typeof habitInput.parse>) { create(input: ReturnType<typeof habitInput.parse>) {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
@@ -114,7 +173,8 @@ export class HabitService {
this.owned(id); this.owned(id);
this.db.transaction(() => { this.db.transaction(() => {
this.db.insert(habitRevisions).values({ habitId: id, effectiveDate: this.today, config, createdAt: this.now }).run(); this.db.insert(habitRevisions).values({ habitId: id, effectiveDate: this.today, config, createdAt: this.now }).run();
this.materialize(this.owned(id)); this.materialize(this.owned(id), this.today, true);
this.changed(id);
}); });
return this.current(id); return this.current(id);
} }
@@ -137,19 +197,25 @@ export class HabitService {
} }
sync() { sync() {
this.db.transaction(() => { this.db.transaction(() => {
for (const habit of this.db.select().from(habits).where(eq(habits.userId, this.user.id)).all()) this.materialize(habit); for (const habit of this.pending()) this.materialize(habit);
// Record the original deadline even after downtime. Only active day snapshots expire. // Start with the small partial index of open occurrences rather than
// revisiting every historical snapshot on every unchanged request.
this.db.run(sql`UPDATE task_occurrences SET this.db.run(sql`UPDATE task_occurrences SET
expired_at = CASE WHEN done = 0 THEN COALESCE(expired_at, (SELECT ends_at FROM habit_days WHERE id = task_occurrences.day_id)) ELSE expired_at END, expired_at = CASE WHEN done = 0 THEN COALESCE(expired_at, (SELECT ends_at FROM habit_days WHERE id = task_occurrences.day_id)) ELSE expired_at END,
closed_at = (SELECT ends_at FROM habit_days WHERE id = task_occurrences.day_id) closed_at = (SELECT ends_at FROM habit_days WHERE id = task_occurrences.day_id)
WHERE closed_at IS NULL AND day_id IN ( WHERE id IN (
SELECT d.id FROM habit_days d JOIN habits h ON h.id = d.habit_id SELECT o.id FROM task_occurrences o INDEXED BY task_occurrences_open_idx
WHERE h.user_id = ${this.user.id} AND d.ends_at <= ${this.now} CROSS JOIN habit_days d ON d.id = o.day_id
CROSS JOIN habits h ON h.id = d.habit_id
WHERE o.closed_at IS NULL AND h.user_id = ${this.user.id} AND d.ends_at <= ${this.now}
AND d.id = (SELECT MAX(d2.id) FROM habit_days d2 WHERE d2.habit_id = d.habit_id AND d2.date = d.date))`); AND d.id = (SELECT MAX(d2.id) FROM habit_days d2 WHERE d2.habit_id = d.habit_id AND d2.date = d.date))`);
}); });
} }
private pending() {
return this.db.select().from(habits).where(and(eq(habits.userId, this.user.id), or(isNull(habits.materializedThrough), lt(habits.materializedThrough, this.today)))).all();
}
private invalidate(id: string) { private invalidate(id: string) {
this.countCache.delete(id); this.revisionCache.delete(id); this.recordsCache.delete(id); this.occurrencesCache.delete(id); this.ownedCache.delete(id); this.ranges.delete(id); this.revisionRanges.delete(id); this.countCache.delete(id); this.revisionCache.delete(id); this.recordsCache.delete(id); this.occurrencesCache.delete(id); this.ownedCache.delete(id);
} }
private deadline(date: string) { private deadline(date: string) {
let value = this.deadlineCache.get(date); let value = this.deadlineCache.get(date);
@@ -160,7 +226,7 @@ export class HabitService {
let records = this.recordsCache.get(id); let records = this.recordsCache.get(id);
if (!records) { if (!records) {
records = new Map(); records = new Map();
for (const day of this.db.select().from(habitDays).where(eq(habitDays.habitId, id)).orderBy(asc(habitDays.id)).all()) records.set(day.date, day); for (const day of this.db.select().from(habitDays).where(and(eq(habitDays.habitId, id), gte(habitDays.date, this.ranges.get(id)!.from), lte(habitDays.date, this.ranges.get(id)!.to), sql`${habitDays.id} = (SELECT MAX(d.id) FROM habit_days d WHERE d.habit_id = ${habitDays.habitId} AND d.date = ${habitDays.date})`)).all()) records.set(day.date, day);
this.recordsCache.set(id, records); this.recordsCache.set(id, records);
} }
return records; return records;
@@ -169,15 +235,17 @@ export class HabitService {
let occurrences = this.occurrencesCache.get(id); let occurrences = this.occurrencesCache.get(id);
if (!occurrences) { if (!occurrences) {
occurrences = new Map(); occurrences = new Map();
const rows = this.db.select({ occurrence: taskOccurrences }).from(taskOccurrences).innerJoin(habitDays, eq(taskOccurrences.dayId, habitDays.id)).where(eq(habitDays.habitId, id)).orderBy(asc(taskOccurrences.id)).all(); const rows = this.db.select({ occurrence: taskOccurrences }).from(taskOccurrences).innerJoin(habitDays, eq(taskOccurrences.dayId, habitDays.id)).where(and(eq(habitDays.habitId, id), gte(habitDays.date, this.ranges.get(id)!.from), lte(habitDays.date, this.ranges.get(id)!.to), sql`${habitDays.id} = (SELECT MAX(d.id) FROM habit_days d WHERE d.habit_id = ${habitDays.habitId} AND d.date = ${habitDays.date})`)).orderBy(asc(taskOccurrences.id)).all();
for (const { occurrence } of rows) { const entries = occurrences.get(occurrence.dayId) ?? []; entries.push(occurrence); occurrences.set(occurrence.dayId, entries); } for (const { occurrence } of rows) { const entries = occurrences.get(occurrence.dayId) ?? []; entries.push(occurrence); occurrences.set(occurrence.dayId, entries); }
this.occurrencesCache.set(id, occurrences); this.occurrencesCache.set(id, occurrences);
} }
return occurrences; return occurrences;
} }
private materialize(habit: Habit) { private materialize(habit: Habit, through = this.today, force = false) {
let date = habit.materializedThrough ?? habit.createdDate; let date = force ? through : habit.materializedThrough ? addDays(habit.materializedThrough, 1) : habit.createdDate;
while (date <= this.today) { if (date > through) return;
this.loadRevisions(habit.id, date, through);
while (date <= through) {
const revision = this.revision(habit.id, date)!; const revision = this.revision(habit.id, date)!;
const existing = this.db.select().from(habitDays).where(and(eq(habitDays.habitId, habit.id), eq(habitDays.date, date))).orderBy(desc(habitDays.id)).get(); const existing = this.db.select().from(habitDays).where(and(eq(habitDays.habitId, habit.id), eq(habitDays.date, date))).orderBy(desc(habitDays.id)).get();
if (!existing || existing.revisionId !== revision.id) { if (!existing || existing.revisionId !== revision.id) {
@@ -193,23 +261,43 @@ export class HabitService {
.innerJoin(habitDays, eq(taskOccurrences.dayId, habitDays.id)) .innerJoin(habitDays, eq(taskOccurrences.dayId, habitDays.id))
.where(and(eq(habitDays.habitId, habit.id), eq(habitDays.date, date))) .where(and(eq(habitDays.habitId, habit.id), eq(habitDays.date, date)))
.orderBy(desc(habitDays.id)).all().map(row => row.occurrence) : []; .orderBy(desc(habitDays.id)).all().map(row => row.occurrence) : [];
for (const task of revision.config.tasks.filter(t => scheduled(t.schedule, date))) { const occurrences = revision.config.tasks.filter(t => scheduled(t.schedule, date)).map(task => {
const old = previous.find(t => t.taskId === task.id); const old = previous.find(t => t.taskId === task.id);
this.db.insert(taskOccurrences).values({ id: crypto.randomUUID(), dayId: day.id, taskId: task.id, name: task.name, return { id: crypto.randomUUID(), dayId: day.id, taskId: task.id, name: task.name,
done: old?.done ?? false, expiredAt: old ? old.expiredAt : day.endsAt <= this.now ? day.endsAt : null, closedAt: old?.closedAt ?? (day.endsAt <= this.now ? day.endsAt : null), updatedAt: old?.updatedAt ?? this.now }).run(); done: old?.done ?? false, expiredAt: old ? old.expiredAt : day.endsAt <= this.now ? day.endsAt : null, closedAt: old?.closedAt ?? (day.endsAt <= this.now ? day.endsAt : null), updatedAt: old?.updatedAt ?? this.now };
} });
if (occurrences.length) this.db.insert(taskOccurrences).values(occurrences).run();
} }
} }
date = addDays(date, 1); date = addDays(date, 1);
} }
this.db.update(habits).set({ materializedThrough: this.today }).where(eq(habits.id, habit.id)).run(); this.db.update(habits).set({ materializedThrough: through }).where(eq(habits.id, habit.id)).run();
} }
private counts(id: string) { private counts(id: string) {
const cached = this.countCache.get(id); if (cached) return cached; const cached = this.countCache.get(id); if (cached) return cached;
const rows = this.db.select({ day: habitDays, config: habitRevisions.config }).from(habitDays) const range = this.ranges.get(id)!;
.innerJoin(habitRevisions, eq(habitDays.revisionId, habitRevisions.id)) const latest = sql`${habitDays.id} = (SELECT MAX(d.id) FROM habit_days d WHERE d.habit_id = ${habitDays.habitId} AND d.date = ${habitDays.date})`;
.where(and(eq(habitDays.habitId, id), sql`${habitDays.id} = (SELECT MAX(d.id) FROM habit_days d WHERE d.habit_id = ${habitDays.habitId} AND d.date = ${habitDays.date})`)) const select = () => this.db.select({ day: habitDays, config: habitRevisions.config }).from(habitDays).innerJoin(habitRevisions, eq(habitDays.revisionId, habitRevisions.id));
.orderBy(asc(habitDays.date)).all(); const rows = select().where(and(eq(habitDays.habitId, id), gte(habitDays.date, range.from), lte(habitDays.date, range.to), latest)).orderBy(asc(habitDays.date)).all();
// Only carry-enabled ranges need a prefix. Walk backwards in bounded pages
// until a logged value or requirements reset establishes the starting state.
const first = rows.find(({day, config}) => config.method !== 'count' || config.archived || scheduled(config.schedule, day.date));
if (first?.config.method === 'count' && first.config.carryPartialProgress && !first.day.countSet) {
let before = range.from;
const prefix: typeof rows = [];
let found = false;
while (!found) {
const page = select().where(and(eq(habitDays.habitId, id), lt(habitDays.date, before), latest)).orderBy(desc(habitDays.date)).limit(64).all();
if (!page.length) break;
for (const entry of page) {
prefix.push(entry);
const { day, config } = entry;
if (config.method !== 'count' || config.archived || (scheduled(config.schedule, day.date) && (day.countSet || !config.carryPartialProgress))) { found = true; break; }
}
before = page.at(-1)!.day.date;
}
rows.unshift(...prefix.reverse());
}
const result = new Map<string, { value: number; carriedFrom: string | null }>(); const result = new Map<string, { value: number; carriedFrom: string | null }>();
let carry = 0; let source: string | null = null; let carry = 0; let source: string | null = null;
for (const { day, config } of rows) { for (const { day, config } of rows) {
@@ -223,6 +311,7 @@ export class HabitService {
} }
day(id: string, date: string) { day(id: string, date: string) {
this.owned(id); this.owned(id);
this.range(id, date);
const revision = this.revision(id, date); const revision = this.revision(id, date);
const record = this.records(id).get(date); const record = this.records(id).get(date);
const config = revision?.config; const config = revision?.config;
@@ -235,7 +324,7 @@ export class HabitService {
const value = !due ? 0 : config!.method === 'count' ? counted?.value ?? 0 : config!.method === 'manual' ? Number(record?.done ?? false) : tasks.filter(t => t.done).length; const value = !due ? 0 : config!.method === 'count' ? counted?.value ?? 0 : config!.method === 'manual' ? Number(record?.done ?? false) : tasks.filter(t => t.done).length;
const target = !config ? null : config.method === 'count' ? config.target : config.method === 'manual' ? 1 : tasks.length; const target = !config ? null : config.method === 'count' ? config.target : config.method === 'manual' ? 1 : tasks.length;
const ratio = due ? Math.min(1, value / target!) : null; const ratio = due ? Math.min(1, value / target!) : null;
return { date, habitId: id, name: config?.name ?? null, method: config?.method ?? null, revisionId: revision?.id ?? null, return { date, habitId: id, calendarRevision: this.owned(id).calendarRevision, name: config?.name ?? null, method: config?.method ?? null, revisionId: revision?.id ?? null,
timezone: record?.timezone ?? this.user.timezone, endsAt: record?.endsAt ?? null, due, future, timezone: record?.timezone ?? this.user.timezone, endsAt: record?.endsAt ?? null, due, future,
status: future ? 'future' : !due ? 'not_due' : ratio === 1 ? 'complete' : value > 0 ? 'partial' : 'empty', status: future ? 'future' : !due ? 'not_due' : ratio === 1 ? 'complete' : value > 0 ? 'partial' : 'empty',
value, target, carriedFrom: counted?.carriedFrom ?? null, loggedCount: record?.countSet ? record.count : null, unit: config?.method === 'count' ? config.unit : config?.method === 'tasks' ? 'tasks' : 'completion', value, target, carriedFrom: counted?.carriedFrom ?? null, loggedCount: record?.countSet ? record.count : null, unit: config?.method === 'count' ? config.unit : config?.method === 'tasks' ? 'tasks' : 'completion',
@@ -256,6 +345,7 @@ export class HabitService {
this.db.transaction(() => { this.db.transaction(() => {
this.db.insert(progressEvents).values({ dayId: record.id, before: { count: detail.value, done: record.done, loggedCount: record.countSet ? record.count : null, carriedFrom: detail.carriedFrom }, after: input, createdAt: this.now }).run(); this.db.insert(progressEvents).values({ dayId: record.id, before: { count: detail.value, done: record.done, loggedCount: record.countSet ? record.count : null, carriedFrom: detail.carriedFrom }, after: input, createdAt: this.now }).run();
this.db.update(habitDays).set('count' in input ? { ...input, countSet: true } : input).where(eq(habitDays.id, record.id)).run(); this.db.update(habitDays).set('count' in input ? { ...input, countSet: true } : input).where(eq(habitDays.id, record.id)).run();
this.changed(id);
}); });
this.invalidate(id); this.invalidate(id);
return this.day(id, date); return this.day(id, date);
@@ -267,6 +357,7 @@ export class HabitService {
this.db.transaction(() => { this.db.transaction(() => {
this.db.insert(progressEvents).values({ dayId: record.id, occurrenceId: occurrence.id, before: { done: occurrence.done }, after: { done }, createdAt: this.now }).run(); this.db.insert(progressEvents).values({ dayId: record.id, occurrenceId: occurrence.id, before: { done: occurrence.done }, after: { done }, createdAt: this.now }).run();
this.db.update(taskOccurrences).set({ done, updatedAt: this.now }).where(eq(taskOccurrences.id, occurrence.id)).run(); this.db.update(taskOccurrences).set({ done, updatedAt: this.now }).where(eq(taskOccurrences.id, occurrence.id)).run();
this.changed(id);
}); });
this.invalidate(id); this.invalidate(id);
return this.day(id, date); return this.day(id, date);
@@ -290,6 +381,7 @@ export class HabitService {
saveSettings(id: string, settings: CalendarSettings) { saveSettings(id: string, settings: CalendarSettings) {
this.owned(id); this.owned(id);
this.db.insert(habitCalendarSettings).values({ habitId: id, settings }).onConflictDoUpdate({ target: habitCalendarSettings.habitId, set: { settings } }).run(); this.db.insert(habitCalendarSettings).values({ habitId: id, settings }).onConflictDoUpdate({ target: habitCalendarSettings.habitId, set: { settings } }).run();
this.changed(id);
return settings; return settings;
} }
chart(id: string) { chart(id: string) {
@@ -306,8 +398,8 @@ export class HabitService {
return this.chart(id); return this.chart(id);
} }
deleteChart(id: string) { this.chart(id); this.db.delete(combinedCharts).where(eq(combinedCharts.id, id)).run(); } deleteChart(id: string) { this.chart(id); this.db.delete(combinedCharts).where(eq(combinedCharts.id, id)).run(); }
calendar(ids: string[], from: string, to: string, settings: CalendarSettings, combined: boolean) { calendar(ids: string[], from: string, to: string, settings: CalendarSettings, combined: boolean, compact = false) {
ids.forEach(id => this.owned(id)); ids.forEach(id => { this.owned(id); this.range(id, from, to); });
const days = []; const days = [];
for (let date = from; date <= to; date = addDays(date, 1)) { for (let date = from; date <= to; date = addDays(date, 1)) {
const details = ids.map(id => this.day(id, date)); const details = ids.map(id => this.day(id, date));
@@ -318,7 +410,10 @@ export class HabitService {
const progress = shade(ratio, steps, settings, !combined && (settings.shadeCount === 'auto' || manual)); const progress = shade(ratio, steps, settings, !combined && (settings.shadeCount === 'auto' || manual));
const future = date > this.today; const future = date > this.today;
days.push({ date, due: due.length, completed, ratio, future, status: future ? 'future' : ratio === null ? 'not_due' : ratio === 1 ? 'complete' : ratio > 0 ? 'partial' : 'empty', days.push({ date, due: due.length, completed, ratio, future, status: future ? 'future' : ratio === null ? 'not_due' : ratio === 1 ? 'complete' : ratio > 0 ? 'partial' : 'empty',
shadeCount: steps, ...progress, color: future ? settings.futureColor : progress.color, habits: details }); shadeCount: steps, ...progress, color: future ? settings.futureColor : progress.color, habits: details.map(detail => {
const { tasks, requirements, ...summary } = detail;
return { ...summary, tasks: compact ? undefined : tasks, requirements: compact ? undefined : requirements };
}) });
} }
return { kind: combined ? 'combined' as const : 'individual' as const, from, to, today: this.today, timezone: this.user.timezone, settings, days, return { kind: combined ? 'combined' as const : 'individual' as const, from, to, today: this.today, timezone: this.user.timezone, settings, days,
// Explicit colors and details are supplied for the client's Nivo colorScale/tooltip adapters. // Explicit colors and details are supplied for the client's Nivo colorScale/tooltip adapters.