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'; import { addDays, endOfDay, localDate, scheduled, schedulesOverlap, shade } from './calendar'; import { calendarSettingsSchema, habitInput, type HabitConfig, type CalendarSettings } from './contracts'; export class ApiError extends Error { constructor(public status: 400 | 403 | 404 | 409 | 422 | 429 | 502 | 503, message: string) { super(message); } } const missing = () => new ApiError(404, 'Not found'); export type Habit = typeof habits.$inferSelect; export type Revision = typeof habitRevisions.$inferSelect; export type HabitDay = typeof habitDays.$inferSelect; export class HabitService { readonly today: string; 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>(); constructor(readonly db: AppDatabase, readonly user: PublicUser, readonly now: number) { // 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; this.user = { ...user, timezone }; // 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; this.today = [localDate(now, this.user.timezone), latest ?? ''].sort().at(-1)!; } owned(id: string): Habit { const cached = this.ownedCache.get(id); if (cached) return cached; const habit = this.db.select().from(habits).where(and(eq(habits.id, id), eq(habits.userId, this.user.id))).get(); 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 { 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); } 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) { const habit = this.owned(id); const revision = this.revision(id)!; const config = revision.config; const warnings = config.method !== 'tasks' ? [] : config.tasks.length === 0 ? [{ code: 'no_tasks', taskId: null, message: 'Add a task to make this habit due.' }] : 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.` })); return { id: habit.id, calendarRevision: habit.calendarRevision, ...config, warnings, createdDate: habit.createdDate, createdAt: habit.createdAt, revisionId: revision.id, effectiveDate: revision.effectiveDate }; } 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.ownedCache.set(h.id, h); return this.current(h.id); }).filter(h => includeArchived || !h.archived); } create(input: ReturnType) { const id = crypto.randomUUID(); const { color, ...requirements } = input; const config: HabitConfig = requirements.method === 'tasks' ? { ...requirements, archived: false, tasks: requirements.tasks.map(t => ({ ...t, id: crypto.randomUUID() })) } : { ...requirements, archived: false }; this.db.transaction(() => { this.db.insert(habits).values({ id, userId: this.user.id, createdDate: this.today, createdAt: this.now }).run(); this.db.insert(habitRevisions).values({ habitId: id, effectiveDate: this.today, config, createdAt: this.now }).run(); if (color) this.saveSettings(id, calendarSettingsSchema.parse({ mainColor: color })); this.materialize(this.owned(id)); }); return this.current(id); } edit(id: string, patch: Record) { const old = this.current(id); const method = patch.method ?? old.method; const base: Record = { name: patch.name ?? old.name, schedule: patch.schedule ?? old.schedule, method }; if (method === 'count') { base.carryPartialProgress = patch.carryPartialProgress ?? (old.method === 'count' ? old.carryPartialProgress : false); base.target = patch.target ?? (old.method === 'count' ? old.target : undefined); base.unit = patch.unit ?? (old.method === 'count' ? old.unit : 'steps'); } else if ('target' in patch || 'unit' in patch || 'carryPartialProgress' in patch) throw new ApiError(422, 'Target, unit and carryPartialProgress require the count method'); if (method === 'tasks') base.tasks = []; const parsed = habitInput.safeParse(base); if (!parsed.success) throw new ApiError(422, 'The selected method requires valid method-specific settings'); const config: HabitConfig = parsed.data.method === 'tasks' ? { ...parsed.data, tasks: old.method === 'tasks' ? old.tasks : [], archived: (patch.archived ?? old.archived) as boolean } : { ...parsed.data, archived: (patch.archived ?? old.archived) as boolean }; return this.db.transaction(() => { const updated = this.revise(id, config); if (patch.color !== undefined) { this.saveSettings(id, calendarSettingsSchema.parse({ ...this.settings(id), mainColor: patch.color })); } 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); this.db.transaction(() => { this.db.insert(habitRevisions).values({ habitId: id, effectiveDate: this.today, config, createdAt: this.now }).run(); this.materialize(this.owned(id)); this.changed(id); }); return this.current(id); } tasks(id: string) { const config = this.current(id); if (config.method !== 'tasks') throw new ApiError(409, 'Habit does not currently use tasks'); return config.tasks; } changeTask(id: string, taskId: string | undefined, input: { name?: string; schedule?: HabitConfig['schedule'] } | null) { const config = this.revision(this.owned(id).id)!.config; if (config.method !== 'tasks') throw new ApiError(409, 'Habit does not currently use tasks'); if (config.archived) throw new ApiError(409, 'Restore the habit before editing tasks'); if (taskId && !config.tasks.some(t => t.id === taskId)) throw missing(); const nextId = taskId ?? crypto.randomUUID(); const tasks = taskId ? config.tasks.flatMap(t => t.id !== taskId ? [t] : input ? [{ ...t, ...input }] : []) : [...config.tasks, { id: nextId, name: input!.name!, schedule: input!.schedule! }]; if (tasks.length > 100) throw new ApiError(422, 'A habit supports at most 100 tasks'); this.revise(id, { ...config, tasks }); return input ? tasks.find(t => t.id === nextId)! : undefined; } sync() { this.db.transaction(() => { for (const habit of this.db.select().from(habits).where(eq(habits.userId, this.user.id)).all()) this.materialize(habit); // Record the original deadline even after downtime. Only active day snapshots expire. 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, closed_at = (SELECT ends_at FROM habit_days WHERE id = task_occurrences.day_id) WHERE closed_at IS NULL AND day_id IN ( SELECT d.id FROM habit_days d JOIN habits h ON h.id = d.habit_id WHERE 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))`); }); } private invalidate(id: string) { 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); if (value === undefined) { value = endOfDay(date, this.user.timezone); this.deadlineCache.set(date, value); } return value; } private records(id: string) { let records = this.recordsCache.get(id); if (!records) { records = new Map(); 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; } private occurrences(id: string) { 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(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); } return occurrences; } private materialize(habit: Habit) { let date = habit.materializedThrough ?? habit.createdDate; while (date <= this.today) { 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(); if (!existing || existing.revisionId !== revision.id) { const prior = existing ? this.db.select().from(habitRevisions).where(eq(habitRevisions.id, existing.revisionId)).get() : undefined; const sameMethod = prior?.config.method === revision.config.method; const day = this.db.insert(habitDays).values({ habitId: habit.id, revisionId: revision.id, date, timezone: existing?.timezone ?? this.user.timezone, endsAt: existing?.endsAt ?? this.deadline(date), countSet: existing && !sameMethod && revision.config.method === 'count' ? true : sameMethod ? existing!.countSet : false, count: sameMethod ? existing!.count : 0, done: sameMethod ? existing!.done : false }).returning().get(); if (revision.config.method === 'tasks' && !revision.config.archived && scheduled(revision.config.schedule, date)) { // A temporary archive or schedule change can leave the immediately prior snapshot empty. // Stable task IDs recover the latest occurrence from this date only. const previous = existing ? this.db.select({ occurrence: taskOccurrences }).from(taskOccurrences) .innerJoin(habitDays, eq(taskOccurrences.dayId, habitDays.id)) .where(and(eq(habitDays.habitId, habit.id), eq(habitDays.date, date))) .orderBy(desc(habitDays.id)).all().map(row => row.occurrence) : []; for (const task of revision.config.tasks.filter(t => scheduled(t.schedule, date))) { 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, 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(); } } } date = addDays(date, 1); } this.db.update(habits).set({ materializedThrough: this.today }).where(eq(habits.id, habit.id)).run(); } private counts(id: string) { const cached = this.countCache.get(id); if (cached) return cached; 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) { if (config.method !== 'count' || config.archived) { carry = 0; source = null; continue; } if (!scheduled(config.schedule, day.date)) continue; const value = day.countSet ? day.count : config.carryPartialProgress ? carry : 0; result.set(day.date, { value, carriedFrom: !day.countSet && config.carryPartialProgress && carry > 0 ? source : null }); carry = value < config.target ? value : 0; source = carry > 0 ? day.date : null; } this.countCache.set(id, result); return result; } 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; const future = date > this.today; const tasks = config?.method === 'tasks' && !config.archived && scheduled(config.schedule, date) ? record ? config.tasks.flatMap(task => (this.occurrences(id).get(record.id) ?? []).filter(occurrence => occurrence.taskId === task.id)) : config.tasks.filter(t => scheduled(t.schedule, date)).map(t => ({ id: null, taskId: t.id, name: t.name, done: false, expiredAt: null, closedAt: null, updatedAt: null })) : []; const due = !!config && !config.archived && scheduled(config.schedule, date) && (config.method !== 'tasks' || tasks.length > 0); const counted = config?.method === 'count' && !future ? this.counts(id).get(date) : undefined; 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 ratio = due ? Math.min(1, value / target!) : 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, 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', ratio, complete: due && ratio === 1, tasks, requirements: config ?? null }; } writable(id: string, date: string) { const detail = this.day(id, date); if (detail.future) throw new ApiError(409, 'Future progress cannot be logged'); if (!detail.due) throw new ApiError(409, 'Nothing is scheduled for this date'); const record = this.db.select().from(habitDays).where(and(eq(habitDays.habitId, id), eq(habitDays.date, date))).orderBy(desc(habitDays.id)).get()!; return { detail, record }; } progress(id: string, date: string, input: { count: number } | { done: boolean }) { const { detail, record } = this.writable(id, date); if (detail.method === 'tasks') throw new ApiError(409, 'Task habit progress is derived from occurrences'); if (detail.method === 'count' ? !('count' in input) : !('done' in input)) throw new ApiError(422, 'Input does not match the method on this date'); this.countCache.delete(id); 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.update(habitDays).set('count' in input ? { ...input, countSet: true } : input).where(eq(habitDays.id, record.id)).run(); this.changed(id); }); this.invalidate(id); return this.day(id, date); } completeTask(id: string, date: string, taskId: string, done: boolean) { const { record } = this.writable(id, date); const occurrence = this.db.select().from(taskOccurrences).where(and(eq(taskOccurrences.dayId, record.id), eq(taskOccurrences.taskId, taskId))).get(); if (!occurrence) throw missing(); 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.update(taskOccurrences).set({ done, updatedAt: this.now }).where(eq(taskOccurrences.id, occurrence.id)).run(); this.changed(id); }); this.invalidate(id); return this.day(id, date); } history(id: string) { this.owned(id); return this.db.select().from(habitRevisions).where(eq(habitRevisions.habitId, id)).orderBy(desc(habitRevisions.id)).all(); } audit(id: string, date: string) { this.owned(id); return this.db.select().from(habitDays).where(and(eq(habitDays.habitId, id), eq(habitDays.date, date))).orderBy(asc(habitDays.id)).all().map(day => ({ ...day, requirements: this.db.select().from(habitRevisions).where(eq(habitRevisions.id, day.revisionId)).get()!.config, occurrences: this.db.select().from(taskOccurrences).where(eq(taskOccurrences.dayId, day.id)).all(), events: this.db.select().from(progressEvents).where(eq(progressEvents.dayId, day.id)).orderBy(asc(progressEvents.id)).all(), })); } settings(id: string): CalendarSettings { this.owned(id); return this.db.select().from(habitCalendarSettings).where(eq(habitCalendarSettings.habitId, id)).get()?.settings ?? calendarSettingsSchema.parse({}); } saveSettings(id: string, settings: CalendarSettings) { this.owned(id); this.db.insert(habitCalendarSettings).values({ habitId: id, settings }).onConflictDoUpdate({ target: habitCalendarSettings.habitId, set: { settings } }).run(); this.changed(id); return settings; } chart(id: string) { const chart = this.db.select().from(combinedCharts).where(and(eq(combinedCharts.id, id), eq(combinedCharts.userId, this.user.id))).get(); if (!chart) throw missing(); return chart; } charts() { return this.db.select().from(combinedCharts).where(eq(combinedCharts.userId, this.user.id)).orderBy(asc(combinedCharts.createdAt), asc(combinedCharts.id)).all(); } saveChart(input: { name: string; habitIds: string[]; settings: CalendarSettings }, id?: string) { if (id) this.chart(id); input.habitIds.forEach(h => this.owned(h)); if (id) this.db.update(combinedCharts).set({ ...input, updatedAt: this.now }).where(eq(combinedCharts.id, id)).run(); else { id = crypto.randomUUID(); this.db.insert(combinedCharts).values({ ...input, id, userId: this.user.id, createdAt: this.now, updatedAt: this.now }).run(); } return this.chart(id); } 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, compact = false) { 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)); const due = details.filter(d => d.due); const completed = due.filter(d => d.complete).length; const ratio = combined ? due.length ? completed / due.length : null : details[0]!.ratio; const manual = !combined && details[0]!.method === 'manual'; const steps = manual ? 1 : settings.shadeCount === 'auto' ? combined ? 4 : details[0]!.target ?? 1 : settings.shadeCount; const progress = shade(ratio, steps, settings, !combined && (settings.shadeCount === 'auto' || manual)); 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', 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, // Explicit colors and details are supplied for the client's Nivo colorScale/tooltip adapters. data: days.map(d => ({ day: d.date, value: d.future ? -2 : d.ratio === null ? -1 : d.level! })) }; } setTimezone(timezone: string) { this.db.update(users).set({ timezone, updatedAt: sql`max(${users.updatedAt} + 1, ${this.now})` }).where(eq(users.id, this.user.id)).run(); return { ...this.user, timezone }; } }