feat(t-paliad-073): audit polish-2 DEFER cleanup (F-23/32/38/40/48/49) #1

Merged
mAi merged 1 commits from mai/ritchie/audit-polish-2-defer into main 2026-04-30 00:31:06 +00:00
12 changed files with 114 additions and 21 deletions

View File

@@ -207,18 +207,35 @@ function groupByDay(items: AgendaItem[]): DayBucket[] {
}
function renderDay(bucket: DayBucket): string {
const expected = expectedUrgency(bucket.day);
return `<section class="agenda-day">
<h2 class="agenda-day-heading">
<span class="agenda-day-relative">${esc(relativeDayLabel(bucket.day))}</span>
<span class="agenda-day-full">${esc(fullDateLabel(bucket.day))}</span>
</h2>
<ul class="agenda-items">
${bucket.items.map(renderItem).join("")}
${bucket.items.map((it) => renderItem(it, expected)).join("")}
</ul>
</section>`;
}
function renderItem(it: AgendaItem): string {
// F-32: an item's urgency tag duplicates the day-bucket heading in the
// common case (a "Heute" item under HEUTE, a "Diese Woche" item under "in 3
// Tagen"). The tag stays only when it disagrees with the bucket — e.g. an
// "Überfällig" deadline that lands in today's bucket because of a filter
// quirk. expectedUrgency mirrors the server's bucketing rule against the
// bucket's day.
function expectedUrgency(day: Date): Urgency {
const today = startOfToday();
const diff = Math.round((day.getTime() - today.getTime()) / 86400000);
if (diff < 0) return "overdue";
if (diff === 0) return "today";
if (diff === 1) return "tomorrow";
if (diff <= 6) return "this_week";
return "later";
}
function renderItem(it: AgendaItem, bucketUrgency: Urgency): string {
const urgencyClass = `agenda-item-${it.urgency}`;
const typeClass = `agenda-item-type-${it.type}`;
const iconHTML = it.type === "deadline" ? deadlineIcon() : appointmentIcon();
@@ -230,7 +247,9 @@ function renderItem(it: AgendaItem): string {
const timePart = it.type === "appointment"
? `<span class="agenda-item-time">${esc(formatAppointmentTime(it))}</span>`
: "";
const urgencyTag = `<span class="agenda-item-urgency">${esc(t(`agenda.urgency.${it.urgency}`))}</span>`;
const urgencyTag = it.urgency !== bucketUrgency
? `<span class="agenda-item-urgency">${esc(t(`agenda.urgency.${it.urgency}`))}</span>`
: "";
const locationPart = it.type === "appointment" && it.location
? `<span class="agenda-item-location">${esc(it.location)}</span>`
: "";

View File

@@ -1,4 +1,5 @@
import { toggleMobileSidebar } from "./sidebar";
import { t } from "./i18n";
const KEYBOARD_THRESHOLD_PX = 100;
const BADGE_REFRESH_MS = 60_000;
@@ -99,11 +100,22 @@ function initAgendaBadge(): void {
if (total <= 0) {
badge!.style.display = "none";
badge!.classList.remove("bottom-nav-badge-overdue");
badge!.removeAttribute("title");
badge!.removeAttribute("aria-label");
return;
}
badge!.textContent = total > 9 ? "9+" : String(total);
badge!.style.display = "";
badge!.classList.toggle("bottom-nav-badge-overdue", overdue > 0);
// F-38: the badge counts "actionable" items only — overdue + due
// today. The accessible label spells that out so the "2" never
// reads as ambiguous (e.g. "2 things this week").
const label = t("bottomnav.badge.deadlines")
.replace("{overdue}", String(overdue))
.replace("{today}", String(today));
badge!.setAttribute("title", label);
badge!.setAttribute("aria-label", label);
badge!.setAttribute("aria-hidden", "false");
})
.catch(() => {
// Badge is decorative; never break the page.

View File

@@ -209,11 +209,18 @@ function render() {
<span class="frist-project-title" title="${esc(f.project_title)}">${esc(f.project_title)}</span>
</td>
<td class="frist-col-rule">${ruleLabel}</td>
<td><span class="akten-status-chip akten-status-${esc(f.status)}">${esc(statusLabel)}</span></td>
<td class="akten-col-status"><span class="akten-status-chip akten-status-${esc(f.status)}">${esc(statusLabel)}</span></td>
</tr>`;
})
.join("");
// F-23: when every visible row carries the same status, hide the column.
// The class is dropped as soon as the user widens the filter and variety
// reappears, so the header is never permanently removed from the DOM.
const statusUnique = new Set(allDeadlines.map((f) => f.status)).size;
const table = document.getElementById("deadlines-table");
table?.classList.toggle("akten-table--hide-status", statusUnique <= 1);
tbody.querySelectorAll<HTMLTableRowElement>(".frist-row").forEach((row) => {
const id = row.dataset.id!;
row.addEventListener("click", (e) => {

View File

@@ -50,6 +50,7 @@ const translations: Record<Lang, Record<string, string>> = {
"bottomnav.add.project": "Projekt anlegen",
"bottomnav.add.project.sub": "Neues Mandat / Verfahren / Patent",
"bottomnav.add.cancel": "Abbrechen",
"bottomnav.badge.deadlines": "{overdue} überfällig + {today} heute fällig",
// Changelog (What's New) — t-paliad-027
"changelog.title": "Neuigkeiten — Paliad",
@@ -252,8 +253,8 @@ const translations: Record<Lang, Record<string, string>> = {
"glossar.subtitle": "Zweisprachiges Glossar der wichtigsten Begriffe im Patentrecht.",
"glossar.search.placeholder": "Suchen...",
"glossar.filter.all": "Alle",
"glossar.filter.litigation": "Litigation",
"glossar.filter.prosecution": "Prosecution",
"glossar.filter.litigation": "Streitsachen",
"glossar.filter.prosecution": "Erteilungsverfahren",
"glossar.filter.general": "Allgemein",
"glossar.col.de": "Deutsch",
"glossar.col.en": "English",
@@ -1431,6 +1432,7 @@ const translations: Record<Lang, Record<string, string>> = {
"bottomnav.add.project": "New project",
"bottomnav.add.project.sub": "New matter / case / patent",
"bottomnav.add.cancel": "Cancel",
"bottomnav.badge.deadlines": "{overdue} overdue + {today} due today",
// Changelog (What's New) — t-paliad-027
"changelog.title": "What's New — Paliad",

View File

@@ -148,12 +148,19 @@ function render() {
<td><span class="akten-type-chip akten-type-${esc(p.type)}">${esc(typeLabel)}</span></td>
<td class="akten-col-ref">${refCell}</td>
<td class="akten-col-ref">${clientMatterCell}</td>
<td><span class="akten-status-chip akten-status-${esc(p.status)}">${esc(statusLabel)}</span></td>
<td class="akten-col-status"><span class="akten-status-chip akten-status-${esc(p.status)}">${esc(statusLabel)}</span></td>
<td class="akten-col-updated">${fmtDate(p.updated_at)}</td>
</tr>`;
})
.join("");
// F-23: when every visible row shares the same status, hide the column to
// cut redundant noise. The toggle re-runs on every filter change, so the
// column comes back as soon as the rows mix again.
const statusUnique = new Set(filtered.map((p) => p.status)).size;
const table = document.getElementById("akten-table");
table?.classList.toggle("akten-table--hide-status", statusUnique <= 1);
tbody.querySelectorAll<HTMLTableRowElement>(".akten-row").forEach((row) => {
row.addEventListener("click", () => {
const id = row.dataset.id!;

View File

@@ -100,7 +100,7 @@ export function renderDeadlines(): string {
<th data-i18n="fristen.col.title">Titel</th>
<th data-i18n="fristen.col.akte">Projekt</th>
<th data-i18n="fristen.col.rule">Regel</th>
<th data-i18n="fristen.col.status">Status</th>
<th className="akten-col-status" data-i18n="fristen.col.status">Status</th>
</tr>
</thead>
<tbody id="deadlines-body" />

View File

@@ -57,8 +57,8 @@ export function renderGlossary(): string {
</div>
<div className="glossar-filters" id="glossar-filters">
<button className="filter-pill active" data-category="all" type="button" data-i18n="glossar.filter.all">Alle</button>
<button className="filter-pill" data-category="Litigation" type="button" data-i18n="glossar.filter.litigation">Litigation</button>
<button className="filter-pill" data-category="Prosecution" type="button" data-i18n="glossar.filter.prosecution">Prosecution</button>
<button className="filter-pill" data-category="Litigation" type="button" data-i18n="glossar.filter.litigation">Streitsachen</button>
<button className="filter-pill" data-category="Prosecution" type="button" data-i18n="glossar.filter.prosecution">Erteilungsverfahren</button>
<button className="filter-pill" data-category="UPC" type="button">UPC</button>
<button className="filter-pill" data-category="EPA" type="button">EPA</button>
<button className="filter-pill" data-category="SEP/FRAND" type="button">SEP/FRAND</button>
@@ -109,8 +109,8 @@ export function renderGlossary(): string {
<div className="form-field">
<label htmlFor="suggest-cat" data-i18n="glossar.suggest.category">Kategorie</label>
<select id="suggest-cat" required>
<option value="Litigation">Litigation</option>
<option value="Prosecution">Prosecution</option>
<option value="Litigation" data-i18n="glossar.filter.litigation">Streitsachen</option>
<option value="Prosecution" data-i18n="glossar.filter.prosecution">Erteilungsverfahren</option>
<option value="UPC">UPC</option>
<option value="EPA">EPA</option>
<option value="SEP/FRAND">SEP/FRAND</option>

View File

@@ -105,7 +105,7 @@ export function renderProjects(): string {
<th data-i18n="projekte.col.type">Typ</th>
<th data-i18n="projekte.col.reference">Referenz</th>
<th data-i18n="projekte.col.clientmatter">ClientMatter</th>
<th data-i18n="projekte.col.status">Status</th>
<th className="akten-col-status" data-i18n="projekte.col.status">Status</th>
<th data-i18n="projekte.col.updated">Zuletzt ge&auml;ndert</th>
</tr>
</thead>

View File

@@ -3917,6 +3917,14 @@ input[type="range"]::-moz-range-thumb {
color: #374151;
}
/* F-23: hide STATUS column when every visible row shares the same value.
Toggled at render time by deadlines.ts / projects.ts. The header and cell
stay in the DOM so the column reappears the moment filters re-introduce
variety. */
.akten-table--hide-status .akten-col-status {
display: none;
}
.akten-status-active {
background: #dcfce7;
color: #166534;

View File

@@ -30,14 +30,6 @@ type Entry struct {
// 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,

View File

@@ -37,6 +37,18 @@ func registerLegacyRedirects(mux *http.ServeMux) {
mux.Handle("GET "+oldPrefix, redirectPrefix(oldPrefix, newPrefix))
mux.Handle("GET "+oldPrefix+"/", redirectPrefix(oldPrefix, newPrefix))
}
// In-path aliases: redirect parameterised legacy URLs whose alias sits
// between path segments rather than at the prefix. The canonical project
// children tab is /projects/{id}/children; bookmarks to the older
// /sub-projects suffix would otherwise hit the 404 chrome.
mux.Handle("GET /projects/{id}/sub-projects", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
target := "/projects/" + r.PathValue("id") + "/children"
if r.URL.RawQuery != "" {
target += "?" + r.URL.RawQuery
}
http.Redirect(w, r, target, http.StatusMovedPermanently)
}))
}
func redirectPrefix(oldPrefix, newPrefix string) http.Handler {

View File

@@ -0,0 +1,34 @@
package handlers
import (
"net/http"
"net/http/httptest"
"testing"
)
// Ensures the /projects/{id}/sub-projects alias from F-48 lands on the
// canonical /projects/{id}/children URL. The redirect is wired on the OUTER
// mux so it fires before auth middleware.
func TestRegisterLegacyRedirects_SubProjectsAlias(t *testing.T) {
mux := http.NewServeMux()
registerLegacyRedirects(mux)
cases := []struct {
path string
want string
}{
{"/projects/abc-123/sub-projects", "/projects/abc-123/children"},
{"/projects/abc-123/sub-projects?foo=bar", "/projects/abc-123/children?foo=bar"},
}
for _, tc := range cases {
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusMovedPermanently {
t.Fatalf("%s: status = %d, want %d", tc.path, w.Code, http.StatusMovedPermanently)
}
if got := w.Header().Get("Location"); got != tc.want {
t.Fatalf("%s: Location = %q, want %q", tc.path, got, tc.want)
}
}
}