Planlagte sendinger

«Send denne kl. 18:00.» Engangs, ikke gjentakende.

Dette er den første delen av appen som må ligge på disk. Kø og historikk har
alltid bare levd i minnet, og det har gått bra fordi en melding spilles med en
gang. En melding planlagt til 18:00 må overleve at containeren startes om
17:45, så schedule.py lagrer i data/scheduled.json – skrevet til side og
flyttet på plass, så fila aldri er halvskrevet.

Ingen database, med vilje: dette er en håndfull meldinger på et arrangement,
og en fil man kan lese med cat er verdt mer her enn indekser. En ødelagt fil
hindrer ikke oppstart; da er det bedre å miste planene og si det i loggen.

Modulen vet ingenting om Flask eller avspilling. Den lagrer, og den svarer på
«hva skal sendes nå?». _scheduler() i announcer.py eier tråden som spør hvert
20. sekund og legger forfalte meldinger i den vanlige køen – derfra oppfører de
seg nøyaktig som en melding sendt for hånd.

Gikk den glipp av, spilles den ikke. due() skiller på grace_seconds (120):
innenfor den sendes meldingen, utenfor markeres den missed og vises som det.
Nådefristen er slingringsmonn for at tråden våkner med mellomrom og for en
kjapp omstart, ikke en policy om å ta igjen etterslep – «turneringen starter om
ti minutter» skal ikke ut på anlegget en time etter at maskina kom tilbake.
due() endrer ingenting selv; markeringen skjer først etter at meldingen
faktisk er i kø, så ingenting kan stå som «sendt» uten å ha blitt det.

Klienten sender absolutt tidspunkt (epoch), så tidssoner spiller ingen rolle
for selve planleggingen. TZ settes likevel fra .env, ellers står loggen i UTC
mens man selv står i CEST.

Planlegging er låst til samme forhåndslytt-nøkkel som «Send ut»: man planlegger
bare det man har hørt. Det gir en bonus – forhåndslytt varmer cache/, så WAV-en
ligger ferdig på disk, og en planlagt edge-tts-melding spiller kl. 18:00 selv
om nettet er nede da.

Sine egne kan man alltid avbryte. Andres krever clear, samme rett som å tømme
køen, siden det er samme slags inngrep i noe andre har satt i gang.

23 nye tester. Verifisert i container: sending fyrer og spiller, overlever en
omstart av containeren, og en melding som forfalt mens maskina var av blir
markert missed uten å bli spilt.

Merk ved oppgradering: schedule er en ny handling, og et oppsett med eksplisitt
[roles] i config.toml får den ikke automatisk. Den må legges inn selv.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kent 2026-10-08 16:29:16 +02:00
parent 6c1253f54c
commit 04b3712ef5
15 changed files with 746 additions and 15 deletions

View file

