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:
m
2026-04-22 23:34:52 +02:00
parent b06a040e2b
commit 94e2fc0024
12 changed files with 537 additions and 0 deletions

View File

@@ -26,6 +26,7 @@ import { renderAppointmentsCalendar } from "./src/appointments-calendar";
import { renderSettings } from "./src/settings";
import { renderDashboard } from "./src/dashboard";
import { renderOnboarding } from "./src/onboarding";
import { renderChangelog } from "./src/changelog";
const DIST = join(import.meta.dir, "dist");
@@ -63,6 +64,7 @@ async function build() {
join(import.meta.dir, "src/client/settings.ts"),
join(import.meta.dir, "src/client/dashboard.ts"),
join(import.meta.dir, "src/client/onboarding.ts"),
join(import.meta.dir, "src/client/changelog.ts"),
],
outdir: join(DIST, "assets"),
naming: "[name].js",
@@ -110,6 +112,7 @@ async function build() {
await Bun.write(join(DIST, "settings.html"), renderSettings());
await Bun.write(join(DIST, "dashboard.html"), renderDashboard());
await Bun.write(join(DIST, "onboarding.html"), renderOnboarding());
await Bun.write(join(DIST, "changelog.html"), renderChangelog());
console.log("Build complete \u2192 dist/");
}

View File

@@ -0,0 +1,41 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { Footer } from "./components/Footer";
export function renderChangelog(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title data-i18n="changelog.title">Neuigkeiten &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/changelog" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<h1 data-i18n="changelog.heading">Neuigkeiten</h1>
<p className="tool-subtitle" data-i18n="changelog.subtitle">
Was sich in Paliad in letzter Zeit getan hat.
</p>
</div>
<ol className="changelog-list" id="changelog-list" />
<p className="changelog-empty" id="changelog-empty" style="display:none" data-i18n="changelog.empty">
Noch keine Einträge.
</p>
</div>
</section>
</main>
<Footer />
<script src="/assets/changelog.js"></script>
</body>
</html>
);
}

View File

@@ -0,0 +1,15 @@
// Shared localStorage tracker for the "What's New" badge.
//
// sidebar.ts reads the stamp on every page to ask the backend how many
// entries are newer; changelog.ts writes the stamp when the user visits
// /changelog so the badge clears on their next page load.
export const SEEN_KEY = "paliad-changelog-seen";
export function getChangelogSeen(): string {
return localStorage.getItem(SEEN_KEY) ?? "";
}
export function markChangelogSeen(): void {
localStorage.setItem(SEEN_KEY, new Date().toISOString());
}

View File

@@ -0,0 +1,89 @@
import { getLang, initI18n, onLangChange, t } from "./i18n";
import { initSidebar } from "./sidebar";
import { markChangelogSeen } from "./changelog-seen";
interface Entry {
date: string;
title_de: string;
title_en: string;
body_de: string;
body_en: string;
tag: "feature" | "content" | "fix";
}
let entries: Entry[] = [];
async function load(): Promise<void> {
const resp = await fetch("/api/changelog");
if (!resp.ok) return;
entries = await resp.json();
render();
}
function formatDate(iso: string): string {
// iso = YYYY-MM-DD. Render locale-aware without Intl allocations per row:
// "20. April 2026" (DE) or "20 April 2026" (EN). Cheap and deterministic.
const [y, m, d] = iso.split("-");
if (!y || !m || !d) return iso;
const monthsDE = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"];
const monthsEN = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
const monthIdx = parseInt(m, 10) - 1;
const day = parseInt(d, 10);
if (getLang() === "en") {
return `${day} ${monthsEN[monthIdx] ?? m} ${y}`;
}
return `${day}. ${monthsDE[monthIdx] ?? m} ${y}`;
}
function escapeHTML(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function render(): void {
const list = document.getElementById("changelog-list") as HTMLOListElement | null;
const empty = document.getElementById("changelog-empty") as HTMLElement | null;
if (!list || !empty) return;
if (entries.length === 0) {
list.innerHTML = "";
empty.style.display = "block";
return;
}
empty.style.display = "none";
const lang = getLang();
list.innerHTML = entries.map((e) => {
const title = lang === "en" ? e.title_en : e.title_de;
const body = lang === "en" ? e.body_en : e.body_de;
const tagLabel = t(`changelog.tag.${e.tag}`);
return (
`<li class="changelog-entry">` +
`<div class="changelog-meta">` +
`<time class="changelog-date" datetime="${escapeHTML(e.date)}">${escapeHTML(formatDate(e.date))}</time>` +
`<span class="changelog-tag changelog-tag-${escapeHTML(e.tag)}">${escapeHTML(tagLabel)}</span>` +
`</div>` +
`<h2 class="changelog-title">${escapeHTML(title)}</h2>` +
`<p class="changelog-body">${escapeHTML(body)}</p>` +
`</li>`
);
}).join("");
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
// Stamp the visit immediately so the sidebar badge clears even if the
// user navigates away before /api/changelog returns.
markChangelogSeen();
// Also clear any locally-rendered badge in the current DOM so it
// disappears without waiting for a reload.
document.querySelectorAll<HTMLElement>(".sidebar-badge").forEach((el) => {
el.remove();
});
onLangChange(render);
load();
});

