Bound habit day and calendar reads to requested history
This commit is contained in:
@@ -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<string, Habit>();
|
||||
private revisionCache = new Map<string, Revision[]>();
|
||||
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 occurrencesCache = new Map<string, Map<number, (typeof taskOccurrences.$inferSelect)[]>>();
|
||||
private countCache = new Map<string, Map<string, { value: number; carriedFrom: string | null }>>();
|
||||
@@ -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<typeof habitInput.parse>) {
|
||||
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<string, { value: number; carriedFrom: string | null }>();
|
||||
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));
|
||||
|
||||
Reference in New Issue
Block a user