Files
MealQuest/web/src/plan/week.ts
T
dev_agent fd087e0ba3
CI / ci (pull_request) Successful in 8m6s
CI / ci (push) Successful in 11m26s
Feature 21: Home Dashboard (#181)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-10 20:07:53 -04:00

163 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Household-local calendar helpers for the weekly plan, mirroring
// internal/household/week.go. Weeks are ISO weeks, Monday to Sunday. Dates
// are household-local YYYY-MM-DD strings; arithmetic is done on UTC
// midnights, which have no DST, so a date plus seven days is always the same
// weekday a week later.
const localDate = /^(\d{4})-(\d{2})-(\d{2})$/
const DAY_MS = 24 * 60 * 60 * 1000
const WEEKDAYS = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'] as const
// midnightUTC reads date as midnight UTC, or throws when it is not a real
// YYYY-MM-DD date.
function midnightUTC(date: string): Date {
const match = localDate.exec(date)
if (match === null) throw new Error(`not a YYYY-MM-DD date: "${date}"`)
const t = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])))
if (formatDate(t) !== date) throw new Error(`not a real date: "${date}"`)
return t
}
function formatDate(t: Date): string {
return t.toISOString().slice(0, 10)
}
// addDays returns date moved by days, which may be negative.
export function addDays(date: string, days: number): string {
return formatDate(new Date(midnightUTC(date).getTime() + days * DAY_MS))
}
// weekStart returns the Monday of the week containing date.
export function weekStart(date: string): string {
const sinceMonday = (midnightUTC(date).getUTCDay() + 6) % 7
return addDays(date, -sinceMonday)
}
// isMonday says whether date is a real YYYY-MM-DD Monday.
export function isMonday(date: string): boolean {
try {
return weekStart(date) === date
} catch {
return false
}
}
// weekdayKicker is date's day as a tile kicker: "MON" to "SUN".
export function weekdayKicker(date: string): string {
return WEEKDAYS[midnightUTC(date).getUTCDay()]!
}
// dayOfMonth is date's day of the month: 1 to 31.
export function dayOfMonth(date: string): number {
return midnightUTC(date).getUTCDate()
}
const weekday = new Intl.DateTimeFormat('en-US', { weekday: 'long', timeZone: 'UTC' })
// weekdayName is date's day in full: "Monday" to "Sunday".
export function weekdayName(date: string): string {
return weekday.format(midnightUTC(date))
}
const longDay = new Intl.DateTimeFormat('en-US', { weekday: 'long', month: 'short', day: 'numeric', timeZone: 'UTC' })
// dayAndDate is date's day in full with its month and day: "Saturday, Oct 10".
export function dayAndDate(date: string): string {
return longDay.format(midnightUTC(date))
}
// monday reads start as midnight UTC, or throws when it is not a Monday.
function monday(start: string): Date {
const t = midnightUTC(start)
if (t.getUTCDay() !== 1) throw new Error(`week start ${start} is not a Monday`)
return t
}
// isoWeek is the ISO 8601 week number of the week starting on Monday start:
// the week holding that week's Thursday's year's first Thursday counts as 1.
function isoWeek(start: Date): number {
const thursday = new Date(start.getTime() + 3 * DAY_MS)
const yearStart = Date.UTC(thursday.getUTCFullYear(), 0, 1)
return Math.floor((thursday.getTime() - yearStart) / DAY_MS / 7) + 1
}
const monthDay = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })
// WeekLabel is the week starting on a Monday as the plan's header shows it:
// "Week 41" over "Oct 5 – 11", or "Sep 28 – Oct 4" when the week spans two
// months. Together they are the API's week_label.
export type WeekLabel = { week: string; dates: string }
// weekLabel labels the week starting on Monday start.
export function weekLabel(start: string): WeekLabel {
const first = monday(start)
const last = new Date(first.getTime() + 6 * DAY_MS)
const end = last.getUTCMonth() === first.getUTCMonth() ? String(last.getUTCDate()) : monthDay.format(last)
return { week: `Week ${isoWeek(first)}`, dates: `${monthDay.format(first)} – ${end}` }
}
// localNow is the household-local date (YYYY-MM-DD) and clock (24-hour
// HH:MM) at instant now in IANA zone.
export function localNow(zone: string, now: Date): { date: string; time: string } {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: zone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).formatToParts(now)
const part = (type: Intl.DateTimeFormatPartTypes) => parts.find((p) => p.type === type)!.value
return { date: `${part('year')}-${part('month')}-${part('day')}`, time: `${part('hour')}:${part('minute')}` }
}
// defaultWeek is the Monday of the week the plan opens on: next week once
// this week's plan_draft time (day of the week counting from Sunday = 0,
// 24-hour HH:MM, both household-local) has come, otherwise this week.
export function defaultWeek(schedule: { timezone: string; plan_draft_dow: number; plan_draft_time: string }, now: Date): string {
const today = localNow(schedule.timezone, now)
const thisWeek = weekStart(today.date)
const draftDay = addDays(thisWeek, (schedule.plan_draft_dow + 6) % 7)
const drafted = today.date > draftDay || (today.date === draftDay && today.time >= schedule.plan_draft_time)
return drafted ? addDays(thisWeek, 7) : thisWeek
}
// wallClock is the household-local wall clock at instant t in IANA zone,
// read as if it were UTC: the zone's UTC offset at t is wallClock - t.
function wallClock(zone: string, t: number): number {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: zone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
}).formatToParts(new Date(t))
const part = (type: Intl.DateTimeFormatPartTypes) => Number(parts.find((p) => p.type === type)!.value)
const wall = Date.UTC(part('year'), part('month') - 1, part('day'), part('hour'), part('minute'), part('second'))
return wall + (((t % 1000) + 1000) % 1000)
}
// untilLocalMidnight is how many milliseconds after instant now the next
// local day begins in IANA zone. It finds the actual instant of the next
// day's midnight, so a day shortened or lengthened by a change of UTC offset
// is counted as it is. The candidates are that midnight at the zone's offset
// now and at the offset that candidate lands on; the earliest that falls on
// the next day is its start, which is the offset change itself when the
// clocks skip midnight.
export function untilLocalMidnight(zone: string, now: Date): number {
const t = now.getTime()
const tomorrow = addDays(localNow(zone, now).date, 1)
const midnight = midnightUTC(tomorrow).getTime()
const first = midnight - (wallClock(zone, t) - t)
const second = midnight - (wallClock(zone, first) - first)
const starts = [first, second].filter((c) => localNow(zone, new Date(c)).date === tomorrow)
if (starts.length === 0) throw new Error(`no start of ${tomorrow} found in ${zone}`)
return Math.min(...starts) - t
}