diff --git a/README.md b/README.md index 35f4fe3..a865cb1 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ School systems come from plugins, not from the server. List one in `config/plugi - [Plugin management](https://docs.schuly.dev/SchulyServer/plugin-management): choose which school systems you offer - [Development setup](https://docs.schuly.dev/SchulyServer/setup/development): run it from source, with the API reference at `/scalar` - [Architecture](https://docs.schuly.dev/SchulyServer/architecture): how the code is layered +- [`tools/screenshots`](tools/screenshots): regenerate the sign-in page screenshots ## License diff --git a/tools/screenshots/README.md b/tools/screenshots/README.md new file mode 100644 index 0000000..f6fb054 --- /dev/null +++ b/tools/screenshots/README.md @@ -0,0 +1,51 @@ +# Sign-in screenshots + +Makes the two Overview screenshots on the web app's sign-in page: `src/Schuly.Frontend/public/screens/overview-light.png` and `overview-dark.png` (1440×900). SchulyWebsite also uses the dark one, cropped, in its hero and social image (see its `tools/hero-images`). + +The web app runs against a fake API with made-up data (Mia Muster, `mia@example.org`), so no backend, database or real account is needed. + +| File | What it does | +|---|---| +| `fake-api.ts` | Bun server on port 5048 that answers the `/auth` and `/api` calls the Overview makes. The school day starts at 07:30 on the current day; the time decides which lesson is running. | +| `pfp.svg` | Mia's made-up profile picture. The API sends it as a data URL. | +| `proxy.json` | `ng serve` proxy config that sends `/api` and `/auth` to the fake API. | +| `capture.ts` | Starts the fake API and `ng serve`, opens the app in your installed Chrome or Edge (headless), takes the light shot, clicks the theme button and takes the dark shot. | + +## Prerequisites + +- Bun. +- `bun install` in `src/Schuly.Frontend`, for `ng serve`. +- Google Chrome or Microsoft Edge. The script looks in the usual install folders; otherwise pass `--browser ` or set `CHROME_PATH`. Nothing is downloaded. + +`capture.ts` drives the browser itself over the Chrome DevTools Protocol, with Bun's built-in WebSocket. It doesn't use Playwright, because both Playwright's launch and its CDP connection hang under Bun on Windows. The page's clock is frozen at `--now` the same way Playwright's `clock.setFixedTime` would freeze it. + +## Regenerate + +From the repo root: + +```sh +bun tools/screenshots/capture.ts +``` + +This writes both PNGs to `src/Schuly.Frontend/public/screens/`. It takes a minute or two, mostly while `ng serve` compiles. + +| Option | Default | | +|---|---|---| +| `--now ` | today at 13:03 local time | The browser's clock and the fake API's "now". 13:03 puts the screenshot 12 minutes before the end of the fifth lesson. To get the published shots exactly, use `2026-11-06T13:03:00+01:00`. | +| `--out ` | `src/Schuly.Frontend/public/screens` | | +| `--browser ` | Chrome, then Edge | The browser executable. | +| `--lang ` | `en-US` | The browser language, which picks the web app's language. | +| `--port ` | `4331` | The port for `ng serve`, which listens on `127.0.0.1`. | +| `--dry-run` | | Starts the fake API, checks that it answers, prints the plan and stops. It doesn't build the app or open a browser. | + +The fake API port is fixed at 5048 in `proxy.json` and `capture.ts`. If you change it, change both. + +On Windows the headless browser can still hold its temporary profile when the script ends. The script then prints the folder's path, and you can delete it later. + +The published shots came from an earlier build of the web app. A run on the current `main` matches them, except for the date formats: they now say `Tue, 10 Nov` where the published shots have `Di., 10. Nov.`. + +To look around by hand instead, run `bun tools/screenshots/fake-api.ts`, then run `bun run ng serve --port 4331 --proxy-config ../../tools/screenshots/proxy.json` in `src/Schuly.Frontend`. Open in a 1440×900 viewport. `GET /__scene/notimetable` or `/__scene/calm` on the fake API switches to an empty timetable or a quiet week; `/__scene/morning` switches back. + +## Licences + +All data is made up. The school website in `fake-api.ts` only selects which school's rules the web app applies, such as the absence fee shown on the Overview. diff --git a/tools/screenshots/capture.ts b/tools/screenshots/capture.ts new file mode 100644 index 0000000..7e254ba --- /dev/null +++ b/tools/screenshots/capture.ts @@ -0,0 +1,119 @@ +// Takes the sign-in page screenshots (the Overview in light and dark) against the fake API. +// Usage: bun tools/screenshots/capture.ts [--now ] [--out ] [--browser ] [--port 4331] [--lang en-US] [--dry-run] +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import type { Subprocess } from 'bun'; + +const root = resolve(import.meta.dir, '../..'); +const frontend = join(root, 'src/Schuly.Frontend'); +const arg = (name: string) => { const i = process.argv.indexOf(name); return i > 0 ? process.argv[i + 1] : undefined; }; +const dryRun = process.argv.includes('--dry-run'); +const out = resolve(arg('--out') ?? join(frontend, 'public/screens')); +const browserPath = [ + arg('--browser'), + process.env.CHROME_PATH, + 'C:/Program Files/Google/Chrome/Application/chrome.exe', + 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', + 'C:/Program Files/Microsoft/Edge/Application/msedge.exe', + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', + '/usr/bin/google-chrome', + '/usr/bin/microsoft-edge', +].find((p) => p && existsSync(p)); +if (!browserPath) throw new Error('No Chrome or Edge found. Pass --browser or set CHROME_PATH.'); +const cdpPort = 9339; +const port = Number(arg('--port') ?? 4331); +const lang = arg('--lang') ?? 'en-US'; // the web app follows the browser language +const apiPort = 5048; // must match proxy.json + +// Default: today at 13:03 local time, 12 minutes before the end of the fifth lesson, as in the published shots. +const today = new Date(); today.setHours(13, 3, 0, 0); +const now = new Date(arg('--now') ?? today.toISOString()); +if (Number.isNaN(now.getTime())) throw new Error('--now must be an ISO date and time, e.g. 2026-11-06T13:03:00+01:00'); + +const children: Subprocess[] = []; +const stopAll = () => { + for (const p of children) { + if (process.platform === 'win32') Bun.spawnSync(['taskkill', '/pid', String(p.pid), '/t', '/f'], { stdout: 'ignore', stderr: 'ignore' }); + else p.kill(); + } +}; +process.on('SIGINT', () => { stopAll(); process.exit(130); }); + +async function waitFor(url: string, timeoutMs: number) { + const until = Date.now() + timeoutMs; + while (Date.now() < until) { + try { if ((await fetch(url)).ok) return; } catch { /* not up yet */ } + await Bun.sleep(1000); + } + throw new Error(`${url} did not answer within ${timeoutMs / 1000} s`); +} + +// A minimal CDP client: send(method, params) resolves with the result of that call. +async function connect(url: string) { + const ws = new WebSocket(url); + await new Promise((ok, fail) => { ws.onopen = ok; ws.onerror = fail; }); + let id = 0; + const pending = new Map void; fail: (e: Error) => void }>(); + ws.onmessage = (e) => { + const msg = JSON.parse(String(e.data)); + const p = pending.get(msg.id); + if (!p) return; + pending.delete(msg.id); + if (msg.error) p.fail(new Error(msg.error.message)); else p.ok(msg.result); + }; + return (method: string, params: object = {}) => new Promise((ok, fail) => { pending.set(++id, { ok, fail }); ws.send(JSON.stringify({ id, method, params })); }); +} + +try { + children.push(Bun.spawn(['bun', join(import.meta.dir, 'fake-api.ts')], { env: { ...process.env, PORT: String(apiPort), SHOTS_NOW: now.toISOString() }, stdout: 'inherit', stderr: 'inherit' })); + await waitFor(`http://127.0.0.1:${apiPort}/api/app`, 10_000); + const agenda = (await (await fetch(`http://127.0.0.1:${apiPort}/api/agendas`)).json()) as unknown[]; + console.log(`fake API up, ${agenda.length} agenda entries for ${now.toString()}`); + + // --host: ng serve otherwise listens on ::1 only, which Bun's fetch of 127.0.0.1 doesn't reach. + const serve = ['bun', 'run', 'ng', 'serve', '--host', '127.0.0.1', '--port', String(port), '--proxy-config', join(import.meta.dir, 'proxy.json')]; + if (dryRun) { + console.log(`dry run: would run "${serve.join(' ')}" in ${frontend},`); + console.log(`open http://127.0.0.1:${port}/ in ${browserPath} at 1440x900 in ${lang} with the clock at ${now.toISOString()}, and write overview-light.png and overview-dark.png to ${out}`); + } else { + children.push(Bun.spawn(serve, { cwd: frontend, stdout: 'ignore', stderr: 'inherit' })); + await waitFor(`http://127.0.0.1:${port}/`, 240_000); + + // The installed browser in headless mode, driven over the Chrome DevTools Protocol with Bun's own WebSocket. + // No Playwright: its launch and its CDP client both hang under Bun on Windows. Nothing is downloaded. + const profile = mkdtempSync(join(tmpdir(), 'schuly-shots-')); + children.push(Bun.spawn([browserPath, '--headless=new', `--remote-debugging-port=${cdpPort}`, `--user-data-dir=${profile}`, '--no-first-run', `--lang=${lang}`, `--accept-lang=${lang}`, '--window-size=1440,900', 'about:blank'], { stdout: 'ignore', stderr: 'ignore' })); + await waitFor(`http://127.0.0.1:${cdpPort}/json/version`, 30_000); + try { + const targets = (await (await fetch(`http://127.0.0.1:${cdpPort}/json/list`)).json()) as { type: string; webSocketDebuggerUrl: string }[]; + const cdp = await connect(targets.find((t) => t.type === 'page')!.webSocketDebuggerUrl); + await cdp('Page.enable'); + await cdp('Emulation.setDeviceMetricsOverride', { width: 1440, height: 900, deviceScaleFactor: 1, mobile: false }); + await cdp('Emulation.setLocaleOverride', { locale: lang }); + await cdp('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: 'light' }] }); + // Freeze the page's clock at --now, like Playwright's clock.setFixedTime. Timers keep running. + await cdp('Page.addScriptToEvaluateOnNewDocument', { source: `(() => { const T = ${now.getTime()}; const D = Date; class F extends D { constructor(...a) { if (a.length) super(...a); else super(T); } static now() { return T; } } window.Date = F; })();` }); + await cdp('Page.navigate', { url: `http://127.0.0.1:${port}/` }); + await Bun.sleep(4000); + const evaluate = async (expression: string) => ((await cdp('Runtime.evaluate', { expression, returnByValue: true })) as { result: { value: unknown } }).result.value; + for (let i = 0; i < 2; i++) { + const dark = await evaluate("document.documentElement.classList.contains('dark')"); + const file = join(out, dark ? 'overview-dark.png' : 'overview-light.png'); + const { data } = (await cdp('Page.captureScreenshot', { format: 'png' })) as { data: string }; + await Bun.write(file, Buffer.from(data, 'base64')); + console.log(`wrote ${file}`); + if (i === 0) { + await evaluate("[...document.querySelectorAll('header button')].at(-1).click()"); // the theme toggle + await Bun.sleep(800); + } + } + } finally { + stopAll(); + try { rmSync(profile, { recursive: true, force: true, maxRetries: 10, retryDelay: 500 }); } catch { console.warn(`could not remove ${profile}; delete it later`); } + } + } +} finally { + stopAll(); +} diff --git a/tools/screenshots/fake-api.ts b/tools/screenshots/fake-api.ts new file mode 100644 index 0000000..80839fe --- /dev/null +++ b/tools/screenshots/fake-api.ts @@ -0,0 +1,100 @@ +// Stand-in for the Schuly API with made-up data, for the sign-in page screenshots of the Overview. +// Usage: bun tools/screenshots/fake-api.ts (PORT=5048 by default; SHOTS_NOW= pins "now", which capture.ts sets) +// The school website picks which school's rules the web app applies, such as the absence fee on the Overview. +const website = 'https://schulnetz.bbbaden.ch'; +const PORT = Number(process.env.PORT ?? 5048); +// Mia's made-up profile picture, sent as a data URL so the screenshot needs no image server. +const PFP = 'data:image/svg+xml;base64,' + Buffer.from(await Bun.file(import.meta.dir + '/pfp.svg').arrayBuffer()).toString('base64'); +const now = () => (process.env.SHOTS_NOW ? new Date(process.env.SHOTS_NOW) : new Date()); +const classes = new Map(); +const classId = (name: string) => { + if (!classes.has(name)) classes.set(name, `c${classes.size + 1}`); + return classes.get(name)!; +}; +let n = 0; +const exam = (subject: string, name: string, date: string | null, score: number, cls = 4.5, weighting = 1) => ({ + id: `e${++n}`, name, description: null, type: 'Exam', date, classAverage: cls, classId: classId(subject), schoolId: 's1', + grades: [{ id: `g${n}`, score, weighting, examId: `e${n}`, schoolUserId: 'u1' }], +}); +const exams = [ + exam('Mathematik Schwerpunkt', 'Mathematik: Potenzfunktionen (22.10.2026)', '2026-10-22', 3.9, 4.6), + exam('Mathematik Schwerpunkt', 'Mathematik:Polynomfunktionen (27.08.2026)', '2026-08-27', 3.6, 4.7), + exam('Deutsch', 'BM23d - Mündliche Prüfung Projekt (22.10.2026)', '2026-10-22', 5.5, 5.2), + exam('Deutsch', 'BM23d - Test Sprachnormen (03.09.2026)', '2026-09-03', 5.1, 4.4), + exam('Wirtschaft und Recht', 'Test 2 Eherecht (15.10.2026)', '2026-10-15', 5.5, 4.9), + exam('Wirtschaft und Recht', 'Test 1 Mietrecht (03.09.2026)', '2026-09-03', 3.6, 4.5), + exam('Naturwissenschaften (Physik)', 'Elektrizität 2 (15.10.2026)', '2026-10-15', 5.6, 5.3), + exam('Naturwissenschaften (Physik)', 'Energie und Grundlagen (10.09.2026)', '2026-09-10', 3.5, 4.2), + exam('Geschichte und Politik', 'Erster Weltkrieg (20.08.2026)', '2026-08-20', 5.0, 4.9), + exam('Interdisziplinäres Arbeiten in den Fächern', 'Zinseszins (25.09.2026)', '2026-09-25', 5.0, 4.8), + exam('Mathematik Schwerpunkt', 'Mathematik: Semesterprüfung (10.06.2026)', '2026-06-10', 4.3, 5.0, 2), + exam('Mathematik Schwerpunkt', 'Mathematik: Gleichungen (20.05.2026)', '2026-05-20', 5.1, 5.1), + exam('Wirtschaft und Recht', 'Test 2a Organisation (22.10.2025)', '2025-10-22', 4.1, 4.9, 0.4), + exam('Wirtschaft und Recht', 'Test 2b Organisation (29.10.2025)', '2025-10-29', 4.0, 3.9, 0.6), + exam('Sport', 'IN23a-Fachkompetenz (01.09.2026)', '2026-09-01', 5.0, 5.1), + exam('Modul 106 - Datenbanken abfragen IN2023-1A', 'Modul 106 - Datenbanken abfragen IN2023-1A', null, 0, 0), + exam('Modul 294 - Frontend realisieren IN2023-1A', 'Modul 294 - Frontend realisieren IN2023-1A', null, 6, 6), + exam('Modul 335 - Mobile-Applikation IN2023-1A', 'Modul 335 - Mobile-Applikation IN2023-1A', null, 5, 5), + exam('183 Applikationssicherheit implementieren', 'M183 (02.10.2026)', '2026-10-02', 5.5, 5.15), +]; +const report = (programCode: string, className: string, schoolYearStart: number, semesterHalf: number, subjects: Record, gradeAverage: number | null, promotionDecision: string | null) => ({ + id: `${className}-${schoolYearStart}-${semesterHalf}`, schoolUserId: 'u1', programCode, schoolYearStart, semesterHalf, className, promotionDecision, gradeAverage, unexcusedAbsences: 0, excusedAbsences: 1, totalAbsences: 1, + subjects: Object.entries(subjects).map(([subjectName, v], i) => ({ id: `s${i}`, semesterReportId: 'r', subjectCode: subjectName, subjectName, ...(typeof v === 'number' ? { grade: v } : { marker: v }) })), +}); +const reports = [ + report('BM-TALS', 'BM23x', 2023, 1, { Deutsch: 4.5, Englisch: 5.5, Französisch: 4.5, 'Mathematik Grundlagen': 4, 'Naturwissenschaften (Chemie)': 5.5 }, 4.8, 'def.'), + report('BM-TALS', 'BM23x', 2023, 2, { Deutsch: 4.5, Englisch: 5, Französisch: 5.5, 'Mathematik Grundlagen': 4, 'Naturwissenschaften (Chemie)': 5.5 }, 4.9, 'def.'), + report('BM-TALS', 'BM23x', 2024, 1, { Deutsch: 5, Englisch: 5.5, Französisch: 4, 'Mathematik Grundlagen': 4.5, IDAF: 5.5 }, 4.75, 'def.'), + report('BM-TALS', 'BM23x', 2024, 2, { Deutsch: 4, Englisch: 5.5, Französisch: 4, 'Mathematik Grundlagen': 3.5 }, 4.25, 'def.'), + report('BM-TALS', 'BM23d', 2025, 1, { Deutsch: 5, 'Mathematik Schwerpunkt': 3, 'Naturwissenschaften (Physik)': 4, 'Wirtschaft und Recht': 5, 'Geschichte und Politik': 5 }, 4.4, 'def.'), + report('BM-TALS', 'BM23d', 2025, 2, { 'Geschichte und Politik': 5.5, 'Mathematik Schwerpunkt': 4, 'Naturwissenschaften (Physik)': 3.5, 'Wirtschaft und Recht': 5.5 }, 4.63, 'def.'), + report('IN', 'IN23a', 2024, 2, { Sport: 4.5, '294 Frontend realisieren': 6, '295 Backend realisieren': 6 }, null, 'kein Promotionsentscheid'), + report('IN', 'IN23a', 2025, 1, { Allgemeinbildung: 'disp', '426 Software entwickeln': 5.5, Sport: 'bes' }, 5.5, 'kein Promotionsentscheid'), + report('IN', 'IN23a', 2025, 2, { Sport: 5, '183 Applikationssicherheit implementieren': 5.5, '323 Funktional programmieren': 5.5 }, 5.38, null), +]; +// Overview: today's lessons, a test in four days and the next events, all relative to now(). +let scene = 'morning'; // morning, notimetable, calm: GET /__scene/ +const SLOTS: [string, number, number, string][] = [ + ['Deutsch', 0, 45, 'B 2.14'], ['Deutsch', 50, 95, 'B 2.14'], ['Mathematik Schwerpunkt', 110, 155, 'A 1.03'], ['Mathematik Schwerpunkt', 160, 205, 'A 1.03'], + ['Wirtschaft und Recht', 300, 345, 'C 3.01'], ['Geschichte und Politik', 350, 395, 'C 3.01'], +]; +const at = (base: number, min: number) => new Date(base + min * 60_000).toISOString(); +const dayAt = (offset: number, h: number, m = 0) => { const d = now(); d.setDate(d.getDate() + offset); d.setHours(h, m, 0, 0); return d.toISOString(); }; +const dayIso = (offset: number) => { const d = now(); d.setDate(d.getDate() + offset); return `${d.toISOString().slice(0, 10)}T00:00:00Z`; }; +function agenda() { + // A fixed school day from 07:30 today; the time of day (SHOTS_NOW) decides which lesson is running. + const t = now(); t.setHours(7, 30, 0, 0); const first = t.getTime(); + const lessons = scene === 'notimetable' ? [] : SLOTS.map(([title, from, to, place], i) => ({ id: `l${i}`, entryType: 'Lesson', title, place, date: at(first, from), endDate: at(first, to) })); + const test = scene === 'notimetable' ? [] : [{ id: 't0', entryType: 'Test', classId: classId('Mathematik Schwerpunkt'), title: 'Logarithmen', place: 'A 1.03', date: at(first, 110), endDate: at(first, 155) }]; + const ahead = scene === 'calm' ? [] : [{ id: 't1', entryType: 'Test', classId: classId('Wirtschaft und Recht'), title: 'Test 3 Erbrecht', place: 'A 2.07', date: dayAt(4, 9, 25), endDate: dayAt(4, 10, 10) }]; + return [...lessons, ...test, ...ahead, + { id: 'e1', entryType: 'Event', title: 'Berufliche Zukunft', place: 'Restaurant Martinsberg', date: dayAt(5, 18, 0), endDate: dayAt(5, 20, 0) }, + { id: 'e2', entryType: 'Event', title: 'Kulturwoche (Theater)', place: null, date: dayIso(10), endDate: dayIso(15) }, + { id: 'e3', entryType: 'Event', title: 'Volleynight BBB', place: 'Sporthallen', date: dayAt(21, 19, 0), endDate: dayAt(21, 22, 0) }, + { id: 'e4', entryType: 'Holiday', title: 'Weihnachtsferien 2026', place: null, date: dayIso(45), endDate: dayIso(59) }]; +} +function absences() { + if (scene === 'calm') return [{ id: 'a0', type: 'Absence', reason: 'Krankheit', from: dayIso(-30), until: dayIso(-30), isExcused: true, schoolUserId: 'u1', schoolId: 's1' }]; + return [{ id: 'a1', type: 'Absence', reason: 'Andere Absenz', from: dayIso(-3), until: dayIso(-3), isExcused: false, schoolUserId: 'u1', schoolId: 's1' }]; +} +const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); +Bun.serve({ + port: PORT, + hostname: '127.0.0.1', + fetch(request) { + const path = new URL(request.url).pathname.toLowerCase(); + if (path.startsWith('/__scene/')) { scene = path.slice(9); return json({ scene }); } + if (path === '/auth/refresh') return json({ access_token: 'test-token', expires_in: 900, token_type: 'Bearer' }); + if (path === '/api/auth/me') return json({ id: 'u1', externalId: 'u1', email: 'mia@example.org', displayName: 'Mia Muster', profilePictureUrl: PFP, schoolUsers: [] }); + if (path === '/api/exams') return json(exams); + if (path === '/api/class') return json([...classes].map(([name, id]) => ({ id, name, schoolId: 's1' }))); + if (path === '/api/semesterreports') return json(reports); + if (path === '/api/agendas') return json(agenda()); + if (path === '/api/absences') return json(absences()); + if (path === '/api/schools/my-schools') return json([{ id: 's1', name: 'Beispielschule', email: 'mia@example.org', fullName: 'Mia Muster', website }]); + if (path === '/api/linking/accounts') return json([{ providerId: 'schulware', providerName: 'Schulnetz', id: 'a1', title: 'Beispielschule', status: 'ok', capabilities: [{ kind: 'school_data', label: 'School data' }] }]); + if (path === '/api/app') return json({ version: 'dev', registrationEnabled: true }); + return json([]); + }, +}); +console.log(`fake overview api on ${PORT}`); diff --git a/tools/screenshots/pfp.svg b/tools/screenshots/pfp.svg new file mode 100644 index 0000000..8edc08c --- /dev/null +++ b/tools/screenshots/pfp.svg @@ -0,0 +1 @@ + diff --git a/tools/screenshots/proxy.json b/tools/screenshots/proxy.json new file mode 100644 index 0000000..280964a --- /dev/null +++ b/tools/screenshots/proxy.json @@ -0,0 +1,4 @@ +{ + "/api": { "target": "http://127.0.0.1:5048", "secure": false }, + "/auth": { "target": "http://127.0.0.1:5048", "secure": false } +}