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:
parent
6c1253f54c
commit
04b3712ef5
15 changed files with 746 additions and 15 deletions
125
index.html
125
index.html
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue