feat(fristenrechner): predefine Determinator perspective from our_side (t-paliad-164 slice 3)

Closes m's 2026-05-08 21:42 dogfood loop: when the user picks an Akte
that knows its own side, the Determinator perspective chip should be
locked to that side instead of asking the user to re-pick something
the project already knows.

ProjectOption gains our_side; the JSON already carries it from
slice 1 (ProjectService.projectColumns). New helper
applyOurSidePredefine maps project.our_side onto the chip:

  claimant  → "claimant"   chip active
  defendant → "defendant"  chip active
  court     → null          chip cleared (court actions are neutral
                            to the user's side, so no narrowing)
  both      → null          explicit "Beide" intent
  null/undef → no-op

URL wins: if ?role= is present at call time the user (or a shared
link) chose it explicitly and we don't overwrite. When we do predefine,
we write the same value to the URL so refresh + back/forward round-trip
correctly. Two call sites:

- selectProject: in-page Akte pick. push history (replaceURL=false) so
  back-button restores the prior state.
- post-fetchProjects hydration: the deep-link / refresh path. Use
  history replace so the URL stays clean.

A small "vorgegeben durch Akte" / "predefined from project" hint
renders next to the chip strip (italic muted). Visible whenever the
active perspective came from the project; cleared on any chip click
(explicit override) and on Step-1 reselect (no Akte = no hint).
popstate restores hint visibility by recomputing from
project.our_side ↔ currentPerspective so back/forward feels right.

Free-pick is preserved: clicking another chip overrides the
predefine and the cascade re-narrows immediately.
This commit is contained in:
m
2026-05-08 21:58:44 +02:00
parent 5d9c62d858
commit 3a41acee07
5 changed files with 103 additions and 0 deletions

View File

@@ -288,6 +288,12 @@ interface ProjectOption {
// (Slice 3b) can scope the cascade by the project's jurisdiction
// without an extra fetch.
proceeding_type_id?: number | null;
// our_side carries which side the firm represents on this project
// (t-paliad-164). When a user selects an Akte, the perspective chip
// pre-locks to this value; a small hint above the strip flags the
// pre-selection and the user can still click another chip to
// override. NULL/undefined leaves the chip unset (free-pick).
our_side?: "claimant" | "defendant" | "court" | "both" | null;
}
function escAttr(s: string): string {
@@ -2530,6 +2536,11 @@ function selectProject(project: ProjectOption) {
writeStep1ContextToURL(currentStep1Context);
renderStep1Summary();
showStep2Card();
// t-paliad-164: project.our_side predefines the perspective chip.
// Only fires when the user hasn't already locked a perspective via
// ?role= in the URL — the URL pick wins because it represents an
// explicit choice (chip click or shared link).
applyOurSidePredefine(project, /* replaceURL */ false);
// Slice 3b: project's proceeding type narrows the B1 cascade if the
// user reaches it via Step 2 → Etwas ist passiert. Refresh here so
// a cascade already on screen (rare but possible via popstate) picks
@@ -2551,6 +2562,12 @@ function clearStep1Context() {
renderStep1Summary();
hideStep2Card();
triggerCascadeRefresh();
// t-paliad-164: hint dies with the project context. We deliberately
// leave the perspective chip itself alone — the user may want to
// keep their pick when returning to Step 1; we only clear the
// "vorgegeben durch Akte" annotation since there's no Akte anymore.
const hint = document.getElementById("fristen-perspective-hint");
if (hint) hint.hidden = true;
}
function renderStep1Summary() {
@@ -2626,6 +2643,10 @@ function initPathwayFork() {
if (currentStep1Context.kind === "project" && currentStep1Context.projectId) {
currentStep1Context.project = cachedAkten.find((p) => p.id === currentStep1Context.projectId);
renderStep1Summary();
// t-paliad-164: deep-link / refresh path. project loaded async, so
// the predefine has to wait for cachedAkten. replace=true keeps
// the URL clean — the user didn't navigate, they just refreshed.
applyOurSidePredefine(currentStep1Context.project, /* replaceURL */ true);
}
renderAkteList("");
// Cascade may already be on screen if the user landed with
@@ -2657,6 +2678,11 @@ function initPathwayFork() {
const next: Perspective = isClear ? null : ((chip.dataset.perspective as Perspective) ?? null);
writePerspectiveToURL(next);
applyPerspective(next);
// t-paliad-164: any chip click is an explicit override; hide the
// "vorgegeben durch Akte" hint so the bar reads as "user choice"
// from here on.
const hint = document.getElementById("fristen-perspective-hint");
if (hint) hint.hidden = true;
});
});
@@ -2744,6 +2770,17 @@ function initPathwayFork() {
renderStep1Summary();
if (currentStep1Context.kind !== "none") showStep2Card(); else hideStep2Card();
applyPerspective(readPerspectiveFromURL());
// t-paliad-164: restore the hint visibility from URL+project state.
// The hint shows when the active URL perspective matches what the
// current project's our_side would have predefined — i.e. the
// "predefined-and-not-yet-overridden" state. Approximation: hint
// visible iff project.our_side maps to currentPerspective.
const hint = document.getElementById("fristen-perspective-hint");
if (hint) {
const proj = currentStep1Context.kind === "project" ? currentStep1Context.project : undefined;
const expected = ourSideToPerspective(proj?.our_side);
hint.hidden = !(proj && proj.our_side && expected === currentPerspective);
}
const path = readPathwayFromURL();
const mode = readBModeFromURL();
showPathway(path, mode);
@@ -3382,6 +3419,45 @@ function applyPerspective(p: Perspective) {
triggerCascadeRefresh();
}
// ourSideToPerspective maps the project-level "Wir vertreten" enum
// onto the chip-strip Perspective. 'court' / 'both' map to null
// (chip cleared) — court actions are neutral to the user's side and
// "both" is explicit no-filter intent.
function ourSideToPerspective(os: string | null | undefined): Perspective {
if (os === "claimant") return "claimant";
if (os === "defendant") return "defendant";
return null;
}
// applyOurSidePredefine locks the perspective chip from
// project.our_side when the user hasn't already explicitly picked
// one. The URL is the "explicit pick" signal: if ?role= is present
// at call time, the user (or a shared link) chose it and we don't
// overwrite. When we do predefine, we write the same value to the
// URL so back/forward + refresh round-trip cleanly, and we show the
// "vorgegeben durch Akte" hint so the user knows where the
// pre-selection came from. Clicking a chip clears the hint.
//
// `replaceURL=true` is for the deep-link / refresh path; `false` for
// in-page project selection so back-button restores the empty state.
function applyOurSidePredefine(project: ProjectOption | undefined, replaceURL: boolean) {
const hint = document.getElementById("fristen-perspective-hint");
if (!project || !project.our_side) {
if (hint) hint.hidden = true;
return;
}
// URL wins — user has an explicit pick. Don't clobber it; also no
// hint, since the active perspective didn't come from the project.
if (readPerspectiveFromURL() !== null) {
if (hint) hint.hidden = true;
return;
}
const next = ourSideToPerspective(project.our_side);
writePerspectiveToURL(next, replaceURL);
applyPerspective(next);
if (hint) hint.hidden = false;
}
// perspectiveAllowsParty returns true when a node tagged with `party`
// should be visible under the current perspective. Neutral nodes
// (party undefined / empty) always pass. "both" matches every

View File

@@ -376,6 +376,7 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.perspective.claimant.title": "Klägerseite — versteckt typische Beklagten-Schriftsätze",
"deadlines.perspective.defendant.title": "Beklagtenseite — versteckt typische Kläger-Schriftsätze",
"deadlines.perspective.appeal_filed_by.label": "Berufung eingelegt durch:",
"deadlines.perspective.predefined_hint": "vorgegeben durch Akte",
"deadlines.event.composite.label": "Zusammengesetzt:",
"deadlines.event.unit.days.one": "Tag",
"deadlines.event.unit.days.many": "Tage",
@@ -2530,6 +2531,7 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.perspective.claimant.title": "Claimant side — hides typical defendant submissions",
"deadlines.perspective.defendant.title": "Defendant side — hides typical claimant submissions",
"deadlines.perspective.appeal_filed_by.label": "Appeal filed by:",
"deadlines.perspective.predefined_hint": "predefined from project",
"deadlines.event.composite.label": "Composite:",
"deadlines.event.unit.days.one": "day",
"deadlines.event.unit.days.many": "days",

View File

@@ -273,6 +273,14 @@ export function renderFristenrechner(): string {
<span data-i18n="deadlines.perspective.both.short">Beide</span>
</button>
</div>
{/* t-paliad-164 — predefined-from-Akte hint. Hidden by
default; client/fristenrechner.ts shows it when the
active perspective came from project.our_side. The
user can still click another chip to override. */}
<span className="fristen-perspective-hint" id="fristen-perspective-hint"
data-i18n="deadlines.perspective.predefined_hint" hidden>
vorgegeben durch Akte
</span>
</div>
<div className="fristen-inbox-bar" id="fristen-inbox-bar" role="group" aria-label="Inbox channel">
<span className="fristen-inbox-bar-label" data-i18n="deadlines.inbox.label">Wo kam es an?</span>

View File

@@ -906,6 +906,7 @@ export type I18nKey =
| "deadlines.perspective.defendant.short"
| "deadlines.perspective.defendant.title"
| "deadlines.perspective.label"
| "deadlines.perspective.predefined_hint"
| "deadlines.print"
| "deadlines.priority.date"
| "deadlines.proceeding.reselect"

View File

@@ -1981,6 +1981,22 @@ input[type="range"]::-moz-range-thumb {
margin-bottom: 0.4rem;
}
/* t-paliad-164 — "vorgegeben durch Akte" hint shown next to the
* perspective chips when project.our_side has predefined the chip.
* Italic, muted, with a subtle leading bullet so it reads as
* meta-info rather than a chip. The user can still click another
* chip to override; the hint quietly disappears when they do. */
.fristen-perspective-hint {
font-size: 0.8rem;
font-style: italic;
color: var(--color-muted, #666);
margin-left: 0.4rem;
}
.fristen-perspective-hint::before {
content: "·\00a0";
}
.fristen-inbox-bar-label {
font-size: 0.875rem;
color: var(--color-muted, #666);