feat(t-paliad-129): Fristenrechner polish — date-aligned columns + Drucken icon

Three changes to the columns view + the Drucken button, per m's 2026-05-04
polish round on top of t-paliad-127 / t-paliad-126:

1. Date-aligned grid timeline. The columns view used to render three
   independent vertical stacks; now each distinct dueDate gets a grid row
   so a Court hearing on the 15th lines up beside a Proactive Antrag on
   the 15th (and an empty cell where the third party has nothing to do).
   Court-set / dateless rows collapse into a final trailing row.

2. "both"-party deadlines are mirrored, not spanned. Previously they
   rendered as a full-width row beneath the columns; now they appear in
   BOTH the Proactive AND Reactive cell of their date-row, with a
   "↔ beide Seiten" / "↔ both parties" caption so the duplication reads
   as deliberate. The Court column at that row stays empty unless a
   court-party deadline also lands on the same date. The full-width
   spans block (.fr-columns-spans) is gone.

3. Drucken button restyle. The grey-square default-button look is
   replaced with a tertiary-action treatment: hairline border, accent
   on hover, subtle lift, inline 16px printer SVG. To keep the icon
   from being wiped by [data-i18n] (which sets textContent), the label
   moved into a child <span data-i18n="deadlines.print"> while the SVG
   sits as its sibling. Both fristen-print-btn and event-print-btn pick
   up the new style via the shared .print-btn class.
This commit is contained in:
m
2026-05-04 19:57:32 +02:00
parent c1ff631257
commit cca433cb10
5 changed files with 161 additions and 112 deletions

View File

