@musecat/functionkit 1.0.1 → 1.0.2

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.
Files changed (120) hide show
  1. package/.agents/references/components/ScrolltoTop.md +36 -0
  2. package/.agents/references/components/SwitchCase.md +47 -0
  3. package/.agents/references/components/ViewportPortal.md +34 -0
  4. package/.agents/references/cookie/cookie.md +47 -0
  5. package/.agents/references/datetime/dateTime.client.md +42 -0
  6. package/.agents/references/datetime/dateTime.server.md +42 -0
  7. package/.agents/references/datetime/dateTime.shared.md +86 -0
  8. package/.agents/references/hooks/useAvoidKeyboard.md +29 -0
  9. package/.agents/references/hooks/useCheckInvisible.md +36 -0
  10. package/.agents/references/hooks/useCheckScroll.md +36 -0
  11. package/.agents/references/hooks/useClientDateTime.md +39 -0
  12. package/.agents/references/hooks/useDebounce.md +46 -0
  13. package/.agents/references/hooks/useDebouncedCallback.md +34 -0
  14. package/.agents/references/hooks/useDoubleClick.md +40 -0
  15. package/.agents/references/hooks/useGeolocation.md +37 -0
  16. package/.agents/references/hooks/useHasMounted.md +27 -0
  17. package/.agents/references/hooks/useIntersectionObserver.md +37 -0
  18. package/.agents/references/hooks/useInterval.md +40 -0
  19. package/.agents/references/hooks/useKeyboardHeight.md +33 -0
  20. package/.agents/references/hooks/useKeyboardListNavigation.md +58 -0
  21. package/.agents/references/hooks/useLongPress.md +48 -0
  22. package/.agents/references/hooks/usePreservedCallback.md +33 -0
  23. package/.agents/references/hooks/usePreservedReference.md +34 -0
  24. package/.agents/references/hooks/useRefEffect.md +35 -0
  25. package/.agents/references/hooks/useRelativeDateTime.md +35 -0
  26. package/.agents/references/hooks/useTimeout.md +42 -0
  27. package/.agents/references/hooks/useToggleState.md +39 -0
  28. package/.agents/references/hooks/useViewportHeight.md +26 -0
  29. package/.agents/references/hooks/useViewportMatch.md +32 -0
  30. package/.agents/references/utils/browserStorage.md +41 -0
  31. package/.agents/references/utils/buildContext.md +47 -0
  32. package/.agents/references/utils/clipboardShare.md +59 -0
  33. package/.agents/references/utils/floatingMotion.md +67 -0
  34. package/.agents/references/utils/getDeviceInfo.md +43 -0
  35. package/.agents/references/utils/isEditableKeyboardTarget.md +35 -0
  36. package/.agents/references/utils/mergeRefs.md +29 -0
  37. package/.agents/references/utils/seen.md +37 -0
  38. package/.agents/references/utils/subscribeKeyboardHeight.md +35 -0
  39. package/AGENTS.md +103 -396
  40. package/README.md +18 -5
  41. package/index.ts +63 -68
  42. package/package.json +27 -13
  43. package/packages/components/ScrolltoTop.tsx +3 -3
  44. package/packages/components/SwitchCase.tsx +7 -7
  45. package/packages/components/ViewportPortal.tsx +23 -23
  46. package/packages/cookie/cookie.shared.ts +97 -101
  47. package/packages/datetime/dateTime.client.ts +86 -86
  48. package/packages/datetime/dateTime.server.ts +86 -86
  49. package/packages/datetime/dateTime.shared.ts +281 -281
  50. package/packages/hooks/useAvoidKeyboard.ts +15 -15
  51. package/packages/hooks/useCheckInvisible.ts +16 -16
  52. package/packages/hooks/useCheckScroll.ts +10 -10
  53. package/packages/hooks/useClientDateTime.ts +33 -33
  54. package/packages/hooks/useDebounce.ts +95 -95
  55. package/packages/hooks/useDebouncedCallback.ts +45 -45
  56. package/packages/hooks/useDoubleClick.ts +43 -43
  57. package/packages/hooks/useGeolocation.ts +130 -130
  58. package/packages/hooks/useHasMounted.ts +5 -5
  59. package/packages/hooks/useIntersectionObserver.ts +20 -20
  60. package/packages/hooks/useInterval.ts +46 -45
  61. package/packages/hooks/useKeyboardHeight.ts +9 -9
  62. package/packages/hooks/useKeyboardListNavigation.ts +156 -156
  63. package/packages/hooks/useLongPress.ts +98 -98
  64. package/packages/hooks/usePreservedCallback.ts +12 -12
  65. package/packages/hooks/usePreservedReference.ts +10 -10
  66. package/packages/hooks/useRefEffect.ts +19 -19
  67. package/packages/hooks/useRelativeDateTime.ts +37 -37
  68. package/packages/hooks/useTimeout.ts +41 -41
  69. package/packages/hooks/useToggleState.ts +5 -5
  70. package/packages/hooks/useViewportHeight.ts +13 -13
  71. package/packages/hooks/useViewportMatch.ts +16 -22
  72. package/packages/utils/browserStorage.ts +26 -26
  73. package/packages/utils/buildContext.tsx +10 -10
  74. package/packages/utils/checkDevice.ts +64 -64
  75. package/packages/utils/clipboardShare.tsx +31 -31
  76. package/packages/utils/clipboardShare.types.ts +7 -7
  77. package/packages/utils/floatingMotion.ts +71 -71
  78. package/packages/utils/keyboardTarget.ts +9 -9
  79. package/packages/utils/mergeRefs.ts +9 -9
  80. package/packages/utils/seen.ts +15 -15
  81. package/packages/utils/subscribeKeyboardHeight.ts +46 -46
  82. package/tests/components/ScrolltoTop.test.tsx +15 -0
  83. package/tests/components/SwitchCase.test.tsx +34 -0
  84. package/tests/components/ViewportPortal.test.tsx +42 -0
  85. package/tests/cookie/cookie.test.ts +156 -0
  86. package/tests/datetime/datetime.test.ts +250 -0
  87. package/tests/hooks/useAvoidKeyboard.test.ts +12 -0
  88. package/tests/hooks/useCheckInvisible.test.ts +28 -0
  89. package/tests/hooks/useCheckScroll.test.ts +23 -0
  90. package/tests/hooks/useClientDateTime.test.ts +14 -0
  91. package/tests/hooks/useDebounce.test.ts +71 -0
  92. package/tests/hooks/useDebouncedCallback.test.ts +34 -0
  93. package/tests/hooks/useDoubleClick.test.ts +31 -0
  94. package/tests/hooks/useGeolocation.test.ts +50 -0
  95. package/tests/hooks/useHasMounted.test.ts +10 -0
  96. package/tests/hooks/useIntersectionObserver.test.ts +13 -0
  97. package/tests/hooks/useInterval.test.ts +31 -0
  98. package/tests/hooks/useKeyboardHeight.test.ts +10 -0
  99. package/tests/hooks/useKeyboardListNavigation.test.ts +82 -0
  100. package/tests/hooks/useLongPress.test.ts +52 -0
  101. package/tests/hooks/usePreservedCallback.test.ts +35 -0
  102. package/tests/hooks/usePreservedReference.test.ts +35 -0
  103. package/tests/hooks/useRefEffect.test.ts +30 -0
  104. package/tests/hooks/useRelativeDateTime.test.ts +19 -0
  105. package/tests/hooks/useTimeout.test.ts +34 -0
  106. package/tests/hooks/useToggleState.test.ts +35 -0
  107. package/tests/hooks/useViewportHeight.test.ts +10 -0
  108. package/tests/hooks/useViewportMatch.test.ts +35 -0
  109. package/tests/setup.ts +9 -0
  110. package/tests/utils/browserStorage.test.ts +54 -0
  111. package/tests/utils/buildContext.test.tsx +27 -0
  112. package/tests/utils/checkDevice.test.ts +84 -0
  113. package/tests/utils/clipboardShare.test.ts +78 -0
  114. package/tests/utils/floatingMotion.test.ts +84 -0
  115. package/tests/utils/keyboardTarget.test.ts +44 -0
  116. package/tests/utils/mergeRefs.test.ts +18 -0
  117. package/tests/utils/seen.test.ts +31 -0
  118. package/tests/utils/subscribeKeyboardHeight.test.ts +50 -0
  119. package/tsconfig.json +16 -16
  120. package/vitest.config.ts +16 -0
@@ -5,354 +5,354 @@ export type TimePreset = "ko" | "12h" | "24h-minute" | "24h-second";
5
5
  const DATE_ONLY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
6
6
 
7
7
  type DateParts = {
8
- year: string;
9
- month: string;
10
- day: string;
8
+ year: string;
9
+ month: string;
10
+ day: string;
11
11
  };
12
12
 
13
13
  type TimeParts = {
14
- hour: string;
15
- minute: string;
16
- second: string;
14
+ hour: string;
15
+ minute: string;
16
+ second: string;
17
17
  };
18
18
 
19
19
  export function normalizeAppLocale(locale?: string): AppLocale {
20
- const normalized =
21
- typeof locale === "string" ? locale.trim().toLowerCase() : "";
22
- if (normalized === "kr" || normalized === "ko") return "kr";
23
- if (normalized === "jp" || normalized === "ja") return "jp";
24
- return "en";
20
+ const normalized =
21
+ typeof locale === "string" ? locale.trim().toLowerCase() : "";
22
+ if (normalized === "kr" || normalized === "ko") return "kr";
23
+ if (normalized === "jp" || normalized === "ja") return "jp";
24
+ return "en";
25
25
  }
26
26
 
27
27
  export function toIntlLocale(locale?: string): string {
28
- switch (normalizeAppLocale(locale)) {
29
- case "kr":
30
- return "ko-KR";
31
- case "jp":
32
- return "ja-JP";
33
- default:
34
- return "en-US";
35
- }
28
+ switch (normalizeAppLocale(locale)) {
29
+ case "kr":
30
+ return "ko-KR";
31
+ case "jp":
32
+ return "ja-JP";
33
+ default:
34
+ return "en-US";
35
+ }
36
36
  }
