feat(calendar): add Nivo data adapter and verify durable API behavior

This commit is contained in:
syntaxbullet
2026-09-04 09:16:09 +02:00
parent 9a7180ce78
commit ff7ec68262
8 changed files with 232 additions and 11 deletions

View File

@@ -1,4 +1,4 @@
import { and, asc, desc, eq, inArray, isNull, lte, sql } from 'drizzle-orm';
import { and, asc, desc, eq, 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';
@@ -15,6 +15,11 @@ export type HabitDay = typeof habitDays.$inferSelect;
export class HabitService {
readonly today: string;
private ownedCache = new Map<string, Habit>();
private revisionCache = new Map<string, Revision[]>();
private deadlineCache = new Map<string, number>();
private recordsCache = new Map<string, Map<string, HabitDay>>();
private occurrencesCache = new Map<string, Map<number, (typeof taskOccurrences.$inferSelect)[]>>();
private countCache = new Map<string, Map<string, { value: number; carriedFrom: string | null }>>();
constructor(readonly db: AppDatabase, readonly user: PublicUser, readonly now: number) {
// Travel west must never reopen or rewrite earlier requirements.
@@ -22,12 +27,15 @@ export class HabitService {
this.today = [localDate(now, 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();
return habit;
this.ownedCache.set(id, habit); return habit;
}
revision(id: string, date = this.today): Revision | undefined {
return this.db.select().from(habitRevisions).where(and(eq(habitRevisions.habitId, id), lte(habitRevisions.effectiveDate, date))).orderBy(desc(habitRevisions.effectiveDate), desc(habitRevisions.id)).get();
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);
}
current(id: string) {
const habit = this.owned(id); const revision = this.revision(id)!;
@@ -67,7 +75,7 @@ export class HabitService {
return this.revise(id, config);
}
revise(id: string, config: HabitConfig) {
this.countCache.delete(id);
this.invalidate(id);
this.owned(id);
this.db.transaction(() => {
this.db.insert(habitRevisions).values({ habitId: id, effectiveDate: this.today, config, createdAt: this.now }).run();
@@ -103,6 +111,33 @@ export class HabitService {
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.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(eq(habitDays.habitId, id)).orderBy(asc(habitDays.id)).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(eq(habitDays.habitId, id)).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) {
@@ -112,8 +147,8 @@ export class HabitService {
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 ?? endOfDay(date, this.user.timezone),
countSet: sameMethod ? existing!.countSet : false, count: sameMethod ? existing!.count : 0, done: sameMethod ? existing!.done : false }).returning().get();
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)) {
const previous = existing ? this.db.select().from(taskOccurrences).where(eq(taskOccurrences.dayId, existing.id)).all() : [];
for (const task of revision.config.tasks.filter(t => scheduled(t.schedule, date))) {
@@ -147,11 +182,11 @@ export class HabitService {
day(id: string, date: string) {
this.owned(id);
const revision = this.revision(id, date);
const record = this.db.select().from(habitDays).where(and(eq(habitDays.habitId, id), eq(habitDays.date, date))).orderBy(desc(habitDays.id)).get();
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 ? this.db.select().from(taskOccurrences).where(eq(taskOccurrences.dayId, record.id)).orderBy(asc(taskOccurrences.id)).all()
? record ? this.occurrences(id).get(record.id) ?? []
: config.tasks.filter(t => scheduled(t.schedule, date)).map(t => ({ id: null, taskId: t.id, name: t.name, done: false, expiredAt: 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;
@@ -180,6 +215,7 @@ export class HabitService {
this.db.insert(progressEvents).values({ dayId: record.id, before: { count: record.count, done: record.done }, 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.invalidate(id);
return this.day(id, date);
}
completeTask(id: string, date: string, taskId: string, done: boolean) {
@@ -190,6 +226,7 @@ export class HabitService {
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.invalidate(id);
return this.day(id, date);
}
history(id: string) {
@@ -241,7 +278,7 @@ export class HabitService {
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 });
}
return { 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.
data: days.map(d => ({ day: d.date, value: d.future ? -2 : d.ratio === null ? -1 : d.level! })) };
}