To halvdele af det samme: at en opgave med et klokkeslæt også kan få en
alarm, uden at man går en omvej hver gang.
Påmindelser har virket længe og skulle sættes én ad gangen. En opgave
dikteret som "i morgen kl 9" fik en tid og INGEN alarm — `CreateReminder`
blev kun kaldt fra sit eget endepunkt, så intet skabte en påmindelse af sig
selv. Derefter skulle man åbne opgaven, finde Påmindelser og vælge et
klokkeslæt, og den omvej er længere end at skrive opgaven.
SÆTNINGEN
Hurtig tilføjelse forstår nu alarmen:
ring til tandlægen i morgen kl 9 #hjem mind mig en time før
Formerne er `mind mig` / `påmind` / `remind me` / `med alarm`, efterfulgt af
en varighed (`en time før`, `10 min før`, `et kvarter før`, `en halv time
før`, `dagen før`, `to dage før`) eller et klokkeslæt (`mind mig kl 8`).
Bar "mind mig" betyder på selve tidspunktet. Dansk og engelsk i samme
linje, som resten af parseren.
Det gælder uanset hvor stemmen kommer ind: tastaturets diktering, en
genvej, eller noget tredje ender som tekst gennem den samme parser. Derfor
er syntaksen forudsætningen for dem alle frem for én af dem.
Trinnet ligger FØR klokke og dato, og den rækkefølge er hele tricket: "mind
mig en time før" har en varighed i sig og "mind mig kl 8" har en klokke, og
begge læst af tidsparseren først bliver opgavens egen forfaldstid i stedet
for dens alarm.
STANDARDEN
En indstilling under Notifikationer giver opgaver med både dato og
klokkeslæt en alarm uden at blive bedt om det, med et valgfrit varsel.
Den rammer alle tre veje, en opgave kan få et klokkeslæt — oprettet,
redigeret og hurtig-tilføjet. Redigeringsvejen er den halvdel, løftet om
"alle opgaver med en dato og tid" ellers taber: en opgave skrevet nu og
givet en tid i eftermiddag er den samme opgave. CalDAV er bevidst udenfor:
en opgave fra Apple Påmindelser bærer sin egen alarm, og to alarmer for én
opgave er værre end ingen.
Slået FRA for alle, der findes i dag. At tænde den i en migrering ville
give hver eksisterende bruger alarmer, de aldrig har bedt om, på opgaver de
har liggende — og det her er software, andre selv driver.
Og standarden skabte et behov for en udvej, så den fik en: "ingen alarm" i
linjen springer en enkelt opgave over, mens man skriver den, frem for at
oprette den, åbne den og slette alarmen der lige blev lavet. En udtrykkelig
alarm slår også standarden — den lægger sig ikke oven på.
TRE TING LÆSNINGEN FANGEDE
* Fortegnet. Lageret LÆGGER forskydningen til forfaldstiden
(`due_datetime + offset_min * 60`), så "en time før" er -60. Gættet
havde givet en alarm en time for sent, og det er den ene retning en
påmindelse ikke må tage fejl i. Samme konvention hele vejen —
indstillingen, parseren og kolonnen — for to fortegnsregler for det
samme begreb viser sig kun som en alarm på et forkert tidspunkt.
* Den stille døde alarm. `DueReminders` vælger forskydningsformen kun
`AND t.due_datetime IS NOT NULL`. Så "mind mig en time før" på en opgave
med en dag men ingen klokketid ville have lagt en række, der aldrig
fyrer. Nu bliver opgaven oprettet, alarmen ikke, og det bliver sagt.
Det er den ene slags fejl en påmindelse ikke må have, fordi intet
bagefter afslører den: der står ingen række at savne.
* "-60 min." på skærmen. TaskDetail viste forskydninger råt. Før nu kunne
de kun laves gennem API'et, så grenen stod reelt urørt — dette er det
første, der bruger den i praksis. Den skriver nu "1 time før".
PRØVET
Prøverne spørger `DueReminders` frem for om rækken findes: en række med
forkert fortegn findes lige så overbevisende og ringer en time for sent. Et
minut før den er forfalden må alarmen ikke være i listen, et minut efter
skal den.
Hver plantet fejl er kontrolleret til at falde i præcis den prøve, der
findes for den — fortegnet vendt, ordgrænsen fjernet, trinnet flyttet efter
dato, umulig klokke accepteret, dobbelt-vagten slået fra, "ingen alarm"
ignoreret, positivt tal accepteret, råt fortegn vist.
Og prøvens eget navn er en rettelse: de første fejlede i hele suiten men
bestod isoleret. Suiten kører med én arbejder mod ÉN database, og "ring til
tandlægen" findes fire steder i filen, så `.first()` ramte en anden prøves
opgave uden alarm.
Go-suiten grøn. E2E: 131 bestod, 0 netafbrydelser.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
507 lines
13 KiB
Svelte
507 lines
13 KiB
Svelte
<script>
|
|
/** Notifications: what has happened, and whether the browser should tell you. */
|
|
import { api, humanMessage } from '$lib/api.js';
|
|
import { app } from '$lib/stores.svelte.js';
|
|
import * as push from '$lib/push.js';
|
|
import { t, plural } from '$lib/i18n.svelte.js';
|
|
|
|
// --- alarm som standard -------------------------------------------------------
|
|
|
|
// Minutter i forhold til forfaldstiden, negativt for fu00f8r. Samme fortegn som
|
|
// reminders.offset_min, fordi det er den kolonne, vu00e6rdien ender i — to
|
|
// fortegnsregler for det samme begreb viser sig kun som en alarm pu00e5 et forkert
|
|
// tidspunkt.
|
|
const ALARM_CHOICES = [0, -5, -10, -15, -30, -60, -120, -1440];
|
|
|
|
// Valgt, nu00e5r indstillingen slu00e5s til igen. Pu00e5 tidspunktet er sju00e6ldent det, man
|
|
// mener med en alarm: der er ingen varsel i at blive mindet om noget, mens det
|
|
// sker.
|
|
const ALARM_DEFAULT = -10;
|
|
|
|
function alarmLabel(minutes) {
|
|
if (minutes === 0) return t('alarm.atDue');
|
|
const n = Math.abs(minutes);
|
|
if (n === 1440) return t('alarm.dayBefore');
|
|
if (n === 60) return t('alarm.hourBefore');
|
|
if (n % 60 === 0) return t('alarm.hoursBefore', { n: n / 60 });
|
|
return t('alarm.minBefore', { n });
|
|
}
|
|
|
|
async function saveAlarm(minutes) {
|
|
try {
|
|
await api.setDefaultReminder(minutes);
|
|
// Kontoen genlu00e6ses frem for at blive gu00e6ttet lokalt: serveren er den, der
|
|
// afviser et positivt tal, su00e5 det, der vises, skal vu00e6re det, der blev gemt.
|
|
app.user = await api.me();
|
|
} catch (e) {
|
|
app.toast(humanMessage(e));
|
|
}
|
|
}
|
|
|
|
// --- web push ----------------------------------------------------------------
|
|
|
|
let pushState = $state('loading');
|
|
let busy = $state(false);
|
|
|
|
$effect(() => {
|
|
if (!push.supported()) {
|
|
pushState = 'unsupported';
|
|
return;
|
|
}
|
|
if (push.permission() === 'denied') {
|
|
pushState = 'blocked';
|
|
return;
|
|
}
|
|
push.current().then((sub) => (pushState = sub ? 'on' : 'off'));
|
|
});
|
|
|
|
async function enablePush() {
|
|
busy = true;
|
|
try {
|
|
await push.subscribe();
|
|
pushState = 'on';
|
|
} catch (e) {
|
|
app.toast(e.message ?? humanMessage(e));
|
|
if (push.permission() === 'denied') pushState = 'blocked';
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
}
|
|
|
|
let testing = $state(false);
|
|
let testResult = $state(null);
|
|
|
|
/**
|
|
* Én notifikation, hele vejen gennem den rigtige rute, og et svar på hvad der
|
|
* skete med den.
|
|
*
|
|
* Den siger ikke "sendt" og lader det være. Kommer den frem, ved man det, fordi
|
|
* den ligger på skærmen; kommer den ikke, står der her, hvem der afviste den og
|
|
* med hvilke ord — og hvis ingen afviste den, står der, at det så er browseren
|
|
* eller styresystemet, der holder den tilbage. Det er de tre steder, den kan
|
|
* blive væk, og nu peger svaret på ét af dem.
|
|
*/
|
|
async function testPush() {
|
|
testing = true;
|
|
testResult = null;
|
|
try {
|
|
const r = await api.testPush(t('push.testTitle'), t('push.testBody'));
|
|
if (!r.subscriptions) {
|
|
testResult = { bad: true, text: t('push.testNone') };
|
|
} else if (r.failed?.length) {
|
|
testResult = {
|
|
bad: true,
|
|
text: r.failed
|
|
.map((f) => t('push.testFailed', { service: f.service, reason: f.reason }))
|
|
.join(' ')
|
|
};
|
|
} else {
|
|
testResult = { bad: false, text: t('push.testSent', { n: r.sent }) };
|
|
}
|
|
} catch (e) {
|
|
testResult = { bad: true, text: humanMessage(e) };
|
|
} finally {
|
|
testing = false;
|
|
}
|
|
}
|
|
|
|
async function disablePush() {
|
|
busy = true;
|
|
try {
|
|
await push.unsubscribe();
|
|
pushState = 'off';
|
|
} catch (e) {
|
|
app.toast(e.message ?? humanMessage(e));
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
}
|
|
|
|
// --- the list -----------------------------------------------------------------
|
|
|
|
// Dagens plan: én besked om morgenen om det, der venter. Slået fra som
|
|
// udgangspunkt — en besked klokken syv, man ikke har bedt om, er ikke en hjælp.
|
|
let plan = $state(null);
|
|
let planBusy = $state(false);
|
|
|
|
$effect(() => {
|
|
// Dagens plan er dagens opgaver.
|
|
if (app.notesOnly) return;
|
|
api
|
|
.planSettings()
|
|
.then((p) => (plan = p))
|
|
.catch(() => {});
|
|
});
|
|
|
|
async function savePlan(change) {
|
|
try {
|
|
plan = await api.setPlanSettings(change);
|
|
} catch (e) {
|
|
app.toast(humanMessage(e));
|
|
}
|
|
}
|
|
|
|
async function sendPlanNow() {
|
|
if (planBusy) return;
|
|
planBusy = true;
|
|
try {
|
|
const r = await api.planNow();
|
|
app.toast(r.sent ? t('ai.planSent') : t('ai.planNothing'));
|
|
} catch (e) {
|
|
app.toast(humanMessage(e));
|
|
} finally {
|
|
planBusy = false;
|
|
}
|
|
}
|
|
|
|
let notifications = $state([]);
|
|
let unread = $state(0);
|
|
let loading = $state(true);
|
|
|
|
$effect(() => {
|
|
api
|
|
.listNotifications()
|
|
.then((r) => {
|
|
notifications = r.notifications;
|
|
unread = r.unread;
|
|
})
|
|
.catch((e) => app.toast(humanMessage(e)))
|
|
.finally(() => (loading = false));
|
|
});
|
|
|
|
async function markAllRead() {
|
|
const previous = notifications;
|
|
notifications = notifications.map((n) => ({ ...n, read: true }));
|
|
unread = 0;
|
|
try {
|
|
await api.markNotificationsRead();
|
|
} catch (e) {
|
|
notifications = previous;
|
|
app.toast(humanMessage(e));
|
|
}
|
|
}
|
|
|
|
function when(iso) {
|
|
const then = new Date(iso);
|
|
const minutes = Math.round((Date.now() - then) / 60000);
|
|
if (minutes < 1) return 'lige nu';
|
|
if (minutes < 60) return `${minutes} min. siden`;
|
|
if (minutes < 60 * 24) return `${Math.round(minutes / 60)} t. siden`;
|
|
return then.toLocaleDateString('da-DK', { day: 'numeric', month: 'short' });
|
|
}
|
|
|
|
// --- version ------------------------------------------------------------------
|
|
|
|
let version = $state(null);
|
|
|
|
// --- restarting from here ---------------------------------------------------
|
|
|
|
let panel = $state(null);
|
|
let restarting = $state(false);
|
|
let restartNote = $state('');
|
|
|
|
$effect(() => {
|
|
api
|
|
.panelStatus()
|
|
.then((p) => (panel = p))
|
|
.catch(() => {});
|
|
});
|
|
|
|
/**
|
|
* Asks the panel to recreate this container, then waits for it to come back.
|
|
*
|
|
* The reply may never arrive — the panel stops this container as part of
|
|
* answering — so the answer is not believed either way. `/healthz` is polled
|
|
* until it responds, which is the only thing that actually says "it is up".
|
|
*/
|
|
async function restart() {
|
|
if (!confirm(t('update.restartQuestion'))) return;
|
|
restarting = true;
|
|
restartNote = t('update.asking');
|
|
try {
|
|
await api.restartFromPanel();
|
|
} catch (e) {
|
|
// A request cut off mid-flight is the successful case wearing a
|
|
// failure's clothes. The poll below decides.
|
|
restartNote = humanMessage(e);
|
|
}
|
|
|
|
restartNote = t('update.waiting');
|
|
for (let i = 0; i < 60; i++) {
|
|
await new Promise((r) => setTimeout(r, 2000));
|
|
try {
|
|
const response = await fetch('/healthz', { cache: 'no-store' });
|
|
if (response.ok) {
|
|
location.reload();
|
|
return;
|
|
}
|
|
} catch {
|
|
// Still down, which is expected for the first few seconds.
|
|
}
|
|
}
|
|
restarting = false;
|
|
restartNote = t('update.stillDown');
|
|
}
|
|
|
|
$effect(() => {
|
|
api.version().then((v) => (version = v)).catch(() => {});
|
|
});
|
|
</script>
|
|
|
|
<section class="panel" id="dagens-plan">
|
|
<header>
|
|
<h2>{t('ai.plan')}</h2>
|
|
<p class="hint">{t('ai.planHint')}</p>
|
|
</header>
|
|
|
|
{#if plan}
|
|
<label class="check">
|
|
<input
|
|
type="checkbox"
|
|
checked={plan.enabled}
|
|
onchange={(e) => savePlan({ enabled: e.currentTarget.checked })}
|
|
/>
|
|
<span>{t('ai.plan')}</span>
|
|
</label>
|
|
|
|
{#if plan.enabled}
|
|
<div class="field">
|
|
<label for="planhour">{t('ai.planHour')}</label>
|
|
<select
|
|
id="planhour"
|
|
value={plan.hour}
|
|
onchange={(e) => savePlan({ hour: Number(e.currentTarget.value) })}
|
|
>
|
|
{#each Array.from({ length: 24 }, (_, i) => i) as hour (hour)}
|
|
<option value={hour}>{String(hour).padStart(2, '0')}:00</option>
|
|
{/each}
|
|
</select>
|
|
</div>
|
|
|
|
<!-- En indstilling, man skal vente til i morgen for at se virkningen af,
|
|
er en indstilling, ingen tør slå til. -->
|
|
<button class="secondary" disabled={planBusy} onclick={sendPlanNow}>
|
|
{t('ai.planNow')}
|
|
</button>
|
|
{/if}
|
|
{/if}
|
|
</section>
|
|
|
|
<section class="panel" id="standard-alarm">
|
|
<header>
|
|
<h2>{t('alarm.title')}</h2>
|
|
<p class="hint">{t('alarm.hint')}</p>
|
|
</header>
|
|
|
|
<label class="check">
|
|
<input
|
|
type="checkbox"
|
|
checked={app.user?.default_reminder_min != null}
|
|
onchange={(e) => saveAlarm(e.currentTarget.checked ? ALARM_DEFAULT : null)}
|
|
/>
|
|
<span>{t('alarm.on')}</span>
|
|
</label>
|
|
|
|
{#if app.user?.default_reminder_min != null}
|
|
<div class="field">
|
|
<label for="alarmwhen">{t('alarm.when')}</label>
|
|
<select
|
|
id="alarmwhen"
|
|
value={app.user.default_reminder_min}
|
|
onchange={(e) => saveAlarm(Number(e.currentTarget.value))}
|
|
>
|
|
{#each ALARM_CHOICES as minutes (minutes)}
|
|
<option value={minutes}>{alarmLabel(minutes)}</option>
|
|
{/each}
|
|
</select>
|
|
</div>
|
|
{/if}
|
|
</section>
|
|
|
|
<section class="panel" id="push">
|
|
<header>
|
|
<h2>{t('push.title')}</h2>
|
|
<p class="hint">
|
|
{t('push.hint')}
|
|
</p>
|
|
</header>
|
|
|
|
{#if pushState === 'loading'}
|
|
<p class="empty">…</p>
|
|
{:else if pushState === 'unsupported'}
|
|
<p class="hint">
|
|
{t('push.unsupported')}
|
|
</p>
|
|
{:else if pushState === 'blocked'}
|
|
<p class="hint">
|
|
{t('push.blocked')}
|
|
</p>
|
|
{:else if pushState === 'on'}
|
|
<p class="hint">{t('push.on')}</p>
|
|
<div class="row">
|
|
<button class="secondary" onclick={disablePush} disabled={busy}>{t('push.turnOff')}</button>
|
|
<!-- "Til" er ikke det samme som "virker".
|
|
Ruden sagde til, så snart der var et abonnement, og en push, tjenesten
|
|
afviste, gik i en logline på Debug, som ingen ser. Tre forskellige
|
|
problemer — browseren tilmeldte sig aldrig, tjenesten afviste den,
|
|
der er ikke noget forfaldent endnu — så ens ud fra den her side. -->
|
|
<button class="secondary" onclick={testPush} disabled={busy || testing}>
|
|
{testing ? t('push.testing') : t('push.test')}
|
|
</button>
|
|
</div>
|
|
{#if testResult}
|
|
<p class="hint" class:bad={testResult.bad}>{testResult.text}</p>
|
|
{/if}
|
|
{:else}
|
|
<div class="row">
|
|
<button class="primary" onclick={enablePush} disabled={busy}>
|
|
{t('push.turnOn')}
|
|
</button>
|
|
</div>
|
|
<p class="hint">{t('push.perBrowser')}</p>
|
|
{/if}
|
|
</section>
|
|
|
|
<section class="panel" id="seneste">
|
|
<header>
|
|
<h2>{t('push.recent')}</h2>
|
|
{#if unread > 0}
|
|
<p class="hint">{plural(unread, 'push.unreadOne', 'push.unreadMany')}</p>
|
|
{/if}
|
|
</header>
|
|
|
|
{#if loading}
|
|
<p class="empty">…</p>
|
|
{:else if notifications.length === 0}
|
|
<p class="empty">{t('push.none')}</p>
|
|
{:else}
|
|
<ul class="list">
|
|
{#each notifications as note (note.id)}
|
|
<li class:unread={!note.read}>
|
|
<div class="what">
|
|
<span class="title">{note.title}</span>
|
|
{#if note.body}<span class="body">{note.body}</span>{/if}
|
|
<span class="meta">
|
|
{#if note.actor_name}{note.actor_name} · {/if}{when(note.created_at)}
|
|
</span>
|
|
</div>
|
|
{#if note.task_id && note.project_id}
|
|
<a class="go" href="/projekt/{note.project_id}">{t('push.open')}</a>
|
|
{/if}
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
|
|
{#if unread > 0}
|
|
<div class="row">
|
|
<button class="secondary" onclick={markAllRead}>{t('push.markAllRead')}</button>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</section>
|
|
|
|
{#if version}
|
|
<section class="panel" id="version">
|
|
<header>
|
|
<h2>{t('push.version')}</h2>
|
|
</header>
|
|
|
|
<p class="hint">
|
|
{t('push.running')} <span class="mono">{version.current}</span>.
|
|
{#if version.disabled}
|
|
{t('push.updatesOff')}
|
|
{:else if version.update_available}
|
|
<strong>{t('push.updateOut', { version: version.latest })}</strong>
|
|
{:else if version.checked_at}
|
|
{t('push.upToDate')}
|
|
{/if}
|
|
</p>
|
|
|
|
<!-- Restarting is what makes a new version arrive: a container cannot replace
|
|
its own image, and the panel pulls `:latest` when it recreates one. The
|
|
button is here rather than in another browser tab. -->
|
|
{#if panel?.configured}
|
|
<div class="row">
|
|
<button class="secondary" onclick={restart} disabled={restarting}>
|
|
{restarting ? t('update.restarting') : t('update.restart')}
|
|
</button>
|
|
{#if restartNote}<span class="saved">{restartNote}</span>{/if}
|
|
</div>
|
|
{:else if panel}
|
|
<p class="hint">{t('update.notConfigured', { missing: panel.missing.join(', ') })}</p>
|
|
{/if}
|
|
|
|
{#if version.update_available}
|
|
{#if version.notes}
|
|
<p class="notes">{version.notes}</p>
|
|
{/if}
|
|
{#if version.url}
|
|
<div class="row">
|
|
<a class="release" href={version.url} target="_blank" rel="noreferrer noopener">
|
|
{t('push.seeRelease')}
|
|
</a>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</section>
|
|
{/if}
|
|
|
|
<style>
|
|
.bad {
|
|
color: var(--danger);
|
|
}
|
|
|
|
.what {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.title {
|
|
font-size: var(--text-sm);
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.body {
|
|
font-size: var(--text-sm);
|
|
color: var(--ink-muted);
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.meta {
|
|
font-size: var(--text-xs);
|
|
color: var(--ink-faint);
|
|
}
|
|
|
|
/* An accent bar rather than a bold row: the list is read top to bottom, and
|
|
weight changes make it look ragged. */
|
|
li.unread {
|
|
border-left: 2px solid var(--accent);
|
|
}
|
|
|
|
.go,
|
|
.release {
|
|
flex: none;
|
|
font-size: var(--text-xs);
|
|
color: var(--accent);
|
|
text-decoration: none;
|
|
}
|
|
|
|
.release {
|
|
font-size: var(--text-sm);
|
|
}
|
|
|
|
.notes {
|
|
margin: 0;
|
|
padding: var(--s3);
|
|
background: var(--surface-sunken);
|
|
border: 1px solid var(--line);
|
|
border-radius: var(--radius);
|
|
font-size: var(--text-sm);
|
|
white-space: pre-wrap;
|
|
line-height: 1.6;
|
|
}
|
|
</style>
|