37
37
 
38
38
  export function toDate(value: DateInput): Date | null {
39
- if (value instanceof Date) {
40
- return Number.isNaN(value.getTime()) ? null : new Date(value.getTime());
41
- }
39
+ if (value instanceof Date) {
40
+ return Number.isNaN(value.getTime()) ? null : new Date(value.getTime());
41
+ }
42
42
 
43
- const date = new Date(value);
44
- return Number.isNaN(date.getTime()) ? null : date;
43
+ const date = new Date(value);
44
+ return Number.isNaN(date.getTime()) ? null : date;
45
45
  }
46
46
 
47
47
  export function toUtcMidnight(date: Date): Date {
48
- return new Date(
49
- Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()),
50
- );
48
+ return new Date(
49
+ Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()),
50
+ );
51
51
  }
52
52
 
53
53
  export function parseUtcDateInput(
54
- value?: DateInput | null,
55
- fallback?: DateInput,
54
+ value?: DateInput | null,
55
+ fallback?: DateInput,
56
56
  ): Date | null {
57
- if (typeof value === "string") {
58
- const trimmed = value.trim();
59
- const match = DATE_ONLY_PATTERN.exec(trimmed);
60
-
61
- if (match) {
62
- const [, year, month, day] = match;
63
- return new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));
64
- }
65
- }
66
-
67
- const parsed = value !== undefined && value !== null ? toDate(value) : null;
68
- if (parsed) {
69
- return toUtcMidnight(parsed);
70
- }
71
-
72
- if (fallback !== undefined) {
73
- const fallbackDate = toDate(fallback);
74
- if (fallbackDate) {
75
- return toUtcMidnight(fallbackDate);
76
- }
77
- }
78
-
79
- return null;
57
+ if (typeof value === "string") {
58
+ const trimmed = value.trim();
59
+ const match = DATE_ONLY_PATTERN.exec(trimmed);
60
+
61
+ if (match) {
62
+ const [, year, month, day] = match;
63
+ return new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));
64
+ }
65
+ }
66
+
67
+ const parsed = value !== undefined && value !== null ? toDate(value) : null;
68
+ if (parsed) {
69
+ return toUtcMidnight(parsed);
70
+ }
71
+
72
+ if (fallback !== undefined) {
73
+ const fallbackDate = toDate(fallback);
74
+ if (fallbackDate) {
75
+ return toUtcMidnight(fallbackDate);
76
+ }
77
+ }
78
+
79
+ return null;
80
80
  }
