fix(habits): surface impossible schedules and harden calendar edge cases
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { expect, test } from 'bun:test';
|
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';
|
import { calendarSettingsSchema, dateSchema, habitInput, scheduleSchema } from './contracts';
|
||||||
test('fixed calendar recurrence uses anchors and Monday weeks independently of completions', () => {
|
test('fixed calendar recurrence uses anchors and Monday weeks independently of completions', () => {
|
||||||
expect(scheduled({ type: 'daily' }, '2026-09-04')).toBe(true);
|
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(null, 8, settings, true).color).toBe(settings.notDueColor);
|
||||||
expect(shade(0.75, 4, settings, false).level).toBe(3);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -31,12 +31,33 @@ export function scheduled(rule: Schedule, date: string): boolean {
|
|||||||
const monday = (n: number) => n - ((new Date(n * DAY).getUTCDay() + 6) % 7);
|
const monday = (n: number) => n - ((new Date(n * DAY).getUTCDay() + 6) % 7);
|
||||||
return weekday === rule.weekday && (monday(day) - monday(anchor)) / 7 % rule.every === 0;
|
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) {
|
export function shade(ratio: number | null, steps: number, settings: CalendarSettings, exactSteps: boolean) {
|
||||||
if (ratio === null) return { level: null, color: settings.notDueColor };
|
if (ratio === null) return { level: null, color: settings.notDueColor };
|
||||||
if (ratio <= 0) return { level: 0, color: settings.emptyColor };
|
if (ratio <= 0) return { level: 0, color: settings.emptyColor };
|
||||||
const total = steps;
|
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 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 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 };
|
return { level, color };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,3 +100,22 @@ test('task occurrences retain definition order across dates and revisions', asyn
|
|||||||
f.setTime('2026-09-05T12:00Z');
|
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']);
|
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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { and, asc, desc, eq, lte, sql } from 'drizzle-orm';
|
|||||||
import type { AppDatabase } from '../auth';
|
import type { AppDatabase } from '../auth';
|
||||||
import type { PublicUser } from '../shared/user';
|
import type { PublicUser } from '../shared/user';
|
||||||
import { habits, habitRevisions, habitDays, taskOccurrences, progressEvents, habitCalendarSettings, combinedCharts, users } from '../db/schema';
|
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';
|
import { calendarSettingsSchema, habitInput, type HabitConfig, type CalendarSettings } from './contracts';
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
@@ -42,7 +42,12 @@ export class HabitService {
|
|||||||
}
|
}
|
||||||
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)!;
|
||||||
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) {
|
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()
|
||||||
@@ -164,7 +169,7 @@ export class HabitService {
|
|||||||
for (const task of revision.config.tasks.filter(t => scheduled(t.schedule, date))) {
|
for (const task of revision.config.tasks.filter(t => scheduled(t.schedule, date))) {
|
||||||
const old = previous.find(t => t.taskId === task.id);
|
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,
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user