返回 AiToEarn
calendarFestival.utils.ts
根目录 / project / aitoearn-web / src / app / [lng] / accounts / components / CalendarTiming / calendarFestival.utils.ts
1 import chineseDaysData from 'chinese-days/dist/chinese-days.json'
2 import dayjs from 'dayjs'
3 import { isChineseLanguage } from '@/app/i18n/languageConfig'
4 import bmcxCalendarEvents2026Data from './data/china-calendar-events-2026.json'
5 import lunarSolarEventsData from './data/china-lunar-solar-events.json'
6
7 export type CalendarFestivalType = 'holiday' | 'workday' | 'traditional' | 'solarTerm' | 'solarFestival'
8
9 export interface CalendarFestivalInfo {
10 id?: string
11 type: CalendarFestivalType
12 nameKey?: string
13 shortNameKey?: string
14 name?: string
15 shortName?: string
16 statusKey: string
17 statusTitleKey: string
18 date?: string
19 lunarDateText?: string
20 weekday?: string
21 description?: string
22 imageUrl?: string
23 important?: boolean
24 display?: boolean
25 isHoliday: boolean
26 isWorkday: boolean
27 isInLieu: boolean
28 }
29
30 export interface CalendarLunarInfo {
31 month: number
32 day: number
33 isLeap: boolean
34 monthKey: string
35 dayKey: string
36 leapPrefixKey: string
37 titleKey: string
38 }
39
40 type CalendarFestivalKey
41 = | 'newYear'
42 | 'springFestival'
43 | 'qingming'
44 | 'labourDay'
45 | 'dragonBoat'
46 | 'midAutumn'
47 | 'nationalDay'
48
49 interface ChinaHolidayData {
50 holidays: Record<string, string>
51 workdays: Record<string, string>
52 inLieuDays: Record<string, string>
53 }
54
55 interface ChinaLunarDateItem {
56 month: number
57 day: number
58 isLeap: boolean
59 }
60
61 interface ChinaLunarSolarDateItem {
62 lunar: ChinaLunarDateItem
63 traditional: string[]
64 solarTerms: string[]
65 }
66
67 interface ChinaLunarSolarEventsData {
68 startYear: number
69 endYear: number
70 dates: Record<string, ChinaLunarSolarDateItem>
71 }
72
73 interface BmcxCalendarEventItem {
74 id: string
75 name: string
76 shortName: string
77 type: 'solarFestival' | 'traditional' | 'solarTerm'
78 lunarMonth?: number
79 lunarDay?: number
80 important: boolean
81 display: boolean
82 imageUrl?: string
83 description?: string
84 }
85
86 interface BmcxCalendarEventsData {
87 baseYear: number
88 solarFestivals: Record<string, BmcxCalendarEventItem[]>
89 lunarFestivals: Record<string, BmcxCalendarEventItem[]>
90 solarTerms: Record<string, BmcxCalendarEventItem[]>
91 }
92
93 const chinaHolidayData: ChinaHolidayData = chineseDaysData
94 const chinaLunarSolarEventsData: ChinaLunarSolarEventsData = lunarSolarEventsData
95 const bmcxCalendarEventsData = bmcxCalendarEvents2026Data as BmcxCalendarEventsData
96 const bmcxCalendarEventNameMap = [
97 ...Object.values(bmcxCalendarEventsData.solarFestivals).flat(),
98 ...Object.values(bmcxCalendarEventsData.lunarFestivals).flat(),
99 ...Object.values(bmcxCalendarEventsData.solarTerms).flat(),
100 ].reduce<Record<string, BmcxCalendarEventItem>>(
101 (result, items) => {
102 result[normalizeFestivalName(items.name)] ??= items
103
104 return result
105 },
106 {},
107 )
108
109 const HOLIDAY_NAME_KEY_MAP: Record<string, CalendarFestivalKey> = {
110 'New Year\'s Day': 'newYear',
111 'Spring Festival': 'springFestival',
112 'Tomb-sweeping Day': 'qingming',
113 'Labour Day': 'labourDay',
114 'Dragon Boat Festival': 'dragonBoat',
115 'Mid-autumn Festival': 'midAutumn',
116 'National Day': 'nationalDay',
117 }
118
119 const HOLIDAY_CHINESE_NAME_MAP: Record<CalendarFestivalKey, string[]> = {
120 newYear: ['元旦'],
121 springFestival: ['春节'],
122 qingming: ['清明', '清明节'],
123 labourDay: ['劳动节'],
124 dragonBoat: ['端午节'],
125 midAutumn: ['中秋节'],
126 nationalDay: ['国庆节'],
127 }
128
129 const FIXED_SOLAR_LEGAL_FESTIVAL_KEY_MAP: Record<string, CalendarFestivalKey> = {
130 '01-01': 'newYear',
131 '05-01': 'labourDay',
132 '10-01': 'nationalDay',
133 }
134
135 const TRADITIONAL_CHINESE_NAME_MAP: Record<string, string[]> = {
136 laba: ['腊八节'],
137 xiaonian: ['北方小年', '南方小年', '小年'],
138 chuxi: ['除夕'],
139 springFestival: ['春节', '农历新年'],
140 lanternFestival: ['元宵节'],
141 dragonHeadsRaising: ['龙抬头'],
142 shangsi: ['上巳节'],
143 dragonBoat: ['端午节', '端午'],
144 qixi: ['七夕节', '七夕情人节', '七夕'],
145 zhongyuan: ['中元节'],
146 midAutumn: ['中秋节'],
147 doubleNinth: ['重阳节'],
148 hanYi: ['寒衣节'],
149 xiayuan: ['下元节'],
150 }
151
152 const SOLAR_TERM_CHINESE_NAME_MAP: Record<string, string[]> = {
153 lesser_cold: ['小寒', '小寒节气'],
154 greater_cold: ['大寒', '大寒节气'],
155 the_beginning_of_spring: ['立春', '立春节气'],
156 rain_water: ['雨水', '雨水节气'],
157 the_waking_of_insects: ['惊蛰', '惊蛰节气'],
158 the_spring_equinox: ['春分', '春分节气'],
159 pure_brightness: ['清明', '清明节', '清明节气'],
160 grain_rain: ['谷雨', '谷雨节气'],
161 the_beginning_of_summer: ['立夏', '立夏节气'],
162 lesser_fullness_of_grain: ['小满', '小满节气'],
163 grain_in_beard: ['芒种', '芒种节气'],
164 the_summer_solstice: ['夏至', '夏至节气'],
165 lesser_heat: ['小暑', '小暑节气'],
166 greater_heat: ['大暑', '大暑节气'],
167 the_beginning_of_autumn: ['立秋', '立秋节气'],
168 the_end_of_heat: ['处暑', '处暑节气'],
169 white_dew: ['白露', '白露节气'],
170 the_autumn_equinox: ['秋分', '秋分节气'],
171 code_dew: ['寒露', '寒露节气'],
172 frost_descent: ['霜降', '霜降节气'],
173 the_beginning_of_winter: ['立冬', '立冬节气'],
174 lesser_snow: ['小雪', '小雪节气'],
175 greater_snow: ['大雪', '大雪节气'],
176 the_winter_solstice: ['冬至', '冬至节气'],
177 }
178
179 type TranslateText = (key: string) => string
180
181 export function getCalendarFestivalName(festival: CalendarFestivalInfo, t: TranslateText, compact = false) {
182 if (compact && festival.shortName) {
183 return festival.shortName
184 }
185
186 if (!compact && festival.name) {
187 return festival.name
188 }
189
190 const key = compact
191 ? festival.shortNameKey ?? festival.nameKey
192 : festival.nameKey ?? festival.shortNameKey
193
194 return key ? t(key) : ''
195 }
196
197 export function getCalendarFestivalTitle(festival: CalendarFestivalInfo, t: TranslateText) {
198 const name = getCalendarFestivalName(festival, t)
199 return `${name} · ${t(festival.statusTitleKey)}`
200 }
201
202 export function getCalendarFestivalTabValue(festival: CalendarFestivalInfo, index: number) {
203 return festival.id ?? `${festival.type}-${festival.nameKey ?? festival.name ?? festival.shortName ?? index}-${index}`
204 }
205
206 function getFestivalKey(name: string) {
207 const [englishName] = name.split(',')
208 return HOLIDAY_NAME_KEY_MAP[englishName]
209 }
210
211 function normalizeFestivalName(name: string) {
212 return name
213 .trim()
214 .replace(/节气$/u, '')
215 }
216
217 function findBmcxCalendarEvent(names: string[]) {
218 const nameSet = new Set(names.map(normalizeFestivalName))
219
220 for (const name of nameSet) {
221 const fallbackEvent = bmcxCalendarEventNameMap[name]
222
223 if (fallbackEvent) {
224 return fallbackEvent
225 }
226 }
227
228 return undefined
229 }
230
231 function getDateKey(dateStr: string) {
232 return dateStr.slice(5)
233 }
234
235 function getLunarDateKey(lunar?: ChinaLunarDateItem) {
236 if (!lunar) {
237 return ''
238 }
239
240 return `${String(lunar.month).padStart(2, '0')}-${String(lunar.day).padStart(2, '0')}`
241 }
242
243 function getCurrentDateBmcxEvents(dateStr: string, lunarSolarEvent?: ChinaLunarSolarDateItem) {
244 const result: BmcxCalendarEventItem[] = []
245 const eventIds = new Set<string>()
246
247 const appendItems = (items?: BmcxCalendarEventItem[]) => {
248 for (const item of items ?? []) {
249 if (eventIds.has(item.id)) {
250 continue
251 }
252
253 eventIds.add(item.id)
254 result.push(item)
255 }
256 }
257
258 appendItems(bmcxCalendarEventsData.solarFestivals[getDateKey(dateStr)])
259
260 const lunarDateKey = getLunarDateKey(lunarSolarEvent?.lunar)
261 if (lunarDateKey) {
262 appendItems(bmcxCalendarEventsData.lunarFestivals[lunarDateKey])
263 }
264
265 for (const solarTermKey of lunarSolarEvent?.solarTerms ?? []) {
266 for (const name of SOLAR_TERM_CHINESE_NAME_MAP[solarTermKey] ?? []) {
267 appendItems(bmcxCalendarEventsData.solarTerms[normalizeFestivalName(name)])
268 }
269 }
270
271 return result
272 }
273
274 function appendChineseNames(result: Set<string>, names?: string[]) {
275 names?.forEach(name => result.add(normalizeFestivalName(name)))
276 }
277
278 function withBmcxMetadata(event: CalendarFestivalInfo, metadata?: BmcxCalendarEventItem): CalendarFestivalInfo {
279 if (!metadata) {
280 return event
281 }
282
283 return {
284 ...event,
285 id: event.id ?? metadata.id,
286 description: metadata.description,
287 imageUrl: metadata.imageUrl,
288 important: metadata.important,
289 display: metadata.display,
290 }
291 }
292
293 function toBmcxCalendarFestivalInfo(item: BmcxCalendarEventItem, dateStr: string): CalendarFestivalInfo {
294 return {
295 id: item.id,
296 type: item.type,
297 name: item.name,
298 shortName: item.shortName,
299 statusKey: item.type === 'solarTerm'
300 ? 'calendar.chinaHolidays.status.solarTerm'
301 : item.type === 'traditional'
302 ? 'calendar.chinaHolidays.status.traditional'
303 : 'calendar.chinaHolidays.status.solarFestival',
304 statusTitleKey: item.type === 'solarTerm'
305 ? 'calendar.chinaHolidays.statusTitle.solarTerm'
306 : item.type === 'traditional'
307 ? 'calendar.chinaHolidays.statusTitle.traditional'
308 : 'calendar.chinaHolidays.statusTitle.solarFestival',
309 date: dateStr,
310 description: item.description,
311 imageUrl: item.imageUrl,
312 important: item.important,
313 display: item.display,
314 isHoliday: false,
315 isWorkday: false,
316 isInLieu: false,
317 }
318 }
319
320 function getAdditionalBmcxCalendarEvents(
321 dateStr: string,
322 lunarSolarEvent: ChinaLunarSolarDateItem | undefined,
323 existingNames: Set<string>,
324 ) {
325 return getCurrentDateBmcxEvents(dateStr, lunarSolarEvent)
326 .filter(item => !existingNames.has(normalizeFestivalName(item.name)))
327 .map(item => toBmcxCalendarFestivalInfo(item, dateStr))
328 }
329
330 function canShowChinaCalendarInfo(date: dayjs.ConfigType, lng?: string) {
331 if ((!lng || !isChineseLanguage(lng)) || !date) {
332 return false
333 }
334
335 return true
336 }
337
338 function getFallbackLegalFestivalKey(dateStr: string, lunarSolarEvent?: ChinaLunarSolarDateItem) {
339 const fixedSolarFestivalKey = FIXED_SOLAR_LEGAL_FESTIVAL_KEY_MAP[getDateKey(dateStr)]
340
341 if (fixedSolarFestivalKey) {
342 return fixedSolarFestivalKey
343 }
344
345 if (lunarSolarEvent?.traditional.includes('springFestival')) {
346 return 'springFestival'
347 }
348
349 if (lunarSolarEvent?.traditional.includes('dragonBoat')) {
350 return 'dragonBoat'
351 }
352
353 if (lunarSolarEvent?.traditional.includes('midAutumn')) {
354 return 'midAutumn'
355 }
356
357 if (lunarSolarEvent?.solarTerms.includes('pure_brightness')) {
358 return 'qingming'
359 }
360
361 return undefined
362 }
363
364 function getLegalCalendarEvent(dateStr: string, lunarSolarEvent?: ChinaLunarSolarDateItem) {
365 const holidayName = chinaHolidayData.holidays[dateStr]
366 const workdayName = chinaHolidayData.workdays[dateStr]
367 const fallbackFestivalKey = getFallbackLegalFestivalKey(dateStr, lunarSolarEvent)
368 const festivalKey = getFestivalKey(holidayName || workdayName || '') ?? fallbackFestivalKey
369
370 if (!festivalKey) {
371 return null
372 }
373
374 const holiday = Boolean(holidayName || (!workdayName && fallbackFestivalKey))
375 const workday = Boolean(workdayName)
376
377 if (!holiday && !workday) {
378 return null
379 }
380
381 return {
382 event: withBmcxMetadata({
383 id: `legal-${dateStr}-${festivalKey}`,
384 type: workday ? 'workday' : 'holiday',
385 nameKey: `calendar.chinaHolidays.festivals.${festivalKey}`,
386 shortNameKey: `calendar.chinaHolidays.festivalShortNames.${festivalKey}`,
387 statusKey: workday
388 ? 'calendar.chinaHolidays.status.workday'
389 : 'calendar.chinaHolidays.status.holiday',
390 statusTitleKey: workday
391 ? 'calendar.chinaHolidays.statusTitle.adjustedWorkday'
392 : chinaHolidayData.inLieuDays[dateStr]
393 ? 'calendar.chinaHolidays.statusTitle.adjustedHoliday'
394 : 'calendar.chinaHolidays.statusTitle.holiday',
395 isHoliday: holiday,
396 isWorkday: workday,
397 isInLieu: Boolean(chinaHolidayData.inLieuDays[dateStr]),
398 display: true,
399 } satisfies CalendarFestivalInfo, findBmcxCalendarEvent(HOLIDAY_CHINESE_NAME_MAP[festivalKey])),
400 festivalKey,
401 }
402 }
403
404 export function getChinaCalendarEvents(date: dayjs.ConfigType, lng?: string): CalendarFestivalInfo[] {
405 if (!canShowChinaCalendarInfo(date, lng)) {
406 return []
407 }
408
409 const dateStr = dayjs(date).format('YYYY-MM-DD')
410 const events: CalendarFestivalInfo[] = []
411 const lunarSolarEvent = chinaLunarSolarEventsData.dates[dateStr]
412 const legalEvent = getLegalCalendarEvent(dateStr, lunarSolarEvent)
413 const existingNames = new Set<string>()
414
415 if (legalEvent) {
416 events.push(legalEvent.event)
417 appendChineseNames(existingNames, HOLIDAY_CHINESE_NAME_MAP[legalEvent.festivalKey])
418 }
419
420 if (!lunarSolarEvent) {
421 return events.concat(getAdditionalBmcxCalendarEvents(dateStr, undefined, existingNames))
422 }
423
424 for (const traditionalKey of lunarSolarEvent.traditional) {
425 appendChineseNames(existingNames, TRADITIONAL_CHINESE_NAME_MAP[traditionalKey])
426
427 if (traditionalKey === legalEvent?.festivalKey) {
428 continue
429 }
430
431 events.push(withBmcxMetadata({
432 id: `traditional-${dateStr}-${traditionalKey}`,
433 type: 'traditional',
434 nameKey: `calendar.chinaHolidays.traditional.${traditionalKey}`,
435 shortNameKey: `calendar.chinaHolidays.traditionalShortNames.${traditionalKey}`,
436 statusKey: 'calendar.chinaHolidays.status.traditional',
437 statusTitleKey: 'calendar.chinaHolidays.statusTitle.traditional',
438 isHoliday: false,
439 isWorkday: false,
440 isInLieu: false,
441 display: true,
442 }, findBmcxCalendarEvent(TRADITIONAL_CHINESE_NAME_MAP[traditionalKey] ?? [])))
443 }
444
445 for (const solarTermKey of lunarSolarEvent.solarTerms) {
446 appendChineseNames(existingNames, SOLAR_TERM_CHINESE_NAME_MAP[solarTermKey])
447
448 if (legalEvent?.festivalKey === 'qingming' && solarTermKey === 'pure_brightness') {
449 continue
450 }
451
452 events.push(withBmcxMetadata({
453 id: `solar-term-${dateStr}-${solarTermKey}`,
454 type: 'solarTerm',
455 nameKey: `calendar.chinaHolidays.solarTerms.${solarTermKey}`,
456 shortNameKey: `calendar.chinaHolidays.solarTermShortNames.${solarTermKey}`,
457 statusKey: 'calendar.chinaHolidays.status.solarTerm',
458 statusTitleKey: 'calendar.chinaHolidays.statusTitle.solarTerm',
459 isHoliday: false,
460 isWorkday: false,
461 isInLieu: false,
462 display: true,
463 }, findBmcxCalendarEvent(SOLAR_TERM_CHINESE_NAME_MAP[solarTermKey] ?? [])))
464 }
465
466 return events.concat(getAdditionalBmcxCalendarEvents(dateStr, lunarSolarEvent, existingNames))
467 }
468
469 export function getChinaCalendarDay(date: dayjs.ConfigType, lng?: string): CalendarFestivalInfo | null {
470 return getChinaCalendarEvents(date, lng)[0] ?? null
471 }
472
473 export function filterCalendarFestivalEvents(
474 festivals: CalendarFestivalInfo[],
475 options: { showSolarFestivals: boolean, showSolarTerms: boolean },
476 ) {
477 return festivals.filter((festival) => {
478 if (festival.type === 'solarFestival') {
479 return options.showSolarFestivals
480 }
481
482 if (festival.type === 'solarTerm') {
483 return options.showSolarTerms
484 }
485
486 return true
487 })
488 }
489
490 export function getChinaCalendarLunarInfo(date: dayjs.ConfigType, lng?: string): CalendarLunarInfo | null {
491 if (!canShowChinaCalendarInfo(date, lng)) {
492 return null
493 }
494
495 const dateStr = dayjs(date).format('YYYY-MM-DD')
496 const lunar = chinaLunarSolarEventsData.dates[dateStr]?.lunar
497
498 if (!lunar) {
499 return null
500 }
501
502 return {
503 ...lunar,
504 monthKey: `calendar.chinaHolidays.lunarMonths.m${lunar.month}`,
505 dayKey: `calendar.chinaHolidays.lunarDays.d${lunar.day}`,
506 leapPrefixKey: 'calendar.chinaHolidays.lunar.leapPrefix',
507 titleKey: 'calendar.chinaHolidays.lunar.title',
508 }
509 }
510
510 lines TYPESCRIPT