diff --git a/src/habits/calendar.test.ts b/src/habits/calendar.test.ts index 13bd067..73c87a8 100644 --- a/src/habits/calendar.test.ts +++ b/src/habits/calendar.test.ts @@ -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); +}); diff --git a/src/habits/calendar.ts b/src/habits/calendar.ts index 51fff7d..cd70a4b 100644 --- a/src/habits/calendar.ts +++ b/src/habits/calendar.ts @@ -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 }; } diff --git a/src/habits/regressions.test.ts b/src/habits/regressions.test.ts index f3f974c..36f831d 100644 --- a/src/habits/regressions.test.ts +++ b/src/habits/regressions.test.ts @@ -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(); +}); diff --git a/src/habits/service.ts b/src/habits/service.ts index bbfad0d..09d74b7 100644 --- a/src/habits/service.ts +++ b/src/habits/service.ts @@ -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(); } } }