Files
wehub-resource-sync 2114b14ee0
Sync main into demo / sync (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:26 +08:00

254 lines
8.2 KiB
TypeScript

import { WEATHER_CONFIG, type WeatherCityDefinition, type WeatherSettings } from '../data';
import type { WeatherBundle, WeatherDaily, WeatherLibrary } from '../types';
import * as TimeService from '../../../os/TimeService';
import { toLocalDateKey } from './dateFormat';
import defaultLibrary from '../data/weatherBundles.json';
export type WeatherCity = WeatherCityDefinition;
export type StoredWeatherBundle = {
updatedAt: number;
lonLat: string;
/**
* 仅定位页需要:用于城市名展示(主页面与城市管理页保持一致)。
* 预设/已添加城市一般使用 city.name。
*/
locationName?: string;
bundle: WeatherBundle;
historicalYesterday?: WeatherDaily | null;
};
export type WeatherStateV1 = {
version: 1;
/** 'located' 表示定位页;其他为 savedCities[].id */
selectedCityId: string;
savedCities: WeatherCity[];
bundlesByCityId: Record<string, StoredWeatherBundle>;
settings: WeatherSettings;
/** 城市搜索历史(城市名列表,最新在前) */
searchHistory?: string[];
/**
* 记录本次在 Weather App 中"实际查看/使用"的城市与 bundle 时间戳,
* 供 bench 判定稳定读取,避免重复调用外部 API 带来的不一致。
*/
lastAccess?: {
cityId: string;
bundleUpdatedAt?: number;
at: number;
};
/**
* 天气数据库:缓存层,所有天气查询优先从此处读取。
* key 为城市 ID 或坐标字符串。数据中的日期字段无意义——
* 读取时通过 materialize 按 TimeService.now() 动态填充。
* bench 可通过 setState 预注入数据。
*/
weatherLibrary?: WeatherLibrary;
};
export type WeatherState = WeatherStateV1;
const nowMs = () => TimeService.now();
const DAY_MS = 86_400_000;
const ISO_DATE_RE = /^(\d{4})-(\d{2})-(\d{2})(.*)$/;
/**
* 将 weatherBundles.json 中的所有日期字段整体平移到"以模拟当天为基准"。
*
* 快照是离线生成的静态数据,其中 daily[0] 代表生成当天,historicalYesterday
* 代表生成前一天,hourly/indices/airQuality 等同理。读取时按当天为 day 0 整体
* 平移偏移量 = 模拟今天 - 快照 day 0,可让前端显示和 bench 判定都看到正确日期。
*
* 只改写形如 `YYYY-MM-DD` 或 `YYYY-MM-DDTHH:mm...` 的字符串,其他字段保持不变。
*/
function shiftDateString(value: string, offsetDays: number): string {
const m = ISO_DATE_RE.exec(value);
if (!m) return value;
const [, y, mo, d, rest] = m;
const base = Date.UTC(Number(y), Number(mo) - 1, Number(d));
const shifted = TimeService.fromTimestamp(base + offsetDays * DAY_MS);
const yy = shifted.getUTCFullYear();
const mm = String(shifted.getUTCMonth() + 1).padStart(2, '0');
const dd = String(shifted.getUTCDate()).padStart(2, '0');
return `${yy}-${mm}-${dd}${rest}`;
}
function shiftTreeDates<T>(node: T, offsetDays: number): T {
if (typeof node === 'string') {
return shiftDateString(node, offsetDays) as unknown as T;
}
if (Array.isArray(node)) {
return node.map((item) => shiftTreeDates(item, offsetDays)) as unknown as T;
}
if (node && typeof node === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(node as Record<string, unknown>)) {
out[k] = shiftTreeDates(v, offsetDays);
}
return out as unknown as T;
}
return node;
}
function rehydrateLibraryDates(raw: WeatherLibrary): WeatherLibrary {
// 以任意一个 entry 的 bundle.daily[0].fxDate 作为"快照 day 0"锚点
let anchorKey: string | null = null;
for (const entry of Object.values(raw)) {
const first = entry?.bundle?.daily?.[0]?.fxDate;
if (typeof first === 'string' && ISO_DATE_RE.test(first)) {
anchorKey = first.slice(0, 10);
break;
}
}
if (!anchorKey) return raw;
const todayKey = toLocalDateKey(nowMs());
if (todayKey === anchorKey) return raw;
const [ay, am, ad] = anchorKey.split('-').map(Number);
const [ty, tm, td] = todayKey.split('-').map(Number);
const offsetDays = Math.round(
(Date.UTC(ty, tm - 1, td) - Date.UTC(ay, am - 1, ad)) / DAY_MS,
);
if (offsetDays === 0) return raw;
return shiftTreeDates(raw, offsetDays);
}
function normalizeSelectedCityId(state: WeatherStateV1): string {
const id = String(state.selectedCityId ?? '').trim();
if (id === 'located') return 'located';
if (id && state.savedCities.some((c) => c.id === id)) return id;
return 'located';
}
export function getDefaultWeatherState(): WeatherStateV1 {
return {
version: 1,
selectedCityId: WEATHER_CONFIG.defaultSelectedCityId,
savedCities: [...WEATHER_CONFIG.savedCities],
bundlesByCityId: {},
settings: { ...WEATHER_CONFIG.settings },
weatherLibrary: rehydrateLibraryDates(
(defaultLibrary as unknown as WeatherLibrary) ?? {},
),
};
}
export function upsertSavedCity(state: WeatherStateV1, city: WeatherCity): WeatherStateV1 {
const existsIdx = state.savedCities.findIndex((c) => c.id === city.id);
const savedCities = [...state.savedCities];
if (existsIdx >= 0) {
savedCities[existsIdx] = city;
} else {
savedCities.push(city);
}
const next: WeatherStateV1 = { ...state, savedCities };
next.selectedCityId = normalizeSelectedCityId(next);
return next;
}
export function removeSavedCity(state: WeatherStateV1, cityId: string): WeatherStateV1 {
const id = String(cityId ?? '').trim();
if (!id) return state;
const savedCities = state.savedCities.filter((c) => c.id !== id);
const bundlesByCityId = { ...state.bundlesByCityId };
delete bundlesByCityId[id];
const next: WeatherStateV1 = { ...state, savedCities, bundlesByCityId };
next.selectedCityId = normalizeSelectedCityId(next);
return next;
}
export function setSelectedCityId(state: WeatherStateV1, cityId: string): WeatherStateV1 {
const id = String(cityId ?? '').trim();
const next: WeatherStateV1 = { ...state, selectedCityId: id };
next.selectedCityId = normalizeSelectedCityId(next);
return next;
}
export function setStoredBundle(
state: WeatherStateV1,
cityId: string,
payload: { lonLat: string; bundle: WeatherBundle; locationName?: string; updatedAt?: number }
): WeatherStateV1 {
const id = String(cityId ?? '').trim();
const lonLat = String(payload.lonLat ?? '').trim();
if (!id || !lonLat) return state;
const prevEntry = state.bundlesByCityId[id];
const todayKey = toLocalDateKey(nowMs());
const normalizedDaily = payload.bundle.daily.filter((day) => String(day.fxDate ?? '').trim() >= todayKey);
const entry: StoredWeatherBundle = {
updatedAt: payload.updatedAt ?? nowMs(),
lonLat,
locationName: payload.locationName ?? prevEntry?.locationName,
bundle: {
...payload.bundle,
daily: normalizedDaily,
},
historicalYesterday: prevEntry?.historicalYesterday,
};
return {
...state,
bundlesByCityId: {
...state.bundlesByCityId,
[id]: entry,
},
};
}
export function setHistoricalYesterday(
state: WeatherStateV1,
cityId: string,
historicalYesterday: WeatherDaily | null,
): WeatherStateV1 {
const id = String(cityId ?? '').trim();
const prevEntry = state.bundlesByCityId[id];
if (!id || !prevEntry) return state;
return {
...state,
bundlesByCityId: {
...state.bundlesByCityId,
[id]: {
...prevEntry,
historicalYesterday,
},
},
};
}
export function isBundleFresh(entry: StoredWeatherBundle | undefined, ttlMs: number): boolean {
if (!entry) return false;
const ttl = Math.max(0, Number(ttlMs));
if (!Number.isFinite(ttl)) return true;
if (ttl === 0) return false;
return nowMs() - entry.updatedAt <= ttl;
}
const MAX_SEARCH_HISTORY = 10;
export function addSearchHistory(state: WeatherStateV1, cityName: string): WeatherStateV1 {
const name = cityName.trim();
if (!name) return state;
const prev = state.searchHistory ?? [];
const filtered = prev.filter((n) => n !== name);
return { ...state, searchHistory: [name, ...filtered].slice(0, MAX_SEARCH_HISTORY) };
}
export function clearSearchHistory(state: WeatherStateV1): WeatherStateV1 {
return { ...state, searchHistory: [] };
}
export function setLastAccess(
state: WeatherStateV1,
cityId: string,
bundleUpdatedAt?: number
): WeatherStateV1 {
return {
...state,
lastAccess: {
cityId,
bundleUpdatedAt,
at: nowMs(),
},
};
}