@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniai-fe/uds-templates",
3
- "version": "0.5.13",
3
+ "version": "0.5.15",
4
4
  "description": "UNIAI Design System; UI Templates Package",
5
5
  "type": "module",
6
6
  "private": false,
@@ -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 { getWeatherBaseMoments } from "../../utils/date-time";
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: { ...API_RES_BASE, temperature: null, humidity: null },
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
- return extractWeatherSummary.forecast(resDefault, res);
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/weather/forecast/hourly";
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: false,
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) return openWeatherMap.isFetchingNow;
42
+ if (!isKoreaProvider)
43
+ return openWeatherMap.isFetchingNow || openWeatherMap.isFetchingForecast;
38
44
  if (korea.isFetchingNow) return true;
39
45
  return false;
40
- }, [isKoreaProvider, korea.isFetchingNow, openWeatherMap.isFetchingNow]);
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 = null;
123
- const minTemperature = null;
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
  );
@@ -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.toISOString().slice(0, 10).replace(/-/g, "");
75
+ const today = dateFormat(now).replace(/-/g, "");
53
76
 
54
- const tomorrow = new Date(now.getTime() + 86400000)
55
- .toISOString()
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
- .toISOString()
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 { now, today, tomorrow, dayAfterTomorrow };
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 getForecastItemDateKey = (item: OWM_Res_Weather_Forecast_Item): string =>
268
- item.dt_txt?.slice(0, 10) || getLocalDateKey(new Date(item.dt * 1000));
267
+ const getTimeZoneDateKey = (dateTimeSeconds: number, timezoneSeconds: number) =>
268
+ new Date((dateTimeSeconds + timezoneSeconds) * 1000)
269
+ .toISOString()
270
+ .slice(0, 10);
269
271
 
270
- const toOpenWeatherMapNextDay = (
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.flatMap(item => [
279
- item.main.temp_min,
280
- item.main.temp_max,
281
- item.main.temp,
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 todayKey = getLocalDateKey(new Date());
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 ? toOpenWeatherMapNextDay(grouped[day1Key]) : undefined,
328
- day_2: day2Key ? toOpenWeatherMapNextDay(grouped[day2Key]) : undefined,
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