diff --git a/docs/features/overview.json b/docs/features/overview.json index 092082e3..fa92e5ab 100644 --- a/docs/features/overview.json +++ b/docs/features/overview.json @@ -81,7 +81,7 @@ "returns": "{ user, currentSemester, subscriptions, nextClass, upcomingDeadlines, upcomingEvents, todos, bus }", "notes": [ "Preferred single-call snapshot for assistant workflows.", - "Overlaps get_my_overview but adds currentSemester, currentSemesterSections, nextClass, merged deadlines, and preferred-bus departures.", + "Overlaps get_my_overview but adds currentSemester, currentSemesterSections, nextClass, merged deadlines, and preferred-bus departures; accepts optional atTime to anchor those time windows instead of using the server clock.", "Default mode should stay assistant-compact; request full mode when the caller explicitly needs full nested schedule, room, teacher, or homework payloads.", "Summary mode should be materially smaller than default, focusing on counts and the next few actionable items instead of mirroring the full compact snapshot." ] @@ -89,7 +89,10 @@ { "name": "get_next_class", "returns": "{ found: Boolean, nextClass, currentSemester }", - "notes": ["Focused extract for 'what is my next class?' prompts."] + "notes": [ + "Focused extract for 'what is my next class?' prompts.", + "Accepts optional atTime to anchor the lookup instead of using the server clock." + ] }, { "name": "get_upcoming_deadlines", diff --git a/src/app/page.tsx b/src/app/page.tsx index d89507b8..7cc0ee4d 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -20,6 +20,8 @@ import { getSubscriptionsTabData, getTodosTabData, } from "@/features/home/server/dashboard-tab-data"; +import { parseDateInput } from "@/lib/time/parse-date-input"; +import { toShanghaiIsoString } from "@/lib/time/serialize-date-output"; export const dynamic = "force-dynamic"; @@ -27,8 +29,15 @@ type HomeSearchParams = { tab?: string; dayType?: string; calendarSemester?: string; + snapshotAt?: string; }; +function parseSnapshotReferenceTime(value: string | undefined) { + if (process.env.E2E_DEBUG_AUTH !== "1" || !value) return undefined; + const parsed = parseDateInput(value); + return parsed instanceof Date ? parsed : undefined; +} + export async function generateMetadata(): Promise { const t = await getTranslations("metadata"); @@ -47,6 +56,7 @@ export default async function HomePage({ if (session?.user?.id) { const params = await searchParams; const tab = params.tab ?? "overview"; + const referenceNow = parseSnapshotReferenceTime(params.snapshotAt); const parsedCalendarSemester = parseInt(params.calendarSemester ?? "", 10); const overviewOptions = tab === "calendar" && @@ -55,10 +65,11 @@ export default async function HomePage({ ? { calendarSemesterId: parsedCalendarSemester, skipLinks: true, + referenceNow, } : tab === "calendar" - ? { skipLinks: true } - : {}; + ? { skipLinks: true, referenceNow } + : { referenceNow }; const [ navStats, @@ -70,7 +81,7 @@ export default async function HomePage({ todosData, busData, ] = await Promise.all([ - getDashboardNavStats(session.user.id), + getDashboardNavStats(session.user.id, referenceNow), tab === "overview" || tab === "calendar" ? getDashboardOverviewData(session.user.id, overviewOptions) : Promise.resolve(null), @@ -104,6 +115,7 @@ export default async function HomePage({ ("incomplete"); const filteredExams = useMemo(() => { - const now = dayjs(); + const anchoredNow = referenceNow ? dayjs(referenceNow) : null; + const now = anchoredNow?.isValid() ? anchoredNow : dayjs(); if (filter === "completed") { return exams.filter((exam) => isExamCompleted(exam, now)); } @@ -72,7 +79,7 @@ export function ExamsList({ exams }: { exams: ExamRow[] }) { return exams.filter((exam) => !isExamCompleted(exam, now)); } return exams; - }, [exams, filter]); + }, [exams, filter, referenceNow]); return (
diff --git a/src/features/home/components/exams-panel.tsx b/src/features/home/components/exams-panel.tsx index f95e9bc4..79383d70 100644 --- a/src/features/home/components/exams-panel.tsx +++ b/src/features/home/components/exams-panel.tsx @@ -25,7 +25,13 @@ type ExamRow = { rooms: string; }; -export async function ExamsPanel({ data }: { data: SubscriptionsTabData }) { +export async function ExamsPanel({ + data, + referenceNow, +}: { + data: SubscriptionsTabData; + referenceNow?: string | null; +}) { const tNav = await getTranslations("meDashboard.nav"); const tCommon = await getTranslations("common"); @@ -95,5 +101,5 @@ export async function ExamsPanel({ data }: { data: SubscriptionsTabData }) { ); } - return ; + return ; } diff --git a/src/features/home/components/home-view.tsx b/src/features/home/components/home-view.tsx index 8c2a774c..2e65b117 100644 --- a/src/features/home/components/home-view.tsx +++ b/src/features/home/components/home-view.tsx @@ -48,6 +48,7 @@ type HomeViewProps = { overviewWeek?: string; }>; navStats: DashboardNavStats; + referenceNow: string | null; overviewData: OverviewData | null; linksData: DashboardLinkSummary[] | null; homeworksData: { @@ -63,6 +64,7 @@ type HomeViewProps = { export async function HomeView({ searchParams, navStats, + referenceNow, overviewData, linksData, homeworksData, @@ -124,7 +126,7 @@ export async function HomeView({ )} {currentTab === "todos" && } {currentTab === "exams" && subscriptionsData && ( - + )} {currentTab === "subscriptions" && subscriptionsData && ( diff --git a/src/features/home/server/assistant-dashboard-snapshot.ts b/src/features/home/server/assistant-dashboard-snapshot.ts index d964abbc..7c5ce771 100644 --- a/src/features/home/server/assistant-dashboard-snapshot.ts +++ b/src/features/home/server/assistant-dashboard-snapshot.ts @@ -19,8 +19,9 @@ export async function getAssistantDashboardSnapshot(input: { userId: string; locale: AppLocale; dayLimit?: number; + atTime?: Date; }) { - const now = new Date(); + const now = input.atTime ?? new Date(); const dayLimit = input.dayLimit ?? 7; const dateTo = new Date(now.getTime() + dayLimit * 24 * 60 * 60 * 1000); const currentSemester = await findCurrentSemester(prisma.semester, now); diff --git a/src/features/home/server/calendar-events.ts b/src/features/home/server/calendar-events.ts index 7dfeac5f..764601bc 100644 --- a/src/features/home/server/calendar-events.ts +++ b/src/features/home/server/calendar-events.ts @@ -14,6 +14,10 @@ function startOfShanghaiDay(date: Date) { return new Date(`${formatShanghaiDate(date)}T00:00:00+08:00`); } +function addDays(date: Date, days: number) { + return new Date(date.getTime() + days * 24 * 60 * 60 * 1000); +} + function toDateTimeFromHHmm(baseDate: Date | null, hhmm: number | null) { if (!baseDate) return null; @@ -24,28 +28,81 @@ function toDateTimeFromHHmm(baseDate: Date | null, hhmm: number | null) { ); } +function isWithinExactWindow( + { + start, + end, + }: { + start: Date | null; + end?: Date | null; + }, + windowStart: Date, + windowEnd: Date, + includeWindowEnd: boolean, +) { + if (!start) return false; + + const startTime = start.getTime(); + if (Number.isNaN(startTime)) return false; + + if (end) { + const endTime = end.getTime(); + if (Number.isNaN(endTime)) return false; + return ( + endTime > windowStart.getTime() && + (includeWindowEnd + ? startTime <= windowEnd.getTime() + : startTime < windowEnd.getTime()) + ); + } + + return ( + startTime >= windowStart.getTime() && + (includeWindowEnd + ? startTime <= windowEnd.getTime() + : startTime < windowEnd.getTime()) + ); +} + export async function listUserCalendarEvents( userId: string, { locale = DEFAULT_LOCALE, dateFrom, dateTo, + dateFromIsDateOnly = false, + dateToIsDateOnly = false, + dateToInclusive = false, }: { locale?: string; dateFrom?: Date | null; dateTo?: Date | null; + dateFromIsDateOnly?: boolean; + dateToIsDateOnly?: boolean; + dateToInclusive?: boolean; } = {}, ) { - const windowStart = dateFrom ?? startOfShanghaiDay(new Date()); + const windowStart = dateFrom + ? dateFromIsDateOnly + ? startOfShanghaiDay(dateFrom) + : dateFrom + : startOfShanghaiDay(new Date()); const windowEnd = - dateTo ?? new Date(windowStart.getTime() + 7 * 24 * 60 * 60 * 1000); + dateTo && dateToIsDateOnly + ? addDays(startOfShanghaiDay(dateTo), 1) + : (dateTo ?? addDays(windowStart, 7)); + const includeWindowEnd = Boolean( + dateTo && dateToInclusive && !dateToIsDateOnly, + ); + const calendarDateStart = startOfShanghaiDay(windowStart); + const calendarDateEnd = dateTo ?? windowEnd; const sectionIds = await getSubscribedSectionIds(userId); const [schedules, homeworks, exams] = await Promise.all([ listSubscribedSchedules(userId, { locale, - dateFrom: windowStart, - dateTo: windowEnd, + dateFrom: calendarDateStart, + dateTo: calendarDateEnd, sectionIds, }), listSubscribedHomeworks(userId, { @@ -57,8 +114,8 @@ export async function listUserCalendarEvents( }), listSubscribedExams(userId, { locale, - dateFrom: windowStart, - dateTo: windowEnd, + dateFrom: calendarDateStart, + dateTo: calendarDateEnd, includeDateUnknown: false, sectionIds, }), @@ -84,12 +141,14 @@ export async function listUserCalendarEvents( orderBy: [{ dueAt: "asc" }, { createdAt: "desc" }], }); - return [ + const events = [ ...schedules.map((schedule) => { const at = toDateTimeFromHHmm(schedule.date, schedule.startTime); return { type: "schedule" as const, at: at ? toShanghaiIsoString(at) : null, + filterStart: at, + filterEnd: null, sortKey: at?.getTime() ?? Number.MAX_SAFE_INTEGER, payload: schedule, }; @@ -99,14 +158,22 @@ export async function listUserCalendarEvents( at: homework.submissionDueAt ? toShanghaiIsoString(homework.submissionDueAt) : null, + filterStart: homework.submissionDueAt, + filterEnd: null, sortKey: homework.submissionDueAt?.getTime() ?? Number.MAX_SAFE_INTEGER, payload: homework, })), ...exams.map((exam) => { const at = toDateTimeFromHHmm(exam.examDate, exam.startTime); + const filterEnd = + exam.examDate && exam.startTime === null + ? addDays(startOfShanghaiDay(exam.examDate), 1) + : null; return { type: "exam" as const, at: at ? toShanghaiIsoString(at) : null, + filterStart: at, + filterEnd, sortKey: at?.getTime() ?? Number.MAX_SAFE_INTEGER, payload: exam, }; @@ -114,10 +181,29 @@ export async function listUserCalendarEvents( ...todos.map((todo) => ({ type: "todo_due" as const, at: todo.dueAt ? toShanghaiIsoString(todo.dueAt) : null, + filterStart: todo.dueAt, + filterEnd: null, sortKey: todo.dueAt?.getTime() ?? Number.MAX_SAFE_INTEGER, payload: todo, })), - ] + ]; + + return events + .filter((event) => + isWithinExactWindow( + { start: event.filterStart, end: event.filterEnd }, + windowStart, + windowEnd, + includeWindowEnd, + ), + ) .sort((a, b) => a.sortKey - b.sortKey) - .map(({ sortKey: _sortKey, ...event }) => event); + .map( + ({ + filterStart: _filterStart, + filterEnd: _filterEnd, + sortKey: _sortKey, + ...event + }) => event, + ); } diff --git a/src/features/home/server/dashboard-overview-data.ts b/src/features/home/server/dashboard-overview-data.ts index 668551da..835eac0f 100644 --- a/src/features/home/server/dashboard-overview-data.ts +++ b/src/features/home/server/dashboard-overview-data.ts @@ -41,6 +41,8 @@ export type OverviewDataOptions = { calendarSemesterId?: number; /** Skip dashboard-links queries when the caller doesn't need them (e.g. calendar tab). */ skipLinks?: boolean; + /** Override the current time for deterministic snapshot and test views. */ + referenceNow?: Date; }; export type DashboardNavStats = { @@ -53,8 +55,11 @@ export type DashboardNavStats = { export async function getDashboardNavStats( userId: string, + referenceDate?: Date, ): Promise { - const referenceNow = shanghaiDayjs(); + const referenceNow = referenceDate + ? shanghaiDayjs(referenceDate) + : shanghaiDayjs(); const todayStart = referenceNow.startOf("day"); const tomorrowStart = todayStart.add(1, "day"); const nowHHmm = referenceNow.hour() * 100 + referenceNow.minute(); @@ -193,7 +198,9 @@ export async function getDashboardOverviewData( ): Promise { const locale = await getLocale(); const localizedPrisma = getPrisma(locale); - const referenceNow = shanghaiDayjs(); + const referenceNow = options.referenceNow + ? shanghaiDayjs(options.referenceNow) + : shanghaiDayjs(); const referenceDate = referenceNow.toDate(); const [semesters, user] = await Promise.all([ diff --git a/src/lib/mcp/tools/calendar-tools.ts b/src/lib/mcp/tools/calendar-tools.ts index 765c47d1..e5dcc2af 100644 --- a/src/lib/mcp/tools/calendar-tools.ts +++ b/src/lib/mcp/tools/calendar-tools.ts @@ -33,6 +33,14 @@ import { summarizeCalendarEventCollection } from "@/lib/mcp/tools/event-summary" import { getPublicOrigin } from "@/lib/site-url"; import { parseDateInput } from "@/lib/time/parse-date-input"; +const DATE_ONLY_INPUT_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +function isDateOnlyInput(value: unknown) { + return ( + typeof value === "string" && DATE_ONLY_INPUT_PATTERN.test(value.trim()) + ); +} + function getCalendarSubscriptionReadPayload( subscription: NonNullable< Awaited> @@ -337,6 +345,9 @@ export function registerCalendarTools(server: McpServer) { locale, dateFrom: parsedDateFrom instanceof Date ? parsedDateFrom : undefined, dateTo: parsedDateTo instanceof Date ? parsedDateTo : undefined, + dateFromIsDateOnly: isDateOnlyInput(dateFrom), + dateToIsDateOnly: isDateOnlyInput(dateTo), + dateToInclusive: true, }); const resolvedMode = resolveMcpMode(mode); diff --git a/src/lib/mcp/tools/dashboard-tools.ts b/src/lib/mcp/tools/dashboard-tools.ts index ac319d83..842548fb 100644 --- a/src/lib/mcp/tools/dashboard-tools.ts +++ b/src/lib/mcp/tools/dashboard-tools.ts @@ -16,6 +16,21 @@ import { } from "@/lib/mcp/tools/dashboard-summary"; import { parseDateInput } from "@/lib/time/parse-date-input"; +function parseOptionalAtTime(atTime: string | undefined) { + if (!atTime) return { ok: true as const, value: undefined }; + const parsed = parseDateInput(atTime); + if (!(parsed instanceof Date)) { + return { + ok: false as const, + result: jsonToolResult({ + success: false, + message: `Invalid atTime: "${atTime}". Use YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS+08:00.`, + }), + }; + } + return { ok: true as const, value: parsed }; +} + export function registerDashboardTools(server: McpServer) { server.registerTool( "get_my_dashboard", @@ -26,13 +41,21 @@ export function registerDashboardTools(server: McpServer) { inputSchema: { locale: mcpLocaleInputSchema, mode: mcpModeInputSchema, + atTime: flexDateInputSchema + .optional() + .describe( + "Override the reference time for next class, deadlines, events, current semester, and preferred shuttle. Defaults to now.", + ), }, }, - async ({ locale, mode }, extra) => { + async ({ locale, mode, atTime }, extra) => { const resolvedMode = resolveMcpMode(mode); + const parsedAtTime = parseOptionalAtTime(atTime); + if (!parsedAtTime.ok) return parsedAtTime.result; const snapshot = await getAssistantDashboardSnapshot({ userId: getUserId(extra.authInfo), locale, + atTime: parsedAtTime.value, }); if (resolvedMode === "full") { return jsonToolResult(snapshot, { mode: "full" }); @@ -56,12 +79,20 @@ export function registerDashboardTools(server: McpServer) { inputSchema: { locale: mcpLocaleInputSchema, mode: mcpModeInputSchema, + atTime: flexDateInputSchema + .optional() + .describe( + "Override the reference time for the next-class lookup. Defaults to now.", + ), }, }, - async ({ locale, mode }, extra) => { + async ({ locale, mode, atTime }, extra) => { + const parsedAtTime = parseOptionalAtTime(atTime); + if (!parsedAtTime.ok) return parsedAtTime.result; const snapshot = await getAssistantDashboardSnapshot({ userId: getUserId(extra.authInfo), locale, + atTime: parsedAtTime.value, }); return jsonToolResult( { diff --git a/tests/integration/mcp-tools.test.ts b/tests/integration/mcp-tools.test.ts index e2520cb7..4d8c7a23 100644 --- a/tests/integration/mcp-tools.test.ts +++ b/tests/integration/mcp-tools.test.ts @@ -174,6 +174,82 @@ describe("flexDateInputSchema — bare YYYY-MM-DD accepted by date-filter tools" ).toBe(true); }); + it("list_my_calendar_events treats same-day bare date ranges as full Shanghai days", async () => { + const result = await mcp.call<{ + events?: Array<{ type?: string; at?: string }>; + }>("list_my_calendar_events", { + dateFrom: SEED_DATE, + dateTo: SEED_DATE, + locale: "zh-cn", + }); + + expect(Array.isArray(result.events)).toBe(true); + expect( + (result.events ?? []).some( + (event) => event.type === "schedule" && event.at?.startsWith(SEED_DATE), + ), + ).toBe(true); + }); + + it("list_my_calendar_events honors an exact inclusive dateTo bound", async () => { + const dueAt = "2026-05-02T21:00:00+08:00"; + const result = await mcp.call<{ + events?: Array<{ type?: string; at?: string }>; + }>("list_my_calendar_events", { + dateFrom: dueAt, + dateTo: dueAt, + locale: "zh-cn", + }); + + expect( + (result.events ?? []).some( + (event) => event.type === "homework_due" && event.at === dueAt, + ), + ).toBe(true); + }); + + it("list_my_calendar_events keeps no-time exams visible through their day", async () => { + const section = await prisma.section.findUnique({ + where: { jwId: DEV_SEED.section.jwId }, + select: { id: true }, + }); + if (!section) { + throw new Error(`Seed section ${DEV_SEED.section.jwId} not found`); + } + + const jwId = 926042900; + await prisma.exam.deleteMany({ where: { jwId } }); + + try { + await prisma.exam.create({ + data: { + jwId, + sectionId: section.id, + examDate: new Date(`${SEED_DATE}T00:00:00.000Z`), + startTime: null, + endTime: null, + }, + }); + + const result = await mcp.call<{ + events?: Array<{ type?: string; at?: string }>; + }>("list_my_calendar_events", { + dateFrom: `${SEED_DATE}T08:00:00+08:00`, + dateTo: `${SEED_DATE}T09:00:00+08:00`, + locale: "zh-cn", + }); + + expect( + (result.events ?? []).some( + (event) => + event.type === "exam" && event.at === `${SEED_DATE}T00:00:00+08:00`, + ), + ).toBe(true); + } finally { + await prisma.exam.deleteMany({ where: { jwId } }); + } + }); + it("returns a descriptive error for a nonsense date string", async () => { const result = await mcp.call<{ success?: boolean; @@ -397,6 +473,26 @@ describe("query_schedules — flexible date filters", () => { // --------------------------------------------------------------------------- describe("get_my_dashboard — default mode compactness", () => { + it("atTime anchors nextClass, deadlines, and events", async () => { + const dashboard = await mcp.call<{ + nextClass?: { type?: string; at?: string | null }; + upcomingDeadlines?: { + total?: number; + items?: Array<{ type?: string; at?: string | null }>; + }; + upcomingEvents?: { total?: number }; + }>("get_my_dashboard", { + locale: "zh-cn", + mode: "summary", + atTime: SEED_AT_TIME, + }); + + expect(dashboard.nextClass?.type).toBe("schedule"); + expect(dashboard.nextClass?.at?.slice(0, 10)).toBe(SEED_DATE); + expect(dashboard.upcomingDeadlines?.total).toBeGreaterThan(0); + expect(dashboard.upcomingEvents?.total).toBeGreaterThan(0); + }); + it("scheduleGroup and roomType are stripped from nextClass payload", async () => { const dashboard = await mcp.call<{ nextClass?: { @@ -409,7 +505,7 @@ describe("get_my_dashboard — default mode compactness", () => { }; subscriptions?: { currentSemesterSectionsTotal?: number }; todos?: { incompleteCount?: number }; - }>("get_my_dashboard", { locale: "zh-cn" }); + }>("get_my_dashboard", { locale: "zh-cn", atTime: SEED_AT_TIME }); if (dashboard.nextClass?.payload) { expect(dashboard.nextClass.payload).not.toHaveProperty("scheduleGroup"); @@ -426,12 +522,14 @@ describe("get_my_dashboard — default mode compactness", () => { await mcp.callTool("get_my_dashboard", { locale: "zh-cn", mode: "default", + atTime: SEED_AT_TIME, }), ); const sum = JSON.stringify( await mcp.callTool("get_my_dashboard", { locale: "zh-cn", mode: "summary", + atTime: SEED_AT_TIME, }), ); expect(sum.length).toBeLessThan(def.length); diff --git a/tools/dev/artifacts/snapshot-cases.ts b/tools/dev/artifacts/snapshot-cases.ts index 045387c4..5df9e92b 100644 --- a/tools/dev/artifacts/snapshot-cases.ts +++ b/tools/dev/artifacts/snapshot-cases.ts @@ -33,6 +33,10 @@ export type McpSnapshotCase = { note?: string; }; +const SNAPSHOT_AT_QUERY = `snapshotAt=${encodeURIComponent( + DEV_SEED_ANCHOR.startOfDayAtTime, +)}`; + export const PAGE_SNAPSHOT_CASES: PageSnapshotCase[] = [ { id: "home", path: "/", auth: "public" }, { id: "signin", path: "/signin", auth: "public" }, @@ -100,16 +104,44 @@ export const PAGE_SNAPSHOT_CASES: PageSnapshotCase[] = [ auth: "debug", resolvePath: "user-id", }, - { id: "dashboard", path: "/?tab=overview", auth: "debug" }, - { id: "dashboard-calendar", path: "/?tab=calendar", auth: "debug" }, - { id: "dashboard-todos", path: "/?tab=todos", auth: "debug" }, - { id: "dashboard-homeworks", path: "/?tab=homeworks", auth: "debug" }, - { id: "dashboard-exams", path: "/?tab=exams", auth: "debug" }, - { id: "dashboard-comments", path: "/?tab=comments", auth: "debug" }, - { id: "dashboard-links", path: "/?tab=links", auth: "debug" }, + { + id: "dashboard", + path: `/?tab=overview&${SNAPSHOT_AT_QUERY}`, + auth: "debug", + }, + { + id: "dashboard-calendar", + path: `/?tab=calendar&${SNAPSHOT_AT_QUERY}`, + auth: "debug", + }, + { + id: "dashboard-todos", + path: `/?tab=todos&${SNAPSHOT_AT_QUERY}`, + auth: "debug", + }, + { + id: "dashboard-homeworks", + path: `/?tab=homeworks&${SNAPSHOT_AT_QUERY}`, + auth: "debug", + }, + { + id: "dashboard-exams", + path: `/?tab=exams&${SNAPSHOT_AT_QUERY}`, + auth: "debug", + }, + { + id: "dashboard-comments", + path: `/?tab=comments&${SNAPSHOT_AT_QUERY}`, + auth: "debug", + }, + { + id: "dashboard-links", + path: `/?tab=links&${SNAPSHOT_AT_QUERY}`, + auth: "debug", + }, { id: "dashboard-subscriptions-sections", - path: "/?tab=subscriptions", + path: `/?tab=subscriptions&${SNAPSHOT_AT_QUERY}`, auth: "debug", }, { id: "admin", path: "/admin", auth: "admin" }, @@ -312,7 +344,11 @@ export const MCP_SNAPSHOT_CASES: McpSnapshotCase[] = [ { name: "get_my_calendar_subscription", arguments: { locale: "zh-cn" } }, { name: "get_my_dashboard", - arguments: { locale: "zh-cn", mode: "summary" }, + arguments: { + locale: "zh-cn", + mode: "summary", + atTime: DEV_SEED_ANCHOR.startOfDayAtTime, + }, }, { name: "get_next_class",