feat(db): add versioned habits and calendar domain contracts

This commit is contained in:
syntaxbullet
2026-09-04 09:00:21 +02:00
parent d3cbf2f7dc
commit 7fefe0c7d8
10 changed files with 1170 additions and 1 deletions

View File

@@ -0,0 +1,38 @@
import { expect, test } from 'bun:test';
import { addDays, endOfDay, localDate, scheduled, 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);
expect(scheduled({ type: 'interval', every: 3, anchor: '2026-09-04' }, '2026-09-07')).toBe(true);
expect(scheduled({ type: 'interval', every: 3, anchor: '2026-09-04' }, '2026-09-01')).toBe(false);
expect(scheduled({ type: 'interval', every: 3, anchor: '2026-09-04' }, '2026-09-06')).toBe(false);
expect(scheduled({ type: 'weekdays', days: [1] }, '2026-09-07')).toBe(true);
expect(scheduled({ type: 'weekdays', days: [1] }, '2026-09-08')).toBe(false);
const rule = { type: 'weekly' as const, every: 2, weekday: 3, anchor: '2026-08-31' };
expect(scheduled(rule, '2026-09-02')).toBe(true);
expect(scheduled(rule, '2026-09-09')).toBe(false);
expect(scheduled(rule, '2026-09-16')).toBe(true);
});
test('real dates, strict settings, and exclusive methods are validated', () => {
for (const d of ['2026-02-29', '2026-13-01', '2026-9-1', 'garbage']) expect(dateSchema.safeParse(d).success).toBe(false);
expect(addDays('2024-02-28', 1)).toBe('2024-02-29');
expect(habitInput.safeParse({ name: 'mixed', method: 'manual', target: 8 }).success).toBe(false);
expect(scheduleSchema.safeParse({ type: 'weekdays', days: [1, 1] }).success).toBe(false);
expect(calendarSettingsSchema.safeParse({ daySpacing: 4 }).success).toBe(false);
});
test('local midnight handles spring, autumn, and fractional timezone offsets', () => {
expect(endOfDay('2026-03-29', 'Europe/Belgrade') - endOfDay('2026-03-28', 'Europe/Belgrade')).toBe(23 * 3600000);
expect(endOfDay('2026-10-25', 'Europe/Belgrade') - endOfDay('2026-10-24', 'Europe/Belgrade')).toBe(25 * 3600000);
expect(new Date(endOfDay('2026-09-04', 'Asia/Kathmandu')).toISOString()).toBe('2026-09-04T18:15:00.000Z');
expect(localDate(Date.parse('2026-09-03T22:00Z'), 'Europe/Belgrade')).toBe('2026-09-04');
});
test('eight positive count shades, full completion, neutral and empty stay distinct', () => {
const settings = calendarSettingsSchema.parse({});
const values = Array.from({ length: 8 }, (_, i) => shade((i + 1) / 8, 8, settings, true));
expect(new Set(values.map(v => v.color)).size).toBe(8);
expect(values.map(v => v.level)).toEqual([1,2,3,4,5,6,7,8]);
expect(shade(1, 1, settings, true).color).toBe(settings.mainColor);
expect(shade(0, 8, settings, true).color).toBe(settings.emptyColor);
expect(shade(null, 8, settings, true).color).toBe(settings.notDueColor);
expect(shade(0.75, 4, settings, false).level).toBe(3);
});

39
src/habits/calendar.ts Normal file
View File

