diff --git a/src/cli.ts b/src/cli.ts index 9c2a6d6..becc1a7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1253,8 +1253,7 @@ async function main(argv: string[]): Promise { let calendar: AcademicCalendar | undefined; let term = undefined; let calendarFailure: string | undefined; - const needsCalendar = includes.includes("holidays") - || (includes.includes("schedule") && values["week-one-monday"] === undefined && values["teaching-start"] === undefined); + const needsCalendar = includes.includes("holidays") || includes.includes("schedule"); if (needsCalendar) { try { calendar = await new CalendarClient().loadYear(calendarYearForSemester(semester), level); @@ -1286,14 +1285,28 @@ async function main(argv: string[]): Promise { try { const entries = await tis.schedule(semester); const anchor = await resolveTisIcalAnchor(values, semester, tis, term); - const scheduleEvents = scheduleIcsEvents(entries, anchor); + const scheduleEvents = scheduleIcsEvents(entries, anchor, term); events.push(...scheduleEvents); + const calendarAdjustmentUnavailable = term === undefined; + if (calendarAdjustmentUnavailable) { + omissions.push({ + source: "schedule", + code: "CALENDAR_ADJUSTMENTS_UNAVAILABLE", + message: calendarFailure + ? `Schedule dates were exported without holiday or compensatory-day adjustments: ${calendarFailure}` + : `Schedule dates were exported without holiday or compensatory-day adjustments because ${semester.value} was absent from the academic calendar.`, + }); + } sourceStatuses.schedule = { requested: true, - state: scheduleEvents.length > 0 ? "included" : "omitted", + state: scheduleEvents.length > 0 + ? (calendarAdjustmentUnavailable ? "partial" : "included") + : "omitted", eventCount: scheduleEvents.length, - omissionCount: scheduleEvents.length > 0 ? 0 : 1, - ...(scheduleEvents.length > 0 ? {} : { message: "No scheduled classes were available for the selected semester." }), + omissionCount: scheduleEvents.length > 0 ? (calendarAdjustmentUnavailable ? 1 : 0) : 1, + ...(scheduleEvents.length > 0 + ? (calendarAdjustmentUnavailable ? { message: "Academic-calendar adjustments were unavailable." } : {}) + : { message: "No scheduled classes were available for the selected semester." }), }; if (scheduleEvents.length === 0) { omissions.push({ @@ -2738,7 +2751,10 @@ async function loadLiveContext( if (scheduleResult.status === "fulfilled") { if (currentWeek > 0) { - const schedule = summariseCurrentOrNextClass(scheduleResult.value, { currentWeek, now }); + const calendarTerm = calendar.terms().find((candidate) => ( + candidate.snapshot.semester.value === semester.value + )); + const schedule = summariseCurrentOrNextClass(scheduleResult.value, { currentWeek, now, calendarTerm }); result.schedule = schedule; liveSources.tisSchedule = { state: schedule.now || schedule.next || schedule.tomorrowMorning ? "provided" : "missing", diff --git a/src/test/academics.test.ts b/src/test/academics.test.ts index c016d6d..6027dce 100644 --- a/src/test/academics.test.ts +++ b/src/test/academics.test.ts @@ -52,7 +52,7 @@ test("academic records are normalized into stable Agent-facing fields", () => { ZC: "011010", }); assert.equal(schedule.day, 2); - assert.deepEqual(schedule.weeks, [2, 3, 5]); + assert.deepEqual(schedule.weeks, [1, 2, 4]); const fallback = normalisePersonalScheduleEntry({ KEY: "xq5_jc9", @@ -63,6 +63,7 @@ test("academic records are normalized into stable Agent-facing fields", () => { assert.equal(fallback.courseName, "写作课"); assert.equal(fallback.periodStart, 9); assert.equal(fallback.periodEnd, 9); + assert.deepEqual(fallback.weeks, []); }); test("GPA summaries use credit-weighted SUSTech grade points", () => { diff --git a/src/test/tis-remaining-calendar.test.ts b/src/test/tis-remaining-calendar.test.ts index 6658a74..85fd24a 100644 --- a/src/test/tis-remaining-calendar.test.ts +++ b/src/test/tis-remaining-calendar.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import test from "node:test"; +import { CalendarTerm } from "../calendar/client.js"; import { buildIcsContent, buildScheduleIcs, @@ -10,6 +11,7 @@ import { nearestUpcomingExam, parseIsoDateTimeToUtcStamp, parseShenzhenExamTimeRange, + PERIOD_START_TIMES, scheduleOccurrences, summariseCurrentOrNextClass, teachingPeriodAtShenzhenTime, @@ -34,6 +36,29 @@ const ENTRIES: PersonalScheduleEntry[] = [ }, ]; +const FALL_2026 = new CalendarTerm({ + season: "fall", + level: "undergraduate", + humanName: "2026 Fall", + semester: { xn: "2026-2027", xq: "1", value: "2026-2027-1" }, + start: "2026-09-01", + end: "2027-01-11", + signIn: "2026-09-04", + teachingStart: "2026-09-07", + teachingEnd: "2026-12-27", + totalTeachingWeeks: 16, + midterm: { start: "2026-10-26", end: "2026-11-08", equivalentWeeks: [8, 9] }, + final: { start: "2026-12-28", end: "2027-01-08", equivalentWeeks: [17] }, + compensatories: [ + { date: "2026-09-20", weekType: "odd", workday: "Friday" }, + { date: "2026-10-10", weekType: "odd", workday: "Wednesday" }, + ], + extraBreaks: ["2026-11-20"], +}, [ + { name: "Mid-Autumn Festival", start: "2026-09-25", end: "2026-09-27" }, + { name: "National Day", start: "2026-10-01", end: "2026-10-07" }, +]); + test("week-one inference backtracks from today's week index to the semester anchor", () => { assert.equal(inferWeekOneMonday("2026-03-04", 2), "2026-02-23"); }); @@ -56,7 +81,7 @@ test("ICS export expands schedule entries into dated UTC events", () => { assert.match(ics, /LOCATION:智华楼102/); }); -test("ICS export supports the thirteenth TIS period", () => { +test("legacy ICS export keeps the historical thirteenth TIS period", () => { const lateEntry: PersonalScheduleEntry = { ...ENTRIES[0], key: "xq1_jc13", @@ -69,6 +94,76 @@ test("ICS export supports the thirteenth TIS period", () => { assert.equal(occurrence?.endUtc, "20260223T145000Z"); }); +test("2026 fall uses the current SUSTech period schedule", () => { + assert.deepEqual(PERIOD_START_TIMES, { + 1: [8, 0], + 2: [9, 0], + 3: [10, 20], + 4: [11, 20], + 5: [14, 0], + 6: [15, 0], + 7: [16, 20], + 8: [17, 20], + 9: [19, 0], + 10: [20, 0], + 11: [21, 0], + }); + assert.match( + teachingPeriodAtShenzhenTime(new Date("2026-09-08T06:05:00Z"))?.periodLabel ?? "", + /P5 14:00-14:50/, + ); + assert.match( + teachingPeriodAtShenzhenTime(new Date("2026-09-08T08:25:00Z"))?.periodLabel ?? "", + /P7 16:20-17:10/, + ); + assert.match( + teachingPeriodAtShenzhenTime(new Date("2026-09-08T13:05:00Z"))?.periodLabel ?? "", + /P11 21:00-21:50/, + ); + assert.equal(teachingPeriodAtShenzhenTime(new Date("2026-09-08T14:05:00Z")), undefined); +}); + +test("calendar-adjusted occurrences move odd-week classes onto compensatory days", () => { + const friday: PersonalScheduleEntry = { + ...ENTRIES[0], + rwh: "FRIDAY", + key: "xq5_jc5", + day: 5, + periodStart: 5, + periodEnd: 6, + weeks: [3], + }; + const wednesday: PersonalScheduleEntry = { + ...ENTRIES[0], + rwh: "WEDNESDAY", + key: "xq3_jc7", + day: 3, + periodStart: 7, + periodEnd: 8, + weeks: [5], + }; + const holidayOnly: PersonalScheduleEntry = { + ...ENTRIES[0], + rwh: "MONDAY", + key: "xq1_jc1", + day: 1, + periodStart: 1, + periodEnd: 2, + weeks: [5], + }; + + const occurrences = scheduleOccurrences( + [friday, wednesday, holidayOnly], + { teachingStartDate: FALL_2026.snapshot.teachingStart }, + FALL_2026, + ); + assert.deepEqual(occurrences.map((entry) => entry.date), ["2026-09-20", "2026-10-10"]); + assert.deepEqual(occurrences.map((entry) => entry.sourceDate), ["2026-09-25", "2026-10-07"]); + assert.ok(occurrences.every((entry) => entry.isCompensatory)); + assert.equal(occurrences[0]?.startUtc, "20260920T060000Z"); + assert.equal(occurrences[1]?.endUtc, "20261010T101000Z"); +}); + test("teaching-period lookup uses Asia/Shanghai wall clock boundaries", () => { const period = teachingPeriodAtShenzhenTime(new Date("2026-08-26T02:25:00Z")); assert.equal(period?.date, "2026-08-26"); @@ -134,6 +229,30 @@ test("current-or-next class summary distinguishes active and upcoming classes", assert.match(upcoming.nextDetail ?? "", /week 4 Wednesday|today|tomorrow/i); }); +test("current class summary follows compensatory dates instead of the holiday weekday", () => { + const wednesday: PersonalScheduleEntry = { + ...ENTRIES[0], + day: 3, + periodStart: 7, + periodEnd: 8, + weeks: [5], + }; + const holiday = summariseCurrentOrNextClass([wednesday], { + currentWeek: 5, + now: new Date("2026-10-07T08:30:00Z"), + calendarTerm: FALL_2026, + }); + assert.equal(holiday.now, undefined); + assert.match(holiday.nextDetail ?? "", /Saturday \(makeup for Wednesday\)/); + + const makeup = summariseCurrentOrNextClass([wednesday], { + currentWeek: 5, + now: new Date("2026-10-10T08:30:00Z"), + calendarTerm: FALL_2026, + }); + assert.match(makeup.now ?? "", /today \(makeup for Wednesday\).*16:20-18:10/); +}); + test("nearest upcoming exam keeps exact-order semantics and reports omitted malformed rows", () => { const exams: ExamRecord[] = [ { diff --git a/src/tis/normalise.ts b/src/tis/normalise.ts index c164111..a1d81e6 100644 --- a/src/tis/normalise.ts +++ b/src/tis/normalise.ts @@ -198,7 +198,7 @@ function firstString(record: Record, keys: string[]): string { function bitmapWeeks(bitmap: string): number[] { return [...bitmap] - .map((enabled, index) => enabled === "1" ? index + 1 : undefined) + .map((enabled, index) => enabled === "1" && index > 0 ? index : undefined) .filter((week): week is number => week !== undefined); } diff --git a/src/tis/remaining-calendar.ts b/src/tis/remaining-calendar.ts index 3124cda..3d86e0f 100644 --- a/src/tis/remaining-calendar.ts +++ b/src/tis/remaining-calendar.ts @@ -4,7 +4,8 @@ import { copyFile, link, lstat, open, rename, rm, stat } from "node:fs/promises" import { basename, dirname, join, resolve as resolvePath } from "node:path"; import { CliError } from "../core/errors.js"; import { assertPathAndParentsAreNotSymlinks } from "../core/local-store.js"; -import type { Holiday } from "../calendar/types.js"; +import type { CalendarTerm } from "../calendar/client.js"; +import type { CompensatoryDay, Holiday, WeekdayName } from "../calendar/types.js"; import type { ExamRecord, PersonalScheduleEntry } from "./types.js"; import { addUtcDays, parseIsoDate, toIsoDate } from "./remaining-shared.js"; @@ -25,6 +26,9 @@ export interface IcsOccurrence { day: number; periodStart: number; periodEnd: number; + isCompensatory?: true; + sourceDate?: string; + sourceWeekday?: WeekdayName; } export interface IcsEvent { @@ -67,7 +71,21 @@ export interface TeachingPeriodWindow { } const CHINA_OFFSET_MINUTES = 8 * 60; +const CURRENT_PERIOD_SCHEDULE_START = "2026-09-07"; export const PERIOD_START_TIMES: Readonly> = { + 1: [8, 0], + 2: [9, 0], + 3: [10, 20], + 4: [11, 20], + 5: [14, 0], + 6: [15, 0], + 7: [16, 20], + 8: [17, 20], + 9: [19, 0], + 10: [20, 0], + 11: [21, 0], +}; +const LEGACY_PERIOD_START_TIMES: Readonly> = { 1: [8, 0], 2: [9, 0], 3: [10, 20], @@ -107,47 +125,134 @@ export function weekOneMondayFromAnchor(anchor: IcsAnchor): string { export function scheduleOccurrences( entries: readonly PersonalScheduleEntry[], anchor: IcsAnchor, + calendarTerm?: CalendarTerm, ): IcsOccurrence[] { + return resolveScheduleOccurrences(entries, anchor, calendarTerm).map(({ entry: _entry, ...occurrence }) => occurrence); +} + +interface ResolvedScheduleOccurrence extends IcsOccurrence { + entry: PersonalScheduleEntry; +} + +function resolveScheduleOccurrences( + entries: readonly PersonalScheduleEntry[], + anchor: IcsAnchor, + calendarTerm?: CalendarTerm, +): ResolvedScheduleOccurrence[] { const weekOneMonday = parseIsoDate(weekOneMondayFromAnchor(anchor)); - const occurrences: IcsOccurrence[] = []; + const occurrences: ResolvedScheduleOccurrence[] = []; for (const entry of entries) { if (entry.day === undefined || entry.periodStart === undefined || entry.periodEnd === undefined) continue; for (const week of [...entry.weeks].sort((left, right) => left - right)) { const date = addUtcDays(weekOneMonday, (week - 1) * 7 + (entry.day - 1)); - const start = periodStartUtc(date, entry.periodStart); - const end = periodEndUtc(date, entry.periodEnd); - const summary = [entry.courseCode, entry.courseName].filter(Boolean).join(" ").trim() || entry.courseName || entry.courseCode || "SUSTech Class"; - const description = [entry.teacher, entry.description, `Week ${week}`].filter(Boolean).join(" | "); - occurrences.push({ - uid: `${entry.rwh || entry.key || summary}-${toIsoDate(date)}-p${entry.periodStart}@sustech-cli`, - summary, - location: entry.room || undefined, - description, - startUtc: start, - endUtc: end, - date: toIsoDate(date), - week, - day: entry.day, - periodStart: entry.periodStart, - periodEnd: entry.periodEnd, - }); + const dateText = toIsoDate(date); + if (calendarTerm && !regularClassRunsOn(calendarTerm, dateText)) continue; + occurrences.push(resolveOccurrence(entry, dateText, week)); + } + } + + if (calendarTerm) { + for (const compensatory of calendarTerm.snapshot.compensatories) { + const target = compensatoryTarget(calendarTerm, compensatory); + if (!target) continue; + const sourceDay = weekdayNumber(compensatory.workday); + for (const entry of entries) { + if ( + entry.day !== sourceDay + || entry.periodStart === undefined + || entry.periodEnd === undefined + || !entry.weeks.includes(target.week) + ) continue; + occurrences.push(resolveOccurrence(entry, compensatory.date, target.week, { + sourceDate: target.date, + sourceWeekday: compensatory.workday, + })); + } } } return occurrences.sort((left, right) => left.startUtc.localeCompare(right.startUtc) || left.uid.localeCompare(right.uid)); } +function resolveOccurrence( + entry: PersonalScheduleEntry, + date: string, + week: number, + makeup?: { sourceDate: string; sourceWeekday: WeekdayName }, +): ResolvedScheduleOccurrence { + const parsedDate = parseIsoDate(date); + const summary = [entry.courseCode, entry.courseName].filter(Boolean).join(" ").trim() + || entry.courseName + || entry.courseCode + || "SUSTech Class"; + const makeupDescription = makeup + ? `Makeup for ${makeup.sourceWeekday} ${makeup.sourceDate}` + : ""; + return { + entry, + uid: `${entry.rwh || entry.key || summary}-${date}-p${entry.periodStart}@sustech-cli`, + summary, + location: entry.room || undefined, + description: [entry.teacher, entry.description, `Week ${week}`, makeupDescription].filter(Boolean).join(" | "), + startUtc: periodStartUtc(parsedDate, entry.periodStart!), + endUtc: periodEndUtc(parsedDate, entry.periodEnd!), + date, + week, + day: entry.day!, + periodStart: entry.periodStart!, + periodEnd: entry.periodEnd!, + ...(makeup ? { + isCompensatory: true as const, + sourceDate: makeup.sourceDate, + sourceWeekday: makeup.sourceWeekday, + } : {}), + }; +} + +function regularClassRunsOn(term: CalendarTerm, date: string): boolean { + const day = term.day(date); + return date >= term.snapshot.teachingStart + && date <= term.snapshot.teachingEnd + && !day.flags.isHoliday + && !day.flags.isExtraBreak + && !day.flags.isFinal; +} + +function compensatoryTarget( + term: CalendarTerm, + compensatory: CompensatoryDay, +): { week: number; date: string } | undefined { + const parity = compensatory.weekType === "odd" ? 1 : 0; + const compensatoryDate = parseIsoDate(compensatory.date); + return Array.from({ length: term.snapshot.totalTeachingWeeks }, (_, index) => index + 1) + .filter((week) => week % 2 === parity) + .map((week) => ({ week, date: term.dateOf(week, compensatory.workday) })) + .filter((candidate) => !regularClassRunsOn(term, candidate.date)) + .sort((left, right) => ( + Math.abs(parseIsoDate(left.date).getTime() - compensatoryDate.getTime()) + - Math.abs(parseIsoDate(right.date).getTime() - compensatoryDate.getTime()) + ))[0]; +} + +function weekdayNumber(weekday: WeekdayName): number { + return ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"].indexOf(weekday) + 1; +} + export function buildScheduleIcs( entries: readonly PersonalScheduleEntry[], anchor: IcsAnchor, - options: { calendarName?: string; nowUtc?: Date } = {}, + options: { calendarName?: string; nowUtc?: Date; calendarTerm?: CalendarTerm } = {}, ): string { - return buildIcsContent(scheduleIcsEvents(entries, anchor), options); + return buildIcsContent(scheduleIcsEvents(entries, anchor, options.calendarTerm), options); } -export function scheduleIcsEvents(entries: readonly PersonalScheduleEntry[], anchor: IcsAnchor): IcsEvent[] { - return scheduleOccurrences(entries, anchor).map((event) => ({ +export function scheduleIcsEvents( + entries: readonly PersonalScheduleEntry[], + anchor: IcsAnchor, + calendarTerm?: CalendarTerm, +): IcsEvent[] { + return scheduleOccurrences(entries, anchor, calendarTerm).map((event) => ({ uid: event.uid, summary: event.summary, description: event.description, @@ -277,39 +382,47 @@ export function parseShenzhenExamTimeRange(date: string, time: string): { startU export function summariseCurrentOrNextClass( entries: readonly PersonalScheduleEntry[], - options: { currentWeek: number; now: Date }, + options: { currentWeek: number; now: Date; calendarTerm?: CalendarTerm }, ): ScheduleReminderSummary { + if (options.calendarTerm) { + return summariseCalendarAdjustedClasses(entries, options.now, options.calendarTerm); + } + const clock = shenzhenWallClock(options.now); const currentMinute = clock.hour * 60 + clock.minute; - let active: PersonalScheduleEntry | undefined; - let next: { entry: PersonalScheduleEntry; week: number; dayOffset: number; startMinutes: number } | undefined; + let active: { entry: PersonalScheduleEntry; week: number; date: string } | undefined; + let next: { entry: PersonalScheduleEntry; week: number; dayOffset: number; startMinutes: number; date: string } | undefined; for (const entry of entries) { if (entry.day === undefined || entry.periodStart === undefined || entry.periodEnd === undefined) continue; - const startSlot = PERIOD_START_TIMES[entry.periodStart]; - const endSlot = PERIOD_START_TIMES[entry.periodEnd]; - if (!startSlot || !endSlot) continue; - const startMinutes = startSlot[0] * 60 + startSlot[1]; - const endMinutes = endSlot[0] * 60 + endSlot[1] + PERIOD_DURATION_MINUTES; for (const week of [...entry.weeks].sort((left, right) => left - right)) { if (week < options.currentWeek) continue; const dayOffset = (week - options.currentWeek) * 7 + (entry.day - clock.weekday); if (dayOffset < 0) continue; + const date = toIsoDate(addUtcDays(parseIsoDate(clock.date), dayOffset)); + const periods = periodStartTimesForDate(date); + const startSlot = periods[entry.periodStart]; + const endSlot = periods[entry.periodEnd]; + if (!startSlot || !endSlot) continue; + const startMinutes = startSlot[0] * 60 + startSlot[1]; + const endMinutes = endSlot[0] * 60 + endSlot[1] + PERIOD_DURATION_MINUTES; if (dayOffset === 0 && startMinutes <= currentMinute && currentMinute < endMinutes) { - if (!active || startMinutes < periodStartMinutes(active.periodStart ?? 99)) active = entry; + if (!active || startMinutes < periodStartMinutes(active.entry.periodStart ?? 99, active.date)) { + active = { entry, week, date }; + } continue; } if (dayOffset === 0 && startMinutes <= currentMinute) continue; if (!next || dayOffset < next.dayOffset || (dayOffset === next.dayOffset && startMinutes < next.startMinutes)) { - next = { entry, week, dayOffset, startMinutes }; + next = { entry, week, dayOffset, startMinutes, date }; } break; } } - if (active) return { now: formatScheduleEntryLabel(active, options.currentWeek) }; + if (active) return { now: formatScheduleEntryLabel(active.entry, active.week, active.date) }; if (next) { - const detail = formatUpcomingScheduleEntryDetail(next.entry, next.week, next.dayOffset); + const detail = formatUpcomingScheduleEntryDetail(next.entry, next.week, next.dayOffset, next.date); return { next: formatScheduleEntryTitle(next.entry), nextDetail: detail, @@ -319,6 +432,44 @@ export function summariseCurrentOrNextClass( return {}; } +function summariseCalendarAdjustedClasses( + entries: readonly PersonalScheduleEntry[], + now: Date, + calendarTerm: CalendarTerm, +): ScheduleReminderSummary { + const clock = shenzhenWallClock(now); + const nowStamp = formatUtc(now); + const occurrences = resolveScheduleOccurrences( + entries, + { teachingStartDate: calendarTerm.snapshot.teachingStart }, + calendarTerm, + ); + const active = occurrences.find((occurrence) => occurrence.startUtc <= nowStamp && nowStamp < occurrence.endUtc); + if (active) { + return { + now: formatScheduleEntryLabel(active.entry, active.week, active.date, active.sourceWeekday), + }; + } + + const next = occurrences.find((occurrence) => occurrence.startUtc > nowStamp); + if (!next) return {}; + const dayOffset = Math.round( + (parseIsoDate(next.date).getTime() - parseIsoDate(clock.date).getTime()) / (24 * 60 * 60 * 1000), + ); + const detail = formatUpcomingScheduleEntryDetail( + next.entry, + next.week, + dayOffset, + next.date, + next.sourceWeekday, + ); + return { + next: formatScheduleEntryTitle(next.entry), + nextDetail: detail, + ...(dayOffset === 1 ? { tomorrowMorning: `${formatScheduleEntryTitle(next.entry)} — ${detail}` } : {}), + }; +} + export function nearestUpcomingExam( exams: readonly ExamRecord[], options: { now: Date }, @@ -407,13 +558,14 @@ export function teachingPeriodAtShenzhenTime(value: Date): TeachingPeriodWindow const wallClockDate = new Date(Date.UTC(year, month - 1, day)); const weekday = wallClockDate.getUTCDay() === 0 ? 7 : wallClockDate.getUTCDay(); const minutesSinceMidnight = hour * 60 + minute; - for (const [periodText, [startHour, startMinute]] of Object.entries(PERIOD_START_TIMES)) { + const date = `${partValue(parts, "year")}-${partValue(parts, "month")}-${partValue(parts, "day")}`; + for (const [periodText, [startHour, startMinute]] of Object.entries(periodStartTimesForDate(date))) { const periodStart = Number(periodText); const startTotal = startHour * 60 + startMinute; const endTotal = startTotal + PERIOD_DURATION_MINUTES; if (minutesSinceMidnight < startTotal || minutesSinceMidnight >= endTotal) continue; return { - date: `${partValue(parts, "year")}-${partValue(parts, "month")}-${partValue(parts, "day")}`, + date, time: `${partValue(parts, "hour")}:${partValue(parts, "minute")}`, weekday, periodStart, @@ -425,13 +577,13 @@ export function teachingPeriodAtShenzhenTime(value: Date): TeachingPeriodWindow } function periodStartUtc(date: Date, period: number): string { - const slot = PERIOD_START_TIMES[period]; + const slot = periodStartTimesForDate(toIsoDate(date))[period]; if (!slot) throw new CliError("ICS export encountered an unsupported class period.", "UNSUPPORTED_PERIOD", 2, { period }); return formatUtc(new Date(chinaLocalUtcMillis(date, slot[0], slot[1]))); } function periodEndUtc(date: Date, period: number): string { - const slot = PERIOD_START_TIMES[period]; + const slot = periodStartTimesForDate(toIsoDate(date))[period]; if (!slot) throw new CliError("ICS export encountered an unsupported class period.", "UNSUPPORTED_PERIOD", 2, { period }); return formatUtc(new Date(chinaLocalUtcMillis(date, slot[0], slot[1]) + PERIOD_DURATION_MINUTES * 60 * 1000)); } @@ -503,20 +655,34 @@ function formatScheduleEntryTitle(entry: PersonalScheduleEntry): string { return [entry.courseCode, entry.courseName || entry.description || entry.descriptionEn || "Unnamed course"].filter(Boolean).join(" ").trim(); } -function formatScheduleEntryLabel(entry: PersonalScheduleEntry, currentWeek: number): string { - return `${formatScheduleEntryTitle(entry)} — ${formatUpcomingScheduleEntryDetail(entry, currentWeek, 0)}`; +function formatScheduleEntryLabel( + entry: PersonalScheduleEntry, + currentWeek: number, + date: string, + sourceWeekday?: WeekdayName, +): string { + return `${formatScheduleEntryTitle(entry)} — ${formatUpcomingScheduleEntryDetail(entry, currentWeek, 0, date, sourceWeekday)}`; } -function formatUpcomingScheduleEntryDetail(entry: PersonalScheduleEntry, week: number, dayOffset: number): string { - const start = PERIOD_START_TIMES[entry.periodStart ?? 0]; - const end = PERIOD_START_TIMES[entry.periodEnd ?? 0]; +function formatUpcomingScheduleEntryDetail( + entry: PersonalScheduleEntry, + week: number, + dayOffset: number, + date: string, + sourceWeekday?: WeekdayName, +): string { + const periods = periodStartTimesForDate(date); + const start = periods[entry.periodStart ?? 0]; + const end = periods[entry.periodEnd ?? 0]; const startText = start ? `${String(start[0]).padStart(2, "0")}:${String(start[1]).padStart(2, "0")}` : `P${entry.periodStart ?? "?"}`; const endTotal = end ? end[0] * 60 + end[1] + PERIOD_DURATION_MINUTES : undefined; const endText = endTotal === undefined ? `P${entry.periodEnd ?? "?"}` : formatPeriodEnd(endTotal); - const weekdayLabel = weekdayName(entry.day ?? 0); + const parsedDate = parseIsoDate(date); + const actualWeekday = parsedDate.getUTCDay() === 0 ? 7 : parsedDate.getUTCDay(); + const weekdayLabel = weekdayName(actualWeekday); const dayLabel = dayOffset === 0 ? "today" : dayOffset === 1 ? "tomorrow" : `week ${week} ${weekdayLabel}`; return [ - dayLabel, + `${dayLabel}${sourceWeekday ? ` (makeup for ${sourceWeekday})` : ""}`, `${startText}-${endText}`, entry.room, entry.teacher, @@ -527,11 +693,15 @@ function weekdayName(day: number): string { return ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"][day - 1] ?? `day ${day}`; } -function periodStartMinutes(period: number): number { - const slot = PERIOD_START_TIMES[period]; +function periodStartMinutes(period: number, date: string): number { + const slot = periodStartTimesForDate(date)[period]; return slot ? slot[0] * 60 + slot[1] : Number.POSITIVE_INFINITY; } +function periodStartTimesForDate(date: string): Readonly> { + return date >= CURRENT_PERIOD_SCHEDULE_START ? PERIOD_START_TIMES : LEGACY_PERIOD_START_TIMES; +} + async function inspectIcsDestination(destination: string, overwrite: boolean): Promise<{ destination: string; existed: boolean }> { const absolute = resolvePath(destination); await assertPathAndParentsAreNotSymlinks(absolute);