- Add `currentTrainMetadata.ts` to manage carrying forward official position metadata between train snapshots. - Introduce `lineDisplayName.ts` for mapping line IDs to display names. - Create `officialPositionCache.ts` for parsing and serializing official position data with cache validation. - Enhance `officialPositionService.ts` to support persistent caching of official positions and improve resolver functionality. - Update `requestCoordinator.ts` to allow cancellation of polling requests without disposing of the coordinator. - Modify `trainTimeFiltering.ts` to incorporate line ID context when checking for duplicate train data. - Refactor `useCurrentTrain.tsx` to support polling for current train data with official position enrichment. - Add tests for new features, including persistent cache behavior and handling of ambiguous train data.
77 lines
2.6 KiB
TypeScript
77 lines
2.6 KiB
TypeScript
import type { OfficialPosition } from "@/lib/domain/railway";
|
|
|
|
const CACHE_VERSION = 1;
|
|
const MAX_CACHE_ENTRIES = 1000;
|
|
|
|
type CacheRecord = Record<string, unknown>;
|
|
|
|
const isRecord = (value: unknown): value is CacheRecord =>
|
|
typeof value === "object" && value !== null;
|
|
|
|
const isOfficialPosition = (value: unknown): value is OfficialPosition => {
|
|
if (!isRecord(value)) return false;
|
|
return (
|
|
typeof value.positionId === "string" &&
|
|
value.positionId.length > 0 &&
|
|
typeof value.pos === "string" &&
|
|
value.pos.length > 0 &&
|
|
typeof value.line === "string" &&
|
|
value.line.length > 0 &&
|
|
typeof value.posNum === "string" &&
|
|
value.posNum.length > 0 &&
|
|
["unclassified", "station", "between", "prediction", "other"].includes(
|
|
value.positionType as string,
|
|
) &&
|
|
(value.platform === null || typeof value.platform === "string") &&
|
|
(value.track === null || typeof value.track === "string") &&
|
|
(value.note === null || typeof value.note === "string") &&
|
|
["unreviewed", "partial", "reviewed"].includes(
|
|
value.enrichmentStatus as string,
|
|
) &&
|
|
(value.firstSeenAt === null || typeof value.firstSeenAt === "string") &&
|
|
(value.lastSeenAt === null || typeof value.lastSeenAt === "string") &&
|
|
typeof value.revision === "number" &&
|
|
Number.isInteger(value.revision) &&
|
|
value.revision >= 0
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Parse the local warm cache without trusting its contents. Invalid or old
|
|
* entries are ignored so a broken cache never blocks the live resolver.
|
|
*/
|
|
export const parseOfficialPositionCache = (
|
|
raw: unknown,
|
|
): Map<string, OfficialPosition> => {
|
|
let value = raw;
|
|
if (typeof value === "string") {
|
|
try {
|
|
value = JSON.parse(value) as unknown;
|
|
} catch {
|
|
return new Map();
|
|
}
|
|
}
|
|
if (!isRecord(value) || value.version !== CACHE_VERSION) return new Map();
|
|
if (!Array.isArray(value.entries)) return new Map();
|
|
|
|
const result = new Map<string, OfficialPosition>();
|
|
for (const entry of value.entries.slice(-MAX_CACHE_ENTRIES)) {
|
|
if (!isRecord(entry)) continue;
|
|
if (typeof entry.key !== "string" || !entry.key) continue;
|
|
if (!isOfficialPosition(entry.value)) continue;
|
|
result.set(entry.key, entry.value);
|
|
}
|
|
return result;
|
|
};
|
|
|
|
/** Serialize only the bounded, validated metadata cache. */
|
|
export const serializeOfficialPositionCache = (
|
|
entries: ReadonlyMap<string, OfficialPosition>,
|
|
): string => {
|
|
const serialized = Array.from(entries.entries())
|
|
.filter(([, value]) => isOfficialPosition(value))
|
|
.slice(-MAX_CACHE_ENTRIES)
|
|
.map(([key, value]) => ({ key, value }));
|
|
return JSON.stringify({ version: CACHE_VERSION, entries: serialized });
|
|
};
|