Compare commits

...
Author SHA1 Message Date
kristianwindandClaude Opus 5 b041b32aa2 feat(beskeder): appens ikon bærer tallet på ulæste
Klokken i hovedet siger, hvad der er nyt — men kun til den, der kigger på
fanen. Det halve af pointen med at få besked er at få den, mens man laver
noget andet, og det eneste sted, appen kan sige noget dér, er på sit eget
ikon: mærket i Dock'en på en Mac, på proceslinjen på en pc.

Sat to steder, fordi der er to tilstande. Appen selv, mens den er åben og
kender tallet, og service-workeren, når den ikke er — og det er netop
dér, mærket er det, der siger, at der er sket noget.

Tallet kommer med i push-beskeden frem for at blive talt op af
service-workeren. Den husker ingenting mellem to opvågninger og kunne
derfor kun lægge én til noget, den ikke ved hvad er: et mærke, der tæller
sig selv op og aldrig kommer ned igen. Serveren ved det i forvejen, og en
tælling, der fejler, udelader feltet frem for at sende et nul — så står
mærket, som det stod, i stedet for at blive tørret af en optælling, der
ikke lykkedes.

Ét tal, aldrig en optælling: mærket sættes til hvad der *er*, så det går
ned af sig selv, når beskederne bliver læst. Og det ryddes ved log ud —
det står i Dock'en, også når ingen er logget ind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 12:12:54 +02:00
Kristian Wind 61634a6bb5 Merge pull request #62 from kristianwind/noter-og-opgaver-giver-besked
feat(beskeder): klokken siger til, når nogen rører noget, der er dit
2026-09-08 11:59:04 +02:00
7 changed files with 111 additions and 1 deletions
+10 -1
View File
@@ -214,7 +214,16 @@ func (s *Server) pushToUser(userID, title, body, projectID string) pushOutcome {
if projectID == "" {
target = "/"
}
payload := push.Payload{Title: title, Body: body, URL: target}
// Mærket på ikonet følger med beskeden. Talt her frem for i service-workeren,
// som ikke har nogen hukommelse mellem to opvågninger og derfor kun kunne lægge
// én til noget, den ikke kender — et mærke, der tæller sig selv op og aldrig
// ned. Fejler tællingen, sendes beskeden alligevel: en besked uden et tal er
// stadig en besked.
unread, err := s.db.UnreadNotificationCount(ctx, userID)
if err != nil {
s.log.Warn("unread count for push", "err", err)
}
payload := push.Payload{Title: title, Body: body, URL: target, Unread: unread}
subject := s.vapidSubject()
for _, sub := range subs {
err := push.Send(ctx, push.Subscription{
+7
View File
@@ -56,6 +56,13 @@ type Payload struct {
Body string `json:"body,omitempty"`
URL string `json:"url,omitempty"`
Tag string `json:"tag,omitempty"`
// Unread er tallet, mærket på appens ikon skal stå på, når beskeden lander.
//
// Sendt med frem for talt i service-workeren. Den har ingen hukommelse mellem
// to opvågninger, så den kan kun lægge én til noget, den ikke ved hvad er — og
// et mærke, der tæller sig selv op, kommer aldrig ned igen af sig selv. Tallet
// er serverens, og serveren ved det i forvejen.
Unread int `json:"unread,omitempty"`
}
var b64 = base64.RawURLEncoding
+23
View File
@@ -4,6 +4,7 @@ import (
"context"
"crypto/ecdh"
"crypto/rand"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
@@ -64,3 +65,25 @@ func testVAPID(t *testing.T) VAPID {
}
return VAPID{Public: pub, Private: priv, Subject: "mailto:kristian@example.dk"}
}
// Tallet til mærket på ikonet skal med i beskeden — og skal blive væk, når der
// ikke er noget at sige. Service-workeren læser netop det: er `unread` ikke et
// tal, står mærket, som det stod, frem for at blive nulstillet af en tælling, der
// ikke lykkedes.
func TestPayloadCarriesTheUnreadCount(t *testing.T) {
with, err := json.Marshal(Payload{Title: "hej", Unread: 3})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(with), `"unread":3`) {
t.Errorf("payload = %s, want the unread count in it", with)
}
without, err := json.Marshal(Payload{Title: "hej"})
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(without), "unread") {
t.Errorf("payload = %s, want no count when there is none to send", without)
}
}
+46
View File
@@ -0,0 +1,46 @@
/**
* Mærket på appens ikon — tallet i Dock'en på en Mac, på proceslinjen på en pc.
*
* Klokken i hovedet siger det samme, men kun til den, der kigger på fanen. Det
* halve af pointen med at få besked er at få den, mens man laver noget andet, og
* det eneste sted, appen kan sige noget dér, er på sit eget ikon.
*
* Sat to steder, fordi der er to tilstande: her, mens appen er åben og selv ved
* hvor mange ulæste der er, og i service-workeren, når den ikke er — se `push`-
* lytteren i service-worker.js, som får tallet med i beskeden.
*
* Ét tal, ikke en optælling. Mærket sættes til hvad der *er*, aldrig til hvad der
* lige er kommet, så det kommer ned igen af sig selv, når beskederne bliver læst.
*/
/**
* Kan browseren mærke sit ikon? Safari og Chrome kan, når appen er lagt i Dock'en
* eller installeret; en almindelig fane har intet ikon at mærke, og kaldet gør
* ingenting. Firefox kan ikke endnu.
*/
export function supported() {
return typeof navigator !== 'undefined' && 'setAppBadge' in navigator;
}
/**
* Sætter mærket til `n`, eller fjerner det ved nul.
*
* Tavs, når det ikke kan lade sig gøre. Et mærke er en venlighed oven på klokken,
* der allerede står i fladen — ikke noget at afbryde nogen med, hvis browseren
* siger nej.
*/
export function setBadge(n) {
if (!supported()) return;
const count = Number(n) || 0;
try {
const done = count > 0 ? navigator.setAppBadge(count) : navigator.clearAppBadge();
done?.catch?.(() => {});
} catch {
// Nogle browsere kaster i stedet for at afvise. Det er det samme svar.
}
}
/** Fjerner mærket helt. Brugt ved log ud: den næste, der lander her, er en anden. */
export function clearBadge() {
setBadge(0);
}
+4
View File
@@ -1,6 +1,7 @@
<script>
import { app, sidebar, projectName } from '$lib/stores.svelte.js';
import { api } from '$lib/api.js';
import { clearBadge } from '$lib/badge.js';
import { TASK, PROJECT, GROUP, NOTE, startDrag, carries, dragged, accept } from '$lib/dnd.js';
import { COLORS, colorVar } from '$lib/colors.js';
import { focusOnMount } from '$lib/focus.js';
@@ -31,6 +32,9 @@
async function signOut() {
await api.logout();
// Mærket på ikonet er den forriges ulæste. Det står i Dock'en, også når
// ingen er logget ind, så det skal væk sammen med resten af dem.
clearBadge();
// Empty the offline data cache before leaving: the service worker keeps the
// last-seen task and note lists so a network drop does not blank the app, and
// the next person to sign in on this browser must not find them.
+8
View File
@@ -10,6 +10,7 @@
import SignIn from '$lib/components/SignIn.svelte';
import TaskDetail from '$lib/components/TaskDetail.svelte';
import { t } from '$lib/i18n.svelte.js';
import { setBadge } from '$lib/badge.js';
let { children } = $props();
@@ -53,6 +54,13 @@
app.load();
});
// Tallet på appens ikon følger klokken. Ét sted, fordi der er ét tal: hentet,
// skubbet over websocket'en eller læst væk — mærket er det samme svar på alle
// tre, og en optælling pr. vej ville være tre, der kan blive uenige.
$effect(() => {
setBadge(app.unread);
});
/**
* Global keys.
*
+13
View File
@@ -145,6 +145,19 @@ self.addEventListener('push', (event) => {
}
const title = payload.title || 'verdande';
// Mærket på appens ikon — i Dock'en på en Mac, på proceslinjen på en pc.
//
// Sat her, fordi det her er det eneste, der kører, når appen ikke er åben: det
// er netop dér, et tal på ikonet er det, der siger, at der er noget nyt. Tallet
// kommer med beskeden frem for at blive talt op her; en service-worker husker
// ingenting mellem to opvågninger, så et mærke, den selv talte, ville kun kunne
// gå opad.
//
// Kan browseren ikke sætte mærker, sker der ingenting. Det er ikke en fejl at
// melde om: beskeden selv kom frem, og den er hovedsagen.
if (typeof payload.unread === 'number' && 'setAppBadge' in self.navigator) {
self.navigator.setAppBadge(payload.unread).catch(() => {});
}
event.waitUntil(
self.registration.showNotification(title, {
body: payload.body || '',