From 9dc2948ab6ce16fb9d40d368461e56ccc409f6f3 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Mon, 17 Aug 2026 18:35:42 +0800 Subject: [PATCH] feat(onboarding): pick the birth place by level instead of by search The single search field asked people to type a place name and then judge which of several near-identical results was theirs, which is the one thing they cannot verify about their own birth record. Province, city and district are now chosen from the dataset the app already ships, so there is nothing to type and nothing to disambiguate. Levels that offer no choice collapse: municipalities show one level, and prefecture cities without districts stop at the city. A district can be left as the city centre, which is accurate enough because the chart only needs coordinates and a timezone. The timezone is resolved once for the chosen place rather than on every keystroke, through a dedicated route that both this picker and the Geoapify path share. Co-authored-by: Cursor --- frontend/DESIGN.md | 11 + .../src/app/api/locations/timezone/route.ts | 28 ++ frontend/src/app/globals.css | 42 +-- frontend/src/app/page.tsx | 49 +-- .../src/components/birth-place-picker.tsx | 303 ++++++++++++++++++ .../components/location-search-combobox.tsx | 281 ---------------- .../lib/birth-location-timezone-service.ts | 62 ++++ frontend/src/lib/china-birth-place.ts | 111 +++++++ frontend/src/lib/geoapify-location-service.ts | 46 +-- frontend/src/lib/location-contract.ts | 27 ++ frontend/tests/birth-place-picker.test.ts | 148 +++++++++ .../tests/location-search-combobox.test.ts | 88 ----- .../tests/mobile-interaction-contract.test.ts | 8 +- 13 files changed, 744 insertions(+), 460 deletions(-) create mode 100644 frontend/src/app/api/locations/timezone/route.ts create mode 100644 frontend/src/components/birth-place-picker.tsx delete mode 100644 frontend/src/components/location-search-combobox.tsx create mode 100644 frontend/src/lib/birth-location-timezone-service.ts create mode 100644 frontend/src/lib/china-birth-place.ts create mode 100644 frontend/tests/birth-place-picker.test.ts delete mode 100644 frontend/tests/location-search-combobox.test.ts diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index d4dba4da..bb87d7a3 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -110,6 +110,17 @@ The base unit is 4px. Tokens are `--space-1: 4px`, `--space-2: 8px`, `--space-3: - **Guided candidate states:** low confidence presents the saved candidate range and either another evidence question or a safe finish; medium confidence can save the range but never apply a representative minute; high confidence names both “候选时间” and “当前排盘使用时间” before explicit confirmation; ready states that the current chart time changed while the original report remains preserved. No state calls a candidate the true birth minute. - **Guided responsive/accessibility contract:** body copy remains at least 14px, labels at least 12px, and all controls at least 44px. Focus is always visible, the composer is keyboard operable, semantic Chinese phrases remain together at 390px, and reduced-motion removes entrance translation while retaining state changes. +### Birth place picker + +- **Composition:** three cascading Base UI Selects — province, city, district — over the bundled China administrative dataset. There is no free-text search and no overseas provider in this surface. +- **Level collapsing:** a level that offers no choice is not rendered. Municipalities and special administrative regions skip the repeated city level; prefecture cities without districts complete at the city; a province with neither lower level completes at the province. +- **Unsure escape hatch:** any city that has districts leads its district list with “不确定,用市区中心”, so browsing never dead-ends. A stored empty district resumes as that choice. +- **Precision copy:** the picker states that county-level precision is enough for a chart, so a village or township birth is not treated as missing data. +- **Value:** administrative codes, the node centre coordinate, and the IANA zone are all persisted. Coordinates come from the dataset; the zone is resolved once per chosen place through the chart engine rather than per keystroke. +- **States:** nothing chosen, partially chosen, resolving the zone, resolved, and zone service unavailable with an explicit retry. An unresolved zone is never reported upward as a usable birth place. +- **Saved profiles:** mounting an already-saved place neither refetches nor reports a change, so opening the profile dialog cannot look like a location edit. Changing the birth date does invalidate the stored offset and resolves the zone again. +- **Accessibility:** every level keeps a visible label, an explicit trigger name, and the 44px target from the select recipe; status text uses a polite live region. + ### Birth date picker - **Composition:** shadcn outline Button trigger, Base UI Popover, and a single-select React DayPicker Calendar. diff --git a/frontend/src/app/api/locations/timezone/route.ts b/frontend/src/app/api/locations/timezone/route.ts new file mode 100644 index 00000000..b9d0b6de --- /dev/null +++ b/frontend/src/app/api/locations/timezone/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server"; +import { resolveBirthLocationTimezone } from "@/lib/birth-location-timezone-service"; +import { birthLocationTimezoneQuerySchema } from "@/lib/location-contract"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; + +export const runtime = "nodejs"; + +export async function POST(request: Request) { + const supabase = await createServerSupabaseClient(); + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "时区解析参数不正确" }, { status: 400 }); + } + + const parsed = birthLocationTimezoneQuerySchema.safeParse(body); + if (!parsed.success) return NextResponse.json({ + error: "时区解析参数不正确", + details: parsed.error.flatten(), + }, { status: 400 }); + + const result = await resolveBirthLocationTimezone(parsed.data); + return NextResponse.json(result, { status: result.status === "ok" ? 200 : 503 }); +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 95201891..c2b234e1 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -237,31 +237,20 @@ button:disabled { cursor: default; opacity: .45; } .profile-grid, .location-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } .location-fieldset { margin: 0; padding: 0; border: 0; } .location-fieldset legend { margin-bottom: 10px; color: var(--color-ink-secondary); font-size: 11px; font-weight: 600; } -.location-combobox { position: relative; display: grid; gap: 7px; } -.location-combobox > label { color: var(--color-ink-secondary); font-size: 11px; font-weight: 600; } -.location-combobox-input-wrap { position: relative; } -.location-combobox-input-wrap input { width: 100%; min-height: 46px; padding-left: 42px; padding-right: 42px; } -.location-combobox.is-selected .location-combobox-input-wrap input { border-color: color-mix(in srgb, var(--color-success) 42%, var(--color-border)); background: color-mix(in srgb, var(--color-success-muted) 42%, var(--color-canvas)); } -.location-combobox-leading { width: 17px; height: 17px; position: absolute; z-index: 1; left: 14px; top: 50%; color: var(--color-ink-tertiary); pointer-events: none; transform: translateY(-50%); } -.location-combobox-leading.is-spinning { animation: location-combobox-spin .8s linear infinite; } -.location-combobox-clear { width: 32px; height: 32px; position: absolute; right: 7px; top: 50%; display: grid; place-items: center; border: 0; border-radius: 50%; background: transparent; color: var(--color-ink-secondary); cursor: pointer; transform: translateY(-50%); } -.location-combobox-clear:hover { background: var(--color-canvas-muted); color: var(--color-ink); } -.location-combobox-clear svg { width: 16px; height: 16px; } -.location-combobox-status { min-height: 20px; display: flex; align-items: flex-start; gap: 6px; margin: 0; color: var(--color-ink-tertiary); font-size: 11px; line-height: 1.5; } -.location-combobox.is-selected .location-combobox-status { color: var(--color-success); } -.location-combobox-status.is-error { color: var(--color-danger); } -.location-combobox-status svg { width: 14px; height: 14px; flex: 0 0 auto; margin-top: 1px; } -.location-combobox-results { width: 100%; max-height: 280px; position: absolute; z-index: 70; top: calc(100% - 16px); left: 0; overflow-y: auto; display: grid; gap: 2px; margin: 0; padding: 5px; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); box-shadow: var(--shadow-elevated); list-style: none; } -.location-combobox-results li { margin: 0; } -.location-combobox-results li > button { width: 100%; min-height: 50px; display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 10px; padding: 9px 10px; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--color-ink); cursor: pointer; text-align: left; } -.location-combobox-results li > button > svg { width: 17px; height: 17px; color: var(--color-ink-tertiary); } -.location-combobox-results li > button > span { min-width: 0; display: grid; gap: 2px; } -.location-combobox-results li > button b { overflow: hidden; font-size: 13px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } -.location-combobox-results li > button small { overflow: hidden; color: var(--color-ink-tertiary); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } -.location-combobox-results li.is-active > button, .location-combobox-results li > button:hover { background: var(--color-action-soft); color: var(--color-action-hover); } -.location-combobox-feedback { padding: 12px; color: var(--color-ink-secondary); font-size: 12px; line-height: 1.5; } -.location-combobox-feedback.is-error { color: var(--color-danger); } -@keyframes location-combobox-spin { to { transform: translateY(-50%) rotate(360deg); } } +.birth-place-picker { display: grid; gap: 8px; } +.birth-place-levels { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; } +.birth-place-levels > label { min-width: 0; display: grid; gap: 7px; } +.birth-place-levels > label > span { color: var(--color-ink-secondary); font-size: 11px; font-weight: 600; } +.birth-place-note { margin: 0; color: var(--color-ink-tertiary); font-size: 11px; line-height: 1.5; text-wrap: pretty; word-break: auto-phrase; } +.birth-place-status { min-height: 20px; display: flex; align-items: flex-start; gap: 6px; margin: 0; color: var(--color-ink-tertiary); font-size: 11px; line-height: 1.5; } +.birth-place-status.is-ready { color: var(--color-success); } +.birth-place-status.is-error { color: var(--color-danger); } +.birth-place-status > svg { width: 14px; height: 14px; flex: 0 0 auto; margin-top: 1px; } +.birth-place-status > span { min-width: 0; text-wrap: pretty; word-break: auto-phrase; } +.birth-place-status .is-spinning { animation: birth-place-spin .8s linear infinite; } +.birth-place-retry { display: inline-flex; align-items: center; gap: 4px; flex: 0 0 auto; border: 0; background: transparent; color: var(--color-action); font: inherit; cursor: pointer; text-decoration: underline; text-underline-offset: 2px; } +.birth-place-retry svg { width: 12px; height: 12px; } +@keyframes birth-place-spin { to { transform: rotate(360deg); } } .save-profile { justify-self: end; } .form-error { background: var(--color-danger-muted); color: var(--color-danger); } .form-success { background: var(--color-success-muted); color: var(--color-success); } @@ -958,7 +947,8 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .message-content { max-width: 88%; } .composer-wrap { padding: var(--space-2) var(--space-3) max(var(--space-3), env(safe-area-inset-bottom)); } :root { --composer-reserve: 116px; } - .location-combobox-results { position: static; top: auto; max-height: min(240px, 42dvh); margin-top: var(--space-2); box-shadow: none; } + .select-content { max-height: min(320px, 56dvh); } + .select-list { max-height: min(288px, 48dvh) !important; } .account-modal { max-height: calc(100dvh - var(--space-8)); padding: var(--space-6); } .profile-modal .account-modal-header { margin: calc(var(--space-6) * -1) calc(var(--space-6) * -1) var(--space-4); padding: var(--space-6) var(--space-6) var(--space-4); } .avatar-editor { grid-template-columns: 72px minmax(0, 1fr); gap: var(--space-4); } diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 1995a499..f6f4841e 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -28,10 +28,7 @@ import { ChatMessageContent } from "@/components/chat-message-content"; import { AgentAvatar, ChatMessageRow } from "@/components/chat-message-row"; import { ModelSelector } from "@/components/model-selector"; import { OnboardingRedeemPaywall } from "@/components/onboarding-redeem-paywall"; -import { - LocationSearchCombobox, - type ResolvedBirthLocation, -} from "@/components/location-search-combobox"; +import { BirthPlacePicker } from "@/components/birth-place-picker"; import { ChatComposer } from "@/components/chat-composer"; import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; import { composerDraftSnapshot, setComposerDraft } from "@/lib/composer-draft"; @@ -390,28 +387,6 @@ function selectedBirthPlace(profile: Profile): BirthPlace | null { }; } -function selectedLocationValue(profile: Profile): ResolvedBirthLocation | null { - const birthPlace = selectedBirthPlace(profile); - if (!birthPlace) return null; - return { - id: profile.birthPlaceProviderId || `legacy-cn:${profile.provinceCode}:${profile.cityCode}:${profile.districtCode}`, - label: birthPlace.label, - placeType: profile.birthPlaceType || "legacy_china_admin", - provider: profile.birthPlaceProvider || "legacy_china_locations", - providerPlaceId: profile.birthPlaceProviderId, - countryCode: profile.countryCode || "CN", - countryName: profile.countryCode === "CN" ? "中国" : "", - admin1: "", - admin2: "", - locality: "", - latitude: birthPlace.lat, - longitude: birthPlace.lon, - timezoneId: birthPlace.timezoneId, - timezoneOffset: birthPlace.tz, - timezoneSource: profile.timezoneSource || "legacy_fixed_offset", - }; -} - function chartLibraryStorageKey(accountId: string) { return `jyotisha_chart_library:${accountId}`; } @@ -761,22 +736,28 @@ function BirthLocationFields({ value, onChange }: { value: Profile; onChange: (p return (
出生地点 - onChange({ ...value, - countryCode: location?.countryCode || "CN", - provinceCode: "", - cityCode: "", - districtCode: "", + countryCode: "CN", + provinceCode: location?.provinceCode || "", + cityCode: location?.cityCode || "", + districtCode: location?.districtCode || "", birthPlaceLabel: location?.label || "", birthPlaceType: location?.placeType || "", - birthPlaceProvider: location?.provider || "", + birthPlaceProvider: location ? "china_locations" : "", birthPlaceProviderId: location?.providerPlaceId || "", timezoneId: location?.timezoneId || "", - timezoneSource: location?.timezoneSource || "", + timezoneSource: location ? "iana_historical" : "", latitude: location?.latitude ?? null, longitude: location?.longitude ?? null, timezoneOffset: location?.timezoneOffset ?? null, diff --git a/frontend/src/components/birth-place-picker.tsx b/frontend/src/components/birth-place-picker.tsx new file mode 100644 index 00000000..7e718633 --- /dev/null +++ b/frontend/src/components/birth-place-picker.tsx @@ -0,0 +1,303 @@ +"use client"; + +import { Check, LoaderCircle, MapPin, RotateCcw } from "lucide-react"; +import { useEffect, useId, useRef, useState } from "react"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { + birthPlaceDraftFromLevels, + chinaProvinces, + findCityNode, + findProvinceNode, + formatBirthPlaceCoordinate, + formatBirthPlaceTimezone, + hasVirtualCityLevel, + resolveBirthPlaceNode, +} from "@/lib/china-birth-place"; + +/** Sentinel for "this city has districts but the user cannot name one". */ +const cityCentreOption = "__city_centre__"; +const cityCentreLabel = "不确定,用市区中心"; + +export type BirthPlaceSelection = { + provinceCode: string; + cityCode: string; + districtCode: string; + label: string; + placeType: "province" | "city" | "district"; + providerPlaceId: string; + latitude: number; + longitude: number; + timezoneId: string; + timezoneOffset: number | null; +}; + +export type SavedBirthPlace = { + provinceCode: string; + cityCode: string; + districtCode: string; + timezoneId: string; + timezoneOffset: number | null; +}; + +type BirthPlacePickerProps = { + value: SavedBirthPlace; + birthDate?: string; + birthTime?: string; + disabled?: boolean; + onChange: (selection: BirthPlaceSelection | null) => void; +}; + +type TimezoneState = + | { status: "idle" } + | { status: "loading" } + | { status: "ready"; timezoneId: string; timezoneOffset: number | null } + | { status: "error" }; + +/** The outcome of one lookup, tagged with the place it was requested for. */ +type TimezoneOutcome = { key: string; state: Extract }; + +export function BirthPlacePicker({ + value, + birthDate = "", + birthTime = "", + disabled = false, + onChange, +}: BirthPlacePickerProps) { + const { provinceCode, cityCode, districtCode } = value; + const fieldPrefix = useId(); + // Read by the lookup effect so that a re-rendered parent does not restart it. + const onChangeRef = useRef(onChange); + const savedRef = useRef(value); + useEffect(() => { + onChangeRef.current = onChange; + savedRef.current = value; + }); + + const externalKey = `${provinceCode}/${cityCode}/${districtCode}`; + const [draft, setDraft] = useState(() => birthPlaceDraftFromLevels({ provinceCode, cityCode, districtCode })); + const [syncedKey, setSyncedKey] = useState(externalKey); + const [outcome, setOutcome] = useState(null); + const [retryToken, setRetryToken] = useState(0); + + // Adopt a profile that was loaded or replaced outside this picker. + if (syncedKey !== externalKey) { + setSyncedKey(externalKey); + if (provinceCode) setDraft(birthPlaceDraftFromLevels({ provinceCode, cityCode, districtCode })); + } + + const province = findProvinceNode(draft.provinceCode); + const virtualCity = Boolean(province && hasVirtualCityLevel(province)); + const city = province && virtualCity ? province.cities[0] : findCityNode(province, draft.cityCode); + const districts = city?.districts ?? []; + const districtChosen = Boolean(draft.districtCode) && districts.some((item) => item.code === draft.districtCode); + + // Levels that offer no choice are skipped, so completeness stops at the deepest real level. + const complete = Boolean(city) && (districts.length === 0 || districtChosen || draft.cityCentre); + const resolved = complete + ? resolveBirthPlaceNode({ + provinceCode: draft.provinceCode, + cityCode: city?.code ?? "", + districtCode: districtChosen ? draft.districtCode : "", + }) + : null; + // The offset the profile already carries is only authoritative while the birth + // moment it was computed against is unchanged. + const birthMoment = `${birthDate}/${birthTime}`; + const [mountedMoment] = useState(birthMoment); + const savedIsCurrent = resolved !== null + && mountedMoment === birthMoment + && value.timezoneId.trim() !== "" + && provinceCode === resolved.province.code + && cityCode === resolved.city.code + && districtCode === (resolved.district?.code ?? ""); + + const syncKey = savedIsCurrent + ? "saved" + : resolved + ? `${resolved.node.code}/${birthMoment}/${retryToken}` + : "empty"; + + useEffect(() => { + if (syncKey === "saved") return; + if (syncKey === "empty") { + const saved = savedRef.current; + if (saved.provinceCode || saved.timezoneId) onChangeRef.current(null); + return; + } + + const target = resolved as NonNullable; + const controller = new AbortController(); + + (async () => { + try { + const response = await fetch("/api/locations/timezone", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + signal: controller.signal, + body: JSON.stringify({ + latitude: target.latitude, + longitude: target.longitude, + ...(birthDate ? { birthDate } : {}), + ...(birthDate && birthTime ? { birthTime } : {}), + }), + }); + if (!response.ok) throw new Error("timezone_unavailable"); + const payload = await response.json() as { timezoneId?: unknown; timezoneOffset?: unknown }; + const timezoneId = typeof payload.timezoneId === "string" ? payload.timezoneId.trim() : ""; + if (!timezoneId) throw new Error("timezone_unavailable"); + const timezoneOffset = typeof payload.timezoneOffset === "number" ? payload.timezoneOffset : null; + + setOutcome({ key: syncKey, state: { status: "ready", timezoneId, timezoneOffset } }); + onChangeRef.current({ + provinceCode: target.province.code, + cityCode: target.city.code, + districtCode: target.district?.code ?? "", + label: target.label, + placeType: target.placeType, + providerPlaceId: target.node.code, + latitude: target.latitude, + longitude: target.longitude, + timezoneId, + timezoneOffset, + }); + } catch { + if (controller.signal.aborted) return; + setOutcome({ key: syncKey, state: { status: "error" } }); + onChangeRef.current(null); + } + })(); + + return () => controller.abort(); + // syncKey collapses the node plus the birth moment the historical offset depends on. + }, [syncKey]); // eslint-disable-line react-hooks/exhaustive-deps + + function chooseProvince(nextCode: string) { + const nextProvince = findProvinceNode(nextCode); + setDraft({ + provinceCode: nextCode, + cityCode: nextProvince && hasVirtualCityLevel(nextProvince) ? nextProvince.cities[0].code : "", + districtCode: "", + cityCentre: false, + }); + } + + function chooseCity(nextCode: string) { + setDraft((current) => ({ ...current, cityCode: nextCode, districtCode: "", cityCentre: false })); + } + + function chooseDistrict(nextCode: string) { + setDraft((current) => ({ + ...current, + districtCode: nextCode === cityCentreOption ? "" : nextCode, + cityCentre: nextCode === cityCentreOption, + })); + } + + // A place with no lookup recorded against it is still being looked up. + const settled: TimezoneState = syncKey === "empty" + ? { status: "idle" } + : syncKey === "saved" + ? { status: "ready", timezoneId: value.timezoneId, timezoneOffset: value.timezoneOffset } + : outcome?.key === syncKey + ? outcome.state + : { status: "loading" }; + const statusText = settled.status === "loading" + ? "正在确认该地点的时区…" + : settled.status === "ready" && resolved + ? `${resolved.label} · ${formatBirthPlaceCoordinate(resolved.latitude, resolved.longitude)} · ${formatBirthPlaceTimezone(settled.timezoneId, settled.timezoneOffset)}` + : settled.status === "error" + ? "时区服务暂时不可用,稍后重试即可。" + : "按省、市、区县逐级选择即可。"; + + return ( +
+
+ + + {province && !virtualCity && ( + + )} + + {city && districts.length > 0 && ( + + )} +
+ +

排盘只需要经纬度和时区,精确到区 / 县就足够,同一个县内的差异不会改变盘面。

+ +

+ {settled.status === "loading" &&

+
+ ); +} diff --git a/frontend/src/components/location-search-combobox.tsx b/frontend/src/components/location-search-combobox.tsx deleted file mode 100644 index 526f7d4d..00000000 --- a/frontend/src/components/location-search-combobox.tsx +++ /dev/null @@ -1,281 +0,0 @@ -"use client"; - -import { Check, LoaderCircle, MapPin, Search, X } from "lucide-react"; -import { useEffect, useId, useRef, useState } from "react"; -import type { KeyboardEvent } from "react"; - -export type ResolvedBirthLocation = { - id: string; - label: string; - placeType: string; - provider: string; - providerPlaceId: string; - countryCode: string; - countryName: string; - admin1: string; - admin2: string; - locality: string; - latitude: number; - longitude: number; - timezoneId: string; - timezoneOffset: number | null; - timezoneSource: string; -}; - -type LocationSearchComboboxProps = { - value: ResolvedBirthLocation | null; - birthDate?: string; - birthTime?: string; - disabled?: boolean; - onChange: (location: ResolvedBirthLocation | null) => void; -}; - -type SearchState = "idle" | "loading" | "ready" | "empty" | "error"; - -function cleanString(value: unknown) { - return typeof value === "string" ? value.trim() : ""; -} - -export function parseLocationSearchResults(payload: unknown): ResolvedBirthLocation[] { - const rawResults = Array.isArray(payload) - ? payload - : payload && typeof payload === "object" && Array.isArray((payload as { results?: unknown }).results) - ? (payload as { results: unknown[] }).results - : payload && typeof payload === "object" && Array.isArray((payload as { locations?: unknown }).locations) - ? (payload as { locations: unknown[] }).locations - : []; - - return rawResults.flatMap((candidate): ResolvedBirthLocation[] => { - if (!candidate || typeof candidate !== "object") return []; - const item = candidate as Record; - const latitude = typeof item.latitude === "number" ? item.latitude : Number.NaN; - const longitude = typeof item.longitude === "number" ? item.longitude : Number.NaN; - const timezoneOffset = item.timezoneOffset === null - ? null - : typeof item.timezoneOffset === "number" - ? item.timezoneOffset - : Number.NaN; - const provider = cleanString(item.provider); - const providerPlaceId = cleanString(item.providerPlaceId); - const id = cleanString(item.id) || (provider && providerPlaceId ? `${provider}:${providerPlaceId}` : ""); - const label = cleanString(item.label); - const countryCode = cleanString(item.countryCode).toUpperCase(); - const timezoneId = cleanString(item.timezoneId); - if (!id || !label || !countryCode || !timezoneId - || !Number.isFinite(latitude) || latitude < -90 || latitude > 90 - || !Number.isFinite(longitude) || longitude < -180 || longitude > 180 - || (timezoneOffset !== null - && (!Number.isFinite(timezoneOffset) || timezoneOffset < -12 || timezoneOffset > 14))) return []; - - return [{ - id, - label, - placeType: cleanString(item.placeType), - provider, - providerPlaceId, - countryCode, - countryName: cleanString(item.countryName), - admin1: cleanString(item.admin1) || cleanString(item.regionName), - admin2: cleanString(item.admin2) || cleanString(item.districtName), - locality: cleanString(item.locality) || cleanString(item.localityName), - latitude, - longitude, - timezoneId, - timezoneOffset, - timezoneSource: cleanString(item.timezoneSource), - }]; - }); -} - -export function LocationSearchCombobox({ - value, - birthDate = "", - birthTime = "", - disabled = false, - onChange, -}: LocationSearchComboboxProps) { - const inputId = useId(); - const listboxId = `${inputId}-listbox`; - const statusId = `${inputId}-status`; - const requestSequence = useRef(0); - const [query, setQuery] = useState(value?.label ?? ""); - const [results, setResults] = useState([]); - const [state, setState] = useState("idle"); - const [expanded, setExpanded] = useState(false); - const [activeIndex, setActiveIndex] = useState(-1); - - useEffect(() => { - const normalized = query.trim(); - if (disabled || value || normalized.length < 2) return; - - const sequence = requestSequence.current + 1; - requestSequence.current = sequence; - const controller = new AbortController(); - const timer = window.setTimeout(async () => { - setState("loading"); - setExpanded(true); - try { - const search = new URLSearchParams({ q: normalized, locale: "zh-CN" }); - if (birthDate) search.set("birthDate", birthDate); - if (birthDate && birthTime) search.set("birthTime", birthTime); - const response = await fetch(`/api/locations/search?${search.toString()}`, { - credentials: "same-origin", - signal: controller.signal, - }); - if (!response.ok) throw new Error("location_search_failed"); - const nextResults = parseLocationSearchResults(await response.json()); - if (requestSequence.current !== sequence) return; - setResults(nextResults); - setState(nextResults.length > 0 ? "ready" : "empty"); - setActiveIndex(nextResults.length > 0 ? 0 : -1); - } catch { - if (controller.signal.aborted || requestSequence.current !== sequence) return; - setResults([]); - setState("error"); - setActiveIndex(-1); - } - }, 260); - - return () => { - window.clearTimeout(timer); - controller.abort(); - }; - }, [birthDate, birthTime, disabled, query, value]); - - function choose(location: ResolvedBirthLocation) { - requestSequence.current += 1; - setQuery(location.label); - setExpanded(false); - setResults([]); - setState("idle"); - setActiveIndex(-1); - onChange(location); - } - - function clear() { - requestSequence.current += 1; - setQuery(""); - setExpanded(false); - setResults([]); - setState("idle"); - setActiveIndex(-1); - onChange(null); - } - - function handleKeyDown(event: KeyboardEvent) { - if (value && (event.key === "Backspace" || event.key === "Delete")) { - event.preventDefault(); - clear(); - return; - } - if (!expanded || results.length === 0) { - if (event.key === "ArrowDown" && results.length > 0) setExpanded(true); - return; - } - if (event.key === "ArrowDown") { - event.preventDefault(); - setActiveIndex((current) => (current + 1) % results.length); - } else if (event.key === "ArrowUp") { - event.preventDefault(); - setActiveIndex((current) => (current <= 0 ? results.length - 1 : current - 1)); - } else if (event.key === "Enter" && activeIndex >= 0) { - event.preventDefault(); - choose(results[activeIndex]); - } else if (event.key === "Escape") { - event.preventDefault(); - setExpanded(false); - setActiveIndex(-1); - } - } - - const isExpanded = expanded && !disabled && !value && query.trim().length >= 2; - const activeDescendant = isExpanded && activeIndex >= 0 - ? `${listboxId}-option-${activeIndex}` - : undefined; - const statusText = state === "loading" - ? "正在搜索地点" - : state === "empty" - ? "没有找到匹配地点,请尝试城市、区县或英文地名" - : state === "error" - ? "地点搜索暂时不可用,请稍后重试" - : state === "ready" - ? `找到 ${results.length} 个地点` - : value - ? `已选择 ${value.label}` - : "输入至少两个字开始搜索"; - - return ( -
- -
- {state === "loading" ?
- -

- {value ?

- - {isExpanded && ( -
    - {state === "loading" &&
  • 正在查找与出生日期对应的地点和时区…
  • } - {state === "empty" &&
  • 没有匹配结果。可以尝试更完整的城市名或英文拼写。
  • } - {state === "error" &&
  • 搜索暂时失败,请检查网络后修改关键词重试。
  • } - {state === "ready" && results.map((location, index) => ( -
  • - -
  • - ))} -
- )} -
- ); -} diff --git a/frontend/src/lib/birth-location-timezone-service.ts b/frontend/src/lib/birth-location-timezone-service.ts new file mode 100644 index 00000000..d73e5f9b --- /dev/null +++ b/frontend/src/lib/birth-location-timezone-service.ts @@ -0,0 +1,62 @@ +import type { + BirthLocationTimezoneQuery, + BirthLocationTimezoneResult, + NormalizedBirthLocation, +} from "./location-contract"; + +type FetchLike = typeof fetch; + +const localTimeStatuses = ["resolved", "not_provided", "ambiguous", "nonexistent"] as const; + +function asLocalTimeStatus(value: unknown): NormalizedBirthLocation["localTimeStatus"] { + const candidate = String(value); + return (localTimeStatuses as readonly string[]).includes(candidate) + ? candidate as NormalizedBirthLocation["localTimeStatus"] + : "not_provided"; +} + +/** + * Resolves an IANA zone and the offset that applied at the birth moment. The chart + * engine owns pre-1949 zone boundaries and historical DST, so this stays a server + * call rather than a browser tzdata lookup. + */ +export async function resolveBirthLocationTimezone( + query: BirthLocationTimezoneQuery, + options: { apiBase?: string; fetchImpl?: FetchLike } = {}, +): Promise { + const fetchImpl = options.fetchImpl ?? fetch; + const apiBase = options.apiBase ?? process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200"; + + let payload: Record; + try { + const response = await fetchImpl(`${apiBase}/api/location/timezone`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + latitude: query.latitude, + longitude: query.longitude, + ...(query.birthDate ? { birthDate: query.birthDate } : {}), + ...(query.birthDate && query.birthTime ? { birthTime: query.birthTime } : {}), + }), + cache: "no-store", + }); + if (!response.ok) return { status: "unavailable", reason: "timezone_service_unavailable" }; + const body: unknown = await response.json(); + payload = body && typeof body === "object" ? body as Record : {}; + } catch { + return { status: "unavailable", reason: "timezone_service_unavailable" }; + } + + const timezoneId = typeof payload.timezoneId === "string" ? payload.timezoneId.trim() : ""; + if (payload.available !== true || !timezoneId) { + return { status: "unavailable", reason: "timezone_service_unavailable" }; + } + + return { + status: "ok", + timezoneId, + timezoneOffset: typeof payload.timezoneOffset === "number" ? payload.timezoneOffset : null, + timezoneSource: "iana_historical", + localTimeStatus: asLocalTimeStatus(payload.localTimeStatus), + }; +} diff --git a/frontend/src/lib/china-birth-place.ts b/frontend/src/lib/china-birth-place.ts new file mode 100644 index 00000000..f6e6a1b0 --- /dev/null +++ b/frontend/src/lib/china-birth-place.ts @@ -0,0 +1,111 @@ +import { chinaLocations, type CityNode, type LocationNode, type ProvinceNode } from "../data/china-locations.ts"; + +export const chinaProvinces = chinaLocations.country.provinces; + +export type BirthPlaceLevels = { + provinceCode: string; + cityCode: string; + districtCode: string; +}; + +export type ResolvedBirthPlaceNode = { + province: ProvinceNode; + city: CityNode; + district: LocationNode | undefined; + node: LocationNode; + placeType: "province" | "city" | "district"; + label: string; + latitude: number; + longitude: number; +}; + +/** + * Municipalities and special administrative regions repeat the province as their + * only city, so that level carries no choice and is not worth showing. + */ +export function hasVirtualCityLevel(province: ProvinceNode) { + return province.cities.length === 1 && province.cities[0].name === province.name; +} + +export function findProvinceNode(code: string) { + return chinaProvinces.find((province) => province.code === code); +} + +export function findCityNode(province: ProvinceNode | undefined, code: string) { + return province?.cities.find((city) => city.code === code); +} + +function resolveCityNode(province: ProvinceNode, cityCode: string) { + return hasVirtualCityLevel(province) ? province.cities[0] : findCityNode(province, cityCode); +} + +/** Resolves the deepest administrative node the three levels can name. */ +export function resolveBirthPlaceNode( + { provinceCode, cityCode, districtCode }: BirthPlaceLevels, +): ResolvedBirthPlaceNode | null { + const province = findProvinceNode(provinceCode); + if (!province) return null; + + const city = resolveCityNode(province, cityCode); + if (!city) return null; + + const district = districtCode ? city.districts.find((item) => item.code === districtCode) : undefined; + if (districtCode && !district) return null; + + const node = district ?? city; + const placeType = district + ? "district" as const + : city.districts.length === 0 && hasVirtualCityLevel(province) + ? "province" as const + : "city" as const; + const label = [province.name, city.name, district?.name] + .filter((name, index, names): name is string => Boolean(name) && names.indexOf(name) === index) + .join(" · "); + + return { + province, + city, + district, + node, + placeType, + label, + latitude: node.center[1], + longitude: node.center[0], + }; +} + +/** + * A saved profile stores an empty district both for cities that have none and for + * users who could only name the city, so a stored city with districts resumes as + * "city centre". Keeping that mapping stable is what makes re-syncing idempotent. + */ +export function birthPlaceDraftFromLevels({ provinceCode, cityCode, districtCode }: BirthPlaceLevels) { + const province = findProvinceNode(provinceCode); + if (!province) return { provinceCode: "", cityCode: "", districtCode: "", cityCentre: false }; + + const city = resolveCityNode(province, cityCode); + if (!city) return { provinceCode, cityCode: "", districtCode: "", cityCentre: false }; + + const district = districtCode ? city.districts.find((item) => item.code === districtCode) : undefined; + return { + provinceCode, + cityCode: city.code, + districtCode: district?.code ?? "", + cityCentre: !district && city.districts.length > 0, + }; +} + +export function formatBirthPlaceCoordinate(latitude: number, longitude: number) { + const northSouth = latitude >= 0 ? "北纬" : "南纬"; + const eastWest = longitude >= 0 ? "东经" : "西经"; + return `${eastWest} ${Math.abs(longitude).toFixed(2)}° · ${northSouth} ${Math.abs(latitude).toFixed(2)}°`; +} + +export function formatBirthPlaceTimezone(timezoneId: string, timezoneOffset: number | null) { + if (timezoneOffset === null) return timezoneId; + const sign = timezoneOffset >= 0 ? "+" : "−"; + const absolute = Math.abs(timezoneOffset); + const hours = Math.floor(absolute); + const minutes = Math.round((absolute - hours) * 60); + return `${timezoneId} · UTC${sign}${hours}${minutes === 0 ? "" : `:${String(minutes).padStart(2, "0")}`}`; +} diff --git a/frontend/src/lib/geoapify-location-service.ts b/frontend/src/lib/geoapify-location-service.ts index 1033b284..9b0204d0 100644 --- a/frontend/src/lib/geoapify-location-service.ts +++ b/frontend/src/lib/geoapify-location-service.ts @@ -4,6 +4,7 @@ import type { NormalizedBirthLocation, } from "./location-contract"; import { chinaLocations } from "../data/china-locations"; +import { resolveBirthLocationTimezone } from "./birth-location-timezone-service"; type FetchLike = typeof fetch; type JsonRecord = Record; @@ -79,23 +80,14 @@ async function resolveTimezone( longitude: number, query: BirthLocationSearchQuery, ) { - const response = await fetchImpl(`${apiBase}/api/location/timezone`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - latitude, - longitude, - ...(query.birthDate ? { birthDate: query.birthDate } : {}), - ...(query.birthTime ? { birthTime: query.birthTime } : {}), - }), - cache: "no-store", - }); - if (!response.ok) throw new Error("timezone_service_unavailable"); - const payload = record(await response.json()); - if (payload.available !== true || !text(payload.timezoneId)) { - throw new Error("timezone_service_unavailable"); - } - return payload; + const resolved = await resolveBirthLocationTimezone({ + latitude, + longitude, + ...(query.birthDate ? { birthDate: query.birthDate } : {}), + ...(query.birthDate && query.birthTime ? { birthTime: query.birthTime } : {}), + }, { apiBase, fetchImpl }); + if (resolved.status !== "ok") throw new Error("timezone_service_unavailable"); + return resolved; } export async function searchGlobalBirthLocations( @@ -126,12 +118,10 @@ export async function searchGlobalBirthLocations( districtName: match.districtName, latitude: match.latitude, longitude: match.longitude, - timezoneId: String(timezone.timezoneId), - timezoneOffset: typeof timezone.timezoneOffset === "number" ? timezone.timezoneOffset : null, - timezoneSource: "iana_historical", - localTimeStatus: ["resolved", "not_provided", "ambiguous", "nonexistent"].includes(String(timezone.localTimeStatus)) - ? timezone.localTimeStatus as NormalizedBirthLocation["localTimeStatus"] - : "not_provided", + timezoneId: timezone.timezoneId, + timezoneOffset: timezone.timezoneOffset, + timezoneSource: timezone.timezoneSource, + localTimeStatus: timezone.localTimeStatus, }; })); return { status: "ok", locations }; @@ -195,12 +185,10 @@ export async function searchGlobalBirthLocations( districtName: text(properties.county) ?? text(properties.district), latitude, longitude, - timezoneId: String(timezone.timezoneId), - timezoneOffset: typeof timezone.timezoneOffset === "number" ? timezone.timezoneOffset : null, - timezoneSource: "iana_historical", - localTimeStatus: ["resolved", "not_provided", "ambiguous", "nonexistent"].includes(String(timezone.localTimeStatus)) - ? timezone.localTimeStatus as NormalizedBirthLocation["localTimeStatus"] - : "not_provided", + timezoneId: timezone.timezoneId, + timezoneOffset: timezone.timezoneOffset, + timezoneSource: timezone.timezoneSource, + localTimeStatus: timezone.localTimeStatus, }); } catch { return { status: "unavailable", reason: "timezone_service_unavailable" }; diff --git a/frontend/src/lib/location-contract.ts b/frontend/src/lib/location-contract.ts index a8bd2b88..4ce429b1 100644 --- a/frontend/src/lib/location-contract.ts +++ b/frontend/src/lib/location-contract.ts @@ -26,6 +26,22 @@ export const birthLocationSearchQuerySchema = z.object({ } }); +export const birthLocationTimezoneQuerySchema = z.object({ + latitude: z.number().finite().min(-90).max(90), + longitude: z.number().finite().min(-180).max(180), + birthDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + birthTime: z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/).optional(), +}).superRefine((value, context) => { + if (value.birthTime && !value.birthDate) context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["birthDate"], + message: "birthTime requires birthDate", + }); + if (value.birthDate && !isIsoCalendarDate(value.birthDate)) { + context.addIssue({ code: z.ZodIssueCode.custom, path: ["birthDate"], message: "invalid birthDate" }); + } +}); + export const normalizedBirthLocationSchema = z.object({ provider: z.enum(["geoapify", "china_locations", "mapbox", "geonames"]), providerPlaceId: z.string().min(1), @@ -46,8 +62,19 @@ export const normalizedBirthLocationSchema = z.object({ }); export type BirthLocationSearchQuery = z.infer; +export type BirthLocationTimezoneQuery = z.infer; export type NormalizedBirthLocation = z.infer; +export type BirthLocationTimezoneResult = + | { + status: "ok"; + timezoneId: string; + timezoneOffset: number | null; + timezoneSource: "iana_historical"; + localTimeStatus: NormalizedBirthLocation["localTimeStatus"]; + } + | { status: "unavailable"; reason: "timezone_service_unavailable" }; + export type BirthLocationSearchResult = | { status: "ok"; locations: NormalizedBirthLocation[] } | { status: "unavailable"; reason: "geoapify_not_configured" | "timezone_service_unavailable" | "provider_unavailable" }; diff --git a/frontend/tests/birth-place-picker.test.ts b/frontend/tests/birth-place-picker.test.ts new file mode 100644 index 00000000..a497b8c6 --- /dev/null +++ b/frontend/tests/birth-place-picker.test.ts @@ -0,0 +1,148 @@ +import assert from "node:assert/strict"; +import { existsSync, readFileSync } from "node:fs"; +import test from "node:test"; +import { + birthPlaceDraftFromLevels, + formatBirthPlaceCoordinate, + formatBirthPlaceTimezone, + hasVirtualCityLevel, + findProvinceNode, + resolveBirthPlaceNode, +} from "../src/lib/china-birth-place.ts"; + +const pickerSource = readFileSync(new URL("../src/components/birth-place-picker.tsx", import.meta.url), "utf8"); +const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + +test("resolves a province, city and district to the district centre", () => { + const resolved = resolveBirthPlaceNode({ provinceCode: "130000", cityCode: "130400", districtCode: "130402" }); + assert.ok(resolved); + assert.equal(resolved.label, "河北省 · 邯郸市 · 邯山区"); + assert.equal(resolved.placeType, "district"); + assert.equal(resolved.node.code, "130402"); + assert.equal(resolved.latitude, 36.603196); + assert.equal(resolved.longitude, 114.484989); +}); + +test("falls back to the city centre when the district is left unnamed", () => { + const resolved = resolveBirthPlaceNode({ provinceCode: "130000", cityCode: "130400", districtCode: "" }); + assert.ok(resolved); + assert.equal(resolved.label, "河北省 · 邯郸市"); + assert.equal(resolved.placeType, "city"); + assert.equal(resolved.node.code, "130400"); + assert.equal(resolved.latitude, 36.612273); +}); + +test("collapses the repeated city level of municipalities and special regions", () => { + assert.equal(hasVirtualCityLevel(findProvinceNode("110000")!), true); + assert.equal(hasVirtualCityLevel(findProvinceNode("130000")!), false); + + // The city code may be absent because the picker never shows that level. + const resolved = resolveBirthPlaceNode({ provinceCode: "110000", cityCode: "", districtCode: "110105" }); + assert.ok(resolved); + assert.equal(resolved.label, "北京市 · 朝阳区"); + assert.equal(resolved.placeType, "district"); +}); + +test("completes at the city for prefecture cities that have no districts", () => { + const resolved = resolveBirthPlaceNode({ provinceCode: "440000", cityCode: "441900", districtCode: "" }); + assert.ok(resolved); + assert.equal(resolved.label, "广东省 · 东莞市"); + assert.equal(resolved.placeType, "city"); + assert.equal(resolved.node.code, "441900"); +}); + +test("completes at the province when neither lower level offers a choice", () => { + const resolved = resolveBirthPlaceNode({ provinceCode: "710000", cityCode: "", districtCode: "" }); + assert.ok(resolved); + assert.equal(resolved.label, "台湾省"); + assert.equal(resolved.placeType, "province"); +}); + +test("rejects codes that do not belong together", () => { + assert.equal(resolveBirthPlaceNode({ provinceCode: "999999", cityCode: "", districtCode: "" }), null); + assert.equal(resolveBirthPlaceNode({ provinceCode: "130000", cityCode: "440100", districtCode: "" }), null); + assert.equal(resolveBirthPlaceNode({ provinceCode: "130000", cityCode: "130400", districtCode: "110105" }), null); +}); + +test("re-syncing a saved profile keeps the same draft so the picker cannot clear itself", () => { + const saved = { provinceCode: "130000", cityCode: "130400", districtCode: "" }; + const draft = birthPlaceDraftFromLevels(saved); + assert.deepEqual(draft, { provinceCode: "130000", cityCode: "130400", districtCode: "", cityCentre: true }); + assert.deepEqual(birthPlaceDraftFromLevels(draft), draft); + + // A city without districts is complete without being marked as an unsure centre. + assert.deepEqual(birthPlaceDraftFromLevels({ provinceCode: "440000", cityCode: "441900", districtCode: "" }), { + provinceCode: "440000", + cityCode: "441900", + districtCode: "", + cityCentre: false, + }); + + // A municipality resumes with its virtual city filled in. + assert.deepEqual(birthPlaceDraftFromLevels({ provinceCode: "110000", cityCode: "", districtCode: "110105" }), { + provinceCode: "110000", + cityCode: "110000-city", + districtCode: "110105", + cityCentre: false, + }); +}); + +test("states coordinates and the resolved offset in plain Chinese", () => { + assert.equal(formatBirthPlaceCoordinate(36.612273, 114.490686), "东经 114.49° · 北纬 36.61°"); + assert.equal(formatBirthPlaceTimezone("Asia/Shanghai", 8), "Asia/Shanghai · UTC+8"); + assert.equal(formatBirthPlaceTimezone("Asia/Kathmandu", 5.75), "Asia/Kathmandu · UTC+5:45"); + assert.equal(formatBirthPlaceTimezone("Asia/Shanghai", null), "Asia/Shanghai"); +}); + +test("the picker offers three cascading levels instead of a free-text search", () => { + assert.equal(existsSync(new URL("../src/components/location-search-combobox.tsx", import.meta.url)), false); + assert.doesNotMatch(pickerSource, /role="combobox"/); + assert.match(pickerSource, /aria-label="出生省份"/); + assert.match(pickerSource, /aria-label="出生城市"/); + assert.match(pickerSource, /aria-label="出生区县"/); + // The level is hidden rather than shown empty when it carries no choice. + assert.match(pickerSource, /\{province && !virtualCity && \(/); + assert.match(pickerSource, /\{city && districts\.length > 0 && \(/); + assert.match(pickerSource, /不确定,用市区中心/); + assert.match(pickerSource, /精确到区 \/ 县就足够/); +}); + +test("timezone is resolved once for the chosen place rather than during browsing", () => { + assert.equal(existsSync(new URL("../src/app/api/locations/timezone/route.ts", import.meta.url)), true); + assert.match(pickerSource, /fetch\("\/api\/locations\/timezone"/); + // Browsing the levels cannot spend a lookup: only a resolved place gets a sync key. + assert.match(pickerSource, /if \(syncKey === "empty"\)/); + assert.match(pickerSource, /: \{ status: "loading" \};/); + // An unresolved zone must not be reported upward as a usable birth place. + assert.match(pickerSource, /setOutcome\(\{ key: syncKey, state: \{ status: "error" \} \}\);\s*onChangeRef\.current\(null\);/); + + const service = readFileSync(new URL("../src/lib/birth-location-timezone-service.ts", import.meta.url), "utf8"); + assert.match(service, /\/api\/location\/timezone/); + assert.match(service, /timezoneSource: "iana_historical"/); +}); + +test("mounting a saved birth place neither refetches nor clears the stored location", () => { + // Reporting null on mount would look like a location edit and would drop a + // rectified birth-time candidate just because the profile dialog was opened. + assert.match(pickerSource, /const savedIsCurrent = resolved !== null/); + assert.match(pickerSource, /if \(syncKey === "saved"\) return;/); + assert.match(pickerSource, /if \(saved\.provinceCode \|\| saved\.timezoneId\) onChangeRef\.current\(null\);/); + // A changed birth date invalidates the stored offset, so the zone is resolved again. + assert.match(pickerSource, /mountedMoment === birthMoment/); +}); + +test("the profile stores administrative codes alongside coordinates and the IANA zone", () => { + assert.match(pageSource, / { + assert.match(pageSource, /profile\.timezoneId\.trim\(\)/); + assert.match(pageSource, /profile\.timezoneOffset === null \|\| Number\.isFinite\(profile\.timezoneOffset\)/); +}); diff --git a/frontend/tests/location-search-combobox.test.ts b/frontend/tests/location-search-combobox.test.ts deleted file mode 100644 index 4d3eaeda..00000000 --- a/frontend/tests/location-search-combobox.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import test from "node:test"; -import React from "react"; -import { renderToStaticMarkup } from "react-dom/server"; -import { - LocationSearchCombobox, - parseLocationSearchResults, - type ResolvedBirthLocation, -} from "../src/components/location-search-combobox.tsx"; - -Object.assign(globalThis, { React }); - -const taipei: ResolvedBirthLocation = { - id: "nominatim:1293250", - label: "台北市, 台湾", - placeType: "city", - provider: "nominatim", - providerPlaceId: "1293250", - countryCode: "TW", - countryName: "台湾", - admin1: "台北市", - admin2: "", - locality: "台北市", - latitude: 25.0375, - longitude: 121.5637, - timezoneId: "Asia/Taipei", - timezoneOffset: 8, - timezoneSource: "historical_tzdb", -}; - -test("location search parser keeps only complete coordinate and timezone results", () => { - assert.deepEqual(parseLocationSearchResults({ - locations: [ - { ...taipei, id: undefined, regionName: taipei.admin1, localityName: taipei.locality }, - { ...taipei, id: "", providerPlaceId: "", label: "invalid" }, - { ...taipei, id: "mapbox:unknown-time", timezoneOffset: null }, - ], - }), [taipei, { ...taipei, id: "mapbox:unknown-time", timezoneOffset: null }]); - assert.deepEqual(parseLocationSearchResults({ results: "not-an-array" }), []); -}); - -test("location combobox exposes accessible search semantics and selected state", () => { - const emptyMarkup = renderToStaticMarkup(React.createElement(LocationSearchCombobox, { - value: null, - birthDate: "1997-08-08", - birthTime: "06:30", - onChange: () => undefined, - })); - assert.match(emptyMarkup, /role="combobox"/); - assert.match(emptyMarkup, /aria-autocomplete="list"/); - assert.match(emptyMarkup, /aria-expanded="false"/); - assert.match(emptyMarkup, /aria-controls="[^"]+-listbox"/); - assert.match(emptyMarkup, /输入至少两个字开始搜索/); - - const selectedMarkup = renderToStaticMarkup(React.createElement(LocationSearchCombobox, { - value: taipei, - onChange: () => undefined, - })); - assert.match(selectedMarkup, /value="台北市, 台湾"/); - assert.match(selectedMarkup, /已选择 台北市, 台湾/); - assert.match(selectedMarkup, /aria-label="清除出生地点"/); -}); - -test("profile integrates global location persistence while retaining legacy China fallback", () => { - const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); - assert.match(pageSource, /birthPlaceProviderId:\s*string/); - assert.match(pageSource, /birth_place_provider_id:\s*nextProfile\.birthPlaceProviderId/); - assert.match(pageSource, /timezone_id:\s*nextProfile\.timezoneId/); - assert.match(pageSource, /function selectedLocationValue/); - assert.match(pageSource, /legacy-cn:/); - assert.match(pageSource, /provinceCode:\s*""[\s\S]*cityCode:\s*""[\s\S]*districtCode:\s*""/); - assert.doesNotMatch(pageSource, /目前先支持中国大陆地区/); -}); - -test("selecting a location cancels stale searches and keeps the result list outside the onboarding card", () => { - const componentSource = readFileSync(new URL("../src/components/location-search-combobox.tsx", import.meta.url), "utf8"); - const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); - assert.match(componentSource, /function choose\(location: ResolvedBirthLocation\) \{\s*requestSequence\.current \+= 1;/); - assert.match(globalStyles, /\.birth-time-transition-card \{ position: relative; overflow: visible; \}/); -}); - -test("profile accepts a selected IANA location before a numeric offset is resolved", () => { - const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); - assert.match(pageSource, /profile\.timezoneId\.trim\(\)/); - assert.match(pageSource, /profile\.timezoneOffset === null \|\| Number\.isFinite\(profile\.timezoneOffset\)/); - assert.match(pageSource, /timezoneId: birthPlace\.timezoneId/); -}); diff --git a/frontend/tests/mobile-interaction-contract.test.ts b/frontend/tests/mobile-interaction-contract.test.ts index 6f90e01f..5a3bd9d3 100644 --- a/frontend/tests/mobile-interaction-contract.test.ts +++ b/frontend/tests/mobile-interaction-contract.test.ts @@ -13,8 +13,12 @@ test("mobile login can scroll and keeps the form reachable on a short screen", ( assert.match(layout, /interactiveWidget:\s*"resizes-content"/); }); -test("mobile onboarding does not clip location results or session history", () => { +test("mobile onboarding does not clip the birth place levels or session history", () => { assert.match(css, /\.session-nav \{ overflow: visible; \}/); assert.doesNotMatch(css, /\.session-nav \{ overflow: hidden; \}/); - assert.match(css, /\.location-combobox-results \{ position: static; top: auto;/); + // The birth place levels portal their popup out of the onboarding card, so an + // ancestor can no longer clip it and only the viewport height still constrains it. + const select = readFileSync(new URL("../src/components/ui/select.tsx", import.meta.url), "utf8"); + assert.match(select, //); + assert.match(css, /\.select-list \{ max-height: min\(288px, 48dvh\) !important; \}/); });