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)
This commit is contained in:
jesse-ux
2026-10-27 03:51:59 +08:00
co-authored by Grok (agent loop)
parent 009456d8e1
commit c3f69ef724
2 changed files with 353 additions and 0 deletions
+215
View File
@@ -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<string, unknown>;
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: "。",
};
}
+138
View File
@@ -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<string, unknown>) {
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, /不知道出生时间时/);
});