feat(api): implement authenticated habit tracking and combined calendars
This commit is contained in:
228
src/habits/service.ts
Normal file
228
src/habits/service.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { and, asc, desc, eq, inArray, isNull, 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';
|
||||
import { addDays, endOfDay, localDate, scheduled, shade } from './calendar';
|
||||
import { calendarSettingsSchema, habitInput, type HabitConfig, type CalendarSettings } from './contracts';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public status: 400 | 404 | 409 | 422, message: string) { super(message); }
|
||||
}
|
||||
const missing = () => new ApiError(404, 'Not found');
|
||||
export type Habit = typeof habits.$inferSelect;
|
||||
export type Revision = typeof habitRevisions.$inferSelect;
|
||||
export type HabitDay = typeof habitDays.$inferSelect;
|
||||
|
||||
export class HabitService {
|
||||
readonly today: string;
|
||||
constructor(readonly db: AppDatabase, readonly user: PublicUser, readonly now: number) {
|
||||
// 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;
|
||||
this.today = [localDate(now, user.timezone), latest ?? ''].sort().at(-1)!;
|
||||
}
|
||||
owned(id: string): Habit {
|
||||
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;
|
||||
}
|
||||
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();
|
||||
}
|
||||
current(id: string) {
|
||||
const habit = this.owned(id); const revision = this.revision(id)!;
|
||||
return { id: habit.id, ...revision.config, 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);
|
||||
}
|
||||
create(input: ReturnType<typeof habitInput.parse>) {
|
||||
const id = crypto.randomUUID();
|
||||
const config: HabitConfig = input.method === 'tasks'
|
||||
? { ...input, archived: false, tasks: input.tasks.map(t => ({ ...t, id: crypto.randomUUID() })) }
|
||||
: { ...input, archived: false };
|
||||
this.db.transaction(() => {
|
||||
this.db.insert(habits).values({ id, userId: this.user.id, createdDate: this.today, createdAt: this.now }).run();
|
||||
this.db.insert(habitRevisions).values({ habitId: id, effectiveDate: this.today, config, createdAt: this.now }).run();
|
||||
this.materialize(this.owned(id));
|
||||
});
|
||||
return this.current(id);
|
||||
}
|
||||
edit(id: string, patch: Record<string, unknown>) {
|
||||
const old = this.current(id);
|
||||
const method = patch.method ?? old.method;
|
||||
const base: Record<string, unknown> = { name: patch.name ?? old.name, schedule: patch.schedule ?? old.schedule, method };
|
||||
if (method === 'count') {
|
||||
base.target = patch.target ?? (old.method === 'count' ? old.target : undefined);
|
||||
base.unit = patch.unit ?? (old.method === 'count' ? old.unit : 'steps');
|
||||
} else if ('target' in patch || 'unit' in patch) throw new ApiError(422, 'Target and unit require the count method');
|
||||
if (method === 'tasks') base.tasks = [];
|
||||
const parsed = habitInput.safeParse(base);
|
||||
if (!parsed.success) throw new ApiError(422, 'The selected method requires valid method-specific settings');
|
||||
const config: HabitConfig = parsed.data.method === 'tasks'
|
||||
? { ...parsed.data, tasks: old.method === 'tasks' ? old.tasks : [], archived: (patch.archived ?? old.archived) as boolean }
|
||||
: { ...parsed.data, archived: (patch.archived ?? old.archived) as boolean };
|
||||
return this.revise(id, config);
|
||||
}
|
||||
revise(id: string, config: HabitConfig) {
|
||||
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));
|
||||
});
|
||||
return this.current(id);
|
||||
}
|
||||
tasks(id: string) {
|
||||
const config = this.current(id);
|
||||
if (config.method !== 'tasks') throw new ApiError(409, 'Habit does not currently use tasks');
|
||||
return config.tasks;
|
||||
}
|
||||
changeTask(id: string, taskId: string | undefined, input: { name?: string; schedule?: HabitConfig['schedule'] } | null) {
|
||||
const config = this.revision(this.owned(id).id)!.config;
|
||||
if (config.method !== 'tasks') throw new ApiError(409, 'Habit does not currently use tasks');
|
||||
if (config.archived) throw new ApiError(409, 'Restore the habit before editing tasks');
|
||||
if (taskId && !config.tasks.some(t => t.id === taskId)) throw missing();
|
||||
const nextId = taskId ?? crypto.randomUUID();
|
||||
const tasks = taskId ? config.tasks.flatMap(t => t.id !== taskId ? [t] : input ? [{ ...t, ...input }] : [])
|
||||
: [...config.tasks, { id: nextId, name: input!.name!, schedule: input!.schedule! }];
|
||||
if (tasks.length > 100) throw new ApiError(422, 'A habit supports at most 100 tasks');
|
||||
this.revise(id, { ...config, tasks });
|
||||
return input ? tasks.find(t => t.id === nextId)! : undefined;
|
||||
}
|
||||
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.
|
||||
this.db.run(sql`UPDATE task_occurrences SET expired_at = (SELECT ends_at FROM habit_days WHERE id = task_occurrences.day_id)
|
||||
WHERE done = 0 AND expired_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}
|
||||
AND d.id = (SELECT MAX(d2.id) FROM habit_days d2 WHERE d2.habit_id = d.habit_id AND d2.date = d.date))`);
|
||||
});
|
||||
}
|
||||
private materialize(habit: Habit) {
|
||||
let date = habit.materializedThrough ?? habit.createdDate;
|
||||
while (date <= this.today) {
|
||||
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) {
|
||||
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),
|
||||
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))) {
|
||||
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?.expiredAt ?? (day.endsAt <= this.now && !old?.done ? day.endsAt : null), updatedAt: this.now }).run();
|
||||
}
|
||||
}
|
||||
}
|
||||
date = addDays(date, 1);
|
||||
}
|
||||
this.db.update(habits).set({ materializedThrough: this.today }).where(eq(habits.id, habit.id)).run();
|
||||
}
|
||||
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 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()
|
||||
: 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 value = !due ? 0 : config!.method === 'count' ? record?.count ?? 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,
|
||||
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, unit: config?.method === 'count' ? config.unit : config?.method === 'tasks' ? 'tasks' : 'completion',
|
||||
ratio, complete: due && ratio === 1, tasks, requirements: config ?? null };
|
||||
}
|
||||
writable(id: string, date: string) {
|
||||
const detail = this.day(id, date);
|
||||
if (detail.future) throw new ApiError(409, 'Future progress cannot be logged');
|
||||
if (!detail.due) throw new ApiError(409, 'Nothing is scheduled for this date');
|
||||
const record = this.db.select().from(habitDays).where(and(eq(habitDays.habitId, id), eq(habitDays.date, date))).orderBy(desc(habitDays.id)).get()!;
|
||||
return { detail, record };
|
||||
}
|
||||
progress(id: string, date: string, input: { count: number } | { done: boolean }) {
|
||||
const { detail, record } = this.writable(id, date);
|
||||
if (detail.method === 'tasks') throw new ApiError(409, 'Task habit progress is derived from occurrences');
|
||||
if (detail.method === 'count' ? !('count' in input) : !('done' in input)) throw new ApiError(422, 'Input does not match the method on this date');
|
||||
this.db.transaction(() => {
|
||||
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(input).where(eq(habitDays.id, record.id)).run();
|
||||
});
|
||||
return this.day(id, date);
|
||||
}
|
||||
completeTask(id: string, date: string, taskId: string, done: boolean) {
|
||||
const { record } = this.writable(id, date);
|
||||
const occurrence = this.db.select().from(taskOccurrences).where(and(eq(taskOccurrences.dayId, record.id), eq(taskOccurrences.taskId, taskId))).get();
|
||||
if (!occurrence) throw missing();
|
||||
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();
|
||||
});
|
||||
return this.day(id, date);
|
||||
}
|
||||
history(id: string) {
|
||||
this.owned(id);
|
||||
return this.db.select().from(habitRevisions).where(eq(habitRevisions.habitId, id)).orderBy(desc(habitRevisions.id)).all();
|
||||
}
|
||||
audit(id: string, date: string) {
|
||||
this.owned(id);
|
||||
return this.db.select().from(habitDays).where(and(eq(habitDays.habitId, id), eq(habitDays.date, date))).orderBy(asc(habitDays.id)).all().map(day => ({
|
||||
...day, requirements: this.db.select().from(habitRevisions).where(eq(habitRevisions.id, day.revisionId)).get()!.config,
|
||||
occurrences: this.db.select().from(taskOccurrences).where(eq(taskOccurrences.dayId, day.id)).all(),
|
||||
events: this.db.select().from(progressEvents).where(eq(progressEvents.dayId, day.id)).orderBy(asc(progressEvents.id)).all(),
|
||||
}));
|
||||
}
|
||||
settings(id: string): CalendarSettings {
|
||||
this.owned(id);
|
||||
return this.db.select().from(habitCalendarSettings).where(eq(habitCalendarSettings.habitId, id)).get()?.settings ?? calendarSettingsSchema.parse({});
|
||||
}
|
||||
saveSettings(id: string, settings: CalendarSettings) {
|
||||
this.owned(id);
|
||||
this.db.insert(habitCalendarSettings).values({ habitId: id, settings }).onConflictDoUpdate({ target: habitCalendarSettings.habitId, set: { settings } }).run();
|
||||
return settings;
|
||||
}
|
||||
chart(id: string) {
|
||||
const chart = this.db.select().from(combinedCharts).where(and(eq(combinedCharts.id, id), eq(combinedCharts.userId, this.user.id))).get();
|
||||
if (!chart) throw missing();
|
||||
return chart;
|
||||
}
|
||||
charts() { return this.db.select().from(combinedCharts).where(eq(combinedCharts.userId, this.user.id)).orderBy(asc(combinedCharts.createdAt), asc(combinedCharts.id)).all(); }
|
||||
saveChart(input: { name: string; habitIds: string[]; settings: CalendarSettings }, id?: string) {
|
||||
if (id) this.chart(id);
|
||||
input.habitIds.forEach(h => this.owned(h));
|
||||
if (id) this.db.update(combinedCharts).set({ ...input, updatedAt: this.now }).where(eq(combinedCharts.id, id)).run();
|
||||
else { id = crypto.randomUUID(); this.db.insert(combinedCharts).values({ ...input, id, userId: this.user.id, createdAt: this.now, updatedAt: this.now }).run(); }
|
||||
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));
|
||||
const days = [];
|
||||
for (let date = from; date <= to; date = addDays(date, 1)) {
|
||||
const details = ids.map(id => this.day(id, date));
|
||||
const due = details.filter(d => d.due); const completed = due.filter(d => d.complete).length;
|
||||
const ratio = combined ? due.length ? completed / due.length : null : details[0]!.ratio;
|
||||
const progress = shade(ratio, combined ? settings.shadeCount : details[0]!.target ?? 1, settings, !combined);
|
||||
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',
|
||||
...progress, color: future ? settings.futureColor : progress.color, habits: details });
|
||||
}
|
||||
return { 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! })) };
|
||||
}
|
||||
setTimezone(timezone: string) {
|
||||
this.db.update(users).set({ timezone, updatedAt: this.now }).where(eq(users.id, this.user.id)).run();
|
||||
return { ...this.user, timezone };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user