81
81
 
82
82
  export function formatUtcDateKey(date: Date): string {
83
- return [
84
- date.getUTCFullYear(),
85
- String(date.getUTCMonth() + 1).padStart(2, "0"),
86
- String(date.getUTCDate()).padStart(2, "0"),
87
- ].join("-");
83
+ return [
84
+ date.getUTCFullYear(),
85
+ String(date.getUTCMonth() + 1).padStart(2, "0"),
86
+ String(date.getUTCDate()).padStart(2, "0"),
87
+ ].join("-");
88
88
  }
89
89
 
90
90
  export function getUtcWeekdayIndex(date: Date): number {
91
- return date.getUTCDay();
91
+ return date.getUTCDay();
92
92
  }
93
93
 
94
94
  export function addUtcDays(date: Date, days: number): Date {
95
- const next = new Date(date.getTime());
96
- next.setUTCDate(next.getUTCDate() + days);
97
- return next;
95
+ const next = new Date(date.getTime());
96
+ next.setUTCDate(next.getUTCDate() + days);
97
+ return next;
98
98
  }
99
99
 
100
100
  function getDateParts(date: Date, timeZone?: string): DateParts {
101
- const parts = new Intl.DateTimeFormat("en-US", {
102
- timeZone,
103
- year: "numeric",
104
- month: "2-digit",
105
- day: "2-digit",
106
- }).formatToParts(date);
107
-
108
- return {
109
- year: parts.find((part) => part.type === "year")?.value ?? "0000",
110
- month: parts.find((part) => part.type === "month")?.value ?? "00",
111
- day: parts.find((part) => part.type === "day")?.value ?? "00",
112
- };
101
+ const parts = new Intl.DateTimeFormat("en-US", {
102
+ timeZone,
103
+ year: "numeric",
104
+ month: "2-digit",
105
+ day: "2-digit",
106
+ }).formatToParts(date);
107
+
108
+ return {
109
+ year: parts.find((part) => part.type === "year")?.value ?? "0000",
110
+ month: parts.find((part) => part.type === "month")?.value ?? "00",
111
+ day: parts.find((part) => part.type === "day")?.value ?? "00",
112
+ };
113
113
  }
114
114
 
115
115
  function getTimeParts(date: Date, timeZone?: string): TimeParts {
116
- const parts = new Intl.DateTimeFormat("en-US", {
117
- timeZone,
118
- hour: "2-digit",
119
- minute: "2-digit",
120
- second: "2-digit",
121
- hourCycle: "h23",
122
- }).formatToParts(date);
123
-
124
- return {
125
- hour: parts.find((part) => part.type === "hour")?.value ?? "00",
126
- minute: parts.find((part) => part.type === "minute")?.value ?? "00",
127
- second: parts.find((part) => part.type === "second")?.value ?? "00",
128
- };
116
+ const parts = new Intl.DateTimeFormat("en-US", {
117
+ timeZone,
118
+ hour: "2-digit",
119
+ minute: "2-digit",
120
+ second: "2-digit",
121
+ hourCycle: "h23",
122
+ }).formatToParts(date);
123
+
124
+ return {
125
+ hour: parts.find((part) => part.type === "hour")?.value ?? "00",
126
+ minute: parts.find((part) => part.type === "minute")?.value ?? "00",
127
+ second: parts.find((part) => part.type === "second")?.value ?? "00",
128
+ };
129
129
  }
