feat(opdatering): en ny version siger til, i stedet for at vente på at

blive fundet

Tjekket fandtes i forvejen, og svaret stod ét sted: nederst på
indstillingssiden for beskeder. Det er en oplysning, man kun får, hvis
man i forvejen havde mistanke om, at der var noget at se efter — og "der
er kommet en rettelse af noget, du er ramt af" er ikke noget, man gætter
sig til at kigge efter.

Nu går den i klokken, i push og på mærket på ikonet, som alt andet, der
er værd at vide. Og den fører hen, hvor knappen til at genstarte sidder,
frem for ud af programmet.

Én besked pr. version, ikke én pr. time. En påmindelse hver time om det
samme er den slags besked, folk slår fra — og så er den næste, der
betyder noget, også slået fra. Versionen, der er sagt til om, skrives
først, når beskederne er sendt: den anden vej rundt ville en fejl
midtvejs betyde, at halvdelen fik besked og resten aldrig gjorde.

Kun administratorer, af samme grund som versionsfeltet allerede er det:
en besked om, at serveren er bagud, er en besked om en andens arbejde,
hvis man ikke selv kan gøre noget ved den.

Og et tjek, operatøren har slået fra, bliver ikke til en besked ad
bagvejen. VERDANDE_UPDATE_CHECK styrer det hele, som før.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kristianwind
2026-09-08 16:55:53 +02:00
co-authored by Claude Opus 5
parent 9fa47822c1
commit 2210dc082a
7 changed files with 238 additions and 0 deletions
+1
View File
@@ -105,6 +105,7 @@ func run() error {
runner.Push = api.PushToUser
runner.SendBeacon = api.SendBeacon
runner.DailyPlans = api.DailyPlans
runner.NotifyOfUpdates = api.NotifyOfUpdates
runner.Start(jobCtx)
srv := &http.Server{
+91
View File
@@ -0,0 +1,91 @@
package httpapi
import (
"context"
"strings"
"github.com/kristianwind/verdande/internal/store"
"github.com/kristianwind/verdande/internal/update"
)
// Besked til administratorerne, når der er kommet en ny version.
//
// Tjekket fandtes i forvejen, og svaret stod ét sted: nederst på en
// indstillingsside. Det er en oplysning, man kun får, hvis man i forvejen havde
// mistanke om, at der var noget at se efter — og "der er kommet en rettelse af
// noget, du er ramt af" er ikke noget, man gætter sig til at kigge efter.
//
// Kun til administratorer, af samme grund som versionsfeltet er det: en besked om,
// at serveren er bagud, er en besked om en andens arbejde, hvis man ikke selv kan
// gøre noget ved den. Og den fører hen, hvor knappen til at gøre det sidder.
const updateScope = "update"
// NotifyOfUpdates er timeslaget. Selve tjekket mellemlagrer sit svar i seks timer,
// så en time mellem opslagene her koster ikke noget — og en instans, der har været
// slukket, opdager det ved den første time, den er vågen.
func (s *Server) NotifyOfUpdates(ctx context.Context) error {
return s.notifyOfUpdate(ctx, s.updates.Status(ctx))
}
// notifyOfUpdate tager status som argument frem for at hente den, så den kan
// prøves uden at spørge GitHub om noget.
func (s *Server) notifyOfUpdate(ctx context.Context, status update.Status) error {
// Slået fra betyder slået fra. Et tjek, operatøren ikke har bedt om, må ikke
// blive til en besked ad bagvejen.
if status.Disabled || !status.Available || status.Latest == "" {
return nil
}
stored, err := s.db.InstanceSettings(ctx, updateScope)
if err != nil {
return err
}
// Én besked pr. version, ikke én pr. dag. En påmindelse hver morgen om det
// samme er den slags besked, folk slår fra — og så er den næste, der betyder
// noget, også slået fra.
if last, _ := stored["notified_version"].(string); last == status.Latest {
return nil
}
users, err := s.db.ListUsers(ctx)
if err != nil {
return err
}
title := updateTitle(status.Latest)
body := firstLineOf(status.Notes)
for _, u := range users {
if !u.IsAdmin {
continue
}
s.notifyDirect(ctx, &store.Notification{
UserID: u.ID, Kind: "update.available", Title: title, Body: body,
})
}
// Skrevet efter beskederne, og kun hvis de blev sendt. Skrevet først ville en
// fejl midtvejs betyde, at halvdelen fik besked og resten aldrig gjorde.
return s.db.SetInstanceSettings(ctx, updateScope, map[string]any{
"notified_version": status.Latest,
})
}
// updateTitle er skrevet på dansk, fordi den også bliver til en push-besked, og
// push har ingen ordbog at slå op i. Fladen skriver sin egen sætning ud fra
// `kind` — se KINDS i Notifications.svelte.
func updateTitle(version string) string {
return "verdande " + version + " er klar"
}
// firstLineOf er den første linje af udgivelsesnoterne, som beskedens krop.
//
// Hele teksten ville være en udgivelsesnote i en notifikation, og det er ikke det,
// en notifikation er til: den skal sige, at der er noget, og hvor man læser det.
func firstLineOf(notes string) string {
for _, line := range strings.Split(notes, "\n") {
if line = strings.TrimSpace(line); line != "" {
return firstWords(line, 30)
}
}
return ""
}
+127
View File
@@ -0,0 +1,127 @@
package httpapi
import (
"testing"
"github.com/kristianwind/verdande/internal/update"
)
// En ny version siger til af sig selv. Uden det stod svaret nederst på en
// indstillingsside, som man kun åbner, hvis man i forvejen havde mistanke om, at
// der var noget at se efter.
func TestANewVersionTellsTheAdministrator(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
status := update.Status{
Current: "v0.44.0", Latest: "v0.45.0", Available: true,
Notes: "Nyt:\n\n- noget godt", URL: "https://example.dk/v0.45.0",
}
if err := ts.api.notifyOfUpdate(t.Context(), status); err != nil {
t.Fatalf("besked om ny version: %v", err)
}
list, unread := notifications(t, ts)
if len(list) != 1 || unread != 1 {
t.Fatalf("beskeder = %d (%v ulæste), want 1", len(list), unread)
}
n := firstOf(t, list)
if n["kind"] != "update.available" {
t.Errorf("besked = %v", n)
}
// Titlen bærer versionen, fordi den også bliver til en push-besked, og push
// har ingen ordbog at slå sætningen op i.
if title, _ := n["title"].(string); title != "verdande v0.45.0 er klar" {
t.Errorf("titel = %q", title)
}
// Én besked pr. version, ikke én pr. time. En påmindelse hver time om det
// samme er den slags besked, folk slår fra — og så er den næste, der betyder
// noget, også slået fra.
if err := ts.api.notifyOfUpdate(t.Context(), status); err != nil {
t.Fatal(err)
}
if list, _ := notifications(t, ts); len(list) != 1 {
t.Errorf("den samme version gav %d beskeder, want 1", len(list))
}
// Og den næste version er en ny ting at få at vide.
status.Latest = "v0.46.0"
if err := ts.api.notifyOfUpdate(t.Context(), status); err != nil {
t.Fatal(err)
}
if list, _ := notifications(t, ts); len(list) != 2 {
t.Errorf("en ny version gav %d beskeder, want 2", len(list))
}
}
// Kun administratorer. En besked om, at serveren er bagud, er en besked om en
// andens arbejde, hvis man ikke selv kan gøre noget ved den.
func TestOnlyAdministratorsHearAboutANewVersion(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "sofie@example.dk", "Sofie")
err := ts.api.notifyOfUpdate(t.Context(), update.Status{
Current: "v0.44.0", Latest: "v0.45.0", Available: true,
})
if err != nil {
t.Fatal(err)
}
if list, _ := notifications(t, other); len(list) != 0 {
t.Errorf("en almindelig bruger fik %d beskeder om en opdatering", len(list))
}
if list, _ := notifications(t, ts); len(list) != 1 {
t.Errorf("administratoren fik %d beskeder, want 1", len(list))
}
}
// Et tjek, operatøren har slået fra, må ikke blive til en besked ad bagvejen.
func TestNoUpdateNoticeWhenTheCheckIsOff(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
for _, status := range []update.Status{
{Current: "v0.44.0", Disabled: true},
{Current: "v0.44.0", Available: false},
{Current: "v0.44.0", Available: true, Latest: ""},
} {
if err := ts.api.notifyOfUpdate(t.Context(), status); err != nil {
t.Fatalf("%v: %v", status, err)
}
}
if list, _ := notifications(t, ts); len(list) != 0 {
t.Errorf("der blev sagt til om en opdatering, der ikke var: %d beskeder", len(list))
}
}
// Beskeden fører hen, hvor knappen sidder — ikke ud af programmet. Prøvet på det,
// fladen læser: en besked uden note, opgave eller projekt skal stadig kunne
// klikkes.
func TestAnUpdateNoticeCarriesNoTarget(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
err := ts.api.notifyOfUpdate(t.Context(), update.Status{
Current: "v0.44.0", Latest: "v0.45.0", Available: true,
})
if err != nil {
t.Fatal(err)
}
n := firstOf(t, mustNotifications(t, ts))
for _, key := range []string{"note_id", "task_id", "project_id"} {
if v, ok := n[key]; ok && v != "" {
t.Errorf("%s = %v, want ingenting", key, v)
}
}
}
func mustNotifications(t *testing.T, ts *testServer) []any {
t.Helper()
list, _ := notifications(t, ts)
if len(list) == 0 {
t.Fatal("ingen beskeder")
}
return list
}
+13
View File
@@ -54,6 +54,9 @@ type Runner struct {
// AI-nøglen ligger derovre, og jobbet skal ikke kende nogen af delene.
DailyPlans func(ctx context.Context) error
// NotifyOfUpdates tells the administrators when a newer version is out.
NotifyOfUpdates func(ctx context.Context) error
// SendBeacon reports this installation to the collector, if it is switched on
// and a day has passed. Supplied by the HTTP layer for the same reason as the
// syncs: that is where the instance settings and the version live.
@@ -98,6 +101,9 @@ func (r *Runner) Start(ctx context.Context) {
// form som ovenstående, og af samme grund. Timen er personens egen: klokken
// syv er syv forskellige steder.
r.every(ctx, "dailyplan", time.Hour, r.dailyPlans)
// Nye versioner. Hver time er rigeligt: selve tjekket mellemlagrer sit svar i
// seks timer, og beskeden sendes én gang pr. version.
r.every(ctx, "updates", time.Hour, r.updateNotices)
}
func (r *Runner) Wait() { r.wg.Wait() }
@@ -132,6 +138,13 @@ func (r *Runner) every(ctx context.Context, name string, interval time.Duration,
}()
}
func (r *Runner) updateNotices(ctx context.Context) error {
if r.NotifyOfUpdates == nil {
return nil
}
return r.NotifyOfUpdates(ctx)
}
func (r *Runner) dailyPlans(ctx context.Context) error {
if r.DailyPlans == nil {
return nil
@@ -26,6 +26,7 @@
'note.shared': 'notif.noteShared',
'note.mention': 'notif.mention',
'daily.plan': 'notif.dailyPlan',
'update.available': 'notif.update',
comment: 'notif.comment'
};
@@ -52,6 +53,9 @@
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');
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}`);
+1
View File
@@ -33,6 +33,7 @@ export const da = {
'notif.noteShared': '{name} delte en note med dig',
'notif.mention': '{name} nævnte dig i en note',
'notif.dailyPlan': 'Dagens plan',
'notif.update': 'Der er kommet en ny version',
'notif.comment': '{name} kommenterede',
'notif.justNow': 'lige nu',
'notif.minutes': '{n} min. siden',
+1
View File
@@ -30,6 +30,7 @@ export const en = {
'notif.noteShared': '{name} shared a note with you',
'notif.mention': '{name} mentioned you in a note',
'notif.dailyPlan': "Today's plan",
'notif.update': 'A new version is out',
'notif.comment': '{name} commented',
'notif.justNow': 'just now',
'notif.minutes': '{n} min ago',