diff --git a/frontend/src/utils/datetime.ts b/frontend/src/utils/datetime.ts index 3dee2cb..a01aae1 100644 --- a/frontend/src/utils/datetime.ts +++ b/frontend/src/utils/datetime.ts @@ -1,3 +1,5 @@ +import { lcm, format } from "./utils"; +import { universalGetDayOfWeek } from "./i18n"; // YYC MARK: // This file is synchronized with "dt.py". @@ -30,16 +32,366 @@ const PRECOMPILED_LOOP_STOP_RULES = { // region: Core Functions -export function resolveLoopRules4UI(strl: string) { +/** Year loop: [type=0, isStrict, yearSpan] */ +type YearLoopRule = [0, boolean, number]; +/** Month loop: [type=1, isStrict, mode, monthSpan] */ +type MonthLoopRule = [1, boolean, 'A' | 'B' | 'C' | 'D', number]; +/** Week loop: [type=2, 7 weekday booleans, weekSpan] */ +type WeekLoopRule = [2, boolean, boolean, boolean, boolean, boolean, boolean, boolean, number]; +/** Day loop: [type=3, daySpan] */ +type DayLoopRule = [3, number]; +type LoopRule = YearLoopRule | MonthLoopRule | WeekLoopRule | DayLoopRule; + +/** Infinity stop: [type=0] */ +type InfinityStopRule = [0]; +/** Datetime stop: [type=1, timestamp] */ +type DatetimeStopRule = [1, number]; +/** Times stop: [type=2, times] */ +type TimesStopRule = [2, number]; + +type LoopStopRule = InfinityStopRule | DatetimeStopRule | TimesStopRule; + +/** + * Parses a raw loop rule string (e.g. "YS2-F") into structured rule arrays + * for UI display purposes. + * @param strl - The loop rule string in format "loopRule-stopRule". + * @returns A tuple of [LoopRule, LoopStopRule] on success, or undefined + * when the string is empty, malformed, or contains no valid rule. + */ +export function resolveLoopRules4UI(strl: string): [LoopRule, LoopStopRule] | undefined { + if (strl == '') return undefined; + + let sp = strl.split('-'); + if (sp.length != 2) return undefined; + let loopRules: LoopRule | undefined = undefined; + let loopStopRules: LoopStopRule | undefined = undefined; + + let match: RegExpExecArray | null; + if ((match = PRECOMPILED_LOOP_RULES.year.exec(sp[0]!)) !== null) { + loopRules = [0, match[1]! == 'S', parseInt(match[2]!)]; + } else if ((match = PRECOMPILED_LOOP_RULES.month.exec(sp[0]!)) !== null) { + loopRules = [1, match[1]! == 'S', match[2]! as 'A' | 'B' | 'C' | 'D', parseInt(match[3]!)]; + } else if ((match = PRECOMPILED_LOOP_RULES.week.exec(sp[0]!)) !== null) { + const w = match[1]!; + loopRules = [2, w[0] == 'T', w[1] == 'T', w[2] == 'T', w[3] == 'T', w[4] == 'T', w[5] == 'T', w[6] == 'T', parseInt(match[2]!)] as WeekLoopRule; + } else if ((match = PRECOMPILED_LOOP_RULES.day.exec(sp[0]!)) !== null) { + loopRules = [3, parseInt(match[1]!)]; + } else return undefined; + + if ((match = PRECOMPILED_LOOP_STOP_RULES.infinity.exec(sp[1]!)) !== null) { + loopStopRules = [0]; + } else if ((match = PRECOMPILED_LOOP_STOP_RULES.datetime.exec(sp[1]!)) !== null) { + loopStopRules = [1, parseInt(match[1]!)]; + } else if ((match = PRECOMPILED_LOOP_STOP_RULES.times.exec(sp[1]!)) !== null) { + loopStopRules = [2, parseInt(match[1]!)]; + } else return undefined; + + return [loopRules, loopStopRules]; } -export function resolveLoopRules2Event(loopRules: string, loopDateTimeStart: number, loopDateTimeEnd: number, eventDateTimeStart: number, eventDateTimeEnd: number, timezoneOffset: number, clampStartDateTime: number) { +/** A pair of [start, end] timestamps (in minutes) representing a single event occurrence. */ +type TimeRange = [number, number]; +/** + * Resolves a loop rule string into concrete event date ranges by expanding + * the loop pattern across the detection window. + * @remark loopDateTimeStart's value does not correspond with the database; + * it is calculated by the program and should point to the closet + * potential event start datetime. + * @remark loopDateTimeEnd is performed like loopDateTimeStart and + * it indicates the possible time point of the tail of legal event. + * @remark clampStartDateTime is the real clamp datetime of event start + * datetime. + * @remark loopDateTimeStart is the start datetime for detection. + * @remark In this section, all times should be analysed with + * `Date((time + timezoneOffset) * 60000)` and use `.getUTC...()` + * functions. + * @param loopRules - The loop rule string (e.g. "YS2-F"). + * @param loopDateTimeStart - Start of the detection window (minutes). + * @param loopDateTimeEnd - End of the detection window (minutes). + * @param eventDateTimeStart - Original event start datetime (minutes). + * @param eventDateTimeEnd - Original event end datetime (minutes). + * @param timezoneOffset - Timezone offset in minutes. + * @param clampStartDateTime - Earliest allowed start datetime (minutes). + * @returns An array of [start, end] timestamp pairs representing every + * occurrence within the window, or undefined if the rule is invalid. + */ +export function resolveLoopRules2Event( + fullLoopRules: string, + loopDateTimeStart: number, + loopDateTimeEnd: number, + eventDateTimeStart: number, + eventDateTimeEnd: number, + timezoneOffset: number, + clampStartDateTime: number): TimeRange[] | undefined { + if (fullLoopRules == '') return [ + [Math.max(eventDateTimeStart, clampStartDateTime), + Math.max(loopDateTimeEnd, eventDateTimeEnd)] + ]; + + let sp = fullLoopRules.split('-'); + if (sp.length != 2) return undefined; + let loopRules = sp[0]!; // we don't need consider stop flag + let result: TimeRange[] = new Array(); + + // compute offset and duration + let eventDateTime = new Date((eventDateTimeStart + timezoneOffset) * 60000); + eventDateTime.setUTCHours(0, 0, 0, 0); + let eventOffset = eventDateTimeStart - (Math.floor(eventDateTime.getTime() / 60000) - timezoneOffset); + let eventDuration = eventDateTimeEnd - eventDateTimeStart; + + let detectDateTime = new Date(loopDateTimeStart * 60000); + detectDateTime.setUTCHours(0, 0, 0, 0); + let originalYear = eventDateTime.getUTCFullYear(); + let originalMonth = eventDateTime.getUTCMonth() + 1; + let originalDay = eventDateTime.getUTCDate(); + + // compute event + let match: RegExpExecArray | null; + if ((match = PRECOMPILED_LOOP_RULES.year.exec(loopRules)) !== null) { + let isStrict = match[1]! == 'S'; + let loopSpan = parseInt(match[2]!); + + let yearCount = detectDateTime.getFullYear() - originalYear; + let isSpecial = (originalMonth == 2 && originalDay == 29); + let realLoopSpan = (isSpecial && isStrict) ? lcm(4, loopSpan) : loopSpan; + + //let fullSpanCount = Math.floor(yearCount / realLoopSpan); + let remainYear = yearCount % realLoopSpan; + //detectDateTime.setUTCFullYear(fullSpanCount + detectDateTime.getUTCFullYear(), 1, 1); + if (remainYear != 0) + detectDateTime.setUTCFullYear(realLoopSpan - remainYear + detectDateTime.getUTCFullYear(), 1 - 1, 1); + + let skipFlag = false; + while (Math.floor(detectDateTime.getTime() / 60000) + eventOffset - timezoneOffset <= loopDateTimeEnd) { + skipFlag = false; + if (isSpecial) { + // is special day, 29 Feb + // try set it in 29 Feb + if (isStrict) { + if (isLeapYear(detectDateTime.getUTCFullYear())) detectDateTime.setUTCMonth(2 - 1, 29); + else skipFlag = true; // order skip + } else { + if (isLeapYear(detectDateTime.getUTCFullYear())) detectDateTime.setUTCMonth(2 - 1, 29); + else detectDateTime.setUTCMonth(2 - 1, 28); + } + } else detectDateTime.setUTCMonth(originalMonth - 1, originalDay); + + if (!skipFlag) { + result.push( + [Math.floor(detectDateTime.getTime() / 60000) + eventOffset - timezoneOffset, + Math.floor(detectDateTime.getTime() / 60000) + eventOffset + eventDuration - timezoneOffset] + ); + } + + detectDateTime.setUTCFullYear(realLoopSpan + detectDateTime.getUTCFullYear()); + } + + } else if ((match = PRECOMPILED_LOOP_RULES.month.exec(loopRules)) !== null) { + let isStrict = match[1]! == 'S'; + let loopMethod = match[2]!; + let loopSpan = parseInt(match[3]!); + + let monthsCountValue = monthsCount(detectDateTime.getUTCFullYear(), detectDateTime.getUTCMonth() + 1) - + monthsCount(originalYear, originalMonth); + + //let fullSpanCount = Math.floor(monthsCountValue / loopSpan); + let remainMonth = monthsCountValue % loopSpan; + //detectDateTime.setUTCMonth(fullSpanCount * loopSpan + detectDateTime.getUTCMonth(), 1); + detectDateTime.setUTCDate(1); + if (remainMonth != 0) + detectDateTime.setUTCMonth(loopSpan - remainMonth + detectDateTime.getUTCMonth(), 1); + + while (Math.floor(detectDateTime.getTime() / 60000) + eventOffset - timezoneOffset <= loopDateTimeEnd) { + let data = getRemanagedDayInMonth(originalYear, originalMonth, originalDay, detectDateTime.getUTCFullYear(), detectDateTime.getUTCMonth() + 1, isStrict); + let predictedDay: number | undefined = undefined; + switch (loopMethod) { + case 'A': + if (typeof (data[0]) !== 'undefined') predictedDay = data[0]; + break; + case 'B': + if (typeof (data[1]) !== 'undefined') predictedDay = data[1]; + break; + case 'C': + if (typeof (data[2]) !== 'undefined') predictedDay = data[2]; + break; + case 'D': + if (typeof (data[3]) !== 'undefined') predictedDay = data[3]; + break; + } + if (typeof (predictedDay) !== 'undefined') { + detectDateTime.setUTCDate(predictedDay); + result.push( + [Math.floor(detectDateTime.getTime() / 60000) + eventOffset - timezoneOffset, + Math.floor(detectDateTime.getTime() / 60000) + eventOffset + eventDuration - timezoneOffset] + ); + } + + detectDateTime.setUTCMonth(loopSpan + detectDateTime.getUTCMonth(), 1); + } + + + } else if ((match = PRECOMPILED_LOOP_RULES.week.exec(loopRules)) !== null) { + let loopSpan = parseInt(match[2]!); + let weekOption: boolean[] = []; + let weekEventCount = 0 + for (let i = 0; i < 7; i++) { + weekOption.push(match[1]![i] == 'T'); + if (match[1]![i] == 'T') weekEventCount++; + } + + let originalWeek = dayOfWeek(originalYear, originalMonth, originalDay); + + // try insert original event + if (!weekOption[originalWeek]) { + result.push( + [eventDateTimeStart, eventDateTimeEnd] + ); + } + + let daysCountValue = daysCount(detectDateTime.getUTCFullYear(), detectDateTime.getUTCMonth() + 1, detectDateTime.getDate()) - + daysCount(originalYear, originalMonth, originalDay); + //let fullSpanCount = Math.floor(daysCountValue / (7 * loopSpan)); + let remainFullSpanCount = Math.floor((daysCountValue % (7 * loopSpan)) / 7); + let remainDays = (daysCountValue % (7 * loopSpan)) % 7; + + //detectDateTime.setUTCDate((7 * loopSpan * fullSpanCount) + detectDateTime.getUTCDate()); + if (remainFullSpanCount != 0) { + detectDateTime.setUTCDate((loopSpan - remainFullSpanCount) * 7 + detectDateTime.getUTCDate()); + } + let weekCounter = remainDays; + + while (Math.floor(detectDateTime.getTime() / 60000) + eventOffset - timezoneOffset <= loopDateTimeEnd) { + if (weekOption[(weekCounter + originalWeek) % 7]) + result.push( + [Math.floor(detectDateTime.getTime() / 60000) + eventOffset - timezoneOffset, + Math.floor(detectDateTime.getTime() / 60000) + eventOffset + eventDuration - timezoneOffset] + ); + + weekCounter = (weekCounter + 1) % 7; + detectDateTime.setUTCDate(detectDateTime.getUTCDate() + 1); + if (weekCounter == 0) + detectDateTime.setUTCDate(detectDateTime.getUTCDate() + (loopSpan - 1) * 7); + } + + } else if ((match = PRECOMPILED_LOOP_RULES.day.exec(loopRules)) !== null) { + let loopSpan = parseInt(match[1]!); + + let daysCountValue = daysCount(detectDateTime.getUTCFullYear(), detectDateTime.getUTCMonth() + 1, detectDateTime.getUTCDate()) - + daysCount(originalYear, originalMonth, originalDay); + //let fullSpanCount = Math.floor(daysCountValue / loopSpan); + let remainDays = daysCountValue % loopSpan; + //detectDateTime.setUTCDate(fullSpanCount * loopSpan + detectDateTime.getUTCDate()); + if (remainDays != 0) + detectDateTime.setUTCDate(loopSpan - remainDays + detectDateTime.getUTCDate()); + + while (Math.floor(detectDateTime.getTime() / 60000) + eventOffset - timezoneOffset <= loopDateTimeEnd) { + result.push( + [Math.floor(detectDateTime.getTime() / 60000) + eventOffset - timezoneOffset, + Math.floor(detectDateTime.getTime() / 60000) + eventOffset + eventDuration - timezoneOffset] + ); + detectDateTime.setUTCDate(detectDateTime.getUTCDate() + loopSpan); + } + } else return undefined; + + // clamp item + let realResult: TimeRange[] = new Array(); + for (let i of result) { + let start = i[0]; + let end = i[1]; + if (end > clampStartDateTime && start <= loopDateTimeEnd) + realResult.push([Math.max(start, clampStartDateTime), Math.min(end, loopDateTimeEnd)]); + } + return realResult; } -export function resolveLoopRules4Text(strl: string, startDateTime: number, timezoneOffset: number) { +/** + * Resolves a loop rule string into a human-readable i18n-localised text + * description suitable for display in the UI. + * @param strl - The loop rule string in format "loopRule-stopRule". + * @param startDateTime - Reference start datetime (minutes) for computing + * contextual fields (e.g. day-in-month info). + * @param timezoneOffset - Timezone offset in minutes. + * @returns A concatenated human-readable string, or an empty string when + * the input is empty or cannot be parsed. + */ +export function resolveLoopRules4Text(strl: string, startDateTime: number, timezoneOffset: number): string { + if (strl == '') return ""; + let sp = strl.split('-'); + if (sp.length != 2) return ""; + let loopRules: string; + let loopStopRules: string; + let datetimeInstance = new Date((startDateTime + timezoneOffset) * 60000) + + let match: RegExpExecArray | null; + if ((match = PRECOMPILED_LOOP_RULES.year.exec(sp[0]!)) !== null) { + if (match[1]! == 'S') + loopRules = "严格模式。"; + else + loopRules = "宽松模式。"; + loopRules += format("每{0}年于{1}循环一次。", + parseInt(match[2]!), datetimeInstance.toLocaleDateString(undefined, { timeZone: "UTC" }) + ); + } else if ((match = PRECOMPILED_LOOP_RULES.month.exec(sp[0]!)) !== null) { + if (match[1]! == 'S') + loopRules = "严格模式。"; + else + loopRules = "宽松模式。"; + + let dayInMonth = getDayInMonth( + datetimeInstance.getUTCFullYear(), + datetimeInstance.getUTCMonth() + 1, + datetimeInstance.getUTCDate()); + switch (match[2]!) { + case 'A': + loopRules = format("每{0}月的第{1}日循环一次。", + parseInt(match[3]!), dayInMonth[0] + ); + break; + case 'B': + loopRules = format("每{0}月的倒数第{1}日循环一次。", + parseInt(match[3]!), dayInMonth[1] + ); + break; + case 'C': + loopRules = format("每{0}月的第{1}个星期{2}循环一次。", + parseInt(match[3]!), dayInMonth[2], dayInMonth[3] + ); + break; + case 'D': + loopRules = format("每{0}月的倒数第{1}个星期{2}循环一次。", + parseInt(match[3]!), dayInMonth[4], dayInMonth[5] + ); + break; + } + } else if ((match = PRECOMPILED_LOOP_RULES.week.exec(sp[0]!)) !== null) { + let weekOfDayCache = []; + for (let i = 0; i < 7; i++) { + if (match[1]![i] == 'T') + weekOfDayCache.push(universalGetDayOfWeek(i)); + } + + loopRules = format("每{0}周的{1}循环一次。", + parseInt(match[2]!), weekOfDayCache.join(', ') + ); + } else if ((match = PRECOMPILED_LOOP_RULES.day.exec(sp[0]!)) !== null) { + loopRules = format("每{0}天循环一次。", parseInt(match[1]!)); + } else return ""; + + + if ((match = PRECOMPILED_LOOP_STOP_RULES.infinity.exec(sp[1]!)) !== null) { + loopStopRules = "永远循环。"; + } else if ((match = PRECOMPILED_LOOP_STOP_RULES.datetime.exec(sp[1]!)) !== null) { + loopStopRules = format("到{0}停止循环。", + new Date(parseInt(match[1]!)).toLocaleDateString() + ); + } else if ((match = PRECOMPILED_LOOP_STOP_RULES.times.exec(sp[1]!)) !== null) { + loopStopRules = format("循环{0}次。", parseInt(match[1]!)); + } else return ""; + + return (loopRules + loopStopRules); } // endregion @@ -77,40 +429,40 @@ function daysCount(year: number, month: number, day: number): number { } function monthsCount(year: number, month: number): number { - return (year - 1) * 12 + (month - 1); + return (year - 1) * 12 + (month - 1); } function dayOfWeek(year: number, month: number, day: number): number { - return daysCount(year, month, day) % 7; + return daysCount(year, month, day) % 7; } type DayInMonthInfo = [ /** The day count to this day, counting from month head to tail. */ - daysForward: number, + daysForward: number, /** The day count to this day, counting from month tail to head. */ - daysBackward: number, + daysBackward: number, /** The count of the week this day located, counting from month head to tail. */ - weeksForward: number, + weeksForward: number, /** The day count in this week. (for `weeksForward` using) */ - weeksForwardDayOfWeek: number, + weeksForwardDayOfWeek: number, /** The count of the week this day located, counting from month tail to head. */ - weeksBackward: number, + weeksBackward: number, /** The day count in this week. (for `weeksBackward` using) */ - weeksBackwardDayOfWeek: number, + weeksBackwardDayOfWeek: number, ]; function getDayInMonth(year: number, month: number, day: number): DayInMonthInfo { - let days = MONTH_DAY_COUNT[month - 1]! + ((month == 2 && isLeapYear(year)) ? 1 : 0); - let firstDayOfWeek = dayOfWeek(year, month, 1); - let bilateralDayOfWeek = (firstDayOfWeek + day - 1) % 7; + let days = MONTH_DAY_COUNT[month - 1]! + ((month == 2 && isLeapYear(year)) ? 1 : 0); + let firstDayOfWeek = dayOfWeek(year, month, 1); + let bilateralDayOfWeek = (firstDayOfWeek + day - 1) % 7; - let dayForwards = day; - let dayBackwards = days - day + 1; + let dayForwards = day; + let dayBackwards = days - day + 1; - let weeksForward = Math.floor((dayForwards - 1) / 7) + 1; - let weeksBackwards = Math.floor((dayBackwards - 1) / 7) + 1; + let weeksForward = Math.floor((dayForwards - 1) / 7) + 1; + let weeksBackwards = Math.floor((dayBackwards - 1) / 7) + 1; - return [dayForwards, dayBackwards, weeksForward, bilateralDayOfWeek, weeksBackwards, bilateralDayOfWeek]; + return [dayForwards, dayBackwards, weeksForward, bilateralDayOfWeek, weeksBackwards, bilateralDayOfWeek]; } // YYC MARK: @@ -119,13 +471,13 @@ function getDayInMonth(year: number, month: number, day: number): DayInMonthInfo type RemanagedDayInMonth = [ /** Count forward by days (Day N) */ - forwardByDayCount: number, + forwardByDayCount: number | undefined, /** Count backward by days (Nth day from the end) */ - backwardByDayCount: number, + backwardByDayCount: number | undefined, /** Count forward by weeks (Week N, Youbi X) */ - forwardByWeekCount: number, + forwardByWeekCount: number | undefined, /** Count backward by weeks (Nth week from the end, Youbi X) */ - backwardByWeekCount: number, + backwardByWeekCount: number | undefined, ]; /** @@ -140,37 +492,37 @@ type RemanagedDayInMonth = [ * @param isStrict * @returns */ -function getRemanagedDayInMonth(oldYear: number, oldMonth: number, oldDay: number, newYear: number, newMonth: number, isStrict: boolean) { - let ddata = getDayInMonth(oldYear, oldMonth, oldDay); - let mdata = getMonthWeekStatistics(newYear, newMonth); - let days = MONTH_DAY_COUNT[newMonth - 1]! + ((newMonth == 2 && isLeapYear(newYear)) ? 1 : 0); - let firstDayOfWeek = dayOfWeek(newYear, newMonth, 1); - //let lastDayOfWeek = (firstDayOfWeek + days - 1) % 7; - - let methodA = undefined; - let methodB = undefined; - if (isStrict) { - methodA = ddata[0] > days ? undefined : ddata[0]; - methodB = ddata[1] > days ? undefined : (days - ddata[1] + 1); - } else { - methodA = Math.min(ddata[0], days); - methodB = days - Math.min(ddata[1], days) + 1; - } +function getRemanagedDayInMonth(oldYear: number, oldMonth: number, oldDay: number, newYear: number, newMonth: number, isStrict: boolean): RemanagedDayInMonth { + let ddata = getDayInMonth(oldYear, oldMonth, oldDay); + let mdata = getMonthWeekStatistics(newYear, newMonth); + let days = MONTH_DAY_COUNT[newMonth - 1]! + ((newMonth == 2 && isLeapYear(newYear)) ? 1 : 0); + let firstDayOfWeek = dayOfWeek(newYear, newMonth, 1); + //let lastDayOfWeek = (firstDayOfWeek + days - 1) % 7; - let methodC = undefined; - if (ddata[2] <= mdata[ddata[3]]! || !isStrict) { - let targetWeek = Math.min(ddata[2], mdata[ddata[3]]!); - methodC = 1 + (targetWeek - 1) * 7 + ((ddata[3] + 7 - firstDayOfWeek) % 7); - } + let methodA = undefined; + let methodB = undefined; + if (isStrict) { + methodA = ddata[0] > days ? undefined : ddata[0]; + methodB = ddata[1] > days ? undefined : (days - ddata[1] + 1); + } else { + methodA = Math.min(ddata[0], days); + methodB = days - Math.min(ddata[1], days) + 1; + } - let methodD = undefined; - if (ddata[4] <= mdata[ddata[5]]! || !isStrict) { - // convert to type c and calc - let targetWeek = mdata[ddata[5]]! - Math.min(ddata[4], mdata[ddata[5]]!) + 1; - methodD = 1 + (targetWeek - 1) * 7 + ((ddata[5] + 7 - firstDayOfWeek) % 7); - } + let methodC = undefined; + if (ddata[2] <= mdata[ddata[3]]! || !isStrict) { + let targetWeek = Math.min(ddata[2], mdata[ddata[3]]!); + methodC = 1 + (targetWeek - 1) * 7 + ((ddata[3] + 7 - firstDayOfWeek) % 7); + } - return [methodA, methodB, methodC, methodD]; + let methodD = undefined; + if (ddata[4] <= mdata[ddata[5]]! || !isStrict) { + // convert to type c and calc + let targetWeek = mdata[ddata[5]]! - Math.min(ddata[4], mdata[ddata[5]]!) + 1; + methodD = 1 + (targetWeek - 1) * 7 + ((ddata[5] + 7 - firstDayOfWeek) % 7); + } + + return [methodA, methodB, methodC, methodD]; } type MonthWeekStatistics = [ @@ -191,27 +543,27 @@ type MonthWeekStatistics = [ ]; function getMonthWeekStatistics(year: number, month: number): MonthWeekStatistics { - let days = MONTH_DAY_COUNT[month - 1]! + ((month == 2 && isLeapYear(year)) ? 1 : 0); - let firstDayOfWeek = dayOfWeek(year, month, 1); + let days = MONTH_DAY_COUNT[month - 1]! + ((month == 2 && isLeapYear(year)) ? 1 : 0); + let firstDayOfWeek = dayOfWeek(year, month, 1); - let result: MonthWeekStatistics = [4, 4, 4, 4, 4, 4, 4]; - let remain = days % 7; - let week = firstDayOfWeek; - while (remain > 0) { - result[week % 7]! += 1; - week++; - remain--; - } + let result: MonthWeekStatistics = [4, 4, 4, 4, 4, 4, 4]; + let remain = days % 7; + let week = firstDayOfWeek; + while (remain > 0) { + result[week % 7]! += 1; + week++; + remain--; + } - return result; + return result; } function isLeapYear(year: number): boolean { - let isLeap = false; - if (year % 4 == 0) isLeap = true; - if (year % 100 == 0) isLeap = false; - if (year % 400 == 0) isLeap = true; - return isLeap; + let isLeap = false; + if (year % 4 == 0) isLeap = true; + if (year % 100 == 0) isLeap = false; + if (year % 400 == 0) isLeap = true; + return isLeap; } // endregion diff --git a/frontend/src/utils/i18n.ts b/frontend/src/utils/i18n.ts index 2ba58a3..547a780 100644 --- a/frontend/src/utils/i18n.ts +++ b/frontend/src/utils/i18n.ts @@ -2,3 +2,65 @@ export enum Language { English, SimplifiedChinese, } + +/** + * + * @param month Zero-based month. + * @returns + */ +export function universalGetMonth(month: number): string { + switch (month) { + case 0: + return "January"; + case 1: + return "February"; + case 2: + return "March"; + case 3: + return "April"; + case 4: + return "May"; + case 5: + return "June"; + case 6: + return "July"; + case 7: + return "August"; + case 8: + return "September"; + case 9: + return "October"; + case 10: + return "November"; + case 11: + return "December"; + default: + return "" + } +} + +/** + * + * @param dayOfWeek Zero-based day of week + * @returns + */ +export function universalGetDayOfWeek(dayOfWeek: number): string { + switch (dayOfWeek) { + case 0: + return "Monday"; + case 1: + return "Tuesday"; + case 2: + return "Wednesday"; + case 3: + return "Thursday"; + case 4: + return "Friday"; + case 5: + return "Saturday"; + case 6: + return "Sunday"; + default: + return ""; + } +} diff --git a/frontend/src/utils/utils.ts b/frontend/src/utils/utils.ts index a47a7f5..7fcb5a9 100644 --- a/frontend/src/utils/utils.ts +++ b/frontend/src/utils/utils.ts @@ -10,6 +10,24 @@ export function lcm(a: number, b: number): number { return a / gcd(a, b) * b; } +/** + * 将字符串中的 `{n}` 占位符替换为对应的参数值 + * @param str 目标字符串,若为 `null`/`undefined`/空字符串则返回原值或空串(根据需求) + * @param args 用于替换的值列表 + * @returns 格式化后的字符串 + */ +export function format(str: string, ...args: any[]): string { + // 若 str 为假值(null/undefined/''),直接返回空字符串 + if (!str) return ''; + + return str.replace(/\{(\d+)\}/g, (match, index) => { + const idx = parseInt(index, 10); + const value = args[idx]; + // 若参数存在且不为 null/undefined,则转为字符串,否则保留占位符 + return value != null ? String(value) : match; + }); +} + /** * Get Monday-first the day of week from given date instead of Sunday-first. * @param date The date for getting weekday.