Files
Jyotisha/frontend/src/lib/rectification-timeline-scale.ts
T
jesse-ux cfb41daf3d
Independent Staging Quality Gate / validate (push) Failing after 6m28s
Independent Staging Quality Gate / publish (push) Skipped
feat(rectification): 出卡加精度门槛,补经历改成系统点名
宽度超过 10 分钟或头名并列时不再出交付卡,改为按大运边界逐条问、
用类型芯片和年/月选择器录入。跳过的线换问法再问一次;答「这类事
都没有过」的不再问。用户说「没有了」仍立刻给目前范围。Skill 10.0.27。

BUG-740~743
2026-09-16 18:35:27 +08:00

291 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Geometry for the rectification timeline: clock strings in, percentages out.
*
* Kept apart from the component so the scale is testable without a DOM. The
* component renders what this returns and decides nothing itself.
*
* Two rules this module exists to hold:
*
* - The axis is the **current** search window (`candidate_range`), never the
* opening one. The window widens (BUG-572: ±15 → ±30 → ±60 → ±120) and
* narrows (a chosen block), so an axis pinned to the opening window would be
* overrun by its own band.
* - Candidate marks carry no confidence. BUG-560 is blocked: relative support
* between candidate minutes is 7–9 out of 100 and calibration found no
* minute-level discriminating power, so a graded mark would render a
* difference the engine cannot support. The only encoding is in-range or
* excluded.
*
* There is deliberately no "narrowed from N minutes to M" readout. The opening
* window is on the wire, but no projection carries that comparison as a field,
* and VOICE.md rule 2 puts progress figures on the server. Computing it here
* would also be wrong as often as right: the axis is the *current* window and
* it widens (BUG-572), so the arithmetic would report progress through a
* widening. The narration keeps saying it, composed server-side where the case
* history is. See docs/tasks/PROGRESS-cend-rectification-20260916.md.
*/
const MINUTES_PER_DAY = 1440;
export type RectificationTimelineStage = "minute" | "block_scan";
export type TimelineMark = Readonly<{
/** Stable across re-renders so CSS transitions animate rather than restart. */
key: string;
percent: number;
/** Binary by design; see the module note on BUG-560. */
state: "in" | "out";
}>;
export type TimelineTick = Readonly<{
key: string;
percent: number;
label: string;
}>;
export type RectificationTimelineView = Readonly<{
/** Inclusive clock labels for the axis ends, for assistive text. */
axisStartLabel: string;
axisEndLabel: string;
bandStartPercent: number;
bandWidthPercent: number;
marks: readonly TimelineMark[];
ticks: readonly TimelineTick[];
/** `05:07–05:09` */
rangeLabel: string;
/** `3 分钟` */
widthLabel: string;
/**
* `代表分钟 05:08`, or `已采用 04:53` once a minute has been adopted. Null
* when the case has no working minute yet.
*
* Adopted is not confirmed: the label states which minute is in use and makes
* no claim about the birth minute being settled. The full boundary sentence
* stays where it belongs, in the delivery/adopt narration.
*/
workingLabel: string | null;
/**
* `已答 6 题`, straight off `inference_state.answered_probe_count`. Null when
* the projection carries no count — never a zero this module invented.
*/
answeredLabel: string | null;
/** `已对照 N 件`. Null when the projection carries no dated-event count. */
datedEventLabel: string | null;
}>;
/** `HH:MM` or `HH:MM:SS` to minutes past midnight; null when unparseable. */
export function parseClockMinutes(value: unknown): number | null {
if (typeof value !== "string") return null;
const match = /^(\d{1,2}):(\d{2})(?::\d{2})?$/.exec(value.trim());
if (!match) return null;
const hours = Number(match[1]);
const minutes = Number(match[2]);
if (!Number.isInteger(hours) || !Number.isInteger(minutes)) return null;
if (hours > 23 || minutes > 59) return null;
return hours * 60 + minutes;
}
export function formatClockMinutes(value: number): string {
const wrapped = ((Math.round(value) % MINUTES_PER_DAY) + MINUTES_PER_DAY) % MINUTES_PER_DAY;
const hours = Math.floor(wrapped / 60);
const minutes = wrapped % 60;
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
}
/**
* Declared periods may cross midnight (`late_night` is 23:00–03:59), which
* arrives as an end earlier than its start. Unrolling onto a second day keeps
* the axis monotonic; every other calculation then works on plain numbers.
*/
export function unrollWindowEnd(start: number, end: number): number {
return end >= start ? end : end + MINUTES_PER_DAY;
}
/** Place a clock reading on an axis that may run past midnight. */
export function alignToAxis(minute: number, axisStart: number, axisEnd: number): number {
if (minute >= axisStart) return minute;
const shifted = minute + MINUTES_PER_DAY;
return shifted <= axisEnd ? shifted : minute;
}
/** Coarser ticks as the window grows; the labels stay on round clock values. */
export function timelineTickStepMinutes(span: number): number {
if (span <= 40) return 5;
if (span <= 90) return 10;
if (span <= 180) return 20;
if (span <= 400) return 60;
if (span <= 900) return 120;
return 240;
}
export function timelineDurationLabel(minutes: number): string {
const total = Math.max(0, Math.round(minutes));
if (total < 60) return `${total} 分钟`;
const hours = Math.floor(total / 60);
const rest = total % 60;
return rest === 0 ? `${hours} 小时` : `${hours} 小时 ${rest} 分`;
}
function clampPercent(value: number): number {
if (!Number.isFinite(value)) return 0;
if (value < 0) return 0;
if (value > 100) return 100;
return value;
}
/** Position on the axis as a percentage; callers clamp-safe by construction. */
export function timelinePercent(minute: number, axisStart: number, axisEnd: number): number {
const span = axisEnd - axisStart;
if (span <= 0) return 0;
return clampPercent(((minute - axisStart) / span) * 100);
}
function buildTicks(axisStart: number, axisEnd: number): TimelineTick[] {
const span = axisEnd - axisStart;
const step = timelineTickStepMinutes(span);
const ticks: TimelineTick[] = [];
const first = Math.ceil(axisStart / step) * step;
for (let at = first; at <= axisEnd; at += step) {
ticks.push({
key: `t${at}`,
percent: timelinePercent(at, axisStart, axisEnd),
label: formatClockMinutes(at),
});
}
return ticks;
}
export type TimelineInferenceMark = Readonly<{
time: string;
eliminated: boolean;
}>;
export type RectificationTimelineInput = Readonly<{
/** `candidate_range`: the case's current search window, and the axis. */
searchWindow: readonly [string, string] | null;
/** `credible_range`; absent during block scan, where the band is the window. */
credibleRange: readonly [string, string] | null;
candidateTimes: readonly string[];
/**
* Inference-layer minutes with elimination status. When present and non-empty,
* these are the marks; `eliminated` is the only in/out judge. When absent,
* engine `candidateTimes` are drawn solid.
*/
inferenceMarks?: readonly TimelineInferenceMark[];
stage: RectificationTimelineStage | null;
/**
* `workingRectificationTime(result)` — the very call the board's title-row
* clock makes. Passing the resolved minute rather than the result keeps one
* source for both surfaces, so the bar and the board can never disagree.
*/
workingTime?: string | null;
/** True once the reader adopted this minute, which changes only the label. */
workingAdopted?: boolean;
/** `inference_state.answered_probe_count`; absent when not projected. */
answeredProbeCount?: number | null;
/** Dated confirmed events already on the ledger. */
datedEventCount?: number | null;
}>;
/**
* Returns null when the bar has nothing truthful to draw — the caller renders
* an equal-height skeleton rather than shrinking, because the bar sits outside
* the scroll container and a height change would silently break stick-to-bottom.
*/
export function buildRectificationTimeline(
input: RectificationTimelineInput,
): RectificationTimelineView | null {
const windowStart = parseClockMinutes(input.searchWindow?.[0]);
const windowEndRaw = parseClockMinutes(input.searchWindow?.[1]);
if (windowStart === null || windowEndRaw === null) return null;
const axisStart = windowStart;
const axisEnd = unrollWindowEnd(windowStart, windowEndRaw);
if (axisEnd <= axisStart) return null;
// Block scan has no scored candidates yet, so the credible range is the whole
// window. Saying "24 小时" there is accurate, not a placeholder.
const bandStartRaw = parseClockMinutes(input.credibleRange?.[0]);
const bandEndRaw = parseClockMinutes(input.credibleRange?.[1]);
const hasBand = bandStartRaw !== null && bandEndRaw !== null;
const bandStart = hasBand
? Math.max(axisStart, alignToAxis(bandStartRaw, axisStart, axisEnd))
: axisStart;
const bandEnd = hasBand
? Math.min(axisEnd, Math.max(bandStart, alignToAxis(unrollWindowEnd(bandStartRaw, bandEndRaw), axisStart, axisEnd)))
: axisEnd;
const bandStartPercent = timelinePercent(bandStart, axisStart, axisEnd);
const bandWidthPercent = clampPercent(
timelinePercent(bandEnd, axisStart, axisEnd) - bandStartPercent,
);
// Minute stage only: during block scan the marks would be blocks, and their
// boundaries do not reach the client as structured data (see PROGRESS).
const marks: TimelineMark[] = [];
if (input.stage === "minute") {
const sourced = input.inferenceMarks && input.inferenceMarks.length > 0
? input.inferenceMarks.map((mark) => ({ time: mark.time, eliminated: mark.eliminated }))
: input.candidateTimes.map((time) => ({ time, eliminated: false }));
const seen = new Set<number>();
for (const item of sourced) {
const parsed = parseClockMinutes(item.time);
if (parsed === null) continue;
const at = alignToAxis(parsed, axisStart, axisEnd);
if (at < axisStart || at > axisEnd) continue;
if (seen.has(at)) continue;
seen.add(at);
marks.push({
key: `m${at}`,
percent: timelinePercent(at, axisStart, axisEnd),
// Status from the inference layer, never band position (BUG-603).
state: item.eliminated ? "out" : "in",
});
}
marks.sort((left, right) => left.percent - right.percent);
}
return {
axisStartLabel: formatClockMinutes(axisStart),
axisEndLabel: formatClockMinutes(axisEnd),
bandStartPercent,
bandWidthPercent,
marks,
ticks: buildTicks(axisStart, axisEnd),
rangeLabel: `${formatClockMinutes(bandStart)}–${formatClockMinutes(bandEnd)}`,
widthLabel: timelineDurationLabel(bandEnd - bandStart + 1),
workingLabel: workingMinuteLabel(input.workingTime, input.workingAdopted === true),
answeredLabel: answeredProbeLabel(input.answeredProbeCount),
datedEventLabel: datedEventLabel(input.datedEventCount),
};
}
/**
* The working minute as the bar says it. The clock string is echoed, not
* recomputed: an unparseable value yields no label rather than a guess.
*/
export function workingMinuteLabel(
workingTime: string | null | undefined,
adopted: boolean,
): string | null {
const minutes = parseClockMinutes(workingTime);
if (minutes === null) return null;
const clock = formatClockMinutes(minutes);
return adopted ? `已采用 ${clock}` : `代表分钟 ${clock}`;
}
/**
* Renders the server's answered-probe count and nothing else. A missing count
* stays missing: showing `已答 0 题` where the projection said nothing would be
* a number this layer made up.
*/
export function answeredProbeLabel(count: number | null | undefined): string | null {
if (typeof count !== "number" || !Number.isInteger(count) || count <= 0) return null;
return `已答 ${count} 题`;
}
export function datedEventLabel(count: number | null | undefined): string | null {
if (typeof count !== "number" || !Number.isInteger(count) || count <= 0) return null;
return `已对照 ${count} 件`;
}