feat: add temporal context and contextual help

This commit is contained in:
2026-08-05 00:03:31 +02:00
parent 982ef636b8
commit add7a99f6d
43 changed files with 1878 additions and 167 deletions
+112
View File
@@ -0,0 +1,112 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode
} from "react";
import {
DEFAULT_TEMPORAL_DATA_SELECTION,
dispatchTemporalContextChanged,
normalizeTemporalDataSelection,
setActiveTemporalDataSelection,
temporalDataSelectionIsDefault,
temporalDataSelectionKey,
type TemporalDataSelection
} from "./temporal";
const TEMPORAL_STORAGE_PREFIX = "govoplan.temporal-data";
type TemporalContextValue = {
selection: TemporalDataSelection;
applySelection: (selection: TemporalDataSelection) => void;
isDefault: boolean;
selectionKey: string;
};
const TemporalContext = createContext<TemporalContextValue>({
selection: DEFAULT_TEMPORAL_DATA_SELECTION,
applySelection: () => undefined,
isDefault: true,
selectionKey: temporalDataSelectionKey(DEFAULT_TEMPORAL_DATA_SELECTION)
});
export function PlatformTemporalProvider({
scopeKey,
children
}: {
scopeKey: string;
children: ReactNode;
}) {
const [selection, setSelection] = useState<TemporalDataSelection>(() =>
loadSelection(scopeKey)
);
useEffect(() => {
const stored = loadSelection(scopeKey);
setActiveTemporalDataSelection(stored);
setSelection(stored);
dispatchTemporalContextChanged(stored, scopeKey);
return () => {
setActiveTemporalDataSelection(DEFAULT_TEMPORAL_DATA_SELECTION);
};
}, [scopeKey]);
const applySelection = useCallback((next: TemporalDataSelection) => {
const normalized = normalizeTemporalDataSelection(next);
setActiveTemporalDataSelection(normalized);
storeSelection(scopeKey, normalized);
setSelection(normalized);
dispatchTemporalContextChanged(normalized, scopeKey);
}, [scopeKey]);
const value = useMemo<TemporalContextValue>(() => ({
selection,
applySelection,
isDefault: temporalDataSelectionIsDefault(selection),
selectionKey: temporalDataSelectionKey(selection)
}), [applySelection, selection]);
return (
<TemporalContext.Provider value={value}>
{children}
</TemporalContext.Provider>
);
}
export function useTemporalDataContext(): TemporalContextValue {
return useContext(TemporalContext);
}
function storageKey(scopeKey: string): string {
return `${TEMPORAL_STORAGE_PREFIX}.${scopeKey}`;
}
function loadSelection(scopeKey: string): TemporalDataSelection {
if (typeof sessionStorage === "undefined") {
return DEFAULT_TEMPORAL_DATA_SELECTION;
}
const stored = sessionStorage.getItem(storageKey(scopeKey));
if (!stored) return DEFAULT_TEMPORAL_DATA_SELECTION;
try {
return normalizeTemporalDataSelection(JSON.parse(stored));
} catch {
sessionStorage.removeItem(storageKey(scopeKey));
return DEFAULT_TEMPORAL_DATA_SELECTION;
}
}
function storeSelection(
scopeKey: string,
selection: TemporalDataSelection
): void {
if (typeof sessionStorage === "undefined") return;
if (temporalDataSelectionIsDefault(selection)) {
sessionStorage.removeItem(storageKey(scopeKey));
return;
}
sessionStorage.setItem(storageKey(scopeKey), JSON.stringify(selection));
}
+2
View File
@@ -194,6 +194,7 @@ function applyServerMetadata(module: PlatformWebModule, info: PlatformModuleInfo
routes: routesWithServerMetadata(module, info),
publicRoutes: filterPublicRoutes(module, info.frontend?.public_routes),
viewSurfaces: mergeViewSurfaces(module, info),
helpContexts: info.help_contexts ?? module.helpContexts,
uiCapabilities: {
...(module.uiCapabilities ?? {}),
...runtimeUiCapabilitiesForModule(module, info)
@@ -218,6 +219,7 @@ function publicModuleInfo(info: PlatformPublicModuleInfo): PlatformModuleInfo {
dependencies: [],
optional_dependencies: [],
enabled: true,
help_contexts: info.help_contexts,
runtime_ui_capabilities: [],
nav: [],
frontend: {
+98
View File
@@ -0,0 +1,98 @@
export type TemporalValidityMode = "current" | "at" | "all";
export type TemporalDataSelection = {
validityMode: TemporalValidityMode;
validAt: string | null;
recordedAt: string | null;
};
export type TemporalContextChangedEventDetail = {
selection: TemporalDataSelection;
scopeKey: string;
};
export const PLATFORM_TEMPORAL_CONTEXT_CHANGED_EVENT =
"govoplan:temporal-context-changed";
export const DEFAULT_TEMPORAL_DATA_SELECTION: TemporalDataSelection = {
validityMode: "current",
validAt: null,
recordedAt: null
};
let activeSelection = DEFAULT_TEMPORAL_DATA_SELECTION;
export function normalizeTemporalDataSelection(
value: Partial<TemporalDataSelection> | null | undefined
): TemporalDataSelection {
const validityMode = ["current", "at", "all"].includes(
String(value?.validityMode)
)
? value?.validityMode as TemporalValidityMode
: "current";
const validAt = validityMode === "at"
? normalizeTimestamp(value?.validAt)
: null;
if (validityMode === "at" && !validAt) {
throw new Error("i18n:govoplan-core.select_valid_date_time");
}
return {
validityMode,
validAt,
recordedAt: normalizeTimestamp(value?.recordedAt)
};
}
export function temporalDataSelectionIsDefault(
value: TemporalDataSelection
): boolean {
return value.validityMode === "current" && !value.recordedAt;
}
export function temporalDataSelectionKey(value: TemporalDataSelection): string {
return [value.validityMode, value.validAt ?? "", value.recordedAt ?? ""].join(":");
}
export function setActiveTemporalDataSelection(
value: TemporalDataSelection
): void {
activeSelection = normalizeTemporalDataSelection(value);
}
export function activeTemporalDataSelection(): TemporalDataSelection {
return activeSelection;
}
export function temporalRequestHeaders(): Record<string, string> {
const selection = activeTemporalDataSelection();
if (temporalDataSelectionIsDefault(selection)) return {};
const headers: Record<string, string> = {
"X-Govoplan-Validity-Mode": selection.validityMode
};
if (selection.validAt) headers["X-Govoplan-Valid-At"] = selection.validAt;
if (selection.recordedAt) {
headers["X-Govoplan-Recorded-At"] = selection.recordedAt;
}
return headers;
}
export function dispatchTemporalContextChanged(
selection: TemporalDataSelection,
scopeKey: string
): void {
if (typeof window === "undefined") return;
window.dispatchEvent(
new CustomEvent<TemporalContextChangedEventDetail>(
PLATFORM_TEMPORAL_CONTEXT_CHANGED_EVENT,
{ detail: { selection, scopeKey } }
)
);
}
function normalizeTimestamp(value: string | null | undefined): string | null {
const clean = String(value || "").trim();
if (!clean) return null;
const instant = new Date(clean);
if (!Number.isFinite(instant.getTime())) return null;
return instant.toISOString();
}