Merge backend performance improvements
This commit is contained in:
1
drizzle/0013_habit_calendar_revision.sql
Normal file
1
drizzle/0013_habit_calendar_revision.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE habits ADD COLUMN calendar_revision integer NOT NULL DEFAULT 0;
|
||||
1
drizzle/0014_open_occurrences.sql
Normal file
1
drizzle/0014_open_occurrences.sql
Normal file
@@ -0,0 +1 @@
|
||||
CREATE INDEX task_occurrences_open_idx ON task_occurrences (day_id) WHERE closed_at IS NULL;
|
||||
1225
drizzle/meta/0013_snapshot.json
Normal file
1225
drizzle/meta/0013_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1233
drizzle/meta/0014_snapshot.json
Normal file
1233
drizzle/meta/0014_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -92,6 +92,20 @@
|
||||
"when": 1788847978168,
|
||||
"tag": "0012_discord_commands",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,8 @@ import { HabitService } from "../habits/service";
|
||||
export function createAccountRoutes(db: AppDatabase, auth: ReturnType<typeof createAuth>, now: () => number) {
|
||||
const app = new Hono<AuthEnv>();
|
||||
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 service = new HabitService(db, user, now()); service.sync();
|
||||
const data = db.transaction(tx => {
|
||||
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));
|
||||
@@ -31,11 +30,12 @@ export function createAccountRoutes(db: AppDatabase, auth: ReturnType<typeof cre
|
||||
});
|
||||
c.header("Content-Disposition", 'attachment; filename="mina-habits-export.json"');
|
||||
return c.json(data);
|
||||
});
|
||||
}));
|
||||
app.delete("/", auth.requireSameOrigin, bodyLimit({ maxSize: 1024 }), async c => {
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (body?.confirmation !== "DELETE") return c.json({ error: "Type DELETE to confirm account deletion." }, 422);
|
||||
const userId = c.get("user").id;
|
||||
return HabitService.withPrepared(db, c.get("user"), now, () => {
|
||||
db.transaction(tx => {
|
||||
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));
|
||||
@@ -50,6 +50,7 @@ export function createAccountRoutes(db: AppDatabase, auth: ReturnType<typeof cre
|
||||
});
|
||||
auth.clearSession(c);
|
||||
return c.body(null, 204);
|
||||
});
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
||||
|
||||
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.
|
||||
export const habits = sqliteTable('habits', {
|
||||
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(),
|
||||
}, t => [index('habits_user_idx').on(t.userId)]);
|
||||
export const habitRevisions = sqliteTable('habit_revisions', {
|
||||
@@ -47,7 +49,7 @@ export const habitDays = sqliteTable('habit_days', {
|
||||
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(),
|
||||
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', {
|
||||
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(),
|
||||
|
||||
85
src/habits/performance.test.ts
Normal file
85
src/habits/performance.test.ts
Normal 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();}
|
||||
});
|
||||
@@ -20,7 +20,7 @@ function date(c: Context<Env>) {
|
||||
return result.data;
|
||||
}
|
||||
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');
|
||||
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; }
|
||||
}
|
||||
c.set('payload', payload);
|
||||
const service = new HabitService(db, c.get('user'), now());
|
||||
service.sync(); c.set('service', service); await next();
|
||||
await HabitService.withPrepared(db, c.get('user'), now, async service => {
|
||||
c.set('service', service); await next();
|
||||
});
|
||||
}); if (response) c.res = response; };
|
||||
if (!['GET', 'HEAD', 'OPTIONS'].includes(c.req.method)) {
|
||||
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.put('/habits/:id/calendar-settings', c => c.json(service(c).saveSettings(id(c), body(c, calendarSettingsSchema))));
|
||||
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 => {
|
||||
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.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 => {
|
||||
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]);
|
||||
|
||||
@@ -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 { PublicUser } from '../shared/user';
|
||||
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 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 {
|
||||
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;
|
||||
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 }>>();
|
||||
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;
|
||||
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 };
|
||||
// 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;
|
||||
@@ -35,10 +77,27 @@ 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);
|
||||
}
|
||||
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)!;
|
||||
@@ -47,11 +106,11 @@ export class HabitService {
|
||||
? [{ 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, ...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) {
|
||||
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();
|
||||
@@ -114,7 +173,8 @@ export class HabitService {
|
||||
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.materialize(this.owned(id), this.today, true);
|
||||
this.changed(id);
|
||||
});
|
||||
return this.current(id);
|
||||
}
|
||||
@@ -137,19 +197,25 @@ export class HabitService {
|
||||
}
|
||||
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.
|
||||
for (const habit of this.pending()) this.materialize(habit);
|
||||
// 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
|
||||
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}
|
||||
WHERE id IN (
|
||||
SELECT o.id FROM task_occurrences o INDEXED BY task_occurrences_open_idx
|
||||
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))`);
|
||||
});
|
||||
}
|
||||
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) {
|
||||
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 +226,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,15 +235,17 @@ 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);
|
||||
}
|
||||
return occurrences;
|
||||
}
|
||||
private materialize(habit: Habit) {
|
||||
let date = habit.materializedThrough ?? habit.createdDate;
|
||||
while (date <= this.today) {
|
||||
private materialize(habit: Habit, through = this.today, force = false) {
|
||||
let date = force ? through : habit.materializedThrough ? addDays(habit.materializedThrough, 1) : habit.createdDate;
|
||||
if (date > through) return;
|
||||
this.loadRevisions(habit.id, date, through);
|
||||
while (date <= through) {
|
||||
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) {
|
||||
@@ -193,23 +261,43 @@ export class HabitService {
|
||||
.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 occurrences = revision.config.tasks.filter(t => scheduled(t.schedule, date)).map(task => {
|
||||
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();
|
||||
}
|
||||
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 };
|
||||
});
|
||||
if (occurrences.length) this.db.insert(taskOccurrences).values(occurrences).run();
|
||||
}
|
||||
}
|
||||
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) {
|
||||
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.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 }>();
|
||||
let carry = 0; let source: string | null = null;
|
||||
for (const { day, config } of rows) {
|
||||
@@ -223,6 +311,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;
|
||||
@@ -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 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, 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,
|
||||
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',
|
||||
@@ -256,6 +345,7 @@ export class HabitService {
|
||||
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);
|
||||
@@ -267,6 +357,7 @@ export class HabitService {
|
||||
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);
|
||||
@@ -290,6 +381,7 @@ export class HabitService {
|
||||
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) {
|
||||
@@ -306,8 +398,8 @@ export class HabitService {
|
||||
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) {
|
||||
ids.forEach(id => this.owned(id));
|
||||
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));
|
||||
@@ -318,7 +410,10 @@ export class HabitService {
|
||||
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 });
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user