;
+ yvaLabel?: string;
};
-export function EntryMetaSection({ entryDraft, isPending, onChange }: Props) {
+export function EntryMetaSection({
+ entryDraft,
+ isPending,
+ onChange,
+ visibleFields,
+ yvaLabel,
+}: Props) {
function updateField(field: keyof EntryDraft, value: string) {
onChange((current) => ({
...current,
[field]: value,
}));
}
+ const visible = (field: keyof EntryDraft) =>
+ !visibleFields || visibleFields.has(field);
return (
@@ -160,68 +170,86 @@ export function EntryMetaSection({ entryDraft, isPending, onChange }: Props) {
- updateField("hyvaksytytAjominuutit", value)}
- />
- updateField("ajoajanPisteet", value)}
- />
- updateField("yva", value)}
- />
+ {visible("hyvaksytytAjominuutit") ? (
+
+ updateField("hyvaksytytAjominuutit", value)
+ }
+ />
+ ) : null}
+ {visible("ajoajanPisteet") ? (
+ updateField("ajoajanPisteet", value)}
+ />
+ ) : null}
+ {visible("yva") ? (
+ updateField("yva", value)}
+ />
+ ) : null}
- updateField("haku", value)}
- />
+ {visible("haku") ? (
+ updateField("haku", value)}
+ />
+ ) : null}
- updateField("hauk", value)}
- />
+ {visible("hauk") ? (
+ updateField("hauk", value)}
+ />
+ ) : null}
- updateField("ansiopisteetYhteensa", value)}
- />
- updateField("tja", value)}
- />
- updateField("pin", value)}
- />
+ {visible("ansiopisteetYhteensa") ? (
+ updateField("ansiopisteetYhteensa", value)}
+ />
+ ) : null}
+ {visible("tja") ? (
+ updateField("tja", value)}
+ />
+ ) : null}
+ {visible("pin") ? (
+ updateField("pin", value)}
+ />
+ ) : null}
diff --git a/apps/web/components/admin/trials/internal/era-section.tsx b/apps/web/components/admin/trials/internal/era-section.tsx
index a7821a47..210ec447 100644
--- a/apps/web/components/admin/trials/internal/era-section.tsx
+++ b/apps/web/components/admin/trials/internal/era-section.tsx
@@ -14,6 +14,8 @@ type Props = {
field: Exclude
,
value: string,
) => void;
+ visibleFields?: ReadonlySet>;
+ yvaLabel?: string;
};
export function EraSection({
@@ -22,6 +24,8 @@ export function EraSection({
onAddEra,
onRemoveEra,
onChangeEraField,
+ visibleFields,
+ yvaLabel,
}: Props) {
return (
<>
@@ -64,32 +68,40 @@ export function EraSection({
"tja",
"pin",
] as const
- ).map((field) => (
-
))}
diff --git a/apps/web/components/admin/trials/internal/lisatiedot-matrix.tsx b/apps/web/components/admin/trials/internal/lisatiedot-matrix.tsx
index f977ee7e..f0902485 100644
--- a/apps/web/components/admin/trials/internal/lisatiedot-matrix.tsx
+++ b/apps/web/components/admin/trials/internal/lisatiedot-matrix.tsx
@@ -1,4 +1,5 @@
import React from "react";
+import { useI18n } from "@/hooks/i18n";
import {
ADMIN_TRIAL_LISATIETO_GROUP_LABELS,
type AdminTrialLisatietoInputKind,
@@ -26,11 +27,12 @@ export function LisatiedotMatrix({
isPending,
onChangeCell,
}: Props) {
+ const { t } = useI18n();
function sanitizeValue(
kind: AdminTrialLisatietoInputKind,
rawValue: string,
): string {
- if (kind === "marker") {
+ if (kind === "marker" || kind === "tri-state") {
if (rawValue === "1" || rawValue === "0" || rawValue === "") {
return rawValue;
}
@@ -98,7 +100,9 @@ export function LisatiedotMatrix({
- {row.osa ? `${row.koodi} ${row.osa}` : row.koodi}
+ {row.osa && !row.hideOsaSuffix
+ ? `${row.koodi} ${row.osa}`
+ : row.koodi}
{row.label}
@@ -107,7 +111,24 @@ export function LisatiedotMatrix({
|
{sortedEras.map((era) => (
- {row.inputKind === "marker" ? (
+ {row.inputKind === "marker" &&
+ row.useSemanticControl ? (
+
+ onChangeCell(
+ row.koodi,
+ row.osa,
+ era.era,
+ event.target.checked ? "1" : "",
+ )
+ }
+ />
+ ) : row.inputKind === "marker" ||
+ row.inputKind === "tri-state" ? (
) : (
{
);
}
});
+
+ it("serializes only the resolved 2023+ rows through registry persistence mapping", () => {
+ const fieldSet = resolveResultCreateFieldSet(event.trialRuleWindowId);
+ const draft = createAdminTrialEntryCreateDraft(event, fieldSet);
+ draft.registrationNo = "FI43560/18";
+
+ expect(
+ draft.lisatiedotRows.some((row) => row.koodi === "25" && row.osa === "b"),
+ ).toBe(false);
+ const marker = draft.lisatiedotRows.find((row) => row.koodi === "10");
+ if (marker) marker.eraValues[1] = "0";
+
+ const result = toCreateAdminTrialEntryRequest("event-1", draft);
+ expect(result).toMatchObject({
+ ok: true,
+ request: {
+ lisatiedotRows: [],
+ },
+ });
+ });
+
+ it("preserves generic marker values in compatibility fallback", () => {
+ const fieldSet = resolveResultCreateFieldSet(null);
+ const draft = createAdminTrialEntryCreateDraft(event, fieldSet);
+ draft.registrationNo = "FI43560/18";
+ const marker = draft.lisatiedotRows.find((row) => row.koodi === "10");
+ if (marker) marker.eraValues[1] = "0";
+
+ const result = toCreateAdminTrialEntryRequest("event-1", draft);
+
+ expect(result).toMatchObject({
+ ok: true,
+ request: {
+ lisatiedotRows: [
+ expect.objectContaining({
+ koodi: "10",
+ eraValues: [{ era: 1, arvo: "0" }],
+ }),
+ ],
+ },
+ });
+ });
});
diff --git a/apps/web/lib/admin/trials/__tests__/result-create-field-registry.test.ts b/apps/web/lib/admin/trials/__tests__/result-create-field-registry.test.ts
new file mode 100644
index 00000000..e97f7e6d
--- /dev/null
+++ b/apps/web/lib/admin/trials/__tests__/result-create-field-registry.test.ts
@@ -0,0 +1,96 @@
+import { describe, expect, it } from "vitest";
+import {
+ resolveResultCreateFieldSet,
+ SEEDED_TRIAL_RULE_WINDOW_IDS,
+} from "../result-create-field-registry";
+
+describe("result create field registry", () => {
+ it("registers the verified 2023+ field set from the persisted rule window", () => {
+ const fieldSet = resolveResultCreateFieldSet("trw_post_20230801");
+
+ expect(fieldSet.id).toBe("post-2023");
+ expect(fieldSet.verified).toBe(true);
+ expect(fieldSet.entryFields.has("tja")).toBe(false);
+ expect(fieldSet.entryFields.has("pin")).toBe(false);
+ expect(fieldSet.eraFields.has("tja")).toBe(false);
+ expect(fieldSet.eraFields.has("pin")).toBe(false);
+ expect(fieldSet.yvaLabels).toEqual({
+ entry: "Ajotaito",
+ era: "ajotaito",
+ });
+ });
+
+ it("matches the verified 2023+ lisatieto codes, parts, order and kinds", () => {
+ const rows = resolveResultCreateFieldSet("trw_post_20230801").lisatiedot;
+ const keys = rows.map((row) => `${row.koodi}:${row.osa}`);
+
+ expect(keys).toEqual([
+ ...Array.from({ length: 15 }, (_, index) => `${index + 10}:`).slice(
+ 0,
+ 15,
+ ),
+ "25:a",
+ "26:",
+ "27:a",
+ ...Array.from({ length: 8 }, (_, index) => `${index + 30}:`),
+ ...Array.from({ length: 3 }, (_, index) => `${index + 40}:`),
+ ...Array.from({ length: 13 }, (_, index) => `${index + 50}:`),
+ ]);
+ expect(
+ Object.fromEntries(
+ rows
+ .filter((row) => ["19", "23", "26", "59"].includes(row.koodi))
+ .map((row) => [row.koodi, row.inputKind]),
+ ),
+ ).toEqual({
+ "19": "integer",
+ "23": "decimal",
+ "26": "decimal",
+ "59": "decimal",
+ });
+ expect(rows.map((row) => row.persistenceOrder)).toEqual(
+ [...rows]
+ .map((row) => row.persistenceOrder)
+ .sort((left, right) => left - right),
+ );
+ });
+
+ it("maps semantic marker state to persistence without exposing raw values", () => {
+ const marker = resolveResultCreateFieldSet(
+ "trw_post_20230801",
+ ).lisatiedot.find((row) => row.koodi === "10");
+ const toPersistedValue = marker?.toPersistedValue;
+
+ expect(marker?.inputKind).toBe("marker");
+ expect(toPersistedValue).toBeTypeOf("function");
+ if (!toPersistedValue) throw new Error("Marker persistence mapper missing");
+ expect(toPersistedValue("1")).toBe("1");
+ expect(toPersistedValue("0")).toBe("");
+ expect(toPersistedValue("")).toBe("");
+ });
+
+ it.each([
+ ...SEEDED_TRIAL_RULE_WINDOW_IDS.filter((id) => id !== "trw_post_20230801"),
+ null,
+ "unknown-window",
+ ])("uses the warned show-all fallback for %s", (trialRuleWindowId) => {
+ const fieldSet = resolveResultCreateFieldSet(trialRuleWindowId);
+
+ expect(fieldSet.id).toBe("unverified-fallback");
+ expect(fieldSet.verified).toBe(false);
+ expect(fieldSet.yvaLabels).toBeUndefined();
+ expect(fieldSet.entryFields.has("tja")).toBe(true);
+ expect(fieldSet.eraFields.has("pin")).toBe(true);
+ expect(
+ fieldSet.lisatiedot.some((row) => row.koodi === "25" && row.osa === "b"),
+ ).toBe(true);
+ expect(
+ fieldSet.lisatiedot.every(
+ (row) =>
+ row.useSemanticControl === undefined &&
+ row.toPersistedValue === undefined &&
+ row.valueHint === undefined,
+ ),
+ ).toBe(true);
+ });
+});
diff --git a/apps/web/lib/admin/trials/entry-create-model.ts b/apps/web/lib/admin/trials/entry-create-model.ts
index 6bc6fa36..d81ee93c 100644
--- a/apps/web/lib/admin/trials/entry-create-model.ts
+++ b/apps/web/lib/admin/trials/entry-create-model.ts
@@ -16,6 +16,7 @@ import {
type EraDraft,
type LisatietoRowDraft,
} from "./entry-edit-dialog-model";
+import type { ResultCreateFieldSet } from "./result-create-field-registry";
export type AdminTrialEntryCreateDraft = {
registrationNo: string;
@@ -48,6 +49,7 @@ function emptyEntry(event: AdminTrialEventDetails): AdminTrialEventEntry {
export function createAdminTrialEntryCreateDraft(
event: AdminTrialEventDetails,
+ fieldSet?: ResultCreateFieldSet,
): AdminTrialEntryCreateDraft {
const entry = emptyEntry(event);
const eras = [createEmptyEraDraft(1)];
@@ -55,7 +57,7 @@ export function createAdminTrialEntryCreateDraft(
registrationNo: "",
entry: toEntryDraft(entry),
eras,
- lisatiedotRows: toLisatietoRows(entry, eras),
+ lisatiedotRows: toLisatietoRows(entry, eras, fieldSet?.lisatiedot),
};
}
@@ -166,7 +168,12 @@ export function toCreateAdminTrialEntryRequest(
})),
lisatiedotRows: draft.lisatiedotRows
.filter((row) =>
- eras.some((era) => (row.eraValues[era.era] ?? "").trim().length > 0),
+ eras.some((era) => {
+ const controlValue = row.eraValues[era.era] ?? "";
+ const persistedValue =
+ row.toPersistedValue?.(controlValue) ?? controlValue;
+ return persistedValue.trim().length > 0;
+ }),
)
.map((row) => ({
koodi: row.koodi,
@@ -175,7 +182,11 @@ export function toCreateAdminTrialEntryRequest(
jarjestys: row.jarjestys,
eraValues: eras.map((era) => ({
era: era.era,
- arvo: parseNullableString(row.eraValues[era.era] ?? ""),
+ arvo: parseNullableString(
+ row.toPersistedValue?.(row.eraValues[era.era] ?? "") ??
+ row.eraValues[era.era] ??
+ "",
+ ),
})),
})),
},
diff --git a/apps/web/lib/admin/trials/entry-edit-config.ts b/apps/web/lib/admin/trials/entry-edit-config.ts
index 115ba8ea..d8dd3cfa 100644
--- a/apps/web/lib/admin/trials/entry-edit-config.ts
+++ b/apps/web/lib/admin/trials/entry-edit-config.ts
@@ -24,7 +24,8 @@ export type AdminTrialLisatietoInputKind =
| "marker"
| "integer"
| "decimal"
- | "text";
+ | "text"
+ | "tri-state";
export type AdminTrialLisatietoConfig = {
koodi: string;
@@ -34,6 +35,10 @@ export type AdminTrialLisatietoConfig = {
inputKind: AdminTrialLisatietoInputKind;
sortOrder: number;
persistenceOrder: number;
+ valueHint?: "marker" | "integer" | "decimal" | "text";
+ toPersistedValue?: (controlValue: string) => string;
+ hideOsaSuffix?: boolean;
+ useSemanticControl?: boolean;
};
function defineLisatieto(
diff --git a/apps/web/lib/admin/trials/entry-edit-dialog-model.ts b/apps/web/lib/admin/trials/entry-edit-dialog-model.ts
index 7a0af752..132807a1 100644
--- a/apps/web/lib/admin/trials/entry-edit-dialog-model.ts
+++ b/apps/web/lib/admin/trials/entry-edit-dialog-model.ts
@@ -4,6 +4,7 @@ import {
getAdminTrialLisatietoConfig,
type AdminTrialLisatietoGroup,
type AdminTrialLisatietoInputKind,
+ type AdminTrialLisatietoConfig,
} from "./entry-edit-config";
export type LisatietoRowDraft = {
@@ -14,6 +15,10 @@ export type LisatietoRowDraft = {
group: AdminTrialLisatietoGroup;
label: string;
inputKind: AdminTrialLisatietoInputKind;
+ valueHint?: "marker" | "integer" | "decimal" | "text";
+ toPersistedValue?: (controlValue: string) => string;
+ hideOsaSuffix?: boolean;
+ useSemanticControl?: boolean;
sortOrder: number;
eraValues: Record;
};
@@ -147,6 +152,7 @@ export function toEraDrafts(entry: AdminTrialEventEntry): EraDraft[] {
export function toLisatietoRows(
entry: AdminTrialEventEntry,
eras: EraDraft[],
+ configs: readonly AdminTrialLisatietoConfig[] = ADMIN_TRIAL_LISATIETO_CONFIG,
): LisatietoRowDraft[] {
const eraNumbers = eras.map((era) => era.era);
const values = new Map();
@@ -175,7 +181,10 @@ export function toLisatietoRows(
return existing;
}
- const config = getAdminTrialLisatietoConfig(input.koodi, input.osa);
+ const config =
+ configs.find(
+ (item) => item.koodi === input.koodi && item.osa === input.osa,
+ ) ?? getAdminTrialLisatietoConfig(input.koodi, input.osa);
const parsedCode = Number.parseInt(input.koodi, 10);
const row: LisatietoRowDraft = {
koodi: input.koodi,
@@ -185,6 +194,10 @@ export function toLisatietoRows(
group: config?.group ?? "unknown",
label: config?.label ?? input.nimi ?? "Tuntematon lisätieto",
inputKind: config?.inputKind ?? "text",
+ valueHint: config?.valueHint,
+ toPersistedValue: config?.toPersistedValue,
+ hideOsaSuffix: config?.hideOsaSuffix,
+ useSemanticControl: config?.useSemanticControl,
sortOrder:
config?.sortOrder ??
(Number.isInteger(parsedCode) ? parsedCode : Number.MAX_SAFE_INTEGER),
@@ -194,7 +207,7 @@ export function toLisatietoRows(
return row;
}
- for (const config of ADMIN_TRIAL_LISATIETO_CONFIG) {
+ for (const config of configs) {
ensureRow({
koodi: config.koodi,
osa: config.osa,
diff --git a/apps/web/lib/admin/trials/index.ts b/apps/web/lib/admin/trials/index.ts
index 02c2b754..9ed98e13 100644
--- a/apps/web/lib/admin/trials/index.ts
+++ b/apps/web/lib/admin/trials/index.ts
@@ -1,6 +1,7 @@
export * from "./trial-route";
export * from "./submit-admin-trial-event-creation";
export * from "./entry-create-model";
+export * from "./result-create-field-registry";
export {
createEmptyEraDraft,
getNextEraNumber,
diff --git a/apps/web/lib/admin/trials/result-create-field-registry.ts b/apps/web/lib/admin/trials/result-create-field-registry.ts
new file mode 100644
index 00000000..654f458e
--- /dev/null
+++ b/apps/web/lib/admin/trials/result-create-field-registry.ts
@@ -0,0 +1,173 @@
+import {
+ ADMIN_TRIAL_LISATIETO_CONFIG,
+ type AdminTrialLisatietoConfig,
+ type AdminTrialLisatietoInputKind,
+} from "./entry-edit-config";
+import type { EntryDraft, EraDraft } from "./entry-edit-dialog-model";
+
+export type ResultCreateSemanticInputKind =
+ | AdminTrialLisatietoInputKind
+ | "tri-state";
+
+export type ResultCreateValueHint = "marker" | "integer" | "decimal" | "text";
+
+export type ResultCreateLisatietoField = AdminTrialLisatietoConfig & {
+ inputKind: ResultCreateSemanticInputKind;
+ valueHint: ResultCreateValueHint;
+ toPersistedValue: (controlValue: string) => string;
+};
+
+export type ResultCreateFieldSet = {
+ id: "post-2023" | "unverified-fallback";
+ verified: boolean;
+ rulePeriodMessageKey:
+ | "admin.trials.manage.resultCreate.rulePeriod.post2023"
+ | "admin.trials.manage.resultCreate.rulePeriod.unverified";
+ entryFields: ReadonlySet;
+ eraFields: ReadonlySet>;
+ yvaLabels?: {
+ entry: string;
+ era: string;
+ };
+ lisatiedot: readonly AdminTrialLisatietoConfig[];
+};
+
+export const SEEDED_TRIAL_RULE_WINDOW_IDS = [
+ "trw_pre_20020801",
+ "trw_range_2002_2005",
+ "trw_range_2005_2011",
+ "trw_post_20110801",
+ "trw_post_20230801",
+] as const;
+
+const ALL_ENTRY_FIELDS = new Set([
+ "koemaasto",
+ "koemuoto",
+ "koetyyppi",
+ "ke",
+ "lk",
+ "award",
+ "rank",
+ "points",
+ "koiriaLuokassa",
+ "hyvaksytytAjominuutit",
+ "ajoajanPisteet",
+ "haku",
+ "hauk",
+ "yva",
+ "hlo",
+ "alo",
+ "tja",
+ "pin",
+ "ansiopisteetYhteensa",
+ "tappiopisteetYhteensa",
+ "judge",
+ "huomautus",
+ "huomautusTeksti",
+ "ylituomariNumeroSnapshot",
+ "ryhmatuomariNimi",
+ "palkintotuomariNimi",
+ "omistajaSnapshot",
+ "omistajanKotikuntaSnapshot",
+]);
+
+const ALL_ERA_FIELDS = new Set>([
+ "alkoi",
+ "hakumin",
+ "ajomin",
+ "haku",
+ "hauk",
+ "yva",
+ "hlo",
+ "alo",
+ "tja",
+ "pin",
+ "huomautusTeksti",
+]);
+
+function identity(value: string): string {
+ return value;
+}
+
+function toMarkerPersistence(value: string): string {
+ return value === "1" ? "1" : "";
+}
+
+function withSemantics(
+ config: AdminTrialLisatietoConfig,
+ inputKind: ResultCreateSemanticInputKind = config.inputKind,
+): ResultCreateLisatietoField {
+ return {
+ ...config,
+ inputKind,
+ valueHint:
+ inputKind === "tri-state"
+ ? "marker"
+ : (inputKind as ResultCreateValueHint),
+ toPersistedValue: inputKind === "marker" ? toMarkerPersistence : identity,
+ useSemanticControl: true,
+ };
+}
+
+const FALLBACK_LISATIEDOT = ADMIN_TRIAL_LISATIETO_CONFIG.map((config) => ({
+ ...config,
+}));
+
+const POST_2023_INPUT_OVERRIDES: Readonly<
+ Record
+> = {
+ "19": "integer",
+ "23": "decimal",
+ "26": "decimal",
+ "59": "decimal",
+};
+
+const POST_2023_LISATIETO_CODES = new Set([
+ ...Array.from({ length: 18 }, (_, index) => String(index + 10)),
+ ...Array.from({ length: 13 }, (_, index) => String(index + 30)),
+ ...Array.from({ length: 13 }, (_, index) => String(index + 50)),
+]);
+
+const POST_2023_LISATIEDOT = ADMIN_TRIAL_LISATIETO_CONFIG.filter(
+ (config) =>
+ POST_2023_LISATIETO_CODES.has(config.koodi) &&
+ (!(config.koodi === "25" || config.koodi === "27") || config.osa === "a"),
+).map((config) => ({
+ ...withSemantics(config, POST_2023_INPUT_OVERRIDES[config.koodi]),
+ hideOsaSuffix: config.koodi === "25" || config.koodi === "27",
+}));
+
+const POST_2023_FIELD_SET: ResultCreateFieldSet = {
+ id: "post-2023",
+ verified: true,
+ rulePeriodMessageKey: "admin.trials.manage.resultCreate.rulePeriod.post2023",
+ entryFields: new Set(
+ [...ALL_ENTRY_FIELDS].filter((field) => field !== "tja" && field !== "pin"),
+ ),
+ eraFields: new Set(
+ [...ALL_ERA_FIELDS].filter((field) => field !== "tja" && field !== "pin"),
+ ),
+ yvaLabels: {
+ entry: "Ajotaito",
+ era: "ajotaito",
+ },
+ lisatiedot: POST_2023_LISATIEDOT,
+};
+
+const UNVERIFIED_FALLBACK_FIELD_SET: ResultCreateFieldSet = {
+ id: "unverified-fallback",
+ verified: false,
+ rulePeriodMessageKey:
+ "admin.trials.manage.resultCreate.rulePeriod.unverified",
+ entryFields: ALL_ENTRY_FIELDS,
+ eraFields: ALL_ERA_FIELDS,
+ lisatiedot: FALLBACK_LISATIEDOT,
+};
+
+export function resolveResultCreateFieldSet(
+ trialRuleWindowId: string | null,
+): ResultCreateFieldSet {
+ return trialRuleWindowId === "trw_post_20230801"
+ ? POST_2023_FIELD_SET
+ : UNVERIFIED_FALLBACK_FIELD_SET;
+}
diff --git a/apps/web/lib/i18n/messages/admin/trials/manage.ts b/apps/web/lib/i18n/messages/admin/trials/manage.ts
index 82e3ad82..8cde11ac 100644
--- a/apps/web/lib/i18n/messages/admin/trials/manage.ts
+++ b/apps/web/lib/i18n/messages/admin/trials/manage.ts
@@ -68,6 +68,17 @@ export const fiAdminTrialsManageMessages = {
"admin.trials.manage.resultCreate.backToWorkspace":
"Takaisin tapahtuman sivulle",
"admin.trials.manage.resultCreate.registration": "Rekisterinumero",
+ "admin.trials.manage.resultCreate.rulePeriod.post2023": "Säännöt: 1.8.2023 →",
+ "admin.trials.manage.resultCreate.rulePeriod.unverified":
+ "Säännöt: historiallinen tai määrittämätön sääntökausi",
+ "admin.trials.manage.resultCreate.ruleWindowWarning":
+ "Tämän sääntökauden kenttiä ei ole vielä tarkistettu pöytäkirjaa vasten. Lomake näyttää yhteensopivuuden vuoksi kaikki nykyiset kentät.",
+ "admin.trials.manage.resultCreate.valueHint.marker": "Valinta",
+ "admin.trials.manage.resultCreate.valueHint.integer": "Kokonaisluku",
+ "admin.trials.manage.resultCreate.valueHint.decimal": "Desimaaliluku",
+ "admin.trials.manage.resultCreate.valueHint.text": "Teksti",
+ "admin.trials.manage.resultCreate.triState.no": "Ei",
+ "admin.trials.manage.resultCreate.triState.yes": "Kyllä",
"admin.trials.manage.resultCreate.saveAnother": "Tallenna ja lisää seuraava",
"admin.trials.manage.resultCreate.saveFinish": "Tallenna ja lopeta",
"admin.trials.manage.resultCreate.cancel": "Peruuta",
@@ -271,6 +282,17 @@ export const svAdminTrialsManageMessages = {
"admin.trials.manage.resultCreate.backToWorkspace":
"Tillbaka till evenemangssidan",
"admin.trials.manage.resultCreate.registration": "Registreringsnummer",
+ "admin.trials.manage.resultCreate.rulePeriod.post2023": "Regler: 1.8.2023 →",
+ "admin.trials.manage.resultCreate.rulePeriod.unverified":
+ "Regler: historisk eller obestämd regelperiod",
+ "admin.trials.manage.resultCreate.ruleWindowWarning":
+ "Fälten för den här regelperioden har ännu inte verifierats mot protokollet. Formuläret visar därför alla nuvarande fält för kompatibilitet.",
+ "admin.trials.manage.resultCreate.valueHint.marker": "Val",
+ "admin.trials.manage.resultCreate.valueHint.integer": "Heltal",
+ "admin.trials.manage.resultCreate.valueHint.decimal": "Decimaltal",
+ "admin.trials.manage.resultCreate.valueHint.text": "Text",
+ "admin.trials.manage.resultCreate.triState.no": "Nej",
+ "admin.trials.manage.resultCreate.triState.yes": "Ja",
"admin.trials.manage.resultCreate.saveAnother": "Spara och lägg till nästa",
"admin.trials.manage.resultCreate.saveFinish": "Spara och avsluta",
"admin.trials.manage.resultCreate.cancel": "Avbryt",
diff --git a/docs/features/admin-trial-management.md b/docs/features/admin-trial-management.md
index 25c88de6..0be1545b 100644
--- a/docs/features/admin-trial-management.md
+++ b/docs/features/admin-trial-management.md
@@ -17,6 +17,12 @@ and follow-up admin flow redesign).
- An admin can create one manual result at a time from an event workspace.
The full-page form supports saving another result for the same event or
finishing back at the workspace.
+- Manual result creation resolves its visible score, era, and lisätieto fields
+ from the event's persisted `trialRuleWindowId`. The 2023+ window is verified
+ against its PDF field set; older, null, and unknown windows retain the
+ complete compatibility form with a warning. The canonical timeline, field
+ sets, and fallback semantics are documented in
+ [Trial rule windows](./trials/rule-windows.md).
## Main files
@@ -59,7 +65,8 @@ and follow-up admin flow redesign).
- Event list response: paginated event summaries (`total`, `totalPages`,
`page`, `filters`, `availableYears`, `items[]`).
- Event detail response: one event (`event`) with event header fields and
- selected dog rows (`entries[]`).
+ selected dog rows (`entries[]`). It includes the event-owned
+ `trialRuleWindowId`; clients must not recalculate the window from the date.
- Event creation requires a positive `sklKoeId`, an ISO date, and a non-empty
place. Duplicate SKL IDs return `SKL_KOE_ID_CONFLICT`.
- Manual result identity is `SKL:|REG:` and
@@ -69,6 +76,10 @@ and follow-up admin flow redesign).
to occur only once across the submitted matrix rows.
- Lisätieto UI sorting is separate from its integer persisted `jarjestys`;
unused create-form rows are omitted from the write payload.
+- The result-create field registry owns create-only visibility, ordering,
+ business grouping, semantic input kinds, localized value-hint categories,
+ and control-to-persistence mapping. Shared edit components use their default
+ complete field set and do not consume create-only configuration.
- Manual-result validation errors may carry safe structured field context for
localized feedback without exposing raw user-entered values.
- `TrialEvent.koepaiva` is a PostgreSQL `DATE`; all trial contracts serialize
@@ -88,6 +99,13 @@ and follow-up admin flow redesign).
confirm internal navigation and use native unload protection for refresh or
close. Browser Back leaves the form without application confirmation because
the App Router has no reliable asynchronous route-blocking hook.
+- The result form shows only a localized rule-period label. For
+ `trw_post_20230801`, entry- and era-level `tja`/`pin` are hidden, lisätieto
+ codes 25 and 27 expose only part `a`, and codes 19, 23, 26, and 59 use their
+ verified semantic input kinds. Other rule windows show the full compatibility
+ set and an unverified-field warning. See
+ [Trial rule windows](./trials/rule-windows.md) for the complete verified
+ configuration and compatibility boundary.
- Selected dog rows have PDF, edit, and result-delete actions.
- A missing workspace event is shown explicitly and never falls back to a
different event.
diff --git a/docs/features/trials/ajokoe-pdf-rule-periods.md b/docs/features/trials/ajokoe-pdf-rule-periods.md
index 77e8b76c..0d7e33d4 100644
--- a/docs/features/trials/ajokoe-pdf-rule-periods.md
+++ b/docs/features/trials/ajokoe-pdf-rule-periods.md
@@ -3,6 +3,11 @@
This document tracks AJOK PDF availability by rule period. It intentionally
does not define field mapping for any single pöytäkirja renderer.
+Persisted rule-window identity and manual result-creation verification are
+documented separately in [Trial rule windows](./rule-windows.md). An
+implemented PDF renderer does not by itself make a rule window verified for
+manual creation.
+
The web app exposes the PDFs through two entrypoints:
- Public stacked view: `/beagle/trials/pdf?trialEntryId=...`
diff --git a/docs/features/trials/rule-windows.md b/docs/features/trials/rule-windows.md
new file mode 100644
index 00000000..4ab4e291
--- /dev/null
+++ b/docs/features/trials/rule-windows.md
@@ -0,0 +1,204 @@
+# Trial rule windows
+
+## Purpose
+
+Trial rule windows give AJOK events a persisted rule-period identity. This
+document is the source of truth for the seeded timeline and for the verification
+status of manual result creation.
+
+Verification in this document is scoped to the manual create form. It is
+separate from PDF renderer availability, which is documented in
+[AJOK PDF rule periods](./ajokoe-pdf-rule-periods.md).
+
+## Runtime resolution
+
+Rule windows are inclusive date ranges stored in `trial_rule_window`. Event
+creation and trial imports resolve an event date against the active ranges and
+persist the selected ID in `TrialEvent.trialRuleWindowId`. The ID may be `null`
+when no active range matches.
+
+Consumers use the persisted event-owned ID. In particular, the manual result
+form must not recalculate a window from the event date in the browser.
+
+The result-create registry uses the ID to select field visibility, terminology,
+Lisätiedot controls, and control-to-persistence conversion. This changes the
+creation UI and how its values are expressed in the existing write format; it
+does not change the backend write contract or database schema and does not add
+rule-window-specific server validation.
+
+## Verification statuses
+
+| Rule-window ID | Effective period | Manual result creation |
+| --------------------- | ------------------ | ------------------------------------------- |
+| `trw_pre_20020801` | Before 1.8.2002 | Unverified; show-all compatibility fallback |
+| `trw_range_2002_2005` | 1.8.2002–31.7.2005 | Unverified; show-all compatibility fallback |
+| `trw_range_2005_2011` | 1.8.2005–31.7.2011 | Unverified; show-all compatibility fallback |
+| `trw_post_20110801` | 1.8.2011–31.7.2023 | Unverified; show-all compatibility fallback |
+| `trw_post_20230801` | From 1.8.2023 | Verified for manual result creation |
+
+`null` and unknown IDs also use the unverified compatibility fallback.
+
+A rule window may have an implemented PDF renderer without having a verified
+manual-create field set. Do not infer one status from the other.
+
+## `trw_post_20230801`
+
+### Effective period
+
+`trw_post_20230801` applies from 1 August 2023 onward. Manual result creation
+selects this configuration only when the trial event's persisted
+`trialRuleWindowId` has that exact value.
+
+### Verification status
+
+This is the only rule window currently verified for manual result creation.
+The field set has been checked against the current 2023+ dog-trial PDF behavior.
+
+### Entry fields
+
+The verified score fields are:
+
+- Hyväksytyt ajominuutit
+- Ajoajan pisteet
+- Haku
+- Haukku
+- Ajotaito
+- Ansiopisteet yhteensä
+
+These compatibility score fields are hidden:
+
+- Tie ja estetyöskentely (`tja`)
+- Metsästysinto (`pin`)
+
+The existing grouped layout remains unchanged:
+
+- Ajo
+- Haku
+- Haukku
+- Muut
+
+Other retained create fields—event/result metadata, loss points, status, notes,
+owner snapshots, and judges—remain available. Rule-window selection does not
+redesign the form.
+
+The verified `yva` term is **Ajotaito**. Default editing and compatibility
+fallback continue using **Ajotaito / yleisvaikutelma**.
+
+### Era fields
+
+The verified era-level fields are:
+
+| Field | Current create label |
+| ----------------- | -------------------- |
+| `alkoi` | `alkoi` |
+| `hakumin` | `hakumin` |
+| `ajomin` | `ajomin` |
+| `haku` | `haku` |
+| `hauk` | `haukku` |
+| `yva` | `ajotaito` |
+| `hlo` | `hakulöysyys` |
+| `alo` | `ajolöysyys` |
+| `huomautusTeksti` | `Huomautusteksti` |
+
+Era-level `tja` and `pin` are hidden. The current-rule term is Ajotaito; the
+generic edit and fallback term remains Ajotaito / yleisvaikutelma.
+
+### Lisätiedot
+
+The verified rows, in registry order, are:
+
+- codes `10`–`24`
+- code `25`, part `a`
+- code `26`
+- code `27`, part `a`
+- codes `30`–`37`
+- codes `40`–`42`
+- codes `50`–`62`
+
+Parts `25:b`, `25:c`, `27:b`, and `27:c` are not included. The retained `a`
+rows are displayed without a part suffix.
+
+Input kinds are:
+
+| Input kind | Codes and parts |
+| ---------- | ------------------------------------------------------------------------- |
+| Marker | `10`, `11`, `13`–`16` |
+| Integer | `12`, `17`–`20`, `27:a`, `36`, `58` |
+| Decimal | `21`–`24`, `25:a`, `26`, `30`–`35`, `37`, `40`–`42`, `50`–`57`, `59`–`62` |
+| Text | None currently configured |
+| Tri-state | None currently configured |
+
+The UI groups rows as Olosuhteet, Haku, Haukku, Metsästysinto, Ajo, and Muut
+ominaisuudet. Rows use the registry's `sortOrder` within those groups.
+Persisted integer `jarjestys` comes from the canonical base configuration's
+`persistenceOrder`; filtering parts out of the verified set does not renumber
+the remaining rows.
+
+### Persistence behavior
+
+The registry owns create-form semantics and conversion to the existing
+Lisätiedot persistence format:
+
+- A selected verified marker persists as `"1"`.
+- An unchecked verified marker maps to empty and is omitted from the write.
+- Integer, decimal, and text controls use their configured input semantics and
+ map to the existing string `arvo` values.
+- Tri-state is available only for an explicitly configured field whose domain
+ distinguishes empty, `"0"`, and `"1"`; no 2023+ row currently uses it.
+- Empty Lisätiedot rows are omitted from the request.
+
+Entry, era, and Lisätiedot request shapes remain the existing backend contract.
+
+## Historical and unknown windows
+
+### Compatibility fallback
+
+Every seeded historical ID, `null`, and unknown ID currently uses one
+unverified show-all compatibility fallback:
+
+- all compatibility entry and era fields remain visible;
+- generic labels remain in use, including Ajotaito / yleisvaikutelma;
+- Lisätiedot rows are built from `ADMIN_TRIAL_LISATIETO_CONFIG`;
+- original generic marker selects and generic inputs remain in use;
+- raw values such as an explicit `"0"` are preserved;
+- no current-rule semantic conversion is applied; and
+- the result-create UI displays an unverified-field-set warning.
+
+Show-all compatibility does **not** mean that a historical rule window has
+been implemented correctly. It is a safe generic data-entry fallback until
+authoritative historical field sets are verified.
+
+### Verification requirements
+
+Before marking a historical window verified, contributors must establish:
+
+- an authoritative rule source;
+- the exact entry fields;
+- the exact era fields;
+- period-correct terminology;
+- exact Lisätiedot codes and parts;
+- input and control semantics;
+- control-to-persistence mapping; and
+- focused registry, presentation, and serialization tests.
+
+Do not infer historical fields from another period, from the compatibility
+fallback, or solely from PDF renderer availability.
+
+## Contributor guidance
+
+- Treat `TrialEvent.trialRuleWindowId` as persisted event context.
+- Keep rule-ID branching inside the result-create registry, not presentation
+ components.
+- Keep the fallback generic until a historical window completes the
+ verification requirements above.
+- Update this document with any verified field-set change.
+- Keep import-specific projection behavior in
+ [Legacy Import Phase 5](../../legacy-import/phase5-trial-runtime-projection.md)
+ and PDF-specific behavior in
+ [AJOK PDF rule periods](./ajokoe-pdf-rule-periods.md).
+
+Implementation source:
+
+- `apps/web/lib/admin/trials/result-create-field-registry.ts`
+- `apps/web/lib/admin/trials/__tests__/result-create-field-registry.test.ts`
+- `apps/web/lib/admin/trials/__tests__/entry-create-model.test.ts`
diff --git a/docs/legacy-import/phase5-trial-runtime-projection.md b/docs/legacy-import/phase5-trial-runtime-projection.md
index 69dc91d1..4c96bbdd 100644
--- a/docs/legacy-import/phase5-trial-runtime-projection.md
+++ b/docs/legacy-import/phase5-trial-runtime-projection.md
@@ -98,16 +98,11 @@ Keep `legacy_akoeall` summary values on `TrialEntry`:
Trial rule timeline:
- Rule windows are seeded by migration before phase 5 runs.
-- `trw_pre_20020801`: `AJOKOKEEN SÄÄNNÖT JA OHJEET (AJOK ja BEAJ), voimassa ennen 1.8.2002`
-- `trw_range_2002_2005`: `AJOKOKEEN SÄÄNNÖT JA OHJEET (AJOK ja BEAJ), voimassa 1.8.2002-31.7.2005`
-- `trw_range_2005_2011`: `AJOKOKEEN SÄÄNNÖT JA OHJEET (AJOK ja BEAJ), voimassa 1.8.2005-31.7.2011`
-- `trw_post_20110801`: `AJOKOKEEN SÄÄNNÖT JA OHJEET (AJOK ja BEAJ), voimassa 1.8.2011-31.7.2023`
-- Current rule window:
- - `id = trw_post_20230801`
- - `fromYmd = 20230801`
- - `toYmd = null`
- - `sortOrder = 50`
- - `label = AJOKOKEEN SÄÄNNÖT JA OHJEET (AJOK ja BEAJ), voimassa 1.8.2023 alkaen`
+- Phase 5 resolves the imported event date against the active inclusive
+ windows and persists the selected ID on `TrialEvent`.
+- The canonical seeded timeline and its manual-creation verification statuses
+ are documented in
+ [Trial rule windows](../features/trials/rule-windows.md).
Map selected `bealt*` era fields to `TrialEra`:
diff --git a/docs/planning/trials/README.md b/docs/planning/trials/README.md
index 19878253..06b43ba7 100644
--- a/docs/planning/trials/README.md
+++ b/docs/planning/trials/README.md
@@ -19,9 +19,13 @@ on the next gate.
identity, transaction, error, date-only, and Server Action backend contract.
- [Result creation R2](./result-creation-r2.md) defines the full-page result
form and admin UI workflow built on the approved R1 contract.
-- [Rule-window-aware result fields](./result-fields-by-rule-window.md) defines
- the second result-creation follow-up after R2: introduce field-set selection
- for every rule window and verify the 2023+ set first.
+- [Rule-window-aware result creation](./result-fields-by-rule-window.md)
+ defines R3A: make the existing result-create form use the event's persisted
+ rule window, verify the 2023+ field set, and retain a warned show-all
+ fallback for other windows.
+- [Result-creation UX](./result-creation-ux.md) defines R3B: evolve the
+ existing full-page create form into a clearer card-based single-page
+ experience after R3A has been reviewed.
- [Later UX](./later-ux.md) records deferred ideas only and does not authorize
their implementation.
@@ -30,6 +34,7 @@ Repository guardrails and current feature documentation:
- [Architecture guardrails](../../../ARCHITECTURE.md)
- [Documentation rules](../../documentation-rules.md)
- [Current admin trial management](../../features/admin-trial-management.md)
+- [Canonical trial rule windows](../../features/trials/rule-windows.md)
- [Koiratietokanta AJOK upsert](../../features/trials/koiratietokanta-api-ajok-upsert.md)
## Grounded current state
@@ -66,6 +71,9 @@ Repository guardrails and current feature documentation:
adding another result to the same event and finishing at the event workspace.
- The existing trials master-detail list and existing result-edit modal remain
in place for BEJ-103.
+- Rule-window-aware presentation is introduced for result creation before any
+ result-editing redesign. Existing result editing remains unchanged through
+ R3A and R3B.
## Implementation order and review rules
@@ -80,14 +88,17 @@ R1 (backend)
↓
R2 (UI)
↓
-R3 (rule-window field sets)
+R3A (rule-window-aware creation)
+ ↓
+R3B (result-creation UX)
```
1. `E1` - event workspace
2. `E2` - event creation and empty-event lifecycle
3. `R1` - manual result schema and backend
4. `R2` - manual result UI and workflow
-5. `R3` - rule-window-aware result fields, after R2 review
+5. `R3A` - rule-window-aware result creation, after R2 review
+6. `R3B` - card-based result-creation UX, after R3A review
For every gate:
@@ -108,6 +119,7 @@ BEJ-103 does not authorize:
- changes to trial statistics or their calculation;
- redesign of legacy import or Koiratietokanta ingestion;
- redesign of existing result editing;
+- rule-window-aware result editing;
- batch entry of several unsaved dog results;
- a draft/publish workflow;
- autosave;
diff --git a/docs/planning/trials/later-ux.md b/docs/planning/trials/later-ux.md
index 76170f9a..9161af6b 100644
--- a/docs/planning/trials/later-ux.md
+++ b/docs/planning/trials/later-ux.md
@@ -16,6 +16,10 @@ the review rules in the [BEJ-103 planning overview](./README.md).
index.
- Move editing of existing results from the modal to the reusable full-page
result form.
+- Make existing result editing rule-window-aware, including preservation of
+ stored fields hidden by a narrower verified field set.
+- Redesign result editing only after the R3A and R3B creation gates have been
+ implemented, validated, and reviewed.
- Add searchable dog selection to manual result creation.
- Allow inline dog creation from the result flow.
- Add an explicit draft/publish state for trial events or results.
@@ -27,8 +31,13 @@ the review rules in the [BEJ-103 planning overview](./README.md).
- E1 and E2 retain the existing trials master-detail list.
- R1 and R2 retain the existing result-edit modal.
-- R2 uses a free-text registration field and saves one complete result at a
- time.
+- R2, R3A, and R3B use a free-text registration field and save one complete
+ result at a time.
+- R3A and R3B do not change result editing. Older, null, and unknown rule
+ windows use the warned show-all fallback only in result creation.
+- R3B does not require a wizard. If a guided presentation is adopted during
+ R3B, it must be a thin coordinator over the same reusable cards and preserve
+ the existing save and navigation behavior.
- Matching Koiratietokanta upserts are resolved by the authoritative backend
behavior documented in [Result creation](./result-creation.md), without a
manual reconciliation screen.
diff --git a/docs/planning/trials/result-creation-ux.md b/docs/planning/trials/result-creation-ux.md
new file mode 100644
index 00000000..1f2009cd
--- /dev/null
+++ b/docs/planning/trials/result-creation-ux.md
@@ -0,0 +1,245 @@
+# Follow-up R3B — Result-Creation UX
+
+## Status and sequencing
+
+This is the second creation follow-up after R2. It is planning only and does
+not authorize implementation.
+
+R3A must be implemented, validated, reviewed, and approved before R3B begins.
+R3B consumes the R3A field-set registry and changes only the presentation of
+the existing full-page result-create experience. Existing result editing
+remains unchanged.
+
+The referenced UX screenshots are concepts for hierarchy, cards, and
+lisätieto interaction. They do not specify exact geometry, field counts,
+placement, wording, or a mandatory navigation model.
+
+## Purpose
+
+Evolve the current single-page result-create form into a clearer,
+mobile-capable set of reusable cards. Preserve the familiar save flow and
+overall navigation while improving hierarchy, spacing, descriptions, and
+grouping.
+
+R3B owns presentation only. Field correctness, availability, ordering,
+business grouping, semantic input kinds, and persistence mapping remain owned
+by the R3A registry.
+
+## Single-page card composition
+
+Keep one full-page form and one in-memory draft. Do not require a wizard or
+mandatory multi-step navigation.
+
+A guided or multi-step presentation is permitted, but it is not an R3B
+requirement. If implementation review shows that guidance is useful, add it as
+a thin coordinator that controls which reusable cards are visible. It must not
+duplicate card content, create separate field models, or change the existing
+save and navigation contract.
+
+Compose the page from independently reusable cards such as:
+
+- event context;
+- Perustiedot;
+- Tulos ja huomautus;
+- Ansiopisteet;
+- Haku, Haukku ja muut;
+- Tuomarit;
+- Erät;
+- Lisätiedot; and
+- an optional informational Yhteenveto.
+
+The exact card boundaries may combine closely related fields when needed for
+responsive layout, but must preserve the current form organization and
+backend request ownership. Other than Lisätiedot, R3B changes spacing,
+descriptions, visual hierarchy, and grouping rather than introducing a new
+workflow.
+
+Cards must not own page navigation or rule-window resolution. They receive
+draft values, callbacks, validation state, and resolved field configuration
+through their interfaces. This keeps them reusable for future editing or for
+an optional R3B coordinator that controls card visibility without rewriting
+the cards.
+
+## Rule-window presentation
+
+- The event already owns the rule window; the administrator never selects it.
+- Show only minimal localized read-only context, for example
+ `Säännöt: 1.8.2023 →`.
+- Do not expose `trialRuleWindowId` as a normal field. The technical ID may
+ appear only in a tooltip, expandable debug information, or developer
+ diagnostics.
+- For an unverified fallback, show the R3A localized warning without asking
+ the administrator to choose another rule window.
+- Presentation components consume the R3A field set and contain no
+ rule-window-specific branching.
+
+## Lisätiedot workspace
+
+Lisätiedot is the primary R3B UX improvement. Replace the extremely long
+scrolling matrix with a dedicated workspace inside its own card.
+
+The workspace supports:
+
+- search by code;
+- search by localized name;
+- filtering by business/PDF domain;
+- collapsible domain groups;
+- expand all and collapse all;
+- a selected-row summary above the groups;
+- semantic per-era controls; and
+- responsive desktop and mobile layouts.
+
+Use the real business/PDF domains as the primary groups:
+
+- Olosuhteet;
+- Haku;
+- Haukku;
+- Metsästysinto;
+- Ajo; and
+- Muut ominaisuudet.
+
+Numeric ranges may appear as secondary information but never define the
+primary grouping.
+
+Each row presents its code, localized name, optional authoritative
+description, and semantic control together. Do not invent descriptions when
+no authoritative source exists; use localized value-kind or unit guidance
+instead.
+
+Controls come from the R3A semantic input kind:
+
+- `marker` renders as a checkbox or equivalent boolean control;
+- `integer` renders as an integer input;
+- `decimal` renders as a decimal input;
+- `text` renders as a text input; and
+- `tri-state` is allowed only when persistence explicitly distinguishes empty,
+ `0`, and `1`, and renders as meaningful localized choices rather than raw
+ persistence values.
+
+Administrators must not normally enter raw persistence values such as `0` or
+`1`. The UI translates semantic control state through registry persistence
+mapping.
+
+Selected rows remain visible in the summary above the groups. Removing a
+selected row clears all of its unsaved era values. Rows with no values are
+omitted from the create request, preserving the existing request behavior.
+
+On desktop, the workspace may use adjacent filter, group, selected-row, and
+editor regions when space permits. On mobile, it uses stacked groups or an
+overlay/sheet while preserving the same selection and semantic controls.
+These are responsive implementation choices, not separate workflows.
+
+## Erät and other sections
+
+- Keep each era visually independent in the existing card direction.
+- Preserve adding and removing continuous eras and the R3A-configured visible
+ fields.
+- Retain the current organization and validation semantics for all other
+ sections.
+- Improve only spacing, localized descriptions, hierarchy, and grouping
+ outside the Lisätiedot workspace.
+
+## Optional summary
+
+A lightweight informational summary card may appear near the bottom of the
+same page. It may summarize:
+
+- event context;
+- dog/registration data;
+- eras; and
+- selected non-empty lisätiedot.
+
+The summary is not a required confirmation step and does not add an official
+PDF preview. The existing official PDF remains available only after a saved
+entry has an ID.
+
+## Save, errors, and navigation
+
+- Preserve the existing two successful submission paths:
+ - **Save and add another** creates one result, then resets the same page with
+ event-level judge defaults restored.
+ - **Save and finish** creates one result and returns to the event workspace.
+- Preserve existing client/server validation, stable error mapping,
+ duplicate-submit protection, Server Action mutation behavior, query
+ invalidation/refetch, and the rule against optimistic partial cache
+ insertion.
+- Validation and server failures keep the populated single-page form visible
+ and identify the relevant card or field without changing navigation.
+- Preserve the existing dirty internal-navigation confirmation and native
+ refresh/tab-close protection. Browser Back retains the R2 behavior.
+- Add Finnish and Swedish labels, value hints, validation messages, success
+ feedback, and error feedback for the evolved presentation.
+- Add a user-visible `CHANGELOG.md` entry and update durable admin-trial
+ documentation when implemented.
+
+## Exclusions
+
+- No requirement to implement a wizard or mandatory multi-step flow. Any
+ guided presentation must remain a thin coordinator over the same reusable
+ cards.
+- No result-edit UI or rule-window-aware editing.
+- No migration of the edit modal to the card-based create page.
+- No unsaved PDF generation or new PDF preview endpoint.
+- No searchable dog picker, inline dog creation, batch creation, autosave,
+ draft/publish state, or reconciliation UI.
+- No Prisma schema, backend write-contract, server-validation, PDF renderer,
+ or Koiratietokanta ingestion changes.
+- No new production dependency.
+- No redesign of the trials index or event workspace.
+
+## Acceptance criteria
+
+- R3A remains the owner of business correctness and R3B changes presentation
+ only.
+- The existing result-create route remains a familiar single-page form with
+ the same save and navigation behavior.
+- A guided presentation, if adopted, reuses the same cards and field model and
+ does not change persistence, save, or navigation behavior.
+- Reusable cards render from the resolved R3A field set and contain no
+ rule-window-specific branching.
+- Rule windows remain mostly invisible: administrators cannot select one,
+ normally see only a localized period label, and do not see the technical ID
+ as a standard field.
+- Lisätiedot provides search, domain filtering, collapsible groups,
+ expand/collapse all, selected-row summary, and semantic per-era controls.
+- Business/PDF domains are the primary lisätieto grouping; numeric ranges are
+ secondary only.
+- Removing a selected lisätieto clears its unsaved values, and empty rows are
+ omitted from the create request.
+- Erät and other sections preserve their current behavior while gaining
+ clearer card hierarchy and responsive presentation.
+- The optional summary is informational and does not create a new confirmation
+ or PDF-preview step.
+- Save-and-add-another, save-and-finish, validation, dirty navigation,
+ mutation, and cache behavior remain unchanged.
+- Cards are independently reusable by future editing without implementing or
+ redesigning editing in R3B.
+- The page is usable at desktop and mobile widths in Finnish and Swedish.
+- The existing result-edit modal behaves exactly as before.
+
+## Targeted validation
+
+- Card tests for independent rendering, field-set-driven visibility,
+ validation presentation, and draft callbacks.
+- Lisätieto tests for code/name search, domain filtering, group
+ expand/collapse, selected-row summary, selection removal, semantic controls,
+ per-era values, and omission of empty rows.
+- Tests proving semantic marker and tri-state controls serialize through the
+ registry without exposing raw persistence values.
+- Responsive component tests for desktop and mobile workspace variants.
+- Regression tests for create request serialization, both success
+ continuations, stable errors, duplicate-submit protection,
+ invalidation/refetch, dirty navigation, and no optimistic row.
+- Regression tests for event-level missing/error states, the event workspace,
+ selected-event panel, and existing result-edit modal.
+- Targeted web type checking, unit/component tests, and lint without cycle
+ lint.
+- Manual desktop and mobile browser checks for the card hierarchy,
+ Lisätiedot workspace, and both successful continuations when browser tooling
+ is available.
+
+## Merge independence and review gate
+
+R3B can merge after R3A without any result-editing work. Stop after validation
+and request creation-flow review. Editing remains a separately planned and
+approved follow-up.
diff --git a/docs/planning/trials/result-fields-by-rule-window.md b/docs/planning/trials/result-fields-by-rule-window.md
index 937e7e33..0a650371 100644
--- a/docs/planning/trials/result-fields-by-rule-window.md
+++ b/docs/planning/trials/result-fields-by-rule-window.md
@@ -1,108 +1,166 @@
-# Follow-up R3 — Rule-window-aware Result Fields
+# Follow-up R3A — Rule-Window-Aware Result Creation
## Status and sequencing
-This is the second result-creation follow-up after the current R2 implementation
-and validation work. It is planning only and does not authorize implementation.
+R3A is implemented in PR #344. This file remains the planning and review
+record; current durable behavior is documented in
+[Trial rule windows](../../features/trials/rule-windows.md).
-R2 must be completed and reviewed before this work begins. The initial R3
-change introduces the rule-window structure and verifies only the current
-2023+ field set. Exact historical field-set audits remain separate follow-up
-work.
+R2 must be completed and reviewed before this work begins. R3A introduces the
+rule-window field-set structure only for the existing full-page result-create
+form and verifies the current 2023+ field set. R3B may redesign result
+creation only after R3A has been implemented, validated, and reviewed.
+
+Existing result editing remains unchanged. Rule-window-aware editing and edit
+UX are deferred work recorded in [Later UX](./later-ux.md).
## Purpose
-Make manual trial result creation and editing choose their visible score, era,
-and lisätieto fields from the event's persisted `trialRuleWindowId`. This keeps
-the current form aligned with the corresponding dog-trial PDF without coupling
-form behavior to PDF coordinates.
+Make manual trial result creation choose its visible score, era, and
+lisätieto fields from the event's persisted `trialRuleWindowId`. This aligns
+new 2023+ results with the corresponding dog-trial PDF before changing the
+creation UI.
+
+R3A owns field correctness, not layout. Its registry is the single source of
+truth for visible fields, ordering, business/PDF grouping, semantic input
+kinds, localized value hints, and the persistence metadata needed to serialize
+a selected field. Presentation components consume the resolved field set and
+must not branch on specific rule-window IDs.
The current mismatch is structural:
-- PDF rendering already selects a renderer by rule window, while the admin form
- uses one global field list.
-- The 2023+ renderer does not print the current form's `tja` and `pin` score
+- PDF rendering already selects a renderer by rule window, while the admin
+ create form uses one global field list.
+- The 2023+ renderer does not print the create form's `tja` and `pin` score
fields.
- The current PDF consumes only part `a` for lisätieto codes `25` and `27`,
while the form exposes parts `a`, `b`, and `c`.
-- Lisätieto input types differ for codes `19`, `23`, `26`, and `59`.
-- Historical templates contain different score and lisätieto sets.
+- Create-form lisätieto input kinds differ from the renderer for codes `19`,
+ `23`, `26`, and `59`.
+- Historical templates contain different score and lisätieto sets, but their
+ exact create-form configurations have not been audited.
## Scope
- Add `trialRuleWindowId` to the admin event-detail contract and propagate the
stored value through the DB and service mappings.
-- Add a semantic admin result field-set registry covering every seeded rule
- window ID.
+- Add a semantic result-create field-set registry covering every seeded rule
+ window ID. The registry defines field visibility, ordering, grouping,
+ semantic input kinds, localized value hints, and persistence mapping.
+- Define semantic input kinds independently from stored values:
+ `marker`, `integer`, `decimal`, and `text`, plus `tri-state` only for a field
+ whose persistence explicitly distinguishes empty, `0`, and `1`. The
+ registry owns the mapping between semantic control state and persisted
+ values.
+- Resolve the create field set from the event's persisted
+ `trialRuleWindowId`, never from the browser or by recalculating from the
+ event date.
- Keep PDF coordinates and drawing logic inside the PDF rule-set modules. The
- shared semantic configuration defines field availability and value kinds,
- not layout.
+ create registry defines field availability and value kinds, not layout.
- Configure `trw_post_20230801` as the first verified field set:
- - drive visible entry score fields, era fields, and lisätieto rows from the
- configuration;
- - omit current-window score fields not consumed by the 2023+ PDF;
- - represent codes `25` and `27` with the PDF-consumed `a` part without showing
- the implementation suffix to the administrator;
- - correct lisätieto value kinds to match the current renderer; and
- - retain shared registration, owner, judge, result-status, trial-type, and
- other metadata required by persistence or PDF generation.
-- Register other known, null, and unknown rule windows through one explicitly
- unverified fallback matching the current form behavior. Show a localized
- warning instead of presenting the fallback as template-verified.
-- Apply the selected field set to both the full-page create form and the
- existing edit modal.
-- Preserve hidden values when editing an existing result. Selecting a narrower
- field set must not silently clear compatibility data or source-projected
- lisätieto rows.
+ - retain the existing registration, event/result metadata, owner, judge,
+ status, note, total, and other persistence fields required by creation and
+ PDF generation;
+ - omit entry- and era-level `tja` and `pin` score fields;
+ - expose lisätieto codes `10`–`27`, `30`–`42`, and `50`–`62`;
+ - represent codes `25` and `27` with only their PDF-consumed `a` part,
+ displayed as a single row without an implementation suffix;
+ - use integer input for code `19`, decimal input for codes `23`, `26`, and
+ `59`, and retain the renderer-compatible kinds for all other rows; and
+ - keep the existing continuous-era behavior: start with one era and permit
+ additional eras without a create-form maximum, even though the current
+ PDF renders only eras 1 and 2.
+- Register older known, null, and unknown rule windows through one explicitly
+ unverified fallback matching the current show-all create-form behavior.
+- Show a localized warning whenever the unverified fallback is active.
+- Treat rule-window selection as event-owned context. The administrator never
+ chooses or changes it from the result-create form.
+- Show only a minimal localized read-only rule-period label, for example
+ `Säännöt: 1.8.2023 →`. The technical `trialRuleWindowId` may appear only in
+ a tooltip, expandable diagnostics, or developer output, never as a normal
+ user-facing form field.
+- Pass field-set configuration only into the result-create flow. Shared
+ components must keep their current default behavior so the existing edit
+ modal is unaffected.
- Keep backend write shapes and their existing validation semantics unchanged.
- This gate controls admin presentation and does not add rule-window-specific
+ R3A controls create-form presentation and does not add rule-window-specific
server rejection.
-- Update the durable admin-trial documentation and add a user-visible
- `CHANGELOG.md` entry when the behavior is implemented.
+- Update durable admin-trial documentation and add a user-visible
+ `CHANGELOG.md` entry when implemented.
## Exclusions
+- No result-create layout or navigation redesign.
+- No changes to the existing result-edit modal, its visible fields, or its
+ serialization behavior.
+- No rule-window-aware result editing or hidden-value preservation work.
- No Prisma schema or migration changes.
- No new production dependency.
-- No PDF coordinate or template changes.
-- No claim that 2005–2011 or 2011–2023 admin field sets are exact.
+- No PDF coordinate, mapper, template, or rendering changes.
+- No exact 2005–2011, 2011–2023, pre-2002, or 2002–2005 field-set claim.
- No removal or migration of stored compatibility fields.
-- No rule-window-specific validation in Koiratietokanta ingestion.
-- No redesign of result creation or the existing edit modal.
+- No rule-window-specific backend validation or Koiratietokanta ingestion
+ changes.
## Acceptance criteria
- Admin event details return the event's persisted `trialRuleWindowId`.
-- Create and edit resolve their field set from that stored ID rather than from
- the browser date.
-- A 2023+ event displays the verified score, era, and lisätieto configuration.
+- Result creation resolves its field set from that stored ID.
+- The field registry is the single source of truth for create-form visibility,
+ ordering, grouping, semantic controls, localized value hints, and
+ persistence mapping; UI components contain no rule-window-ID branches.
+- Semantic control state maps to persistence values through the registry,
+ including tri-state only where empty, `0`, and `1` are distinct domain
+ values.
+- Administrators cannot select a rule window and normally see only its
+ localized period label.
+- A 2023+ create form displays the verified score, era, and lisätieto
+ configuration.
- Codes `25` and `27` persist with part `a`, appear as single rows, and reach
the existing PDF pivot correctly.
-- Current-window lisätieto value kinds match the 2023+ renderer.
-- Other known, null, and unknown windows retain the existing generic editing
+- Current-window lisätieto input kinds match the 2023+ renderer.
+- Additional continuous eras remain available in the create form.
+- Older known, null, and unknown windows retain the existing generic creation
capability and display an unverified-field-set warning.
-- Editing through a narrower field set preserves hidden existing values.
-- Existing create/update request contracts and server validation behavior do
- not change.
+- Existing create request contracts and server validation behavior do not
+ change.
+- The existing edit modal renders and submits exactly as before.
## Targeted validation
-- Contract, DB-mapping, and service tests for `trialRuleWindowId` in admin event
- details.
-- Field-registry tests covering every seeded rule-window ID and fallback
- behavior.
-- 2023+ parity tests for visible score fields, era fields, lisätieto codes,
+- Contract, DB-mapping, and service tests for `trialRuleWindowId` in admin
+ event details.
+- Field-registry tests covering every seeded rule-window ID and null/unknown
+ fallback behavior.
+- Registry tests for semantic input kinds and control-state-to-persistence
+ mapping, including localized value hints and any explicitly configured
+ tri-state field.
+- 2023+ parity tests for visible entry fields, era fields, lisätieto codes,
parts, ordering, and input kinds.
-- Create-form and edit-modal tests for rule selection, the fallback warning,
- and hidden-value preservation.
-- Regression tests for request serialization, current validation feedback, and
- PDF part-`a` mapping.
+- Create-form tests for persisted-rule selection, fallback warning, unlimited
+ continuous eras, minimal rule-period presentation, and request
+ serialization.
+- Presentation tests proving the resolved registry configuration drives
+ rendering without rule-window-specific component branches.
+- Regression tests proving the edit modal retains its current complete field
+ set and request serialization.
+- PDF pivot regression tests for part `a` of codes `25` and `27`.
- Targeted web, contracts, server, and DB type checks and tests, plus targeted
lint without cycle lint.
-## Later historical audit
+## Merge independence and review gate
+
+R3A can merge independently after R2. It changes only the semantic
+presentation of result creation and leaves result editing unchanged.
+
+Stop after validation and request explicit review. Do not begin the
+card-based creation UX in R3B without separate approval.
+
+## Later historical and edit work
+
+After the creation gates are reviewed, plan result editing separately. That
+work must define rule-window selection, hidden stored-value preservation, and
+the edit interaction before changing the existing modal.
-After R3 is reviewed, separately compare the 2005–2011 and 2011–2023 PDF
-templates and renderers with stored legacy/API data. Replace the unverified
-fallback for each window only after its score fields, supported era behavior,
-lisätieto rows, value kinds, and preservation behavior have dedicated tests.
+Historical create/edit field sets also require separate audits against the
+2005–2011, 2011–2023, pre-2002, and 2002–2005 templates and source data.
diff --git a/packages/contracts/admin/trials/manage/admin-trial-event-details.ts b/packages/contracts/admin/trials/manage/admin-trial-event-details.ts
index f0380fa2..5a5a463d 100644
--- a/packages/contracts/admin/trials/manage/admin-trial-event-details.ts
+++ b/packages/contracts/admin/trials/manage/admin-trial-event-details.ts
@@ -66,6 +66,7 @@ export type AdminTrialEntryEraLisatieto = {
};
export type AdminTrialEventDetails = AdminTrialEventSummary & {
+ trialRuleWindowId: string | null;
entries: AdminTrialEventEntry[];
};
diff --git a/packages/db/admin/trials/manage/__tests__/get-trial-event-details.parity.test.ts b/packages/db/admin/trials/manage/__tests__/get-trial-event-details.parity.test.ts
index 728dcf2e..db95aafb 100644
--- a/packages/db/admin/trials/manage/__tests__/get-trial-event-details.parity.test.ts
+++ b/packages/db/admin/trials/manage/__tests__/get-trial-event-details.parity.test.ts
@@ -37,6 +37,7 @@ describe("getAdminTrialEventDetailsDb parity", () => {
it("maps event entries and decimal points", async () => {
trialEventFindUniqueMock.mockResolvedValue({
id: "event-1",
+ trialRuleWindowId: "trw_post_20230801",
sklKoeId: 1001,
koepaiva: new Date("2026-03-01T00:00:00.000Z"),
koekunta: "Helsinki",
@@ -95,6 +96,7 @@ describe("getAdminTrialEventDetailsDb parity", () => {
expect(result).toEqual({
trialEventId: "event-1",
+ trialRuleWindowId: "trw_post_20230801",
eventDate: new Date("2026-03-01T00:00:00.000Z"),
eventPlace: "Helsinki",
eventName: "Talvikoe",
diff --git a/packages/db/admin/trials/manage/get-trial-event-details.ts b/packages/db/admin/trials/manage/get-trial-event-details.ts
index 6b113c76..fda1062c 100644
--- a/packages/db/admin/trials/manage/get-trial-event-details.ts
+++ b/packages/db/admin/trials/manage/get-trial-event-details.ts
@@ -23,6 +23,7 @@ export async function getAdminTrialEventDetailsDb(
},
select: {
id: true,
+ trialRuleWindowId: true,
sklKoeId: true,
koepaiva: true,
koekunta: true,
@@ -117,6 +118,7 @@ export async function getAdminTrialEventDetailsDb(
return {
trialEventId: row.id,
+ trialRuleWindowId: row.trialRuleWindowId,
eventDate: row.koepaiva,
eventPlace: row.koekunta,
eventName: row.jarjestaja,
diff --git a/packages/db/admin/trials/manage/types.ts b/packages/db/admin/trials/manage/types.ts
index e86675a2..079d2d1b 100644
--- a/packages/db/admin/trials/manage/types.ts
+++ b/packages/db/admin/trials/manage/types.ts
@@ -99,6 +99,7 @@ export type AdminTrialEntryEraLisatietoDb = {
export type AdminTrialEventDetailsDb = {
trialEventId: string;
+ trialRuleWindowId: string | null;
eventDate: Date;
eventPlace: string;
eventName: string | null;
diff --git a/packages/server/admin/trials/manage/__tests__/get-trial-event.test.ts b/packages/server/admin/trials/manage/__tests__/get-trial-event.test.ts
index 2bd98309..2f1f6b13 100644
--- a/packages/server/admin/trials/manage/__tests__/get-trial-event.test.ts
+++ b/packages/server/admin/trials/manage/__tests__/get-trial-event.test.ts
@@ -55,6 +55,7 @@ describe("getAdminTrialEvent", () => {
it("maps event and entries from db", async () => {
getAdminTrialEventDetailsDbMock.mockResolvedValue({
trialEventId: "event-1",
+ trialRuleWindowId: "trw_post_20230801",
eventDate: new Date("2026-04-14T00:00:00.000Z"),
eventPlace: "Helsinki",
eventName: "Talvikoe",
@@ -99,6 +100,7 @@ describe("getAdminTrialEvent", () => {
data: {
event: {
trialEventId: "event-1",
+ trialRuleWindowId: "trw_post_20230801",
eventDate: "2026-04-14",
eventPlace: "Helsinki",
eventName: "Talvikoe",
diff --git a/packages/server/admin/trials/manage/get-trial-event.ts b/packages/server/admin/trials/manage/get-trial-event.ts
index 053e043b..2c498b95 100644
--- a/packages/server/admin/trials/manage/get-trial-event.ts
+++ b/packages/server/admin/trials/manage/get-trial-event.ts
@@ -74,6 +74,7 @@ export async function getAdminTrialEvent(
data: {
event: {
trialEventId: result.trialEventId,
+ trialRuleWindowId: result.trialRuleWindowId,
eventDate: formatTrialDateOnly(result.eventDate),
eventPlace: result.eventPlace,
eventName: result.eventName,
diff --git a/packages/server/trials/pdf/__tests__/get-trial-dog-pdf-data.test.ts b/packages/server/trials/pdf/__tests__/get-trial-dog-pdf-data.test.ts
index 6cbe5d4b..6424e64d 100644
--- a/packages/server/trials/pdf/__tests__/get-trial-dog-pdf-data.test.ts
+++ b/packages/server/trials/pdf/__tests__/get-trial-dog-pdf-data.test.ts
@@ -131,6 +131,41 @@ describe("getTrialDogPdfDataService", () => {
expect(result.body.data.trialRuleWindowId).toBe("trw_range_2005_2011");
});
+ it("pivots only PDF-consumed part a for lisatieto codes 25 and 27", async () => {
+ const base = dbRow();
+ getTrialDogPdfDataDbMock.mockResolvedValue(
+ dbRow({
+ eras: [
+ {
+ ...base.eras[0],
+ lisatiedot: [
+ { koodi: "25", osa: "a", arvo: "2.5" },
+ { koodi: "25", osa: "b", arvo: "99" },
+ { koodi: "27", osa: "a", arvo: "18" },
+ { koodi: "27", osa: "c", arvo: "88" },
+ ],
+ },
+ {
+ ...base.eras[1],
+ lisatiedot: [
+ { koodi: "25", osa: "a", arvo: "3.5" },
+ { koodi: "27", osa: "a", arvo: "20" },
+ ],
+ },
+ ],
+ }),
+ );
+
+ const result = await getTrialDogPdfDataService("entry-1");
+
+ expect(result.status).toBe(200);
+ if (!result.body.ok) throw new Error("Expected ok=true");
+ expect(result.body.data.lisatiedotRows).toEqual([
+ { koodi: "25", era1: "2.5", era2: "3.5" },
+ { koodi: "27", era1: "18", era2: "20" },
+ ]);
+ });
+
it("derives accepted driving minutes and driving time points for legacy rows", async () => {
getTrialDogPdfDataDbMock.mockResolvedValue(dbRow());
|