mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +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:
@@ -1211,6 +1211,66 @@ describe('PlaceInspector', () => {
|
||||
expect(screen.getByText('LH400')).toBeTruthy();
|
||||
});
|
||||
|
||||
// ── Open/closed ring (#1680) ─────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-095: the ring follows the periods, not the cached open_now', async () => {
|
||||
const seoul = buildPlace({ id: 708, name: 'Round the clock', google_place_id: 'gp-708', lat: 37.5665, lng: 126.978 });
|
||||
vi.mocked(mapsApi.details).mockResolvedValue({
|
||||
place: {
|
||||
open_now: false,
|
||||
// A period with no close is Google's round-the-clock place. The weekday lines
|
||||
// arrive in the user's language and are shown, never parsed.
|
||||
opening_hours: ['月曜日: 24 時間営業'],
|
||||
opening_periods: [{ open: { day: 0, hour: 0, minute: 0 } }],
|
||||
},
|
||||
} as any);
|
||||
render(<PlaceInspector {...defaultProps} place={seoul} />);
|
||||
expect(await screen.findByText('Open')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-096: the ring is read in the timezone of the place', async () => {
|
||||
// Sunday 20:00 UTC — already Monday 05:00 in Seoul, still Sunday 22:00 in Paris.
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
vi.setSystemTime(new Date('2026-07-26T20:00:00Z'));
|
||||
try {
|
||||
const periods = [{ open: { day: 1, hour: 0, minute: 0 }, close: { day: 1, hour: 12, minute: 0 } }];
|
||||
vi.mocked(mapsApi.details).mockResolvedValue({
|
||||
place: { open_now: false, opening_periods: periods },
|
||||
} as any);
|
||||
|
||||
const seoul = buildPlace({ id: 709, name: 'Seoul spot', google_place_id: 'gp-709', lat: 37.5665, lng: 126.978 });
|
||||
const { unmount } = render(<PlaceInspector {...defaultProps} place={seoul} />);
|
||||
expect(await screen.findByText('Open')).toBeTruthy();
|
||||
unmount();
|
||||
|
||||
const paris = buildPlace({ id: 710, name: 'Paris spot', google_place_id: 'gp-710', lat: 48.8566, lng: 2.3522 });
|
||||
render(<PlaceInspector {...defaultProps} place={paris} />);
|
||||
expect(await screen.findByText('Closed')).toBeTruthy();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-097: a holiday in the payload leaves the verdict to the server', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
// Wednesday 12:00 in Seoul, inside the regular Wednesday period.
|
||||
vi.setSystemTime(new Date('2026-07-29T03:00:00Z'));
|
||||
try {
|
||||
vi.mocked(mapsApi.details).mockResolvedValue({
|
||||
place: {
|
||||
open_now: false,
|
||||
opening_periods: [{ open: { day: 3, hour: 9, minute: 0 }, close: { day: 3, hour: 18, minute: 0 } }],
|
||||
opening_special_days: ['2026-07-29'],
|
||||
},
|
||||
} as any);
|
||||
const seoul = buildPlace({ id: 711, name: 'Holiday spot', google_place_id: 'gp-711', lat: 37.5665, lng: 126.978 });
|
||||
render(<PlaceInspector {...defaultProps} place={seoul} />);
|
||||
expect(await screen.findByText('Closed')).toBeTruthy();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-089: picking a file hands it to onUploadImage', async () => {
|
||||
const onUploadImage = vi.fn(async () => {});
|
||||
render(<PlaceInspector {...defaultProps} onUploadImage={onUploadImage} />);
|
||||
|
||||
@@ -29,6 +29,7 @@ import { useTripStore } from '../../store/tripStore'
|
||||
import { formatDistance, formatElevation } from '../../utils/units'
|
||||
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
|
||||
import { getOpenStreetMapUrlForPlace } from './placeOpenStreetMap'
|
||||
import { resolveOpenNow, resolvePlaceTimeZone, placeWeekdayIndex } from './placeOpenState'
|
||||
|
||||
const detailsCache = new Map()
|
||||
|
||||
@@ -61,10 +62,10 @@ function usePlaceDetails(googlePlaceId, osmId, language) {
|
||||
return details
|
||||
}
|
||||
|
||||
function getWeekdayIndex(dateStr) {
|
||||
function getWeekdayIndex(dateStr, timeZone) {
|
||||
// weekdayDescriptions[0] = Monday … [6] = Sunday
|
||||
const d = dateStr ? new Date(dateStr + 'T12:00:00') : new Date()
|
||||
const jsDay = d.getDay()
|
||||
if (!dateStr) return placeWeekdayIndex(new Date(), timeZone)
|
||||
const jsDay = new Date(dateStr + 'T12:00:00').getDay()
|
||||
return jsDay === 0 ? 6 : jsDay - 1
|
||||
}
|
||||
|
||||
@@ -259,8 +260,18 @@ export default function PlaceInspector({
|
||||
?? dayAssignments.find(a => a.place?.id === place.id))
|
||||
: null
|
||||
|
||||
// The weekday lines are display text; the ring is computed from the structured
|
||||
// periods next to them, in the place's own timezone. open_now stays the fallback.
|
||||
const openingHours = googleDetails?.opening_hours || null
|
||||
const openNow = googleDetails?.open_now ?? null
|
||||
const detailLat = place.lat ?? googleDetails?.lat
|
||||
const detailLng = place.lng ?? googleDetails?.lng
|
||||
const placeTimeZone = resolvePlaceTimeZone(detailLat, detailLng)
|
||||
const openNow = resolveOpenNow(
|
||||
{ periods: googleDetails?.opening_periods, specialDays: googleDetails?.opening_special_days },
|
||||
detailLat,
|
||||
detailLng,
|
||||
googleDetails?.open_now,
|
||||
)
|
||||
// Prefer the place's stored ftid; if it has none yet, use the one just fetched from Google.
|
||||
const googleMapsUrl = getGoogleMapsUrlForPlace(
|
||||
place ? { ...place, google_ftid: place.google_ftid || googleDetails?.google_ftid || null } : null,
|
||||
@@ -268,7 +279,7 @@ export default function PlaceInspector({
|
||||
)
|
||||
const openStreetMapUrl = getOpenStreetMapUrlForPlace(place)
|
||||
const selectedDay = days?.find(d => d.id === selectedDayId)
|
||||
const weekdayIndex = getWeekdayIndex(selectedDay?.date)
|
||||
const weekdayIndex = getWeekdayIndex(selectedDay?.date, placeTimeZone)
|
||||
|
||||
const placeFiles = (files || []).filter(f => String(f.place_id) === String(place.id) || (f.linked_place_ids || []).includes(place.id))
|
||||
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { resolveOpenNow, resolvePlaceTimeZone, placeWeekdayIndex } from './placeOpenState';
|
||||
import type { OpeningPeriod } from './placeOpenState';
|
||||
|
||||
// Seoul is UTC+9, Paris UTC+2 in summer — far enough apart that a viewer's clock and
|
||||
// the place's clock disagree on both the hour and the weekday.
|
||||
const SEOUL: [number, number] = [37.5665, 126.978];
|
||||
const PARIS: [number, number] = [48.8566, 2.3522];
|
||||
|
||||
// Periods count days Google's way: Sunday is 0.
|
||||
const SUN = 0, MON = 1, TUE = 2, WED = 3, THU = 4, SAT = 6;
|
||||
|
||||
function period(day: number, openHour: number, closeDay: number, closeHour: number): OpeningPeriod {
|
||||
return {
|
||||
open: { day, hour: openHour, minute: 0 },
|
||||
close: { day: closeDay, hour: closeHour, minute: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
describe('resolvePlaceTimeZone', () => {
|
||||
it('FE-PLANNER-OPENSTATE-001: maps coordinates to an IANA zone', () => {
|
||||
expect(resolvePlaceTimeZone(...SEOUL)).toBe('Asia/Seoul');
|
||||
expect(resolvePlaceTimeZone(...PARIS)).toBe('Europe/Paris');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-OPENSTATE-002: returns null for missing or non-finite coordinates', () => {
|
||||
expect(resolvePlaceTimeZone(null, null)).toBeNull();
|
||||
expect(resolvePlaceTimeZone(undefined, 2.3)).toBeNull();
|
||||
expect(resolvePlaceTimeZone('48.8', '2.3')).toBeNull();
|
||||
expect(resolvePlaceTimeZone(NaN, 2.3)).toBeNull();
|
||||
expect(resolvePlaceTimeZone(48.8, Infinity)).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-OPENSTATE-003: returns null when the lookup rejects the coordinate', () => {
|
||||
expect(resolvePlaceTimeZone(1200, 4000)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('placeWeekdayIndex', () => {
|
||||
it('FE-PLANNER-OPENSTATE-004: is Monday-based and read in the place timezone', () => {
|
||||
// Sunday 20:00 UTC is already Monday 05:00 in Seoul.
|
||||
const at = new Date('2026-07-26T20:00:00Z');
|
||||
expect(placeWeekdayIndex(at, 'Asia/Seoul')).toBe(0);
|
||||
expect(placeWeekdayIndex(at, 'UTC')).toBe(6);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-OPENSTATE-005: falls back to the viewer weekday without a timezone', () => {
|
||||
const at = new Date('2026-07-29T12:00:00Z');
|
||||
const jsDay = at.getDay();
|
||||
expect(placeWeekdayIndex(at, null)).toBe(jsDay === 0 ? 6 : jsDay - 1);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-OPENSTATE-006: falls back to the viewer weekday for an unusable timezone', () => {
|
||||
const at = new Date('2026-07-29T12:00:00Z');
|
||||
const jsDay = at.getDay();
|
||||
expect(placeWeekdayIndex(at, 'Not/AZone')).toBe(jsDay === 0 ? 6 : jsDay - 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveOpenNow', () => {
|
||||
it('FE-PLANNER-OPENSTATE-010: judges the periods in the place timezone, not the viewer one', () => {
|
||||
// Sunday 20:00 UTC → Monday 05:00 in Seoul. The server said closed.
|
||||
const hours = { periods: [period(MON, 0, MON, 12)] };
|
||||
expect(resolveOpenNow(hours, ...SEOUL, false, new Date('2026-07-26T20:00:00Z'))).toBe(true);
|
||||
// The same instant is still Sunday 22:00 in Paris, where Monday has not begun.
|
||||
expect(resolveOpenNow(hours, ...PARIS, true, new Date('2026-07-26T20:00:00Z'))).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-OPENSTATE-011: reports closed when the place clock is outside the hours', () => {
|
||||
const hours = { periods: [period(MON, 9, MON, 18)] };
|
||||
// Sunday 23:00 UTC → Monday 08:00 Seoul is still an hour before opening.
|
||||
expect(resolveOpenNow(hours, ...SEOUL, true, new Date('2026-07-26T23:00:00Z'))).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-OPENSTATE-012: a weekday with no period of its own counts as closed', () => {
|
||||
const hours = { periods: [period(MON, 9, MON, 18), period(TUE, 9, TUE, 18)] };
|
||||
// Wednesday 12:00 in Seoul.
|
||||
expect(resolveOpenNow(hours, ...SEOUL, true, new Date('2026-07-29T03:00:00Z'))).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-OPENSTATE-013: minutes are honoured on both ends of a period', () => {
|
||||
const hours = {
|
||||
periods: [{ open: { day: WED, hour: 9, minute: 30 }, close: { day: WED, hour: 17, minute: 15 } }],
|
||||
};
|
||||
// 09:29 Seoul — a minute short.
|
||||
expect(resolveOpenNow(hours, ...SEOUL, true, new Date('2026-07-29T00:29:00Z'))).toBe(false);
|
||||
// 09:30 Seoul.
|
||||
expect(resolveOpenNow(hours, ...SEOUL, false, new Date('2026-07-29T00:30:00Z'))).toBe(true);
|
||||
// 17:15 Seoul — the closing minute itself is shut.
|
||||
expect(resolveOpenNow(hours, ...SEOUL, true, new Date('2026-07-29T08:15:00Z'))).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-OPENSTATE-014: handles a second period after a midday break', () => {
|
||||
const hours = { periods: [period(WED, 9, WED, 12), period(WED, 14, WED, 20)] };
|
||||
// 13:00 Seoul — in the break.
|
||||
expect(resolveOpenNow(hours, ...SEOUL, true, new Date('2026-07-29T04:00:00Z'))).toBe(false);
|
||||
// 15:00 Seoul — second period.
|
||||
expect(resolveOpenNow(hours, ...SEOUL, false, new Date('2026-07-29T06:00:00Z'))).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-OPENSTATE-015: a period running past midnight stays open after the start', () => {
|
||||
const hours = { periods: [period(WED, 20, THU, 2)] };
|
||||
// 23:00 Seoul on Wednesday.
|
||||
expect(resolveOpenNow(hours, ...SEOUL, false, new Date('2026-07-29T14:00:00Z'))).toBe(true);
|
||||
// 01:00 Seoul on Thursday, which has no period of its own.
|
||||
expect(resolveOpenNow(hours, ...SEOUL, false, new Date('2026-07-29T16:00:00Z'))).toBe(true);
|
||||
// 03:00 Seoul on Thursday — the night is over.
|
||||
expect(resolveOpenNow(hours, ...SEOUL, true, new Date('2026-07-29T18:00:00Z'))).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-OPENSTATE-016: a Saturday night period carries over into Sunday', () => {
|
||||
const hours = { periods: [period(SAT, 22, SUN, 4)] };
|
||||
// Sunday 02:00 Seoul — the period started the previous day and wraps the week end.
|
||||
expect(resolveOpenNow(hours, ...SEOUL, false, new Date('2026-07-25T17:00:00Z'))).toBe(true);
|
||||
// Sunday 05:00 Seoul.
|
||||
expect(resolveOpenNow(hours, ...SEOUL, true, new Date('2026-07-25T20:00:00Z'))).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-OPENSTATE-017: a period without a close is a round-the-clock place', () => {
|
||||
// Google describes 24/7 as one period opening Sunday midnight and never closing —
|
||||
// no text in any language is involved.
|
||||
const hours = { periods: [{ open: { day: SUN, hour: 0, minute: 0 } }] };
|
||||
expect(resolveOpenNow(hours, ...SEOUL, false, new Date('2026-07-29T03:00:00Z'))).toBe(true);
|
||||
expect(resolveOpenNow({ periods: [{ open: { day: SUN, hour: 0, minute: 0 }, close: null }] }, ...SEOUL, false,
|
||||
new Date('2026-07-29T18:00:00Z'))).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-OPENSTATE-018: a full week of daily periods is open at every hour', () => {
|
||||
const hours = { periods: [0, 1, 2, 3, 4, 5, 6].map(d => ({ open: { day: d, hour: 0, minute: 0 }, close: { day: (d + 1) % 7, hour: 0, minute: 0 } })) };
|
||||
for (const iso of ['2026-07-29T03:00:00Z', '2026-07-29T14:59:00Z', '2026-07-25T16:00:00Z']) {
|
||||
expect(resolveOpenNow(hours, ...SEOUL, false, new Date(iso))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-PLANNER-OPENSTATE-019: keeps the server verdict on a special day', () => {
|
||||
// 2026-07-29 is a holiday in the payload, so Google's own verdict decides.
|
||||
const hours = { periods: [period(WED, 9, WED, 18)], specialDays: ['2026-07-29'] };
|
||||
expect(resolveOpenNow(hours, ...SEOUL, false, new Date('2026-07-29T03:00:00Z'))).toBe(false);
|
||||
expect(resolveOpenNow(hours, ...SEOUL, true, new Date('2026-07-29T03:00:00Z'))).toBe(true);
|
||||
// The day before is a normal Tuesday with no period — recomputed, not deferred.
|
||||
expect(resolveOpenNow(hours, ...SEOUL, true, new Date('2026-07-28T03:00:00Z'))).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-OPENSTATE-020: keeps the server verdict without usable periods', () => {
|
||||
const at = new Date('2026-07-29T03:00:00Z');
|
||||
expect(resolveOpenNow(null, ...SEOUL, true, at)).toBe(true);
|
||||
expect(resolveOpenNow({ periods: [] }, ...SEOUL, false, at)).toBe(false);
|
||||
expect(resolveOpenNow({ periods: null }, ...SEOUL, true, at)).toBe(true);
|
||||
expect(resolveOpenNow({}, ...SEOUL, undefined, at)).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-OPENSTATE-021: keeps the server verdict without coordinates', () => {
|
||||
const hours = { periods: [period(WED, 0, WED, 23)] };
|
||||
expect(resolveOpenNow(hours, null, null, true, new Date('2026-07-29T03:00:00Z'))).toBe(true);
|
||||
expect(resolveOpenNow(hours, null, null, undefined, new Date('2026-07-29T03:00:00Z'))).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-OPENSTATE-022: drops malformed periods instead of guessing', () => {
|
||||
const at = new Date('2026-07-29T03:00:00Z');
|
||||
// Wednesday 12:00 Seoul would be inside every one of these if they were trusted.
|
||||
const junk = [
|
||||
{ open: { day: 9, hour: 0, minute: 0 }, close: { day: 9, hour: 23, minute: 0 } },
|
||||
{ open: { day: WED, hour: 24, minute: 0 }, close: { day: WED, hour: 23, minute: 0 } },
|
||||
{ open: { day: WED, hour: '09', minute: 0 } },
|
||||
{ close: { day: WED, hour: 23, minute: 0 } },
|
||||
null,
|
||||
];
|
||||
// Nothing usable is left, so the server verdict stands instead of a stray "open".
|
||||
expect(resolveOpenNow({ periods: junk as never }, ...SEOUL, false, at)).toBe(false);
|
||||
// A period whose close point is broken is dropped rather than read as 24/7 — the
|
||||
// Monday period keeps the list usable, so the state is still recomputed.
|
||||
const brokenClose = [
|
||||
period(MON, 9, MON, 10),
|
||||
{ open: { day: WED, hour: 9, minute: 0 }, close: { day: WED, hour: 61, minute: 0 } },
|
||||
];
|
||||
expect(resolveOpenNow({ periods: brokenClose }, ...SEOUL, true, at)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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