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:
co-authored by
Grok (agent loop)
parent
009456d8e1
commit
c3f69ef724
@@ -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: "。",
|
||||
};
|
||||
}
|
||||
@@ -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, /不知道出生时间时/);
|
||||
});
|
||||
Reference in New Issue
Block a user