From 85376a54b3b1a0b32fbf6da7dd9c148b73bacee7 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Tue, 8 Sep 2026 16:13:48 +0200 Subject: [PATCH] Bound habit day and calendar reads to requested history --- src/habits/performance.test.ts | 23 +++++++++++++ src/habits/service.ts | 61 ++++++++++++++++++++++++++-------- 2 files changed, 71 insertions(+), 13 deletions(-) create mode 100644 src/habits/performance.test.ts diff --git a/src/habits/performance.test.ts b/src/habits/performance.test.ts new file mode 100644 index 0000000..e8979dd --- /dev/null +++ b/src/habits/performance.test.ts @@ -0,0 +1,23 @@ +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(); } +}); diff --git a/src/habits/service.ts b/src/habits/service.ts index 1239f98..3b2911b 100644 --- a/src/habits/service.ts +++ b/src/habits/service.ts @@ -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, 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'; @@ -18,6 +18,8 @@ export class HabitService { private ownedCache = new Map(); private revisionCache = new Map(); private deadlineCache = new Map(); + private ranges = new Map(); + private revisionRanges = new Map(); private recordsCache = new Map>(); private occurrencesCache = new Map>(); private countCache = new Map>(); @@ -35,10 +37,23 @@ export class HabitService { if (!habit) throw missing(); 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 { - let revisions = this.revisionCache.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); } - return revisions.find(r => r.effectiveDate <= date); + const range = this.revisionRanges.get(id); + if (!range || date < range.from || date > range.to) this.loadRevisions(id, date, 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); } current(id: string) { const habit = this.owned(id); const revision = this.revision(id)!; @@ -51,7 +66,7 @@ export class HabitService { } list(includeArchived = false) { 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) { const id = crypto.randomUUID(); @@ -149,7 +164,7 @@ export class HabitService { }); } 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) { let value = this.deadlineCache.get(date); @@ -160,7 +175,7 @@ export class HabitService { let records = this.recordsCache.get(id); if (!records) { 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); } return records; @@ -169,7 +184,7 @@ export class HabitService { let occurrences = this.occurrencesCache.get(id); if (!occurrences) { 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); } this.occurrencesCache.set(id, occurrences); } @@ -206,10 +221,29 @@ export class HabitService { } private counts(id: string) { const cached = this.countCache.get(id); if (cached) return cached; - const rows = this.db.select({ day: habitDays, config: habitRevisions.config }).from(habitDays) - .innerJoin(habitRevisions, eq(habitDays.revisionId, habitRevisions.id)) - .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})`)) - .orderBy(asc(habitDays.date)).all(); + const range = this.ranges.get(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})`; + const select = () => this.db.select({ day: habitDays, config: habitRevisions.config }).from(habitDays).innerJoin(habitRevisions, eq(habitDays.revisionId, habitRevisions.id)); + 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[0]; + 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(); let carry = 0; let source: string | null = null; for (const { day, config } of rows) { @@ -223,6 +257,7 @@ export class HabitService { } day(id: string, date: string) { this.owned(id); + this.range(id, date); const revision = this.revision(id, date); const record = this.records(id).get(date); const config = revision?.config; @@ -307,7 +342,7 @@ export class HabitService { } 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) { - ids.forEach(id => this.owned(id)); + ids.forEach(id => { this.owned(id); this.range(id, from, to); }); const days = []; for (let date = from; date <= to; date = addDays(date, 1)) { const details = ids.map(id => this.day(id, date));