From 560443218792e78e75f69244f508db13858f23f6 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Mon, 1 Jun 2026 22:32:12 +0200 Subject: [PATCH] refactor: share jst helpers --- static/schedule_board.ts | 89 +++++++------------------------ static/shared/broadcast.ts | 88 +++++++++++++++++++++++++++++++ static/timezone.ts | 104 +++++++------------------------------ 3 files changed, 126 insertions(+), 155 deletions(-) create mode 100644 static/shared/broadcast.ts diff --git a/static/schedule_board.ts b/static/schedule_board.ts index 0f491db..400966e 100644 --- a/static/schedule_board.ts +++ b/static/schedule_board.ts @@ -1,3 +1,10 @@ +import { + convertJstToLocalTime, + isJstTimezone, + normalizeWeekday, + parseHHMM, +} from "./shared/broadcast"; + export {}; type WeekStart = "monday" | "sunday" | "saturday"; @@ -60,61 +67,6 @@ const saveSettings = (settings: ScheduleSettings): void => { localStorage.setItem(settingsKey, JSON.stringify(settings)); }; -interface ParsedTime { - hour: number; - minute: number; -} - -const parseHHMM = (value: string | null): ParsedTime | null => { - if (!value) return null; - const match = value.trim().match(/^(\d{1,2}):(\d{2})$/); - if (!match) return null; - const hour = Number.parseInt(match[1], 10); - const minute = Number.parseInt(match[2], 10); - if ( - Number.isNaN(hour) || - Number.isNaN(minute) || - hour < 0 || - hour > 23 || - minute < 0 || - minute > 59 - ) { - return null; - } - return { hour, minute }; -}; - -const normalizeWeekday = (value: string | null): number | null => { - if (!value) return null; - const key = value.trim().toLowerCase().replace(/s$/, ""); - const map: Record = { - sun: 0, - sunday: 0, - mon: 1, - monday: 1, - tue: 2, - tues: 2, - tuesday: 2, - wed: 3, - wednesday: 3, - thu: 4, - thur: 4, - thurs: 4, - thursday: 4, - fri: 5, - friday: 5, - sat: 6, - saturday: 6, - }; - return typeof map[key] === "number" ? map[key] : null; -}; - -const isJstTimezone = (tz: string | null): boolean => { - const normalized = (tz ?? "").trim().toLowerCase(); - if (!normalized) return true; - return normalized === "asia/tokyo" || normalized === "jst"; -}; - interface LocalSlot { localDayIndex: number; localMinutes: number; @@ -144,23 +96,20 @@ const getLocalSlot = ( if (sourceDayIndex === null || !parsed) return null; if (!isJstTimezone(broadcastTimezone)) return null; - // Treat the broadcast time as JST (UTC+9) and convert using the user's current local offset. - const jstOffsetMinutes = 9 * 60; const localOffsetMinutes = -new Date().getTimezoneOffset(); - const sourceMinutes = parsed.hour * 60 + parsed.minute; - const diff = jstOffsetMinutes - localOffsetMinutes; // JST ahead of local - const localTotal = sourceMinutes - diff; - - const dayShift = Math.floor(localTotal / 1440); - const normalizedMinutes = ((localTotal % 1440) + 1440) % 1440; - const localHour = Math.floor(normalizedMinutes / 60); - const localMinute = normalizedMinutes % 60; - - const localDayIndex = (((sourceDayIndex + dayShift) % 7) + 7) % 7; - const localMinutes = localHour * 60 + localMinute; - const timeLabel = formatLocalTime(localHour, localMinute, timeFormat); - return { localDayIndex, localMinutes, timeLabel }; + const localTime = convertJstToLocalTime( + sourceDayIndex, + parsed.hour, + parsed.minute, + localOffsetMinutes, + ); + const timeLabel = formatLocalTime(localTime.hour, localTime.minute, timeFormat); + return { + localDayIndex: localTime.dayIndex, + localMinutes: localTime.totalMinutes, + timeLabel, + }; }; const orderedDayIndexes = (weekStart: WeekStart): number[] => { diff --git a/static/shared/broadcast.ts b/static/shared/broadcast.ts new file mode 100644 index 0000000..dc37123 --- /dev/null +++ b/static/shared/broadcast.ts @@ -0,0 +1,88 @@ +export interface ParsedClockTime { + hour: number; + minute: number; +} + +export interface LocalClockTime { + dayIndex: number; + hour: number; + minute: number; + totalMinutes: number; +} + +const jstOffsetMinutes = 9 * 60; + +const weekdayIndexes: Record = { + sun: 0, + sunday: 0, + mon: 1, + monday: 1, + tue: 2, + tues: 2, + tuesday: 2, + wed: 3, + wednesday: 3, + thu: 4, + thur: 4, + thurs: 4, + thursday: 4, + fri: 5, + friday: 5, + sat: 6, + saturday: 6, +}; + +export const normalizeWeekday = (value: string | null): number | null => { + if (!value) return null; + const key = value.trim().toLowerCase().replace(/s$/, ""); + const dayIndex = weekdayIndexes[key]; + return typeof dayIndex === "number" ? dayIndex : null; +}; + +export const parseHHMM = (value: string | null): ParsedClockTime | null => { + if (!value) return null; + const match = value.trim().match(/^(\d{1,2}):(\d{2})$/); + if (!match) return null; + const hour = Number.parseInt(match[1], 10); + const minute = Number.parseInt(match[2], 10); + if ( + Number.isNaN(hour) || + Number.isNaN(minute) || + hour < 0 || + hour > 23 || + minute < 0 || + minute > 59 + ) { + return null; + } + return { hour, minute }; +}; + +export const isJstTimezone = (value: string | null): boolean => { + const normalized = (value ?? "").trim().toLowerCase(); + if (!normalized) return true; + return normalized === "asia/tokyo" || normalized === "jst"; +}; + +export const convertJstToLocalTime = ( + dayIndex: number, + hour: number, + minute: number, + localOffsetMinutes: number, +): LocalClockTime => { + const sourceMinutes = hour * 60 + minute; + const diff = jstOffsetMinutes - localOffsetMinutes; + const localTotal = sourceMinutes - diff; + + const dayShift = Math.floor(localTotal / 1440); + const normalizedMinutes = ((localTotal % 1440) + 1440) % 1440; + const localHour = Math.floor(normalizedMinutes / 60); + const localMinute = normalizedMinutes % 60; + + return { + dayIndex: (((dayIndex + dayShift) % 7) + 7) % 7, + hour: localHour, + minute: localMinute, + totalMinutes: localHour * 60 + localMinute, + }; +}; diff --git a/static/timezone.ts b/static/timezone.ts index 0aa80c6..f8b8f6e 100644 --- a/static/timezone.ts +++ b/static/timezone.ts @@ -1,7 +1,11 @@ -export {}; +import { + convertJstToLocalTime, + isJstTimezone, + normalizeWeekday, + parseHHMM, +} from "./shared/broadcast"; -// JST offset from UTC in minutes (UTC+9) -const jstOffsetMinutes = 9 * 60; +export {}; interface ParsedBroadcast { day: string; @@ -9,42 +13,6 @@ interface ParsedBroadcast { minute: number; } -const parseBroadcastTime = (value: string | null): { hour: number; minute: number } | null => { - if (!value || typeof value !== "string") { - return null; - } - - const match = value.trim().match(/^(\d{1,2}):(\d{2})$/); - if (!match) { - return null; - } - - const hour = Number.parseInt(match[1], 10); - const minute = Number.parseInt(match[2], 10); - // validate ranges - if ( - Number.isNaN(hour) || - Number.isNaN(minute) || - hour < 0 || - hour > 23 || - minute < 0 || - minute > 59 - ) { - return null; - } - - return { hour, minute }; -}; - -const isJstTimezone = (timezone: string | null): boolean => { - if (!timezone) { - return true; // treat missing timezone as JST (anime default) - } - - const normalized = timezone.trim().toLowerCase(); - return normalized === "asia/tokyo" || normalized === "jst"; -}; - const parseFromStructuredAttrs = (node: Element): ParsedBroadcast | null => { const day = node.getAttribute("data-broadcast-day"); const time = node.getAttribute("data-broadcast-time"); @@ -54,7 +22,7 @@ const parseFromStructuredAttrs = (node: Element): ParsedBroadcast | null => { return null; } - const parsedTime = parseBroadcastTime(time); + const parsedTime = parseHHMM(time); if (!parsedTime) { return null; } @@ -88,69 +56,35 @@ const parseBroadcast = (text: string | null): ParsedBroadcast | null => { return { day, hour, minute }; }; -const normalizeDay = (day: string): number | null => { - // strip trailing 's' for plural forms, then lookup - const key = day.trim().toLowerCase().replace(/s$/, ""); - const days: Record = { - mon: 1, - monday: 1, - tue: 2, - tues: 2, - tuesday: 2, - wed: 3, - wednesday: 3, - thu: 4, - thur: 4, - thurs: 4, - thursday: 4, - fri: 5, - friday: 5, - sat: 6, - saturday: 6, - sun: 0, - sunday: 0, - }; - - if (typeof days[key] !== "number") { - return null; - } - - return days[key]; -}; - const convertToLocal = (parsed: ParsedBroadcast, localOffsetMinutes: number): string | null => { - const sourceMinutes = parsed.hour * 60 + parsed.minute; - const diff = jstOffsetMinutes - localOffsetMinutes; // JST ahead of local - const localTotal = sourceMinutes - diff; - - const dayShift = Math.floor(localTotal / 1440); - const normalizedMinutes = ((localTotal % 1440) + 1440) % 1440; // handle negatives - const localHour = Math.floor(normalizedMinutes / 60); - const localMinute = normalizedMinutes % 60; - - const sourceDayIndex = normalizeDay(parsed.day); + const sourceDayIndex = normalizeWeekday(parsed.day); if (sourceDayIndex === null) { return null; } - const localDayIndex = (((sourceDayIndex + dayShift) % 7) + 7) % 7; // proper modulo + const localTime = convertJstToLocalTime( + sourceDayIndex, + parsed.hour, + parsed.minute, + localOffsetMinutes, + ); const localDay = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"][ - localDayIndex + localTime.dayIndex ]; - const time = `${localHour.toString().padStart(2, "0")}:${localMinute.toString().padStart(2, "0")}`; + const time = `${localTime.hour.toString().padStart(2, "0")}:${localTime.minute.toString().padStart(2, "0")}`; return `${localDay} at ${time} (Local)`; }; const nextAiringUTC = (parsed: ParsedBroadcast): Date | null => { - const targetDay = normalizeDay(parsed.day); + const targetDay = normalizeWeekday(parsed.day); if (targetDay === null) { return null; } // convert local time to JST to compare against JST now const now = new Date(); - const jstNow = new Date(now.getTime() + jstOffsetMinutes * 60 * 1000); + const jstNow = new Date(now.getTime() + 9 * 60 * 60 * 1000); const currentDay = jstNow.getUTCDay(); const currentMinuteOfDay = jstNow.getUTCHours() * 60 + jstNow.getUTCMinutes();