feat(db): add versioned habits and calendar domain contracts
This commit is contained in:
39
src/habits/calendar.ts
Normal file
39
src/habits/calendar.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user