From c3f69ef7241e7c96947fa5787d8d1f2b0472fea0 Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Tue, 27 Oct 2026 03:51:59 +0800 Subject: [PATCH] Add the ascendant precheck time window. Hospital, family, approximate, period, and unknown birth sources map to the scan window. Adopted times and legacy imports stay quiet. Ran tests/lagna-precheck.test.ts: 2 pass. Co-Authored-By: Grok (agent loop) --- frontend/src/lib/lagna-precheck.ts | 215 ++++++++++++++++++++++++++ frontend/tests/lagna-precheck.test.ts | 138 +++++++++++++++++ 2 files changed, 353 insertions(+) create mode 100644 frontend/src/lib/lagna-precheck.ts create mode 100644 frontend/tests/lagna-precheck.test.ts diff --git a/frontend/src/lib/lagna-precheck.ts b/frontend/src/lib/lagna-precheck.ts new file mode 100644 index 00000000..8246c660 --- /dev/null +++ b/frontend/src/lib/lagna-precheck.ts @@ -0,0 +1,215 @@ +import { declaredRangeWrapsMidnight } from "./declared-birth-window.ts"; +import { + birthTimeSourceDefaults, + declaredWindowRangeForDraft, + isBirthClockTime, + parseBirthDate, + type BirthTimePeriod, +} from "./birth-time-intake-model.ts"; +import type { ChartViewLagnaPrecheck } from "./chart-view-contract.ts"; + +/** Inclusive minute cap of POST /api/rectification/v5/varga-segments. */ +const MAX_INCLUSIVE_MINUTES = 1441; + +export type LagnaPrecheckSource = + | "hospital_record" + | "family_exact" + | "approximate" + | "period_only" + | "unknown"; + +export type LagnaPrecheckWindow = { + readonly start_at: string; + readonly end_at: string; + readonly minutes: number; + readonly source: LagnaPrecheckSource; + readonly precisionKind: "minutes" | "period" | "unknown"; + readonly beforeMinutes?: number; + readonly afterMinutes?: number; + readonly periodStart?: string; + readonly periodEnd?: string; +}; + +export type LagnaPrecheckLine = { + readonly before: string; + readonly linked: boolean; + readonly after: string; +}; + +type BirthRow = Record; + +function pad(value: number): string { + return String(value).padStart(2, "0"); +} + +function text(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function clock(value: unknown): string { + const head = text(value).slice(0, 5); + return isBirthClockTime(head) ? head : ""; +} + +function wholeMinutes(value: unknown, fallback: number): number { + const parsed = typeof value === "number" + ? value + : typeof value === "string" && value.trim() + ? Number(value) + : Number.NaN; + if (!Number.isInteger(parsed) || parsed < 0 || parsed > 24 * 60) return fallback; + return parsed; +} + +function stampAt(date: string, time: string, deltaMinutes: number): { at: string; date: string; time: string } | null { + if (!parseBirthDate(date) || !isBirthClockTime(time)) return null; + const [year, month, day] = date.split("-").map(Number); + const [hour, minute] = time.split(":").map(Number); + const next = new Date(Date.UTC(year, month - 1, day, hour, minute) + deltaMinutes * 60_000); + const dayText = `${next.getUTCFullYear()}-${pad(next.getUTCMonth() + 1)}-${pad(next.getUTCDate())}`; + const clockText = `${pad(next.getUTCHours())}:${pad(next.getUTCMinutes())}`; + return { date: dayText, time: clockText, at: `${dayText}T${clockText}` }; +} + +function inclusiveMinutes(startAt: string, endAt: string): number | null { + const start = Date.parse(`${startAt}:00Z`); + const end = Date.parse(`${endAt}:00Z`); + if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return null; + return Math.round((end - start) / 60_000) + 1; +} + +function capped(startAt: string, endAt: string): { start_at: string; end_at: string; minutes: number } | null { + const minutes = inclusiveMinutes(startAt, endAt); + if (minutes === null || minutes < 1) return null; + if (minutes <= MAX_INCLUSIVE_MINUTES) return { start_at: startAt, end_at: endAt, minutes }; + const [date, time] = startAt.split("T"); + const clipped = date && time ? stampAt(date, time, MAX_INCLUSIVE_MINUTES - 1) : null; + if (!clipped) return null; + return { start_at: startAt, end_at: clipped.at, minutes: MAX_INCLUSIVE_MINUTES }; +} + +function windowFromClocks( + date: string, + startClock: string, + endClock: string, + wraps: boolean, +): { start_at: string; end_at: string; minutes: number } | null { + const start = stampAt(date, startClock, 0); + const end = stampAt(date, endClock, wraps ? 24 * 60 : 0); + if (!start || !end) return null; + return capped(start.at, end.at); +} + +/** + * The minute window the ascendant precheck scans. + * Adopted rectification times (`accepted` / `confirmed`) and legacy imports + * return null so the chart page stays quiet. + */ +export function lagnaPrecheckWindow(birth: { readonly row?: BirthRow | null } | null | undefined): LagnaPrecheckWindow | null { + const row = birth?.row; + if (!row) return null; + const status = text(row.birth_time_status); + if (status === "accepted" || status === "confirmed") return null; + const source = text(row.birth_time_source); + if (source === "legacy_import" || source === "") return null; + const date = text(row.birth_date).slice(0, 10); + if (!parseBirthDate(date)) return null; + + if (source === "unknown") { + const span = windowFromClocks(date, "00:00", "23:59", false); + if (!span) return null; + return { ...span, source, precisionKind: "unknown" }; + } + + if (source === "period_only") { + const range = declaredWindowRangeForDraft({ + birthTimeSource: "period_only", + birthTimePeriod: text(row.birth_time_period) as BirthTimePeriod, + declaredWindowStart: clock(row.declared_window_start), + declaredWindowEnd: clock(row.declared_window_end), + }); + if (!range) return null; + const span = windowFromClocks(date, range.startTime, range.endTime, declaredRangeWrapsMidnight(range)); + if (!span) return null; + return { + ...span, + source, + precisionKind: "period", + periodStart: range.startTime, + periodEnd: range.endTime, + }; + } + + if (source !== "hospital_record" && source !== "family_exact" && source !== "approximate") return null; + const reported = clock(row.reported_birth_time); + if (!reported) return null; + const defaults = birthTimeSourceDefaults[source]; + const before = wholeMinutes(row.uncertainty_before_minutes, defaults.uncertaintyBeforeMinutes ?? 0); + const after = wholeMinutes(row.uncertainty_after_minutes, defaults.uncertaintyAfterMinutes ?? 0); + const start = stampAt(date, reported, -before); + const end = stampAt(date, reported, after); + if (!start || !end) return null; + const span = capped(start.at, end.at); + if (!span) return null; + return { + ...span, + source, + precisionKind: "minutes", + beforeMinutes: before, + afterMinutes: after, + }; +} + +function precisionClause(value: ChartViewLagnaPrecheck): string { + if (value.precisionKind === "period" && value.periodStart && value.periodEnd) { + return `按你给的时间段(${value.periodStart}–${value.periodEnd})`; + } + if (value.precisionKind === "minutes") { + const before = value.beforeMinutes ?? 0; + const after = value.afterMinutes ?? 0; + const detail = before === after ? `前后约 ${before} 分钟` : `前 ${before} 分钟、后 ${after} 分钟`; + return `按你给的时间精度(${detail})`; + } + return "按你给的时间精度"; +} + +function signList(signs: readonly string[]): string { + return signs.map((sign) => `${sign}座`).join("、"); +} + +/** Header sentence. `linked` is the words 「出生时间校正」. A fixed ascendant has no link. */ +export function lagnaPrecheckLine(value: ChartViewLagnaPrecheck): LagnaPrecheckLine { + if (value.state === "unknown_time") { + return { + before: "不知道出生时间时,上升星座定不下来;做一次", + linked: true, + after: "可以缩小范围。", + }; + } + if (value.state === "fixed") { + const sign = value.signs[0] ?? ""; + return { + before: `${precisionClause(value)},上升星座都是${sign}座,这一点不会因为时间误差而变。`, + linked: false, + after: "", + }; + } + if (value.signs.length >= 3) { + return { + before: `按你给的时间段,上升可能落在${signList(value.signs)}中的一个。想定下来可以做一次`, + linked: true, + after: "。", + }; + } + const [first, second] = value.signs; + const when = value.changeAt?.[0]; + const change = when ? `(大约 ${when} 换座)` : ""; + const lead = value.precisionKind === "period" && value.periodStart && value.periodEnd + ? `按你给的时间段(${value.periodStart}–${value.periodEnd})` + : "按你给的时间精度"; + return { + before: `${lead},上升可能是${first ?? ""}座,也可能是${second ?? ""}座${change}。想定下来可以做一次`, + linked: true, + after: "。", + }; +} diff --git a/frontend/tests/lagna-precheck.test.ts b/frontend/tests/lagna-precheck.test.ts new file mode 100644 index 00000000..95486cbf --- /dev/null +++ b/frontend/tests/lagna-precheck.test.ts @@ -0,0 +1,138 @@ +/** + * Ascendant precheck window from the birth row the chart page already loaded. + * Fictional clock only. BUG-1444. + */ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { lagnaPrecheckLine, lagnaPrecheckWindow } from "../src/lib/lagna-precheck.ts"; +import type { ChartViewLagnaPrecheck } from "../src/lib/chart-view-contract.ts"; + +function row(patch: Record) { + return { + birth_date: "1990-06-15", + reported_birth_time: "12:00:00", + birth_time_status: "reported", + ...patch, + }; +} + +test("each birth-time source becomes the scan window, or stays quiet", () => { + const hospital = lagnaPrecheckWindow({ row: row({ birth_time_source: "hospital_record" }) }); + assert.equal(hospital?.start_at, "1990-06-15T11:58"); + assert.equal(hospital?.end_at, "1990-06-15T12:02"); + assert.equal(hospital?.minutes, 5); + assert.equal(hospital?.beforeMinutes, 2); + assert.equal(hospital?.afterMinutes, 2); + assert.equal(hospital?.precisionKind, "minutes"); + + const stated = lagnaPrecheckWindow({ row: row({ + birth_time_source: "hospital_record", + uncertainty_before_minutes: 30, + uncertainty_after_minutes: "60", + }) }); + assert.equal(stated?.start_at, "1990-06-15T11:30"); + assert.equal(stated?.end_at, "1990-06-15T13:00"); + assert.equal(stated?.beforeMinutes, 30); + assert.equal(stated?.afterMinutes, 60); + assert.equal(stated?.minutes, 91); + + const family = lagnaPrecheckWindow({ row: row({ birth_time_source: "family_exact" }) }); + assert.equal(family?.start_at, "1990-06-15T12:00"); + assert.equal(family?.end_at, "1990-06-15T12:00"); + assert.equal(family?.minutes, 1); + assert.equal(family?.beforeMinutes, 0); + + const approximate = lagnaPrecheckWindow({ row: row({ birth_time_source: "approximate" }) }); + assert.equal(approximate?.start_at, "1990-06-15T11:45"); + assert.equal(approximate?.end_at, "1990-06-15T12:15"); + assert.equal(approximate?.minutes, 31); + + const morning = lagnaPrecheckWindow({ row: row({ + birth_time_source: "period_only", + birth_time_period: "morning", + }) }); + assert.equal(morning?.start_at, "1990-06-15T08:00"); + assert.equal(morning?.end_at, "1990-06-15T11:59"); + assert.equal(morning?.precisionKind, "period"); + assert.equal(morning?.periodStart, "08:00"); + assert.equal(morning?.periodEnd, "11:59"); + + const late = lagnaPrecheckWindow({ row: row({ + birth_time_source: "period_only", + birth_time_period: "late_night", + }) }); + assert.equal(late?.start_at, "1990-06-15T23:00"); + assert.equal(late?.end_at, "1990-06-16T03:59"); + assert.equal(late?.minutes, 300); + + const custom = lagnaPrecheckWindow({ row: row({ + birth_time_source: "period_only", + declared_window_start: "23:10", + declared_window_end: "00:20", + }) }); + assert.equal(custom?.start_at, "1990-06-15T23:10"); + assert.equal(custom?.end_at, "1990-06-16T00:20"); + + const unknown = lagnaPrecheckWindow({ row: row({ birth_time_source: "unknown" }) }); + assert.equal(unknown?.start_at, "1990-06-15T00:00"); + assert.equal(unknown?.end_at, "1990-06-15T23:59"); + assert.equal(unknown?.minutes, 1440); + assert.equal(unknown?.precisionKind, "unknown"); + + const capped = lagnaPrecheckWindow({ row: row({ + birth_time_source: "approximate", + uncertainty_before_minutes: 1440, + uncertainty_after_minutes: 1440, + }) }); + assert.equal(capped?.start_at, "1990-06-14T12:00"); + assert.equal(capped?.end_at, "1990-06-15T12:00"); + assert.equal(capped?.minutes, 1441); + + assert.equal(lagnaPrecheckWindow({ row: row({ birth_time_source: "hospital_record", birth_time_status: "accepted" }) }), null); + assert.equal(lagnaPrecheckWindow({ row: row({ birth_time_source: "hospital_record", birth_time_status: "confirmed" }) }), null); + assert.equal(lagnaPrecheckWindow({ row: row({ birth_time_source: "legacy_import" }) }), null); + assert.equal(lagnaPrecheckWindow({ row: row({ birth_time_source: "" }) }), null); + assert.equal(lagnaPrecheckWindow({ row: row({ birth_time_source: "hospital_record", reported_birth_time: "" }) }), null); + assert.equal(lagnaPrecheckWindow(null), null); + assert.equal(lagnaPrecheckWindow({ row: row({ birth_time_source: "hospital_record", birth_date: "not-a-date" }) }), null); +}); + +test("header sentences name the sign and link only when it can change", () => { + const fixed: ChartViewLagnaPrecheck = { + state: "fixed", + signs: ["白羊"], + windowMinutes: 5, + precisionKind: "minutes", + beforeMinutes: 2, + afterMinutes: 2, + }; + assert.equal(lagnaPrecheckLine(fixed).linked, false); + assert.match(lagnaPrecheckLine(fixed).before, /前后约 2 分钟/); + + const uneven: ChartViewLagnaPrecheck = { ...fixed, beforeMinutes: 2, afterMinutes: 15 }; + assert.match(lagnaPrecheckLine(uneven).before, /前 2 分钟、后 15 分钟/); + + const two: ChartViewLagnaPrecheck = { + state: "may_change", + signs: ["白羊", "金牛"], + changeAt: ["12:01"], + windowMinutes: 31, + precisionKind: "period", + periodStart: "11:45", + periodEnd: "12:15", + }; + const twoLine = lagnaPrecheckLine(two); + assert.equal(twoLine.linked, true); + assert.match(twoLine.before, /按你给的时间段(11:45–12:15)/); + assert.match(twoLine.before, /大约 12:01 换座/); + + const unknown: ChartViewLagnaPrecheck = { + state: "unknown_time", + signs: ["白羊", "金牛"], + windowMinutes: 1440, + precisionKind: "unknown", + }; + assert.equal(lagnaPrecheckLine(unknown).linked, true); + assert.match(lagnaPrecheckLine(unknown).before, /不知道出生时间时/); +});