Keep school dates in the school's timezone
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) <noreply@anthropic.com>
This commit is contained in:
86
src/core/dates.ts
Normal file
86
src/core/dates.ts
Normal file
@@ -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, ''));
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|||||||
import { ErrorCode, type GetPromptResult } from '@modelcontextprotocol/sdk/types.js';
|
import { ErrorCode, type GetPromptResult } from '@modelcontextprotocol/sdk/types.js';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import type { ServerContext } from '../context.ts';
|
import type { ServerContext } from '../context.ts';
|
||||||
|
import { germanDate } from '../core/dates.ts';
|
||||||
import { fold, joinSections, matchesAll, tokenize } from '../core/text.ts';
|
import { fold, joinSections, matchesAll, tokenize } from '../core/text.ts';
|
||||||
import { courseUri, roomUri } from './resources.ts';
|
import { courseUri, roomUri } from './resources.ts';
|
||||||
import { readCourse } from './tools/content.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);
|
return withOverview(context, target, 'Prüfungsvorbereitung', text);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- arguments -----------------------------------------------------------
|
// --- 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 {
|
function numbered(items: (string | false | undefined)[]): string {
|
||||||
return items
|
return items
|
||||||
.filter((item): item is string => Boolean(item))
|
.filter((item): item is string => Boolean(item))
|
||||||
|
|||||||
66
test/dates.test.ts
Normal file
66
test/dates.test.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { describe, it } from 'node:test';
|
import { describe, it } from 'node:test';
|
||||||
import { ErrorCode } from '@modelcontextprotocol/sdk/types.js';
|
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';
|
import { ProtocolError } from '../src/mcp/tools/result.ts';
|
||||||
|
|
||||||
const CANDIDATES: Target[] = [
|
const CANDIDATES: Target[] = [
|
||||||
@@ -151,3 +152,4 @@ describe('germanDate', () => {
|
|||||||
assert.equal(germanDate(new Date('2026-09-14T23:30:00Z')), 'Dienstag, 15.09.2026');
|
assert.equal(germanDate(new Date('2026-09-14T23:30:00Z')), 'Dienstag, 15.09.2026');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user