feat: 의료 어시스턴트 앱 + Home Assistant 임베드 앱 추가

법률 사건관리 앱과 같은 패턴으로 의료 사건(진료기록) 관리 앱 추가
(routes-doctor-cases.ts + doctor-app.html + WOPI 라우트). Home Assistant를
iframe으로 띄우는 얇은 래퍼 앱 추가(ha.applecherry.net, 2026-08-01 통합
완료된 것 URL만 새로 노출). weather_openmeteo의 요청 성형/리포트 포맷팅
로직을 weather-openmeteo-format.ts로 분리(순수함수라 테스트 가능,
2026-07-29 있었던 타임존/롤링윈도우/daily-in-hourly 버그 3건 회귀 방지
테스트 포함) + geocode 재시도 테스트.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-08-07 13:16:01 +09:00
co-authored by Claude Sonnet 5
parent 02247e4ee3
commit d51c40484a
7 changed files with 2331 additions and 1 deletions
+206
View File
@@ -0,0 +1,206 @@
/**
* weather-openmeteo-format.ts
*
* Pure request-shaping and report-formatting logic for `weather_openmeteo`.
*
* Split out of weather.ts because three silent failures on 2026-07-29 all lived in this
* logic rather than in the HTTP call, and none of them produced an error:
*
* 1. The request pinned `timezone=Asia/Seoul` for every coordinate on earth. A Berlin
* forecast came back stamped in KST (utc_offset 32400), so "today" in the output was
* a Korean day. Nothing downstream could recover the city's own day boundary.
* 2. `forecast_hours` is a rolling window from *now*, not a calendar day. Asked at 22:00
* KST (15:00 in Berlin), the 24h window reached into the next European afternoon, and
* the max over that window — 34.4°C — was reported as "today's high". The real values
* were 29.8°C today and 36.7°C tomorrow: the answer was tomorrow's weather.
* 3. `temperature_2m_max` is a *daily* variable, but it was passed straight into
* `hourly=`. Open-Meteo accepts it there and returns an hourly series, so a plausible
* name produced plausible numbers that simply were not daily maxima.
*
* The fix for all three is to decide, before the fetch, which block each variable belongs
* to and to label every timestamp with the location's own calendar day. That decision is
* pure, so it is testable — unlike the fetch it feeds.
*/
/**
* Open-Meteo variables that exist ONLY in the `daily=` block.
*
* `weather_code`, `temperature_2m` and friends are deliberately absent: they are valid
* hourly variables, and a few (weather_code, uv_index) exist in both blocks. Only names
* that are meaningless as an hourly series belong here — those are the ones that silently
* return wrong-shaped data when misrouted.
*/
const DAILY_ONLY_VARIABLES = new Set([
'temperature_2m_max', 'temperature_2m_min', 'temperature_2m_mean',
'apparent_temperature_max', 'apparent_temperature_min', 'apparent_temperature_mean',
'precipitation_sum', 'rain_sum', 'showers_sum', 'snowfall_sum', 'precipitation_hours',
'precipitation_probability_max', 'precipitation_probability_min', 'precipitation_probability_mean',
'wind_speed_10m_max', 'wind_gusts_10m_max', 'wind_direction_10m_dominant',
'shortwave_radiation_sum', 'et0_fao_evapotranspiration',
'uv_index_max', 'uv_index_clear_sky_max',
'sunrise', 'sunset', 'daylight_duration', 'sunshine_duration',
]);
export interface SplitVariables {
hourly: string[];
daily: string[];
}
/**
* Route each requested variable to the Open-Meteo block that actually serves it.
*
* Duplicates are dropped and order is preserved so the output columns match what the
* caller asked for.
*/
export function splitOpenMeteoVariables(variables: string | string[]): SplitVariables {
const list = (Array.isArray(variables) ? variables : String(variables || '').split(','))
.map(v => v.trim())
.filter(Boolean);
const hourly: string[] = [];
const daily: string[] = [];
for (const v of list) {
const bucket = DAILY_ONLY_VARIABLES.has(v) ? daily : hourly;
if (!bucket.includes(v)) bucket.push(v);
}
return { hourly, daily };
}
export function isDailyOnlyVariable(name: string): boolean {
return DAILY_ONLY_VARIABLES.has(String(name || '').trim());
}
/**
* Format a UTC offset in seconds as `UTC+02:00`.
*
* Printed next to the timezone name because the offset is the part that makes a
* mislabelled day obvious at a glance — a Berlin forecast reading UTC+09:00 is the exact
* bug this module exists to prevent.
*/
export function formatUtcOffset(seconds: number): string {
const s = Number(seconds);
if (!Number.isFinite(s)) return 'UTC+00:00';
const sign = s < 0 ? '-' : '+';
const abs = Math.abs(s);
const hh = String(Math.floor(abs / 3600)).padStart(2, '0');
const mm = String(Math.floor((abs % 3600) / 60)).padStart(2, '0');
return `UTC${sign}${hh}:${mm}`;
}
/**
* Relative label for a local calendar date, as seen from the location's own "today".
*
* Returns null past 모레 rather than inventing labels — an unlabelled date is honest,
* while "3일 후" invites the same off-by-one reading the labels exist to prevent.
*/
export function relativeDayLabel(date: string, todayLocal: string): string | null {
const d = Date.parse(`${date}T00:00:00Z`);
const t = Date.parse(`${todayLocal}T00:00:00Z`);
if (!Number.isFinite(d) || !Number.isFinite(t)) return null;
const diff = Math.round((d - t) / 86_400_000);
if (diff === 0) return '오늘';
if (diff === 1) return '내일';
if (diff === 2) return '모레';
if (diff === -1) return '어제';
return null;
}
/**
* The location's current calendar date, derived from the UTC offset Open-Meteo reports.
*
* Deliberately does not use the server's own timezone: the server sits in KST and the
* question is about the city's day, which is the whole point.
*/
export function localTodayFromOffset(utcOffsetSeconds: number, now: Date = new Date()): string {
const shifted = new Date(now.getTime() + (Number(utcOffsetSeconds) || 0) * 1000);
return shifted.toISOString().slice(0, 10);
}
export interface OpenMeteoReportInput {
displayName: string;
timezone: string;
utcOffsetSeconds: number;
/** Rolling-hour window that was requested, for the hourly section heading. */
hours: number;
hourlyVars: string[];
dailyVars: string[];
/** Raw Open-Meteo response blocks. */
hourlyBlock?: { time?: string[]; [k: string]: any };
dailyBlock?: { time?: string[]; [k: string]: any };
hourlyUnits?: Record<string, string>;
dailyUnits?: Record<string, string>;
/** Max hourly rows to print. */
maxHourlyRows?: number;
/** Per-variable value renderer (WMO code names, wind direction, …). */
renderValue?: (key: string, value: any) => string;
now?: Date;
}
/**
* Build the text report.
*
* Every timestamp is labelled with the location's local day and, where applicable,
* 오늘/내일/모레. The daily table comes first when present because that is the direct
* answer to "today's high" — leaving the model to derive it from an hourly series is
* exactly how a rolling window got reported as a calendar day.
*/
export function formatOpenMeteoReport(input: OpenMeteoReportInput): string {
const {
displayName, timezone, utcOffsetSeconds, hours,
hourlyVars, dailyVars, hourlyBlock, dailyBlock,
hourlyUnits = {}, dailyUnits = {}, maxHourlyRows = 48,
renderValue = (_k, v) => String(v),
now,
} = input;
const todayLocal = localTodayFromOffset(utcOffsetSeconds, now);
const out: string[] = [
`Open-Meteo 예보: ${displayName}`,
`현지 시간대: ${timezone} (${formatUtcOffset(utcOffsetSeconds)}) — 아래 모든 시각·날짜는 현지 기준`,
];
const dailyTimes: string[] = dailyBlock?.time || [];
if (dailyVars.length && dailyTimes.length) {
out.push('', `[일별] ${dailyVars.length}개 변수 / ${dailyTimes.length}일`);
out.push(`단위: ${dailyVars.map(k => `${k}=${dailyUnits[k] || '-'}`).join(', ')}`);
out.push(['날짜', ...dailyVars.map(k => k.replace(/_/g, ' '))].join('\t'));
for (let i = 0; i < dailyTimes.length; i++) {
const label = relativeDayLabel(dailyTimes[i], todayLocal);
const dateCell = label ? `${dailyTimes[i]} (${label})` : dailyTimes[i];
const vals = dailyVars.map(k => {
const v = dailyBlock?.[k]?.[i];
return v == null ? '-' : renderValue(k, v);
});
out.push([dateCell, ...vals].join('\t'));
}
}
const hourlyTimes: string[] = hourlyBlock?.time || [];
if (hourlyVars.length && hourlyTimes.length) {
const shown = Math.min(hours, maxHourlyRows, hourlyTimes.length);
// Say plainly that this window starts now, so its max is never mistaken for a daily max.
out.push('', `[시간별] 지금부터 ${hours}시간 (달력상 하루가 아니라 현재 시각 기준 연속 구간)`);
out.push(`단위: ${hourlyVars.map(k => `${k}=${hourlyUnits[k] || '-'}`).join(', ')}`);
out.push(['시각', ...hourlyVars.map(k => k.replace(/_/g, ' '))].join('\t'));
let lastDate = '';
for (let i = 0; i < shown; i++) {
const raw = hourlyTimes[i];
const [date, clock = ''] = raw.split('T');
let timeCell: string;
if (date !== lastDate) {
const label = relativeDayLabel(date, todayLocal);
timeCell = label ? `${date} ${clock} (${label})` : `${date} ${clock}`;
lastDate = date;
} else {
timeCell = `${date} ${clock}`;
}
const vals = hourlyVars.map(k => {
const v = hourlyBlock?.[k]?.[i];
return v == null ? '-' : renderValue(k, v);
});
out.push([timeCell, ...vals].join('\t'));
}
}
return out.join('\n');
}