130
130
 
131
131
  export function formatLongDate(
132
- date: Date,
133
- locale?: string,
134
- timeZone?: string,
132
+ date: Date,
133
+ locale?: string,
134
+ timeZone?: string,
135
135
  ): string {
136
- const localeKey = normalizeAppLocale(locale);
137
- const { year, month, day } = getDateParts(date, timeZone);
138
-
139
- if (localeKey === "kr") {
140
- return `${year}년 ${Number(month)}월 ${Number(day)}일`;
141
- }
142
-
143
- if (localeKey === "jp") {
144
- return `${year}年${Number(month)}月${Number(day)}日`;
145
- }
146
-
147
- return new Intl.DateTimeFormat(toIntlLocale(localeKey), {
148
- timeZone,
149
- year: "numeric",
150
- month: "long",
151
- day: "numeric",
152
- }).format(date);
136
+ const localeKey = normalizeAppLocale(locale);
137
+ const { year, month, day } = getDateParts(date, timeZone);
138
+
139
+ if (localeKey === "kr") {
140
+ return `${year}년 ${Number(month)}월 ${Number(day)}일`;
141
+ }
142
+
143
+ if (localeKey === "jp") {
144
+ return `${year}年${Number(month)}月${Number(day)}日`;
145
+ }
146
+
147
+ return new Intl.DateTimeFormat(toIntlLocale(localeKey), {
148
+ timeZone,
149
+ year: "numeric",
150
+ month: "long",
151
+ day: "numeric",
152
+ }).format(date);
153
153
  }
154
154
 
155
155
  export function formatDotDate(date: Date, timeZone?: string): string {
156
- const { year, month, day } = getDateParts(date, timeZone);
157
- return `${year}. ${Number(month)}. ${Number(day)}.`;
156
+ const { year, month, day } = getDateParts(date, timeZone);
157
+ return `${year}. ${Number(month)}. ${Number(day)}.`;
158
158
  }
159
159
 
160
160
  export function format24HourTime(
161
- date: Date,
162
- options?: {
163
- includeSeconds?: boolean;
164
- timeZone?: string;
165
- },
161
+ date: Date,
162
+ options?: {
163
+ includeSeconds?: boolean;
164
+ timeZone?: string;
165
+ },
166
166
  ): string {
167
- const { includeSeconds = false, timeZone } = options ?? {};
168
- const { hour, minute, second } = getTimeParts(date, timeZone);
169
- return includeSeconds ? `${hour}:${minute}:${second}` : `${hour}:${minute}`;
167
+ const { includeSeconds = false, timeZone } = options ?? {};
168
+ const { hour, minute, second } = getTimeParts(date, timeZone);
169
+ return includeSeconds ? `${hour}:${minute}:${second}` : `${hour}:${minute}`;
170
170
  }
171
171
 
172
172
  export function formatKoreanTime(date: Date, timeZone?: string): string {
173
- const { hour, minute } = getTimeParts(date, timeZone);
174
- const hourNumber = Number(hour);
175
- const meridiem = hourNumber < 12 ? "오전" : "오후";
176
- const hour12 = hourNumber % 12 === 0 ? 12 : hourNumber % 12;
177
- return `${meridiem} ${hour12}시 ${minute}분`;
173
+ const { hour, minute } = getTimeParts(date, timeZone);
174
+ const hourNumber = Number(hour);
175
+ const meridiem = hourNumber < 12 ? "오전" : "오후";
176
+ const hour12 = hourNumber % 12 === 0 ? 12 : hourNumber % 12;
177
+ return `${meridiem} ${hour12}시 ${minute}분`;
178
178
  }
179
179
 
