feat(rectification): 区间常驻条补代表分钟与已答题数,盘面五段折叠收成两段
R7 / 决策 D10。任务书 docs/tasks/TASK-cend-surfaces-claude-alignment-20260916.md。 T7.1 常驻条。任务书 E9 说区间只在盘面标题行,实际读代码:对话区顶部 已有常驻条 RectificationTimeline 且已显示区间与宽度,窄屏 .is-compact 下 也可见。真正只存在于 .rectification-board__clock 的是代表分钟——窄屏盘面 是默认关闭的 overlay,代表分钟就此看不见。所以没有另造第二条(会撞固定条 高这条正确性约束),而是在既有条上补齐缺的两项: 05:07–05:09 · 3 分钟 · 代表分钟 05:08 · 已答 6 题 代表分钟与盘面标题行是同一次 workingRectificationTime(result) 调用,一个 值两处用,不是两条推导路径。已答题数逐字读服务端的 inference_state.answered_probe_count,投影没给就整项不显示,不补「已答 0 题」。 条上不写确认,边界句仍只在交付/采用旁白里出现一次。读数只增宽不增高:条高 在滚动容器外,变高会让贴底读者的内容无声滑出视野。 收窄进度未做,触发让步顺序第 5 条。没有这个字段(服务端只把这句话拼进旁白 正文),且前端自己减会算错——candidate_range 是当前窗口且会放宽(BUG-572), 拿它当「最初」相减会把一次放宽报成一次收窄。缺口与解除条件写进 BLOCKED.md。 T7.2 盘面折叠。实际有 8 个 details(任务书记的五处漏了交界节奏、错标了两块), 按内容归成「候选分钟」(换升时刻 + 次级候选)与「参数与口径」(宫位表、确认门、 本轮技法、两段本命上升、经历与大运对照含双轨一致性、交界节奏)。每块保留原有 渲染条件,engineMeaningToDisplayCopy() 仍是 5 处,逐分钟行的 is-changed / 当前时间 一字未改。共享组件 technique-audit-disclosure.tsx 未动。 顺带修掉一个静默失效的断言:rectification-agentic-entry 用 board.slice(indexOf(A), indexOf(B)),B 改名后 indexOf 返回 -1,slice(i, -1) 会悄悄退化成几乎整份文件,底下四条 doesNotMatch 照样绿。已换成带越界断言的 sliceBetween()。两处改动的断言都在测试里写了原值/新值/原因。 tsc 0 错;lint 0 error / 118 warning(同基线);npm test 3350 → 3360,失败 仍是 31 条无 Docker 用例且与基线逐条一致;next build exit 0,/ 仍 ○ Static; 产物 CSS gzip 39,017 → 38,973 B(−0.11%)。 窄屏 .is-compact 下常驻条的实际可见性无 Chrome 无法验证,已按任务书写成环境 缺口,未标通过;CSS 上已确认无任何规则在该状态下隐藏该条,并写成测试断言。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
This commit is contained in:
co-authored by
Claude Opus 5
parent
a1a78c7ebc
commit
ca0204984b
@@ -2523,6 +2523,11 @@ button.nav-rail-identity:hover { background: var(--sidebar-accent); }
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
}
|
||||
/* Both take the single cell and stack. The chat keeps its three rows here, so
|
||||
the range bar is on screen at every width — the overlay only covers it while
|
||||
the board is deliberately open, and it is closed by default. Nothing below
|
||||
may hide `.rectification-timeline` under `.is-compact`: on a narrow viewport
|
||||
that bar is the only place the range and the working minute appear (D10). */
|
||||
.rectification-workspace.is-compact .rectification-workspace__chat,
|
||||
.rectification-workspace.is-compact .rectification-board-overlay {
|
||||
grid-area: 1 / 1;
|
||||
@@ -2562,11 +2567,19 @@ button.nav-rail-identity:hover { background: var(--sidebar-accent); }
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
overflow: hidden;
|
||||
}
|
||||
/* One line, always. The bar's height is fixed on purpose (see above), so the
|
||||
readout may grow wider but never taller; wrapping would push the axis out of
|
||||
a box nothing is observing. Four short items clear 375px with room to spare,
|
||||
and the bar's own `overflow: hidden` clips rather than reflows if they ever
|
||||
do not. */
|
||||
.rectification-timeline__readout {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: nowrap;
|
||||
gap: var(--space-2) var(--space-3);
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
@@ -2578,6 +2591,22 @@ button.nav-rail-identity:hover { background: var(--sidebar-accent); }
|
||||
.rectification-timeline__width {
|
||||
color: var(--color-ink-secondary);
|
||||
}
|
||||
/* The working minute reads at the same weight as the range: on a compact
|
||||
viewport the board is a closed overlay, so this is the only place it shows. */
|
||||
.rectification-timeline__working {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 500;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
/* Last in the row and first to be clipped: the count is context, not the
|
||||
answer, and it is the only item the reader can do without. */
|
||||
.rectification-timeline__answered {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--color-ink-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.rectification-timeline__axis {
|
||||
position: relative;
|
||||
height: 26px;
|
||||
@@ -2832,6 +2861,22 @@ button.nav-rail-identity:hover { background: var(--sidebar-accent); }
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
/* The board folds to two sections, so most disclosures are now nested one level
|
||||
inside 候选分钟 or 参数与口径. The reset above strips every separator, which
|
||||
left the nested rows flush against each other; the rhythm inside an open
|
||||
section is restored here rather than by relaxing that reset, which the
|
||||
top-level sections still want. */
|
||||
.rectification-board .technique-audit > .technique-audit,
|
||||
.rectification-board .technique-audit > .technique-audit-panel,
|
||||
.rectification-board .technique-audit > .rectification-board__group,
|
||||
.rectification-board .technique-audit > .rectification-board__minutes,
|
||||
.rectification-board .technique-audit > .rectification-board__disclosure {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
/* A label sits closer to the list it names than to the block before it. */
|
||||
.rectification-board .technique-audit > .rectification-board__group + .rectification-board__minutes {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
.rectification-board__section {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
@@ -2843,6 +2888,16 @@ button.nav-rail-identity:hover { background: var(--sidebar-accent); }
|
||||
font-weight: 600;
|
||||
letter-spacing: -.2px;
|
||||
}
|
||||
/* Names the list that follows it now that 换升时刻 is a group inside 候选分钟
|
||||
rather than a fold of its own. Not a <summary>: this content is always open
|
||||
once its section is, so a second disclosure to click would be one more step
|
||||
to the same place. */
|
||||
.rectification-board__group {
|
||||
margin: 0;
|
||||
color: var(--color-ink-secondary);
|
||||
font-size: var(--type-caption);
|
||||
font-weight: 600;
|
||||
}
|
||||
.rectification-board__minutes {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
|
||||
@@ -43,6 +43,7 @@ import { RectificationRangeDelivery } from "@/components/rectification-range-del
|
||||
import {
|
||||
diffRectificationBoard,
|
||||
RECTIFICATION_BOARD_SPLIT_MIN_PX,
|
||||
workingRectificationTime,
|
||||
} from "@/lib/rectification-board-model";
|
||||
import {
|
||||
rectificationAdoptingLabel,
|
||||
@@ -1405,6 +1406,12 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
candidateTimes: candidateResult?.candidates.map((candidate) => candidate.time) ?? [],
|
||||
inferenceMarks: candidateResult?.inferenceMarks ?? [],
|
||||
stage: caseStage,
|
||||
// The same call the board's title-row clock makes, on the same result, so
|
||||
// the bar and `.rectification-board__clock` are one number by construction
|
||||
// rather than by two code paths that happen to agree today.
|
||||
workingTime: workingRectificationTime(candidateResult),
|
||||
workingAdopted: Boolean(candidateResult?.selectedTime),
|
||||
answeredProbeCount: candidateResult?.answeredProbeCount ?? null,
|
||||
});
|
||||
const persistedOfferKey = [...messages].reverse().find((message) => message.candidateOffer)?.renderKey;
|
||||
const liveSelectionCardKey = persistedOfferKey
|
||||
|
||||
@@ -70,6 +70,20 @@ function RectificationBoardBody({
|
||||
const contrast = result?.lagnaContrast ?? null;
|
||||
const agreement = result?.dashaAgreement ?? null;
|
||||
const nakshatra = result?.nakshatraBoundary ?? null;
|
||||
// Two sections, not five (D10). Nothing was dropped in the regrouping — the
|
||||
// minutes moved under 候选分钟, and the house table, the confirmation gate,
|
||||
// the technique audit, the two-lagna split, the event/dasha ledger with its
|
||||
// dual-track agreement line and the nakshatra note all moved under 参数与口径.
|
||||
// Each block keeps the render condition it had, so a state that showed
|
||||
// nothing before still shows nothing: the gate and the audit stay behind
|
||||
// `table`, exactly as when they sat inside `.rectification-snapshot`.
|
||||
const hasMinuteDetail = scoringMinutes.length > 0 || displayMinutes.length > 0;
|
||||
const hasParameterDetail = Boolean(
|
||||
table
|
||||
|| contrast
|
||||
|| ledger.length > 0
|
||||
|| (nakshatra?.near_boundary && nakshatra.user_meaning),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -114,26 +128,17 @@ function RectificationBoardBody({
|
||||
])}
|
||||
/>
|
||||
</figure>
|
||||
<details className="technique-audit">
|
||||
<summary>本命宫位表</summary>
|
||||
<RectificationHouseTableView
|
||||
table={table}
|
||||
changedHouses={changedHouses}
|
||||
lagnaChanged={diff.lagnaChanged}
|
||||
/>
|
||||
</details>
|
||||
{result && <ConfirmationGateDisclosure gate={result.confirmationGate} />}
|
||||
{savedStatus === "accepted" && result && result.techniqueAudit.length > 0 && (
|
||||
<TechniqueAuditDisclosure rows={result.techniqueAudit} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(scoringMinutes.length > 0 || displayMinutes.length > 0) && (
|
||||
<details className="technique-audit" aria-label="换升时刻">
|
||||
{hasMinuteDetail && (
|
||||
<details className="technique-audit" aria-label="候选分钟">
|
||||
<summary>
|
||||
换升时刻 · {grouped.length} 个
|
||||
候选分钟
|
||||
<small>{grouped.length} 个时刻</small>
|
||||
</summary>
|
||||
{scoringMinutes.length > 0 && (
|
||||
<>
|
||||
<p className="rectification-board__group">换升时刻</p>
|
||||
<ol className="rectification-board__minutes">
|
||||
{scoringMinutes.map((row) => {
|
||||
const changed = changedMinutes.has(row.at);
|
||||
@@ -155,11 +160,12 @@ function RectificationBoardBody({
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</>
|
||||
)}
|
||||
{displayMinutes.length > 0 && (
|
||||
<details className="technique-audit">
|
||||
<summary>
|
||||
显示层
|
||||
次级候选
|
||||
<small>{displayMinutes.length} 个时刻</small>
|
||||
</summary>
|
||||
<ol className="rectification-board__minutes">
|
||||
@@ -178,40 +184,61 @@ function RectificationBoardBody({
|
||||
)}
|
||||
</details>
|
||||
)}
|
||||
{contrast && (
|
||||
<details className="technique-audit">
|
||||
<summary>两段本命上升</summary>
|
||||
<div className="rectification-board__disclosure">
|
||||
<p>{engineMeaningToDisplayCopy(contrast.user_meaning)}</p>
|
||||
<ul>
|
||||
{contrast.intervals.map((interval) => (
|
||||
<li key={`${interval.start}-${interval.end}`}>
|
||||
{interval.start}-{interval.end}:{interval.lagna}
|
||||
{interval.lords.l1 ? `;一/四/七/十宫主 ${[interval.lords.l1, interval.lords.l4, interval.lords.l7, interval.lords.l10].filter(Boolean).join("、")}` : ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
{ledger.length > 0 && (
|
||||
<details className="technique-audit">
|
||||
<summary>
|
||||
经历与大运对照
|
||||
<small>{ledger.length} 项</small>
|
||||
</summary>
|
||||
<ul className="rectification-board__disclosure">
|
||||
{ledger.map((item) => (
|
||||
<li key={item.summary}>{engineMeaningToDisplayCopy(item.user_meaning)}</li>
|
||||
))}
|
||||
</ul>
|
||||
{agreement ? <p className="rectification-board__disclosure">{engineMeaningToDisplayCopy(agreement.user_meaning)}</p> : null}
|
||||
</details>
|
||||
)}
|
||||
{nakshatra?.near_boundary && nakshatra.user_meaning && (
|
||||
<details className="technique-audit">
|
||||
<summary>交界节奏</summary>
|
||||
<p className="rectification-board__disclosure">{engineMeaningToDisplayCopy(nakshatra.user_meaning)}</p>
|
||||
{hasParameterDetail && (
|
||||
<details className="technique-audit" aria-label="参数与口径">
|
||||
<summary>参数与口径</summary>
|
||||
{table && (
|
||||
<>
|
||||
<details className="technique-audit">
|
||||
<summary>本命宫位表</summary>
|
||||
<RectificationHouseTableView
|
||||
table={table}
|
||||
changedHouses={changedHouses}
|
||||
lagnaChanged={diff.lagnaChanged}
|
||||
/>
|
||||
</details>
|
||||
{result && <ConfirmationGateDisclosure gate={result.confirmationGate} />}
|
||||
{savedStatus === "accepted" && result && result.techniqueAudit.length > 0 && (
|
||||
<TechniqueAuditDisclosure rows={result.techniqueAudit} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{contrast && (
|
||||
<details className="technique-audit">
|
||||
<summary>两段本命上升</summary>
|
||||
<div className="rectification-board__disclosure">
|
||||
<p>{engineMeaningToDisplayCopy(contrast.user_meaning)}</p>
|
||||
<ul>
|
||||
{contrast.intervals.map((interval) => (
|
||||
<li key={`${interval.start}-${interval.end}`}>
|
||||
{interval.start}-{interval.end}:{interval.lagna}
|
||||
{interval.lords.l1 ? `;一/四/七/十宫主 ${[interval.lords.l1, interval.lords.l4, interval.lords.l7, interval.lords.l10].filter(Boolean).join("、")}` : ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
{ledger.length > 0 && (
|
||||
<details className="technique-audit">
|
||||
<summary>
|
||||
经历与大运对照
|
||||
<small>{ledger.length} 项</small>
|
||||
</summary>
|
||||
<ul className="rectification-board__disclosure">
|
||||
{ledger.map((item) => (
|
||||
<li key={item.summary}>{engineMeaningToDisplayCopy(item.user_meaning)}</li>
|
||||
))}
|
||||
</ul>
|
||||
{agreement ? <p className="rectification-board__disclosure">{engineMeaningToDisplayCopy(agreement.user_meaning)}</p> : null}
|
||||
</details>
|
||||
)}
|
||||
{nakshatra?.near_boundary && nakshatra.user_meaning && (
|
||||
<details className="technique-audit">
|
||||
<summary>交界节奏</summary>
|
||||
<p className="rectification-board__disclosure">{engineMeaningToDisplayCopy(nakshatra.user_meaning)}</p>
|
||||
</details>
|
||||
)}
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,16 +5,25 @@
|
||||
* minute do I take". Adoption stays on the delivery card, so this component
|
||||
* takes no callbacks and renders no controls.
|
||||
*
|
||||
* It carries exactly two things — the axis and the range readout. Counts, a
|
||||
* legend, the stage name, the range-change sentence and an explanatory footnote
|
||||
* were all considered and cut: that sentence is already written into the
|
||||
* persisted assistant message (BUG-588 requires it there, so repeating it here
|
||||
* would put one sentence on screen twice), and the rest explain what a single
|
||||
* glance already shows.
|
||||
* It carries the axis and a one-line readout: range, width, working minute and
|
||||
* the answered-question count. The working minute is here because on a narrow
|
||||
* viewport the board is an overlay and closed by default, which used to leave
|
||||
* the one number the reader tracks off screen entirely (D10, 2026-09-16).
|
||||
*
|
||||
* Still cut, and staying cut: a legend, the stage name, and the range-change
|
||||
* sentence — that sentence is already written into the persisted assistant
|
||||
* message (BUG-588 requires it there, so repeating it would put one sentence on
|
||||
* screen twice).
|
||||
*
|
||||
* Every figure is echoed from the server projection. The working minute comes
|
||||
* from the same `workingRectificationTime(result)` call the board's title-row
|
||||
* clock makes, so the two surfaces cannot disagree; the count is the server's
|
||||
* `answered_probe_count`. Nothing is computed here.
|
||||
*
|
||||
* The bar lives outside the scroll container as its own grid row, so its height
|
||||
* must never change — see `.rectification-timeline` in globals.css. An unready
|
||||
* case renders the same-height skeleton rather than nothing.
|
||||
* must never change — see `.rectification-timeline` in globals.css. The readout
|
||||
* stays one line for that reason: it grew wider, never taller. An unready case
|
||||
* renders the same-height skeleton rather than nothing.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
@@ -31,11 +40,23 @@ export function RectificationTimeline({ view }: { view: RectificationTimelineVie
|
||||
<p className="rectification-timeline__readout">
|
||||
<span className="rectification-timeline__range">{view.rangeLabel}</span>
|
||||
<span className="rectification-timeline__width">{view.widthLabel}</span>
|
||||
{view.workingLabel ? (
|
||||
<span className="rectification-timeline__working">{view.workingLabel}</span>
|
||||
) : null}
|
||||
{view.answeredLabel ? (
|
||||
<span className="rectification-timeline__answered">{view.answeredLabel}</span>
|
||||
) : null}
|
||||
</p>
|
||||
<div
|
||||
className="rectification-timeline__axis"
|
||||
role="img"
|
||||
aria-label={`出生时间搜索范围 ${view.axisStartLabel} 到 ${view.axisEndLabel},当前区间 ${view.rangeLabel},${view.widthLabel}`}
|
||||
aria-label={[
|
||||
`出生时间搜索范围 ${view.axisStartLabel} 到 ${view.axisEndLabel}`,
|
||||
`当前区间 ${view.rangeLabel}`,
|
||||
view.widthLabel,
|
||||
view.workingLabel,
|
||||
view.answeredLabel,
|
||||
].filter(Boolean).join(",")}
|
||||
>
|
||||
<span className="rectification-timeline__rule" />
|
||||
{/* Placed and resized with transform only. Animating left/width would
|
||||
|
||||
@@ -105,6 +105,12 @@ export type RectificationCandidateResult = Readonly<{
|
||||
verificationReportMarkdown: string | null;
|
||||
/** Inference-layer minutes including eliminated ones. Empty when no inference_state. */
|
||||
inferenceMarks?: readonly RectificationInferenceMark[];
|
||||
/**
|
||||
* `inference_state.answered_probe_count`, read verbatim off the wire. The
|
||||
* count is the server's own; nothing here recounts answers or derives one
|
||||
* from the transcript (VOICE.md rule 2: numbers come from the projection).
|
||||
*/
|
||||
answeredProbeCount?: number | null;
|
||||
}>;
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
@@ -294,6 +300,17 @@ function parseCredibleRange(value: unknown): readonly [string, string] | null {
|
||||
return [start, end];
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's count of answered discrimination probes. Read, never computed:
|
||||
* the bar above the conversation may only show numbers the projection already
|
||||
* carries, so this returns null rather than counting anything locally.
|
||||
*/
|
||||
function parseAnsweredProbeCount(receipt: Record<string, unknown> | null): number | null {
|
||||
const state = record(receipt?.inference_state);
|
||||
const value = state?.answered_probe_count;
|
||||
return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null;
|
||||
}
|
||||
|
||||
function parseInferenceMarks(receipt: Record<string, unknown> | null): readonly RectificationInferenceMark[] {
|
||||
const state = record(receipt?.inference_state);
|
||||
if (!state || !Array.isArray(state.candidates)) return [];
|
||||
@@ -411,6 +428,7 @@ export function parseRectificationCandidateResult(value: unknown): Rectification
|
||||
(snapshot.rangeDelivery ?? snapshot.range_delivery) as { verification_markdown?: unknown } | undefined,
|
||||
),
|
||||
inferenceMarks: parseInferenceMarks(receipt),
|
||||
answeredProbeCount: parseAnsweredProbeCount(receipt),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,14 @@
|
||||
* 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;
|
||||
@@ -47,6 +55,20 @@ export type RectificationTimelineView = Readonly<{
|
||||
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;
|
||||
}>;
|
||||
|
||||
/** `HH:MM` or `HH:MM:SS` to minutes past midnight; null when unparseable. */
|
||||
@@ -149,6 +171,16 @@ export type RectificationTimelineInput = Readonly<{
|
||||
*/
|
||||
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;
|
||||
}>;
|
||||
|
||||
/**
|
||||
@@ -218,5 +250,31 @@ export function buildRectificationTimeline(
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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} 题`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user