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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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<BirthLocationTimezoneResult> {
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const apiBase = options.apiBase ?? process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200";
|
||||
|
||||
let payload: Record<string, unknown>;
|
||||
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<string, unknown> : {};
|
||||
} 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),
|
||||
};
|
||||
}
|
||||
@@ -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")}`}`;
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
@@ -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" };
|
||||
|
||||
@@ -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<typeof birthLocationSearchQuerySchema>;
|
||||
export type BirthLocationTimezoneQuery = z.infer<typeof birthLocationTimezoneQuerySchema>;
|
||||
export type NormalizedBirthLocation = z.infer<typeof normalizedBirthLocationSchema>;
|
||||
|
||||
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" };
|
||||
|
||||
Reference in New Issue
Block a user