180
180
  export function formatTwelveHourTime(
181
- date: Date,
182
- locale?: string,
183
- timeZone?: string,
181
+ date: Date,
182
+ locale?: string,
183
+ timeZone?: string,
184
184
  ): string {
185
- const localeKey = normalizeAppLocale(locale);
186
- if (localeKey === "kr") {
187
- const { hour, minute } = getTimeParts(date, timeZone);
188
- const hourNumber = Number(hour);
189
- const meridiem = hourNumber < 12 ? "오전" : "오후";
190
- const hour12 = hourNumber % 12 === 0 ? 12 : hourNumber % 12;
191
- return `${meridiem} ${hour12}:${minute}`;
192
- }
193
-
194
- return new Intl.DateTimeFormat(toIntlLocale(localeKey), {
195
- timeZone,
196
- hour: "numeric",
197
- minute: "2-digit",
198
- hour12: true,
199
- }).format(date);
185
+ const localeKey = normalizeAppLocale(locale);
186
+ if (localeKey === "kr") {
187
+ const { hour, minute } = getTimeParts(date, timeZone);
188
+ const hourNumber = Number(hour);
189
+ const meridiem = hourNumber < 12 ? "오전" : "오후";
190
+ const hour12 = hourNumber % 12 === 0 ? 12 : hourNumber % 12;
191
+ return `${meridiem} ${hour12}:${minute}`;
192
+ }
193
+
194
+ return new Intl.DateTimeFormat(toIntlLocale(localeKey), {
195
+ timeZone,
196
+ hour: "numeric",
197
+ minute: "2-digit",
198
+ hour12: true,
199
+ }).format(date);
200
200
  }
201
201
 
202
202
  export function formatRelativeText(
203
- diffMs: number,
204
- locale?: string,
203
+ diffMs: number,
204
+ locale?: string,
205
205
  ): { text: string; isRelative: boolean } {
206
- const localeKey = normalizeAppLocale(locale);
207
- const safeDiffMs = Math.max(0, diffMs);
208
- const seconds = Math.max(1, Math.floor(safeDiffMs / 1000));
209
- const minutes = Math.max(1, Math.floor(safeDiffMs / (60 * 1000)));
210
- const hours = Math.max(1, Math.floor(safeDiffMs / (60 * 60 * 1000)));
211
- const days = Math.max(1, Math.floor(safeDiffMs / (24 * 60 * 60 * 1000)));
212
-
213
- if (safeDiffMs < 60 * 1000) {
214
- return {
215
- text: formatRelativeUnit(seconds, "second", localeKey),
216
- isRelative: true,
217
- };
218
- }
219
-
220
- if (safeDiffMs < 60 * 60 * 1000) {
221
- return {
222
- text: formatRelativeUnit(minutes, "minute", localeKey),
223
- isRelative: true,
224
- };
225
- }
226
-
227
- if (safeDiffMs < 24 * 60 * 60 * 1000) {
228
- return {
229
- text: formatRelativeUnit(hours, "hour", localeKey),
230
- isRelative: true,
231
- };
232
- }
233
-
234
- return { text: formatRelativeUnit(days, "day", localeKey), isRelative: true };
206
+ const localeKey = normalizeAppLocale(locale);
207
+ const safeDiffMs = Math.max(0, diffMs);
208
+ const seconds = Math.max(1, Math.floor(safeDiffMs / 1000));
209
+ const minutes = Math.max(1, Math.floor(safeDiffMs / (60 * 1000)));
210
+ const hours = Math.max(1, Math.floor(safeDiffMs / (60 * 60 * 1000)));
211
+ const days = Math.max(1, Math.floor(safeDiffMs / (24 * 60 * 60 * 1000)));
212
+
213
+ if (safeDiffMs < 60 * 1000) {
214
+ return {
215
+ text: formatRelativeUnit(seconds, "second", localeKey),
216
+ isRelative: true,
217
+ };
218
+ }
219
+
220
+ if (safeDiffMs < 60 * 60 * 1000) {
221
+ return {
222
+ text: formatRelativeUnit(minutes, "minute", localeKey),
223
+ isRelative: true,
224
+ };
225
+ }
226
+
227
+ if (safeDiffMs < 24 * 60 * 60 * 1000) {
228
+ return {
229
+ text: formatRelativeUnit(hours, "hour", localeKey),
230
+ isRelative: true,
231
+ };
232
+ }
233
+
234
+ return { text: formatRelativeUnit(days, "day", localeKey), isRelative: true };
235
235
  }
