Panelet hang under klokken med `right: 0` og en fast bredde på 340 px. Klokken sidder ikke yderst — der er en temaknap, et mellemrum og en kantafstand til højre for den — så der er kun omkring 330 px tilbage, og resten blev skubbet ud over venstre kant og klippet af. Målt til -10 px ved en skærmbredde på 390; på en smallere telefon mere. Overskriften "Beskeder" klæbede til kanten uden luft, hvilket er det synlige tegn. På en telefon hører panelet nu til skærmen i stedet for til klokken: det spænder fra kant til kant med en margen i hver side, under toplinjen. Samme knæk ved 820 px som toplinjens egen menuknap, så de to skifter form samtidig. Det får også `overscroll-behavior: contain`, så det at rulle i listen ikke trækker siden bagved med. Toplinjens underkant måles frem for at blive regnet ud. Bredden KUNNE trækkes fra i CSS, men så ville den her fil kende sine naboers størrelse, og den næste knap i toplinjen ville lave fejlen om igen uden at sige noget. --- og fire ustabile prøver mere --- Fladen tegner hver ændring med det samme og sender kaldet bagefter. Alt, man kan se, er altså sandt, før serveren har hørt om det, og en prøve, der genindlæser eller klikker videre i det hul, måler den gamle tilstand. `en monospace-linje kan forlades` ventede 1200 ms før en genindlæsning. Datoprøven ventede 400 ms. Et gæt om, hvor hurtig maskinen er, holder indtil CI er travl — begge fejlede i en fuld kørsel her. `en gruppe er en side med sine projekter` er den mest lærerige: trækket virkede, og `toPass` blev grøn på den lokale tilstand, hvorefter klikket videre hentede en gruppe uden projekter i. Prøvens egen kommentar gætter på et upålideligt HTML5-træk; det var gemningen, der ikke var nået frem. Monospace-prøven havde OVENI en anden fejl, som først viste sig, da den første var lukket: den klikkede "Ny note" og skrev med det samme. `create()` venter på serverens svar, og imens står den forrige notes editor stadig på skærmen, så teksten gik ind i den forrige note og den nye blev tom. Ny hjælper `nyNote()` venter på et tomt felt. Omkring 14 andre steder i filen har samme mønster; de er ikke skrevet om, fordi de består, og en samlet omskrivning ville bytte kendte prøver for ukendte. Det hul findes også for et menneske: klikker man Ny note og skriver straks, lander de første anslag i den forrige note. Det er millisekunder lokalt og sekunder på en dårlig forbindelse. Samme familie som #86, men tiden FØR svaret frem for efter. Noteret som et selvstændigt punkt, ikke rettet her. `noteGemt()` fra #88 er nu `gemt(side, sti)` med noteGemt, opgaveGemt og projektGemt ovenpå. Der står 23 faste ventetider tilbage, men ingen af dem lige før en genindlæsning eller en navigation. Fuld chromium-suite kørt to gange: 90 bestået, 0 fejlet, begge gange. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
330 lines
8.9 KiB
Svelte
330 lines
8.9 KiB
Svelte
<script>
|
|
/**
|
|
* Klokken.
|
|
*
|
|
* De ting, andre har gjort, som man skal vide: en note, der er delt med en, er
|
|
* blevet rettet, en opgave er blevet ens, nogen har kommenteret. Ikke ens egne
|
|
* handlinger — serveren lader være med at fortælle nogen, hvad de lige selv
|
|
* gjorde — og ikke alt, der sker; en klokke, der ringer ved hver ændring i et
|
|
* delt projekt, er en klokke, folk holder op med at se på.
|
|
*
|
|
* Sætningen skrives her og ikke på serveren. Beskeden bærer *hvad der skete*
|
|
* (`kind`) og *hvem* (`actor_name`); ordene kommer fra ordbogen, så en klokke i
|
|
* en engelsk flade er engelsk. Serverens egen `title` er skrevet på dansk til
|
|
* Web Push, som ikke har nogen ordbog at slå op i, og bruges kun dér.
|
|
*/
|
|
import { app } from '$lib/stores.svelte.js';
|
|
import { t } from '$lib/i18n.svelte.js';
|
|
import { goto } from '$app/navigation';
|
|
|
|
let open = $state(false);
|
|
let wrap = $state(null);
|
|
|
|
/**
|
|
* Hvor panelet begynder på en telefon — målt, ikke regnet ud.
|
|
*
|
|
* På en bred skærm hænger panelet under klokken og hører til den. På en telefon
|
|
* kan det ikke: klokken sidder tættere på højre kant end panelet er bredt, så et
|
|
* panel, der er stillet op efter klokken, skydes ud over venstre side og bliver
|
|
* klippet. Der stod tre en halv linje tekst uden for skærmen.
|
|
*
|
|
* Bredden kunne trækkes fra i CSS — der er en temaknap, et mellemrum og en
|
|
* kantafstand til højre for klokken — men så ville den her fil kende sine
|
|
* naboers størrelse, og næste knap i toplinjen ville lave fejlen om igen uden
|
|
* at sige noget. Toplinjens underkant måles i stedet, og så spænder panelet over
|
|
* skærmen med en kant i hver side.
|
|
*/
|
|
let below = $state(0);
|
|
|
|
function place() {
|
|
if (wrap) below = Math.round(wrap.getBoundingClientRect().bottom);
|
|
}
|
|
|
|
function toggle() {
|
|
open = !open;
|
|
if (open) place();
|
|
}
|
|
|
|
const KINDS = {
|
|
assigned: 'notif.assigned',
|
|
'note.changed': 'notif.noteChanged',
|
|
'note.shared': 'notif.noteShared',
|
|
'project.shared': 'notif.projectShared',
|
|
'note.mention': 'notif.mention',
|
|
'daily.plan': 'notif.dailyPlan',
|
|
'update.available': 'notif.update',
|
|
comment: 'notif.comment'
|
|
};
|
|
|
|
// Ukendte slags falder tilbage på serverens egen sætning frem for at forsvinde:
|
|
// en besked fra en nyere serverbygning end denne flade skal stadig kunne læses.
|
|
const headline = (n) => (KINDS[n.kind] ? t(KINDS[n.kind], { name: n.actor_name ?? '' }) : n.title);
|
|
|
|
function when(iso) {
|
|
const minutes = Math.round((Date.now() - new Date(iso)) / 60000);
|
|
if (minutes < 1) return t('notif.justNow');
|
|
if (minutes < 60) return t('notif.minutes', { n: String(minutes) });
|
|
if (minutes < 60 * 24) return t('notif.hours', { n: String(Math.round(minutes / 60)) });
|
|
return new Date(iso).toLocaleDateString(undefined, { day: 'numeric', month: 'short' });
|
|
}
|
|
|
|
/**
|
|
* Hvor en besked fører hen.
|
|
*
|
|
* En note åbnes på notesiden med sit id i adressen; en opgave åbnes i sin egen
|
|
* rude oven på det, man stod i. Det er samme valg som alle andre steder i
|
|
* fladen: en opgave er noget, man kigger på og lukker igen, en note er et sted,
|
|
* man går hen.
|
|
*/
|
|
function follow(n) {
|
|
open = false;
|
|
if (!n.read) app.markRead(n.id);
|
|
// En ny version hører ingen steder hen i data — den fører hen, hvor knappen
|
|
// til at gøre noget ved den sidder.
|
|
if (n.kind === 'update.available') return goto('/indstillinger/notifikationer');
|
|
// Dagens plan står på I dag, hvor den bliver ved med at stå — beskeden er
|
|
// påmindelsen, kortet er stedet.
|
|
if (n.kind === 'daily.plan') return goto('/');
|
|
if (n.note_id) goto(`/noter?note=${n.note_id}`);
|
|
else if (n.task_id) app.openDetail(n.task_id);
|
|
else if (n.project_id) goto(`/projekt/${n.project_id}`);
|
|
}
|
|
|
|
function outside(event) {
|
|
if (open && wrap && !wrap.contains(event.target)) open = false;
|
|
}
|
|
</script>
|
|
|
|
<svelte:window
|
|
onclick={outside}
|
|
onresize={() => open && place()}
|
|
onkeydown={(e) => {
|
|
if (e.key === 'Escape' && open) open = false;
|
|
}}
|
|
/>
|
|
|
|
<div class="wrap" bind:this={wrap}>
|
|
<button
|
|
class="bell"
|
|
class:on={open}
|
|
onclick={toggle}
|
|
aria-expanded={open}
|
|
aria-haspopup="dialog"
|
|
aria-label={app.unread
|
|
? t('notif.titleWithCount', { n: String(app.unread) })
|
|
: t('notif.title')}
|
|
title={t('notif.title')}
|
|
>
|
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
<path d="M18 8a6 6 0 10-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9" />
|
|
<path d="M10.3 21a2 2 0 003.4 0" />
|
|
</svg>
|
|
{#if app.unread}
|
|
<!-- Et tal, ikke en prik. "Der er sket noget" er en anden besked end "der
|
|
er sket fjorten ting", og den anden er den, der får folk til at kigge. -->
|
|
<span class="count" aria-hidden="true">{app.unread > 9 ? '9+' : app.unread}</span>
|
|
{/if}
|
|
</button>
|
|
|
|
{#if open}
|
|
<div class="panel" role="dialog" aria-label={t('notif.title')} style="--below: {below}px">
|
|
<header>
|
|
<span>{t('notif.title')}</span>
|
|
{#if app.unread}
|
|
<button class="allread" onclick={() => app.markRead()}>{t('notif.markAllRead')}</button>
|
|
{/if}
|
|
</header>
|
|
|
|
{#if app.notifications.length}
|
|
<ul>
|
|
{#each app.notifications as n (n.id)}
|
|
<li>
|
|
<button class="row" class:unread={!n.read} onclick={() => follow(n)}>
|
|
<span class="what">{headline(n)}</span>
|
|
<!-- Dagens plan er tre linjer og ikke en overskrift. Klippet af
|
|
efter én linje sagde den "2 forfalder i dag, 1 er sprun…",
|
|
hvilket er den halve oplysning og hele støjen. -->
|
|
{#if n.body}<span class="about" class:full={n.kind === 'daily.plan'}>{n.body}</span>{/if}
|
|
<span class="ago">{when(n.created_at)}</span>
|
|
</button>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{:else}
|
|
<p class="none">{t('notif.empty')}</p>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.wrap {
|
|
position: relative;
|
|
}
|
|
|
|
.bell {
|
|
position: relative;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 32px;
|
|
height: 32px;
|
|
border: none;
|
|
background: none;
|
|
border-radius: var(--radius);
|
|
color: var(--ink-muted);
|
|
cursor: pointer;
|
|
}
|
|
.bell:hover,
|
|
.bell.on {
|
|
background: var(--surface-raised);
|
|
color: var(--ink);
|
|
}
|
|
.bell svg {
|
|
width: 18px;
|
|
height: 18px;
|
|
fill: none;
|
|
stroke: currentColor;
|
|
stroke-width: 1.7;
|
|
stroke-linecap: round;
|
|
stroke-linejoin: round;
|
|
}
|
|
|
|
.count {
|
|
position: absolute;
|
|
top: 1px;
|
|
right: 0;
|
|
min-width: 15px;
|
|
padding: 0 3px;
|
|
border-radius: var(--radius-full);
|
|
background: var(--accent);
|
|
color: var(--on-accent, #fff);
|
|
font-size: 10px;
|
|
line-height: 15px;
|
|
font-weight: 600;
|
|
text-align: center;
|
|
}
|
|
|
|
.panel {
|
|
position: absolute;
|
|
top: calc(100% + 6px);
|
|
right: 0;
|
|
z-index: 40;
|
|
width: min(340px, calc(100vw - var(--s3) * 2));
|
|
max-height: 60vh;
|
|
overflow-y: auto;
|
|
background: var(--surface);
|
|
border: 1px solid var(--line);
|
|
border-radius: var(--radius);
|
|
box-shadow: var(--shadow-lg, 0 8px 30px rgb(0 0 0 / 0.25));
|
|
}
|
|
|
|
/* På en telefon hører panelet til skærmen, ikke til klokken. Se `below` i
|
|
skriptet for hvorfor det måles frem for at blive regnet ud. Samme knæk som
|
|
toplinjens egen menuknap, så de to skifter form samtidig. */
|
|
@media (max-width: 820px) {
|
|
.panel {
|
|
position: fixed;
|
|
top: calc(var(--below) + 6px);
|
|
left: var(--s3);
|
|
right: var(--s3);
|
|
width: auto;
|
|
max-height: calc(100dvh - var(--below) - var(--s3) * 2);
|
|
overscroll-behavior: contain;
|
|
}
|
|
}
|
|
|
|
.panel header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: var(--s2);
|
|
padding: var(--s2);
|
|
border-bottom: 1px solid var(--line);
|
|
font-size: var(--text-xs);
|
|
color: var(--ink-muted);
|
|
}
|
|
|
|
.allread {
|
|
border: none;
|
|
background: none;
|
|
padding: 0;
|
|
font: inherit;
|
|
color: var(--accent);
|
|
cursor: pointer;
|
|
}
|
|
.allread:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.panel ul {
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
.row {
|
|
display: grid;
|
|
gap: 2px;
|
|
width: 100%;
|
|
padding: var(--s2);
|
|
border: none;
|
|
border-bottom: 1px solid var(--line);
|
|
background: none;
|
|
text-align: left;
|
|
cursor: pointer;
|
|
color: var(--ink-muted);
|
|
}
|
|
.row:hover {
|
|
background: var(--surface-raised);
|
|
}
|
|
|
|
/* Ulæst er en vægt og en prik, ikke en farvet baggrund. Baggrunden var det
|
|
første forsøg, og den gjorde listen til et bånd af firkanter, hvor man ikke
|
|
kunne se hvad der var én besked. */
|
|
.row.unread .what {
|
|
font-weight: 600;
|
|
color: var(--ink);
|
|
}
|
|
.row.unread .what::before {
|
|
content: '';
|
|
display: inline-block;
|
|
width: 6px;
|
|
height: 6px;
|
|
margin-right: 6px;
|
|
vertical-align: middle;
|
|
border-radius: var(--radius-full);
|
|
background: var(--accent);
|
|
}
|
|
|
|
.what {
|
|
font-size: var(--text-sm);
|
|
}
|
|
|
|
.about {
|
|
font-size: var(--text-xs);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.about.full {
|
|
white-space: pre-line;
|
|
text-overflow: clip;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.ago {
|
|
font-size: var(--text-xs);
|
|
color: var(--ink-faint);
|
|
}
|
|
|
|
.none {
|
|
margin: 0;
|
|
padding: var(--s3) var(--s2);
|
|
text-align: center;
|
|
font-size: var(--text-xs);
|
|
color: var(--ink-faint);
|
|
}
|
|
</style>
|