@uniai-fe/uds-templates 0.5.13 → 0.5.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/weather/apis/korea/server.ts +90 -3
- package/src/weather/apis/open-weather-map/server.ts +1 -1
- package/src/weather/components/page-header/Today.tsx +65 -5
- package/src/weather/types/api.ts +6 -0
- package/src/weather/utils/date-time.ts +34 -10
- package/src/weather/utils/weather.ts +56 -12
package/package.json
CHANGED
|
@@ -12,7 +12,10 @@ import type {
|
|
|
12
12
|
KMA_Res_WeatherNow,
|
|
13
13
|
} from "../../types";
|
|
14
14
|
import WEATHER_KOREA_RESPONSE from "../../data/response";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
getWeatherBaseMoments,
|
|
17
|
+
getWeatherDailyRangeBaseMoments,
|
|
18
|
+
} from "../../utils/date-time";
|
|
16
19
|
import { extractWeatherSummary } from "../../utils";
|
|
17
20
|
import type { NextResponse } from "next/server";
|
|
18
21
|
import { generateQueryUrl, nextAPILog } from "@uniai-fe/util-api";
|
|
@@ -63,6 +66,51 @@ const getWeatherParams = ({
|
|
|
63
66
|
};
|
|
64
67
|
};
|
|
65
68
|
|
|
69
|
+
const getWeatherDailyRangeParams = ({
|
|
70
|
+
authKey,
|
|
71
|
+
searchParams,
|
|
72
|
+
}: {
|
|
73
|
+
authKey: string;
|
|
74
|
+
searchParams: URLSearchParams;
|
|
75
|
+
}): KMA_Req_Weather => {
|
|
76
|
+
const { base_date, base_time } = getWeatherDailyRangeBaseMoments();
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
pageNo: "1",
|
|
80
|
+
numOfRows: "1000",
|
|
81
|
+
authKey,
|
|
82
|
+
dataType: "JSON",
|
|
83
|
+
nx: 0,
|
|
84
|
+
ny: 0,
|
|
85
|
+
...Object.fromEntries(searchParams.entries()),
|
|
86
|
+
base_date,
|
|
87
|
+
base_time,
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const hasTodayTemperatureRange = (
|
|
92
|
+
forecast: API_Res_WeatherKoreaForecast,
|
|
93
|
+
): boolean =>
|
|
94
|
+
forecast.today.max_temperature !== null &&
|
|
95
|
+
forecast.today.min_temperature !== null;
|
|
96
|
+
|
|
97
|
+
const fillTodayTemperatureRange = ({
|
|
98
|
+
forecast,
|
|
99
|
+
dailyRange,
|
|
100
|
+
}: {
|
|
101
|
+
forecast: API_Res_WeatherKoreaForecast;
|
|
102
|
+
dailyRange: API_Res_WeatherKoreaForecast;
|
|
103
|
+
}): API_Res_WeatherKoreaForecast => ({
|
|
104
|
+
...forecast,
|
|
105
|
+
today: {
|
|
106
|
+
...forecast.today,
|
|
107
|
+
max_temperature:
|
|
108
|
+
forecast.today.max_temperature ?? dailyRange.today.max_temperature,
|
|
109
|
+
min_temperature:
|
|
110
|
+
forecast.today.min_temperature ?? dailyRange.today.min_temperature,
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
66
114
|
/**
|
|
67
115
|
* 기상청 API; 현재 날씨
|
|
68
116
|
* @method GET
|
|
@@ -82,6 +130,8 @@ export const routeWeatherKoreaNow = async ({
|
|
|
82
130
|
const NOW_DATA: API_Res_WeatherKoreaToday = {
|
|
83
131
|
...API_RES_BASE,
|
|
84
132
|
temperature: null,
|
|
133
|
+
max_temperature: null,
|
|
134
|
+
min_temperature: null,
|
|
85
135
|
humidity: null,
|
|
86
136
|
};
|
|
87
137
|
const resDefault: API_Res_WeatherKoreaNow = {
|
|
@@ -146,7 +196,13 @@ export const routeWeatherKoreaForecast = async ({
|
|
|
146
196
|
};
|
|
147
197
|
const resDefault: API_Res_WeatherKoreaForecast = {
|
|
148
198
|
raw: API_RES_RAW as KMA_Res_WeatherForecast,
|
|
149
|
-
today: {
|
|
199
|
+
today: {
|
|
200
|
+
...API_RES_BASE,
|
|
201
|
+
temperature: null,
|
|
202
|
+
max_temperature: null,
|
|
203
|
+
min_temperature: null,
|
|
204
|
+
humidity: null,
|
|
205
|
+
},
|
|
150
206
|
day_1: { ...FORECAST_DATA },
|
|
151
207
|
day_2: { ...FORECAST_DATA },
|
|
152
208
|
};
|
|
@@ -177,7 +233,38 @@ export const routeWeatherKoreaForecast = async ({
|
|
|
177
233
|
try {
|
|
178
234
|
const res: KMA_Res_WeatherForecast = await (await fetch(url)).json();
|
|
179
235
|
resDefault.raw = res;
|
|
180
|
-
|
|
236
|
+
const forecast = extractWeatherSummary.forecast(resDefault, res);
|
|
237
|
+
|
|
238
|
+
if (hasTodayTemperatureRange(forecast)) {
|
|
239
|
+
return forecast;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const dailyRangeQueryParams = Object.fromEntries(
|
|
243
|
+
Object.entries(getWeatherDailyRangeParams({ authKey, searchParams })).map(
|
|
244
|
+
([key, value]) => [key, String(value)],
|
|
245
|
+
),
|
|
246
|
+
);
|
|
247
|
+
const dailyRangeUrl = generateQueryUrl({
|
|
248
|
+
domain,
|
|
249
|
+
routeUrl,
|
|
250
|
+
queryUrl: QUERY_URL.forecast,
|
|
251
|
+
searchParams: new URLSearchParams(dailyRangeQueryParams),
|
|
252
|
+
});
|
|
253
|
+
const dailyRangeRes: KMA_Res_WeatherForecast = await (
|
|
254
|
+
await fetch(dailyRangeUrl)
|
|
255
|
+
).json();
|
|
256
|
+
const dailyRange = extractWeatherSummary.forecast(
|
|
257
|
+
{
|
|
258
|
+
...resDefault,
|
|
259
|
+
raw: dailyRangeRes,
|
|
260
|
+
today: { ...resDefault.today },
|
|
261
|
+
day_1: { ...resDefault.day_1 },
|
|
262
|
+
day_2: { ...resDefault.day_2 },
|
|
263
|
+
},
|
|
264
|
+
dailyRangeRes,
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
return fillTodayTemperatureRange({ forecast, dailyRange });
|
|
181
268
|
} catch (error) {
|
|
182
269
|
nextAPILog("GET", routeUrl, url, { error });
|
|
183
270
|
|
|
@@ -15,7 +15,7 @@ const API_BASE_CURRENT = "https://api.openweathermap.org/data/2.5/weather";
|
|
|
15
15
|
* 글로벌 날씨 API; 예보 날씨 URL
|
|
16
16
|
*/
|
|
17
17
|
const API_BASE_FORECAST =
|
|
18
|
-
"https://pro.openweathermap.org/data/2.5/
|
|
18
|
+
"https://pro.openweathermap.org/data/2.5/forecast/hourly";
|
|
19
19
|
|
|
20
20
|
const COMMON_OPTIONS = {
|
|
21
21
|
units: "metric", // 섭씨 온도
|
|
@@ -10,6 +10,7 @@ import type { WeatherPageHeaderTodayProps } from "../../types";
|
|
|
10
10
|
import { useOpenWeatherMap, useWeatherKorea } from "../../hooks";
|
|
11
11
|
import {
|
|
12
12
|
getOpenWeatherMapConditionCode,
|
|
13
|
+
getOpenWeatherMapTodayForecast,
|
|
13
14
|
isKoreaWeatherLocale,
|
|
14
15
|
resolveWeatherPageHeaderTexts,
|
|
15
16
|
} from "../../utils";
|
|
@@ -27,17 +28,27 @@ export default function WeatherPageHeaderToday({
|
|
|
27
28
|
const openWeatherMapWeatherFallback = useOpenWeatherMap({
|
|
28
29
|
locale,
|
|
29
30
|
enabledNow: !openWeatherMapWeather && !isKoreaProvider,
|
|
30
|
-
enabledForecast:
|
|
31
|
+
enabledForecast: !openWeatherMapWeather && !isKoreaProvider,
|
|
31
32
|
});
|
|
32
33
|
const korea = koreaWeather ?? koreaWeatherFallback;
|
|
33
34
|
const openWeatherMap = openWeatherMapWeather ?? openWeatherMapWeatherFallback;
|
|
34
35
|
const texts = resolveWeatherPageHeaderTexts(textOverrides);
|
|
36
|
+
const openWeatherMapToday = useMemo(
|
|
37
|
+
() => getOpenWeatherMapTodayForecast(openWeatherMap.forecast),
|
|
38
|
+
[openWeatherMap.forecast],
|
|
39
|
+
);
|
|
35
40
|
|
|
36
41
|
const isFetchingAlternate = useMemo(() => {
|
|
37
|
-
if (!isKoreaProvider)
|
|
42
|
+
if (!isKoreaProvider)
|
|
43
|
+
return openWeatherMap.isFetchingNow || openWeatherMap.isFetchingForecast;
|
|
38
44
|
if (korea.isFetchingNow) return true;
|
|
39
45
|
return false;
|
|
40
|
-
}, [
|
|
46
|
+
}, [
|
|
47
|
+
isKoreaProvider,
|
|
48
|
+
korea.isFetchingNow,
|
|
49
|
+
openWeatherMap.isFetchingForecast,
|
|
50
|
+
openWeatherMap.isFetchingNow,
|
|
51
|
+
]);
|
|
41
52
|
|
|
42
53
|
const iconCode = useMemo(() => {
|
|
43
54
|
if (isKoreaProvider) {
|
|
@@ -119,8 +130,57 @@ export default function WeatherPageHeaderToday({
|
|
|
119
130
|
openWeatherMap.now?.main?.humidity,
|
|
120
131
|
]);
|
|
121
132
|
|
|
122
|
-
const maxTemperature =
|
|
123
|
-
|
|
133
|
+
const maxTemperature = useMemo(() => {
|
|
134
|
+
if (isKoreaProvider) {
|
|
135
|
+
const kma_forecast = korea.forecast?.today?.max_temperature;
|
|
136
|
+
if (
|
|
137
|
+
kma_forecast !== null &&
|
|
138
|
+
kma_forecast !== undefined &&
|
|
139
|
+
!Number.isNaN(Number(kma_forecast))
|
|
140
|
+
)
|
|
141
|
+
return Number(kma_forecast);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const alt_forecast = openWeatherMapToday?.max_temperature;
|
|
145
|
+
if (
|
|
146
|
+
alt_forecast !== null &&
|
|
147
|
+
alt_forecast !== undefined &&
|
|
148
|
+
!Number.isNaN(Number(alt_forecast))
|
|
149
|
+
)
|
|
150
|
+
return Number(alt_forecast);
|
|
151
|
+
|
|
152
|
+
return null;
|
|
153
|
+
}, [
|
|
154
|
+
isKoreaProvider,
|
|
155
|
+
korea.forecast?.today?.max_temperature,
|
|
156
|
+
openWeatherMapToday?.max_temperature,
|
|
157
|
+
]);
|
|
158
|
+
|
|
159
|
+
const minTemperature = useMemo(() => {
|
|
160
|
+
if (isKoreaProvider) {
|
|
161
|
+
const kma_forecast = korea.forecast?.today?.min_temperature;
|
|
162
|
+
if (
|
|
163
|
+
kma_forecast !== null &&
|
|
164
|
+
kma_forecast !== undefined &&
|
|
165
|
+
!Number.isNaN(Number(kma_forecast))
|
|
166
|
+
)
|
|
167
|
+
return Number(kma_forecast);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const alt_forecast = openWeatherMapToday?.min_temperature;
|
|
171
|
+
if (
|
|
172
|
+
alt_forecast !== null &&
|
|
173
|
+
alt_forecast !== undefined &&
|
|
174
|
+
!Number.isNaN(Number(alt_forecast))
|
|
175
|
+
)
|
|
176
|
+
return Number(alt_forecast);
|
|
177
|
+
|
|
178
|
+
return null;
|
|
179
|
+
}, [
|
|
180
|
+
isKoreaProvider,
|
|
181
|
+
korea.forecast?.today?.min_temperature,
|
|
182
|
+
openWeatherMapToday?.min_temperature,
|
|
183
|
+
]);
|
|
124
184
|
const alert = (
|
|
125
185
|
<WeatherPageHeaderAlert locale={locale} texts={textOverrides} />
|
|
126
186
|
);
|
package/src/weather/types/api.ts
CHANGED
|
@@ -158,11 +158,17 @@ export type API_Res_WeatherKoreaBase = {
|
|
|
158
158
|
/**
|
|
159
159
|
* 기상청 API; 오늘 날씨
|
|
160
160
|
* @property {number|string|null} temperature 기온(℃)
|
|
161
|
+
* @property {number|string|null} max_temperature 최고기온(℃)
|
|
162
|
+
* @property {number|string|null} min_temperature 최저기온(℃)
|
|
161
163
|
* @property {number|string|null} humidity 습도(%)
|
|
162
164
|
*/
|
|
163
165
|
export type API_Res_WeatherKoreaToday = API_Res_WeatherKoreaBase & {
|
|
164
166
|
/** 기온(℃) */
|
|
165
167
|
temperature: number | string | null;
|
|
168
|
+
/** 최고 기온(℃) */
|
|
169
|
+
max_temperature: number | string | null;
|
|
170
|
+
/** 최저 기온(℃) */
|
|
171
|
+
min_temperature: number | string | null;
|
|
166
172
|
/** 습도(%) */
|
|
167
173
|
humidity: number | string | null;
|
|
168
174
|
};
|
|
@@ -37,6 +37,29 @@ export const getWeatherBaseMoments = (
|
|
|
37
37
|
return { base_date, base_time };
|
|
38
38
|
};
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* 날씨 도구; KMA 오늘 일최고/일최저 조회 기준시각
|
|
42
|
+
* @util
|
|
43
|
+
* @return {KMA_Req_BaseMoments} 오늘 TMN/TMX 보강용 발표일자/발표시각
|
|
44
|
+
* @desc
|
|
45
|
+
* - KMA 최신 단기예보는 과거 예보시각의 오늘 TMN/TMX를 포함하지 않을 수 있다.
|
|
46
|
+
* - 기본은 전날 23시 발표를 사용하고, 23시 이후에는 최근 24시간 범위 안의 당일 02시 발표를 사용한다.
|
|
47
|
+
*/
|
|
48
|
+
export const getWeatherDailyRangeBaseMoments = (): KMA_Req_BaseMoments => {
|
|
49
|
+
const now = new Date();
|
|
50
|
+
const base = new Date(now);
|
|
51
|
+
const base_hour = now.getHours() >= 23 ? 2 : 23;
|
|
52
|
+
|
|
53
|
+
if (base_hour === 23) {
|
|
54
|
+
base.setDate(base.getDate() - 1);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
base_date: dateFormat(base).replace(/-/g, ""),
|
|
59
|
+
base_time: `${base_hour.toString().padStart(2, "0")}00`,
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
|
|
40
63
|
/**
|
|
41
64
|
* 날씨 도구; 현재 날씨/예보 시각
|
|
42
65
|
* @util
|
|
@@ -49,17 +72,18 @@ export const getWeatherBaseMoments = (
|
|
|
49
72
|
*/
|
|
50
73
|
export const getMoments = (): WeatherUtilDateTimeMoments => {
|
|
51
74
|
const now = new Date();
|
|
52
|
-
const today = now
|
|
75
|
+
const today = dateFormat(now).replace(/-/g, "");
|
|
53
76
|
|
|
54
|
-
const tomorrow = new Date(now.getTime() + 86400000)
|
|
55
|
-
|
|
56
|
-
.slice(0, 10)
|
|
57
|
-
.replace(/-/g, "");
|
|
77
|
+
const tomorrow = new Date(now.getTime() + 86400000);
|
|
78
|
+
const tomorrowKey = dateFormat(tomorrow).replace(/-/g, "");
|
|
58
79
|
|
|
59
|
-
const dayAfterTomorrow = new Date(now.getTime() + 2 * 86400000)
|
|
60
|
-
|
|
61
|
-
.slice(0, 10)
|
|
62
|
-
.replace(/-/g, "");
|
|
80
|
+
const dayAfterTomorrow = new Date(now.getTime() + 2 * 86400000);
|
|
81
|
+
const dayAfterTomorrowKey = dateFormat(dayAfterTomorrow).replace(/-/g, "");
|
|
63
82
|
|
|
64
|
-
return {
|
|
83
|
+
return {
|
|
84
|
+
now,
|
|
85
|
+
today,
|
|
86
|
+
tomorrow: tomorrowKey,
|
|
87
|
+
dayAfterTomorrow: dayAfterTomorrowKey,
|
|
88
|
+
};
|
|
65
89
|
};
|
|
@@ -264,10 +264,32 @@ const getLocalDateKey = (date: Date): string => {
|
|
|
264
264
|
return `${date.getFullYear()}-${month}-${day}`;
|
|
265
265
|
};
|
|
266
266
|
|
|
267
|
-
const
|
|
268
|
-
|
|
267
|
+
const getTimeZoneDateKey = (dateTimeSeconds: number, timezoneSeconds: number) =>
|
|
268
|
+
new Date((dateTimeSeconds + timezoneSeconds) * 1000)
|
|
269
|
+
.toISOString()
|
|
270
|
+
.slice(0, 10);
|
|
269
271
|
|
|
270
|
-
const
|
|
272
|
+
const getOpenWeatherMapDateKey = (
|
|
273
|
+
forecast?: OWM_Res_Weather_Forecast,
|
|
274
|
+
): string => {
|
|
275
|
+
const timezoneSeconds = forecast?.city?.timezone;
|
|
276
|
+
|
|
277
|
+
if (typeof timezoneSeconds === "number" && Number.isFinite(timezoneSeconds)) {
|
|
278
|
+
return getTimeZoneDateKey(Math.floor(Date.now() / 1000), timezoneSeconds);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return getLocalDateKey(new Date());
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const getForecastItemDateKey = (
|
|
285
|
+
item: OWM_Res_Weather_Forecast_Item,
|
|
286
|
+
timezoneSeconds = 0,
|
|
287
|
+
): string =>
|
|
288
|
+
Number.isFinite(item.dt)
|
|
289
|
+
? getTimeZoneDateKey(item.dt, timezoneSeconds)
|
|
290
|
+
: item.dt_txt?.slice(0, 10) || getLocalDateKey(new Date(item.dt * 1000));
|
|
291
|
+
|
|
292
|
+
const toOpenWeatherMapDayForecast = (
|
|
271
293
|
items: OWM_Res_Weather_Forecast_Item[],
|
|
272
294
|
): API_Res_WeatherKoreaNextDays | undefined => {
|
|
273
295
|
if (items.length === 0) return undefined;
|
|
@@ -275,11 +297,11 @@ const toOpenWeatherMapNextDay = (
|
|
|
275
297
|
const conditionItem =
|
|
276
298
|
items.find(item => item.dt_txt?.includes("12:00")) ||
|
|
277
299
|
items[Math.floor(items.length / 2)];
|
|
278
|
-
const temperatures = items
|
|
279
|
-
item.main.temp_min,
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
300
|
+
const temperatures = items
|
|
301
|
+
.flatMap(item => [item.main.temp_min, item.main.temp_max, item.main.temp])
|
|
302
|
+
.filter((temperature): temperature is number =>
|
|
303
|
+
Number.isFinite(temperature),
|
|
304
|
+
);
|
|
283
305
|
|
|
284
306
|
return {
|
|
285
307
|
sky: null,
|
|
@@ -311,11 +333,12 @@ export const getOpenWeatherMapNextDays = (
|
|
|
311
333
|
} => {
|
|
312
334
|
if (!Array.isArray(forecast?.list)) return {};
|
|
313
335
|
|
|
314
|
-
const
|
|
336
|
+
const timezoneSeconds = forecast.city?.timezone ?? 0;
|
|
337
|
+
const todayKey = getOpenWeatherMapDateKey(forecast);
|
|
315
338
|
const grouped = forecast.list.reduce<
|
|
316
339
|
Record<string, OWM_Res_Weather_Forecast_Item[]>
|
|
317
340
|
>((days, item) => {
|
|
318
|
-
const dateKey = getForecastItemDateKey(item);
|
|
341
|
+
const dateKey = getForecastItemDateKey(item, timezoneSeconds);
|
|
319
342
|
if (dateKey === todayKey) return days;
|
|
320
343
|
|
|
321
344
|
days[dateKey] = [...(days[dateKey] || []), item];
|
|
@@ -324,11 +347,30 @@ export const getOpenWeatherMapNextDays = (
|
|
|
324
347
|
const [day1Key, day2Key] = Object.keys(grouped).sort();
|
|
325
348
|
|
|
326
349
|
return {
|
|
327
|
-
day_1: day1Key ?
|
|
328
|
-
day_2: day2Key ?
|
|
350
|
+
day_1: day1Key ? toOpenWeatherMapDayForecast(grouped[day1Key]) : undefined,
|
|
351
|
+
day_2: day2Key ? toOpenWeatherMapDayForecast(grouped[day2Key]) : undefined,
|
|
329
352
|
};
|
|
330
353
|
};
|
|
331
354
|
|
|
355
|
+
/**
|
|
356
|
+
* 날씨 도구; OpenWeatherMap 예보 응답을 오늘 최고/최저 형태로 변환
|
|
357
|
+
* @param {OWM_Res_Weather_Forecast} [forecast] OpenWeatherMap 예보 응답
|
|
358
|
+
* @return {API_Res_WeatherKoreaNextDays | undefined} 오늘 최고/최저 예보
|
|
359
|
+
*/
|
|
360
|
+
export const getOpenWeatherMapTodayForecast = (
|
|
361
|
+
forecast?: OWM_Res_Weather_Forecast,
|
|
362
|
+
): API_Res_WeatherKoreaNextDays | undefined => {
|
|
363
|
+
if (!Array.isArray(forecast?.list)) return undefined;
|
|
364
|
+
|
|
365
|
+
const timezoneSeconds = forecast.city?.timezone ?? 0;
|
|
366
|
+
const todayKey = getOpenWeatherMapDateKey(forecast);
|
|
367
|
+
const todayItems = forecast.list.filter(
|
|
368
|
+
item => getForecastItemDateKey(item, timezoneSeconds) === todayKey,
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
return toOpenWeatherMapDayForecast(todayItems);
|
|
372
|
+
};
|
|
373
|
+
|
|
332
374
|
export function now(
|
|
333
375
|
outputResponse: API_Res_WeatherKoreaNow,
|
|
334
376
|
apiResponse: KMA_Res_WeatherNow,
|
|
@@ -396,6 +438,8 @@ export function forecast(
|
|
|
396
438
|
};
|
|
397
439
|
|
|
398
440
|
res.today.temperature = getValueForecast(items, today, "TMP");
|
|
441
|
+
res.today.max_temperature = getValueForecast(items, today, "TMX");
|
|
442
|
+
res.today.min_temperature = getValueForecast(items, today, "TMN");
|
|
399
443
|
res.today.humidity = getValueForecast(items, today, "REH");
|
|
400
444
|
Object.assign(res.today, getDailyState(today));
|
|
401
445
|
|