feat(t-paliad-134): pill ordering + name standardisation + chip dedup

Five m's-bookmark fixes on top of the B1 surface change:

1. Sort proceeding pills inside concept cards by real-world frequency.
   New paliad.proceeding_types.display_order column (m's spec values:
   UPC_INF=10, DE_INF=20, UPC_REV=30, ..., UPC_PI=920, ...). Default
   999 for unmapped legacy codes. Search service surfaces it through
   the deadline_search matview (rebuilt to add the column) and uses
   it as primary key in pillSortKey, replacing the jurisdiction-rank.

2. Name standardisation: -klage → -verfahren on the proceeding-types
   that describe a multi-step process. Specifically:
     UPC_REV  Nichtigkeitsklage              → Nichtigkeitsverfahren
     UPC_APP  Berufung                       → Berufungsverfahren
     DE_INF   Verletzungsklage (LG)          → Verletzungsverfahren (LG)
     DE_INF_OLG, DE_NULL_BGH, DPMA_OPP, DPMA_BPATG_BESCHWERDE,
     UPC_COST_APPEAL, UPC_APP_ORDERS, DPMA_BGH_RB, DE_INF_BGH —
     same -verfahren standardisation.

3. legal_source for rev.defence × UPC_REV: was NULL, leaking the
   internal local_code 'rev.defence' to the UI. Set to UPC.RoP.49.1
   (Defence to Application for Revocation, R.49.1).

4. Frontend renderPill no longer falls back to rule_local_code when
   legal_source is missing — the source span just collapses, so no
   internal slug ever shows up as a "citation".

5. Quick-pick chips refactored to a slug-based array (QUICK_CHIPS) in
   fristenrechner.tsx, single source of truth for both fork-shortcut
   and B2-search-bar rows. Each chip carries data-chip-name-de /
   data-chip-name-en; relabelChips() rewrites visible text per active
   language. Dropped the duplicate "Statement of Defence" chip (same
   concept as "Klageerwiderung"). Each chip now maps to one concept
   slug — Klageerwiderung→statement-of-defence, Berufung→notice-of-
   appeal, Einspruch→opposition, Replik→reply-to-defence,
   Beschwerde→nichtzulassungsbeschwerde, Schadensbemessung→
   application-for-determination-of-damages, Wiedereinsetzung→
   wiedereinsetzung.

Migration 051 uses RAISE WARNING (not EXCEPTION) on coverage gates
per the 049 outage lesson — partial-migration recovery beats whole-
transaction failure. Matview rebuild stays inside the transaction;
RefreshSearchView() on next boot is a cheap no-op.
This commit is contained in:
m
2026-05-05 11:53:13 +02:00
parent b32cfed37d
commit 63eb5bde6f
5 changed files with 412 additions and 43 deletions

View File