@@ -494,82 +494,92 @@ function renderTimelineBody(data: DeadlineResponse): string {
return html;
}
// Three-column lane layout: Proactive (claimant) | Court | Reactive
// (defendant). Rows with party=both render as a full-width row beneath each
// column block, since they apply to all sides and read awkwardly inside any
// single lane. Each column is independently date-ordered (the backend
// already sorts the array, but we re-derive per-party order so future
// re-renders stay sane).
// Three-column timeline layout: Proactive (claimant) | Court | Reactive
// (defendant). Each grid row corresponds to a distinct dueDate, so events on
// the same day line up across columns. Deadlines with party=both render in
// BOTH the Proactive and Reactive cells of their row with a "beide Seiten"
// caption so the duplication is legible as intentional. Court-set / dateless
// rows collapse into a single trailing row at the bottom.
function renderColumnsBody(data: DeadlineResponse): string {
const proactive: CalculatedDeadline[] = [];
const court: CalculatedDeadline[] = [];
const reactive: CalculatedDeadline[] = [];
const both: CalculatedDeadline[] = [];
type Cell = CalculatedDeadline[];
type Row = { proactive: Cell; court: Cell; reactive: Cell };
const NO_DATE = "";
const rowsMap = new Map<string, Row>();
const ensureRow = (key: string): Row => {
let r = rowsMap.get(key);
if (!r) {
r = { proactive: [], court: [], reactive: [] };
rowsMap.set(key, r);
}
return r;
};
for (const dl of data.deadlines) {
const key = dl.dueDate || NO_DATE;
const row = ensureRow(key);
switch (dl.party) {
case "claimant":
proactive.push(dl);
row.proactive.push(dl);
break;
case "defendant":
reactive.push(dl);
row.reactive.push(dl);
break;
case "court":
court.push(dl);
row.court.push(dl);
break;
case "both":
both.push(dl);
// Mirrored: same card lands in Proactive AND Reactive at this date.
row.proactive.push(dl);
row.reactive.push(dl);
break;
default:
// Unknown party: treat as court-neutral so it still appears
// somewhere instead of getting swallowed silently.
court.push(dl);
// Unknown party: keep visible by parking in the Court column.
row.court.push(dl);
}
}
const byDueDate = (a: CalculatedDeadline, b: CalculatedDeadline) => {
// Court-set rows have no dueDate; sink them to the bottom of their lane.
const aKey = a.dueDate || "9999-12-31";
const bKey = b.dueDate || "9999-12-31";
return aKey < bKey ? -1 : aKey > bKey ? 1 : 0;
};
proactive.sort(byDueDate);
court.sort(byDueDate);
reactive.sort(byDueDate);
both.sort(byDueDate);
// Sort row keys chronologically; the dateless bucket (court-set rows) sinks
// to the bottom because it has no temporal anchor.
const keys = Array.from(rowsMap.keys()).sort((a, b) => {
if (a === NO_DATE) return 1;
if (b === NO_DATE) return -1;
return a < b ? -1 : a > b ? 1 : 0;
});
const renderColumn = (
title: string,
cls: string,
items: CalculatedDeadline[],
): string => {
const rows = items.length === 0
? `<li class="fr-col-empty">\u2014</li>`
: items
.map((dl) => `<li class="fr-col-item ${dl.isRootEvent ? "fr-col-root" : ""}">${deadlineCardHtml(dl, { showParty: false })}</li>`)
.join("");
return `<div class="fr-col ${cls}">
<h3 class="fr-col-heading">${title}</h3>
<ol class="fr-col-list">${rows}</ol>
</div>`;
const renderCell = (items: CalculatedDeadline[]): string => {
if (items.length === 0) {
return `<div class="fr-col-cell fr-col-cell--empty"></div>`;
}
const cards = items
.map((dl) => {
const mirrorTag = dl.party === "both"
? `<div class="fr-col-mirror">\u2194 ${escHtml(t("deadlines.party.both.label"))}</div>`
: "";
return `<div class="fr-col-item ${dl.isRootEvent ? "fr-col-root" : ""}">
${deadlineCardHtml(dl, { showParty: false })}
${mirrorTag}
</div>`;
})
.join("");
return `<div class="fr-col-cell">${cards}</div>`;
};
const headerCell = (label: string, cls: string) =>
`<div class="fr-col-header ${cls}">${escHtml(label)}</div>`;
let html = '<div class="fr-columns-view">';
html += renderColumn(t("deadlines.col.proactive"), "fr-col-proactive", proactive);
html += renderColumn(t("deadlines.col.court"), "fr-col-court", court);
html += renderColumn(t("deadlines.col.reactive"), "fr-col-reactive", reactive);
html += "</div>";
html += headerCell(t("deadlines.col.proactive"), "fr-col-proactive");
html += headerCell(t("deadlines.col.court"), "fr-col-court");
html += headerCell(t("deadlines.col.reactive"), "fr-col-reactive");
if (both.length > 0) {
const bothRows = both
.map((dl) => `<div class="fr-span-row ${dl.isRootEvent ? "fr-span-root" : ""}">${deadlineCardHtml(dl, { showParty: false })}</div>`)
.join("");
html += `<div class="fr-columns-spans" aria-label="${t("deadlines.col.both")}">
<div class="fr-spans-label">${t("deadlines.col.both")}</div>
${bothRows}
</div>`;
for (const key of keys) {
const row = rowsMap.get(key)!;
html += renderCell(row.proactive);
html += renderCell(row.court);
html += renderCell(row.reactive);
}
html += "</div>";
return html;
}

View File

@@ -228,6 +228,7 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.party.defendant": "Beklagter",
"deadlines.party.court": "Gericht",
"deadlines.party.both": "Beide",
"deadlines.party.both.label": "beide Seiten",
"deadlines.court.set": "vom Gericht bestimmt",
"deadlines.view.label": "Ansicht:",
"deadlines.view.timeline": "Zeitstrahl",
@@ -1746,6 +1747,7 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.party.defendant": "Defendant",
"deadlines.party.court": "Court",
"deadlines.party.both": "Both",
"deadlines.party.both.label": "both parties",
"deadlines.court.set": "set by court",
"deadlines.view.label": "View:",
"deadlines.view.timeline": "Timeline",

View File

@@ -159,8 +159,13 @@ export function renderFristenrechner(): string {
<button type="button" id="fristen-save-cta" className="btn-primary btn-cta-lime" style="display:none" data-i18n="deadlines.save.cta">
Als Frist(en) speichern
</button>
<button type="button" id="fristen-print-btn" className="print-btn" style="display:none" data-i18n="deadlines.print">
Drucken
<button type="button" id="fristen-print-btn" className="print-btn" style="display:none">
<svg className="print-btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="6 9 6 2 18 2 18 9"></polyline>
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path>
<rect x="6" y="14" width="12" height="8"></rect>
</svg>
<span data-i18n="deadlines.print">Drucken</span>
</button>
</div>
</div>
@@ -220,8 +225,13 @@ export function renderFristenrechner(): string {
</h3>
<div id="event-results-container"></div>
<div className="fristen-result-actions">
<button type="button" id="event-print-btn" className="print-btn" style="display:none" data-i18n="deadlines.print">
Drucken
<button type="button" id="event-print-btn" className="print-btn" style="display:none">
<svg className="print-btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="6 9 6 2 18 2 18 9"></polyline>
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path>
<rect x="6" y="14" width="12" height="8"></rect>
</svg>
<span data-i18n="deadlines.print">Drucken</span>
</button>
</div>
</div>

View File

@@ -658,6 +658,7 @@ export type I18nKey =
| "deadlines.neu.subtitle"
| "deadlines.neu.title"
| "deadlines.party.both"
| "deadlines.party.both.label"
| "deadlines.party.claimant"
| "deadlines.party.court"
| "deadlines.party.defendant"

View File

@@ -2080,55 +2080,60 @@ input[type="range"]::-moz-range-thumb {
Each lane is independently date-ordered; party=both rows render below
as full-width spans because they apply to all sides. */
/* Columns view: a CSS grid timeline. The first three children are sticky
header cells (Proactive / Court / Reactive). After that, each distinct
dueDate produces three sibling cells (one per column) so deadlines on the
same day line up across columns. party=both deadlines appear in BOTH the
proactive and reactive cell of their row, marked with .fr-col-mirror. */
.fr-columns-view {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0.75rem;
column-gap: 1rem;
row-gap: 0.5rem;
margin-top: 0.5rem;
align-items: stretch;
}
.fr-col {
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
overflow: hidden;
display: flex;
flex-direction: column;
}
.fr-col-heading {
.fr-col-header {
margin: 0;
padding: 0.5rem 0.75rem;
font-size: 0.82rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
border-bottom: 1px solid var(--color-border);
border: 1px solid var(--color-border);
border-radius: var(--radius);
text-align: center;
position: sticky;
top: 0;
z-index: 1;
}
.fr-col-proactive .fr-col-heading {
.fr-col-header.fr-col-proactive {
background: var(--status-blue-bg);
color: var(--status-blue-fg);
}
.fr-col-court .fr-col-heading {
.fr-col-header.fr-col-court {
background: var(--status-blue-soft-bg);
color: var(--status-blue-soft-fg);
}
.fr-col-reactive .fr-col-heading {
.fr-col-header.fr-col-reactive {
background: var(--status-amber-bg);
color: var(--status-amber-fg);
}
.fr-col-list {
list-style: none;
margin: 0;
padding: 0.5rem 0.75rem;
.fr-col-cell {
display: flex;
flex-direction: column;
gap: 0.6rem;
flex: 1;
gap: 0.5rem;
min-height: 0;
}
.fr-col-cell--empty {
/* No content: still occupies its grid track so siblings stay aligned. */
}
.fr-col-item {
@@ -2143,47 +2148,68 @@ input[type="range"]::-moz-range-thumb {
box-shadow: 0 0 0 1px var(--color-accent) inset;
}
.fr-col-empty {
.fr-col-mirror {
margin-top: 0.4rem;
font-size: 0.72rem;
color: var(--color-text-muted);
font-style: italic;
text-align: center;
padding: 0.75rem 0;
}
.fr-columns-spans {
margin-top: 0.75rem;
padding: 0.5rem 0.75rem;
border: 1px dashed var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.fr-spans-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
}
.fr-span-row {
padding: 0.5rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-bg);
}
.fr-span-root {
border-color: var(--color-accent);
letter-spacing: 0.02em;
}
@media (max-width: 640px) {
.fr-columns-view {
grid-template-columns: 1fr;
}
.fr-col-header {
position: static;
}
}
/* Drucken / Print button — used by the procedure and event Fristenrechner
tabs. Tertiary action: hairline border, accent on hover, printer glyph. */
.print-btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-family: var(--font-sans);
font-size: 0.85rem;
font-weight: 500;
padding: 0.5rem 0.875rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
cursor: pointer;
transition: border-color 0.15s ease, color 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
}
.print-btn:hover {
border-color: var(--color-accent);
color: var(--color-accent-fg);
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06);
}
.print-btn:focus-visible {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 2px var(--color-accent-light);
}
.print-btn:active {
transform: translateY(0);
box-shadow: none;
}
.print-btn-icon {
flex-shrink: 0;
color: var(--color-text-muted);
transition: color 0.15s ease;
}
.print-btn:hover .print-btn-icon {
color: var(--color-accent-fg);
}
/* --- Responsive: Sidebar --- */