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
kristianwindandClaude Opus 5 ab77024ec9 feat(beskeder): klokken siger til, når nogen rører noget, der er dit
En delt note blev rettet af en anden, og man opdagede det kun ved at åbne
den. En opgave blev givet til en, og den lå bare der. Det, andre gør ved
det, man deler, skal man kunne se uden at lede efter det.

Klokken i hovedet: ulæste beskeder om noter, der er blevet rettet, delt
med en, eller opgaver, man har fået. Sætningen skrives i browseren, ikke
på serveren, så et engelsk interface får en engelsk klokke.

Beskeder om den samme note bliver slået sammen, mens de er ulæste. En
note gemmes på en tastepause, så en række pr. gem ville lave én person,
der skriver i fem minutter, om til tyve ens linjer. Er beskeden læst, er
den næste rettelse en ny ting at få at vide — ikke en gentagelse.

note_id får sin egen søjle ved siden af project_id og task_id frem for et
generisk (kind, id)-par: de tre er ikke det samme slags nul, og
fremmednøglen — dén, der rydder beskeden op, når noten slettes — kan et
generisk par ikke have.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 11:40:43 +02:00
Kristian Wind 48509e3073 Merge pull request #61 from kristianwind/delte-noter-tager-links-med
Release / Build and publish image (push) Failing after 8s
feat(noter): en delt note tager de noter med, den peger på
2026-09-07 22:59:47 +02:00
18 changed files with 848 additions and 13 deletions
+11 -1
View File
@@ -4560,12 +4560,22 @@ components:
Notification:
type: object
description: >
Something somebody else did that you need to know about. `kind` says what
happened and the client composes the sentence from it, so the bell follows
the reader's language; `title` is the server's own Danish wording, used for
Web Push, which has no dictionary to look words up in.
properties:
id: { type: string }
actor_name: { type: string }
project_id: { type: string }
task_id: { type: string }
kind: { type: string }
note_id:
description: Set on a notification about a note — a shared note somebody else edited.
type: string
kind:
type: string
enum: [assigned, note.changed, comment]
title: { type: string }
body: { type: string }
read: { type: boolean }
+6
View File
@@ -306,6 +306,12 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
if n.Title != wasTitled || !sameLinks(wasLinking, store.LinksIn(n.Body)) {
s.followLinks(r, n)
}
// Kun når teksten har flyttet sig. En note, der bliver stjernemarkeret eller
// lagt i et projekt, er ikke en note, nogen har skrevet i — og en besked om
// det ville lære folk at overse dem alle sammen.
if before != n.Body {
s.notifyNoteChanged(r, n)
}
writeJSON(w, http.StatusOK, n)
}
+76 -5
View File
@@ -19,6 +19,7 @@ type notificationJSON struct {
ActorName string `json:"actor_name,omitempty"`
ProjectID string `json:"project_id,omitempty"`
TaskID string `json:"task_id,omitempty"`
NoteID string `json:"note_id,omitempty"`
Kind string `json:"kind"`
Title string `json:"title"`
Body string `json:"body,omitempty"`
@@ -44,8 +45,8 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
for _, n := range list {
out = append(out, notificationJSON{
ID: n.ID, ActorName: n.ActorName, ProjectID: n.ProjectID, TaskID: n.TaskID,
Kind: n.Kind, Title: n.Title, Body: n.Body, Read: n.ReadAt != nil,
CreatedAt: n.CreatedAt.Format(time.RFC3339),
NoteID: n.NoteID, Kind: n.Kind, Title: n.Title, Body: n.Body,
Read: n.ReadAt != nil, CreatedAt: n.CreatedAt.Format(time.RFC3339),
})
}
writeJSON(w, http.StatusOK, map[string]any{"notifications": out, "unread": unread})
@@ -75,8 +76,9 @@ func (s *Server) notify(r *http.Request, n *store.Notification) {
}
s.hub.PublishToUser(n.UserID, "notification", notificationJSON{
ID: n.ID, ProjectID: n.ProjectID, TaskID: n.TaskID, Kind: n.Kind,
Title: n.Title, Body: n.Body, CreatedAt: n.CreatedAt.Format(time.RFC3339),
ID: n.ID, ActorName: n.ActorName, ProjectID: n.ProjectID, TaskID: n.TaskID,
NoteID: n.NoteID, Kind: n.Kind, Title: n.Title, Body: n.Body,
CreatedAt: n.CreatedAt.Format(time.RFC3339),
})
// Detached from the request: a push round trip to Google or Mozilla has no
@@ -102,6 +104,66 @@ func (s *Server) notifyProject(r *http.Request, projectID, taskID, actorID, kind
}
}
// notifyAssigned tells somebody a task is now theirs.
//
// Kun ved *skiftet*, og kun til den, der får den. En opgave, der bliver rørt ti
// gange bagefter, er stadig den samme besked — og den, der uddelegerer, ved godt
// hvad de lige har gjort.
func (s *Server) notifyAssigned(r *http.Request, t *store.Task) {
if t == nil || t.AssigneeID == "" {
return
}
actor := userFrom(r.Context())
s.notify(r, &store.Notification{
UserID: t.AssigneeID, ActorID: actor.ID, ProjectID: t.ProjectID, TaskID: t.ID,
Kind: "assigned", Title: actor.Name + " gav dig en opgave", Body: t.Content,
})
}
// notifyNoteChanged tells everybody who can see a note that somebody else wrote in
// it — folded into one line per note while it is unread.
//
// Skrevet som ét kald pr. modtager frem for ét pr. note, fordi sammenlægningen er
// pr. person: to mennesker kan have læst den forrige besked på hvert sit tidspunkt,
// og den ene skal have en ny linje, mens den anden får sin gamle rykket op.
func (s *Server) notifyNoteChanged(r *http.Request, n *store.Note) {
if n == nil {
return
}
actor := userFrom(r.Context())
audience, err := s.db.NoteAudience(r.Context(), n.ID)
if err != nil {
s.log.Warn("note audience for notification", "err", err, "note", n.ID)
return
}
title := actor.Name + " rettede en note"
body := n.Title
for _, userID := range audience {
if userID == actor.ID {
continue
}
// Fold first, and only write a row when there was nothing to fold into.
// Den anden vej rundt — skriv, og ryd op bagefter — efterlader et vindue,
// hvor klokken siger to.
folded, err := s.db.BumpUnreadNoteNotification(r.Context(), userID, n.ID, actor.ID, title, body)
if err != nil {
s.log.Warn("fold note notification", "err", err, "note", n.ID)
continue
}
if folded {
// Klokken skal stadig vide det: tallet er uændret, men linjen er rykket
// op og siger nu et andet navn. Uden det her opdager en åben fane først
// den sammenlagte rettelse ved næste genindlæsning.
s.hub.PublishToUser(userID, "notification.folded", map[string]any{"note_id": n.ID})
continue
}
s.notify(r, &store.Notification{
UserID: userID, ActorID: actor.ID, ProjectID: n.ProjectID, NoteID: n.ID,
Kind: "note.changed", Title: title, Body: body,
})
}
}
// PushToUser is pushToUser for callers outside this package — the reminder job,
// which has the schedule but not the keys.
func (s *Server) PushToUser(userID, title, body, projectID string) {
@@ -152,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{
+196
View File
@@ -0,0 +1,196 @@
package httpapi
import (
"net/http"
"testing"
)
// notifications reads somebody's bell.
func notifications(t *testing.T, ts *testServer) ([]any, float64) {
t.Helper()
resp, out := ts.do(t, "GET", "/api/v1/notifications", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("list notifications: status %d", resp.StatusCode)
}
list, _ := out["notifications"].([]any)
unread, _ := out["unread"].(float64)
return list, unread
}
func firstOf(t *testing.T, list []any) map[string]any {
t.Helper()
if len(list) == 0 {
t.Fatal("ingen beskeder")
}
n, _ := list[0].(map[string]any)
return n
}
// At blive tildelt en opgave giver besked — og kun ved selve skiftet.
func TestBeingGivenATaskNotifiesTheAssignee(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
other := owner.newUser(t, "sofie@example.dk", "Sofie")
sofie := userID(t, owner, "sofie@example.dk")
_, project := owner.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Fælles"})
projectID, _ := project["id"].(string)
// Medlemskab kommer gennem en invitation; en konto, der allerede findes, bliver
// medlem med det samme.
if resp, _ := owner.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
map[string]any{"email": "sofie@example.dk", "role": "editor"}); resp.StatusCode >= 300 {
t.Fatalf("invitér: %d", resp.StatusCode)
}
resp, task := owner.do(t, "POST", "/api/v1/tasks",
map[string]any{"project_id": projectID, "content": "ring til revisor"})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("opret opgave: %d", resp.StatusCode)
}
taskID, _ := task["id"].(string)
// Hvad hun havde, før nogen tildelte hende noget. Ikke nødvendigvis nul —
// invitationen kan selv have sagt noget — og prøven handler om forskellen.
before, _ := notifications(t, other)
if resp, _ := owner.do(t, "PATCH", "/api/v1/tasks/"+taskID,
map[string]any{"assignee_id": sofie}); resp.StatusCode != http.StatusOK {
t.Fatalf("tildel: %d", resp.StatusCode)
}
list, _ := notifications(t, other)
if len(list) != len(before)+1 {
t.Fatalf("Sofie fik %d beskeder, want %d", len(list), len(before)+1)
}
n := firstOf(t, list)
if n["kind"] != "assigned" {
t.Errorf("kind = %v, want assigned", n["kind"])
}
if n["task_id"] != taskID {
t.Errorf("beskeden pegede ikke på opgaven: %v", n["task_id"])
}
if n["actor_name"] != "Kristian" {
t.Errorf("actor_name = %v", n["actor_name"])
}
// En rettelse af noget andet, mens den samme tildeling står i forespørgslen,
// er ikke en ny besked. Det er dét, en fladet, der sender hele opgaven ved hver
// ændring, gør — og så bliver klokken ubrugelig på en dag.
if resp, _ := owner.do(t, "PATCH", "/api/v1/tasks/"+taskID,
map[string]any{"assignee_id": sofie, "content": "ring til revisoren"}); resp.StatusCode != http.StatusOK {
t.Fatalf("ret: %d", resp.StatusCode)
}
if now, _ := notifications(t, other); len(now) != len(before)+1 {
t.Errorf("en uændret tildeling gav besked igen: %d beskeder", len(now))
}
// Og den, der tildeler sig selv, får ingenting.
me := userID(t, owner, "kristian@example.dk")
if resp, _ := owner.do(t, "PATCH", "/api/v1/tasks/"+taskID,
map[string]any{"assignee_id": me}); resp.StatusCode != http.StatusOK {
t.Fatalf("tildel til sig selv: %d", resp.StatusCode)
}
if list, _ := notifications(t, owner); len(list) != 0 {
t.Errorf("man fik besked om noget, man selv gjorde: %d beskeder", len(list))
}
}
// En rettelse i en delt note giver besked — og gentagne rettelser bliver til én,
// så længe den ikke er læst.
func TestEditingASharedNoteNotifiesTheOthers(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
other := owner.newUser(t, "sofie@example.dk", "Sofie")
sofie := userID(t, owner, "sofie@example.dk")
noteID := owner.createNote(t, "# Aftale om levering\nfirst")
if resp, _ := owner.do(t, "POST", "/api/v1/notes/"+noteID+"/shares",
map[string]string{"user_id": sofie, "role": "editor"}); resp.StatusCode != http.StatusOK {
t.Fatal("del noten")
}
// Ejeren skriver. Sofie skal høre det.
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"body": "# Aftale om levering\nanden linje"}); resp.StatusCode != http.StatusOK {
t.Fatal("ret noten")
}
list, unread := notifications(t, other)
if unread != 1 || len(list) != 1 {
t.Fatalf("Sofie fik %d beskeder (%v ulæste), want 1", len(list), unread)
}
n := firstOf(t, list)
if n["kind"] != "note.changed" || n["note_id"] != noteID {
t.Errorf("besked = %v", n)
}
// En gemning mere, mens den er ulæst, må ikke blive til en linje mere. En note
// gemmes ved hver tastepause, så uden det her er fem minutters skriveri tyve
// ens linjer i klokken.
for i := 0; i < 3; i++ {
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"body": "# Aftale om levering\nlinje nummer " + string(rune('a'+i))}); resp.StatusCode != http.StatusOK {
t.Fatal("ret noten igen")
}
}
if list, unread := notifications(t, other); len(list) != 1 || unread != 1 {
t.Errorf("tre gemninger blev til %d beskeder, want 1", len(list))
}
// Læst, og så en rettelse mere: dét er en ny ting at få at vide.
if resp, _ := other.do(t, "POST", "/api/v1/notifications/read", nil); resp.StatusCode != http.StatusNoContent {
t.Fatal("markér som læst")
}
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"body": "# Aftale om levering\nefter læsningen"}); resp.StatusCode != http.StatusOK {
t.Fatal("ret noten efter læsningen")
}
if list, unread := notifications(t, other); len(list) != 2 || unread != 1 {
t.Errorf("efter læsningen gav en rettelse %d beskeder (%v ulæste), want 2 og 1", len(list), unread)
}
// Ejeren har ikke fået besked om sine egne rettelser undervejs.
if list, _ := notifications(t, owner); len(list) != 0 {
t.Errorf("ejeren fik %d beskeder om sine egne rettelser", len(list))
}
// Og den anden vej: Sofie skriver, ejeren hører det.
if resp, _ := other.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"body": "# Aftale om levering\nfra Sofie"}); resp.StatusCode != http.StatusOK {
t.Fatal("Sofie retter")
}
list, _ = notifications(t, owner)
if len(list) != 1 {
t.Fatalf("ejeren fik %d beskeder, da noten blev rettet af en anden, want 1", len(list))
}
if firstOf(t, list)["actor_name"] != "Sofie" {
t.Errorf("beskeden nævnte ikke hvem: %v", list[0])
}
// En ændring, der ikke er tekst, er ikke en rettelse at give besked om.
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"pinned": true}); resp.StatusCode != http.StatusOK {
t.Fatal("stjernemarkér")
}
if list, _ := notifications(t, other); len(list) != 2 {
t.Errorf("en stjernemarkering gav besked: %d beskeder, want 2", len(list))
}
}
// En note, ingen deler, er ingen at give besked.
func TestEditingAPrivateNoteNotifiesNobody(t *testing.T) {
owner := newTestServer(t)
owner.bootstrap(t)
other := owner.newUser(t, "sofie@example.dk", "Sofie")
noteID := owner.createNote(t, "# Min egen\nintet")
if resp, _ := owner.do(t, "PATCH", "/api/v1/notes/"+noteID,
map[string]any{"body": "# Min egen\nnoget"}); resp.StatusCode != http.StatusOK {
t.Fatal("ret noten")
}
if list, _ := notifications(t, other); len(list) != 0 {
t.Errorf("Sofie hørte om en note, hun ikke kan se: %d beskeder", len(list))
}
if list, _ := notifications(t, owner); len(list) != 0 {
t.Errorf("ejeren fik besked om sin egen note: %d beskeder", len(list))
}
}
+18
View File
@@ -245,6 +245,10 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
t.Labels = req.Labels
s.activity(r, t.ProjectID, t.ID, "task.created", map[string]any{"content": t.Content})
s.publish(t.ProjectID, "task.created", toTaskJSON(*t))
// Lavet til en anden med det samme. Det er den samme besked som at blive
// tildelt en, der allerede fandtes — hvad der ikke skal ske er, at den ene vej
// ind i "det her er dit nu" giver besked og den anden ikke.
s.notifyAssigned(r, t)
writeJSON(w, http.StatusCreated, toTaskJSON(*t))
}
@@ -272,6 +276,17 @@ func (s *Server) handleUpdateTask(w http.ResponseWriter, r *http.Request) {
}
}
// Læst før ændringen, og kun når der faktisk kommer en tildeling med i den:
// beskeden skal gå på *skiftet*, ikke på at feltet stod i forespørgslen. Den,
// der får en opgave tildelt igen, den allerede har, skal ikke have besked om
// det hver gang nogen retter en overskrift.
wasAssigned := ""
if req.AssigneeID != nil {
if before, err := s.db.GetTask(r.Context(), taskID, user.ID); err == nil {
wasAssigned = before.AssigneeID
}
}
u := store.TaskUpdate{
Description: req.Description, Priority: req.Priority,
SectionID: req.SectionID, ParentID: req.ParentID,
@@ -324,6 +339,9 @@ func (s *Server) handleUpdateTask(w http.ResponseWriter, r *http.Request) {
}
s.activity(r, t.ProjectID, t.ID, "task.updated", nil)
s.publish(t.ProjectID, "task.updated", toTaskJSON(*t))
if req.AssigneeID != nil && t.AssigneeID != wasAssigned {
s.notifyAssigned(r, t)
}
writeJSON(w, http.StatusOK, toTaskJSON(*t))
}
+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)
}
}
@@ -0,0 +1,18 @@
-- En besked kan handle om en note.
--
-- Tabellen kunne pege på et projekt og en opgave, fordi det var de to ting, der
-- kunne ske noget med. Nu kan der også: en note, der er delt, bliver rettet af en
-- anden, og den, den er delt med, skal kunne se at det er sket — uden at åbne den
-- for at opdage det.
--
-- En søjle ved siden af de to andre frem for et generisk (kind, id)-par. De tre er
-- ikke det samme slags nul: en besked om en opgave har *også* et projekt, og en om
-- en note har måske ingen af delene. Et par ville skulle tolkes hvert sted, det
-- læses, og fremmednøglen — som er dén, der rydder beskeden op, når noten slettes
-- — kan et generisk par slet ikke have.
ALTER TABLE notifications ADD COLUMN note_id TEXT REFERENCES notes (id) ON DELETE CASCADE;
-- Beskeder om den samme note bliver slået sammen, mens de er ulæste, og opslaget
-- er nøjagtig det: min, ulæst, om denne note.
CREATE INDEX notifications_unread_by_note
ON notifications (user_id, note_id) WHERE read_at IS NULL AND note_id IS NOT NULL;
+38
View File
@@ -372,3 +372,41 @@ func (db *DB) SyncLinkedShares(ctx context.Context, ownerID string) ([]LinkedSha
})
return out, nil
}
// NoteAudience is everybody who can see a note: its owner, the people it is shared
// with — chosen or followed along a link — and the members of the project it is
// filed in.
//
// Ét spørgsmål frem for tre, fordi svaret skal være en mængde og ikke tre lister,
// der skal lægges sammen bagefter: en person kan både eje projektet og have noten
// delt direkte, og to beskeder om den samme rettelse er én for mange.
//
// The note's own row is joined rather than passed in, so a caller cannot ask about
// a note that has been deleted and get an audience for it anyway.
func (db *DB) NoteAudience(ctx context.Context, noteID string) ([]string, error) {
rows, err := db.QueryContext(ctx, `
SELECT created_by FROM notes WHERE id = ? AND deleted_at IS NULL AND created_by IS NOT NULL
UNION
SELECT user_id FROM note_shares WHERE note_id = ?
UNION
SELECT p.owner_id FROM notes n JOIN projects p ON p.id = n.project_id
WHERE n.id = ? AND n.deleted_at IS NULL AND p.deleted_at IS NULL
UNION
SELECT m.user_id FROM notes n JOIN project_members m ON m.project_id = n.project_id
WHERE n.id = ? AND n.deleted_at IS NULL`,
noteID, noteID, noteID, noteID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
out = append(out, id)
}
return out, rows.Err()
}
+33 -7
View File
@@ -18,6 +18,7 @@ type Notification struct {
ActorName string
ProjectID string
TaskID string
NoteID string
Kind string
Title string
Body string
@@ -32,21 +33,45 @@ func (db *DB) CreateNotification(ctx context.Context, n *Notification) error {
n.CreatedAt = time.Now().UTC()
_, err := db.ExecContext(ctx,
`INSERT INTO notifications (id, user_id, actor_id, project_id, task_id, kind,
`INSERT INTO notifications (id, user_id, actor_id, project_id, task_id, note_id, kind,
title, body, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
n.ID, n.UserID, nullString(n.ActorID), nullString(n.ProjectID),
nullString(n.TaskID), n.Kind, n.Title, n.Body, n.CreatedAt.Unix())
nullString(n.TaskID), nullString(n.NoteID), n.Kind, n.Title, n.Body, n.CreatedAt.Unix())
return err
}
// BumpUnreadNoteNotification folds a second change to the same note into the first,
// and says whether it found one to fold into.
//
// A note is saved on a typing pause, so writing a row per save would turn one
// person editing one note for five minutes into a column of twenty identical
// lines. Slået sammen, mens beskeden er ulæst: den rykker op som ny og siger hvem
// der rørte den sidst.
//
// Kun mens den er ulæst — og det er hele reglen. Har man læst "Andreas rettede
// Aftalen" og går videre, er den næste rettelse en ny ting at få at vide, ikke en
// gentagelse af noget, man allerede har set.
func (db *DB) BumpUnreadNoteNotification(ctx context.Context, userID, noteID, actorID, title, body string) (bool, error) {
res, err := db.ExecContext(ctx, `
UPDATE notifications
SET created_at = ?, actor_id = ?, title = ?, body = ?
WHERE user_id = ? AND note_id = ? AND kind = 'note.changed' AND read_at IS NULL`,
time.Now().UTC().Unix(), nullString(actorID), title, body, userID, noteID)
if err != nil {
return false, err
}
n, err := res.RowsAffected()
return n > 0, err
}
func (db *DB) ListNotifications(ctx context.Context, userID string, limit int) ([]Notification, error) {
if limit <= 0 || limit > 100 {
limit = 30
}
rows, err := db.QueryContext(ctx, `
SELECT n.id, n.user_id, n.actor_id, COALESCE(u.name, ''), n.project_id, n.task_id,
n.kind, n.title, n.body, n.read_at, n.created_at
n.note_id, n.kind, n.title, n.body, n.read_at, n.created_at
FROM notifications n
LEFT JOIN users u ON u.id = n.actor_id
WHERE n.user_id = ?
@@ -60,14 +85,15 @@ func (db *DB) ListNotifications(ctx context.Context, userID string, limit int) (
out := []Notification{}
for rows.Next() {
var n Notification
var actorID, projectID, taskID sql.NullString
var actorID, projectID, taskID, noteID sql.NullString
var readAt sql.NullInt64
var created int64
if err := rows.Scan(&n.ID, &n.UserID, &actorID, &n.ActorName, &projectID,
&taskID, &n.Kind, &n.Title, &n.Body, &readAt, &created); err != nil {
&taskID, &noteID, &n.Kind, &n.Title, &n.Body, &readAt, &created); err != nil {
return nil, err
}
n.ActorID, n.ProjectID, n.TaskID = actorID.String, projectID.String, taskID.String
n.ActorID, n.ProjectID = actorID.String, projectID.String
n.TaskID, n.NoteID = taskID.String, noteID.String
if readAt.Valid {
v := time.Unix(readAt.Int64, 0).UTC()
n.ReadAt = &v
+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);
}
+268
View File
@@ -0,0 +1,268 @@
<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);
const KINDS = {
assigned: 'notif.assigned',
'note.changed': 'notif.noteChanged',
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);
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}
onkeydown={(e) => {
if (e.key === 'Escape' && open) open = false;
}}
/>
<div class="wrap" bind:this={wrap}>
<button
class="bell"
class:on={open}
onclick={() => (open = !open)}
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')}>
<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>
{#if n.body}<span class="about">{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));
}
.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;
}
.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>
+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.
+12
View File
@@ -22,6 +22,18 @@ export const da = {
'notes.unshared': 'Noten er din igen.',
// Deling med enkeltpersoner, ved siden af projektet.
'notes.groupSharedWithMe': 'Delt med mig',
// Klokken: hvad andre har gjort, som man skal vide. Sætningen skrives i fladen
// og ikke på serveren, så en engelsk flade også får en engelsk klokke.
'notif.title': 'Beskeder',
'notif.titleWithCount': 'Beskeder ({n} ulæste)',
'notif.empty': 'Ingen beskeder.',
'notif.markAllRead': 'Markér alle som læst',
'notif.assigned': '{name} gav dig en opgave',
'notif.noteChanged': '{name} rettede en note',
'notif.comment': '{name} kommenterede',
'notif.justNow': 'lige nu',
'notif.minutes': '{n} min. siden',
'notif.hours': '{n} t. siden',
'notes.linkOpen': 'Åbn',
'notes.linkMissing': 'Noten findes ikke, eller den er ikke delt med dig.',
'notes.sharedBy': 'Delt af {name}',
+13
View File
@@ -18,6 +18,19 @@ export const en = {
'notes.unshared': 'The note is yours again.',
// Sharing with individual people, alongside the project.
'notes.groupSharedWithMe': 'Shared with me',
// The bell: what other people did that you need to know about. The sentence is
// composed here rather than on the server, so an English interface gets an
// English bell.
'notif.title': 'Notifications',
'notif.titleWithCount': 'Notifications ({n} unread)',
'notif.empty': 'Nothing yet.',
'notif.markAllRead': 'Mark all as read',
'notif.assigned': '{name} gave you a task',
'notif.noteChanged': '{name} edited a note',
'notif.comment': '{name} commented',
'notif.justNow': 'just now',
'notif.minutes': '{n} min ago',
'notif.hours': '{n} h ago',
'notes.linkOpen': 'Open',
'notes.linkMissing': 'That note does not exist, or it is not shared with you.',
'notes.sharedBy': 'Shared by {name}',
+55
View File
@@ -108,6 +108,7 @@ class AppState {
this.groups = groups;
this.people = people;
this.connect();
this.loadNotifications();
} catch (e) {
this.user = null;
} finally {
@@ -145,10 +146,64 @@ class AppState {
socket.onerror = () => socket.close();
}
// --- klokken -----------------------------------------------------------------
//
// Beskederne ligger i butikken frem for i komponenten, fordi de kommer to veje:
// hentet, når man lander, og skubbet over websocket'en, mens man sidder der. En
// klokke, der kun kendte den første, ville stå på nul, indtil man genindlæste —
// og en besked, man skal genindlæse for at se, er ikke en besked.
notifications = $state([]);
unread = $state(0);
async loadNotifications() {
try {
const r = await api.listNotifications();
this.notifications = r.notifications ?? [];
this.unread = r.unread ?? 0;
} catch {
// En klokke, der ikke kunne hentes, er ikke værd at afbryde nogen for.
}
}
/** Marks one read, or all of them when no id is given. */
async markRead(id) {
const previous = this.notifications;
const wasUnread = this.unread;
this.notifications = this.notifications.map((n) =>
!id || n.id === id ? { ...n, read: true } : n
);
this.unread = this.notifications.filter((n) => !n.read).length;
try {
await api.markNotificationsRead(id);
} catch {
this.notifications = previous;
this.unread = wasUnread;
}
}
/** Applies a change made by somebody else. */
#applyRemote(event) {
const task = event.payload;
switch (event.type) {
// Beskeder kommer på personens egen kanal, ikke projektets: en note delt
// med én person hører ikke til noget projekt, og der er intet rum at
// udsende den i.
case 'notification':
this.notifications = [
{ ...event.payload, read: false },
...this.notifications.filter((n) => n.id !== event.payload?.id)
].slice(0, 30);
this.unread = this.notifications.filter((n) => !n.read).length;
break;
// En rettelse, der er lagt oven i en besked, man allerede har. Tallet er
// uændret; linjen er en anden. Hentet frem for flettet, fordi serveren
// har det rigtige svar og listen er tredive rækker.
case 'notification.folded':
this.loadNotifications();
break;
case 'task.created':
case 'task.updated':
case 'task.completed':
+11
View File
@@ -1,6 +1,7 @@
<script>
import '../app.css';
import { app, theme, sidebar } from '$lib/stores.svelte.js';
import Notifications from '$lib/components/Notifications.svelte';
import { connectivity } from '$lib/connectivity.svelte.js';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
@@ -9,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();
@@ -52,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.
*
@@ -169,6 +178,8 @@
<kbd>⌘K</kbd>
</button>
<Notifications />
<button
class="theme"
onclick={() => theme.toggle()}
+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 || '',