diff --git a/package-lock.json b/package-lock.json index e039726..6e4735c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@nbtca/docs": "^0.3.4", - "@nbtca/nbtcal": "^0.4.3", + "@nbtca/nbtcal": "^0.4.4", "chalk": "^5.6.2", "marked": "^15.0.12" }, @@ -779,9 +779,9 @@ } }, "node_modules/@nbtca/nbtcal": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@nbtca/nbtcal/-/nbtcal-0.4.3.tgz", - "integrity": "sha512-2xaqZ4gECjsJ6rfKbdxi0G4RiTWPDx3ZZuOC1SmbOu+H+Fr+m3XlzFQQWtf18p7uoihRExl8TboQjI9GGaC1Mw==", + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@nbtca/nbtcal/-/nbtcal-0.4.4.tgz", + "integrity": "sha512-brD5OgI4i4B3/+DiwZP+zwfDcKBGNTwL/VadrEeW/akYinKK/v7pW5jTrxjSDQ88kWbpOxn3lS7yslS9bxPOww==", "license": "MIT", "dependencies": { "ical.js": "^2.2.1" diff --git a/package.json b/package.json index 70afd6c..2ad2c92 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ ], "dependencies": { "@nbtca/docs": "^0.3.4", - "@nbtca/nbtcal": "^0.4.3", + "@nbtca/nbtcal": "^0.4.4", "chalk": "^5.6.2", "marked": "^15.0.12" }, diff --git a/src/app/views/schedule.test.ts b/src/app/views/schedule.test.ts index 8302c2a..c462910 100644 --- a/src/app/views/schedule.test.ts +++ b/src/app/views/schedule.test.ts @@ -91,7 +91,7 @@ vi.mock('../../features/calendar.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - loadCalendarOrThrow: vi.fn().mockResolvedValue({ + loadSchoolCalendar: vi.fn().mockResolvedValue({ upcoming: calendarUpcoming, past: vi.fn().mockReturnValue([]), next: vi.fn().mockReturnValue([]), diff --git a/src/app/views/schedule.ts b/src/app/views/schedule.ts index 7b7c0d0..77992e3 100644 --- a/src/app/views/schedule.ts +++ b/src/app/views/schedule.ts @@ -42,7 +42,7 @@ import { loadTimetableCache, clearScheduleCache, } from '../../features/schedule-store.js'; -import { loadCalendarOrThrow } from '../../features/calendar.js'; +import { loadSchoolCalendar } from '../../features/calendar.js'; import { currentAcademicWindow, inferWeekOneMonday, @@ -175,7 +175,7 @@ async function goToPublic(ctx: AppContext, generation = lifecycleGeneration): Pr state = { mode: 'public', publicField: buildPublicField() }; ctx.rerender(); try { - const cal = await loadCalendarOrThrow(ctx.signal); + const cal = await loadSchoolCalendar(ctx.signal); if (!isLifecycleActive(ctx, generation)) return; const now = new Date(); const windowEvents = cal.inRange(addLocalDays(now, -400), addLocalDays(now, 400)); @@ -190,7 +190,7 @@ async function goToPublic(ctx: AppContext, generation = lifecycleGeneration): Pr async function tryInferWeekOne(ctx: AppContext, generation: number): Promise { try { - const cal = await loadCalendarOrThrow(ctx.signal); + const cal = await loadSchoolCalendar(ctx.signal); if (!isLifecycleActive(ctx, generation)) return null; const now = new Date(); const events = cal.inRange(addLocalDays(now, -400), addLocalDays(now, 400)); diff --git a/src/features/calendar-load.test.ts b/src/features/calendar-load.test.ts index 180ae8b..d70a7e6 100644 --- a/src/features/calendar-load.test.ts +++ b/src/features/calendar-load.test.ts @@ -81,7 +81,7 @@ describe('loadCalendarOrThrow', () => { expect(uids(await loadCalendarOrThrow())).toEqual(['fresh']); expect(sentValidators()).toEqual({}); - expect(saveFeedCache).toHaveBeenCalledWith(result); + expect(saveFeedCache).toHaveBeenCalledWith('calendar-feed', result); }); it('reuses the peeked calendar on 304 without parsing it again', async () => { @@ -97,7 +97,7 @@ describe('loadCalendarOrThrow', () => { expect(sentValidators()).toEqual({ etag: '"v1"' }); expect(parseCalendar).toHaveBeenCalledTimes(1); expect(saveFeedCache).not.toHaveBeenCalled(); - expect(touchFeedCache).toHaveBeenCalledWith({ etag: '"v1"' }); + expect(touchFeedCache).toHaveBeenCalledWith('calendar-feed', { etag: '"v1"' }); await loadCalendarOrThrow(); expect(fetchFeedConditional).toHaveBeenCalledTimes(1); @@ -133,7 +133,7 @@ describe('loadCalendarOrThrow', () => { const calendar = await loadCalendarOrThrow(); expect(uids(calendar)).toEqual(['changed']); expect(peekCalendar()).toBe(calendar); - expect(saveFeedCache).toHaveBeenCalledWith(result); + expect(saveFeedCache).toHaveBeenCalledWith('calendar-feed', result); expect(touchFeedCache).not.toHaveBeenCalled(); }); @@ -157,3 +157,29 @@ describe('loadCalendarOrThrow', () => { await expect(loadCalendarOrThrow()).rejects.toThrow('HTTP 304'); }); }); + +describe('loadSchoolCalendar', () => { + it('fetches the school feed into its own cache', async () => { + const { loadSchoolCalendar, loadCalendarOrThrow } = await freshCalendar(); + loadFeedCache.mockReturnValue(null); + const school = { status: 'modified', text: feed('school'), validators: {} }; + fetchFeedConditional.mockResolvedValueOnce(school); + expect(uids(await loadSchoolCalendar())).toEqual(['school']); + expect(fetchFeedConditional.mock.calls[0]?.[0]).toBe('https://ical.nbtca.space/school.ics'); + expect(saveFeedCache).toHaveBeenCalledWith('school-feed', school); + fetchFeedConditional.mockResolvedValueOnce({ + status: 'modified', + text: feed('club'), + validators: {}, + }); + expect(uids(await loadCalendarOrThrow())).toEqual(['club']); + }); + + it('falls back to the cached school feed when the network fails', async () => { + const { loadSchoolCalendar } = await freshCalendar(); + fetchFeedConditional.mockRejectedValue(new Error('offline')); + loadFeedCache.mockReturnValue(cached); + expect(uids(await loadSchoolCalendar())).toEqual(['cached']); + expect(loadFeedCache).toHaveBeenCalledWith('school-feed'); + }); +}); diff --git a/src/features/calendar-store.test.ts b/src/features/calendar-store.test.ts index dc77a2c..bf2ac56 100644 --- a/src/features/calendar-store.test.ts +++ b/src/features/calendar-store.test.ts @@ -18,14 +18,17 @@ function withDir(run: (dir: string) => void): void { describe('calendar-store', () => { it('round-trips the feed and its validators via an injected dir', () => { withDir((dir) => { - saveFeedCache({ text: 'BEGIN:VCALENDAR\nEND:VCALENDAR', validators }, dir); - expect(loadFeedCache(dir)).toEqual({ text: 'BEGIN:VCALENDAR\nEND:VCALENDAR', validators }); + saveFeedCache('calendar-feed', { text: 'BEGIN:VCALENDAR\nEND:VCALENDAR', validators }, dir); + expect(loadFeedCache('calendar-feed', dir)).toEqual({ + text: 'BEGIN:VCALENDAR\nEND:VCALENDAR', + validators, + }); }); }); it.runIf(process.platform !== 'win32')('keeps both files private', () => { withDir((dir) => { - saveFeedCache({ text: 'x', validators }, dir); + saveFeedCache('calendar-feed', { text: 'x', validators }, dir); expect(statSync(join(dir, 'calendar-feed.ics')).mode & 0o777).toBe(0o600); expect(statSync(join(dir, 'calendar-feed.json')).mode & 0o777).toBe(0o600); }); @@ -33,7 +36,7 @@ describe('calendar-store', () => { it('reports a miss when nothing was cached', () => { withDir((dir) => { - expect(loadFeedCache(dir)).toBeNull(); + expect(loadFeedCache('calendar-feed', dir)).toBeNull(); }); }); @@ -44,31 +47,31 @@ describe('calendar-store', () => { ['an unsafe header value', JSON.stringify({ etag: '"v1"\r\nX: y', lastModified: 42 })], ])('drops validators that are %s', (_label, content) => { withDir((dir) => { - saveFeedCache({ text: 'x', validators }, dir); + saveFeedCache('calendar-feed', { text: 'x', validators }, dir); const file = join(dir, 'calendar-feed.json'); if (content === null) rmSync(file); else writeFileSync(file, content); - expect(loadFeedCache(dir)).toEqual({ text: 'x', validators: {} }); + expect(loadFeedCache('calendar-feed', dir)).toEqual({ text: 'x', validators: {} }); }); }); it('refuses a feed older than the max age', () => { withDir((dir) => { - saveFeedCache({ text: 'stale', validators: {} }, dir); + saveFeedCache('calendar-feed', { text: 'stale', validators: {} }, dir); const longAgo = new Date(Date.now() - 60 * 60 * 1000); utimesSync(join(dir, 'calendar-feed.ics'), longAgo, longAgo); - expect(loadFeedCache(dir, 30 * 60 * 1000)).toBeNull(); - expect(loadFeedCache(dir, 2 * 60 * 60 * 1000)?.text).toBe('stale'); + expect(loadFeedCache('calendar-feed', dir, 30 * 60 * 1000)).toBeNull(); + expect(loadFeedCache('calendar-feed', dir, 2 * 60 * 60 * 1000)?.text).toBe('stale'); }); }); it('renews the feed age and validators when the feed is unchanged', () => { withDir((dir) => { - saveFeedCache({ text: 'same', validators }, dir); + saveFeedCache('calendar-feed', { text: 'same', validators }, dir); const longAgo = new Date(Date.now() - 60 * 60 * 1000); utimesSync(join(dir, 'calendar-feed.ics'), longAgo, longAgo); - touchFeedCache({ etag: '"v2"' }, dir); - expect(loadFeedCache(dir, 30 * 60 * 1000)).toEqual({ + touchFeedCache('calendar-feed', { etag: '"v2"' }, dir); + expect(loadFeedCache('calendar-feed', dir, 30 * 60 * 1000)).toEqual({ text: 'same', validators: { etag: '"v2"' }, }); @@ -78,8 +81,8 @@ describe('calendar-store', () => { it('stays quiet when the directory cannot be written', () => { const missing = join(tmpdir(), 'cal-missing', 'deeper'); expect(() => { - saveFeedCache({ text: 'x', validators }, missing); - touchFeedCache(validators, missing); + saveFeedCache('calendar-feed', { text: 'x', validators }, missing); + touchFeedCache('calendar-feed', validators, missing); }).not.toThrow(); }); }); diff --git a/src/features/calendar-store.ts b/src/features/calendar-store.ts index 1fd39ed..fa1a438 100644 --- a/src/features/calendar-store.ts +++ b/src/features/calendar-store.ts @@ -3,8 +3,6 @@ import path from 'path'; import type { FeedValidators } from '@nbtca/nbtcal'; import { getStateDir, getWritableStateDir } from '../config/paths.js'; -const FEED_FILE = 'calendar-feed.ics'; -const VALIDATORS_FILE = 'calendar-feed.json'; const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; const HEADER_VALUE = /^[\x21-\x7e][\x20-\x7e]*$/; @@ -17,9 +15,9 @@ function headerValue(value: unknown): string | undefined { return typeof value === 'string' && HEADER_VALUE.test(value) ? value : undefined; } -function readValidators(dir: string): FeedValidators { +function readValidators(name: string, dir: string): FeedValidators { try { - const data = JSON.parse(fs.readFileSync(path.join(dir, VALIDATORS_FILE), 'utf8')) as Record< + const data = JSON.parse(fs.readFileSync(path.join(dir, `${name}.json`), 'utf8')) as Record< string, unknown >; @@ -32,22 +30,22 @@ function readValidators(dir: string): FeedValidators { } // Validators are written after the text and read before it, so they can never be newer than the text. -export function saveFeedCache({ text, validators }: FeedCache, dir?: string): void { +export function saveFeedCache(name: string, { text, validators }: FeedCache, dir?: string): void { try { const target = dir ?? getWritableStateDir(); - fs.writeFileSync(path.join(target, FEED_FILE), text, { encoding: 'utf8', mode: 0o600 }); - touchFeedCache(validators, target); + fs.writeFileSync(path.join(target, `${name}.ics`), text, { encoding: 'utf8', mode: 0o600 }); + touchFeedCache(name, validators, target); } catch { /* best effort */ } } -export function touchFeedCache(validators: FeedValidators, dir?: string): void { +export function touchFeedCache(name: string, validators: FeedValidators, dir?: string): void { try { const target = dir ?? getWritableStateDir(); const now = new Date(); - fs.utimesSync(path.join(target, FEED_FILE), now, now); - fs.writeFileSync(path.join(target, VALIDATORS_FILE), JSON.stringify(validators), { + fs.utimesSync(path.join(target, `${name}.ics`), now, now); + fs.writeFileSync(path.join(target, `${name}.json`), JSON.stringify(validators), { encoding: 'utf8', mode: 0o600, }); @@ -56,11 +54,11 @@ export function touchFeedCache(validators: FeedValidators, dir?: string): void { } } -export function loadFeedCache(dir?: string, maxAgeMs = MAX_AGE_MS): FeedCache | null { +export function loadFeedCache(name: string, dir?: string, maxAgeMs = MAX_AGE_MS): FeedCache | null { try { const source = dir ?? getStateDir(); - const validators = readValidators(source); - const file = path.join(source, FEED_FILE); + const validators = readValidators(name, source); + const file = path.join(source, `${name}.ics`); if (Date.now() - fs.statSync(file).mtimeMs > maxAgeMs) return null; return { text: fs.readFileSync(file, 'utf8'), validators }; } catch { diff --git a/src/features/calendar.ts b/src/features/calendar.ts index 557baae..be334e2 100644 --- a/src/features/calendar.ts +++ b/src/features/calendar.ts @@ -5,6 +5,7 @@ import { FeedFetchError, FeedParseError, eventToICS, + SCHOOL_FEED_URL, } from '@nbtca/nbtcal'; import type { Calendar, CalendarEvent, FeedValidators, HeatmapBucket } from '@nbtca/nbtcal'; import chalk from 'chalk'; @@ -73,56 +74,76 @@ function formatTime(date: Date): string { const MEMO_TTL_MS = 5 * 60 * 1000; -let memo: { calendar: Calendar; validators: FeedValidators; fetchedAt: number } | undefined; -let inFlight: Promise | undefined; +function feedLoader(url: string | undefined, name: string) { + let memo: { calendar: Calendar; validators: FeedValidators; fetchedAt: number } | undefined; + let inFlight: Promise | undefined; -function remember(text: string, validators: FeedValidators, fetchedAt: number): Calendar { - memo = { calendar: createCalendar(parseCalendar(text)), validators, fetchedAt }; - return memo.calendar; -} + function remember(text: string, validators: FeedValidators, fetchedAt: number): Calendar { + memo = { calendar: createCalendar(parseCalendar(text)), validators, fetchedAt }; + return memo.calendar; + } -export function peekCalendar(): Calendar | undefined { - if (memo) return memo.calendar; - const cache = loadFeedCache(); - if (cache === null) return undefined; - try { - return remember(cache.text, cache.validators, 0); - } catch { - return undefined; + function peek(): Calendar | undefined { + if (memo) return memo.calendar; + const cache = loadFeedCache(name); + if (cache === null) return undefined; + try { + return remember(cache.text, cache.validators, 0); + } catch { + return undefined; + } } -} -async function refetchCalendar(signal?: AbortSignal): Promise { - try { - peekCalendar(); - const result = await fetchFeedConditional(undefined, { - timeoutMs: 15000, - ...(signal === undefined ? {} : { signal }), - validators: memo?.validators ?? {}, - }); - if (result.status === 'modified') { - const calendar = remember(result.text, result.validators, Date.now()); - saveFeedCache(result); - return calendar; + async function refetch(signal?: AbortSignal): Promise { + try { + peek(); + const result = await fetchFeedConditional(url, { + timeoutMs: 15000, + ...(signal === undefined ? {} : { signal }), + validators: memo?.validators ?? {}, + }); + if (result.status === 'modified') { + const calendar = remember(result.text, result.validators, Date.now()); + saveFeedCache(name, result); + return calendar; + } + if (!memo) throw new FeedFetchError('Feed request failed: HTTP 304'); + memo = { ...memo, validators: result.validators, fetchedAt: Date.now() }; + touchFeedCache(name, result.validators); + return memo.calendar; + } catch (err) { + const detail = sanitizeTerminalLine( + err instanceof FeedFetchError || err instanceof FeedParseError ? err.message : String(err), + ); + throw new Error(`${t().calendar.error}: ${detail}`); } - if (!memo) throw new FeedFetchError('Feed request failed: HTTP 304'); - memo = { ...memo, validators: result.validators, fetchedAt: Date.now() }; - touchFeedCache(result.validators); - return memo.calendar; - } catch (err) { - const detail = sanitizeTerminalLine( - err instanceof FeedFetchError || err instanceof FeedParseError ? err.message : String(err), - ); - throw new Error(`${t().calendar.error}: ${detail}`); } + + async function load(signal?: AbortSignal): Promise { + if (memo && Date.now() - memo.fetchedAt < MEMO_TTL_MS) return memo.calendar; + inFlight ??= refetch(signal).finally(() => { + inFlight = undefined; + }); + return inFlight; + } + + return { peek, load }; } -export async function loadCalendarOrThrow(signal?: AbortSignal): Promise { - if (memo && Date.now() - memo.fetchedAt < MEMO_TTL_MS) return memo.calendar; - inFlight ??= refetchCalendar(signal).finally(() => { - inFlight = undefined; - }); - return inFlight; +const eventsFeed = feedLoader(undefined, 'calendar-feed'); +const schoolFeed = feedLoader(SCHOOL_FEED_URL, 'school-feed'); + +export const peekCalendar = eventsFeed.peek; +export const loadCalendarOrThrow = eventsFeed.load; + +export async function loadSchoolCalendar(signal?: AbortSignal): Promise { + try { + return await schoolFeed.load(signal); + } catch (err) { + const cached = schoolFeed.peek(); + if (!cached) throw err; + return cached; + } } export function toDisplayEvent(e: CalendarEvent): Event {