fix(rectification): label declared birth time as record or estimate (BUG-690)
Hospital records, family estimates and period-only windows now keep distinct copy on the board and range card. Scoring still uses the reported clock as the search-window centre. Hospital minutes outside the current range are stated with the offset and no preference.
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* User-facing labels for the declared birth-time source.
|
||||
* Scoring still uses reportedBirthTime as the search-window centre.
|
||||
*/
|
||||
|
||||
export const PROVENANCE_SOURCES = ["hospital_record", "approximate", "period_only"] as const;
|
||||
|
||||
export type ProvenanceSource = (typeof PROVENANCE_SOURCES)[number];
|
||||
|
||||
const CLOCK = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
|
||||
|
||||
export function normalizeBirthTimeSource(value: string | null | undefined): ProvenanceSource {
|
||||
if (value === "hospital_record") return "hospital_record";
|
||||
if (value === "period_only") return "period_only";
|
||||
return "approximate";
|
||||
}
|
||||
|
||||
export const REPORTED_TIME_LABEL: Readonly<Record<ProvenanceSource, string>> = {
|
||||
hospital_record: "你的出生记录时间",
|
||||
approximate: "你填的大概时间",
|
||||
period_only: "你给的时间段",
|
||||
};
|
||||
|
||||
/** Approximate / period_only copy must not match this. Hospital uses 出生记录时间. */
|
||||
export const FORBIDDEN_DECLARED_BIRTH_TIME_PHRASE = /你的出生时间/;
|
||||
|
||||
export function reportedTimeLabel(source: string | null | undefined): string {
|
||||
return REPORTED_TIME_LABEL[normalizeBirthTimeSource(source)];
|
||||
}
|
||||
|
||||
export function boardDeclaredTimeLine(time: string, source?: string | null): string {
|
||||
const kind = normalizeBirthTimeSource(source);
|
||||
if (kind === "hospital_record") return `出生记录时间 ${time}`;
|
||||
if (kind === "period_only") return `你给的时间段 ${time}`;
|
||||
return `你填的大概时间 ${time}`;
|
||||
}
|
||||
|
||||
export function boardDeclaredTimePeek(time: string, source?: string | null): string {
|
||||
const kind = normalizeBirthTimeSource(source);
|
||||
if (kind === "hospital_record") return `出生记录 ${time}`;
|
||||
if (kind === "period_only") return `时间段 ${time}`;
|
||||
return `大概 ${time}`;
|
||||
}
|
||||
|
||||
function clockMinutes(value: string): number | null {
|
||||
const clock = value.trim().slice(0, 5);
|
||||
if (!CLOCK.test(clock)) return null;
|
||||
const hours = Number(clock.slice(0, 2));
|
||||
const minutes = Number(clock.slice(3, 5));
|
||||
return hours * 60 + minutes;
|
||||
}
|
||||
|
||||
export function minutesBetweenClocks(left: string, right: string): number | null {
|
||||
const start = clockMinutes(left);
|
||||
const end = clockMinutes(right);
|
||||
if (start == null || end == null) return null;
|
||||
return Math.abs(start - end);
|
||||
}
|
||||
|
||||
function clockInRange(clock: string, range: readonly [string, string]): boolean {
|
||||
const time = clockMinutes(clock);
|
||||
const start = clockMinutes(range[0]);
|
||||
const end = clockMinutes(range[1]);
|
||||
if (time == null || start == null || end == null) return false;
|
||||
return time >= Math.min(start, end) && time <= Math.max(start, end);
|
||||
}
|
||||
|
||||
function offsetMinutes(reported: string, range: readonly [string, string] | null, representative: string | null): number | null {
|
||||
if (representative) {
|
||||
const direct = minutesBetweenClocks(reported, representative);
|
||||
if (direct != null) return direct;
|
||||
}
|
||||
if (!range) return null;
|
||||
if (clockInRange(reported, range)) return 0;
|
||||
const toStart = minutesBetweenClocks(reported, range[0]);
|
||||
const toEnd = minutesBetweenClocks(reported, range[1]);
|
||||
if (toStart == null) return toEnd;
|
||||
if (toEnd == null) return toStart;
|
||||
return Math.min(toStart, toEnd);
|
||||
}
|
||||
|
||||
/**
|
||||
* How the declared clock sits next to the current range. Null when there is
|
||||
* no comparable minute (typical for period_only).
|
||||
*/
|
||||
export function reportedTimeOffsetCopy(input: {
|
||||
source?: string | null;
|
||||
reportedTime?: string | null;
|
||||
range?: readonly [string, string] | null;
|
||||
representativeTime?: string | null;
|
||||
}): string | null {
|
||||
const reported = (input.reportedTime ?? "").trim().slice(0, 5);
|
||||
if (!CLOCK.test(reported)) return null;
|
||||
const source = normalizeBirthTimeSource(input.source);
|
||||
const offset = offsetMinutes(reported, input.range ?? null, input.representativeTime ?? null);
|
||||
if (offset == null) return null;
|
||||
if (source === "hospital_record") {
|
||||
const inside = input.range ? clockInRange(reported, input.range) : offset === 0;
|
||||
if (inside) return `出生记录时间 ${reported},落在目前范围内`;
|
||||
const edge = input.range
|
||||
? Math.min(
|
||||
minutesBetweenClocks(reported, input.range[0]) ?? Number.POSITIVE_INFINITY,
|
||||
minutesBetweenClocks(reported, input.range[1]) ?? Number.POSITIVE_INFINITY,
|
||||
)
|
||||
: offset;
|
||||
const gap = Number.isFinite(edge) ? edge : offset;
|
||||
return `出生记录时间 ${reported}。目前范围不含这一分钟,相差 ${gap} 分钟`;
|
||||
}
|
||||
if (source === "period_only") {
|
||||
return `与你给的时间段参照 ${reported} 相差 ${offset} 分钟`;
|
||||
}
|
||||
return `与你填的大概时间 ${reported} 相差 ${offset} 分钟`;
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
REPRESENTATIVE_MINUTE_DISCLAIMER,
|
||||
nonConvergingRangeNarration,
|
||||
} from "../user-copy.ts";
|
||||
import { normalizeBirthTimeSource, type ProvenanceSource } from "../birth-time-provenance.ts";
|
||||
|
||||
export { REPRESENTATIVE_MINUTE_DISCLAIMER, nonConvergingRangeNarration };
|
||||
|
||||
@@ -199,6 +200,8 @@ export type RectificationDecision = Readonly<{
|
||||
terminationCopy?: string | null;
|
||||
/** Close lead with unused D9/D10 style questions: ask those before delivering. */
|
||||
heldForTieBreak?: boolean;
|
||||
/** Declared-time source for copy. Missing means approximate. */
|
||||
birthTimeSource?: "hospital_record" | "approximate" | "period_only";
|
||||
}>;
|
||||
|
||||
export type DecideRectificationInput = Readonly<{
|
||||
@@ -930,6 +933,7 @@ export function publicNextAction(decision: RectificationDecision): Readonly<{
|
||||
representative_time: string | null;
|
||||
credible_range: readonly [string, string] | null;
|
||||
stop_reason: EvidenceStopReason | null;
|
||||
birth_time_source?: ProvenanceSource;
|
||||
}> {
|
||||
return {
|
||||
type: decision.nextAction,
|
||||
@@ -945,6 +949,7 @@ export function publicNextAction(decision: RectificationDecision): Readonly<{
|
||||
representative_time: decision.representativeTime,
|
||||
credible_range: decision.credibleRange,
|
||||
stop_reason: decision.stopReason ?? null,
|
||||
birth_time_source: normalizeBirthTimeSource(decision.birthTimeSource),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
|
||||
import { PROBE_EXPLAIN_COPY } from "./v9/probe-explain.ts";
|
||||
import { STEP_STATE_COPY } from "./v9/step-state.ts";
|
||||
import {
|
||||
REPORTED_TIME_LABEL,
|
||||
boardDeclaredTimeLine,
|
||||
reportedTimeOffsetCopy,
|
||||
} from "./birth-time-provenance.ts";
|
||||
|
||||
export const REPRESENTATIVE_MINUTE_DISCLAIMER = "这只是代表性候选,不是已确认的唯一出生分钟。";
|
||||
|
||||
@@ -606,6 +611,23 @@ export function listUserVisibleCopy(): string[] {
|
||||
...Object.values(USER_COLLECT_QUESTION_RETRY),
|
||||
"还有吗?比如第一份工作、搬到别的城市、谈恋爱或分手、家里添丁或长辈住院。",
|
||||
"再说一件带年月的事",
|
||||
REPORTED_TIME_LABEL.hospital_record,
|
||||
REPORTED_TIME_LABEL.approximate,
|
||||
REPORTED_TIME_LABEL.period_only,
|
||||
boardDeclaredTimeLine("05:00", "hospital_record"),
|
||||
boardDeclaredTimeLine("05:00", "approximate"),
|
||||
reportedTimeOffsetCopy({
|
||||
source: "approximate",
|
||||
reportedTime: "05:00",
|
||||
range: ["04:48", "05:07"],
|
||||
representativeTime: "04:53",
|
||||
}) ?? "",
|
||||
reportedTimeOffsetCopy({
|
||||
source: "hospital_record",
|
||||
reportedTime: "05:12",
|
||||
range: ["04:48", "05:07"],
|
||||
representativeTime: "04:53",
|
||||
}) ?? "",
|
||||
];
|
||||
return values;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { rangeDeliveryForSnapshot } from "@/lib/rectification-agentic/v9/diverge
|
||||
import { rectificationFollowupCatalog } from "@/lib/rectification-agentic/v9/decision-from-dossier";
|
||||
import { tieBreakGateInput, tieBreakPersonalityAvailable } from "@/lib/rectification-agentic/v9/method-followup";
|
||||
import { latestResultToolProjection } from "@/mastra/rectification-v9-tools";
|
||||
import { normalizeBirthTimeSource } from "@/lib/rectification-agentic/birth-time-provenance";
|
||||
|
||||
export function dossierResponse(
|
||||
dossier: V9CaseDossier,
|
||||
@@ -31,6 +32,7 @@ export function dossierResponse(
|
||||
const listed = options.listed ?? { focuses: [] as const, available: true };
|
||||
const turnsWithQuestions = attachQuestionsToTurns(dossier.turns, listed.focuses);
|
||||
const fields = publicDecisionFields(decision);
|
||||
const birthTimeSource = normalizeBirthTimeSource(dossier.case.birthTimeSource);
|
||||
const overlaid = dossier.latestResult
|
||||
? overlayPublicDecision(dossier.latestResult, decision)
|
||||
: fields;
|
||||
@@ -55,6 +57,7 @@ export function dossierResponse(
|
||||
completed_at: dossier.case.completedAt,
|
||||
closed_reason: dossier.case.closedReason,
|
||||
last_activity_at: dossier.case.lastActivityAt,
|
||||
birth_time_source: birthTimeSource,
|
||||
case_revision: previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null)?.revision ?? 0,
|
||||
},
|
||||
turns: turns.map((turn) => ({
|
||||
@@ -75,6 +78,7 @@ export function dossierResponse(
|
||||
: null,
|
||||
interview: {
|
||||
...fields,
|
||||
birth_time_source: birthTimeSource,
|
||||
collection_progress: collectionProgressFromReceipt(dossier.latestResult?.decisionReceipt ?? null),
|
||||
},
|
||||
current_question: projectCurrentQuestion(dossier.conversationSummary.activeFocus),
|
||||
@@ -121,6 +125,8 @@ function publicLatestResult(
|
||||
key.includes("varga.d9") || key.includes("varga.d10")
|
||||
)),
|
||||
stillTied: decision.separation.lead <= 1 && decision.separation.ranked.length >= 2,
|
||||
reportedBirthTime: dossier.case.reportedBirthTime,
|
||||
birthTimeSource: dossier.case.birthTimeSource,
|
||||
});
|
||||
const withDelivery = {
|
||||
...projected,
|
||||
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
import { evidenceLedgerFingerprint } from "./tool-service";
|
||||
import { followupCaseArgs, blockScanDeclinedForFingerprint } from "./block-scan.ts";
|
||||
import { openingRangeFromCandidateRange } from "../user-copy.ts";
|
||||
import { normalizeBirthTimeSource } from "../birth-time-provenance.ts";
|
||||
import {
|
||||
representativeNearWindowEdge,
|
||||
} from "./search-window.ts";
|
||||
@@ -926,6 +927,7 @@ export function decideFromDossier(
|
||||
...narrowingExhaustion(dossier, inference, options, catalog),
|
||||
}),
|
||||
droppedProbes: mergeDroppedProbes(gated.dropped, nakshatra.dropped),
|
||||
birthTimeSource: normalizeBirthTimeSource(dossier.case.birthTimeSource),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -971,30 +973,33 @@ export function decideAfterInferenceChange(input: {
|
||||
if (!input.state) {
|
||||
const evidenceStops = evidenceStopInputs(input.dossier.evidence);
|
||||
const caseStage = input.dossier.case.stage === "block_scan" ? "block_scan" : "minute";
|
||||
return decideRectification({
|
||||
methodCoverageAll: blockingMethodsCovered(collecting.methods),
|
||||
trainingGateOpen: caseStage === "block_scan"
|
||||
? evidenceStops.datedEventCount >= MIN_STANDALONE_DATED_EVENTS
|
||||
&& evidenceStops.datedDomainCount >= MIN_STANDALONE_DATED_DOMAINS
|
||||
: trainingScoreableGate(input.dossier.evidence).open,
|
||||
candidateScores: [],
|
||||
userStopped: input.userStopped,
|
||||
snapshotCurrent,
|
||||
engineCeiling: engineCapabilityCeilingFromReceipt(input.dossier.latestResult?.decisionReceipt ?? null),
|
||||
...decisionBudgetFromInference(null),
|
||||
...evidenceStops,
|
||||
caseStage: input.dossier.case.stage === "block_scan" ? "block_scan" : "minute",
|
||||
blockScanDeclined: blockScanDeclinedForFingerprint(
|
||||
input.dossier.case.blockScan,
|
||||
evidenceLedgerFingerprint(input.dossier.evidence as never),
|
||||
),
|
||||
windowWidenSuggested: windowWidenSuggestedFromDossier(
|
||||
input.dossier,
|
||||
evidenceLedgerFingerprint(input.dossier.evidence as never),
|
||||
),
|
||||
openingCandidateRange: openingRangeFromCandidateRange(input.dossier.case.candidateRange),
|
||||
...tieBreakHold,
|
||||
});
|
||||
return {
|
||||
...decideRectification({
|
||||
methodCoverageAll: blockingMethodsCovered(collecting.methods),
|
||||
trainingGateOpen: caseStage === "block_scan"
|
||||
? evidenceStops.datedEventCount >= MIN_STANDALONE_DATED_EVENTS
|
||||
&& evidenceStops.datedDomainCount >= MIN_STANDALONE_DATED_DOMAINS
|
||||
: trainingScoreableGate(input.dossier.evidence).open,
|
||||
candidateScores: [],
|
||||
userStopped: input.userStopped,
|
||||
snapshotCurrent,
|
||||
engineCeiling: engineCapabilityCeilingFromReceipt(input.dossier.latestResult?.decisionReceipt ?? null),
|
||||
...decisionBudgetFromInference(null),
|
||||
...evidenceStops,
|
||||
caseStage: input.dossier.case.stage === "block_scan" ? "block_scan" : "minute",
|
||||
blockScanDeclined: blockScanDeclinedForFingerprint(
|
||||
input.dossier.case.blockScan,
|
||||
evidenceLedgerFingerprint(input.dossier.evidence as never),
|
||||
),
|
||||
windowWidenSuggested: windowWidenSuggestedFromDossier(
|
||||
input.dossier,
|
||||
evidenceLedgerFingerprint(input.dossier.evidence as never),
|
||||
),
|
||||
openingCandidateRange: openingRangeFromCandidateRange(input.dossier.case.candidateRange),
|
||||
...tieBreakHold,
|
||||
}),
|
||||
birthTimeSource: normalizeBirthTimeSource(input.dossier.case.birthTimeSource),
|
||||
};
|
||||
}
|
||||
const training = input.state.events.filter((item) => item.usage === "training");
|
||||
const trainingDomains = new Set(training.map((item) => item.domain));
|
||||
@@ -1075,6 +1080,7 @@ export function decideAfterInferenceChange(input: {
|
||||
...narrowingExhaustion(input.dossier, input.state, undefined, catalog),
|
||||
}),
|
||||
droppedProbes: mergeDroppedProbes(gated.dropped, nakshatra.dropped),
|
||||
birthTimeSource: normalizeBirthTimeSource(input.dossier.case.birthTimeSource),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
rangeDeliveryWindowLine,
|
||||
sharedTraitLine,
|
||||
} from "../user-copy.ts";
|
||||
import { reportedTimeOffsetCopy } from "../birth-time-provenance.ts";
|
||||
import { previousInferenceFromReceipt } from "./inference-adapter.ts";
|
||||
import {
|
||||
rangeNarrowHint,
|
||||
@@ -84,6 +85,7 @@ export type RangeDeliveryProjection = Readonly<{
|
||||
narrow_hint: string | null;
|
||||
tie_break_available: boolean;
|
||||
tie_break_note: string | null;
|
||||
provenance_line: string | null;
|
||||
}>;
|
||||
|
||||
export type PublicCandidateClock = Readonly<{
|
||||
@@ -338,6 +340,8 @@ export function buildRangeDelivery(input: {
|
||||
tieBreakAvailable?: boolean;
|
||||
tieBreakUsed?: boolean;
|
||||
stillTied?: boolean;
|
||||
reportedBirthTime?: string | null;
|
||||
birthTimeSource?: string | null;
|
||||
}): RangeDeliveryProjection {
|
||||
const inference = input.inference;
|
||||
const range = input.credibleRange
|
||||
@@ -428,6 +432,12 @@ export function buildRangeDelivery(input: {
|
||||
&& input.stillTied === true
|
||||
? RECTIFICATION_USER_COPY.rangeDeliveryTieBreakUsed
|
||||
: null,
|
||||
provenance_line: reportedTimeOffsetCopy({
|
||||
source: input.birthTimeSource,
|
||||
reportedTime: input.reportedBirthTime,
|
||||
range: normalizedRange,
|
||||
representativeTime,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -488,6 +498,10 @@ export function rangeDeliveryForSnapshot(snapshot: {
|
||||
tieBreakAvailable?: boolean;
|
||||
tieBreakUsed?: boolean;
|
||||
stillTied?: boolean;
|
||||
reportedBirthTime?: string | null;
|
||||
reported_birth_time?: string | null;
|
||||
birthTimeSource?: string | null;
|
||||
birth_time_source?: string | null;
|
||||
} | null | undefined): RangeDeliveryProjection {
|
||||
const receipt = snapshot?.decisionReceipt ?? snapshot?.decision_receipt ?? null;
|
||||
const inference = previousInferenceFromReceipt(receipt);
|
||||
@@ -520,6 +534,8 @@ export function rangeDeliveryForSnapshot(snapshot: {
|
||||
tieBreakAvailable: snapshot?.accepted !== true && snapshot?.tieBreakAvailable === true,
|
||||
tieBreakUsed: snapshot?.tieBreakUsed === true,
|
||||
stillTied: snapshot?.stillTied === true,
|
||||
reportedBirthTime: snapshot?.reportedBirthTime ?? snapshot?.reported_birth_time,
|
||||
birthTimeSource: snapshot?.birthTimeSource ?? snapshot?.birth_time_source,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -667,5 +683,10 @@ export function parseRangeDelivery(value: unknown): RangeDeliveryProjection | nu
|
||||
: typeof row.tieBreakNote === "string" && row.tieBreakNote.trim()
|
||||
? row.tieBreakNote.trim()
|
||||
: null,
|
||||
provenance_line: typeof row.provenance_line === "string" && row.provenance_line.trim()
|
||||
? row.provenance_line.trim()
|
||||
: typeof row.provenanceLine === "string" && row.provenanceLine.trim()
|
||||
? row.provenanceLine.trim()
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type RectificationCandidateResult,
|
||||
type RectificationHouseTable,
|
||||
} from "./rectification-candidate-result";
|
||||
import { boardDeclaredTimePeek } from "./rectification-agentic/birth-time-provenance";
|
||||
|
||||
export const RECTIFICATION_BOARD_SPLIT_MIN_PX = 768;
|
||||
|
||||
@@ -163,6 +164,7 @@ export function diffRectificationBoard(
|
||||
export function rectificationBoardPeekCopy(
|
||||
result: RectificationCandidateResult | null,
|
||||
declaredTime: string | null = null,
|
||||
source?: string | null,
|
||||
): Readonly<{
|
||||
title: string;
|
||||
detail: string | null;
|
||||
@@ -171,7 +173,10 @@ export function rectificationBoardPeekCopy(
|
||||
if (!table) {
|
||||
// Before any candidate exists the board still has a time to show: the one
|
||||
// the reader declared. Without one, say what fills it later.
|
||||
return { title: "当前盘面", detail: declaredTime ? `填报 ${declaredTime}` : "补充经历后会在这里更新" };
|
||||
return {
|
||||
title: "当前盘面",
|
||||
detail: declaredTime ? boardDeclaredTimePeek(declaredTime, source) : "补充经历后会在这里更新",
|
||||
};
|
||||
}
|
||||
return { title: "当前盘面", detail: `${table.time} · 上升${table.lagna}` };
|
||||
}
|
||||
|
||||
@@ -11,6 +11,11 @@
|
||||
import type { PersistedRectificationTurn } from "../components/conversational-birth-time-rectification.tsx";
|
||||
import { BOOTSTRAP_PREPARE_TIMEOUT_MS } from "./home-bootstrap.ts";
|
||||
import { sessionOutcomeAllowsDelivery } from "./rectification-agentic/core/rectification-decision.ts";
|
||||
import {
|
||||
boardDeclaredTimeLine,
|
||||
normalizeBirthTimeSource,
|
||||
type ProvenanceSource,
|
||||
} from "./rectification-agentic/birth-time-provenance.ts";
|
||||
|
||||
/**
|
||||
* Upper bound on hydrating a Case (turns + snapshot) after `/cases/open`
|
||||
@@ -82,6 +87,7 @@ export type RectificationCaseSnapshotPayload = Readonly<{
|
||||
*/
|
||||
candidate_range?: unknown;
|
||||
stage?: unknown;
|
||||
birth_time_source?: unknown;
|
||||
}>;
|
||||
turns?: unknown;
|
||||
}>;
|
||||
@@ -469,7 +475,10 @@ export function rectificationAdoptingLabel(time: string): string {
|
||||
}
|
||||
|
||||
/** Board copy when no candidate result exists yet (task 1.3). */
|
||||
export function rectificationBoardEmptyCopy(declaredTime: string | null): Readonly<{
|
||||
export function rectificationBoardEmptyCopy(
|
||||
declaredTime: string | null,
|
||||
source?: string | null,
|
||||
): Readonly<{
|
||||
clock: string | null;
|
||||
lines: readonly string[];
|
||||
}> {
|
||||
@@ -478,10 +487,15 @@ export function rectificationBoardEmptyCopy(declaredTime: string | null): Readon
|
||||
}
|
||||
return {
|
||||
clock: declaredTime,
|
||||
lines: [`填报出生时间 ${declaredTime}`, "回答几个问题后,这里会显示宫位随时间的变化。"],
|
||||
lines: [boardDeclaredTimeLine(declaredTime, source), "回答几个问题后,这里会显示宫位随时间的变化。"],
|
||||
};
|
||||
}
|
||||
|
||||
export function birthTimeSourceFromSnapshot(payload: RectificationCaseSnapshotPayload | null | undefined): ProvenanceSource {
|
||||
const raw = payload?.case?.birth_time_source;
|
||||
return normalizeBirthTimeSource(typeof raw === "string" ? raw : null);
|
||||
}
|
||||
|
||||
/** The declared birth minute from the profile, or null when only a period was given. */
|
||||
export function declaredBirthTime(profile: Readonly<{ time?: string; reportedTime?: string }>): string | null {
|
||||
const candidate = (profile.reportedTime || profile.time || "").trim();
|
||||
|
||||
Reference in New Issue
Block a user