Merge: t-paliad-165 — Regel ↔ Typ collapse via auto-link on the deadline create form

Two slices on mai/noether/collapse-regel-typ-on:

  0c12644  feat(deadline-rules): expose concept's canonical event_type per rule
  1e97ecc  feat(deadlines/new): auto-link Typ to Regel's concept

What ships:

- New junction paliad.deadline_concept_event_types maps every
  paliad.deadline_concepts row to its canonical paliad.event_types
  row(s). Many-to-many for concepts with multiple legitimate variants
  (statement-of-defence ↔ base + with_ccr + no_ccr; opposition across
  EPO + DPMA). Exactly one row per concept marked is_default = true
  by a partial unique index — that is the row the deadline form
  auto-fills with.

- Backend: paliad.deadline_rules_with_concept_event_type view + the
  deadline-rules read path now expose the rule's default concept
  event_type so the form has the auto-fill target without an extra
  round-trip.

- Frontend deadline create / edit form: when the user picks a Regel,
  the Typ chip auto-fills with the rule's concept's default event_type.
  A small "vorgegeben durch Regel — überschreiben?" hint sits next to
  the chip so the auto-fill is visible. The user can override (free-
  text or pick a different type); the override is explicit, no
  blocking validation.

- Free-text Typ stays available — manual deadlines without a
  matching rule (e.g. "Call me" reminders) keep working as today.

Migration housekeeping
======================

noether authored her migration as 072 on her branch but main had
already taken 072 via minkowski's t-paliad-164 (paliad.projects.our_side).
Renumbered to 073 during merge resolution to resolve the same-number
collision. Added IF NOT EXISTS guards on CREATE TABLE / CREATE INDEX
for re-run safety (the seed INSERT already had ON CONFLICT DO NOTHING).

Live tracker bumped 72 → 73 in the same operation: both effects
(our_side column AND deadline_concept_event_types table) were
applied to live during dev (each worker against the same DB), so
the tracker advance reflects schema reality. Next deploy sees
tracker=73 with file 073 present and has nothing to apply.

Refs m/paliad#18.
This commit is contained in:
m
2026-05-08 22:01:44 +02:00
8 changed files with 284 additions and 0 deletions

View File