236
236
 
237
237
  export function formatRemainingText(
238
- diffMs: number,
239
- locale?: string,
240
- options?: {
241
- includeSuffix?: boolean;
242
- },
238
+ diffMs: number,
239
+ locale?: string,
240
+ options?: {
241
+ includeSuffix?: boolean;
242
+ },
243
243
  ): string {
244
- const localeKey = normalizeAppLocale(locale);
245
- const includeSuffix = options?.includeSuffix ?? true;
246
- const safeDiffMs = Math.max(0, diffMs);
247
-
248
- if (safeDiffMs === 0) {
249
- if (localeKey === "kr") return "마감됨";
250
- if (localeKey === "jp") return "締切終了";
251
- return "Closed";
252
- }
253
-
254
- const units = [
255
- { label: "day", value: 24 * 60 * 60 * 1000 },
256
- { label: "hour", value: 60 * 60 * 1000 },
257
- { label: "minute", value: 60 * 1000 },
258
- { label: "second", value: 1000 },
259
- ] as const;
260
-
261
- const parts: string[] = [];
262
- let remaining = safeDiffMs;
263
-
264
- for (const unit of units) {
265
- const count = Math.floor(remaining / unit.value);
266
- remaining -= count * unit.value;
267
-
268
- if (count > 0) {
269
- parts.push(formatRemainingUnit(count, unit.label, localeKey));
270
- }
271
-
272
- if (parts.length >= 2) break;
273
- }
274
-
275
- if (parts.length === 0) {
276
- parts.push(formatRemainingUnit(1, "second", localeKey));
277
- }
278
-
279
- if (!includeSuffix) {
280
- return parts.join(localeKey === "jp" ? "" : " ");
281
- }
282
-
283
- if (localeKey === "kr") {
284
- return `${parts.join(" ")} 남음`;
285
- }
286
-
287
- if (localeKey === "jp") {
288
- return `あと${parts.join("")}`;
289
- }
290
-
291
- return `${parts.join(" ")} left`;
244
+ const localeKey = normalizeAppLocale(locale);
245
+ const includeSuffix = options?.includeSuffix ?? true;
246
+ const safeDiffMs = Math.max(0, diffMs);
247
+
248
+ if (safeDiffMs === 0) {
249
+ if (localeKey === "kr") return "마감됨";
250
+ if (localeKey === "jp") return "締切終了";
251
+ return "Closed";
252
+ }
253
+
254
+ const units = [
255
+ { label: "day", value: 24 * 60 * 60 * 1000 },
256
+ { label: "hour", value: 60 * 60 * 1000 },
257
+ { label: "minute", value: 60 * 1000 },
258
+ { label: "second", value: 1000 },
259
+ ] as const;
260
+
261
+ const parts: string[] = [];
262
+ let remaining = safeDiffMs;
263
+
264
+ for (const unit of units) {
265
+ const count = Math.floor(remaining / unit.value);
266
+ remaining -= count * unit.value;
267
+
268
+ if (count > 0) {
269
+ parts.push(formatRemainingUnit(count, unit.label, localeKey));
270
+ }
271
+
272
+ if (parts.length >= 2) break;
273
+ }
274
+
275
+ if (parts.length === 0) {
276
+ parts.push(formatRemainingUnit(1, "second", localeKey));
277
+ }
278
+
279
+ if (!includeSuffix) {
280
+ return parts.join(localeKey === "jp" ? "" : " ");
281
+ }
282
+
283
+ if (localeKey === "kr") {
284
+ return `${parts.join(" ")} 남음`;
285
+ }
286
+
287
+ if (localeKey === "jp") {
288
+ return `あと${parts.join("")}`;
289
+ }
290
+
291
+ return `${parts.join(" ")} left`;
292
292
  }