@@ -0,0 +1,39 @@
import type { Schedule, CalendarSettings } from './contracts';
const DAY = 86400000;
export const dayNumber = (date: string) => Date.parse(`${date}T00:00:00Z`) / DAY;
export const addDays = (date: string, n: number) => new Date((dayNumber(date) + n) * DAY).toISOString().slice(0, 10);
export function localDate(timestamp: number, timezone: string): string {
return new Intl.DateTimeFormat('en-CA', { timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(timestamp);
}
// Search an instant boundary rather than adding 24h: DST days can be 23 or 25h.
export function endOfDay(date: string, timezone: string): number {
const target = addDays(date, 1);
let lo = Date.parse(`${target}T00:00:00Z`) - 36 * 3600000;
let hi = lo + 72 * 3600000;
while (hi - lo > 1) {
const mid = Math.floor((lo + hi) / 2);
if (localDate(mid, timezone) < target) lo = mid; else hi = mid;
}
return hi;
}
export function scheduled(rule: Schedule, date: string): boolean {
const day = dayNumber(date);
const weekday = new Date(day * DAY).getUTCDay();
if (rule.type === 'daily') return true;
if (rule.type === 'weekdays') return rule.days.includes(weekday);
const anchor = dayNumber(rule.anchor);
if (day < anchor) return false;
if (rule.type === 'interval') return (day - anchor) % rule.every === 0;
// Weeks start Monday; the anchor identifies week zero, even if not the due weekday.
const monday = (n: number) => n - ((new Date(n * DAY).getUTCDay() + 6) % 7);
return weekday === rule.weekday && (monday(day) - monday(anchor)) / 7 % rule.every === 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 = exactSteps ? steps : settings.shadeCount;
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('');
return { level, color };
}

44
src/habits/contracts.ts Normal file
View File

@@ -0,0 +1,44 @@
import { z } from "zod";
export const dateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(value => {
const date = new Date(`${value}T00:00:00Z`);
return !Number.isNaN(+date) && date.toISOString().slice(0, 10) === value && value >= '1970-01-01' && value <= '9998-12-31';
}, 'Expected a real calendar date from 1970 through 9998');
const name = z.string().trim().min(1).max(200);
export const scheduleSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('daily') }).strict(),
z.object({ type: z.literal('interval'), every: z.number().int().min(1).max(3650), anchor: dateSchema }).strict(),
z.object({ type: z.literal('weekdays'), days: z.array(z.number().int().min(0).max(6)).min(1).max(7).refine(a => new Set(a).size === a.length) }).strict(),
z.object({ type: z.literal('weekly'), every: z.number().int().min(1).max(520), weekday: z.number().int().min(0).max(6), anchor: dateSchema }).strict(),
]);
export type Schedule = z.infer<typeof scheduleSchema>;
const schedule = scheduleSchema.default({ type: 'daily' });
export const taskInput = z.object({ name, schedule }).strict();
export const taskPatch = taskInput.partial().refine(v => Object.keys(v).length > 0);
const common = { name, schedule };
export const habitInput = z.discriminatedUnion('method', [
z.object({ ...common, method: z.literal('count'), target: z.number().int().min(1).max(10000), unit: z.string().trim().min(1).max(80).default('steps') }).strict(),
z.object({ ...common, method: z.literal('manual') }).strict(),
z.object({ ...common, method: z.literal('tasks'), tasks: z.array(taskInput).max(100).default([]) }).strict(),
]);
export type HabitConfig = { name: string; schedule: Schedule; archived: boolean } & (
{ method: 'count'; target: number; unit: string } |
{ method: 'manual' } |
{ method: 'tasks'; tasks: { id: string; name: string; schedule: Schedule }[] }
);
export const habitPatch = z.object({ name: name.optional(), schedule: scheduleSchema.optional(), method: z.enum(['count', 'manual', 'tasks']).optional(), target: z.number().int().min(1).max(10000).optional(), unit: z.string().trim().min(1).max(80).optional(), archived: z.boolean().optional() }).strict().refine(v => Object.keys(v).length > 0);
export const progressInput = z.union([z.object({ count: z.number().int().min(0).max(1_000_000_000) }).strict(), z.object({ done: z.boolean() }).strict()]);
export const doneInput = z.object({ done: z.boolean() }).strict();
const color = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'Use a six-digit hex color');
// Product preferences only; layout and callbacks belong to the future UI.
export const calendarSettingsSchema = z.object({
mainColor: color.default('#196127'),
shadeCount: z.number().int().min(2).max(20).default(4),
emptyColor: color.default('#ebedf0'),
notDueColor: color.default('#f5f5f5'),
futureColor: color.default('#dbeafe'),
}).strict();
export type CalendarSettings = z.infer<typeof calendarSettingsSchema>;
export const chartInput = z.object({ name, habitIds: z.array(z.string().uuid()).min(1).max(100).refine(a => new Set(a).size === a.length), settings: calendarSettingsSchema.default(() => calendarSettingsSchema.parse({})) }).strict();
export const chartPatch = chartInput.partial().refine(v => Object.keys(v).length > 0);
export const timezoneInput = z.object({ timezone: z.string().min(1).max(100).refine(v => { try { return !/^[+-]/.test(v) && !!new Intl.DateTimeFormat('en', { timeZone: v }); } catch { return false; } }) }).strict();