From 196e10eacc8a97be4b20476bca0beb130a7dfcab Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Thu, 17 Sep 2026 20:43:24 +0200 Subject: [PATCH] Keep school dates in the school's timezone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The container runs on UTC and the school does not: at 00:30 in Erfurt the process clock still says yesterday, so anything deriving "today" from a Date would prepare the wrong school day twice a night. core/dates.ts holds calendar dates as plain YYYY-MM-DD strings, which is what a school day is — today in Europe/Berlin, whole-day arithmetic anchored at noon UTC so no daylight-saving change can shift a date, the German weekday and day formats, and the compact form the timetable API takes. isCalendarDate round-trips rather than only matching a shape: Date.parse turns 2026-02-30 into March 2nd instead of refusing it, so a shape check alone would let a caller read a different day than it asked for. germanDate moves here from mcp/prompts.ts, its only previous home, so the timezone is stated in one place. Co-Authored-By: Claude Opus 5 (1M context) --- src/core/dates.ts | 86 ++++++++++++++++++++++++++++++++++++++++++++ src/mcp/prompts.ts | 13 ++----- test/dates.test.ts | 66 ++++++++++++++++++++++++++++++++++ test/prompts.test.ts | 4 ++- 4 files changed, 157 insertions(+), 12 deletions(-) create mode 100644 src/core/dates.ts create mode 100644 test/dates.test.ts diff --git a/src/core/dates.ts b/src/core/dates.ts new file mode 100644 index 0000000..39005ef --- /dev/null +++ b/src/core/dates.ts @@ -0,0 +1,86 @@ +/** + * Calendar dates in the school's timezone. + * + * The container runs on UTC and the school does not: at 00:30 in Erfurt it is + * still yesterday in UTC, so "today's timetable" derived from the process clock + * would fetch the wrong day twice a night. Every date a person or the timetable + * API sees is derived here instead, in Europe/Berlin. + * + * Dates are plain `YYYY-MM-DD` strings on purpose. A `Date` carries a time and + * a zone, which is exactly what a school day does not have. + */ + +export const SCHOOL_TIME_ZONE = 'Europe/Berlin'; + +/** `YYYY-MM-DD`, in the school's timezone. */ +export function schoolToday(now: Date = new Date()): string { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: SCHOOL_TIME_ZONE, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(now); + const part = (type: Intl.DateTimeFormatPartTypes): string => parts.find((p) => p.type === type)?.value ?? ''; + return `${part('year')}-${part('month')}-${part('day')}`; +} + +/** + * True for a date that exists. + * + * The round trip is the point: `Date.parse` turns `2026-02-30` into March 2nd + * rather than rejecting it, so a shape check alone would let a tool read a + * different day than the caller asked for. + */ +export function isCalendarDate(value: string): boolean { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; + const at = Date.parse(`${value}T12:00:00Z`); + return !Number.isNaN(at) && new Date(at).toISOString().slice(0, 10) === value; +} + +function noonUtc(date: string): number { + const at = Date.parse(`${date}T12:00:00Z`); + if (Number.isNaN(at)) throw new Error(`Not a YYYY-MM-DD date: ${date}`); + return at; +} + +/** + * Shifts a calendar date by whole days. + * + * Anchored at noon UTC so that no daylight-saving change can push the result + * onto the neighbouring day — the arithmetic stays on calendar dates. + */ +export function addDays(date: string, days: number): string { + return new Date(noonUtc(date) + days * 86_400_000).toISOString().slice(0, 10); +} + +/** Whole days from `from` to `to`; negative when `to` is earlier. */ +export function daysBetween(from: string, to: string): number { + return Math.round((noonUtc(to) - noonUtc(from)) / 86_400_000); +} + +/** `2026-09-21` → `Montag`. */ +export function germanWeekday(date: string): string { + return new Intl.DateTimeFormat('de-DE', { weekday: 'long', timeZone: 'UTC' }).format(new Date(noonUtc(date))); +} + +/** `2026-09-21` → `21.09.2026`. */ +export function germanDay(date: string): string { + const [year, month, day] = date.split('-'); + return `${day}.${month}.${year}`; +} + +/** A moment → `Dienstag, 15.09.2026` in the school's timezone. */ +export function germanDate(date: Date): string { + return new Intl.DateTimeFormat('de-DE', { + weekday: 'long', + day: '2-digit', + month: '2-digit', + year: 'numeric', + timeZone: SCHOOL_TIME_ZONE, + }).format(date); +} + +/** `2026-09-21` → `20260921`, the compact form the WebUntis API takes. */ +export function compactDate(date: string): number { + return Number(date.replace(/-/g, '')); +} diff --git a/src/mcp/prompts.ts b/src/mcp/prompts.ts index 6badabd..b126fe8 100644 --- a/src/mcp/prompts.ts +++ b/src/mcp/prompts.ts @@ -2,6 +2,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { ErrorCode, type GetPromptResult } from '@modelcontextprotocol/sdk/types.js'; import { z } from 'zod'; import type { ServerContext } from '../context.ts'; +import { germanDate } from '../core/dates.ts'; import { fold, joinSections, matchesAll, tokenize } from '../core/text.ts'; import { courseUri, roomUri } from './resources.ts'; import { readCourse } from './tools/content.ts'; @@ -79,6 +80,7 @@ export function registerPrompts(server: McpServer, context: ServerContext): void return withOverview(context, target, 'Prüfungsvorbereitung', text); }, ); + } // --- arguments ----------------------------------------------------------- @@ -281,17 +283,6 @@ export function examPrompt(target: Target, options: { topic?: string; date?: str ]); } -/** "Dienstag, 15.09.2026" — with the weekday, so a plan can say "bis Freitag". */ -export function germanDate(date: Date): string { - return new Intl.DateTimeFormat('de-DE', { - weekday: 'long', - day: '2-digit', - month: '2-digit', - year: 'numeric', - timeZone: 'Europe/Berlin', - }).format(date); -} - function numbered(items: (string | false | undefined)[]): string { return items .filter((item): item is string => Boolean(item)) diff --git a/test/dates.test.ts b/test/dates.test.ts new file mode 100644 index 0000000..4784b1a --- /dev/null +++ b/test/dates.test.ts @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + addDays, + compactDate, + daysBetween, + germanDate, + germanDay, + germanWeekday, + isCalendarDate, + schoolToday, +} from '../src/core/dates.ts'; + +describe('schoolToday', () => { + it('is the school\'s date, not the container\'s', () => { + // 23:30 UTC in January is already tomorrow in Berlin (UTC+1) — the case + // that would make a nightly briefing prepare the wrong day. + assert.equal(schoolToday(new Date('2026-01-15T23:30:00Z')), '2026-01-16'); + // And in summer the offset is two hours. + assert.equal(schoolToday(new Date('2026-07-15T22:30:00Z')), '2026-07-16'); + assert.equal(schoolToday(new Date('2026-07-15T21:30:00Z')), '2026-07-15'); + }); +}); + +describe('addDays and daysBetween', () => { + it('walks calendar dates across a daylight-saving change', () => { + // Clocks go forward in the EU on 2026-03-29, so a day of 23 hours lies + // inside this range. + assert.equal(addDays('2026-03-28', 2), '2026-03-30'); + assert.equal(daysBetween('2026-03-28', '2026-03-30'), 2); + // And back again in October. + assert.equal(addDays('2026-10-24', 2), '2026-10-26'); + }); + + it('crosses months and years', () => { + assert.equal(addDays('2026-12-31', 1), '2027-01-01'); + assert.equal(addDays('2026-01-01', -1), '2025-12-31'); + assert.equal(addDays('2028-02-28', 1), '2028-02-29'); + assert.equal(daysBetween('2026-09-21', '2026-09-18'), -3); + }); + + it('refuses something that is not a calendar date', () => { + assert.throws(() => addDays('21.09.2026', 1), /YYYY-MM-DD/); + }); +}); + +describe('calendar date helpers', () => { + it('recognises real dates only', () => { + assert.equal(isCalendarDate('2026-09-21'), true); + assert.equal(isCalendarDate('2026-13-01'), false); + assert.equal(isCalendarDate('2026-02-30'), false); + assert.equal(isCalendarDate('2026-9-21'), false); + assert.equal(isCalendarDate('heute'), false); + }); + + it('formats the way a German timetable reads', () => { + assert.equal(germanWeekday('2026-09-21'), 'Montag'); + assert.equal(germanWeekday('2026-09-17'), 'Donnerstag'); + assert.equal(germanDay('2026-09-21'), '21.09.2026'); + assert.equal(compactDate('2026-09-21'), 20_260_921); + }); + + it('gives a moment the school\'s day, with its weekday', () => { + assert.equal(germanDate(new Date('2026-09-14T23:30:00Z')), 'Dienstag, 15.09.2026'); + }); +}); diff --git a/test/prompts.test.ts b/test/prompts.test.ts index b0998aa..7aade99 100644 --- a/test/prompts.test.ts +++ b/test/prompts.test.ts @@ -1,7 +1,8 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { ErrorCode } from '@modelcontextprotocol/sdk/types.js'; -import { argumentText, examPrompt, germanDate, resolveTarget, summaryPrompt, type Target } from '../src/mcp/prompts.ts'; +import { germanDate } from '../src/core/dates.ts'; +import { argumentText, examPrompt, resolveTarget, summaryPrompt, type Target } from '../src/mcp/prompts.ts'; import { ProtocolError } from '../src/mcp/tools/result.ts'; const CANDIDATES: Target[] = [ @@ -151,3 +152,4 @@ describe('germanDate', () => { assert.equal(germanDate(new Date('2026-09-14T23:30:00Z')), 'Dienstag, 15.09.2026'); }); }); +