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,
|
"when": 1788847978168,
|
||||||
"tag": "0012_discord_commands",
|
"tag": "0012_discord_commands",
|
||||||
"breakpoints": true
|
"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.
|
// Revisions are append-only, including multiple edits on the same local date.
|
||||||
export const habits = sqliteTable('habits', {
|
export const habits = sqliteTable('habits', {
|
||||||
id: text('id').primaryKey(), userId: text('user_id').notNull().references(() => users.id),
|
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(),
|
createdDate: text('created_date').notNull(), materializedThrough: text('materialized_through'), createdAt: integer('created_at').notNull(),
|
||||||
}, t => [index('habits_user_idx').on(t.userId)]);
|
}, t => [index('habits_user_idx').on(t.userId)]);
|
||||||
export const habitRevisions = sqliteTable('habit_revisions', {
|
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" >=');
|
expect(logs.find(query => query.includes('task_occurrences'))).toContain('"habit_days"."date" >=');
|
||||||
} finally { f.close(); }
|
} 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;
|
return result.data;
|
||||||
}
|
}
|
||||||
function range(c: Context<Env>) {
|
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');
|
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;
|
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.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.put('/habits/:id/calendar-settings', c => c.json(service(c).saveSettings(id(c), body(c, calendarSettingsSchema))));
|
||||||
app.get('/habits/:id/calendar', c => {
|
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 => {
|
app.get('/today', c => {
|
||||||
const s = service(c); const habits = s.list().map(h => s.day(h.id, s.today));
|
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.delete('/charts/:id', c => { service(c).deleteChart(id(c)); return c.body(null, 204); });
|
||||||
app.get('/charts/:id/calendar', c => {
|
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 => {
|
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]);
|
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.recordsCache.delete(id); this.occurrencesCache.delete(id); this.countCache.delete(id);
|
||||||
this.loadRevisions(id, from, to);
|
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) {
|
current(id: string) {
|
||||||
const habit = this.owned(id); const revision = this.revision(id)!;
|
const habit = this.owned(id); const revision = this.revision(id)!;
|
||||||
const config = revision.config;
|
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.' }]
|
? [{ code: 'no_tasks', taskId: null, message: 'Add a task to make this habit due.' }]
|
||||||
: config.tasks.filter(task => !schedulesOverlap(config.schedule, task.schedule))
|
: 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.` }));
|
.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) {
|
list(includeArchived = false) {
|
||||||
return this.db.select().from(habits).where(eq(habits.userId, this.user.id)).orderBy(asc(habits.createdAt), asc(habits.id)).all()
|
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.transaction(() => {
|
||||||
this.db.insert(habitRevisions).values({ habitId: id, effectiveDate: this.today, config, createdAt: this.now }).run();
|
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.changed(id);
|
||||||
});
|
});
|
||||||
return this.current(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 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 target = !config ? null : config.method === 'count' ? config.target : config.method === 'manual' ? 1 : tasks.length;
|
||||||
const ratio = due ? Math.min(1, value / target!) : null;
|
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,
|
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',
|
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',
|
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.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.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.db.update(habitDays).set('count' in input ? { ...input, countSet: true } : input).where(eq(habitDays.id, record.id)).run();
|
||||||
|
this.changed(id);
|
||||||
});
|
});
|
||||||
this.invalidate(id);
|
this.invalidate(id);
|
||||||
return this.day(id, date);
|
return this.day(id, date);
|
||||||
@@ -302,6 +308,7 @@ export class HabitService {
|
|||||||
this.db.transaction(() => {
|
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.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.db.update(taskOccurrences).set({ done, updatedAt: this.now }).where(eq(taskOccurrences.id, occurrence.id)).run();
|
||||||
|
this.changed(id);
|
||||||
});
|
});
|
||||||
this.invalidate(id);
|
this.invalidate(id);
|
||||||
return this.day(id, date);
|
return this.day(id, date);
|
||||||
@@ -325,6 +332,7 @@ export class HabitService {
|
|||||||
saveSettings(id: string, settings: CalendarSettings) {
|
saveSettings(id: string, settings: CalendarSettings) {
|
||||||
this.owned(id);
|
this.owned(id);
|
||||||
this.db.insert(habitCalendarSettings).values({ habitId: id, settings }).onConflictDoUpdate({ target: habitCalendarSettings.habitId, set: { settings } }).run();
|
this.db.insert(habitCalendarSettings).values({ habitId: id, settings }).onConflictDoUpdate({ target: habitCalendarSettings.habitId, set: { settings } }).run();
|
||||||
|
this.changed(id);
|
||||||
return settings;
|
return settings;
|
||||||
}
|
}
|
||||||
chart(id: string) {
|
chart(id: string) {
|
||||||
@@ -341,7 +349,7 @@ export class HabitService {
|
|||||||
return this.chart(id);
|
return this.chart(id);
|
||||||
}
|
}
|
||||||
deleteChart(id: string) { this.chart(id); this.db.delete(combinedCharts).where(eq(combinedCharts.id, id)).run(); }
|
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); });
|
ids.forEach(id => { this.owned(id); this.range(id, from, to); });
|
||||||
const days = [];
|
const days = [];
|
||||||
for (let date = from; date <= to; date = addDays(date, 1)) {
|
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 progress = shade(ratio, steps, settings, !combined && (settings.shadeCount === 'auto' || manual));
|
||||||
const future = date > this.today;
|
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',
|
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,
|
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.
|
// Explicit colors and details are supplied for the client's Nivo colorScale/tooltip adapters.
|
||||||
|
|||||||
Reference in New Issue
Block a user