From ebd751dca66efc9bff782e7eef91bfff16f70193 Mon Sep 17 00:00:00 2001 From: Grada Date: Sat, 5 Sep 2026 17:19:23 +0800 Subject: [PATCH] feat: refine context into an AI-ready daily snapshot --- CHANGELOG.md | 8 + README.md | 38 ++- src/cli.ts | 332 ++++++++++++------------ src/context/live.ts | 18 +- src/context/schedule.ts | 75 ++++++ src/context/service.ts | 74 +++--- src/context/types.ts | 30 ++- src/test/cli.test.ts | 6 + src/test/context-live.test.ts | 6 + src/test/context.test.ts | 30 ++- src/test/tis-remaining-calendar.test.ts | 21 ++ 11 files changed, 423 insertions(+), 215 deletions(-) create mode 100644 src/context/schedule.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3706383..db294e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to `sustech-cli` are documented in this file. ## [Unreleased] +### Changed + +- Refined `context` into a Shanghai-time daily snapshot with structured current, + next and today's classes, teaching-week parity and makeup details, explicit + empty/unavailable sources, and weather/AQI at normal detail level. Environmental + observations retain source timestamps and freshness; public source failures + no longer prevent a partial snapshot. Non-today live snapshots are rejected. + ### Fixed - Made `auth status` use a metadata-only macOS Keychain lookup instead of diff --git a/README.md b/README.md index 3e657aa..b8a13e2 100644 --- a/README.md +++ b/README.md @@ -301,23 +301,45 @@ academic state once, compares it against the existing local state file when present, reports the changes, and updates that local file. It does not poll, it does not loop in the background, and it does not write any remote campus state. -## Context v2 +## Daily context for AI assistants ```bash sustech context --level terse sustech context --live --level normal +sustech context --live --json sustech context --live --level verbose ``` `context` now has three explicit detail levels: -- `terse`: compact calendar and near-term summary -- `normal`: adds the next deadline, next evaluation, and next exam when known -- `verbose`: adds public weather, AQI, and library-status observations when - `--live` is enabled - -`--live` keeps source status explicit. Missing or failed live sources stay -marked as missing or partial; they are not silently backfilled. +- `terse`: date, teaching week and parity, holiday/makeup timetable, and current/next class; only the timetable is requested with `--live` +- `normal` (default): adds the next assignment deadline, evaluation, exam, weather and AQI with `--live` +- `verbose`: also retrieves library opening status + +All dates and display times use **Asia/Shanghai**, including on overseas machines. +JSON includes `generatedAt` (snapshot creation), `referenceAt` (the instant used +for class/deadline selection), `timezone`, and the full public `academicDay`. +`schedule.currentClass`, `nextClass`, and `todayClasses` expose ISO timestamps, +periods, locations when available, and `makeupFor` dates. Current and next classes +can appear together; holiday/makeup dates use the same rules as ICS exports. + +`sourceStatus` distinguishes a successful empty result (`empty`) from unavailable +data (`missing`). `liveSources` adds errors, missing credentials, partial coverage, +and intentionally skipped requests (`not-requested`). An empty result describes +only the successfully retrieved sources; it is not a claim about all university +systems. A failed public calendar fetch does not prevent other available sources +from being returned. + +Weather and air quality include source URLs and upstream `observedAt` timestamps +when supplied. Observations older than three hours are labeled `stale`; absent +timestamps are `unknown`. AQI uses **US EPA** categories, not China's AQI scale. +Public environmental requests time out after eight seconds, and TIS, Blackboard, +and environmental reads run concurrently. + +Without `--live`, only the date/calendar snapshot is requested. Use +`context --date YYYY-MM-DD` for a calendar preview (reference time: noon in +Shanghai); combining a non-today date with `--live` is rejected so today's +observations cannot be mistaken for historical data or forecasts. ## Library catalog diff --git a/src/cli.ts b/src/cli.ts index d6dbb0e..42d59c3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -72,7 +72,8 @@ import { fetchContextWeather, } from "./context/live.js"; import { ContextService } from "./context/service.js"; -import type { ContextLevel, DeadlineSummary } from "./context/types.js"; +import { buildContextSchedule } from "./context/schedule.js"; +import type { ContextInput, ContextLevel, DeadlineSummary } from "./context/types.js"; import { DOCTOR_SERVICES, buildDoctorReport, @@ -150,7 +151,6 @@ import { resolveLiveRoom, scheduleIcsEvents, summariseEvaluationStatuses, - summariseCurrentOrNextClass, summariseLiveOccupancy, teachingPeriodAtShenzhenTime, verifySelectionWrite, @@ -2858,36 +2858,40 @@ async function runContext( ): Promise { if (positionals.length !== 1) throw usageError(`Unknown command: ${positionals.join(" ")}`); const date = isoDate(values.date ?? todayInShenzhen(), "--date"); + if (values.live && date !== todayInShenzhen()) throw usageError("--live is only available for today's date in Asia/Shanghai. Omit --live for a calendar date preview."); const year = Number(date.slice(0, 4)); - const calendar = await new CalendarClient().loadYear(year, calendarLevel(values["calendar-level"])); const level = contextLevel(values.level); + const requestedCalendarLevel = calendarLevel(values["calendar-level"]); + let calendar: AcademicCalendar | undefined; + let calendarError: string | undefined; + try { + calendar = await new CalendarClient().loadYear(year, requestedCalendarLevel); + } catch (error) { + calendarError = errorMessage(error); + } const service = new ContextService(); - const now = contextReferenceTime(date, values.live); + const now = contextReferenceTime(date); const live = values.live ? await loadLiveContext(date, now, calendar, values, level) : undefined; const snapshot = service.build({ now, calendar, - ...(live?.schedule ? { schedule: live.schedule } : {}), - ...(live?.nextDeadline ? { nextDeadline: live.nextDeadline } : {}), - ...(live?.nextEvaluation ? { nextEvaluation: live.nextEvaluation } : {}), - ...(live?.nextExam ? { nextExam: live.nextExam } : {}), - ...(live?.weather ? { weather: live.weather } : {}), - ...(live?.airQuality ? { airQuality: live.airQuality } : {}), - ...(live?.libraryStatus ? { libraryStatus: live.libraryStatus } : {}), + ...live, }, level); const liveText = live ? formatContextLiveSources(live.liveSources) : []; writeSuccess({ command: "context", data: { ...service.toRecord(snapshot), + mode: date === todayInShenzhen() ? "daily" : "date-preview", + calendarSource: { state: calendar ? "provided" : "error", ...(calendarError ? { message: calendarError } : {}) }, ...(live ? { liveSources: live.liveSources } : {}), }, - text: [...snapshot.lines, ...liveText].join("\n"), + text: [...snapshot.lines, ...liveText, ...(calendarError ? [`Calendar unavailable: ${calendarError}`] : []), ...(!live ? ["Personal and environmental sources not requested; use --live for a daily snapshot."] : [])].join("\n"), ...(live ? { meta: { liveSources: live.liveSources } } : {}), }, output); } -type ContextLiveSourceState = "provided" | "missing" | "partial" | "credentials-missing" | "error"; +type ContextLiveSourceState = "provided" | "empty" | "not-requested" | "missing" | "partial" | "credentials-missing" | "error"; interface ContextLiveSourceStatus { state: ContextLiveSourceState; @@ -2897,17 +2901,7 @@ interface ContextLiveSourceStatus { message?: string; } -async function loadLiveContext( - date: string, - now: Date, - calendar: AcademicCalendar, - values: Values, - level: ContextLevel, -): Promise<{ - schedule?: { now?: string; next?: string; nextDetail?: string; tomorrowMorning?: string }; - nextDeadline?: DeadlineSummary; - nextEvaluation?: { course: string; name: string; daysLeft?: number; dueAt?: string }; - nextExam?: { name: string; code: string; date: string; time?: string; building?: string; room?: string; campus?: string }; +interface ContextLiveResult extends ContextInput { liveSources: { tisSchedule: ContextLiveSourceStatus; tisExams: ContextLiveSourceStatus; @@ -2917,180 +2911,176 @@ async function loadLiveContext( airQuality?: ContextLiveSourceStatus; libraryStatus?: ContextLiveSourceStatus; }; - weather?: { condition: string; icon?: string; tempC?: number; feelsLikeC?: number; humidity?: number; windKmh?: number; precipitationMm?: number }; - airQuality?: { aqi: number; level?: string; pm25?: number; pm10?: number; ozone?: number }; - libraryStatus?: string; -}> { - const liveSources: { - tisSchedule: ContextLiveSourceStatus; - tisExams: ContextLiveSourceStatus; - blackboardDeadlines: ContextLiveSourceStatus; - tisEvaluations?: ContextLiveSourceStatus; - weather?: ContextLiveSourceStatus; - airQuality?: ContextLiveSourceStatus; - libraryStatus?: ContextLiveSourceStatus; - } = { +} + +async function loadLiveContext( + date: string, + now: Date, + calendar: AcademicCalendar | undefined, + values: Values, + level: ContextLevel, +): Promise { + const liveSources: ContextLiveResult["liveSources"] = { tisSchedule: { state: "missing" }, - tisExams: { state: "missing" }, - blackboardDeadlines: { state: "missing" }, + tisExams: { state: contextLoadsNormalFields(level) ? "missing" : "not-requested" }, + blackboardDeadlines: { state: contextLoadsNormalFields(level) ? "missing" : "not-requested" }, ...(contextLoadsNormalFields(level) ? { tisEvaluations: { state: "missing" as const } } : {}), - ...(contextLoadsVerboseFields(level) + ...(contextLoadsNormalFields(level) ? { weather: { state: "missing" as const }, airQuality: { state: "missing" as const }, - libraryStatus: { state: "missing" as const }, } : {}), + ...(contextLoadsVerboseFields(level) ? { libraryStatus: { state: "missing" as const } } : {}), }; - const result: { - schedule?: { now?: string; next?: string; nextDetail?: string; tomorrowMorning?: string }; - nextDeadline?: DeadlineSummary; - nextEvaluation?: { course: string; name: string; daysLeft?: number; dueAt?: string }; - nextExam?: { name: string; code: string; date: string; time?: string; building?: string; room?: string; campus?: string }; - liveSources: { - tisSchedule: ContextLiveSourceStatus; - tisExams: ContextLiveSourceStatus; - blackboardDeadlines: ContextLiveSourceStatus; - tisEvaluations?: ContextLiveSourceStatus; - weather?: ContextLiveSourceStatus; - airQuality?: ContextLiveSourceStatus; - libraryStatus?: ContextLiveSourceStatus; - }; - weather?: { condition: string; icon?: string; tempC?: number; feelsLikeC?: number; humidity?: number; windKmh?: number; precipitationMm?: number }; - airQuality?: { aqi: number; level?: string; pm25?: number; pm10?: number; ozone?: number }; - libraryStatus?: string; - } = { liveSources }; + const result: ContextLiveResult = { liveSources }; - const calendarDay = calendar.day(date); - const termSemester = calendarDay.semester; + const calendarDay = calendar?.day(date); + const termSemester = calendarDay?.semester; const semester = termSemester ? parseSemester(termSemester.semester.value) : parseSemester(undefined); - const currentWeek = calendarDay.week; + const calendarTerm = calendar?.terms().find((candidate) => candidate.snapshot.semester.value === semester.value); - let tis: TisClient | undefined; - try { - tis = await tisClient(values); - } catch (error) { - const message = errorMessage(error); - const state = error instanceof CliError && error.code === "CREDENTIALS_REQUIRED" ? "credentials-missing" : "error"; - liveSources.tisSchedule = { state, message }; - liveSources.tisExams = { state, message }; - if (liveSources.tisEvaluations) liveSources.tisEvaluations = { state, message }; - } - - if (tis) { - const [scheduleResult, examsResult, evaluationsResult] = await Promise.allSettled([ - currentWeek > 0 ? tis.schedule(semester) : Promise.resolve([] as PersonalScheduleEntry[]), - tis.exams(), - contextLoadsNormalFields(level) - ? tis.evaluations(semester.value, "all") - : Promise.resolve(undefined), - ]); + const loadTis = async () => { + let tis: TisClient | undefined; + try { + tis = await tisClient(values); + } catch (error) { + const message = errorMessage(error); + const state = error instanceof CliError && error.code === "CREDENTIALS_REQUIRED" ? "credentials-missing" : "error"; + liveSources.tisSchedule = { state, message }; + if (contextLoadsNormalFields(level)) liveSources.tisExams = { state, message }; + if (liveSources.tisEvaluations) liveSources.tisEvaluations = { state, message }; + } - if (scheduleResult.status === "fulfilled") { - if (currentWeek > 0) { - 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", - }; + if (tis) { + const [scheduleResult, examsResult, evaluationsResult] = await Promise.allSettled([ + calendarTerm ? tis.schedule(semester).then((entries) => buildContextSchedule(entries, calendarTerm, now)) : Promise.resolve(undefined), + contextLoadsNormalFields(level) ? tis.exams() : Promise.resolve([] as ExamRecord[]), + contextLoadsNormalFields(level) + ? tis.evaluations(semester.value, "all") + : Promise.resolve(undefined), + ]); + + if (scheduleResult.status === "fulfilled") { + if (scheduleResult.value) { + const schedule = scheduleResult.value; + result.schedule = schedule; + liveSources.tisSchedule = { + state: schedule.omissionCount ? "partial" : schedule.now || schedule.next || schedule.todayClasses?.length ? "provided" : "empty", + omissionCount: schedule.omissionCount, + ...(schedule.omissionCount ? { message: `${schedule.omissionCount} timetable row(s) lacked usable weeks or periods.` } : {}), + }; + } else { + liveSources.tisSchedule = { + state: "missing", + message: `Date ${date} is outside the loaded academic teaching weeks.`, + }; + } } else { liveSources.tisSchedule = { - state: "missing", - message: `Date ${date} is outside the loaded academic teaching weeks.`, + state: "error", + message: errorMessage(scheduleResult.reason), }; } - } else { - liveSources.tisSchedule = { - state: "error", - message: errorMessage(scheduleResult.reason), - }; - } - if (examsResult.status === "fulfilled") { - const semesterOmissions = examsResult.value - .filter((exam) => !matchesSemesterLabel(exam.semester, semester)) - .map((exam) => ({ - code: exam.code || exam.name || "exam", - message: exam.semester - ? `Skipped ${exam.code || exam.name || "exam"}: exam semester "${exam.semester}" did not match ${semester.value}.` - : `Skipped ${exam.code || exam.name || "exam"}: exam semester was missing.`, - })); - const selection = nearestUpcomingExam( - examsResult.value.filter((exam) => matchesSemesterLabel(exam.semester, semester)), - { now }, - ); - if (selection.exam) result.nextExam = contextExamSummary(selection.exam); - const omissionCount = selection.omissions.length + semesterOmissions.length; - liveSources.tisExams = { - state: omissionCount > 0 ? "partial" : selection.exam ? "provided" : "missing", - omissionCount, - ...((selection.omissions[0] ?? semesterOmissions[0]) ? { message: (selection.omissions[0] ?? semesterOmissions[0])?.message } : {}), - }; - } else { - liveSources.tisExams = { - state: "error", - message: errorMessage(examsResult.reason), - }; - } - - if (liveSources.tisEvaluations) { - if (evaluationsResult.status === "fulfilled") { - const selection = nextPendingEvaluationSummary(evaluationsResult.value ?? [], now); - if (selection.evaluation) result.nextEvaluation = selection.evaluation; - liveSources.tisEvaluations = { - state: selection.state, - omissionCount: selection.omissionCount, - ...(selection.message ? { message: selection.message } : {}), + if (contextLoadsNormalFields(level) && examsResult.status === "fulfilled") { + const semesterOmissions = examsResult.value + .filter((exam) => !matchesSemesterLabel(exam.semester, semester)) + .map((exam) => ({ + code: exam.code || exam.name || "exam", + message: exam.semester + ? `Skipped ${exam.code || exam.name || "exam"}: exam semester "${exam.semester}" did not match ${semester.value}.` + : `Skipped ${exam.code || exam.name || "exam"}: exam semester was missing.`, + })); + const selection = nearestUpcomingExam( + examsResult.value.filter((exam) => matchesSemesterLabel(exam.semester, semester)), + { now }, + ); + if (selection.exam) result.nextExam = contextExamSummary(selection.exam); + const omissionCount = selection.omissions.length + semesterOmissions.length; + if (!selection.exam && omissionCount === 0) result.nextExam = null; + liveSources.tisExams = { + state: omissionCount > 0 ? "partial" : selection.exam ? "provided" : "empty", + omissionCount, + ...((selection.omissions[0] ?? semesterOmissions[0]) ? { message: (selection.omissions[0] ?? semesterOmissions[0])?.message } : {}), }; - } else { - liveSources.tisEvaluations = { + } else if (examsResult.status === "rejected") { + liveSources.tisExams = { state: "error", - message: errorMessage(evaluationsResult.reason), + message: errorMessage(examsResult.reason), }; } + + if (liveSources.tisEvaluations) { + if (evaluationsResult.status === "fulfilled") { + const selection = nextPendingEvaluationSummary(evaluationsResult.value ?? [], now); + if (selection.evaluation) result.nextEvaluation = selection.evaluation; + else if (selection.state === "empty") result.nextEvaluation = null; + liveSources.tisEvaluations = { + state: selection.state, + omissionCount: selection.omissionCount, + ...(selection.message ? { message: selection.message } : {}), + }; + } else { + liveSources.tisEvaluations = { + state: "error", + message: errorMessage(evaluationsResult.reason), + }; + } + } } - } - try { - const adapter = await casServiceAdapter(values, "bb"); - const report = await listBlackboardDeadlines(adapter, { now }); - const deadline = nextBlackboardDeadline(report); - if (deadline) result.nextDeadline = contextDeadlineSummary(deadline); - liveSources.blackboardDeadlines = { - state: report.failures.length > 0 ? "partial" : deadline ? "provided" : "missing", - generatedAt: report.generatedAt, - failureCount: report.failures.length, - ...(report.failures[0]?.message ? { message: report.failures[0].message } : {}), - }; - } catch (error) { - const message = errorMessage(error); - liveSources.blackboardDeadlines = { - state: error instanceof CliError && error.code === "CREDENTIALS_REQUIRED" ? "credentials-missing" : "error", - message, - }; - } + }; - if (contextLoadsVerboseFields(level)) { - const [weatherResult, airQualityResult, libraryStatusResult] = await Promise.allSettled([ - fetchContextWeather(), - fetchContextAirQuality(), - fetchContextLibraryStatus(), - ]); + const loadDeadlines = async () => { + if (!contextLoadsNormalFields(level)) return; + try { + const adapter = await casServiceAdapter(values, "bb"); + const report = await listBlackboardDeadlines(adapter, { now }); + const deadline = nextBlackboardDeadline(report); + if (deadline) result.nextDeadline = contextDeadlineSummary(deadline); + else if (report.failures.length === 0) result.nextDeadline = null; + liveSources.blackboardDeadlines = { + state: report.failures.length > 0 ? "partial" : deadline ? "provided" : "empty", + generatedAt: report.generatedAt, + failureCount: report.failures.length, + ...(report.failures[0]?.message ? { message: report.failures[0].message } : {}), + }; + } catch (error) { + const message = errorMessage(error); + liveSources.blackboardDeadlines = { + state: error instanceof CliError && error.code === "CREDENTIALS_REQUIRED" ? "credentials-missing" : "error", + message, + }; + } + + }; - liveSources.weather = settledContextPublicSource(weatherResult); - if (weatherResult.status === "fulfilled" && weatherResult.value) result.weather = weatherResult.value; + const loadEnvironment = async () => { + if (contextLoadsNormalFields(level)) { + const [weatherResult, airQualityResult, libraryStatusResult] = await Promise.allSettled([ + fetchContextWeather(), + fetchContextAirQuality(), + contextLoadsVerboseFields(level) ? fetchContextLibraryStatus() : Promise.resolve(null), + ]); - liveSources.airQuality = settledContextPublicSource(airQualityResult); - if (airQualityResult.status === "fulfilled" && airQualityResult.value) result.airQuality = airQualityResult.value; + liveSources.weather = settledContextPublicSource(weatherResult); + if (weatherResult.status === "fulfilled" && weatherResult.value) result.weather = weatherResult.value; + + liveSources.airQuality = settledContextPublicSource(airQualityResult); + if (airQualityResult.status === "fulfilled" && airQualityResult.value) result.airQuality = airQualityResult.value; + + if (contextLoadsVerboseFields(level)) liveSources.libraryStatus = settledContextPublicSource(libraryStatusResult); + if (libraryStatusResult.status === "fulfilled" && libraryStatusResult.value) result.libraryStatus = libraryStatusResult.value; + } + }; - liveSources.libraryStatus = settledContextPublicSource(libraryStatusResult); - if (libraryStatusResult.status === "fulfilled" && libraryStatusResult.value) result.libraryStatus = libraryStatusResult.value; + await Promise.all([loadTis(), loadDeadlines(), loadEnvironment()]); + for (const source of Object.values(liveSources)) { + if (source.state !== "not-requested") source.generatedAt ??= new Date().toISOString(); } return result; @@ -3152,7 +3142,7 @@ function nextPendingEvaluationSummary( evaluation?: { course: string; name: string; daysLeft?: number; dueAt?: string }; } { const actionable = (rows ?? []).filter((row) => !row.submitted); - if (actionable.length === 0) return { state: "missing", omissionCount: 0 }; + if (actionable.length === 0) return { state: "empty", omissionCount: 0 }; const dated = actionable .map((row) => ({ row, due: parseContextDueAt(row.deadline) })) @@ -3430,8 +3420,8 @@ function formatTisIcalSourceStatus(status: TisIcalSourceStatus): string { return `${status.state}${extras ? ` (${extras})` : ""}`; } -function contextReferenceTime(date: string, live: boolean | undefined): Date { - if (live && date === todayInShenzhen()) return new Date(); +function contextReferenceTime(date: string): Date { + if (date === todayInShenzhen()) return new Date(); return new Date(`${date}T12:00:00+08:00`); } diff --git a/src/context/live.ts b/src/context/live.ts index df7f169..69cfcd0 100644 --- a/src/context/live.ts +++ b/src/context/live.ts @@ -39,7 +39,7 @@ export async function loadWeatherSummary( export async function fetchContextWeather( adapter: ServiceAdapter = createFetchAdapter(), ): Promise { - const raw = await fetchJson(adapter, "https://api.sustech.online/weather"); + const raw = await fetchJson(adapter, "https://api.sustech.online/weather", { signal: AbortSignal.timeout(8000) }); return normaliseContextWeather(raw); } @@ -82,14 +82,14 @@ export async function fetchContextAirQuality( "ozone", ], timezone: "Asia/Shanghai", - })); + }), { signal: AbortSignal.timeout(8000) }); return normaliseContextAirQuality(raw); } export async function fetchContextLibraryStatus( adapter: ServiceAdapter = createFetchAdapter(), ): Promise { - const html = await fetchText(adapter, "https://lib.sustech.edu.cn/"); + const html = await fetchText(adapter, "https://lib.sustech.edu.cn/", { signal: AbortSignal.timeout(8000) }); return parseContextLibraryStatus(html); } @@ -184,6 +184,8 @@ export function normaliseContextWeather(raw: unknown): WeatherSummary | null { const temp = /气温\s*(-?\d+(?:\.\d+)?)\s*℃/.exec(text); const feelsLike = /体感\s*(-?\d+(?:\.\d+)?)\s*℃/.exec(text); return { + source: "https://api.sustech.online/weather", + ...observationTime(recordValue(raw).update_time), condition, ...(temp ? { tempC: Math.round(Number(temp[1])) } : {}), ...(feelsLike ? { feelsLikeC: Math.round(Number(feelsLike[1])) } : {}), @@ -195,6 +197,9 @@ export function normaliseContextAirQuality(raw: unknown): AirQualitySummary | nu const aqi = optionalNumber(current.us_aqi); if (aqi === undefined) return null; return { + standard: "US EPA", + source: "https://air-quality-api.open-meteo.com", + ...observationTime(current.time), aqi, level: aqiLevel(aqi), pm25: optionalNumber(current.pm2_5), @@ -203,6 +208,13 @@ export function normaliseContextAirQuality(raw: unknown): AirQualitySummary | nu }; } +function observationTime(value: unknown): { observedAt?: string } { + if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(value)) return {}; + const normalized = value.replace(" ", "T"); + const timestamp = new Date(/[zZ]|[+-]\d{2}:\d{2}$/.test(normalized) ? normalized : `${normalized}+08:00`); + return Number.isNaN(timestamp.getTime()) ? {} : { observedAt: timestamp.toISOString() }; +} + export function parseContextLibraryStatus(html: string): string | null { const spanBody = String.raw`((?:(?!<\/span>)[\s\S])*)`; const matches = [...html.matchAll(new RegExp( diff --git a/src/context/schedule.ts b/src/context/schedule.ts new file mode 100644 index 0000000..8eda930 --- /dev/null +++ b/src/context/schedule.ts @@ -0,0 +1,75 @@ +import type { CalendarTerm } from "../calendar/client.js"; +import { CliError } from "../core/errors.js"; +import { scheduleOccurrences } from "../tis/remaining-calendar.js"; +import type { PersonalScheduleEntry } from "../tis/types.js"; +import type { ContextClass, ScheduleReminder } from "./types.js"; + +/** Use the same holiday/makeup instances as ICS, with explicit timestamps for agents. */ +export function buildContextSchedule(entries: readonly PersonalScheduleEntry[], term: CalendarTerm, now: Date): ScheduleReminder { + const date = new Intl.DateTimeFormat("sv-SE", { timeZone: "Asia/Shanghai" }).format(now); + let omissionCount = 0; + const occurrences = entries.flatMap((entry) => { + if (entry.day === undefined || entry.periodStart === undefined || entry.periodEnd === undefined || entry.weeks.length === 0) { + omissionCount++; + return []; + } + try { + return scheduleOccurrences([entry], { teachingStartDate: term.snapshot.teachingStart }, term); + } catch (error) { + if (!(error instanceof CliError) || error.code !== "UNSUPPORTED_PERIOD") throw error; + omissionCount++; + return []; + } + }).sort((left, right) => left.startUtc.localeCompare(right.startUtc) || left.uid.localeCompare(right.uid)); + const classes = occurrences + .map((occurrence): ContextClass => { + const startAt = expandedUtc(occurrence.startUtc); + const endAt = expandedUtc(occurrence.endUtc); + return { + name: occurrence.summary, + startAt, + endAt, + ...(occurrence.location ? { location: occurrence.location } : {}), + week: occurrence.week, + periodStart: occurrence.periodStart, + periodEnd: occurrence.periodEnd, + status: now.getTime() >= Date.parse(endAt) ? "completed" + : now.getTime() >= Date.parse(startAt) ? "in-progress" : "upcoming", + ...(occurrence.sourceDate ? { makeupFor: occurrence.sourceDate } : {}), + }; + }); + const currentClass = classes.find((item) => item.status === "in-progress"); + const nextClass = classes.find((item) => item.status === "upcoming"); + const todayClasses = classes.filter((item) => shanghaiDate(item.startAt) === date); + const tomorrow = shanghaiDate(new Date(Date.parse(`${date}T00:00:00+08:00`) + 86400000).toISOString()); + return { + todayClasses, + omissionCount, + ...(currentClass ? { currentClass, now: `${currentClass.name} — ${classDetail(currentClass)}` } : {}), + ...(nextClass ? { + nextClass, + next: nextClass.name, + nextDetail: classDetail(nextClass), + ...(shanghaiDate(nextClass.startAt) === tomorrow && shanghaiTime(nextClass.startAt) < "12:00" + ? { tomorrowMorning: `${nextClass.name} — ${classDetail(nextClass)}` } : {}), + } : {}), + }; +} + +function expandedUtc(value: string): string { + return value.replace(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/, "$1-$2-$3T$4:$5:$6Z"); +} + +function shanghaiDate(value: string): string { + return new Intl.DateTimeFormat("sv-SE", { timeZone: "Asia/Shanghai" }).format(new Date(value)); +} + +function shanghaiTime(value: string): string { + return new Intl.DateTimeFormat("en-GB", { timeZone: "Asia/Shanghai", hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).format(new Date(value)); +} + +function classDetail(item: ContextClass): string { + return `${shanghaiDate(item.startAt)} ${shanghaiTime(item.startAt)}–${shanghaiTime(item.endAt)}` + + (item.location ? ` @ ${item.location}` : "") + + (item.makeupFor ? ` (makeup for ${item.makeupFor})` : ""); +} diff --git a/src/context/service.ts b/src/context/service.ts index 68cfa58..4dc4d39 100644 --- a/src/context/service.ts +++ b/src/context/service.ts @@ -23,9 +23,9 @@ export class ContextService { const sourceStatus: ContextSourceStatus = { academicDay: academic.state, schedule: input.schedule ? "provided" : "missing", - nextDeadline: input.nextDeadline === undefined ? "missing" : "provided", - nextEvaluation: input.nextEvaluation === undefined ? "missing" : "provided", - nextExam: input.nextExam === undefined ? "missing" : "provided", + nextDeadline: sourceState(input.nextDeadline), + nextEvaluation: sourceState(input.nextEvaluation), + nextExam: sourceState(input.nextExam), weather: input.weather === undefined ? "missing" : "provided", airQuality: input.airQuality === undefined ? "missing" : "provided", libraryStatus: input.libraryStatus === undefined ? "missing" : "provided", @@ -33,10 +33,14 @@ export class ContextService { const snapshot: ContextSnapshot = { level, - generatedAt: now.toISOString(), + generatedAt: (input.generatedAt ?? new Date()).toISOString(), + referenceAt: now.toISOString(), + timezone: "Asia/Shanghai", date: formatDate(now), time: formatTime(now), - weekday: WEEKDAY_NAMES[now.getDay()], + weekday: new Intl.DateTimeFormat("en-US", { timeZone: "Asia/Shanghai", weekday: "long" }).format(now), + ...(academic.day ? { academicDay: academic.day } : {}), + ...(academic.day && academic.day.week > 0 ? { weekParity: academic.day.week % 2 ? "odd" as const : "even" as const } : {}), ...(academic.day?.week ? { week: academic.day.week } : {}), ...(academic.day?.label ? { label: academic.day.label } : {}), ...(academic.day?.phase ? { phase: academic.day.phase } : {}), @@ -46,10 +50,10 @@ export class ContextService { nextDeadline: input.nextDeadline ?? null, nextEvaluation: input.nextEvaluation ?? null, nextExam: input.nextExam ?? null, + weather: environmentFreshness(input.weather, now), + airQuality: environmentFreshness(input.airQuality, now), } : {}), ...(LEVEL_ORDER[level] >= LEVEL_ORDER.verbose ? { - weather: input.weather ?? null, - airQuality: input.airQuality ?? null, libraryStatus: input.libraryStatus ?? null, } : {}), sourceStatus, @@ -61,6 +65,9 @@ export class ContextService { public toRecord(snapshot: ContextSnapshot): Record { const record: Record = { + generatedAt: snapshot.generatedAt, + referenceAt: snapshot.referenceAt, + timezone: snapshot.timezone, date: snapshot.date, time: snapshot.time, weekday: snapshot.weekday, @@ -71,14 +78,16 @@ export class ContextService { if (snapshot.label) record.label = snapshot.label; if (snapshot.phase) record.phase = snapshot.phase; if (snapshot.holiday) record.holiday = snapshot.holiday; + if (snapshot.academicDay) record.academicDay = snapshot.academicDay; + if (snapshot.weekParity) record.weekParity = snapshot.weekParity; if (LEVEL_ORDER[snapshot.level] >= LEVEL_ORDER.normal) { record.nextDeadline = snapshot.nextDeadline ?? null; record.nextEvaluation = snapshot.nextEvaluation ?? null; record.nextExam = snapshot.nextExam ?? null; - } - if (LEVEL_ORDER[snapshot.level] >= LEVEL_ORDER.verbose) { record.weather = snapshot.weather ?? null; record.airQuality = snapshot.airQuality ?? null; + } + if (LEVEL_ORDER[snapshot.level] >= LEVEL_ORDER.verbose) { record.libraryStatus = snapshot.libraryStatus ?? null; } return record; @@ -90,29 +99,33 @@ export class ContextService { } function renderLines(snapshot: ContextSnapshot): string[] { + const isToday = snapshot.date === formatDate(new Date(snapshot.generatedAt)); const lines = [ - `Today is [${snapshot.date}], [${snapshot.weekday}]`, + `${isToday ? "Today is" : "Date preview:"} [${snapshot.date}], [${snapshot.weekday}]`, ...(snapshot.label ? [`According to SUSTech academic calendar, this is [${snapshot.label}]`] : []), - `Current time is [${snapshot.time}]`, + `${isToday ? "Current time is" : "Reference time:"} [${snapshot.time}] (Asia/Shanghai)`, ...(snapshot.holiday ? [`Today is [${snapshot.holiday}]`] : []), + ...(snapshot.academicDay?.compensatory ? [`Makeup timetable: ${snapshot.academicDay.compensatory.weekType} ${snapshot.academicDay.compensatory.workday}`] : []), ]; appendSchedule(lines, snapshot.schedule); if (LEVEL_ORDER[snapshot.level] >= LEVEL_ORDER.normal) appendNormal(lines, snapshot.nextDeadline ?? null, snapshot.nextEvaluation ?? null, snapshot.nextExam ?? null); - if (LEVEL_ORDER[snapshot.level] >= LEVEL_ORDER.verbose) appendVerbose(lines, snapshot.weather ?? null, snapshot.airQuality ?? null, snapshot.libraryStatus ?? null); + if (LEVEL_ORDER[snapshot.level] >= LEVEL_ORDER.normal) appendVerbose(lines, snapshot.weather ?? null, snapshot.airQuality ?? null, snapshot.libraryStatus ?? null); + if (snapshot.sourceStatus.nextDeadline === "empty" && LEVEL_ORDER[snapshot.level] >= LEVEL_ORDER.normal) lines.push("No upcoming assignments in the retrieved Blackboard deadlines."); + if (snapshot.sourceStatus.nextExam === "empty" && LEVEL_ORDER[snapshot.level] >= LEVEL_ORDER.normal) lines.push("No upcoming exams in the retrieved TIS records."); return lines; } function appendSchedule(lines: string[], schedule: ScheduleReminder): void { + if (schedule.todayClasses?.length === 0 && !schedule.omissionCount) lines.push("No classes scheduled today in the retrieved timetable."); if (schedule.now) { lines.push(`Now: [${schedule.now}]`); - return; } if (schedule.next) { const detail = schedule.nextDetail ? ` — ${schedule.nextDetail}` : ""; lines.push(`Next: [${schedule.next}]${detail}`); return; } - if (schedule.tomorrowMorning) { + if (!schedule.next && schedule.tomorrowMorning) { lines.push(`Tomorrow morning: [${schedule.tomorrowMorning}]`); } } @@ -123,7 +136,7 @@ function appendNormal( nextEvaluation: EvaluationSummary | null, nextExam: ExamSummary | null, ): void { - if (nextDeadline) lines.push(`Next deadline: [${nextDeadline.name}] — ${deadlineStatus(nextDeadline.daysLeft, nextDeadline.dueAt)}`); + if (nextDeadline) lines.push(`Next deadline: [${nextDeadline.name}] — ${deadlineStatus(nextDeadline.daysLeft, nextDeadline.dueAt)}${nextDeadline.dueAt ? ` (${nextDeadline.dueAt})` : ""}`); if (nextEvaluation) lines.push(`Next evaluation: [${nextEvaluation.course} — ${nextEvaluation.name}] — ${deadlineStatus(nextEvaluation.daysLeft, nextEvaluation.dueAt, "Evaluation")}`); if (nextExam) { const location = [nextExam.building, nextExam.room].filter(Boolean).join(" ").trim() || nextExam.campus || ""; @@ -139,10 +152,10 @@ function appendVerbose( ): void { if (weather?.condition) { const temperature = weather.tempC !== undefined ? ` ${weather.tempC}C` : ""; - lines.push(`Weather at SUSTech: [${weather.condition}]${temperature}`); + lines.push(`Weather at SUSTech: [${weather.condition}]${temperature}${observationLabel(weather)}`); } if (airQuality) { - lines.push(`Air quality: [AQI ${airQuality.aqi}]${airQuality.level ? ` ${airQuality.level}` : ""}`); + lines.push(`Air quality: [${airQuality.standard ? `${airQuality.standard} ` : ""}AQI ${airQuality.aqi}]${airQuality.level ? ` ${airQuality.level}` : ""}${observationLabel(airQuality)}`); } if (libraryStatus) { lines.push(`Library: [${libraryStatus}]`); @@ -176,23 +189,24 @@ function normaliseNow(value: Date | string | undefined): Date { } function formatDate(now: Date): string { - return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; + return new Intl.DateTimeFormat("sv-SE", { timeZone: "Asia/Shanghai" }).format(now); } function formatTime(now: Date): string { - return `${pad(now.getHours())}:${pad(now.getMinutes())}`; + return new Intl.DateTimeFormat("en-GB", { timeZone: "Asia/Shanghai", hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).format(now); +} + +function sourceState(value: unknown): SourceState { + return value === undefined ? "missing" : value === null ? "empty" : "provided"; } -function pad(value: number): string { - return String(value).padStart(2, "0"); +function environmentFreshness(value: T | null | undefined, now: Date): T | null { + if (!value) return null; + const observed = value.observedAt ? Date.parse(value.observedAt) : NaN; + return { ...value, freshness: !Number.isFinite(observed) ? "unknown" : now.getTime() - observed > 3 * 3600000 ? "stale" : "fresh" }; } -const WEEKDAY_NAMES = [ - "Sunday", - "Monday", - "Tuesday", - "Wednesday", - "Thursday", - "Friday", - "Saturday", -]; +function observationLabel(value: WeatherSummary | AirQualitySummary): string { + return value.freshness === "stale" ? ` (stale; observed ${value.observedAt})` + : value.observedAt ? ` (observed ${value.observedAt})` : " (observation time unavailable)"; +} diff --git a/src/context/types.ts b/src/context/types.ts index 55bba7d..5f75b39 100644 --- a/src/context/types.ts +++ b/src/context/types.ts @@ -2,13 +2,29 @@ import type { AcademicCalendar } from "../calendar/client.js"; import type { CalendarDayInfo } from "../calendar/types.js"; export type ContextLevel = "terse" | "normal" | "verbose"; -export type SourceState = "provided" | "derived" | "missing"; +export type SourceState = "provided" | "derived" | "empty" | "missing"; + +export interface ContextClass { + name: string; + startAt: string; + endAt: string; + location?: string; + week: number; + periodStart: number; + periodEnd: number; + status: "completed" | "in-progress" | "upcoming"; + makeupFor?: string; +} export interface ScheduleReminder { now?: string; next?: string; nextDetail?: string; tomorrowMorning?: string; + currentClass?: ContextClass; + nextClass?: ContextClass; + todayClasses?: ContextClass[]; + omissionCount?: number; } export interface DeadlineSummary { @@ -36,6 +52,9 @@ export interface ExamSummary { } export interface WeatherSummary { + source?: string; + observedAt?: string; + freshness?: "fresh" | "stale" | "unknown"; condition: string; icon?: string; tempC?: number; @@ -46,6 +65,10 @@ export interface WeatherSummary { } export interface AirQualitySummary { + standard?: "US EPA"; + source?: string; + observedAt?: string; + freshness?: "fresh" | "stale" | "unknown"; aqi: number; level?: string; pm25?: number; @@ -55,6 +78,7 @@ export interface AirQualitySummary { export interface ContextInput { now?: Date | string; + generatedAt?: Date; calendar?: AcademicCalendar; academicDay?: CalendarDayInfo; schedule?: ScheduleReminder; @@ -80,6 +104,8 @@ export interface ContextSourceStatus { export interface ContextSnapshot { level: ContextLevel; generatedAt: string; + referenceAt: string; + timezone: "Asia/Shanghai"; date: string; time: string; weekday: string; @@ -87,6 +113,8 @@ export interface ContextSnapshot { label?: string; phase?: string; holiday?: string; + academicDay?: CalendarDayInfo; + weekParity?: "odd" | "even"; schedule: ScheduleReminder; nextDeadline?: DeadlineSummary | null; nextEvaluation?: EvaluationSummary | null; diff --git a/src/test/cli.test.ts b/src/test/cli.test.ts index 6b92f4b..581f17d 100644 --- a/src/test/cli.test.ts +++ b/src/test/cli.test.ts @@ -916,6 +916,12 @@ test("context live supports calendar level and degrades gracefully when credenti assert.equal(envelope.data.liveSources.blackboardDeadlines.state, "credentials-missing"); }); +test("context rejects mixing live observations with a historical date before accessing sources", () => { + const result = runWithoutCredentials(["context", "--date", "2020-01-01", "--live", "--json"]); + assert.equal(result.status, 2); + assert.match(JSON.parse(result.stdout).error.message, /only available for today's date/); +}); + test("profile commands remain machine-readable when credentials are unavailable", () => { const show = runWithoutCredentials(["profile", "show", "--json"]); assert.equal(show.status, 0); diff --git a/src/test/context-live.test.ts b/src/test/context-live.test.ts index d8f2f0d..831a089 100644 --- a/src/test/context-live.test.ts +++ b/src/test/context-live.test.ts @@ -14,6 +14,8 @@ test("context weather parser extracts concise condition and rounded temperatures update_time: "2026-08-28T12:00:00+08:00", }), { + source: "https://api.sustech.online/weather", + observedAt: "2026-08-28T04:00:00.000Z", condition: "气温26.8℃,体感29.1℃,近两个小时内无降雨。", tempC: 27, feelsLikeC: 29, @@ -26,6 +28,7 @@ test("context AQI parser preserves particles and maps standard levels", () => { assert.deepEqual( normaliseContextAirQuality({ current: { + time: "2026-08-28T12:00", us_aqi: 88, pm2_5: 18.4, pm10: 26.1, @@ -33,6 +36,9 @@ test("context AQI parser preserves particles and maps standard levels", () => { }, }), { + standard: "US EPA", + source: "https://air-quality-api.open-meteo.com", + observedAt: "2026-08-28T04:00:00.000Z", aqi: 88, level: "Moderate", pm25: 18.4, diff --git a/src/test/context.test.ts b/src/test/context.test.ts index c9216ec..efc3e8b 100644 --- a/src/test/context.test.ts +++ b/src/test/context.test.ts @@ -131,7 +131,33 @@ test("context service exposes verbose environmental fields and explicit partial assert.match(service.toText(snapshot), /Library: \[Main Hall: Open\]/); const record = service.toRecord(snapshot); - assert.deepEqual(record.weather, { condition: "晴", tempC: 26, feelsLikeC: 29 }); - assert.deepEqual(record.airQuality, { aqi: 48, level: "Good" }); + assert.deepEqual(record.weather, { condition: "晴", tempC: 26, feelsLikeC: 29, freshness: "unknown" }); + assert.deepEqual(record.airQuality, { aqi: 48, level: "Good", freshness: "unknown" }); assert.equal(record.libraryStatus, "Main Hall: Open"); }); + +test("daily context uses Shanghai midnight and distinguishes empty sources from unavailable ones", () => { + const service = new ContextService(); + const snapshot = service.build({ + now: "2026-09-06T16:05:00Z", + generatedAt: new Date("2026-09-06T16:06:00Z"), + nextDeadline: null, + weather: { condition: "Clear", observedAt: "2026-09-06T12:00:00Z" }, + airQuality: { aqi: 30, standard: "US EPA", observedAt: "2026-09-06T16:00:00Z" }, + schedule: { now: "Synthetic A", next: "Synthetic B", nextDetail: "09:00" }, + }); + assert.equal(snapshot.date, "2026-09-07"); + assert.equal(snapshot.time, "00:05"); + assert.equal(snapshot.weekday, "Monday"); + assert.equal(snapshot.sourceStatus.nextDeadline, "empty"); + assert.equal(snapshot.sourceStatus.nextExam, "missing"); + assert.equal(snapshot.weather?.freshness, "stale"); + assert.equal(snapshot.airQuality?.freshness, "fresh"); + assert.match(service.toText(snapshot), /Now:.*Synthetic A/); + assert.match(service.toText(snapshot), /Next:.*Synthetic B/); + assert.match(service.toText(snapshot), /US EPA AQI/); + const record = service.toRecord(snapshot); + assert.equal(record.timezone, "Asia/Shanghai"); + assert.equal(record.referenceAt, "2026-09-06T16:05:00.000Z"); + assert.equal(record.generatedAt, "2026-09-06T16:06:00.000Z"); +}); diff --git a/src/test/tis-remaining-calendar.test.ts b/src/test/tis-remaining-calendar.test.ts index 85fd24a..18a2ff0 100644 --- a/src/test/tis-remaining-calendar.test.ts +++ b/src/test/tis-remaining-calendar.test.ts @@ -3,6 +3,7 @@ import { mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promise import { join } from "node:path"; import test from "node:test"; import { CalendarTerm } from "../calendar/client.js"; +import { buildContextSchedule } from "../context/schedule.js"; import { buildIcsContent, buildScheduleIcs, @@ -63,6 +64,26 @@ test("week-one inference backtracks from today's week index to the semester anch assert.equal(inferWeekOneMonday("2026-03-04", 2), "2026-02-23"); }); +test("daily snapshot exposes current and next classes together on the adjusted makeup date", () => { + const morning = { ...ENTRIES[0], day: 5, weeks: [3], periodStart: 5, periodEnd: 6 }; + const afternoon = { ...morning, rwh: "R2", periodStart: 7, periodEnd: 8 }; + const snapshot = buildContextSchedule([morning, afternoon], FALL_2026, new Date("2026-09-20T14:30:00+08:00")); + assert.equal(snapshot.todayClasses?.length, 2); + assert.equal(snapshot.currentClass?.startAt, "2026-09-20T06:00:00Z"); + assert.equal(snapshot.nextClass?.startAt, "2026-09-20T08:20:00Z"); + assert.equal(snapshot.currentClass?.makeupFor, "2026-09-25"); + assert.ok(snapshot.now && snapshot.next); + const holiday = buildContextSchedule([morning], FALL_2026, new Date("2026-09-25T14:30:00+08:00")); + assert.deepEqual(holiday.todayClasses, []); + assert.equal(holiday.nextClass, undefined); + const beforeTerm = buildContextSchedule([{ ...ENTRIES[0], weeks: [1] }], FALL_2026, new Date("2026-09-05T10:00:00+08:00")); + assert.equal(beforeTerm.nextClass?.startAt, "2026-09-07T02:20:00Z"); + const priorDay = buildContextSchedule([morning], FALL_2026, new Date("2026-09-19T10:00:00+08:00")); + assert.equal(priorDay.tomorrowMorning, undefined); + const unknownTime = buildContextSchedule([{ ...morning, weeks: [] }], FALL_2026, new Date("2026-09-20T10:00:00+08:00")); + assert.equal(unknownTime.omissionCount, 1); +}); + test("ICS export expands schedule entries into dated UTC events", () => { const occurrences = scheduleOccurrences(ENTRIES, { weekOneMonday: "2026-02-23" }); assert.equal(occurrences.length, 2);