feat(changelog): What's New page with sidebar badge
Adds a hardcoded changelog (internal/changelog) served via GET /api/changelog and /api/changelog/unseen-count?since=<iso>, a /changelog page that renders entries newest-first, and a sidebar "Neuigkeiten" link with a lime badge showing the count of unseen entries since the caller's last visit (localStorage stamp). - internal/changelog: Entry struct, 11 pre-populated entries covering everything shipped so far (Dashboard, Projects/Deadlines/Appointments, CalDAV, Checklists v2, Glossary, Courts, Invitations, Settings, Paliad rename, and the changelog itself). - Handler: public via auth-gated protected mux. Lexicographic string compare treats YYYY-MM-DD entries and ISO 8601 cutoffs symmetrically. - Sidebar: new sidebar-changelog link before the Einladen button; the badge is populated by a fetch on every page load, suppressed on /changelog itself to avoid flash, and cleared on visit by stamping localStorage in changelog.ts's DOMContentLoaded handler. - i18n: DE + EN keys for nav, page chrome, and tag labels. - Unit tests for sort order, copy semantics, and same-day cutoff. Task: t-paliad-027
This commit is contained in:
146
internal/changelog/changelog.go
Normal file
146
internal/changelog/changelog.go
Normal file
@@ -0,0 +1,146 @@
|
||||
// Package changelog holds the hardcoded "What's New" feed.
|
||||
//
|
||||
// Entries are maintained in source — adding a new one is a struct append
|
||||
// plus a deploy, no database migration, no admin UI. The list is kept
|
||||
// newest-first by convention; ByDateDesc guarantees that ordering at
|
||||
// runtime regardless of how authors insert new entries.
|
||||
package changelog
|
||||
|
||||
import "sort"
|
||||
|
||||
// Tag categorises an entry for filtering and UI styling.
|
||||
type Tag string
|
||||
|
||||
const (
|
||||
TagFeature Tag = "feature"
|
||||
TagContent Tag = "content"
|
||||
TagFix Tag = "fix"
|
||||
)
|
||||
|
||||
// Entry is one visible row in the changelog / one badge-worthy event.
|
||||
type Entry struct {
|
||||
Date string `json:"date"` // ISO 8601, YYYY-MM-DD
|
||||
TitleDE string `json:"title_de"`
|
||||
TitleEN string `json:"title_en"`
|
||||
BodyDE string `json:"body_de"`
|
||||
BodyEN string `json:"body_en"`
|
||||
Tag Tag `json:"tag"`
|
||||
}
|
||||
|
||||
// Entries lists everything shipped so far, newest first. Append new rows
|
||||
// at the top.
|
||||
var Entries = []Entry{
|
||||
{
|
||||
Date: "2026-04-22",
|
||||
Tag: TagFeature,
|
||||
TitleDE: "Neuigkeiten",
|
||||
TitleEN: "What's New",
|
||||
BodyDE: "Diese Seite zeigt, was sich in Paliad tut. Der kleine grüne Punkt in der Seitenleiste meldet sich, wenn es etwas Neues gibt — ein Klick hierher lässt ihn wieder verschwinden.",
|
||||
BodyEN: "This page shows what's new in Paliad. The small green dot in the sidebar appears whenever there's something new — clicking through to this page clears it.",
|
||||
},
|
||||
{
|
||||
Date: "2026-04-20",
|
||||
Tag: TagFeature,
|
||||
TitleDE: "Einstellungen mit Benachrichtigungs-Präferenzen",
|
||||
TitleEN: "Settings with notification preferences",
|
||||
BodyDE: "Unter Einstellungen lassen sich Profil, CalDAV-Zugang und E-Mail-Benachrichtigungen in drei Tabs verwalten. Fristen-Erinnerungen können pro Art (7 Tage / 1 Tag / überfällig) einzeln ein- oder ausgeschaltet werden.",
|
||||
BodyEN: "Settings now bundles profile, CalDAV and email notifications in three tabs. Deadline reminders can be toggled per type (7 days / 1 day / overdue) independently.",
|
||||
},
|
||||
{
|
||||
Date: "2026-04-18",
|
||||
Tag: TagFeature,
|
||||
TitleDE: "Kolleg:innen per E-Mail einladen",
|
||||
TitleEN: "Invite colleagues by email",
|
||||
BodyDE: "Die Schaltfläche „Kolleg:in einladen\" in der Seitenleiste öffnet ein Modal und verschickt einen Registrierungslink an eine HLC-E-Mail-Adresse. Einladungsstatus und Tageslimit werden direkt angezeigt.",
|
||||
BodyEN: "The \"Invite colleague\" button in the sidebar opens a modal and sends a registration link to an HLC email address. Invitation status and daily quota are shown inline.",
|
||||
},
|
||||
{
|
||||
Date: "2026-04-16",
|
||||
Tag: TagContent,
|
||||
TitleDE: "Paliad — neuer Name, gleicher Lime",
|
||||
TitleEN: "Paliad — new name, same lime",
|
||||
BodyDE: "Nach dem Zusammenschluss zu HLC wurde patHoLo zu Paliad. Ein firmenunabhängiger Name, damit die Plattform auch künftige Umbenennungen überlebt. Alte Bookmarks auf patholo.de leiten automatisch weiter.",
|
||||
BodyEN: "Following the HLC merger, patHoLo became Paliad. A firm-agnostic name so the platform outlives future renames. Old patholo.de bookmarks redirect automatically.",
|
||||
},
|
||||
{
|
||||
Date: "2026-04-15",
|
||||
Tag: TagFeature,
|
||||
TitleDE: "Dashboard als Startseite",
|
||||
TitleEN: "Dashboard as landing page",
|
||||
BodyDE: "Nach dem Login landen Sie jetzt auf einem Dashboard mit Fristen-Ampel, nächsten Terminen und letzter Aktivität — alles gefiltert auf Projekte, die Sie sehen dürfen. Server-gerendert, kein Warten auf Daten.",
|
||||
BodyEN: "After login you now land on a dashboard with deadline traffic lights, upcoming appointments and recent activity — all scoped to projects you can see. Server-rendered, no data waterfall.",
|
||||
},
|
||||
{
|
||||
Date: "2026-04-12",
|
||||
Tag: TagFeature,
|
||||
TitleDE: "Termine + CalDAV-Sync",
|
||||
TitleEN: "Appointments + CalDAV sync",
|
||||
BodyDE: "Termine lassen sich mit Projekten verknüpfen und optional in den persönlichen Kalender synchronisieren (iCloud, Google, eigener CalDAV-Server). Passwörter werden AES-GCM-verschlüsselt gespeichert.",
|
||||
BodyEN: "Appointments can be linked to projects and optionally synced to your personal calendar (iCloud, Google, self-hosted CalDAV). Passwords are stored AES-GCM encrypted.",
|
||||
},
|
||||
{
|
||||
Date: "2026-04-08",
|
||||
Tag: TagFeature,
|
||||
TitleDE: "Fristen-Verwaltung",
|
||||
TitleEN: "Deadline management",
|
||||
BodyDE: "Persistente Fristen mit Ampel-Karten, Kalenderansicht und E-Mail-Erinnerungen. Verknüpft mit Projekten, teilbar im Team, abhakbar per Klick.",
|
||||
BodyEN: "Persistent deadlines with traffic-light cards, a calendar view and email reminders. Linked to projects, shared with the team, completable in one click.",
|
||||
},
|
||||
{
|
||||
Date: "2026-04-04",
|
||||
Tag: TagFeature,
|
||||
TitleDE: "Projekte mit Hierarchie",
|
||||
TitleEN: "Hierarchical projects",
|
||||
BodyDE: "Projekte in vier Ebenen: Mandant → Streit → Patent → Akte. Team-Sichtbarkeit vererbt sich entlang des Baums — ein Team am obersten Knoten sieht alle Unterknoten automatisch.",
|
||||
BodyEN: "Projects in four levels: Client → Litigation → Patent → Matter. Team visibility is inherited down the tree — a team on the top node automatically sees every descendant.",
|
||||
},
|
||||
{
|
||||
Date: "2026-03-28",
|
||||
Tag: TagFeature,
|
||||
TitleDE: "Checklisten v2 — interaktiv",
|
||||
TitleEN: "Checklists v2 — interactive",
|
||||
BodyDE: "Einreichungs-Checklisten lassen sich jetzt als Instanz pro Akte durchklicken, Fortschritt wird gespeichert, Rücksetzen jederzeit möglich.",
|
||||
BodyEN: "Filing checklists can now be run as per-matter instances, with saved progress and a reset option.",
|
||||
},
|
||||
{
|
||||
Date: "2026-03-20",
|
||||
Tag: TagContent,
|
||||
TitleDE: "Gerichtsverzeichnis",
|
||||
TitleEN: "Court directory",
|
||||
BodyDE: "Kontaktdaten, Einreichungshinweise und Praxistipps zu UPC-Kammern, deutschen Patentgerichten und dem EPA — durchsuchbar und filterbar nach Typ und Land.",
|
||||
BodyEN: "Contact details, filing notes and practical hints for UPC divisions, German patent courts and the EPO — searchable and filterable by type and country.",
|
||||
},
|
||||
{
|
||||
Date: "2026-03-10",
|
||||
Tag: TagContent,
|
||||
TitleDE: "Patentglossar DE/EN",
|
||||
TitleEN: "Patent glossary DE/EN",
|
||||
BodyDE: "Über 80 Fachbegriffe aus Litigation, Prosecution, UPC, EPA und SEP/FRAND — zweisprachig mit Definitionen. Fehlende Begriffe lassen sich direkt vorschlagen.",
|
||||
BodyEN: "Over 80 terms from litigation, prosecution, UPC, EPO and SEP/FRAND — bilingual with definitions. Missing terms can be suggested inline.",
|
||||
},
|
||||
}
|
||||
|
||||
// All returns a copy of the entry list sorted newest-first by Date.
|
||||
// Returning a copy keeps the package-level slice immutable for callers
|
||||
// that might otherwise sort it in-place.
|
||||
func All() []Entry {
|
||||
out := make([]Entry, len(Entries))
|
||||
copy(out, Entries)
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
return out[i].Date > out[j].Date
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// UnseenCount returns how many entries have a Date strictly greater than
|
||||
// the given cutoff (ISO 8601, YYYY-MM-DD). An empty cutoff is treated as
|
||||
// "never seen" — every entry counts.
|
||||
func UnseenCount(since string) int {
|
||||
n := 0
|
||||
for _, e := range Entries {
|
||||
if e.Date > since {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
45
internal/changelog/changelog_test.go
Normal file
45
internal/changelog/changelog_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package changelog
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAll_NewestFirst(t *testing.T) {
|
||||
entries := All()
|
||||
if len(entries) == 0 {
|
||||
t.Fatal("expected at least one entry")
|
||||
}
|
||||
for i := 1; i < len(entries); i++ {
|
||||
if entries[i-1].Date < entries[i].Date {
|
||||
t.Fatalf("entries not sorted newest-first at index %d: %q before %q",
|
||||
i, entries[i-1].Date, entries[i].Date)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAll_ReturnsCopy(t *testing.T) {
|
||||
a := All()
|
||||
if len(a) == 0 {
|
||||
t.Skip("no entries")
|
||||
}
|
||||
a[0] = Entry{Date: "1970-01-01"}
|
||||
b := All()
|
||||
if b[0].Date == "1970-01-01" {
|
||||
t.Fatal("All() should return an independent copy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnseenCount(t *testing.T) {
|
||||
if got := UnseenCount(""); got != len(Entries) {
|
||||
t.Fatalf("empty cutoff: want %d, got %d", len(Entries), got)
|
||||
}
|
||||
if got := UnseenCount("9999-12-31"); got != 0 {
|
||||
t.Fatalf("far-future cutoff: want 0, got %d", got)
|
||||
}
|
||||
// An ISO timestamp for the same day as an entry is lexicographically
|
||||
// greater than the bare date, so that entry counts as seen.
|
||||
entries := All()
|
||||
newest := entries[0].Date
|
||||
cutoff := newest + "T10:00:00Z"
|
||||
if got := UnseenCount(cutoff); got != 0 {
|
||||
t.Fatalf("same-day ISO timestamp after entry: want 0 unseen, got %d", got)
|
||||
}
|
||||
}
|
||||
31
internal/handlers/changelog.go
Normal file
31
internal/handlers/changelog.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"mgit.msbls.de/m/patholo/internal/changelog"
|
||||
)
|
||||
|
||||
// handleChangelogPage serves the static /changelog HTML shell. The entries
|
||||
// are fetched client-side via /api/changelog.
|
||||
func handleChangelogPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/changelog.html")
|
||||
}
|
||||
|
||||
// handleChangelogAPI returns every entry, newest first.
|
||||
func handleChangelogAPI(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, changelog.All())
|
||||
}
|
||||
|
||||
// handleChangelogUnseenCount returns how many entries are newer than the
|
||||
// ?since=<ISO timestamp> query parameter. Missing / empty since is treated
|
||||
// as "never seen" so the badge shows on a user's very first session.
|
||||
//
|
||||
// ISO 8601 timestamps compare correctly as strings against YYYY-MM-DD
|
||||
// entry dates (a timestamp for 2026-04-22T10:00:00Z sorts after the date
|
||||
// "2026-04-22", which is what we want — same-day entries posted at 00:00
|
||||
// are considered seen by mid-day callers).
|
||||
func handleChangelogUnseenCount(w http.ResponseWriter, r *http.Request) {
|
||||
since := r.URL.Query().Get("since")
|
||||
writeJSON(w, http.StatusOK, map[string]int{"count": changelog.UnseenCount(since)})
|
||||
}
|
||||
@@ -110,6 +110,9 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("GET /courts", handleCourtsPage)
|
||||
protected.HandleFunc("GET /api/courts", handleCourtsAPI)
|
||||
protected.HandleFunc("POST /api/courts/feedback", handleCourtsFeedback)
|
||||
protected.HandleFunc("GET /changelog", handleChangelogPage)
|
||||
protected.HandleFunc("GET /api/changelog", handleChangelogAPI)
|
||||
protected.HandleFunc("GET /api/changelog/unseen-count", handleChangelogUnseenCount)
|
||||
|
||||
// Phase B (DB-backed) — return 503 if DATABASE_URL unset.
|
||||
protected.HandleFunc("GET /api/deadline-rules", handleListDeadlineRules)
|
||||
|
||||
Reference in New Issue
Block a user