feat(t-paliad-073): audit polish-2 DEFER cleanup (F-23/32/38/40/48/49) #1
@@ -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>`
|
||||
: "";
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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!;
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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ändert</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
34
internal/handlers/redirects_test.go
Normal file
34
internal/handlers/redirects_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user