293
293
 
294
294
  function formatRelativeUnit(
295
- count: number,
296
- unit: "second" | "minute" | "hour" | "day",
297
- locale: AppLocale,
295
+ count: number,
296
+ unit: "second" | "minute" | "hour" | "day",
297
+ locale: AppLocale,
298
298
  ): string {
299
- if (locale === "kr") {
300
- const suffix =
301
- unit === "second"
302
- ? "초"
303
- : unit === "minute"
304
- ? "분"
305
- : unit === "hour"
306
- ? "시간"
307
- : "일";
308
- return `${count}${suffix} 전`;
309
- }
310
-
311
- if (locale === "jp") {
312
- const suffix =
313
- unit === "second"
314
- ? "秒"
315
- : unit === "minute"
316
- ? "分"
317
- : unit === "hour"
318
- ? "時間"
319
- : "日";
320
- return `${count}${suffix}前`;
321
- }
322
-
323
- const englishUnit = count === 1 ? unit : `${unit}s`;
324
- return `${count} ${englishUnit} ago`;
299
+ if (locale === "kr") {
300
+ const suffix =
301
+ unit === "second"
302
+ ? "초"
303
+ : unit === "minute"
304
+ ? "분"
305
+ : unit === "hour"
306
+ ? "시간"
307
+ : "일";
308
+ return `${count}${suffix} 전`;
309
+ }
310
+
311
+ if (locale === "jp") {
312
+ const suffix =
313
+ unit === "second"
314
+ ? "秒"
315
+ : unit === "minute"
316
+ ? "分"
317
+ : unit === "hour"
318
+ ? "時間"
319
+ : "日";
320
+ return `${count}${suffix}前`;
321
+ }
322
+
323
+ const englishUnit = count === 1 ? unit : `${unit}s`;
324
+ return `${count} ${englishUnit} ago`;
325
325
  }
326
326
 
327
327
  function formatRemainingUnit(
328
- count: number,
329
- unit: "second" | "minute" | "hour" | "day",
330
- locale: AppLocale,
328
+ count: number,
329
+ unit: "second" | "minute" | "hour" | "day",
330
+ locale: AppLocale,
331
331
  ): string {
332
- if (locale === "kr") {
333
- const suffix =
334
- unit === "second"
335
- ? "초"
336
- : unit === "minute"
337
- ? "분"
338
- : unit === "hour"
339
- ? "시간"
340
- : "일";
341
- return `${count}${suffix}`;
342
- }
343
-
344
- if (locale === "jp") {
345
- const suffix =
346
- unit === "second"
347
- ? "秒"
348
- : unit === "minute"
349
- ? "分"
350
- : unit === "hour"
351
- ? "時間"
352
- : "日";
353
- return `${count}${suffix}`;
354
- }
355
-
356
- const englishUnit = count === 1 ? unit : `${unit}s`;
357
- return `${count} ${englishUnit}`;
332
+ if (locale === "kr") {
333
+ const suffix =
334
+ unit === "second"
335
+ ? "초"
336
+ : unit === "minute"
337
+ ? "분"
338
+ : unit === "hour"
339
+ ? "시간"
340
+ : "일";
341
+ return `${count}${suffix}`;
342
+ }
343
+
344
+ if (locale === "jp") {
345
+ const suffix =
346
+ unit === "second"
347
+ ? "秒"
348
+ : unit === "minute"
349
+ ? "分"
350
+ : unit === "hour"
351
+ ? "時間"
352
+ : "日";
353
+ return `${count}${suffix}`;
354
+ }
355
+
356
+ const englishUnit = count === 1 ? unit : `${unit}s`;
357
+ return `${count} ${englishUnit}`;
358
358
  }