feat(habits): add opt-in partial count carryover with daily reset defaults
This commit is contained in:
@@ -15,6 +15,7 @@ export type HabitDay = typeof habitDays.$inferSelect;
|
||||
|
||||
export class HabitService {
|
||||
readonly today: string;
|
||||
private countCache = new Map<string, Map<string, { value: number; carriedFrom: string | null }>>();
|
||||
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;
|
||||
@@ -53,9 +54,10 @@ export class HabitService {
|
||||
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.carryPartialProgress = patch.carryPartialProgress ?? (old.method === 'count' ? old.carryPartialProgress : false);
|
||||
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');
|
||||
} else if ('target' in patch || 'unit' in patch || 'carryPartialProgress' in patch) throw new ApiError(422, 'Target, unit and carryPartialProgress 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');
|
||||
@@ -65,6 +67,7 @@ export class HabitService {
|
||||
return this.revise(id, config);
|
||||
}
|
||||
revise(id: string, config: HabitConfig) {
|
||||
this.countCache.delete(id);
|
||||
this.owned(id);
|
||||
this.db.transaction(() => {
|
||||
this.db.insert(habitRevisions).values({ habitId: id, effectiveDate: this.today, config, createdAt: this.now }).run();
|
||||
@@ -110,7 +113,7 @@ export class HabitService {
|
||||
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();
|
||||
countSet: sameMethod ? existing!.countSet : false, 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))) {
|
||||
@@ -124,6 +127,23 @@ export class HabitService {
|
||||
}
|
||||
this.db.update(habits).set({ materializedThrough: this.today }).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 result = new Map<string, { value: number; carriedFrom: string | null }>();
|
||||
let carry = 0; let source: string | null = null;
|
||||
for (const { day, config } of rows) {
|
||||
if (config.method !== 'count' || config.archived) { carry = 0; source = null; continue; }
|
||||
if (!scheduled(config.schedule, day.date)) continue;
|
||||
const value = day.countSet ? day.count : config.carryPartialProgress ? carry : 0;
|
||||
result.set(day.date, { value, carriedFrom: !day.countSet && config.carryPartialProgress && carry > 0 ? source : null });
|
||||
carry = value < config.target ? value : 0; source = carry > 0 ? day.date : null;
|
||||
}
|
||||
this.countCache.set(id, result); return result;
|
||||
}
|
||||
day(id: string, date: string) {
|
||||
this.owned(id);
|
||||
const revision = this.revision(id, date);
|
||||
@@ -134,13 +154,14 @@ export class HabitService {
|
||||
? 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 counted = config?.method === 'count' && !future ? this.counts(id).get(date) : undefined;
|
||||
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,
|
||||
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',
|
||||
value, target, carriedFrom: counted?.carriedFrom ?? null, loggedCount: record?.countSet ? record.count : null, 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) {
|
||||
@@ -154,9 +175,10 @@ export class HabitService {
|
||||
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.countCache.delete(id);
|
||||
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();
|
||||
this.db.update(habitDays).set('count' in input ? { ...input, countSet: true } : input).where(eq(habitDays.id, record.id)).run();
|
||||
});
|
||||
return this.day(id, date);
|
||||
}
|
||||
@@ -212,10 +234,12 @@ export class HabitService {
|
||||
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 manual = !combined && details[0]!.method === 'manual';
|
||||
const steps = manual ? 1 : settings.shadeCount === 'auto' ? combined ? 4 : details[0]!.target ?? 1 : settings.shadeCount;
|
||||
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',
|
||||
...progress, color: future ? settings.futureColor : progress.color, habits: details });
|
||||
shadeCount: steps, ...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.
|
||||
|
||||
Reference in New Issue
Block a user