Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
2 changes: 1 addition & 1 deletion src/app/views/schedule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ vi.mock('../../features/calendar.js', async (importOriginal) => {
const actual = await importOriginal<typeof CalendarModule>();
return {
...actual,
loadCalendarOrThrow: vi.fn().mockResolvedValue({
loadSchoolCalendar: vi.fn().mockResolvedValue({
upcoming: calendarUpcoming,
past: vi.fn().mockReturnValue([]),
next: vi.fn().mockReturnValue([]),
Expand Down
6 changes: 3 additions & 3 deletions src/app/views/schedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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));
Expand All @@ -190,7 +190,7 @@ async function goToPublic(ctx: AppContext, generation = lifecycleGeneration): Pr

async function tryInferWeekOne(ctx: AppContext, generation: number): Promise<string | null> {
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));
Expand Down
32 changes: 29 additions & 3 deletions src/features/calendar-load.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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);
Expand Down Expand Up @@ -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();
});

Expand All @@ -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');
});
});
31 changes: 17 additions & 14 deletions src/features/calendar-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,25 @@ 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);
});
});

it('reports a miss when nothing was cached', () => {
withDir((dir) => {
expect(loadFeedCache(dir)).toBeNull();
expect(loadFeedCache('calendar-feed', dir)).toBeNull();
});
});

Expand All @@ -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"' },
});
Expand All @@ -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();
});
});
24 changes: 11 additions & 13 deletions src/features/calendar-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]*$/;

Expand All @@ -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
>;
Expand All @@ -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,
});
Expand All @@ -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 {
Expand Down
105 changes: 63 additions & 42 deletions src/features/calendar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<Calendar> | undefined;
function feedLoader(url: string | undefined, name: string) {
let memo: { calendar: Calendar; validators: FeedValidators; fetchedAt: number } | undefined;
let inFlight: Promise<Calendar> | 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<Calendar> {
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<Calendar> {
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<Calendar> {
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<Calendar> {
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<Calendar> {
try {
return await schoolFeed.load(signal);
} catch (err) {
const cached = schoolFeed.peek();
if (!cached) throw err;
return cached;
}
}

export function toDisplayEvent(e: CalendarEvent): Event {
Expand Down
Loading