diff --git a/frontend/src/lib/chart-view-contract.ts b/frontend/src/lib/chart-view-contract.ts index 14ebf67e..744d9b91 100644 --- a/frontend/src/lib/chart-view-contract.ts +++ b/frontend/src/lib/chart-view-contract.ts @@ -276,6 +276,21 @@ export const chartViewFailureStatusSchema = z.enum([ ]); export type ChartViewFailureStatus = z.infer; +const clockText = z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/); + +export const chartViewLagnaPrecheckSchema = z.object({ + state: z.enum(["fixed", "may_change", "unknown_time"]), + signs: z.array(z.string().min(1)).min(1).max(12), + changeAt: z.array(clockText).max(11).optional(), + windowMinutes: z.number().int().min(1).max(1441), + precisionKind: z.enum(["minutes", "period", "unknown"]), + beforeMinutes: z.number().int().min(0).max(24 * 60).optional(), + afterMinutes: z.number().int().min(0).max(24 * 60).optional(), + periodStart: clockText.optional(), + periodEnd: clockText.optional(), +}); +export type ChartViewLagnaPrecheck = z.infer; + export const chartViewOkSchema = z.object({ layerFailures: z.object({ varga: chartViewFailureStatusSchema.optional(), @@ -285,6 +300,7 @@ export const chartViewOkSchema = z.object({ bhava: chartViewFailureStatusSchema.optional(), transit: chartViewFailureStatusSchema.optional(), }).optional(), + lagnaPrecheck: chartViewLagnaPrecheckSchema.optional(), status: z.literal("ok"), billed: z.literal(false), profile: chartViewProfileSchema, diff --git a/frontend/src/lib/chart-view-load.ts b/frontend/src/lib/chart-view-load.ts index e35fe190..ffcf596c 100644 --- a/frontend/src/lib/chart-view-load.ts +++ b/frontend/src/lib/chart-view-load.ts @@ -4,6 +4,7 @@ import { resolveAyanamsa } from "./ayanamsa.ts"; import type { ChartBirthFailureReason } from "./chart-birth-truth.ts"; import { chartViewMessageSchema, + type ChartViewLagnaPrecheck, type ChartViewResponse, } from "./chart-view-contract.ts"; import { @@ -22,7 +23,6 @@ import { engineChartHasPlanets, type ChartViewProfileInput, } from "./chart-view-mapper.ts"; - /** * `cacheScope` separates calls to one path whose body is not the birth moment * (the transit chart posts `/api/chart` for today): the server cache is keyed @@ -44,6 +44,8 @@ export type ChartViewLoadInput = { layers?: readonly ChartViewLayer[]; accountId?: string | null; profileFingerprint?: string | null; + /** Started beside `/api/chart`. Failure resolves to null and never fails the chart. */ + lagnaPrecheck?: Promise; }; function message( @@ -131,6 +133,9 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{ httpStatus: number; body: ChartViewResponse; }> { + // Attach the catch immediately. A chart failure returns before this is awaited, + // and a rejected precheck must not fail the chart or surface as unhandled. + const lagnaReady = input.lagnaPrecheck?.catch(() => null) ?? Promise.resolve(null); if (!input.userId) { return { httpStatus: 401, @@ -235,6 +240,7 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{ : Promise.resolve(null), ]); await dashaReady; + const lagnaPrecheck = await lagnaReady; try { const body = buildChartView({ @@ -247,6 +253,7 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{ transit, profile, asOf: input.asOf, + lagnaPrecheck, }); return { httpStatus: 200, body: stamp({ ...body, layerFailures }, input) }; } catch (error) { diff --git a/frontend/src/lib/chart-view-mapper.ts b/frontend/src/lib/chart-view-mapper.ts index 5f004df7..5dd811d1 100644 --- a/frontend/src/lib/chart-view-mapper.ts +++ b/frontend/src/lib/chart-view-mapper.ts @@ -21,15 +21,19 @@ import { WESTERN_BODY_ZH, isSignName, planetZh, + SIGN_ZH, signZh, } from "./chart-view-labels.ts"; import { traditionalSymbol } from "./chart-view-symbols.ts"; import { vargaDignityLevel } from "./chart-view-varga.ts"; import { transitsFromEngine } from "./ephemeris-view.ts"; import { TRANSIT_LOCAL_TIME } from "./transit-chart-body.ts"; +import type { LagnaPrecheckWindow } from "./lagna-precheck.ts"; import { + chartViewLagnaPrecheckSchema, chartViewOkSchema, type ChartViewBhavaOk, + type ChartViewLagnaPrecheck, type ChartViewMoonChart, type ChartViewOk, type ChartViewTransitOk, @@ -60,6 +64,7 @@ export type ChartViewEngineBundle = { varga: Record | null; chara: Record | null; yogini?: Record | null; + lagnaPrecheck?: ChartViewLagnaPrecheck | null; western: Record | null; bhava?: Record | null; transit?: Record | null; @@ -852,8 +857,53 @@ function mapWestern(raw: Record | null, ayanamsaDisplay: string }; } +function clockFromStamp(value: unknown): string | null { + if (typeof value !== "string") return null; + const matched = /(?:T|\s)(\d{2}:\d{2})/.exec(value) ?? /^(\d{2}:\d{2})$/.exec(value); + const clock = matched?.[1]; + return clock && /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(clock) ? clock : null; +} + +/** One D1 segment → fixed. Two or more → may_change. Unknown birth time stays unknown_time. */ +export function mapLagnaPrecheck(window: LagnaPrecheckWindow, payload: unknown): ChartViewLagnaPrecheck | null { + const segments = record(record(payload)?.segments); + const rows = segments?.D1; + if (!Array.isArray(rows) || rows.length === 0) return null; + const signs: string[] = []; + const changeAt: string[] = []; + for (const row of rows) { + const item = record(row); + const signIndex = finite(item?.sign); + if (signIndex === null || !Number.isInteger(signIndex) || signIndex < 0 || signIndex > 11) return null; + signs.push(SIGN_ZH[SIGN_ORDER[signIndex]!]); + if (signs.length > 1) { + const clock = clockFromStamp(item?.start_at); + if (clock) changeAt.push(clock); + } + } + const state = window.source === "unknown" + ? "unknown_time" as const + : signs.length === 1 + ? "fixed" as const + : "may_change" as const; + const parsed = chartViewLagnaPrecheckSchema.safeParse({ + state, + signs, + ...(changeAt.length > 0 ? { changeAt } : {}), + windowMinutes: window.minutes, + precisionKind: window.precisionKind, + ...(window.precisionKind === "minutes" + ? { beforeMinutes: window.beforeMinutes, afterMinutes: window.afterMinutes } + : {}), + ...(window.precisionKind === "period" + ? { periodStart: window.periodStart, periodEnd: window.periodEnd } + : {}), + }); + return parsed.success ? parsed.data : null; +} + export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk { - const { chart, varga, chara, yogini = null, western, bhava, transit, profile, asOf } = bundle; + const { chart, varga, chara, yogini = null, western, bhava, transit, profile, asOf, lagnaPrecheck = null } = bundle; const planets = asPlanetMap(chart.planets); const moon = planets.Moon ?? {}; const ascendant = record(chart.ascendant); @@ -871,6 +921,7 @@ export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk { const view = { status: "ok" as const, billed: false as const, + ...(lagnaPrecheck ? { lagnaPrecheck } : {}), profile: { name: profile.name?.trim() || "未命名", date: profile.date, diff --git a/frontend/src/lib/chart-view-service.ts b/frontend/src/lib/chart-view-service.ts index 98223547..60ee170a 100644 --- a/frontend/src/lib/chart-view-service.ts +++ b/frontend/src/lib/chart-view-service.ts @@ -5,6 +5,8 @@ import { consumeRequestRateLimit } from "./request-rate-limit.ts"; import { createServerSupabaseClient } from "./supabase/server.ts"; import { ConsultationSubjectError, loadSubjectBirth, subjectIdFromQuery } from "./subject-birth.ts"; import { resolveAyanamsa } from "./ayanamsa.ts"; +import { fetchLagnaPrecheck } from "./lagna-precheck-fetch.ts"; +import { lagnaPrecheckWindow } from "./lagna-precheck.ts"; import { assembleChartView, type ChartViewEnginePost } from "./chart-view-load.ts"; import type { ChartViewProfileInput } from "./chart-view-mapper.ts"; import type { ChartViewResponse } from "./chart-view-contract.ts"; @@ -195,6 +197,16 @@ export async function loadChartView(input: { body: { ...failureBody(prepared.failure, user.id, prepared.birth.fingerprint), subjectId: resolved.subjectId }, }; } + const birthWindow = lagnaPrecheckWindow(resolved); + const lagnaPrecheck = birthWindow + ? fetchLagnaPrecheck({ + window: birthWindow, + latitude: prepared.profile.latitude, + longitude: prepared.profile.longitude, + timezoneOffset: prepared.profile.timezoneOffset, + ayanamsa: prepared.profile.ayanamsa, + }) + : undefined; const assembled = await assembleChartView({ userId: user.id, accountId: user.id, @@ -203,6 +215,7 @@ export async function loadChartView(input: { postEngine: cachedPostEngine(user.id, resolved.subjectId, prepared.profile), asOf: now.toISOString().slice(0, 10), layers: input.layers, + lagnaPrecheck, }); return { ...assembled, diff --git a/frontend/src/lib/lagna-precheck-fetch.ts b/frontend/src/lib/lagna-precheck-fetch.ts new file mode 100644 index 00000000..4f2a075c --- /dev/null +++ b/frontend/src/lib/lagna-precheck-fetch.ts @@ -0,0 +1,40 @@ +import { resolveAyanamsa } from "./ayanamsa.ts"; +import { mapLagnaPrecheck } from "./chart-view-mapper.ts"; +import type { ChartViewLagnaPrecheck } from "./chart-view-contract.ts"; +import type { LagnaPrecheckWindow } from "./lagna-precheck.ts"; + +export const LAGNA_PRECHECK_TIMEOUT_MS = 3000; + +const apiBase = () => process.env.JYOTISH_API_BASE?.trim() || "http://127.0.0.1:5200"; + +export async function fetchLagnaPrecheck(input: { + readonly window: LagnaPrecheckWindow; + readonly latitude: number; + readonly longitude: number; + readonly timezoneOffset: number; + readonly ayanamsa?: string; + readonly fetchImpl?: typeof fetch; + readonly timeoutMs?: number; +}): Promise { + const fetchImpl = input.fetchImpl ?? fetch; + try { + const response = await fetchImpl(`${apiBase()}/api/rectification/v5/varga-segments`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + signal: AbortSignal.timeout(input.timeoutMs ?? LAGNA_PRECHECK_TIMEOUT_MS), + body: JSON.stringify({ + start_at: input.window.start_at, + end_at: input.window.end_at, + latitude: input.latitude, + longitude: input.longitude, + timezone_offset: input.timezoneOffset, + target_charts: ["D1"], + ayanamsa: resolveAyanamsa({ ayanamsa: input.ayanamsa }), + }), + }); + if (!response.ok) return null; + return mapLagnaPrecheck(input.window, await response.json()); + } catch { + return null; + } +} diff --git a/frontend/tests/lagna-precheck-chart.test.ts b/frontend/tests/lagna-precheck-chart.test.ts new file mode 100644 index 00000000..50be67b1 --- /dev/null +++ b/frontend/tests/lagna-precheck-chart.test.ts @@ -0,0 +1,150 @@ +/** + * Chart view keeps a D1 ascendant precheck beside the natal chart. + * A failed scan leaves the field off and the chart at 200. Fictional fixture only. + */ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { assembleChartView, type ChartViewEnginePost } from "../src/lib/chart-view-load.ts"; +import { fetchLagnaPrecheck } from "../src/lib/lagna-precheck-fetch.ts"; +import { mapLagnaPrecheck, type ChartViewProfileInput } from "../src/lib/chart-view-mapper.ts"; +import type { LagnaPrecheckWindow } from "../src/lib/lagna-precheck.ts"; +const golden = JSON.parse( + readFileSync(new URL("./fixtures/chart-view-golden.json", import.meta.url), "utf8"), +) as { chart: Record }; + +const profile: ChartViewProfileInput = { + name: "示例", + date: "1990-06-15", + time: "12:00", + placeLabel: "北京", + latitude: 39.9, + longitude: 116.4, + timezoneOffset: 8, + ayanamsa: "raman", +}; + +const birthWindow: LagnaPrecheckWindow = { + start_at: "1990-06-15T11:58", + end_at: "1990-06-15T12:02", + minutes: 5, + source: "hospital_record", + precisionKind: "minutes", + beforeMinutes: 2, + afterMinutes: 2, +}; + +function postEngine(fail = false): ChartViewEnginePost { + return async (path) => { + if (fail || path !== "/api/chart") return { status: "http_error", path, elapsedMs: 1, httpStatus: 500 }; + return { status: "ok", payload: golden.chart }; + }; +} + +test("one D1 segment is fixed and two segments name the change clock", () => { + const fixed = mapLagnaPrecheck(birthWindow, { + segments: { D1: [{ sign: 0, start_at: "1990-06-15T11:58", end_at: "1990-06-15T12:02" }] }, + }); + assert.equal(fixed?.state, "fixed"); + assert.deepEqual(fixed?.signs, ["白羊"]); + assert.equal(fixed?.changeAt, undefined); + + const changed = mapLagnaPrecheck(birthWindow, { + segments: { D1: [ + { sign: 0, start_at: "1990-06-15T11:58", end_at: "1990-06-15T12:00" }, + { sign: 1, start_at: "1990-06-15 12:01", end_at: "1990-06-15T12:02" }, + ] }, + }); + assert.equal(changed?.state, "may_change"); + assert.deepEqual(changed?.signs, ["白羊", "金牛"]); + assert.deepEqual(changed?.changeAt, ["12:01"]); + + const unknown = mapLagnaPrecheck({ ...birthWindow, source: "unknown", precisionKind: "unknown" }, { + segments: { D1: [{ sign: 2, start_at: "1990-06-15T00:00", end_at: "1990-06-15T23:59" }] }, + }); + assert.equal(unknown?.state, "unknown_time"); + assert.equal(mapLagnaPrecheck(birthWindow, { segments: { D1: [] } }), null); + assert.equal(mapLagnaPrecheck(birthWindow, { segments: { D1: [{ sign: 12, start_at: "1990-06-15T11:58" }] } }), null); +}); + +test("a thrown or non-ok segment scan returns null and does not call boundary charts", async () => { + const thrown = await fetchLagnaPrecheck({ + window: birthWindow, + latitude: 39.9, + longitude: 116.4, + timezoneOffset: 8, + fetchImpl: async () => { throw new Error("down"); }, + }); + assert.equal(thrown, null); + + const failed = await fetchLagnaPrecheck({ + window: birthWindow, + latitude: 39.9, + longitude: 116.4, + timezoneOffset: 8, + fetchImpl: async () => new Response("no", { status: 500 }), + }); + assert.equal(failed, null); + + const seen: { body: Record | null } = { body: null }; + const ok = await fetchLagnaPrecheck({ + window: birthWindow, + latitude: 39.9, + longitude: 116.4, + timezoneOffset: 8, + ayanamsa: "raman", + fetchImpl: async (_url, init) => { + seen.body = JSON.parse(String(init?.body)) as Record; + assert.equal(init?.signal instanceof AbortSignal, true); + return new Response(JSON.stringify({ + segments: { D1: [{ sign: 0, start_at: "1990-06-15T11:58", end_at: "1990-06-15T12:02" }] }, + }), { status: 200 }); + }, + }); + assert.equal(ok?.state, "fixed"); + assert.ok(seen.body); + assert.deepEqual(seen.body.target_charts, ["D1"]); + assert.equal("boundary_charts" in seen.body, false); + assert.equal(seen.body.start_at, "1990-06-15T11:58"); +}); + +test("a rejected precheck is omitted and a failed chart does not wait for it", async () => { + const ok = await assembleChartView({ + userId: "user-1", + profile, + postEngine: postEngine(), + asOf: "2026-09-15", + lagnaPrecheck: Promise.resolve(mapLagnaPrecheck(birthWindow, { + segments: { D1: [ + { sign: 0, start_at: "1990-06-15T11:58", end_at: "1990-06-15T12:00" }, + { sign: 1, start_at: "1990-06-15T12:01", end_at: "1990-06-15T12:02" }, + ] }, + })), + }); + assert.equal(ok.httpStatus, 200); + assert.equal(ok.body.status, "ok"); + assert.equal(ok.body.status === "ok" ? ok.body.lagnaPrecheck?.state : null, "may_change"); + + const rejected = await assembleChartView({ + userId: "user-1", + profile, + postEngine: postEngine(), + asOf: "2026-09-15", + lagnaPrecheck: Promise.reject(new Error("timeout")), + }); + assert.equal(rejected.httpStatus, 200); + assert.equal(rejected.body.status, "ok"); + assert.equal(rejected.body.status === "ok" && rejected.body.lagnaPrecheck, undefined); + + const failedChart = await assembleChartView({ + userId: "user-1", + profile, + postEngine: postEngine(true), + asOf: "2026-09-15", + lagnaPrecheck: new Promise(() => {}), + }); + assert.equal(failedChart.httpStatus, 200); + assert.notEqual(failedChart.body.status, "ok"); + assert.equal("lagnaPrecheck" in failedChart.body, false); +});