fix(habits): surface impossible schedules and harden calendar edge cases

This commit is contained in:
syntaxbullet
2026-09-04 09:51:03 +02:00
parent adb1cf43c1
commit f520d73bbc
4 changed files with 65 additions and 5 deletions

View File

@@ -1,5 +1,5 @@
import { expect, test } from 'bun:test';
import { addDays, endOfDay, localDate, scheduled, shade } from './calendar';
import { addDays, endOfDay, localDate, scheduled, schedulesOverlap, shade } from './calendar';
import { calendarSettingsSchema, dateSchema, habitInput, scheduleSchema } from './contracts';
test('fixed calendar recurrence uses anchors and Monday weeks independently of completions', () => {
expect(scheduled({ type: 'daily' }, '2026-09-04')).toBe(true);
@@ -36,3 +36,18 @@ test('eight positive count shades, full completion, neutral and empty stay disti
expect(shade(null, 8, settings, true).color).toBe(settings.notDueColor);
expect(shade(0.75, 4, settings, false).level).toBe(3);
});
test('white and pale main colors still produce distinct positive shades', () => {
for (const mainColor of ['#ffffff', '#fefdfc', '#ffff00', '#000000']) {
const settings = calendarSettingsSchema.parse({ mainColor, shadeCount: 20 });
const colors = Array.from({ length: 20 }, (_, i) => shade((i + 1) / 20, 20, settings, true).color);
expect(new Set(colors).size).toBe(20); expect(colors.at(-1)).toBe(mainColor);
}
});
test('schedule intersection detects incompatible anchored day and week cycles', () => {
expect(schedulesOverlap({ type: 'interval', every: 7, anchor: '2026-09-04' }, { type: 'weekdays', days: [1] })).toBe(false);
expect(schedulesOverlap({ type: 'interval', every: 3, anchor: '2026-09-04' }, { type: 'weekdays', days: [1] })).toBe(true);
expect(schedulesOverlap({ type: 'weekly', every: 2, weekday: 3, anchor: '2026-08-31' }, { type: 'weekly', every: 2, weekday: 3, anchor: '2026-09-07' })).toBe(false);
expect(schedulesOverlap({ type: 'weekly', every: 2, weekday: 3, anchor: '2026-08-31' }, { type: 'weekly', every: 3, weekday: 3, anchor: '2026-09-07' })).toBe(true);
expect(schedulesOverlap({ type: 'daily' }, { type: 'weekly', every: 520, weekday: 0, anchor: '2026-09-04' })).toBe(true);
});

View File

@@ -31,12 +31,33 @@ export function scheduled(rule: Schedule, date: string): boolean {
const monday = (n: number) => n - ((new Date(n * DAY).getUTCDay() + 6) % 7);
return weekday === rule.weekday && (monday(day) - monday(anchor)) / 7 % rule.every === 0;
}
/** Each rule is a set of residues on a finite repeating period. */
export function schedulesOverlap(a: Schedule, b: Schedule): boolean {
const mod = (n: number, period: number) => ((n % period) + period) % period;
function cycle(rule: Schedule): { period: number; residues: number[] } {
if (rule.type === 'daily') return { period: 1, residues: [0] };
// Unix day zero is Thursday (weekday 4).
if (rule.type === 'weekdays') return { period: 7, residues: rule.days.map(day => mod(day - 4, 7)) };
const anchor = dayNumber(rule.anchor);
if (rule.type === 'interval') return { period: rule.every, residues: [mod(anchor, rule.every)] };
const monday = anchor - ((new Date(anchor * DAY).getUTCDay() + 6) % 7);
return { period: 7 * rule.every, residues: [mod(monday + (rule.weekday + 6) % 7, 7 * rule.every)] };
}
const left = cycle(a); const right = cycle(b);
let x = left.period; let y = right.period;
while (y) { const remainder = x % y; x = y; y = remainder; }
// Congruences intersect iff their residues agree modulo the gcd. Anchors
// only restrict the lower bound; a compatible repeating solution recurs.
return left.residues.some(l => right.residues.some(r => mod(l - r, x) === 0));
}
export function shade(ratio: number | null, steps: number, settings: CalendarSettings, exactSteps: boolean) {
if (ratio === null) return { level: null, color: settings.notDueColor };
if (ratio <= 0) return { level: 0, color: settings.emptyColor };
const total = steps;
const level = exactSteps ? Math.min(total, Math.round(ratio * total)) : ratio >= 1 ? total : Math.min(total - (total > 1 ? 1 : 0), Math.max(1, Math.floor(ratio * total)));
const intensity = total <= 1 ? 1 : 0.2 + 0.8 * (level - 1) / (total - 1);
const color = '#' + [1, 3, 5].map(i => Math.round(255 * (1 - intensity) + parseInt(settings.mainColor.slice(i, i + 2), 16) * intensity).toString(16).padStart(2, '0')).join('');
const luminance = (0.2126 * parseInt(settings.mainColor.slice(1, 3), 16) + 0.7152 * parseInt(settings.mainColor.slice(3, 5), 16) + 0.0722 * parseInt(settings.mainColor.slice(5, 7), 16)) / 255;
const base = luminance > 0.75 ? 0 : 255;
const color = '#' + [1, 3, 5].map(i => Math.round(base * (1 - intensity) + parseInt(settings.mainColor.slice(i, i + 2), 16) * intensity).toString(16).padStart(2, '0')).join('');
return { level, color };
}

View File

@@ -100,3 +100,22 @@ test('task occurrences retain definition order across dates and revisions', asyn
f.setTime('2026-09-05T12:00Z');
expect((await f.json(`/habits/${h.id}/days/2026-09-05`)).tasks.map((t: any) => t.name)).toEqual(['First', 'Second', 'Third']);
});
test('impossible schedule intersections expose warnings while valid intervals remain allowed', async () => {
const h = await f.json('/habits', 'POST', { name: 'Monday', method: 'tasks', schedule: { type: 'weekdays', days: [1] }, tasks: [
{ name: 'Friday', schedule: { type: 'weekdays', days: [5] } },
{ name: 'Every three days', schedule: { type: 'interval', every: 3, anchor: '2026-09-04' } },
] }, 201);
expect(h.warnings).toHaveLength(1); expect(h.warnings[0]).toMatchObject({ code: 'task_never_due', taskId: h.tasks[0].id });
await f.json(`/habits/${h.id}/tasks/${h.tasks[0].id}`, 'PATCH', { schedule: { type: 'daily' } });
expect((await f.json(`/habits/${h.id}`)).warnings).toEqual([]);
const empty = await f.json('/habits', 'POST', { name: 'Empty', method: 'tasks' }, 201);
expect(empty.warnings[0].code).toBe('no_tasks');
});
test('revisions preserve a finalized null expiry when a timezone transition extends the local date', async () => {
const h = await taskHabit(); await tick(h, true);
await f.json('/me', 'PATCH', { timezone: 'America/Los_Angeles' });
f.setTime('2026-09-04T23:00Z'); // Still Sept 4 locally, past its preserved Belgrade deadline.
await tick(h, false);
await f.json(`/habits/${h.id}/tasks/${h.tasks[0].id}`, 'PATCH', { name: 'Renamed after deadline' });
expect((await detail(h.id)).tasks[0].expiredAt).toBeNull();
});

View File

@@ -2,7 +2,7 @@ import { and, asc, desc, eq, 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 { addDays, endOfDay, localDate, scheduled, schedulesOverlap, shade } from './calendar';
import { calendarSettingsSchema, habitInput, type HabitConfig, type CalendarSettings } from './contracts';
export class ApiError extends Error {
@@ -42,7 +42,12 @@ export class HabitService {
}
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 };
const config = revision.config;
const warnings = config.method !== 'tasks' ? [] : config.tasks.length === 0
? [{ 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 };
}
list(includeArchived = false) {
return this.db.select().from(habits).where(eq(habits.userId, this.user.id)).orderBy(asc(habits.createdAt), asc(habits.id)).all()
@@ -164,7 +169,7 @@ export class HabitService {
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), closedAt: old?.closedAt ?? (day.endsAt <= this.now ? day.endsAt : null), updatedAt: old?.updatedAt ?? this.now }).run();
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();
}
}
}