Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions docs/features/overview.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,15 +81,18 @@
"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."
]
},
{
"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",
Expand Down
18 changes: 15 additions & 3 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,24 @@ 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";

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<Metadata> {
const t = await getTranslations("metadata");

Expand All @@ -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" &&
Expand All @@ -55,10 +65,11 @@ export default async function HomePage({
? {
calendarSemesterId: parsedCalendarSemester,
skipLinks: true,
referenceNow,
}
: tab === "calendar"
? { skipLinks: true }
: {};
? { skipLinks: true, referenceNow }
: { referenceNow };

const [
navStats,
Expand All @@ -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),
Expand Down Expand Up @@ -104,6 +115,7 @@ export default async function HomePage({
<HomeView
searchParams={searchParams}
navStats={navStats}
referenceNow={referenceNow ? toShanghaiIsoString(referenceNow) : null}
overviewData={overviewData}
linksData={linksData?.dashboardLinks ?? null}
homeworksData={homeworksData}
Expand Down
13 changes: 10 additions & 3 deletions src/features/home/components/exams-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,21 +58,28 @@ function isExamCompleted(exam: ExamRow, now: dayjs.Dayjs) {
return examEnd.isBefore(now);
}

export function ExamsList({ exams }: { exams: ExamRow[] }) {
export function ExamsList({
exams,
referenceNow,
}: {
exams: ExamRow[];
referenceNow?: string | null;
}) {
const t = useTranslations("meDashboard.nav.exams");
const tSection = useTranslations("sectionDetail");
const [filter, setFilter] = useState<ExamFilter>("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));
}
if (filter === "incomplete") {
return exams.filter((exam) => !isExamCompleted(exam, now));
}
return exams;
}, [exams, filter]);
}, [exams, filter, referenceNow]);

return (
<div className="space-y-4">
Expand Down
10 changes: 8 additions & 2 deletions src/features/home/components/exams-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -95,5 +101,5 @@ export async function ExamsPanel({ data }: { data: SubscriptionsTabData }) {
);
}

return <ExamsList exams={exams} />;
return <ExamsList exams={exams} referenceNow={referenceNow} />;
}
4 changes: 3 additions & 1 deletion src/features/home/components/home-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ type HomeViewProps = {
overviewWeek?: string;
}>;
navStats: DashboardNavStats;
referenceNow: string | null;
overviewData: OverviewData | null;
linksData: DashboardLinkSummary[] | null;
homeworksData: {
Expand All @@ -63,6 +64,7 @@ type HomeViewProps = {
export async function HomeView({
searchParams,
navStats,
referenceNow,
overviewData,
linksData,
homeworksData,
Expand Down Expand Up @@ -124,7 +126,7 @@ export async function HomeView({
)}
{currentTab === "todos" && <TodosPanel todos={todosData ?? []} />}
{currentTab === "exams" && subscriptionsData && (
<ExamsPanel data={subscriptionsData} />
<ExamsPanel data={subscriptionsData} referenceNow={referenceNow} />
)}
{currentTab === "subscriptions" && subscriptionsData && (
<SubscriptionsPanel data={subscriptionsData} />
Expand Down
3 changes: 2 additions & 1 deletion src/features/home/server/assistant-dashboard-snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Comment thread
tiankaima marked this conversation as resolved.
Comment thread
tiankaima marked this conversation as resolved.
const dayLimit = input.dayLimit ?? 7;
const dateTo = new Date(now.getTime() + dayLimit * 24 * 60 * 60 * 1000);
const currentSemester = await findCurrentSemester(prisma.semester, now);
Expand Down
104 changes: 95 additions & 9 deletions src/features/home/server/calendar-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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, {
Expand All @@ -57,8 +114,8 @@ export async function listUserCalendarEvents(
}),
listSubscribedExams(userId, {
locale,
dateFrom: windowStart,
dateTo: windowEnd,
dateFrom: calendarDateStart,
dateTo: calendarDateEnd,
includeDateUnknown: false,
sectionIds,
}),
Expand All @@ -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,
};
Expand All @@ -99,25 +158,52 @@ 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,
};
}),
...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,
);
}
11 changes: 9 additions & 2 deletions src/features/home/server/dashboard-overview-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -53,8 +55,11 @@ export type DashboardNavStats = {

export async function getDashboardNavStats(
userId: string,
referenceDate?: Date,
): Promise<DashboardNavStats | null> {
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();
Expand Down Expand Up @@ -193,7 +198,9 @@ export async function getDashboardOverviewData(
): Promise<OverviewData | null> {
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([
Expand Down
Loading
Loading