The China city table loads on demand; old code-only profiles load it before the people list settles, so they resolve exactly as before. The gate keeps the six holdout fields it reads, pinned to the JSON by a test. The onboarding shell is preloaded before reveal only when the profile is incomplete. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
115 lines
4.1 KiB
TypeScript
115 lines
4.1 KiB
TypeScript
import { chinaLocations, type CityNode, type LocationNode, type ProvinceNode } from "../data/china-locations.ts";
|
||
import { registerChinaCountry } from "./china-locations-client.ts";
|
||
|
||
export const chinaProvinces = chinaLocations.country.provinces;
|
||
// Whoever loads the picker has the table; old profiles read it from here too.
|
||
registerChinaCountry(chinaLocations.country);
|
||
|
||
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")}`}`;
|
||
}
|