@@ -19,8 +19,22 @@ interface DeadlineRule {
name: string;
name_en: string;
rule_code?: string;
// t-paliad-165 — canonical event_type for this rule's concept,
// hydrated server-side from paliad.deadline_concept_event_types.
// Drives auto-fill of the Typ chip when the user picks this rule.
concept_default_event_type_id?: string | null;
}
// Rules indexed by id so the Regel-change handler can look up the
// concept's canonical event_type without re-fetching.
let rulesByID = new Map<string, DeadlineRule>();
// Last event_type the rule auto-filled. Tracked so we can tell whether
// the picker still reflects the rule's suggestion (replace silently on
// new rule pick) or whether the user has manually edited (leave alone,
// surface the mismatch warning instead).
let lastAutoFilledEventTypeID: string | null = null;
let preselectedProjectID = "";
function esc(s: string): string {
@@ -71,6 +85,7 @@ async function loadRules() {
const resp = await fetch("/api/deadline-rules");
if (!resp.ok) return;
const rules: DeadlineRule[] = await resp.json();
rulesByID = new Map(rules.map((r) => [r.id, r]));
const opts: string[] = [
`<option value="" data-i18n="deadlines.field.rule.none">${esc(t("deadlines.field.rule.none"))}</option>`,
];
@@ -85,6 +100,65 @@ async function loadRules() {
}
}
// t-paliad-165 — refresh the autofill hint + mismatch warning state. The
// hint shows when the picker exactly matches the current rule's
// suggestion (so the chip is "vorgegeben durch Regel"). The warning
// shows when the user has picked event_types that don't include the
// rule's canonical default (override is fine, just call it out).
function refreshRuleHints(): void {
const hint = document.getElementById("deadline-event-type-rule-hint");
const warn = document.getElementById("deadline-event-type-rule-mismatch");
if (!hint || !warn) return;
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement | null)?.value || "";
const rule = ruleID ? rulesByID.get(ruleID) : undefined;
const expected = rule?.concept_default_event_type_id ?? null;
const picked = eventTypePicker?.getIDs() ?? [];
if (!ruleID || !expected) {
hint.style.display = "none";
warn.style.display = "none";
return;
}
if (picked.length === 1 && picked[0] === expected) {
hint.style.display = "";
warn.style.display = "none";
return;
}
hint.style.display = "none";
warn.style.display = picked.includes(expected) ? "none" : "";
}
// applyRuleAutoFill replaces the picker silently when it still reflects
// the previous rule's suggestion (or is empty); leaves a manually-edited
// picker alone. Called whenever the Regel select changes.
function applyRuleAutoFill(): void {
if (!eventTypePicker) return;
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement | null)?.value || "";
const rule = ruleID ? rulesByID.get(ruleID) : undefined;
const expected = rule?.concept_default_event_type_id ?? null;
const current = eventTypePicker.getIDs();
const pickerStillReflectsLastSuggestion =
lastAutoFilledEventTypeID !== null &&
current.length === 1 &&
current[0] === lastAutoFilledEventTypeID;
const pickerIsEmpty = current.length === 0;
if (expected) {
if (pickerIsEmpty || pickerStillReflectsLastSuggestion) {
eventTypePicker.setIDs([expected]);
lastAutoFilledEventTypeID = expected;
}
} else if (pickerStillReflectsLastSuggestion) {
// New rule has no canonical event_type — clear the stale auto-fill
// so the picker doesn't carry a hint from the old rule.
eventTypePicker.setIDs([]);
lastAutoFilledEventTypeID = null;
}
refreshRuleHints();
}
function initBackLinks() {
if (preselectedProjectID) {
const back = document.getElementById("deadline-new-back") as HTMLAnchorElement;
@@ -233,8 +307,15 @@ document.addEventListener("DOMContentLoaded", async () => {
if (pickerHost) {
eventTypePicker = attachEventTypePicker(pickerHost, {
currentUserAdmin,
onChange: () => refreshRuleHints(),
});
}
// t-paliad-165 — Regel change auto-fills the Typ chip from the rule's
// concept's canonical event_type, when the picker hasn't been
// manually edited away from the previous rule's suggestion.
document.getElementById("deadline-rule")?.addEventListener("change", () => {
applyRuleAutoFill();
});
// Wire approval-hint refresh: on first render + on project change.
void refreshApprovalHint();
document.getElementById("deadline-project")?.addEventListener("change", () => {

View File

@@ -741,6 +741,8 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.field.due": "F\u00e4lligkeitsdatum",
"deadlines.field.rule": "Regel (optional)",
"deadlines.field.rule.none": "Keine Regel",
"deadlines.field.rule.autofill": "Typ vorgegeben durch Regel — entfernen, um zu überschreiben.",
"deadlines.field.rule.mismatch": "Hinweis: Typ widerspricht Regel — Sie haben den Typ überschrieben.",
"deadlines.field.notes": "Notizen (optional)",
"deadlines.field.notes.placeholder": "Hinweise, Verweise, n\u00e4chste Schritte\u2026",
"deadlines.error.required": "Akte, Titel und F\u00e4lligkeitsdatum sind Pflichtfelder.",
@@ -2889,6 +2891,8 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.field.due": "Due date",
"deadlines.field.rule": "Rule (optional)",
"deadlines.field.rule.none": "No rule",
"deadlines.field.rule.autofill": "Type set by rule — remove to override.",
"deadlines.field.rule.mismatch": "Note: type contradicts rule — you have overridden the type.",
"deadlines.field.notes": "Notes (optional)",
"deadlines.field.notes.placeholder": "References, hints, next steps\u2026",
"deadlines.error.required": "Matter, title and due date are required.",

View File

@@ -58,6 +58,26 @@ export function renderDeadlinesNew(): string {
<div className="form-field">
<label data-i18n="deadlines.field.event_type">Typ (optional)</label>
<div id="deadline-event-types" className="event-type-picker-host" />
{/* t-paliad-165 — soft hint when the Regel auto-populated
the chip, and a soft warning when the user picked a
Regel + Typ combination that contradicts the rule's
concept. Both rendered yellow, never blocking. */}
<p
className="form-hint"
id="deadline-event-type-rule-hint"
style="display:none"
data-i18n="deadlines.field.rule.autofill"
>
Typ vorgegeben durch Regel &mdash; entfernen, um zu &uuml;berschreiben.
</p>
<p
className="form-hint form-hint--warning"
id="deadline-event-type-rule-mismatch"
style="display:none"
data-i18n="deadlines.field.rule.mismatch"
>
Hinweis: Typ widerspricht Regel &mdash; Sie haben den Typ &uuml;berschrieben.
</p>
</div>
<div className="form-field-row">

View File

@@ -820,6 +820,8 @@ export type I18nKey =
| "deadlines.field.notes"
| "deadlines.field.notes.placeholder"
| "deadlines.field.rule"
| "deadlines.field.rule.autofill"
| "deadlines.field.rule.mismatch"
| "deadlines.field.rule.none"
| "deadlines.field.title"
| "deadlines.field.title.placeholder"

View File

@@ -0,0 +1,2 @@
-- t-paliad-165 down: drop the concept→event_type junction.
DROP TABLE IF EXISTS paliad.deadline_concept_event_types;

View File

@@ -0,0 +1,113 @@
-- t-paliad-165: junction paliad.deadline_concept_event_types — maps each
-- deadline_concept to the canonical paliad.event_types row(s) that
-- represent it on the Typ chip cluster of the deadline create form.
--
-- Why this exists
-- ---------------
-- The deadline create form (/projects/{id}/deadlines/new and the global
-- /deadlines/new) lets the user pick a Regel (paliad.deadline_rules) AND
-- independently pick a Typ (paliad.event_types). They are decoupled, so a
-- user can save a deadline whose Regel is `damages.rejoin — Duplik` but
-- Typ is `Klageerwiderung` — two different legal events. m hit this
-- contradiction during 2026-05-08 dogfooding (Gitea m/paliad#18).
--
-- Each rule already carries paliad.deadline_rules.concept_id (mig 040),
-- so the rule knows what legal idea it represents. What was missing was
-- the canonical event_type for that concept. Slug-pattern heuristics are
-- unreliable (concept `notice-of-appeal` ↔ event_type
-- `upc_statement_of_appeal_2201`) and many concepts have multiple
-- candidate event_types (`statement-of-defence` ↔ base + with_ccr +
-- no_ccr); this junction makes the mapping explicit and curated.
--
-- Shape
-- -----
-- Many-to-many, so concepts that genuinely have several candidate types
-- (with_ccr / no_ccr / base; UPC + EPO + DPMA opposition) get one row
-- per type. is_default picks the single row the create-form auto-fills
-- when the user picks a Regel attached to this concept. The remaining
-- rows are reserved for future surfaces (e.g. Determinator save flow
-- might want to see all candidates) but the create-form only consumes
-- is_default for now.
--
-- Idempotent against re-seeds: the seed below uses ON CONFLICT DO
-- NOTHING so a second run after manual mapping additions doesn't blow
-- them away. Down migration drops the table entirely.
CREATE TABLE IF NOT EXISTS paliad.deadline_concept_event_types (
concept_id uuid NOT NULL
REFERENCES paliad.deadline_concepts(id) ON DELETE CASCADE,
event_type_id uuid NOT NULL
REFERENCES paliad.event_types(id) ON DELETE CASCADE,
is_default bool NOT NULL DEFAULT false,
sort_order int NOT NULL DEFAULT 100,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (concept_id, event_type_id)
);
COMMENT ON TABLE paliad.deadline_concept_event_types IS
'Junction mapping paliad.deadline_concepts → paliad.event_types. '
'Lets the deadline create form auto-populate the Typ chip when the '
'user picks a Regel — the rule''s concept points here for its '
'canonical event_type(s). Many-to-many for concepts with several '
'natural variants (with_ccr / no_ccr / base, EPO + DPMA opposition).';
COMMENT ON COLUMN paliad.deadline_concept_event_types.is_default IS
'Exactly one row per concept_id should be marked default — that is '
'the row the create-form chip cluster auto-fills with. Other rows '
'remain selectable from the picker as alternatives.';
CREATE UNIQUE INDEX IF NOT EXISTS deadline_concept_event_types_one_default
ON paliad.deadline_concept_event_types (concept_id)
WHERE is_default = true;
CREATE INDEX IF NOT EXISTS deadline_concept_event_types_event_type
ON paliad.deadline_concept_event_types (event_type_id);
-- ============================================================================
-- Seed: curated mapping for active concepts that drive existing rules.
--
-- Concepts without an obvious event_type counterpart (filing, grant,
-- decision, publication, communication-r71-3, search-report, the various
-- DE-only Begründung concepts) stay unmapped — auto-fill silently
-- skips them, leaving the user to pick a Typ manually as today.
-- Future migrations can fill those gaps as event_types are added.
-- ============================================================================
INSERT INTO paliad.deadline_concept_event_types (concept_id, event_type_id, is_default, sort_order)
SELECT dc.id, et.id, mapping.is_default, mapping.sort_order
FROM (VALUES
-- (concept_slug, event_type_slug, is_default, sort_order)
('application-for-cost-decision', 'upc_application_for_cost_decision', true, 10),
('application-for-determination-of-damages', 'upc_application_for_damages', true, 10),
('application-for-revocation', 'upc_statement_for_revocation', true, 10),
('application-for-provisional-measures', 'upc_protective_letter', true, 10),
('cost-decision', 'upc_decision_on_costs', true, 10),
('counterclaim-for-infringement', 'upc_counterclaim_for_infringement', true, 10),
('counterclaim-for-revocation', 'upc_counterclaim_for_revocation', true, 10),
('cross-appeal', 'upc_cross_appeal_2242a', true, 10),
('defence-to-application-to-amend', 'upc_defence_to_amend_patent', true, 10),
('defence-to-counterclaim-for-revocation', 'upc_defence_to_revocation', true, 10),
('notice-of-appeal', 'upc_statement_of_appeal_2201', true, 10),
('opposition', 'epo_opposition_filing', true, 10),
('opposition', 'dpma_opposition', false, 20),
('oral-hearing', 'upc_oral_hearing', true, 10),
('order', 'upc_case_management_order', true, 10),
('rejoinder', 'upc_rejoinder_to_reply', true, 10),
('reply-to-cross-appeal', 'upc_cross_appeal_2242a', true, 10),
('reply-to-defence', 'upc_reply_to_defence', true, 10),
('reply-to-defence-to-application-to-amend', 'upc_reply_to_defence_to_amend_patent', true, 10),
('reply-to-defence-to-counterclaim-for-revocation','upc_reply_to_defence_to_revocation', true, 10),
('request-for-examination', 'dpma_examination_request', true, 10),
('request-to-lay-open-books', 'upc_request_to_lay_open_books', true, 10),
('response-to-appeal', 'upc_grounds_of_appeal_2242a', true, 10),
('statement-of-claim', 'upc_statement_of_claim', true, 10),
('statement-of-defence', 'upc_statement_of_defence', true, 10),
('statement-of-defence', 'upc_statement_of_defence_with_ccr', false, 20),
('statement-of-defence', 'upc_statement_of_defence_no_ccr', false, 30),
('statement-of-grounds-of-appeal', 'upc_grounds_of_appeal_2242a', true, 10),
('statement-of-grounds-of-appeal', 'epo_appeal_grounds', false, 20)
) AS mapping(concept_slug, event_type_slug, is_default, sort_order)
JOIN paliad.deadline_concepts dc ON dc.slug = mapping.concept_slug
JOIN paliad.event_types et ON et.slug = mapping.event_type_slug
AND et.archived_at IS NULL
ON CONFLICT (concept_id, event_type_id) DO NOTHING;

View File

@@ -475,6 +475,12 @@ type DeadlineRule struct {
AltRuleCode *string `db:"alt_rule_code" json:"alt_rule_code,omitempty"`
AnchorAlt *string `db:"anchor_alt" json:"anchor_alt,omitempty"`
ConceptID *uuid.UUID `db:"concept_id" json:"concept_id,omitempty"`
// ConceptDefaultEventTypeID is the canonical paliad.event_types row for
// this rule's concept (joined via paliad.deadline_concept_event_types
// where is_default = true). Lets the deadline create form auto-populate
// the Typ chip when the user picks this rule. Hydrated by the service
// layer; not a column. NULL when the concept has no mapped event_type.
ConceptDefaultEventTypeID *uuid.UUID `db:"-" json:"concept_default_event_type_id,omitempty"`
LegalSource *string `db:"legal_source" json:"legal_source,omitempty"`
IsSpawn bool `db:"is_spawn" json:"is_spawn"`
SpawnLabel *string `db:"spawn_label" json:"spawn_label,omitempty"`

View File

@@ -32,6 +32,9 @@ const proceedingTypeColumns = `id, code, name, name_en, description, jurisdictio
category, default_color, sort_order, is_active`
// List returns active rules, optionally filtered by proceeding type.
// Each row has ConceptDefaultEventTypeID hydrated from
// paliad.deadline_concept_event_types so the deadline-create form can
// auto-populate the Typ chip when the user picks a Regel.
func (s *DeadlineRuleService) List(ctx context.Context, proceedingTypeID *int) ([]models.DeadlineRule, error) {
var rules []models.DeadlineRule
var err error
@@ -52,9 +55,62 @@ func (s *DeadlineRuleService) List(ctx context.Context, proceedingTypeID *int) (
if err != nil {
return nil, fmt.Errorf("list deadline rules: %w", err)
}
if err := s.hydrateConceptDefaultEventTypes(ctx, rules); err != nil {
return nil, err
}
return rules, nil
}
// hydrateConceptDefaultEventTypes resolves rule.ConceptID →
// paliad.deadline_concept_event_types.event_type_id (where is_default)
// for every rule with a non-nil ConceptID, and assigns the result.
// One round-trip; rules whose concept has no default mapping stay NULL.
func (s *DeadlineRuleService) hydrateConceptDefaultEventTypes(ctx context.Context, rules []models.DeadlineRule) error {
conceptIDs := make([]uuid.UUID, 0, len(rules))
seen := make(map[uuid.UUID]bool, len(rules))
for _, r := range rules {
if r.ConceptID == nil || seen[*r.ConceptID] {
continue
}
seen[*r.ConceptID] = true
conceptIDs = append(conceptIDs, *r.ConceptID)
}
if len(conceptIDs) == 0 {
return nil
}
query, args, err := sqlx.In(
`SELECT concept_id, event_type_id
FROM paliad.deadline_concept_event_types
WHERE is_default = true AND concept_id IN (?)`, conceptIDs)
if err != nil {
return fmt.Errorf("build concept→event_type IN query: %w", err)
}
query = s.db.Rebind(query)
type row struct {
ConceptID uuid.UUID `db:"concept_id"`
EventTypeID uuid.UUID `db:"event_type_id"`
}
var rows []row
if err := s.db.SelectContext(ctx, &rows, query, args...); err != nil {
return fmt.Errorf("load concept→event_type defaults: %w", err)
}
defaultByConcept := make(map[uuid.UUID]uuid.UUID, len(rows))
for _, r := range rows {
defaultByConcept[r.ConceptID] = r.EventTypeID
}
for i := range rules {
if rules[i].ConceptID == nil {
continue
}
if et, ok := defaultByConcept[*rules[i].ConceptID]; ok {
etCopy := et
rules[i].ConceptDefaultEventTypeID = &etCopy
}
}
return nil
}
// RuleTreeNode pairs a rule with its child rules in a parent_id hierarchy.
type RuleTreeNode struct {
models.DeadlineRule