@@ -1397,7 +1397,10 @@ function renderPill(pill: SearchPill, lang: "de" | "en"): string {
const procLabel = pill.proceeding
? (lang === "en" && pill.proceeding.name_en ? pill.proceeding.name_en : pill.proceeding.name_de)
: "";
const sourceLabel = pill.legal_source_display || pill.legal_source || pill.rule_local_code;
// t-paliad-134: never fall back to rule_local_code — that's an
// internal slug like "rev.defence" / "inf.decision" and leaks
// implementation detail to the UI when legal_source is unset.
const sourceLabel = pill.legal_source_display || pill.legal_source || "";
const ruleName = lang === "en" && pill.rule_name_en ? pill.rule_name_en : pill.rule_name_de;
const partyLabel = partyLabelFor(pill.party);
@@ -1408,6 +1411,9 @@ function renderPill(pill: SearchPill, lang: "de" | "en"): string {
? `<span class="fristen-pill-proc">${escHtml(procLabel)}</span>`
: `<span class="fristen-pill-proc fristen-pill-proc--cross">${escHtml(t("deadlines.search.pills.cross_cutting"))}</span>`;
const partyHtml = partyLabel ? `<span class="fristen-pill-party fristen-pill-party--${escAttr(pill.party)}">${escHtml(partyLabel)}</span>` : "";
const sourceHtml = sourceLabel
? `<span class="fristen-pill-source">${escHtml(sourceLabel)}</span>`
: "";
// data-* attributes carry everything the click handler needs to drill in
// without re-parsing JSON. drill_url is the canonical fallback (used when
@@ -1423,7 +1429,7 @@ function renderPill(pill: SearchPill, lang: "de" | "en"): string {
<a href="${escAttr(pill.drill_url)}" class="fristen-pill" ${dataAttrs}>
${procHtml}
<span class="fristen-pill-rule">${escHtml(ruleName)}</span>
<span class="fristen-pill-source">${escHtml(sourceLabel)}</span>
${sourceHtml}
${durationHtml}
${partyHtml}
</a>`;
@@ -1517,6 +1523,36 @@ function readInitialSearchQuery(): string {
return new URLSearchParams(window.location.search).get("q") || "";
}
// Quick-pick chips (t-paliad-134) carry both DE and EN labels via
// data-chip-name-de / data-chip-name-en attributes. relabelChips
// rewrites the visible text to match the active language; chipQueryFor
// returns the active-language label for use as the search query.
function relabelChips() {
const lang = getLang();
document.querySelectorAll<HTMLButtonElement>(".fristen-search-chip").forEach((chip) => {
const de = chip.dataset.chipNameDe;
const en = chip.dataset.chipNameEn;
if (!de && !en) return; // legacy chip without slug-based labels
const label = lang === "en" ? (en || de || "") : (de || en || "");
if (label && chip.textContent?.trim() !== label) {
chip.textContent = label;
}
// data-q kept in sync so existing click paths (e.g. fork-chip path)
// see the right query string without needing a chip-aware fallback.
chip.dataset.q = label;
});
}
function chipQueryFor(chip: HTMLButtonElement): string {
const lang = getLang();
const de = chip.dataset.chipNameDe;
const en = chip.dataset.chipNameEn;
if (de || en) {
return lang === "en" ? (en || de || "") : (de || en || "");
}
return chip.dataset.q || chip.textContent || "";
}
// ----- Wiring ----------------------------------------------------------------
function initSearch() {
@@ -1555,16 +1591,20 @@ function initSearch() {
chips.addEventListener("click", (e) => {
const target = (e.target as HTMLElement).closest<HTMLButtonElement>(".fristen-search-chip");
if (!target) return;
const q = target.dataset.q || target.textContent || "";
// Slug-based chips (t-paliad-134) carry both labels and use the
// active language. Legacy chips (no slug) fall back to data-q.
const q = chipQueryFor(target);
input.value = q;
input.focus();
scheduleSearch(0);
});
relabelChips();
wirePillClicks(results);
// Re-render on language flip so card / pill labels follow the active locale.
onLangChange(() => {
relabelChips();
const q = input.value.trim();
if (q !== "") scheduleSearch(0);
});
@@ -1686,6 +1726,8 @@ function navigateToPathway(path: Pathway, mode?: BMode) {
}
function initPathwayFork() {
// Set chip labels to active language before user sees them.
relabelChips();
// Initial render from URL (or saved preference if URL is bare).
const initial = readPathwayFromURL();
const initialMode = readBModeFromURL();
@@ -1727,7 +1769,7 @@ function initPathwayFork() {
// Quick-pick chips on the fork shortcut row → jump straight to Pathway B + filter mode + prefilled query.
document.querySelectorAll<HTMLButtonElement>("#fristen-fork-chips .fristen-search-chip").forEach((chip) => {
chip.addEventListener("click", () => {
const q = chip.dataset.q || "";
const q = chipQueryFor(chip);
const url = new URL(window.location.href);
url.searchParams.set("path", "b");
url.searchParams.set("mode", "filter");

View File

@@ -18,6 +18,40 @@ function proceedingBtn(p: ProceedingDef): string {
);
}
// Quick-pick chip definition. Each chip targets ONE deadline_concepts
// slug — clicking sets the search query to the concept's name in the
// active language so trigram search lands on the right concept card.
// Single source of truth for both fork-shortcut and B2-search-bar
// chip rows. Dedup invariant: no two chips share a slug. Label flips
// per language via the chip wiring in client/fristenrechner.ts.
interface QuickChip {
slug: string;
name_de: string;
name_en: string;
}
const QUICK_CHIPS: QuickChip[] = [
{ slug: "statement-of-defence", name_de: "Klageerwiderung", name_en: "Statement of Defence" },
{ slug: "notice-of-appeal", name_de: "Berufungsschrift", name_en: "Notice of Appeal" },
{ slug: "opposition", name_de: "Einspruchsfrist", name_en: "Opposition" },
{ slug: "reply-to-defence", name_de: "Replik", name_en: "Reply to Defence" },
{ slug: "nichtzulassungsbeschwerde", name_de: "Nichtzulassungsbeschwerde", name_en: "Non-admission Appeal (NZB)" },
{ slug: "application-for-determination-of-damages",name_de: "Antrag auf Schadensbemessung", name_en: "Application for Determination of Damages" },
{ slug: "wiedereinsetzung", name_de: "Wiedereinsetzung", name_en: "Re-establishment of Rights" },
];
function quickChip(c: QuickChip): string {
return (
<button type="button" className="fristen-search-chip"
data-chip-slug={c.slug}
data-chip-name-de={c.name_de}
data-chip-name-en={c.name_en}
data-q={c.name_de}>
{c.name_de}
</button>
);
}
const UPC_TYPES: ProceedingDef[] = [
{ code: "UPC_INF", i18nKey: "deadlines.upc_inf", name: "Verletzungsverfahren" },
{ code: "UPC_REV", i18nKey: "deadlines.upc_rev", name: "Nichtigkeitsklage" },
@@ -103,12 +137,7 @@ export function renderFristenrechner(): string {
oder direkt zu einer Frist springen:
</div>
<div className="fristen-search-chips" id="fristen-fork-chips" role="group" aria-label="Schnellzugriff">
<button type="button" className="fristen-search-chip" data-q="Klageerwiderung">Klageerwiderung</button>
<button type="button" className="fristen-search-chip" data-q="Berufung">Berufung</button>
<button type="button" className="fristen-search-chip" data-q="Einspruch">Einspruch</button>
<button type="button" className="fristen-search-chip" data-q="Replik">Replik</button>
<button type="button" className="fristen-search-chip" data-q="Beschwerde">Beschwerde</button>
<button type="button" className="fristen-search-chip" data-q="Wiedereinsetzung">Wiedereinsetzung</button>
{QUICK_CHIPS.map((c) => quickChip(c))}
</div>
</div>
</div>
@@ -174,14 +203,7 @@ export function renderFristenrechner(): string {
</div>
<div className="fristen-search-chips" id="fristen-search-chips" role="group" aria-label="Schnellzugriff">
<span className="fristen-search-chips-label" data-i18n="deadlines.search.chips.label">Schnellzugriff:</span>
<button type="button" className="fristen-search-chip" data-q="Klageerwiderung">Klageerwiderung</button>
<button type="button" className="fristen-search-chip" data-q="Berufung">Berufung</button>
<button type="button" className="fristen-search-chip" data-q="Einspruch">Einspruch</button>
<button type="button" className="fristen-search-chip" data-q="Replik">Replik</button>
<button type="button" className="fristen-search-chip" data-q="Beschwerde">Beschwerde</button>
<button type="button" className="fristen-search-chip" data-q="Statement of Defence">Statement of Defence</button>
<button type="button" className="fristen-search-chip" data-q="Schadensbemessung">Schadensbemessung</button>
<button type="button" className="fristen-search-chip" data-q="Wiedereinsetzung">Wiedereinsetzung</button>
{QUICK_CHIPS.map((c) => quickChip(c))}
</div>
{/* Forum filter row — populated by Phase D. */}
<div className="fristen-forum-filter" id="fristen-forum-filter" hidden>

View File

@@ -0,0 +1,83 @@
-- Down: restore prior names + drop display_order column.
BEGIN;
UPDATE paliad.proceeding_types SET name = 'Nichtigkeitsklage' WHERE code = 'UPC_REV';
UPDATE paliad.proceeding_types SET name = 'Berufung' WHERE code = 'UPC_APP';
UPDATE paliad.proceeding_types SET name = 'Verletzungsklage (LG)' WHERE code = 'DE_INF';
UPDATE paliad.proceeding_types SET name = 'Berufung OLG (Verletzung)' WHERE code = 'DE_INF_OLG';
UPDATE paliad.proceeding_types SET name = 'Berufung BGH (Nichtigkeit)' WHERE code = 'DE_NULL_BGH';
UPDATE paliad.proceeding_types SET name = 'Beschwerde BPatG (DPMA)' WHERE code = 'DPMA_BPATG_BESCHWERDE';
UPDATE paliad.proceeding_types SET name = 'Einspruch DPMA' WHERE code = 'DPMA_OPP';
UPDATE paliad.proceeding_types SET name = 'Berufung Kostenentscheidung' WHERE code = 'UPC_COST_APPEAL';
UPDATE paliad.proceeding_types SET name = 'Berufung Anordnungen' WHERE code = 'UPC_APP_ORDERS';
UPDATE paliad.proceeding_types SET name = 'Rechtsbeschwerde BGH' WHERE code = 'DPMA_BGH_RB';
UPDATE paliad.proceeding_types SET name = 'Revision/NZB BGH (Verletzung)' WHERE code = 'DE_INF_BGH';
UPDATE paliad.deadline_rules dr
SET legal_source = NULL,
rule_code = NULL
FROM paliad.proceeding_types p
WHERE dr.proceeding_type_id = p.id
AND p.code = 'UPC_REV'
AND dr.code = 'rev.defence'
AND dr.legal_source = 'UPC.RoP.49.1';
-- Recreate matview without proceeding_display_order, then drop column.
DROP MATERIALIZED VIEW IF EXISTS paliad.deadline_search;
CREATE MATERIALIZED VIEW paliad.deadline_search AS
SELECT
'rule'::text AS kind,
'r:' || dr.id::text AS row_key,
dc.id AS concept_id,
dc.slug AS concept_slug,
dc.name_de AS concept_name_de,
dc.name_en AS concept_name_en,
dc.description AS concept_description,
dc.aliases AS concept_aliases,
dc.party AS concept_party,
dc.category AS concept_category,
dc.sort_order AS concept_sort_order,
dr.id AS rule_id,
NULL::bigint AS trigger_event_id,
pt.code AS proceeding_code,
pt.name AS proceeding_name_de,
pt.name_en AS proceeding_name_en,
pt.jurisdiction AS jurisdiction,
dr.code AS rule_local_code,
dr.name AS rule_name_de,
dr.name_en AS rule_name_en,
dr.legal_source AS legal_source,
dr.rule_code AS rule_code,
dr.duration_value,
dr.duration_unit,
dr.timing,
COALESCE(dr.primary_party, dc.party) AS effective_party
FROM paliad.deadline_rules dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
JOIN paliad.deadline_concepts dc ON dc.id = dr.concept_id
WHERE dr.is_active AND pt.is_active AND pt.category = 'fristenrechner'
UNION ALL
SELECT
'trigger'::text, 't:' || te.id::text, dc.id, dc.slug, dc.name_de,
dc.name_en, dc.description, dc.aliases, dc.party, dc.category,
dc.sort_order, NULL::uuid, te.id, NULL::text, NULL::text, NULL::text,
'cross-cutting'::text, te.code, te.name_de, te.name, NULL::text,
NULL::text, NULL::int, NULL::text, NULL::text, dc.party
FROM paliad.trigger_events te
JOIN paliad.deadline_concepts dc ON dc.slug = te.concept_id
WHERE te.is_active;
CREATE UNIQUE INDEX deadline_search_row_key ON paliad.deadline_search (row_key);
CREATE INDEX deadline_search_concept_id ON paliad.deadline_search (concept_id);
CREATE INDEX deadline_search_proc_code ON paliad.deadline_search (proceeding_code);
CREATE INDEX deadline_search_legal_source ON paliad.deadline_search (legal_source);
CREATE INDEX deadline_search_effective_party ON paliad.deadline_search (effective_party);
CREATE INDEX deadline_search_legal_source_trgm ON paliad.deadline_search USING gin (legal_source gin_trgm_ops);
CREATE INDEX deadline_search_concept_de_trgm ON paliad.deadline_search USING gin (concept_name_de gin_trgm_ops);
CREATE INDEX deadline_search_concept_en_trgm ON paliad.deadline_search USING gin (concept_name_en gin_trgm_ops);
CREATE INDEX deadline_search_rule_de_trgm ON paliad.deadline_search USING gin (rule_name_de gin_trgm_ops);
CREATE INDEX deadline_search_rule_en_trgm ON paliad.deadline_search USING gin (rule_name_en gin_trgm_ops);
CREATE INDEX deadline_search_rule_code_trgm ON paliad.deadline_search USING gin (rule_code gin_trgm_ops);
ALTER TABLE paliad.proceeding_types DROP COLUMN IF EXISTS display_order;
COMMIT;

View File

@@ -0,0 +1,223 @@
-- t-paliad-134 add-on (m 2026-05-05): pill ordering by real-world
-- frequency + name standardisation + legal-source fix for rev.defence.
--
-- Three changes on paliad.proceeding_types and paliad.deadline_rules:
--
-- 1. New column display_order: secondary sort key for proceeding-pill
-- ordering inside concept cards. sort_order is per-jurisdiction
-- grouping (used in the wizard); display_order reflects pill
-- likelihood (most common first). m's spec values, lower = more
-- common; gap-numbered so future additions slot in.
--
-- 2. name standardisation — m wants -verfahren (process) consistently.
-- 'Nichtigkeitsklage' → 'Nichtigkeitsverfahren' is the explicit
-- example; Berufung/Beschwerde/Einspruch get the same treatment
-- where the row describes the multi-step process rather than a
-- single submission.
--
-- 3. legal_source for the rev.defence rule (UPC_REV statement-of-
-- defence): was NULL, which leaked the internal local_code 'rev.
-- defence' to the UI as the citation. Should be 'UPC.RoP.49.1'.
-- rule_code mirrors that as 'RoP.49.1' for trigram search hits.
--
-- Validation gates use RAISE WARNING (not EXCEPTION) per the 049 outage
-- lesson — partial-migration recovery beats whole-transaction failure.
BEGIN;
-- ---------------------------------------------------------------------
-- 1. display_order column + backfill
-- ---------------------------------------------------------------------
ALTER TABLE paliad.proceeding_types
ADD COLUMN IF NOT EXISTS display_order int NOT NULL DEFAULT 999;
COMMENT ON COLUMN paliad.proceeding_types.display_order IS
'Pill-ordering rank by real-world frequency (lower = shown first). '
'Independent of sort_order, which groups by jurisdiction for the '
'Verfahrensablauf wizard. Default 999 = appears at the end.';
UPDATE paliad.proceeding_types SET display_order = CASE code
-- Common, listed first
WHEN 'UPC_INF' THEN 10
WHEN 'DE_INF' THEN 20
WHEN 'UPC_REV' THEN 30
WHEN 'DE_NULL' THEN 40
WHEN 'EPA_OPP' THEN 50
WHEN 'EPA_APP' THEN 60
WHEN 'DPMA_OPP' THEN 70
WHEN 'UPC_APP' THEN 80
WHEN 'DE_INF_OLG' THEN 90
WHEN 'DE_INF_BGH' THEN 100
WHEN 'DE_NULL_BGH' THEN 110
-- Less common, pushed back
WHEN 'EP_GRANT' THEN 200
WHEN 'DPMA_BPATG_BESCHWERDE' THEN 210
WHEN 'DPMA_BGH_RB' THEN 220
WHEN 'UPC_APP_ORDERS' THEN 230
-- Rare, last block
WHEN 'UPC_DAMAGES' THEN 900
WHEN 'UPC_DISCOVERY' THEN 910
WHEN 'UPC_PI' THEN 920
WHEN 'UPC_COST_APPEAL' THEN 930
-- Legacy v0 codes (INF/REV/CCR/APM/APP/AMD/ZPO_CIVIL): keep at 999.
ELSE display_order
END;
-- Soft check: warn (don't fail) if any active proceeding still has the
-- default. m wants visibility, not transactional gate-keeping.
DO $$
DECLARE
n_default int;
BEGIN
SELECT count(*)
INTO n_default
FROM paliad.proceeding_types
WHERE is_active = true
AND display_order = 999
AND code IN ('UPC_INF','UPC_REV','UPC_PI','UPC_APP','UPC_DAMAGES',
'UPC_DISCOVERY','UPC_COST_APPEAL','UPC_APP_ORDERS',
'DE_INF','DE_NULL','DE_INF_OLG','DE_INF_BGH','DE_NULL_BGH',
'EPA_OPP','EPA_APP','EP_GRANT',
'DPMA_OPP','DPMA_BPATG_BESCHWERDE','DPMA_BGH_RB');
IF n_default > 0 THEN
RAISE WARNING 't-paliad-134: % active proceeding_types in the v3 set still have default display_order=999. Frequency-sort will push them to the end.', n_default;
END IF;
END$$;
-- ---------------------------------------------------------------------
-- 2. -klage / -verfahren standardisation in name (DE)
-- ---------------------------------------------------------------------
-- Each row keyed by code so renames are idempotent across re-runs.
UPDATE paliad.proceeding_types SET name = 'Nichtigkeitsverfahren' WHERE code = 'UPC_REV';
UPDATE paliad.proceeding_types SET name = 'Berufungsverfahren' WHERE code = 'UPC_APP';
UPDATE paliad.proceeding_types SET name = 'Verletzungsverfahren (LG)' WHERE code = 'DE_INF';
UPDATE paliad.proceeding_types SET name = 'Berufungsverfahren OLG (Verletzung)' WHERE code = 'DE_INF_OLG';
UPDATE paliad.proceeding_types SET name = 'Berufungsverfahren BGH (Nichtigkeit)' WHERE code = 'DE_NULL_BGH';
UPDATE paliad.proceeding_types SET name = 'Beschwerdeverfahren BPatG (DPMA)' WHERE code = 'DPMA_BPATG_BESCHWERDE';
UPDATE paliad.proceeding_types SET name = 'Einspruchsverfahren DPMA' WHERE code = 'DPMA_OPP';
UPDATE paliad.proceeding_types SET name = 'Berufungsverfahren Kosten' WHERE code = 'UPC_COST_APPEAL';
UPDATE paliad.proceeding_types SET name = 'Berufungsverfahren Anordnungen' WHERE code = 'UPC_APP_ORDERS';
UPDATE paliad.proceeding_types SET name = 'Rechtsbeschwerdeverfahren BGH' WHERE code = 'DPMA_BGH_RB';
UPDATE paliad.proceeding_types SET name = 'Revisions-/NZB-Verfahren BGH (Verletzung)' WHERE code = 'DE_INF_BGH';
-- ---------------------------------------------------------------------
-- 3. legal_source for the rev.defence rule (UPC_REV)
-- ---------------------------------------------------------------------
UPDATE paliad.deadline_rules dr
SET legal_source = 'UPC.RoP.49.1',
rule_code = 'RoP.49.1'
FROM paliad.proceeding_types p
WHERE dr.proceeding_type_id = p.id
AND p.code = 'UPC_REV'
AND dr.code = 'rev.defence'
AND dr.legal_source IS NULL;
DO $$
DECLARE
n_unfixed int;
BEGIN
SELECT count(*)
INTO n_unfixed
FROM paliad.deadline_rules dr
JOIN paliad.proceeding_types p ON p.id = dr.proceeding_type_id
WHERE p.code = 'UPC_REV' AND dr.code = 'rev.defence' AND dr.legal_source IS NULL;
IF n_unfixed > 0 THEN
RAISE WARNING 't-paliad-134: rev.defence × UPC_REV still has NULL legal_source after backfill (% rows).', n_unfixed;
END IF;
END$$;
-- ---------------------------------------------------------------------
-- 4. Matview rebuild — surface proceeding_display_order so the search
-- service can ORDER BY it directly. Drop + recreate keeps the
-- column list explicit; existing query patterns and all indexes
-- are reproduced verbatim with the one new column added.
-- ---------------------------------------------------------------------
DROP MATERIALIZED VIEW IF EXISTS paliad.deadline_search;
CREATE MATERIALIZED VIEW paliad.deadline_search AS
SELECT
'rule'::text AS kind,
'r:' || dr.id::text AS row_key,
dc.id AS concept_id,
dc.slug AS concept_slug,
dc.name_de AS concept_name_de,
dc.name_en AS concept_name_en,
dc.description AS concept_description,
dc.aliases AS concept_aliases,
dc.party AS concept_party,
dc.category AS concept_category,
dc.sort_order AS concept_sort_order,
dr.id AS rule_id,
NULL::bigint AS trigger_event_id,
pt.code AS proceeding_code,
pt.name AS proceeding_name_de,
pt.name_en AS proceeding_name_en,
pt.jurisdiction AS jurisdiction,
pt.display_order AS proceeding_display_order,
dr.code AS rule_local_code,
dr.name AS rule_name_de,
dr.name_en AS rule_name_en,
dr.legal_source AS legal_source,
dr.rule_code AS rule_code,
dr.duration_value,
dr.duration_unit,
dr.timing,
COALESCE(dr.primary_party, dc.party) AS effective_party
FROM paliad.deadline_rules dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
JOIN paliad.deadline_concepts dc ON dc.id = dr.concept_id
WHERE dr.is_active
AND pt.is_active
AND pt.category = 'fristenrechner'
UNION ALL
SELECT
'trigger'::text,
't:' || te.id::text,
dc.id,
dc.slug,
dc.name_de,
dc.name_en,
dc.description,
dc.aliases,
dc.party,
dc.category,
dc.sort_order,
NULL::uuid,
te.id,
NULL::text,
NULL::text,
NULL::text,
'cross-cutting'::text,
9999::int AS proceeding_display_order,
te.code,
te.name_de,
te.name,
NULL::text,
NULL::text,
NULL::int,
NULL::text,
NULL::text,
dc.party
FROM paliad.trigger_events te
JOIN paliad.deadline_concepts dc ON dc.slug = te.concept_id
WHERE te.is_active;
CREATE UNIQUE INDEX deadline_search_row_key ON paliad.deadline_search (row_key);
CREATE INDEX deadline_search_concept_id ON paliad.deadline_search (concept_id);
CREATE INDEX deadline_search_proc_code ON paliad.deadline_search (proceeding_code);
CREATE INDEX deadline_search_legal_source ON paliad.deadline_search (legal_source);
CREATE INDEX deadline_search_effective_party ON paliad.deadline_search (effective_party);
CREATE INDEX deadline_search_legal_source_trgm ON paliad.deadline_search USING gin (legal_source gin_trgm_ops);
CREATE INDEX deadline_search_concept_de_trgm ON paliad.deadline_search USING gin (concept_name_de gin_trgm_ops);
CREATE INDEX deadline_search_concept_en_trgm ON paliad.deadline_search USING gin (concept_name_en gin_trgm_ops);
CREATE INDEX deadline_search_rule_de_trgm ON paliad.deadline_search USING gin (rule_name_de gin_trgm_ops);
CREATE INDEX deadline_search_rule_en_trgm ON paliad.deadline_search USING gin (rule_name_en gin_trgm_ops);
CREATE INDEX deadline_search_rule_code_trgm ON paliad.deadline_search USING gin (rule_code gin_trgm_ops);
-- The fresh matview is populated by the implicit initial scan above. The
-- next RefreshSearchView() (called from cmd/server/main.go after this
-- migration runner) is a no-op refresh and stays cheap.
COMMIT;

View File

@@ -160,6 +160,12 @@ type Pill struct {
Duration *PillDuration `json:"duration,omitempty"`
Party string `json:"party"`
DrillURL string `json:"drill_url"`
// t-paliad-134: server-assigned ordering hint by real-world
// proceeding frequency. Frontend doesn't need to read this — the
// server already sorts before sending — but it's exposed so the
// frontend can stable-sort if it interleaves cards from multiple
// requests.
ProceedingDisplayOrder int `json:"proceeding_display_order"`
}
// rankRow is the per-concept score row from query 1.
@@ -188,6 +194,10 @@ type pillRow struct {
ProceedingNameDE sql.NullString `db:"proceeding_name_de"`
ProceedingNameEN sql.NullString `db:"proceeding_name_en"`
Jurisdiction string `db:"jurisdiction"`
// t-paliad-134: pill ordering by real-world frequency. Lower =
// shown first inside each concept card. 9999 for trigger pills
// (no proceeding context).
ProceedingDisplayOrder int `db:"proceeding_display_order"`
RuleLocalCode string `db:"rule_local_code"`
RuleNameDE string `db:"rule_name_de"`
RuleNameEN string `db:"rule_name_en"`
@@ -509,6 +519,7 @@ SELECT
s.proceeding_name_de,
s.proceeding_name_en,
s.jurisdiction,
s.proceeding_display_order,
s.rule_local_code,
s.rule_name_de,
s.rule_name_en,
@@ -529,7 +540,7 @@ SELECT
OR s.kind = 'trigger'
OR s.proceeding_code = ANY($5::text[])
)
ORDER BY s.concept_id, s.kind, s.proceeding_code NULLS LAST, s.rule_local_code
ORDER BY s.concept_id, s.kind, s.proceeding_display_order, s.proceeding_code NULLS LAST, s.rule_local_code
`
var rows []pillRow
if err := s.db.SelectContext(ctx, &rows, sqlText,
@@ -595,11 +606,12 @@ func assembleCards(ranks []rankRow, pills []pillRow) ([]ConceptCard, int) {
func buildPill(p pillRow) Pill {
pill := Pill{
Kind: p.Kind,
RuleLocalCode: p.RuleLocalCode,
RuleNameDE: p.RuleNameDE,
RuleNameEN: p.RuleNameEN,
Party: p.EffectiveParty,
Kind: p.Kind,
RuleLocalCode: p.RuleLocalCode,
RuleNameDE: p.RuleNameDE,
RuleNameEN: p.RuleNameEN,
Party: p.EffectiveParty,
ProceedingDisplayOrder: p.ProceedingDisplayOrder,
}
if p.RuleID.Valid {
pill.RuleID = &p.RuleID.String
@@ -655,30 +667,17 @@ func pillDrillURL(p pillRow) string {
return "/tools/fristenrechner"
}
// pillSortKey orders pills inside a card: rule pills before triggers,
// jurisdictions in HLC working order (UPC > EU > DE > DPMA > other),
// then by rule_local_code.
// pillSortKey orders pills inside a card. Rule pills before triggers;
// inside rules, by proceeding_display_order (real-world frequency,
// t-paliad-134) ascending; ties broken by rule_local_code so the order
// is stable across runs.
func pillSortKey(p Pill) string {
kindRank := "1"
if p.Kind == "trigger" {
kindRank = "2"
}
jurRank := "9"
if p.Proceeding != nil {
switch p.Proceeding.Jurisdiction {
case "UPC":
jurRank = "1"
case "EU":
jurRank = "2"
case "DE":
jurRank = "3"
case "DPMA":
jurRank = "4"
case "cross-cutting":
jurRank = "8"
}
}
return kindRank + jurRank + p.RuleLocalCode
// Zero-pad to 5 digits so lexicographic compare matches numeric.
return fmt.Sprintf("%s%05d%s", kindRank, p.ProceedingDisplayOrder, p.RuleLocalCode)
}
func nullable(v string) *string {