Daylight after work study — calculation source SunCalc version: 2.0.1 Inputs: /data/daylight-study.csv (coordinates, timezone, and reference dates). The sections below show the study and shared date/solar helpers; they are TypeScript modules, not a standalone command. // src/lib/daylightStudy.ts import * as SunCalc from 'suncalc'; import { getAllCities } from './cities'; import { localNoonForDate } from './localDate'; import { daylightAfter, daylightMinutes } from './planner'; import { formatEvent } from './format'; export const studyYear = 2026; let cache: ReturnType | undefined; function computeStudy() { return getAllCities().map(city => { const winterDate = {year:studyYear,month:12,day:21}; const summerDate = {year:studyYear,month:6,day:21}; const winter = SunCalc.getTimes(localNoonForDate(winterDate,city.timezone),city.lat,city.lng); const summer = SunCalc.getTimes(localNoonForDate(summerDate,city.timezone),city.lat,city.lng); return {city,winterDate:`${studyYear}-12-21`,summerDate:`${studyYear}-06-21`, winterSunrise:winter.sunrise?.toISOString() ?? '',winterSunset:winter.sunset?.toISOString() ?? '', winterSunsetLocal:formatEvent(winter.sunset,city.timezone),summerSunset:summer.sunset?.toISOString() ?? '', summerSunsetLocal:formatEvent(summer.sunset,city.timezone),winterDaylight:daylightMinutes(winter),summerDaylight:daylightMinutes(summer), winterAfterFive:daylightAfter(winter,winterDate,city.timezone,17),summerAfterFive:daylightAfter(summer,summerDate,city.timezone,17)}; }).sort((a,b)=>b.winterAfterFive-a.winterAfterFive || a.city.city.localeCompare(b.city.city)); } export function getDaylightStudy() { return cache ??= computeStudy(); } export function studyCsv() { const columns=['city','state','latitude','longitude','timezone','winter_date','summer_date','winter_sunrise_utc','winter_sunset_utc','summer_sunset_utc','winter_daylight_minutes','summer_daylight_minutes','winter_daylight_after_1700_minutes','summer_daylight_after_1700_minutes']; const quote=(value:string|number)=>`"${String(value).replaceAll('"','""')}"`; return [columns, ...getDaylightStudy().map(r=>[r.city.city,r.city.stateId,r.city.lat,r.city.lng,r.city.timezone,r.winterDate,r.summerDate,r.winterSunrise,r.winterSunset,r.summerSunset,r.winterDaylight.toFixed(4),r.summerDaylight.toFixed(4),r.winterAfterFive.toFixed(4),r.summerAfterFive.toFixed(4)])].map(row=>row.map(quote).join(',')).join('\r\n'); } // src/lib/planner.ts import * as SunCalc from 'suncalc'; import { addCalendarDays, calendarDateKey, clockSecondsInTimeZone, localNoonForDate, type LocalDateParts } from './localDate.ts'; export const BLUE_HOUR_ANGLE = -4; SunCalc.addTime(BLUE_HOUR_ANGLE, 'blueDawnEnd', 'blueDuskStart'); export type SunTimes = ReturnType; export function daylightMinutes(times: SunTimes): number { return times.sunrise && times.sunset ? (+times.sunset - +times.sunrise) / 60000 : times.alwaysUp ? 1440 : 0; } export function daylightRemaining(times: SunTimes, instant: Date): number { if (!times.sunrise || !times.sunset) return times.alwaysUp ? Infinity : 0; return Math.max(0, (+times.sunset - Math.max(+instant, +times.sunrise)) / 60000); } export function daylightAfter(times: SunTimes, date: LocalDateParts, timezone: string, hour: number): number { if (!times.sunrise || !times.sunset) return times.alwaysUp ? (24 - hour) * 60 : 0; const start = +localNoonForDate(date, timezone) + (hour - 12) * 3600000; const end = Math.min(+times.sunset, +localNoonForDate(addCalendarDays(date, 1), timezone) - 12 * 3600000); return Math.max(0, (end - Math.max(start, +times.sunrise)) / 60000); } /** Scan local calendar days, not 24-hour UTC steps, so DST boundaries stay correct. */ export function findDaylightMilestone(lat: number, lng: number, timezone: string, from: LocalDateParts, kind: 'sunset' | 'daylight', threshold: number) { for (let offset = 0; offset <= 366; offset++) { const date = addCalendarDays(from, offset); if (date.year > 2100) break; const times = SunCalc.getTimes(localNoonForDate(date, timezone), lat, lng); const value = kind === 'sunset' ? (times.sunset ? clockSecondsInTimeZone(times.sunset, timezone) / 3600 : null) : daylightMinutes(times) / 60; if (value !== null && value >= threshold) return { date: calendarDateKey(date), offset, times }; } return null; } // src/lib/localDate.ts /** Calendar fields for an instant as observed in an IANA timezone. */ export interface LocalDateParts { year: number; month: number; day: number; } const dateFormatters = new Map(); const clockFormatters = new Map(); function dateFormatter(timeZone: string): Intl.DateTimeFormat { let formatter = dateFormatters.get(timeZone); if (!formatter) { formatter = new Intl.DateTimeFormat('en-US', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit', }); dateFormatters.set(timeZone, formatter); } return formatter; } export function datePartsInTimeZone(instant: Date, timeZone: string): LocalDateParts { const parts = dateFormatter(timeZone).formatToParts(instant); const value = (type: Intl.DateTimeFormatPartTypes) => Number(parts.find((part) => part.type === type)?.value); return { year: value('year'), month: value('month'), day: value('day') }; } function minutesOnClock(instant: Date, timeZone: string): number { let formatter = clockFormatters.get(timeZone); if (!formatter) { formatter = new Intl.DateTimeFormat('en-US', { timeZone, hour: '2-digit', minute: '2-digit', hourCycle: 'h23', }); clockFormatters.set(timeZone, formatter); } const parts = formatter.formatToParts(instant); const hour = Number(parts.find((part) => part.type === 'hour')?.value) % 24; const minute = Number(parts.find((part) => part.type === 'minute')?.value); return hour * 60 + minute; } /** * An instant at local noon for a calendar date in an IANA timezone. * Noon is deliberately used as SunCalc's date anchor: it remains safely away * from daylight-saving transitions and UTC date boundaries. */ export function localNoonForDate(parts: LocalDateParts, timeZone: string): Date { let instant = new Date(Date.UTC(parts.year, parts.month - 1, parts.day, 12)); // Two passes handle every UTC offset, including date-line zones, without // needing the Temporal proposal or a timezone database of our own. for (let i = 0; i < 2; i++) { const seen = datePartsInTimeZone(instant, timeZone); const wantedDay = Date.UTC(parts.year, parts.month - 1, parts.day); const seenDay = Date.UTC(seen.year, seen.month - 1, seen.day); const dayCorrection = (wantedDay - seenDay) / 60000; const clockCorrection = 720 - minutesOnClock(instant, timeZone); instant = new Date(instant.getTime() + (dayCorrection + clockCorrection) * 60000); } return instant; } export function localNoonForInstant(instant: Date, timeZone: string): Date { return localNoonForDate(datePartsInTimeZone(instant, timeZone), timeZone); } export function addCalendarDays(parts: LocalDateParts, days: number): LocalDateParts { const date = new Date(Date.UTC(parts.year, parts.month - 1, parts.day + days)); return { year: date.getUTCFullYear(), month: date.getUTCMonth() + 1, day: date.getUTCDate() }; } export function isoLocalDate(instant: Date, timeZone: string): string { const { year, month, day } = datePartsInTimeZone(instant, timeZone); return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; } /** Reject overflow dates (including non-leap February 29) and unsupported years. */ export function parseLocalDate(value: string | null): LocalDateParts | null { if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return null; const [year, month, day] = value.split('-').map(Number); const date = new Date(Date.UTC(year, month - 1, day)); return year >= 1900 && year <= 2100 && date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day ? { year, month, day } : null; } export function calendarDateKey(parts: LocalDateParts): string { return `${parts.year}-${String(parts.month).padStart(2, '0')}-${String(parts.day).padStart(2, '0')}`; } const secondFormatters = new Map(); /** Full precision is essential when comparing annual clock-time extremes. */ export function clockSecondsInTimeZone(instant: Date, timeZone: string): number { let formatter = secondFormatters.get(timeZone); if (!formatter) { formatter = new Intl.DateTimeFormat('en-US', { timeZone, hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23' }); secondFormatters.set(timeZone, formatter); } const parts = formatter.formatToParts(instant); const get = (type: string) => Number(parts.find(p => p.type === type)?.value); return get('hour') * 3600 + get('minute') * 60 + get('second') + instant.getMilliseconds() / 1000; }