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
214 lines
7.8 KiB
TypeScript
214 lines
7.8 KiB
TypeScript
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: "。" };
|
||
}
|