Add compact calendar responses and persisted habit change versions
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;
|
||||
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
@@ -92,6 +92,13 @@
|
||||
"when": 1788847978168,
|
||||
"tag": "0012_discord_commands",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "6",
|
||||
"when": 1788860000000,
|
||||
"tag": "0013_habit_calendar_revision",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,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', {
|
||||
|
||||
@@ -21,3 +21,26 @@ test('single-day reads load only the selected latest snapshot and its tasks', as
|
||||
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(); }
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -80,7 +80,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 +100,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]);
|
||||
|
||||
@@ -55,6 +55,10 @@ export class HabitService {
|
||||
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;
|
||||
@@ -62,7 +66,7 @@ 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()
|
||||
@@ -130,6 +134,7 @@ export class HabitService {
|
||||
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);
|
||||
}
|
||||
@@ -270,7 +275,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',
|
||||
@@ -291,6 +296,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);
|
||||
@@ -302,6 +308,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);
|
||||
@@ -325,6 +332,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) {
|
||||
@@ -341,7 +349,7 @@ 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) {
|
||||
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)) {
|
||||
@@ -353,7 +361,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