mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
fix(places): evaluate open/closed in the place's timezone (#1680)
The ring was checked against the viewer's clock, so a trip in Europe planned from Singapore showed the wrong state. It evaluates in the timezone of the place now, from Google's structured opening periods rather than the localised description text — parsing display strings breaks as soon as the account language is not English. Midnight-spanning hours, closed days and 24/7 are covered.
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
import tzlookup from 'tz-lookup'
|
||||
|
||||
// The open/closed ring used to show whatever `open_now` the details endpoint had
|
||||
// handed back — a boolean Google computed when the payload was fetched and that the
|
||||
// server then cached for days. Read from another continent that value is wrong twice
|
||||
// over: it is stale, and nothing in the chain ever looked at the place's own clock.
|
||||
// The details payload also carries machine-readable periods — day/hour/minute, the
|
||||
// same shape for Google and for OSM places — and those are what the ring is recomputed
|
||||
// from, in the timezone of the coordinates. The weekday lines stay what they always
|
||||
// were: display text in the user's language, unusable as a data source. Issue #1680.
|
||||
|
||||
const MINUTES_PER_DAY = 24 * 60
|
||||
const MINUTES_PER_WEEK = 7 * MINUTES_PER_DAY
|
||||
|
||||
// Periods count days the way Google does: Sunday is 0. Intl reports the weekday by
|
||||
// name, so this maps one to the other.
|
||||
const WEEKDAY_NUMBER: Record<string, number> = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 }
|
||||
|
||||
export interface OpeningTimePoint {
|
||||
day: number
|
||||
hour: number
|
||||
minute: number
|
||||
}
|
||||
|
||||
export interface OpeningPeriod {
|
||||
open: OpeningTimePoint
|
||||
/** Absent or null means the place never closes. */
|
||||
close?: OpeningTimePoint | null
|
||||
}
|
||||
|
||||
export interface PlaceOpeningHours {
|
||||
periods?: OpeningPeriod[] | null
|
||||
/** YYYY-MM-DD dates the weekly periods do not describe (holidays and the like). */
|
||||
specialDays?: string[] | null
|
||||
}
|
||||
|
||||
export function resolvePlaceTimeZone(lat: unknown, lng: unknown): string | null {
|
||||
if (typeof lat !== 'number' || typeof lng !== 'number' || !Number.isFinite(lat) || !Number.isFinite(lng)) return null
|
||||
try {
|
||||
return tzlookup(lat, lng)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Monday-based weekday index for `now` in `timeZone`; the viewer's own zone when it is null. */
|
||||
export function placeWeekdayIndex(now: Date, timeZone: string | null): number {
|
||||
const local = localParts(now, timeZone)
|
||||
if (local) return (local.day + 6) % 7
|
||||
const jsDay = now.getDay()
|
||||
return jsDay === 0 ? 6 : jsDay - 1
|
||||
}
|
||||
|
||||
/** Weekday (Sunday = 0), minute of day and calendar date of `now` in `timeZone`. */
|
||||
function localParts(now: Date, timeZone: string | null): { day: number; minutes: number; date: string } | null {
|
||||
if (!timeZone) return null
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone,
|
||||
weekday: 'short',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).formatToParts(now)
|
||||
const value = (type: Intl.DateTimeFormatPartTypes) => parts.find(p => p.type === type)?.value ?? ''
|
||||
const day = WEEKDAY_NUMBER[value('weekday')]
|
||||
const hour = parseInt(value('hour'), 10)
|
||||
const minute = parseInt(value('minute'), 10)
|
||||
if (day === undefined || Number.isNaN(hour) || Number.isNaN(minute)) return null
|
||||
return {
|
||||
day,
|
||||
minutes: (hour % 24) * 60 + minute,
|
||||
date: `${value('year')}-${value('month')}-${value('day')}`,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function isTimePoint(point: unknown): point is OpeningTimePoint {
|
||||
if (!point || typeof point !== 'object') return false
|
||||
const { day, hour, minute } = point as OpeningTimePoint
|
||||
if (typeof day !== 'number' || typeof hour !== 'number' || typeof minute !== 'number') return false
|
||||
return day >= 0 && day <= 6 && hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59
|
||||
}
|
||||
|
||||
/** Periods with a usable open point; a close point is optional but must be usable if present. */
|
||||
function usablePeriods(periods: unknown): OpeningPeriod[] {
|
||||
if (!Array.isArray(periods)) return []
|
||||
return periods.filter(
|
||||
(p): p is OpeningPeriod =>
|
||||
!!p && isTimePoint((p as OpeningPeriod).open) && ((p as OpeningPeriod).close == null || isTimePoint((p as OpeningPeriod).close)),
|
||||
)
|
||||
}
|
||||
|
||||
function pointMinutes(point: OpeningTimePoint): number {
|
||||
return point.day * MINUTES_PER_DAY + point.hour * 60 + point.minute
|
||||
}
|
||||
|
||||
/**
|
||||
* Open/closed state of a place at `now`, judged by its own local clock.
|
||||
* Returns `fallback` (the server's `open_now`) whenever the periods cannot decide it.
|
||||
*/
|
||||
export function resolveOpenNow(
|
||||
hours: PlaceOpeningHours | null | undefined,
|
||||
lat: unknown,
|
||||
lng: unknown,
|
||||
fallback: boolean | null | undefined,
|
||||
now: Date = new Date(),
|
||||
): boolean | null {
|
||||
const fb = fallback ?? null
|
||||
const periods = usablePeriods(hours?.periods)
|
||||
if (periods.length === 0) return fb
|
||||
|
||||
const local = localParts(now, resolvePlaceTimeZone(lat, lng))
|
||||
if (!local) return fb
|
||||
|
||||
// A holiday is not in the weekly periods, but Google knew about it when it computed
|
||||
// open_now — on such a day its verdict stands rather than our recomputation.
|
||||
const specialDays = hours?.specialDays
|
||||
if (Array.isArray(specialDays) && specialDays.includes(local.date)) return fb
|
||||
|
||||
const nowMinutes = local.day * MINUTES_PER_DAY + local.minutes
|
||||
for (const period of periods) {
|
||||
if (period.close == null) return true
|
||||
const start = pointMinutes(period.open)
|
||||
let end = pointMinutes(period.close)
|
||||
// A closing time at or before the opening time runs past midnight, and past the end
|
||||
// of the week when it opens on Saturday — hence the second comparison.
|
||||
if (end <= start) end += MINUTES_PER_WEEK
|
||||
if (nowMinutes >= start && nowMinutes < end) return true
|
||||
if (nowMinutes + MINUTES_PER_WEEK >= start && nowMinutes + MINUTES_PER_WEEK < end) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user