The application is published at horaires.ita-ito.com. One origin serves both the panel and the browser, so the OIDC callback, the image URL the device is handed and the documentation all follow from this single name. Port 3000 is already taken by the facture_ocr stack on the development machine, so the app listens on 3010 there and the reason is written next to the setting rather than left to be rediscovered. The fixtures and the frozen payload snapshot move to the real domain too: a contract snapshot carrying a hostname that never existed is a small puzzle left for whoever reads it next. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
130 lines
4.5 KiB
TypeScript
130 lines
4.5 KiB
TypeScript
/**
|
||
* Renders the panel locally, so the layout can be iterated on without a device.
|
||
*
|
||
* npm run screen:preview
|
||
*
|
||
* Writes `.preview/screen.svg`, `.preview/screen.bmp`, `.preview/screen.png`
|
||
* and an `index.html` that shows the real 1-bit image at 1:1 next to a
|
||
* magnified view, which is where clipping and thin strokes give themselves up.
|
||
*/
|
||
|
||
import { mkdir, writeFile } from 'node:fs/promises';
|
||
import path from 'node:path';
|
||
|
||
import type { ScreenPayload } from '../lib/screen/contract';
|
||
import { encodeScreen } from '../lib/screen/encode';
|
||
import { SCREEN_HEIGHT, SCREEN_WIDTH } from '../lib/screen/layout';
|
||
import { renderScreenSvg } from '../lib/screen/render';
|
||
import { buildScreenPayload } from '../lib/screen/viewmodel';
|
||
import type { ScheduleContext } from '../lib/schedule/types';
|
||
|
||
const ZURICH = 'Europe/Zurich';
|
||
|
||
/** A deliberately busy week: a lunch break, a late opening and a holiday. */
|
||
const CONTEXT: ScheduleContext = {
|
||
timezone: ZURICH,
|
||
weekly: [
|
||
{ dayOfWeek: 0, isClosed: true, slots: [] },
|
||
{ dayOfWeek: 1, isClosed: true, slots: [] },
|
||
{ dayOfWeek: 2, isClosed: false, slots: [{ open: '10:00', close: '18:30' }] },
|
||
{
|
||
dayOfWeek: 3,
|
||
isClosed: false,
|
||
slots: [
|
||
{ open: '10:00', close: '13:00' },
|
||
{ open: '14:00', close: '18:30' },
|
||
],
|
||
},
|
||
{ dayOfWeek: 4, isClosed: false, slots: [{ open: '10:00', close: '18:30' }] },
|
||
{ dayOfWeek: 5, isClosed: false, slots: [{ open: '10:00', close: '18:30' }] },
|
||
{ dayOfWeek: 6, isClosed: false, slots: [{ open: '10:00', close: '18:30' }] },
|
||
],
|
||
exceptions: [
|
||
{
|
||
date: '2026-09-24',
|
||
isClosed: false,
|
||
slots: [{ open: '14:00', close: '18:30' }],
|
||
reason: 'TEMPORARY',
|
||
noteFr: 'Ouverture retardée',
|
||
noteEn: 'Late opening',
|
||
source: 'MANUAL',
|
||
},
|
||
],
|
||
vacations: [],
|
||
holidays: [
|
||
{ date: '2026-09-25', nameFr: 'Jeûne genevois', nameEn: 'Geneva Fast', isAutoClosed: true },
|
||
],
|
||
};
|
||
|
||
function payload(): ScreenPayload {
|
||
return buildScreenPayload({
|
||
now: new Date('2026-09-23T10:30:00Z'),
|
||
ctx: CONTEXT,
|
||
shopName: 'ITA ITO',
|
||
logoUrl: 'https://horaires.ita-ito.com/brand/logo-eink.png',
|
||
messages: [
|
||
{
|
||
textFr: 'Fermeture exceptionnelle jeudi matin',
|
||
textEn: 'Exceptionally closed on Thursday morning',
|
||
startsAt: null,
|
||
endsAt: null,
|
||
priority: 0,
|
||
isActive: true,
|
||
},
|
||
],
|
||
});
|
||
}
|
||
|
||
async function main(): Promise<void> {
|
||
const directory = path.join(process.cwd(), '.preview');
|
||
await mkdir(directory, { recursive: true });
|
||
|
||
const screen = payload();
|
||
const svg = await renderScreenSvg(screen);
|
||
|
||
await writeFile(path.join(directory, 'screen.svg'), svg);
|
||
await writeFile(path.join(directory, 'screen.bmp'), encodeScreen(svg, 'bmp'));
|
||
await writeFile(path.join(directory, 'screen.png'), encodeScreen(svg, 'png'));
|
||
await writeFile(path.join(directory, 'payload.json'), `${JSON.stringify(screen, null, 2)}\n`);
|
||
await writeFile(path.join(directory, 'index.html'), page());
|
||
|
||
console.log(`Preview written to ${path.relative(process.cwd(), directory)}/index.html`);
|
||
console.log('Open it in a browser, or look at screen.png directly.');
|
||
}
|
||
|
||
function page(): string {
|
||
return `<!doctype html>
|
||
<html lang="fr">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<title>Aperçu écran — ITA ITO</title>
|
||
<style>
|
||
body { margin: 0; padding: 32px; background: #f4f4f2; color: #141413;
|
||
font: 15px/1.5 -apple-system, system-ui, sans-serif; }
|
||
h1 { font-size: 18px; font-weight: 600; margin: 0 0 4px; }
|
||
p { margin: 0 0 24px; color: #5e5d59; }
|
||
h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .08em;
|
||
color: #5e5d59; margin: 32px 0 8px; }
|
||
img { display: block; background: #fff; border: 1px solid #d6d3c7;
|
||
image-rendering: pixelated; }
|
||
.one-to-one img { width: ${SCREEN_WIDTH}px; height: ${SCREEN_HEIGHT}px; }
|
||
.magnified img { width: ${SCREEN_WIDTH * 2}px; height: ${SCREEN_HEIGHT * 2}px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1>Aperçu de l'écran — ${SCREEN_WIDTH} × ${SCREEN_HEIGHT}, 1 bit</h1>
|
||
<p>Image réelle envoyée à l'appareil, après seuillage. Aucun gris n'est possible ici.</p>
|
||
<h2>Taille réelle</h2>
|
||
<div class="one-to-one"><img src="screen.png" alt="Aperçu à l'échelle 1:1"></div>
|
||
<h2>Agrandi ×2 — pour repérer les coupures et les traits trop fins</h2>
|
||
<div class="magnified"><img src="screen.png" alt="Aperçu agrandi"></div>
|
||
</body>
|
||
</html>
|
||
`;
|
||
}
|
||
|
||
main().catch((error: unknown) => {
|
||
console.error(error);
|
||
process.exitCode = 1;
|
||
});
|