Files
Jyotisha/frontend/src/lib/lagna-precheck.ts
T
ClaudeandClaude Opus 5.5 ee08562471 feat(rectification): rectify other people in the profile list (issue #160)
Product 2026-11-01: 1A 2A.
- Cases record whose chart (chart_profile_id, null = owner); add-only
  migration 20261101010000_rectification_chart_subject.sql, old cases stay
  the owner's.
- Adoption / confirmation / segment adoption / VedAstro refresh read and
  write that person's row through two helpers; the owner's profile and
  cases are untouched. Owner edits rebaseline only owner cases, person
  edits only that person's; a new declaration drops the adopted minute.
- Open cases are counted per person (entry summary v2, in-chat offer,
  resume lookup); open_agentic_rectification_case_v3 binds a new case and
  its conversation to the person.
- delete_chart_subject deletes the person's cases and their
  conversations first (no refund).
- /rectification follows the current person (switcher beside the title),
  shows name and relation; self-only limits removed (hook, chat offer,
  chart ascendant link, starter hint).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyeZrYn6ryNcSe5Y2Y
2026-11-01 17:26:04 +08:00

214 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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. Since issue #160 someone else's chart links too: /rectification
* opens for the same current person.
*/
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) {
const listed = `按你给的时间段,上升可能落在${signList(value.signs)}中的一个。`;
return { before: `${listed}想定下来可以做一次`, 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})`
: "按你给的时间精度";
const signs = `${lead},上升可能是${first ?? ""}座,也可能是${second ?? ""}座${change}。`;
return { before: `${signs}想定下来可以做一次`, linked: true, after: "。" };
}