@ -64,6 +64,18 @@
#pinbox input { background: var(--panel2); color: var(--fg); border: 1px solid var(--line); border-radius: 8px; padding: 10px; font: inherit; width: 100%; }
.hint { color: var(--muted); font-size: 12px; margin-top: 6px; }
.hidden { display: none !important; }
.when { display: flex; gap: 8px; align-items: center; margin-top: 12px; flex-wrap: wrap; }
.when input[type=datetime-local] { background: var(--panel2); color: var(--fg); border: 1px solid var(--line);
border-radius: 8px; padding: 10px; font: inherit; }
.when button { flex: 0 1 auto; padding: 10px 14px; font-size: 14px; }
.when .q { display: flex; gap: 6px; }
.when .q button { background: var(--panel2); border: 1px solid var(--line); color: var(--fg);
border-radius: 999px; padding: 6px 10px; font: inherit; font-size: 13px; cursor: pointer; }
.when .q button:hover { border-color: var(--accent); }
ul.list li button.x { background: none; border: none; color: var(--muted); cursor: pointer;
font: inherit; font-size: 13px; padding: 0 4px; }
ul.list li button.x:hover { color: var(--danger); }
ul.list li.cancelled .s, ul.list li.missed .s { color: var(--warn); }
</style>
</head>
<body>
@ -112,10 +124,20 @@
<button class="btn" id="btnPreview">▶ Forhåndslytt</button>
<button class="btn primary" id="btnAnnounce" disabled>📢 Send ut på anlegget</button>
</div>
<div class="when" id="whenbox">
<input id="at" type="datetime-local" step="60">
<div class="q" id="atquick"></div>
<button class="btn" id="btnSchedule" disabled>🕑 Planlegg</button>
</div>
<audio id="player" controls></audio>
<div class="msg" id="msg"></div>
</div>
<div class="card hidden" id="schedcard">
<div class="status-head"><h2>Planlagt</h2></div>
<ul class="list" id="sched"></ul>
</div>
<div class="card">
<div class="status-head">
<h2>Anlegget</h2>
@ -138,7 +160,7 @@
const $ = s => document.querySelector(s);
const text = $('#text'), voice = $('#voice'), speed = $('#speed'), chime = $('#chime');
const msg = $('#msg'), audio = $('#player'), btnPreview = $('#btnPreview'), btnAnnounce = $('#btnAnnounce');
let cfg = {}, can = [], previewedKey = null, pollTimer = null;
let cfg = {}, can = [], previewedKey = null, pollTimer = null, me = null, skew = 0;
function key() { return JSON.stringify(payload()); }
function payload() {
@ -161,6 +183,44 @@ function updateAnnounce() {
(btnAnnounce.disabled ? 'Forhåndslytt først' : '');
btnPreview.disabled = !may('preview');
btnPreview.title = may('preview') ? '' : 'Du har ikke lov til å forhåndslytte';
// Planlegging er låst til forhåndslytten på samme måte som «Send ut»:
// ingen skal legge noe i kalenderen de ikke har hørt.
const at = $('#at').value;
$('#whenbox').classList.toggle('hidden', !may('schedule'));
$('#btnSchedule').disabled = !ready || !at || !may('schedule');
$('#btnSchedule').title = !at ? 'Velg tidspunkt' :
(!ready ? 'Forhåndslytt først' : '');
}
// Serverens klokke gjelder. Containeren kan stå i UTC mens nettleseren står
// i CEST, og da ville «om 20 minutter» blitt to timer feil.
function serverNow() { return Date.now() / 1000 + skew; }
function atEpoch() {
const v = $('#at').value;
if (!v) return null;
const t = new Date(v).getTime(); // datetime-local tolkes lokalt
return Number.isFinite(t) ? t / 1000 : null;
}
function omTid(ts) {
const s = ts - serverNow();
if (s < 0) return 'nå';
if (s < 90) return 'om ' + Math.round(s) + ' s';
if (s < 5400) return 'om ' + Math.round(s / 60) + ' min';
if (s < 86400) return 'om ' + (s / 3600).toFixed(1).replace('.0', '') + ' t';
return 'om ' + Math.round(s / 86400) + ' d';
}
function setAtIn(minutes) {
const d = new Date(Date.now() + minutes * 60000);
d.setSeconds(0, 0);
// datetime-local vil ha lokal tid uten sone, ikke ISO i UTC.
const pad = v => String(v).padStart(2, '0');
$('#at').value = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
+ `T${pad(d.getHours())}:${pad(d.getMinutes())}`;
updateAnnounce();
}
async function req(path, opts) {
@ -250,6 +310,7 @@ function applyAuth(auth) {
if (!$('#pin').value) $('#pin').value = localStorage.getItem('pin') || '';
}
me = auth.user;
$('#btnStop').classList.toggle('hidden', !may('stop'));
$('#btnClear').classList.toggle('hidden', !may('clear'));
updateAnnounce();
@ -281,10 +342,42 @@ async function init() {
if (!chime.value) chime.value = c.default_chime;
applyAuth(c.auth || {});
$('#atquick').replaceChildren(...[
['+5 min', 5], ['+15 min', 15], ['+1 t', 60], ['+3 t', 180],
].map(([label, min]) => {
const b = document.createElement('button');
b.textContent = label; b.onclick = () => setAtIn(min);
return b;
}));
poll();
pollTimer = setInterval(poll, 2000);
}
$('#at').addEventListener('input', updateAnnounce);
$('#btnSchedule').addEventListener('click', async () => {
const at = atEpoch();
if (!at) return say('Velg et tidspunkt først.', 'err');
if (at < serverNow()) return say('Tidspunktet er passert.', 'err');
$('#btnSchedule').disabled = true;
try {
const item = await (await api('/api/schedule', { ...payload(), at })).json();
say(`Planlagt ${new Date(item.at * 1000).toLocaleString('no', {
weekday: 'short', hour: '2-digit', minute: '2-digit' })}.`, 'ok');
text.value = ''; text.dispatchEvent(new Event('input')); previewedKey = null;
$('#at').value = '';
audio.pause(); audio.classList.remove('show');
poll();
} catch (e) { onError(e); }
finally { updateAnnounce(); }
});
async function cancelSched(id, what) {
if (!confirm(`Avbryte den planlagte meldingen?\n\n${what}`)) return;
try { await api('/api/schedule/cancel', { id }); say('Avbrutt.', 'ok'); poll(); }
catch (e) { onError(e); }
}
let pinDebounce = null;
$('#pin').addEventListener('input', e => {
localStorage.setItem('pin', e.target.value);
@ -341,10 +434,40 @@ function li(it) {
l.querySelector('.s').textContent = (STATUS[it.status] || it.status) + (it.error ? ' · ' + it.error : '');
return l;
}
const SCHED_STATUS = { sent: 'sendt', missed: 'gikk glipp av', cancelled: 'avbrutt' };
function schedLi(it) {
const l = document.createElement('li');
l.className = it.status;
l.innerHTML = '<span class="t"></span><span class="x"></span><span class="by"></span><span class="s"></span>';
const when = new Date(it.at * 1000);
l.querySelector('.t').textContent = when.toLocaleTimeString('no', { hour: '2-digit', minute: '2-digit' });
l.querySelector('.x').textContent = it.text;
l.querySelector('.by').textContent = it.user || '';
const s = l.querySelector('.s');
if (it.status === 'pending') {
s.textContent = omTid(it.at) + ' ';
// Egne kan man alltid avbryte; andres krever samme rett som å tømme kø.
if (may('schedule') && (!me || !it.user || may('clear') || it.user === me.name)) {
const b = document.createElement('button');
b.className = 'x'; b.textContent = '✕'; b.title = 'Avbryt';
b.onclick = () => cancelSched(it.id, it.text);
s.append(b);
}
} else {
s.textContent = (SCHED_STATUS[it.status] || it.status) + (it.note ? ' · ' + it.note : '');
}
return l;
}
async function poll() {
const now = $('#now');
try {
const s = await (await req('/api/status', { headers: headers(false) })).json();
if (s.now) skew = s.now - Date.now() / 1000;
const sc = s.scheduled || { pending: [], done: [] };
const rows = [...sc.pending, ...sc.done];
$('#schedcard').classList.toggle('hidden', rows.length === 0);
$('#sched').replaceChildren(...rows.map(schedLi));
if (s.current) { now.className = 'now'; now.innerHTML = '<span class="pulse"></span>'; now.append(document.createTextNode(s.current.text)); }
else { now.className = 'now idle'; now.textContent = (cfg.app && cfg.app.idle_text) || ''; }
$('#queue').replaceChildren(...s.queued.map(li));