Files
Jyotisha/frontend/src/lib/rectification-page.ts
T
jesseandClaude Opus 5.5 dfd3208979
Independent Staging Quality Gate / validate (push) Failing after 13m25s
Independent Staging Quality Gate / publish (push) Skipped
feat: standalone rectification entry at /rectification (issue #145)
- Sidebar 「生时校正」 under 星盘, behind a new account `rectification` flag.
- /rectification page: status card from entry-summary (not started / in
  progress with window and confirmed event count / adopted time), a short
  data check, then the home rectification surface (?rectify=1, or
  ?rectify=new to start over; ?c=<id> to continue or view). No price on
  the page.
- entry-summary gains session id, candidate range, confirmed evidence count
  and adopted times (add-only migration 20261028170000).
- The offer card under a consultation answer becomes one line and a link
  to /rectification; an explicit request in chat also goes there. No case
  is opened from a conversation any more.
- Starter home shows a rectification prompt for self charts with an
  estimated time; onboarding checklist gains 做一次生时校正 for them.
- Chart-page link points to /rectification. Shell registers its router so
  home hooks can navigate client-side without useRouter in Home().
- Docs: VOICE, DESIGN, CHANGELOG, device checklist.

Self profile only; other people are issue #160.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 16:50:55 +08:00

137 lines
5.3 KiB
TypeScript

/**
* The standalone /rectification page (issue #145). Which card it shows comes
* from the server's entry summary and the account; the page never guesses
* from session lists. Self profile only (other people: issue #160).
*/
import { rectificationEntryHref, sessionHref } from "./chat-session-url.ts";
import { blocksNewRectification } from "./rectification-in-chat.ts";
import type { RectificationEntrySummary } from "./rectification-entry.ts";
export type RectificationPageState =
| { readonly kind: "signed-out" }
| { readonly kind: "unavailable" }
| { readonly kind: "self-missing" }
| { readonly kind: "none" }
| {
readonly kind: "active";
readonly sessionId: string | null;
readonly range: { readonly start: string; readonly end: string } | null;
readonly evidenceCount: number | null;
}
| { readonly kind: "done"; readonly sessionId: string | null; readonly time: string };
/**
* The birth time is only an estimate and no time has been adopted yet. Drives
* the onboarding prompt and the checklist item (issue #145). A certificate or
* a self-declared exact minute does not count; nor does an adopted time.
*/
export function birthTimeNeedsRectification(profile: {
readonly birthTimeSource: string;
readonly birthTimeStatus: string;
}): boolean {
if (profile.birthTimeStatus === "accepted" || profile.birthTimeStatus === "confirmed") return false;
return birthTimeWasEstimated(profile);
}
/** The self birth time came from an estimate, adopted since or not (checklist item, issue #145). */
export function birthTimeWasEstimated(profile: { readonly birthTimeSource: string }): boolean {
return profile.birthTimeSource === "approximate"
|| profile.birthTimeSource === "period_only"
|| profile.birthTimeSource === "unknown";
}
export const RECTIFICATION_HOME_HINT = {
text: "出生时间还不确定,可以先做生时校正。",
link: "去校正",
} as const;
export const RECTIFICATION_PAGE_COPY = {
title: "生时校正",
intro: "出生时间不确定时,用几件记得日期的大事,把钟点一步步收窄。",
waiting: "校正记录还在读",
loadFailed: "校正记录这会儿读不到。",
retry: "再试一次",
signedOut: "登录后可以做生时校正。",
signIn: "去登录",
unavailable: "生时校正这会儿还做不了。",
selfMissing: "先把你自己的出生日期、时间和地点填好,才能做生时校正。",
fillSelf: "去填资料",
currentTime: "你现在用的时间",
start: "开始",
activeLabel: "进行中",
windowLabel: "时间窗",
evidenceLabel: "已收大事",
resume: "继续",
doneLabel: "已采用",
doneNote: "之后排盘、咨询和报告都用这个时间。",
viewResult: "查看结果",
again: "再校正一次",
back: "返回",
checkTitle: "先核对一下资料",
birthDate: "出生日期",
birthTime: "出生时间",
birthPlace: "出生城市",
confirmStart: "开始校正",
editProfile: "改这份",
} as const;
/** `07:42:00` → `07:42`. Anything else is returned trimmed. */
export function clockLabel(value: string): string {
const match = /^(\d{2}):(\d{2})(?::\d{2}(?:\.\d+)?)?$/.exec(value.trim());
return match ? `${match[1]}:${match[2]}` : value.trim();
}
export function evidenceCountLabel(count: number): string {
return `${count} 件`;
}
export function rectificationPageState(input: {
readonly signedOut: boolean;
/** `account.rectification`; missing means the flag did not arrive, so the page stays closed. */
readonly enabled: boolean | undefined;
readonly selfComplete: boolean;
readonly summary: RectificationEntrySummary;
}): RectificationPageState {
if (input.signedOut) return { kind: "signed-out" };
if (input.enabled !== true) return { kind: "unavailable" };
if (!input.selfComplete) return { kind: "self-missing" };
const open = input.summary.latestResumable;
if (open && blocksNewRectification(open.status)) {
const range = open.candidateRange
? { start: clockLabel(open.candidateRange.startTime), end: clockLabel(open.candidateRange.endTime) }
: null;
return {
kind: "active",
sessionId: open.sessionId ?? null,
range,
evidenceCount: open.evidenceCount ?? null,
};
}
// An accepted case is a finished stop (BUG-1423), even though the shared
// resumable set still lists its status.
if (open && open.status === "candidate_accepted" && open.acceptedTime) {
return { kind: "done", sessionId: open.sessionId ?? null, time: clockLabel(open.acceptedTime) };
}
const finished = input.summary.latestTerminal;
const finishedTime = finished?.confirmedTime ?? finished?.acceptedTime ?? null;
if (finished && finished.hasUsableTime && finishedTime) {
return { kind: "done", sessionId: finished.sessionId ?? null, time: clockLabel(finishedTime) };
}
return { kind: "none" };
}
/** Where 开始校正 goes: a fresh case after a finished one, otherwise the homepage opener. */
export function rectificationStartHref(state: RectificationPageState): string {
return rectificationEntryHref(state.kind === "done" ? "new" : "homepage");
}
/**
* 继续 / 查看结果: the case's own conversation. Without a session id (an older
* server) the homepage opener still resumes the open case.
*/
export function rectificationSessionHref(sessionId: string | null): string {
return sessionId ? sessionHref("", sessionId) : rectificationEntryHref("homepage");
}