View File

@@ -32,8 +32,18 @@ const translations: Record<Lang, Record<string, string>> = {
"nav.group.werkzeuge": "Werkzeuge",
"nav.group.wissen": "Wissen",
"nav.group.ressourcen": "Ressourcen",
"nav.neuigkeiten": "Neuigkeiten",
"nav.soon.tooltip": "Bald verf\u00fcgbar",
// Changelog (What's New) — t-paliad-027
"changelog.title": "Neuigkeiten — Paliad",
"changelog.heading": "Neuigkeiten",
"changelog.subtitle": "Was sich in Paliad in letzter Zeit getan hat.",
"changelog.empty": "Noch keine Eintr\u00e4ge.",
"changelog.tag.feature": "Neu",
"changelog.tag.content": "Inhalt",
"changelog.tag.fix": "Fix",
// Footer
"footer.text": "\u00a9 2026 Paliad \u2014 Nur f\u00fcr internen Gebrauch.",
@@ -1035,8 +1045,18 @@ const translations: Record<Lang, Record<string, string>> = {
"nav.group.werkzeuge": "Tools",
"nav.group.wissen": "Knowledge",
"nav.group.ressourcen": "Resources",
"nav.neuigkeiten": "What's New",
"nav.soon.tooltip": "Coming soon",
// Changelog (What's New) — t-paliad-027
"changelog.title": "What's New — Paliad",
"changelog.heading": "What's New",
"changelog.subtitle": "Recent changes and additions in Paliad.",
"changelog.empty": "Nothing here yet.",
"changelog.tag.feature": "New",
"changelog.tag.content": "Content",
"changelog.tag.fix": "Fix",
// Footer
"footer.text": "\u00a9 2026 Paliad \u2014 Internal use only.",

View File

@@ -1,6 +1,8 @@
// Sidebar client-side behavior
// Hover expand with delay, pin toggle, mobile hamburger
import { getChangelogSeen } from "./changelog-seen";
const PIN_KEY = "paliad-sidebar-pinned";
const LEGACY_PIN_KEY = "patholo-sidebar-pinned";
@@ -19,6 +21,7 @@ function migrateLegacyPinKey(): void {
export function initSidebar() {
migrateLegacyPinKey();
initInviteModal();
initChangelogBadge();
const sidebar = document.querySelector<HTMLElement>(".sidebar");
if (!sidebar) return;
@@ -123,6 +126,31 @@ export function initSidebar() {
});
}
// Changelog badge — fetches the count of entries newer than the locally
// stored "last seen" stamp and renders a dot + number on the Neuigkeiten
// link. Skipped on the changelog page itself because changelog.ts stamps
// the visit on load and we don't want a flash of badge before that runs.
function initChangelogBadge(): void {
const badge = document.getElementById("sidebar-changelog-badge") as HTMLElement | null;
if (!badge) return;
if (window.location.pathname === "/changelog") return;
const since = getChangelogSeen();
const url = since
? `/api/changelog/unseen-count?since=${encodeURIComponent(since)}`
: "/api/changelog/unseen-count";
fetch(url, { credentials: "same-origin" })
.then((r) => (r.ok ? r.json() : null))
.then((data: { count?: number } | null) => {
if (!data || typeof data.count !== "number" || data.count <= 0) return;
badge.textContent = data.count > 9 ? "9+" : String(data.count);
badge.style.display = "";
})
.catch(() => {
// silent: the badge is optional and must never break the page.
});
}
// Invitation modal — opened from the sidebar "Kolleg:in einladen" button.
// Keeps the whole flow client-side: validates, POSTs to /api/invite, shows
// success or the server's error message in the same modal. Kept inside

View File

@@ -18,6 +18,7 @@ const ICON_CALENDAR = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor
const ICON_GAUGE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 14l3.5-3.5"/><path d="M3 12a9 9 0 0 1 18 0"/><path d="M12 3v2"/><path d="M3 12H5"/><path d="M19 12h2"/><path d="M5.6 5.6l1.4 1.4"/><path d="M17 7l1.4-1.4"/></svg>';
const ICON_GEAR = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>';
const ICON_MAIL = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><polyline points="3 7 12 13 21 7"/></svg>';
const ICON_SPARKLE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v4"/><path d="M12 17v4"/><path d="M3 12h4"/><path d="M17 12h4"/><path d="M5.6 5.6l2.8 2.8"/><path d="M15.6 15.6l2.8 2.8"/><path d="M5.6 18.4l2.8-2.8"/><path d="M15.6 8.4l2.8-2.8"/></svg>';
interface SidebarProps {
currentPath: string;
@@ -108,6 +109,11 @@ export function Sidebar({ currentPath }: SidebarProps): string {
<div className="sidebar-spacer" />
<div className="sidebar-bottom">
<a href="/changelog" className={`sidebar-item sidebar-changelog${currentPath === "/changelog" ? " active" : ""}`} id="sidebar-changelog-link">
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: ICON_SPARKLE }} />
<span className="sidebar-label" data-i18n="nav.neuigkeiten">Neuigkeiten</span>
<span className="sidebar-badge" id="sidebar-changelog-badge" style="display:none" aria-hidden="true" />
</a>
<button type="button" className="sidebar-item sidebar-invite-btn" id="sidebar-invite-btn">
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: ICON_MAIL }} />
<span className="sidebar-label" data-i18n="invite.button">Kolleg:in einladen</span>

View File

@@ -5381,3 +5381,113 @@ input[type="range"]::-moz-range-thumb {
resize: vertical;
min-height: 5rem;
}
/* --- Changelog / What's New (t-paliad-027) --- */
.sidebar-changelog {
position: relative;
}
.sidebar-badge {
position: absolute;
top: 0.4rem;
left: calc(var(--sidebar-collapsed) - 1rem);
min-width: 1rem;
height: 1rem;
padding: 0 0.3rem;
border-radius: 999px;
background: var(--color-accent);
color: #fff;
font-size: 0.65rem;
font-weight: 700;
line-height: 1rem;
text-align: center;
box-shadow: 0 0 0 2px var(--color-bg, #fff);
pointer-events: none;
transition: left 150ms ease;
}
.sidebar.expanded .sidebar-badge,
.sidebar.pinned .sidebar-badge {
left: auto;
right: 1rem;
top: 50%;
transform: translateY(-50%);
}
.changelog-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 1.75rem;
max-width: 44rem;
}
.changelog-entry {
border-bottom: 1px solid var(--color-border, #e5e7eb);
padding-bottom: 1.75rem;
}
.changelog-entry:last-child {
border-bottom: none;
padding-bottom: 0;
}
.changelog-meta {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.4rem;
}
.changelog-date {
color: var(--color-text-muted);
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
}
.changelog-tag {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.15rem 0.55rem;
border-radius: 999px;
}
.changelog-tag-feature {
background: rgba(101, 163, 13, 0.14);
color: var(--color-accent);
}
.changelog-tag-content {
background: rgba(37, 99, 235, 0.12);
color: #2563eb;
}
.changelog-tag-fix {
background: rgba(180, 83, 9, 0.14);
color: #92400e;
}
.changelog-title {
font-size: 1.05rem;
font-weight: 600;
margin: 0 0 0.35rem;
letter-spacing: -0.01em;
}
.changelog-body {
color: var(--color-text-muted);
line-height: 1.55;
margin: 0;
font-size: 0.92rem;
}
.changelog-empty {
color: var(--color-text-muted);
font-style: italic;
padding: 1.5rem 0;
}

View 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
}

View 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)
}
}

View 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)})
}

View File

@@ -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)