From 3ed462c410ede94c1b410918ef980346d32905c4 Mon Sep 17 00:00:00 2001 From: SerafimSoldatov Date: Mon, 28 Sep 2026 11:01:22 +0300 Subject: [PATCH 01/13] refactor(dashboard): remove countdown widget --- src/components/dashboard/CountdownWidget.tsx | 141 ------------------- src/components/dashboard/DashboardPage.tsx | 2 - 2 files changed, 143 deletions(-) delete mode 100644 src/components/dashboard/CountdownWidget.tsx diff --git a/src/components/dashboard/CountdownWidget.tsx b/src/components/dashboard/CountdownWidget.tsx deleted file mode 100644 index 0921073d..00000000 --- a/src/components/dashboard/CountdownWidget.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import { useNowMS } from "@/lib/utils/use-now.ts"; -import Logo108 from "@/components/icons/Logo108.tsx"; -import type { ReactNode } from "react"; - -type CountdownEventTemplate = { - dateTemplate: string; - name: string | ReactNode; - iconClass: string; -}; - -type CountdownEvent = CountdownEventTemplate & { - date: Date; -}; - -/** - * Declaration of all events for countdown - * - * Format for dates is `YYYY-MM-DDTHH:MM:SS+TZ` (ISO 8601), but you can - * use `*` instead of year to create an annual event. - * - * E.g. *-01-01T00:00:00+03:00 is a convenient declaration for a New Year in Moscow timezone. - */ -const countdownEventTemplates: CountdownEventTemplate[] = [ - { - dateTemplate: "*-01-01T00:00:00+03:00", - name: "New Year countdown", - iconClass: "icon-[twemoji--christmas-tree]", - }, - { - dateTemplate: "*-06-01T00:00:00+03:00", - name: "Summer countdown", - iconClass: "icon-[twemoji--sun]", - }, - { - dateTemplate: "2026-02-06T19:00+03:00", - name: ( - <> - Introduction to 108 countdown - - Introduction to - - countdown - - - ), - iconClass: "icon-[twemoji--purple-heart]", - }, -]; - -/** - * Expands the list of events to include annual events, sorts them by date and returns the next event - * - * @param dateNow - Current date and time - * @returns The next event from the list of events, or null if there are no events in the future - */ -function getNextCountdownEvent(dateNow: Date): CountdownEvent | null { - const expandedEvents: CountdownEvent[] = []; - - for (const template of countdownEventTemplates) { - const [y, _] = template.dateTemplate.split("-", 2); - - if (y === "*") { - const fullYear = dateNow.getFullYear(); - expandedEvents.push({ - ...template, - date: new Date(template.dateTemplate.replace("*", fullYear.toString())), - }); - expandedEvents.push({ - ...template, - date: new Date( - template.dateTemplate.replace("*", (fullYear + 1).toString()), - ), - }); - } else { - expandedEvents.push({ - ...template, - date: new Date(template.dateTemplate), - }); - } - } - - const sortedEvents = expandedEvents.sort( - (a, b) => a.date.getTime() - b.date.getTime(), - ); - for (const event of sortedEvents) { - if (event.date.getTime() > dateNow.getTime()) { - return event; - } - } - return null; -} - -/** - * Formats the time left for the next event - * - * @param deltaTimeSeconds - The time left in seconds - * @returns The formatted time left in days, hours, minutes and seconds - */ -const formatCountdownTimeLeft = (deltaTimeSeconds: number): string => { - const seconds = Math.floor(deltaTimeSeconds % 60); - const minutes = Math.floor((deltaTimeSeconds / 60) % 60); - const hours = Math.floor((deltaTimeSeconds / 60 / 60) % 24); - const days = Math.floor(deltaTimeSeconds / 60 / 60 / 24); - - return ( - `${days} day${days > 1 ? "s" : ""}, ` + - `${hours} hour${hours > 1 ? "s" : ""}, ` + - `${minutes} minute${minutes > 1 ? "s" : ""}, ` + - `${seconds} second${seconds > 1 ? "s" : ""}` - ); -}; - -export function CountdownWidget() { - const nowMs = useNowMS(true, 1000); - - const nextEvent = getNextCountdownEvent(new Date(nowMs)); - if (!nextEvent) { - return null; - } - - const deltaTimeSeconds = (nextEvent.date.getTime() - nowMs) / 1000; - - return ( -
-
- ); -} diff --git a/src/components/dashboard/DashboardPage.tsx b/src/components/dashboard/DashboardPage.tsx index 692d0a46..35633819 100644 --- a/src/components/dashboard/DashboardPage.tsx +++ b/src/components/dashboard/DashboardPage.tsx @@ -3,7 +3,6 @@ import { AuthWall } from "@/components/common/AuthWall.tsx"; import { AcademicCalendarWidget } from "@/components/dashboard/AcademicCalendarWidget.tsx"; import { AccountWidget } from "@/components/dashboard/AccountWidget.tsx"; import { AdvWidget } from "@/components/dashboard/AdvWidget.tsx"; -import { CountdownWidget } from "@/components/dashboard/CountdownWidget.tsx"; import { PwaWidget } from "@/components/dashboard/PwaWidget.tsx"; import { SportsWidget } from "@/components/dashboard/SportsWidget.tsx"; import { MusicRoomWidget } from "@/components/dashboard/MusicRoomWidget.tsx"; @@ -34,7 +33,6 @@ export function DashboardPage() { - From 0027b0bbb8cf1c447470e0966bf70bc7d1ecf737 Mon Sep 17 00:00:00 2001 From: SerafimSoldatov Date: Mon, 28 Sep 2026 11:02:07 +0300 Subject: [PATCH 02/13] refactor(dashboard): move music room into supporting widget row --- src/components/dashboard/DashboardPage.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/components/dashboard/DashboardPage.tsx b/src/components/dashboard/DashboardPage.tsx index 35633819..510032b4 100644 --- a/src/components/dashboard/DashboardPage.tsx +++ b/src/components/dashboard/DashboardPage.tsx @@ -28,10 +28,12 @@ export function DashboardPage() { return (
-
- - + +
+
+
+ From c99cf9cf1acf05edab9cc7cbc4ef07bcbd4d95d5 Mon Sep 17 00:00:00 2001 From: SerafimSoldatov Date: Mon, 28 Sep 2026 11:14:03 +0300 Subject: [PATCH 03/13] feat(dashboard): add daily calendar activities and live status Reuse selected calendar sources without sport; show five remaining daily activities with loading and error states and move calendar navigation into the card. Closes one-zero-eight/website#168 --- src/components/calendar/CalendarPage.tsx | 116 +----- .../calendar/calendar-sources.test.ts | 94 +++++ src/components/calendar/calendar-sources.ts | 144 +++++++ .../calendar/dashboard-calendar-data.test.ts | 362 ++++++++++++++++++ .../calendar/dashboard-calendar-data.ts | 219 +++++++++++ .../calendar/useDashboardCalendar.test.ts | 267 +++++++++++++ .../calendar/useDashboardCalendar.ts | 142 +++++++ .../useWhen2MeetCalendarEvents.test.ts | 139 +++++++ .../calendar/useWhen2MeetCalendarEvents.ts | 52 ++- src/components/dashboard/ActivitySchedule.tsx | 126 ++++++ .../dashboard/CalendarWidget.test.ts | 85 ++++ src/components/dashboard/CalendarWidget.tsx | 46 +++ src/components/dashboard/DashboardPage.tsx | 11 +- src/components/dashboard/activities.test.ts | 86 +++++ src/components/dashboard/activities.ts | 71 ++++ 15 files changed, 1829 insertions(+), 131 deletions(-) create mode 100644 src/components/calendar/calendar-sources.test.ts create mode 100644 src/components/calendar/calendar-sources.ts create mode 100644 src/components/calendar/dashboard-calendar-data.test.ts create mode 100644 src/components/calendar/dashboard-calendar-data.ts create mode 100644 src/components/calendar/useDashboardCalendar.test.ts create mode 100644 src/components/calendar/useDashboardCalendar.ts create mode 100644 src/components/calendar/useWhen2MeetCalendarEvents.test.ts create mode 100644 src/components/dashboard/ActivitySchedule.tsx create mode 100644 src/components/dashboard/CalendarWidget.test.ts create mode 100644 src/components/dashboard/CalendarWidget.tsx create mode 100644 src/components/dashboard/activities.test.ts create mode 100644 src/components/dashboard/activities.ts diff --git a/src/components/calendar/CalendarPage.tsx b/src/components/calendar/CalendarPage.tsx index c8482f0e..f86aca02 100644 --- a/src/components/calendar/CalendarPage.tsx +++ b/src/components/calendar/CalendarPage.tsx @@ -1,23 +1,14 @@ -import { $schedule, scheduleTypes } from "@/api/schedule"; +import { $schedule } from "@/api/schedule"; import { $sport } from "@/api/sport"; import { Calendar } from "@/components/calendar/Calendar.tsx"; -import { URLType } from "@/components/calendar/CalendarViewer.tsx"; import { filterUpcomingCheckedInSchedule, fromCalendarSpace, trainingScheduleToCalendarEvent, } from "@/components/sport/sport-calendar-events.ts"; import { toScheduleApiDateTime } from "@/components/sport/sport-week-utils.ts"; -import { useWhen2MeetCalendarEvents } from "@/components/calendar/useWhen2MeetCalendarEvents.ts"; -import { - getImportedLink, - getICSLink, - getMyMoodleLink, - getMyMusicRoomLink, - getMyRoomBookingsLink, - getMyEventsLink, -} from "@/api/schedule/links.ts"; -import { SchemaLinkedCalendarView } from "@/api/schedule/types.ts"; +import { calendarSourceToUrl, getCalendarSources } from "./calendar-sources.ts"; +import { useWhen2MeetCalendarEvents } from "./useWhen2MeetCalendarEvents.ts"; import type { EventInput } from "@fullcalendar/core"; import { useMemo, useRef, useState } from "react"; @@ -81,21 +72,15 @@ export function CalendarPage() { ); } - -function getCalendarsToShow( - favorites: string[], - hidden: string[], - predefined: string[], - eventGroups: scheduleTypes.SchemaListEventGroupsResponse, - userId: number | undefined, - music_room_hidden: boolean, - moodle_hidden: boolean, - imported: SchemaLinkedCalendarView[], -): URLType[] { - const visibleAliases = new Set( - favorites.concat(predefined).filter((alias) => !hidden.includes(alias)), - ); - const hasAssistantEnglish = [...visibleAliases].some((alias) => - alias.startsWith("english-"), - ); - - // Remove hidden calendars - const toShow: URLType[] = [...visibleAliases].flatMap((alias) => { - const group = eventGroups.event_groups.find( - (eventGroup) => eventGroup.alias === alias, - ); - if (!group) return []; - const isCoreCourses = group.tags?.some( - (tag) => tag.type === "core-courses", - ); - return [ - { - url: getICSLink(group.alias, userId), - eventGroup: group, - excludeEnglish: hasAssistantEnglish && isCoreCourses, - }, - ]; - }); - - // Add personal calendars - if (!music_room_hidden) { - toShow.push({ - url: getMyMusicRoomLink(), - color: "seagreen", - sourceLink: "https://telegram.me/InnoMusicRoomBot", - updatedAt: new Date().toISOString(), - }); - } - - if (!moodle_hidden) { - toShow.push({ - url: getMyMoodleLink(), - color: "seagreen", - sourceLink: - "https://moodle.innopolis.university/calendar/view.php?view=month", - updatedAt: new Date().toISOString(), - }); - } - - toShow.push({ - url: getMyEventsLink(), - color: "seagreen", - sourceLink: "https://innohassle.ru/events", - updatedAt: new Date().toISOString(), - }); - - toShow.push({ - url: getMyRoomBookingsLink(), - color: "seagreen", - sourceLink: "https://innohassle.ru/room-booking", - updatedAt: new Date().toISOString(), - }); - - // Add imported calendars - imported.forEach((v) => { - if (v.is_active) - toShow.push({ - url: getImportedLink(userId, v.alias), - color: v.color || undefined, - updatedAt: new Date().toISOString(), - }); - }); - - // Return unique items - return toShow.filter((value, index, array) => array.indexOf(value) === index); -} diff --git a/src/components/calendar/calendar-sources.test.ts b/src/components/calendar/calendar-sources.test.ts new file mode 100644 index 00000000..86f77a70 --- /dev/null +++ b/src/components/calendar/calendar-sources.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { + getCalendarSources, + type CalendarSelection, +} from "./calendar-sources.ts"; + +const user: CalendarSelection = { + id: 123, + favorite_event_groups: ["group-1", "group-2", "missing"], + hidden_event_groups: ["group-2"], + music_room_hidden: false, + sports_hidden: false, + moodle_hidden: false, + linked_calendars: { + active: { + id: 1, + user_id: 123, + alias: "active", + url: "https://example.com/active.ics", + is_active: true, + }, + inactive: { + id: 2, + user_id: 123, + alias: "inactive", + url: "https://example.com/inactive.ics", + is_active: false, + }, + }, +}; +const groups = { + event_groups: [1, 2, 3].map((id) => ({ + id, + alias: `group-${id}`, + created_at: "2026-01-01", + updated_at: "2026-01-01", + virtual: false, + })), +}; + +describe("shared calendar source selection", () => { + it("combines favorites/predefined uniquely, removes hidden/unknown groups, and includes active imports/personal feeds", () => { + const result = getCalendarSources( + user, + ["group-1", "group-2", "group-3"], + groups, + ); + expect(result.map((source) => source.id)).toEqual([ + "group:group-1", + "group:group-3", + "music-room", + "sport", + "moodle", + "events", + "room-bookings", + "imported:active", + ]); + expect(result[0]).toMatchObject({ + userId: 123, + group: { alias: "group-1" }, + }); + expect(result.at(-1)).toMatchObject({ + userId: 123, + calendar: { alias: "active" }, + }); + }); + + it("excludes sport only for dashboard without changing other selected sources", () => { + const full = getCalendarSources(user, ["group-3"], groups); + const dashboard = getCalendarSources(user, ["group-3"], groups, { + excludeSport: true, + }); + expect(dashboard).toEqual(full.filter((source) => source.kind !== "sport")); + }); + + it("respects hidden personal feeds but always includes events and bookings", () => { + const result = getCalendarSources( + { + ...user, + favorite_event_groups: [], + music_room_hidden: true, + sports_hidden: true, + moodle_hidden: true, + linked_calendars: null, + }, + [], + groups, + ); + expect(result.map((source) => source.id)).toEqual([ + "events", + "room-bookings", + ]); + }); +}); diff --git a/src/components/calendar/calendar-sources.ts b/src/components/calendar/calendar-sources.ts new file mode 100644 index 00000000..d70c168f --- /dev/null +++ b/src/components/calendar/calendar-sources.ts @@ -0,0 +1,144 @@ +import type { scheduleTypes } from "@/api/schedule"; +import { + getImportedLink, + getICSLink, + getMyEventsLink, + getMyMoodleLink, + getMyMusicRoomLink, + getMyRoomBookingsLink, + getMySportLink, +} from "@/api/schedule/links.ts"; + +export type CalendarSource = + | { + id: string; + kind: "group"; + group: scheduleTypes.SchemaViewEventGroup; + userId: number; + excludeEnglish: boolean; + } + | { + id: string; + kind: "imported"; + calendar: scheduleTypes.SchemaLinkedCalendarView; + userId: number; + } + | { + id: string; + kind: "music-room" | "sport" | "moodle" | "events" | "room-bookings"; + }; + +export type CalendarSelection = { + id: number; + favorite_event_groups?: string[]; + hidden_event_groups?: string[]; + music_room_hidden: boolean; + sports_hidden: boolean; + moodle_hidden: boolean; + linked_calendars?: Record< + string, + scheduleTypes.SchemaLinkedCalendarView + > | null; +}; + +/** Shared selection for the full calendar and dashboard; only the latter excludes sport. */ +export function getCalendarSources( + user: CalendarSelection, + predefined: string[], + eventGroups: scheduleTypes.SchemaListEventGroupsResponse, + { excludeSport = false } = {}, +): CalendarSource[] { + const { favorite_event_groups, hidden_event_groups } = user; + if (!favorite_event_groups || !hidden_event_groups) return []; + const visibleAliases = new Set( + [...favorite_event_groups, ...predefined].filter( + (alias) => !hidden_event_groups.includes(alias), + ), + ); + const hasAssistantEnglish = [...visibleAliases].some((alias) => + alias.startsWith("english-"), + ); + const sources: CalendarSource[] = []; + for (const alias of visibleAliases) { + const group = eventGroups.event_groups.find( + (eventGroup) => eventGroup.alias === alias, + ); + if (!group) continue; + const isCoreCourses = group.tags?.some( + (tag) => tag.type === "core-courses", + ); + sources.push({ + id: `group:${alias}`, + kind: "group", + group, + userId: user.id, + excludeEnglish: hasAssistantEnglish && !!isCoreCourses, + }); + } + if (!user.music_room_hidden) + sources.push({ id: "music-room", kind: "music-room" }); + if (!excludeSport && !user.sports_hidden) + sources.push({ id: "sport", kind: "sport" }); + if (!user.moodle_hidden) sources.push({ id: "moodle", kind: "moodle" }); + sources.push( + { id: "events", kind: "events" }, + { id: "room-bookings", kind: "room-bookings" }, + ); + for (const calendar of Object.values(user.linked_calendars ?? {})) { + if (calendar.is_active) + sources.push({ + id: `imported:${calendar.alias}`, + kind: "imported", + calendar, + userId: user.id, + }); + } + return sources; +} + +/** Keep the full calendar's URL and display metadata unchanged. No UI imports. */ +export function calendarSourceToUrl(source: CalendarSource) { + if (source.kind === "group") { + return { + url: getICSLink(source.group.alias, source.userId), + eventGroup: source.group, + excludeEnglish: source.excludeEnglish, + }; + } + if (source.kind === "imported") { + return { + url: getImportedLink(source.userId, source.calendar.alias), + color: source.calendar.color || undefined, + updatedAt: new Date().toISOString(), + }; + } + const personal = { + "music-room": { + url: getMyMusicRoomLink, + sourceLink: "https://telegram.me/InnoMusicRoomBot", + }, + sport: { + url: getMySportLink, + sourceLink: "https://sport.innopolis.university", + }, + moodle: { + url: getMyMoodleLink, + sourceLink: + "https://moodle.innopolis.university/calendar/view.php?view=month", + }, + events: { + url: getMyEventsLink, + sourceLink: "https://innohassle.ru/events", + }, + "room-bookings": { + url: getMyRoomBookingsLink, + sourceLink: "https://innohassle.ru/room-booking", + }, + }[source.kind]; + return { + url: personal.url(), + sourceLink: personal.sourceLink, + color: "seagreen", + updatedAt: new Date().toISOString(), + }; +} diff --git a/src/components/calendar/dashboard-calendar-data.test.ts b/src/components/calendar/dashboard-calendar-data.test.ts new file mode 100644 index 00000000..e3bc642a --- /dev/null +++ b/src/components/calendar/dashboard-calendar-data.test.ts @@ -0,0 +1,362 @@ +import { describe, expect, it } from "vitest"; +import { + getLocalCalendarDay, + normalizeDashboardIcs, + normalizeDashboardMeetings, +} from "./dashboard-calendar-data.ts"; + +const nowMs = new Date(2026, 5, 15, 12).getTime(); +function calendar(...events: string[]) { + return ["BEGIN:VCALENDAR", "VERSION:2.0", ...events, "END:VCALENDAR"].join( + "\r\n", + ); +} +function event(...properties: string[]) { + return ["BEGIN:VEVENT", ...properties, "END:VEVENT"].join("\r\n"); +} +function normalize(...events: string[]) { + return normalizeDashboardIcs(calendar(...events), "test", nowMs); +} +const recurring = event( + "UID:series", + "SUMMARY:Class", + "DTSTART:20260614T100000", + "DTEND:20260614T110000", + "RRULE:FREQ=DAILY;COUNT=4", +); + +describe("dashboard ICS normalization", () => { + it("keeps local-day overlaps, including ended and overnight events, without UI sorting/limiting", () => { + const result = normalize( + event( + "UID:overnight", + "SUMMARY:Overnight", + "LOCATION:Room 101", + "DTSTART:20260614T230000", + "DTEND:20260615T010000", + ), + event("UID:ended", "DTSTART:20260615T090000", "DTEND:20260615T100000"), + event("UID:tomorrow", "DTSTART:20260616T000000", "DTEND:20260616T010000"), + event( + "UID:yesterday", + "DTSTART:20260614T220000", + "DTEND:20260615T000000", + ), + ); + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({ + title: "Overnight", + location: "Room 101", + start: new Date(2026, 5, 14, 23), + end: new Date(2026, 5, 15, 1), + allDay: false, + }); + expect(result[1].title).toBe("Untitled event"); + }); + + it("retains date-only all-day spans with exclusive local-midnight ends", () => { + const result = normalize( + event( + "UID:all-day", + "DTSTART;VALUE=DATE:20260615", + "DTEND;VALUE=DATE:20260616", + ), + event( + "UID:multi-day", + "DTSTART;VALUE=DATE:20260614", + "DTEND;VALUE=DATE:20260617", + ), + event( + "UID:past", + "DTSTART;VALUE=DATE:20260614", + "DTEND;VALUE=DATE:20260615", + ), + ); + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({ + start: new Date(2026, 5, 15), + end: new Date(2026, 5, 16), + allDay: true, + }); + }); + + it("applies full-calendar default durations when DTEND and DURATION are absent", () => { + const result = normalize( + event("UID:date-only", "DTSTART;VALUE=DATE:20260615"), + event("UID:timed", "DTSTART:20260615T100000"), + event("UID:previous-night", "DTSTART:20260614T233000"), + ); + expect(result).toHaveLength(3); + expect(result[0].end).toEqual(new Date(2026, 5, 16)); + expect(result[1].end).toEqual(new Date(2026, 5, 15, 11)); + expect(result[2].end).toEqual(new Date(2026, 5, 15, 0, 30)); + }); + + it("expands recurrence and applies duration", () => { + const result = normalize( + event( + "UID:duration", + "DTSTART:20260614T100000", + "DURATION:PT90M", + "RRULE:FREQ=DAILY;COUNT=3", + ), + ); + expect(result).toHaveLength(1); + expect(result[0].start).toEqual(new Date(2026, 5, 15, 10)); + expect(result[0].end).toEqual(new Date(2026, 5, 15, 11, 30)); + }); + + it("does not truncate series that started more than 1000 occurrences ago", () => { + const result = normalize( + event( + "UID:old", + "DTSTART:20200101T100000", + "DTEND:20200101T110000", + "RRULE:FREQ=DAILY", + ), + ); + expect(result).toHaveLength(1); + expect(result[0].start).toEqual(new Date(2026, 5, 15, 10)); + }); + + it("fails visibly instead of scanning an unbounded minutely recurrence", () => { + expect(() => + normalize( + event( + "UID:frequent", + "DTSTART:20260101T100000", + "DURATION:PT1M", + "RRULE:FREQ=MINUTELY", + ), + ), + ).toThrow("Calendar test exceeds the 10000-iteration recurrence limit"); + }); + + it("shares the recurrence budget across all series in a feed", () => { + const series = Array.from({ length: 5 }, (_, index) => + event( + `UID:old-${index}`, + "DTSTART:20200101T100000", + "DTEND:20200101T110000", + "RRULE:FREQ=DAILY", + ), + ); + expect(() => normalize(...series)).toThrow( + "10000-iteration recurrence limit", + ); + // Each individual series fits; the cap is per feed, not globally shared. + expect(normalize(series[0])).toHaveLength(1); + }); + + it("honors every value in a multi-value EXDATE", () => { + expect( + normalize( + event( + "UID:excluded", + "DTSTART:20260614T100000", + "DTEND:20260614T110000", + "RRULE:FREQ=DAILY;COUNT=4", + "EXDATE:20260614T100000,20260615T100000", + ), + ), + ).toEqual([]); + }); + + it("replaces recurring instances and inherits missing title/location", () => { + const result = normalize( + recurring, + event( + "UID:series", + "RECURRENCE-ID:20260615T100000", + "DTSTART:20260615T140000", + "DTEND:20260615T150000", + "LOCATION:Library", + ), + ); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + title: "Class", + location: "Library", + start: new Date(2026, 5, 15, 14), + }); + expect(result[0].id).toBe("test:series:2026-06-15T10:00:00"); + }); + + it("keeps exceptions scoped to their UID when unrelated series share start times", () => { + const result = normalize( + recurring, + event( + "UID:other-series", + "SUMMARY:Other class", + "DTSTART:20260614T100000", + "DTEND:20260614T110000", + "RRULE:FREQ=DAILY;COUNT=4", + ), + event( + "UID:series", + "RECURRENCE-ID:20260615T100000", + "DTSTART:20260615T140000", + "DTEND:20260615T150000", + ), + ); + expect(result).toHaveLength(2); + expect( + result.map((activity) => [activity.uid, activity.start.getHours()]), + ).toEqual([ + ["other-series", 10], + ["series", 14], + ]); + expect(new Set(result.map((activity) => activity.id)).size).toBe(2); + }); + + it("includes exceptions moved into today and excludes those moved away", () => { + const result = normalize( + recurring, + event( + "UID:series", + "RECURRENCE-ID:20260614T100000", + "DTSTART:20260615T080000", + "DTEND:20260615T090000", + ), + event( + "UID:series", + "RECURRENCE-ID:20260615T100000", + "DTSTART:20260616T120000", + "DTEND:20260616T130000", + ), + ); + expect(result).toHaveLength(1); + expect(result[0].start).toEqual(new Date(2026, 5, 15, 8)); + }); + + it("suppresses cancelled instances even without DTSTART, and cancelled series/singles", () => { + expect( + normalize( + recurring, + event( + "UID:series", + "RECURRENCE-ID:20260615T100000", + "STATUS:CANCELLED", + ), + ), + ).toEqual([]); + expect( + normalize( + event( + "UID:cancelled", + "DTSTART:20260615T100000", + "DTEND:20260615T110000", + "STATUS:CANCELLED", + ), + ), + ).toEqual([]); + expect( + normalize( + event( + "UID:cancelled-series", + "DTSTART:20260614T100000", + "DTEND:20260614T110000", + "RRULE:FREQ=DAILY;COUNT=4", + "STATUS:CANCELLED", + ), + ), + ).toEqual([]); + }); + + it("keeps UTC timestamps as instants", () => { + const result = normalize( + event("UID:utc", "DTSTART:20260615T100000Z", "DTEND:20260615T110000Z"), + ); + expect(result[0].start.toISOString()).toBe("2026-06-15T10:00:00.000Z"); + }); + + it("resolves embedded VTIMEZONE offsets", () => { + const timezone = [ + "BEGIN:VTIMEZONE", + "TZID:Custom/PlusThree", + "BEGIN:STANDARD", + "DTSTART:19700101T000000", + "TZOFFSETFROM:+0300", + "TZOFFSETTO:+0300", + "END:STANDARD", + "END:VTIMEZONE", + ].join("\r\n"); + const result = normalizeDashboardIcs( + calendar( + timezone, + event( + "UID:zoned", + "DTSTART;TZID=Custom/PlusThree:20260615T100000", + "DTEND;TZID=Custom/PlusThree:20260615T110000", + ), + ), + "test", + nowMs, + ); + expect(result[0].start.toISOString()).toBe("2026-06-15T07:00:00.000Z"); + }); + + it("namespaces stable occurrence IDs by source", () => { + const ics = calendar(recurring); + expect(normalizeDashboardIcs(ics, "first", nowMs)[0].id).not.toBe( + normalizeDashboardIcs(ics, "second", nowMs)[0].id, + ); + expect(normalizeDashboardIcs(ics, "first", nowMs)).toEqual( + normalizeDashboardIcs(ics, "first", nowMs + 1000), + ); + }); + + it("distinguishes valid empty calendars from malformed feeds", () => { + expect(() => + normalizeDashboardIcs('{"error":"failed"}', "broken", nowMs), + ).toThrow(); + expect(normalizeDashboardIcs(calendar(), "empty", nowMs)).toEqual([]); + }); + + it("re-expands on local midnight and keeps day boundaries local", () => { + const day = getLocalCalendarDay(nowMs); + expect(day).toEqual({ + start: new Date(2026, 5, 15), + end: new Date(2026, 5, 16), + }); + expect( + normalizeDashboardIcs(calendar(recurring), "test", day.end.getTime())[0] + .start, + ).toEqual(new Date(2026, 5, 16, 10)); + }); +}); + +describe("dashboard When2Meet normalization", () => { + it("uses selected ISO instants and ignores unscheduled/out-of-day meetings", () => { + const result = normalizeDashboardMeetings( + [ + { + slug: "today", + name: "Study", + selected_time: { + start: "2026-06-15T12:00:00+03:00", + end: "2026-06-15T13:00:00+03:00", + }, + }, + { slug: "unscheduled", name: "Voting" }, + { + slug: "future", + name: "Later", + selected_time: { + start: "2026-06-17T10:00:00Z", + end: "2026-06-17T11:00:00Z", + }, + }, + ], + nowMs, + ); + expect(result).toEqual([ + { + id: "when2meet:today", + title: "Study", + start: new Date("2026-06-15T09:00:00Z"), + end: new Date("2026-06-15T10:00:00Z"), + }, + ]); + }); +}); diff --git a/src/components/calendar/dashboard-calendar-data.ts b/src/components/calendar/dashboard-calendar-data.ts new file mode 100644 index 00000000..4fd9efdb --- /dev/null +++ b/src/components/calendar/dashboard-calendar-data.ts @@ -0,0 +1,219 @@ +import type { when2meetTypes } from "@/api/when2meet"; +import ICAL from "ical.js"; +import { IcalExpander } from "./iCalendarPlugin/ical-expander/IcalExpander.js"; + +export type DashboardCalendarActivity = { + id: string; + /** Original ICS UID, used to merge the same event across selected feeds. */ + uid?: string; + title: string; + start: Date; + end: Date; + location?: string; + allDay?: boolean; +}; + +export type CalendarDataError = { sourceId: string; error: unknown }; + +const MAX_FEED_RECURRENCE_ITERATIONS = 10_000; + +type ExpandedOccurrence = { + item: ICAL.Event; + startDate: ICAL.Time; + endDate: ICAL.Time; +}; +type ExpandedCalendar = { + events: ICAL.Event[]; + occurrences: ExpandedOccurrence[]; +}; + +export function getLocalCalendarDay(nowMs: number) { + const start = new Date(nowMs); + start.setHours(0, 0, 0, 0); + const end = new Date(start); + end.setDate(end.getDate() + 1); + return { start, end }; +} + +export function activitiesInCalendarDay( + activities: DashboardCalendarActivity[], + nowMs: number, +) { + const { start, end } = getLocalCalendarDay(nowMs); + return activities.filter( + (activity) => activity.start < end && activity.end > start, + ); +} + +/** Match the full calendar's UID/start/end deduplication across ICS sources. */ +export function deduplicateCalendarActivities( + activities: DashboardCalendarActivity[], +) { + const seen = new Set(); + return activities.filter((activity) => { + if (!activity.uid) return true; + const key = JSON.stringify([ + activity.uid, + activity.start.getTime(), + activity.end.getTime(), + ]); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function isCancelled(event: ICAL.Event) { + return ( + String( + event.component.getFirstPropertyValue("status") ?? "", + ).toUpperCase() === "CANCELLED" + ); +} + +/** + * Expand only the requested local day. UTC, floating dates and VTIMEZONE use + * ical.js's native JS-Date conversion; date-only values stay local midnight. + * End dates are exclusive, including all-day and overnight events. + */ +export function normalizeDashboardIcs( + ics: string, + sourceId: string, + nowMs: number, +): DashboardCalendarActivity[] { + const expander = new IcalExpander({ ics, maxIterations: 0 }); + if (expander.component.name !== "vcalendar") + throw new Error("Expected an iCalendar document"); + // ICAL.Event otherwise auto-relates every sibling exception, even when its + // UID belongs to another series. Manual exception handling below owns this. + const events: ICAL.Event[] = expander.events.map( + (event: ICAL.Event) => new ICAL.Event(event.component, { exceptions: [] }), + ); + const exceptions = events.filter((event) => event.isRecurrenceException()); + const masters = events.filter((event) => !event.isRecurrenceException()); + const mastersByUid = new Map(masters.map((event) => [event.uid, event])); + + // The existing expander substitutes exceptions only when the ORIGINAL start + // overlaps the range. Exclude their original instances, then add replacements + // separately so instances moved into/out of today (and cancellation-only + // exceptions without DTSTART) work as well. + for (const master of masters) { + const excludedDates = master.component + .getAllProperties("exdate") + .flatMap((property) => property.getValues()); + master.component.removeAllProperties("exdate"); + for (const date of excludedDates) + master.component.addPropertyWithValue("exdate", date); + for (const exception of exceptions) { + if (exception.uid === master.uid) + master.component.addPropertyWithValue("exdate", exception.recurrenceId); + } + } + expander.events = masters.filter((event) => !isCancelled(event)); + let remainingIterations = MAX_FEED_RECURRENCE_ITERATIONS; + for (const master of expander.events) { + const createIterator = master.iterator.bind(master); + master.iterator = (...args: Parameters) => { + const iterator = createIterator(...args); + const next = iterator.next.bind(iterator); + iterator.next = () => { + // Bound work across the entire feed, not separately for each series. + // The expander's own limit silently truncates; throw instead so the + // query reports this source as failed rather than falsely empty. + if (remainingIterations-- <= 0) { + throw new Error( + `Calendar ${sourceId} exceeds the ${MAX_FEED_RECURRENCE_ITERATIONS}-iteration recurrence limit`, + ); + } + return next(); + }; + return iterator; + }; + } + const { start, end } = getLocalCalendarDay(nowMs); + const expandedStart = new Date(start); + expandedStart.setDate(expandedStart.getDate() - 1); + const expanded = expander.between(expandedStart, end) as ExpandedCalendar; + const instances: ExpandedOccurrence[] = [ + ...expanded.events.map((item) => ({ + item, + startDate: item.startDate, + endDate: item.endDate, + })), + ...expanded.occurrences, + ...exceptions + .filter((item) => !isCancelled(item) && !isCancelledMaster(item)) + .map((item) => ({ + item, + startDate: item.startDate, + endDate: item.endDate, + })), + ]; + + function isCancelledMaster(event: ICAL.Event) { + const master = mastersByUid.get(event.uid); + return master ? isCancelled(master) : false; + } + + const activities = instances.map(({ item, startDate, endDate }) => { + const activityStart = startDate.toJSDate(); + let activityEnd = endDate.toJSDate(); + const master = mastersByUid.get(item.uid); + if ( + !item.component.hasProperty("dtend") && + !item.component.hasProperty("duration") + ) { + if (item.isRecurrenceException() && master) { + const inheritedEnd = startDate.clone(); + inheritedEnd.addDuration(master.duration); + activityEnd = inheritedEnd.toJSDate(); + } + if (activityEnd <= activityStart) { + // Match the full calendar's default duration for events without an end. + activityEnd = new Date(activityStart); + if (startDate.isDate) activityEnd.setDate(activityEnd.getDate() + 1); + else activityEnd.setHours(activityEnd.getHours() + 1); + } + } + if ( + !Number.isFinite(activityStart.getTime()) || + !Number.isFinite(activityEnd.getTime()) + ) { + throw new Error(`Invalid event dates in ${sourceId}`); + } + return { + id: `${sourceId}:${item.uid}:${(item.recurrenceId ?? startDate).toString()}`, + uid: item.uid, + title: item.summary || master?.summary || "Untitled event", + start: activityStart, + end: activityEnd, + location: item.location || master?.location || undefined, + allDay: startDate.isDate, + }; + }); + return activitiesInCalendarDay(activities, nowMs); +} + +/** Use actual timestamps, not FullCalendar's display-only UTC date adjustment. */ +export function normalizeDashboardMeetings( + meetings: Pick< + when2meetTypes.SchemaEventView, + "slug" | "name" | "selected_time" + >[], + nowMs: number, +): DashboardCalendarActivity[] { + return activitiesInCalendarDay( + meetings.flatMap((meeting) => { + if (!meeting.selected_time) return []; + return [ + { + id: `when2meet:${meeting.slug}`, + title: meeting.name, + start: new Date(meeting.selected_time.start), + end: new Date(meeting.selected_time.end), + }, + ]; + }), + nowMs, + ); +} diff --git a/src/components/calendar/useDashboardCalendar.test.ts b/src/components/calendar/useDashboardCalendar.test.ts new file mode 100644 index 00000000..c6afe9cb --- /dev/null +++ b/src/components/calendar/useDashboardCalendar.test.ts @@ -0,0 +1,267 @@ +import { act, createElement, useEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + dashboardCalendarQueryOptions, + useDashboardCalendar, +} from "./useDashboardCalendar.ts"; + +const mocks = vi.hoisted(() => ({ + loadFeed: vi.fn(), + refetchMetadata: vi.fn(), + refetchMeetings: vi.fn(), + metadataPending: false, + metadataError: false, + meetingsError: false, +})); +vi.mock("@/api/schedule", () => ({ + $schedule: { + useQuery: (_method: string, path: string) => { + const metadata: Record = { + "/users/me": { + id: 123, + favorite_event_groups: [], + hidden_event_groups: [], + music_room_hidden: true, + sports_hidden: false, + moodle_hidden: true, + linked_calendars: {}, + }, + "/event-groups/": { event_groups: [] }, + "/users/me/predefined": { event_groups: [] }, + }; + const data = metadata[path]; + return { + data: mocks.metadataPending || mocks.metadataError ? undefined : data, + isPending: mocks.metadataPending, + isError: mocks.metadataError, + error: mocks.metadataError ? new Error("Selection failed") : null, + refetch: mocks.refetchMetadata, + }; + }, + queryOptions: (method: string, path: string, init: unknown) => ({ + queryKey: ["schedule", method, path, init], + queryFn: () => mocks.loadFeed(path, init), + }), + }, +})); +vi.mock("./useWhen2MeetCalendarEvents.ts", () => ({ + useWhen2MeetCalendarData: () => ({ + meetings: [], + isPending: false, + isError: mocks.meetingsError, + errors: mocks.meetingsError + ? [{ sourceId: "when2meet:owned", error: new Error("Meeting failed") }] + : [], + refetch: mocks.refetchMeetings, + }), +})); + +const ics = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "BEGIN:VEVENT", + "UID:day", + "SUMMARY:Workshop", + "DTSTART:20260615T100000", + "DTEND:20260615T110000", + "RRULE:FREQ=DAILY;COUNT=2", + "END:VEVENT", + "END:VCALENDAR", +].join("\r\n"); +const emptyIcs = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR"; +let result: ReturnType; +let root: Root; +let container: HTMLDivElement; +let client: QueryClient; +let nowMs: number; +function Harness() { + const calendar = useDashboardCalendar(nowMs); + useEffect(() => { + result = calendar; + }, [calendar]); + return null; +} +async function render() { + await act(async () => + root.render( + createElement(QueryClientProvider, { client }, createElement(Harness)), + ), + ); +} +async function settled() { + await vi.waitFor(async () => { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(result.isPending).toBe(false); + }); +} + +beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + mocks.metadataPending = false; + mocks.metadataError = false; + mocks.meetingsError = false; + mocks.loadFeed + .mockReset() + .mockImplementation((path: string) => + Promise.resolve(path.includes("events") ? ics : emptyIcs), + ); + mocks.refetchMetadata.mockReset().mockResolvedValue(undefined); + mocks.refetchMeetings.mockReset().mockResolvedValue(undefined); + nowMs = new Date(2026, 5, 15, 12).getTime(); + container = document.createElement("div"); + root = createRoot(container); + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); +}); +afterEach(async () => { + await act(async () => root.unmount()); + client.clear(); +}); + +describe("useDashboardCalendar", () => { + it("builds typed group/import feed queries with user and alias parameters", () => { + const group = dashboardCalendarQueryOptions({ + id: "group:1", + kind: "group", + userId: 123, + excludeEnglish: false, + group: { + id: 1, + alias: "group-one", + created_at: "2026-01-01", + updated_at: "2026-01-01", + virtual: false, + }, + }); + expect(group.queryKey).toEqual([ + "schedule", + "get", + "/{event_group_alias}.ics", + { + params: { + path: { event_group_alias: "group-one" }, + query: { user_id: 123, export_type: "web" }, + }, + parseAs: "text", + }, + ]); + const imported = dashboardCalendarQueryOptions({ + id: "imported:external", + kind: "imported", + userId: 123, + calendar: { + id: 9, + user_id: 123, + alias: "external", + url: "https://example.com/calendar.ics", + is_active: true, + }, + }); + expect(imported.queryKey).toEqual([ + "schedule", + "get", + "/users/{user_id}/linked/{linked_alias}.ics", + { + params: { path: { user_id: 123, linked_alias: "external" } }, + parseAs: "text", + }, + ]); + }); + + it("waits for selection before requesting typed text feeds, excluding sport", async () => { + mocks.metadataPending = true; + await render(); + expect(result.isPending).toBe(true); + expect(mocks.loadFeed).not.toHaveBeenCalled(); + mocks.metadataPending = false; + await render(); + await settled(); + expect(result.activities).toHaveLength(1); + expect(result.isError).toBe(false); + expect(mocks.loadFeed).toHaveBeenCalledWith("/users/me/events.ics", { + parseAs: "text", + }); + expect(mocks.loadFeed).toHaveBeenCalledWith("/users/me/room-bookings.ics", { + parseAs: "text", + }); + expect( + mocks.loadFeed.mock.calls.some(([path]) => + String(path).includes("sport"), + ), + ).toBe(false); + }); + + it("merges the same UID and interval from different feeds before the UI limits results", async () => { + mocks.loadFeed.mockResolvedValue(ics); + await render(); + await settled(); + expect(mocks.loadFeed).toHaveBeenCalledTimes(2); + expect(result.activities).toHaveLength(1); + expect(result.activities[0].uid).toBe("day"); + }); + + it("reports selection errors instead of a successful empty calendar", async () => { + mocks.metadataError = true; + await render(); + expect(result.isError).toBe(true); + expect(result.errors.map((error) => error.sourceId)).toContain( + "selection:user", + ); + expect(mocks.loadFeed).not.toHaveBeenCalled(); + }); + + it("retains successful data alongside HTTP, parse and meeting failures", async () => { + mocks.loadFeed.mockImplementation((path: string) => + path.includes("events") + ? Promise.resolve(ics) + : Promise.reject(new Error("HTTP failure")), + ); + mocks.meetingsError = true; + await render(); + await settled(); + expect(result.activities).toHaveLength(1); + expect(result.isError).toBe(true); + expect(result.errors.map((error) => error.sourceId)).toEqual([ + "room-bookings", + "when2meet:owned", + ]); + mocks.loadFeed.mockResolvedValue("not an ICS document"); + await act(async () => { + await result.refetch(); + }); + await settled(); + expect(result.errors.some((error) => error.sourceId === "events")).toBe( + true, + ); + }); + + it("refetches metadata, feeds and meetings, and recovers from errors", async () => { + mocks.loadFeed.mockRejectedValue(new Error("Offline")); + await render(); + await settled(); + expect(result.isError).toBe(true); + mocks.loadFeed.mockResolvedValue(emptyIcs); + await act(async () => { + await result.refetch(); + }); + await settled(); + expect(result.isError).toBe(false); + expect(mocks.refetchMetadata).toHaveBeenCalledTimes(3); + expect(mocks.refetchMeetings).toHaveBeenCalledOnce(); + expect(result.activities).toEqual([]); + }); + + it("reloads and re-expands for the new day when nowMs crosses local midnight", async () => { + await render(); + await settled(); + expect(result.activities[0].start).toEqual(new Date(2026, 5, 15, 10)); + nowMs = new Date(2026, 5, 16).getTime(); + await render(); + await settled(); + expect(result.activities[0].start).toEqual(new Date(2026, 5, 16, 10)); + expect(mocks.loadFeed).toHaveBeenCalledTimes(4); + }); +}); diff --git a/src/components/calendar/useDashboardCalendar.ts b/src/components/calendar/useDashboardCalendar.ts new file mode 100644 index 00000000..a6341a4b --- /dev/null +++ b/src/components/calendar/useDashboardCalendar.ts @@ -0,0 +1,142 @@ +import { $schedule } from "@/api/schedule"; +import { useQueries, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useMemo, useRef } from "react"; +import { type CalendarSource, getCalendarSources } from "./calendar-sources.ts"; +import { + type CalendarDataError, + deduplicateCalendarActivities, + getLocalCalendarDay, + normalizeDashboardIcs, + normalizeDashboardMeetings, +} from "./dashboard-calendar-data.ts"; +import { useWhen2MeetCalendarData } from "./useWhen2MeetCalendarEvents.ts"; + +export type { + DashboardCalendarActivity, + CalendarDataError, +} from "./dashboard-calendar-data.ts"; + +/** Typed text endpoints preserve auth middleware and TanStack's query caching. */ +export function dashboardCalendarQueryOptions(source: CalendarSource) { + if (source.kind === "group") { + return $schedule.queryOptions("get", "/{event_group_alias}.ics", { + params: { + path: { event_group_alias: source.group.alias }, + query: { user_id: source.userId, export_type: "web" }, + }, + parseAs: "text", + }); + } + if (source.kind === "imported") { + return $schedule.queryOptions( + "get", + "/users/{user_id}/linked/{linked_alias}.ics", + { + params: { + path: { user_id: source.userId, linked_alias: source.calendar.alias }, + }, + parseAs: "text", + }, + ); + } + const paths = { + "music-room": "/users/me/music-room.ics", + sport: "/users/me/sport.ics", + moodle: "/users/me/moodle.ics", + events: "/users/me/events.ics", + "room-bookings": "/users/me/room-bookings.ics", + } as const; + return $schedule.queryOptions("get", paths[source.kind], { parseAs: "text" }); +} + +/** + * All selected non-sport activities overlapping today's local day. The caller + * owns ticking nowMs, not-ended filtering, sorting, and the visible-item limit. + * isError includes partial failures; successful activities remain available. + */ +export function useDashboardCalendar(nowMs: number) { + const queryClient = useQueryClient(); + const userQuery = $schedule.useQuery("get", "/users/me"); + const groupsQuery = $schedule.useQuery("get", "/event-groups/"); + const predefinedQuery = $schedule.useQuery("get", "/users/me/predefined"); + const when2meet = useWhen2MeetCalendarData(); + const sources = useMemo(() => { + if (!userQuery.data || !groupsQuery.data || !predefinedQuery.data) + return []; + return getCalendarSources( + userQuery.data, + predefinedQuery.data.event_groups, + groupsQuery.data, + { excludeSport: true }, + ); + }, [userQuery.data, groupsQuery.data, predefinedQuery.data]); + + const dayStartMs = getLocalCalendarDay(nowMs).start.getTime(); + const feedOptions = useMemo( + () => + sources.map((source) => { + const options = dashboardCalendarQueryOptions(source); + return { + ...options, + staleTime: 60_000, + // Stable selectors avoid re-expanding old recurring series on every tick. + // TanStack exposes parser failures as query errors alongside HTTP errors. + select: (ics: string) => + normalizeDashboardIcs(ics, source.id, dayStartMs), + }; + }), + [sources, dayStartMs], + ); + const feedQueries = useQueries({ queries: feedOptions }); + const previousDayMs = useRef(dayStartMs); + useEffect(() => { + if (previousDayMs.current === dayStartMs) return; + previousDayMs.current = dayStartMs; + // Retain shared typed cache keys while refreshing feeds at local midnight. + // Selectors already re-expand cached text immediately for the new day. + for (const source of sources) { + void queryClient.invalidateQueries({ + queryKey: dashboardCalendarQueryOptions(source).queryKey, + }); + } + }, [dayStartMs, sources, queryClient]); + const metadataQueries = [userQuery, groupsQuery, predefinedQuery]; + const metadataIds = [ + "selection:user", + "selection:groups", + "selection:predefined", + ]; + const errors: CalendarDataError[] = [ + ...metadataQueries.flatMap((query, index) => + query.isError + ? [{ sourceId: metadataIds[index], error: query.error }] + : [], + ), + ...feedQueries.flatMap((query, index) => + query.isError + ? [{ sourceId: sources[index].id, error: query.error }] + : [], + ), + ...when2meet.errors, + ]; + + return { + activities: deduplicateCalendarActivities([ + ...feedQueries.flatMap((query) => query.data ?? []), + ...normalizeDashboardMeetings(when2meet.meetings, dayStartMs), + ]), + isPending: + metadataQueries.some((query) => query.isPending) || + feedQueries.some((query) => query.isPending) || + when2meet.isPending, + isError: errors.length > 0, + errors, + refetch: async (): Promise => { + await Promise.all([ + ...metadataQueries.map((query) => query.refetch()), + ...feedQueries.map((query) => query.refetch()), + when2meet.refetch(), + ]); + }, + }; +} diff --git a/src/components/calendar/useWhen2MeetCalendarEvents.test.ts b/src/components/calendar/useWhen2MeetCalendarEvents.test.ts new file mode 100644 index 00000000..671f860b --- /dev/null +++ b/src/components/calendar/useWhen2MeetCalendarEvents.test.ts @@ -0,0 +1,139 @@ +import { act, createElement, useEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + useWhen2MeetCalendarData, + useWhen2MeetCalendarEvents, +} from "./useWhen2MeetCalendarEvents.ts"; +import { toCalendarLocalDate } from "./when2meet-events.ts"; + +const mocks = vi.hoisted(() => ({ + loadMeeting: vi.fn(), + refetchList: vi.fn(), + enabled: true, + listError: false, +})); +vi.mock("@/api/accounts/user.ts", () => ({ + useMe: () => ({ me: mocks.enabled ? { id: "123" } : undefined }), +})); +vi.mock("@/api/when2meet", () => ({ + $when2meet: { + useQuery: (_method: string, path: string) => ({ + data: + path === "/meetings/" + ? [{ slug: "shared" }] + : [{ slug: "shared" }, { slug: "other" }], + isPending: !mocks.enabled, + isError: mocks.listError, + error: mocks.listError ? new Error("List failed") : null, + refetch: mocks.refetchList, + }), + queryOptions: ( + _method: string, + _path: string, + init: { params: { path: { meeting_ref: string } } }, + ) => ({ + queryKey: ["meeting", init.params.path.meeting_ref], + queryFn: () => mocks.loadMeeting(init.params.path.meeting_ref), + }), + }, +})); + +let data: ReturnType; +let legacy: ReturnType; +let root: Root; +let client: QueryClient; +function Harness() { + const queryData = useWhen2MeetCalendarData(); + const calendarEvents = useWhen2MeetCalendarEvents(); + useEffect(() => { + data = queryData; + legacy = calendarEvents; + }, [queryData, calendarEvents]); + return null; +} +async function render() { + await act(async () => + root.render( + createElement(QueryClientProvider, { client }, createElement(Harness)), + ), + ); +} +async function settled() { + await vi.waitFor(async () => { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(data.isPending).toBe(false); + }); +} +beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + mocks.enabled = true; + mocks.listError = false; + mocks.refetchList.mockReset().mockResolvedValue(undefined); + mocks.loadMeeting.mockReset().mockImplementation((slug: string) => + Promise.resolve({ + slug, + name: slug, + selected_time: + slug === "shared" + ? { start: "2026-06-15T12:00:00Z", end: "2026-06-15T13:00:00Z" } + : null, + }), + ); + root = createRoot(document.createElement("div")); + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); +}); +afterEach(async () => { + await act(async () => root.unmount()); + client.clear(); +}); + +describe("When2Meet query-aware calendar wrapper", () => { + it("deduplicates owned/participating meetings while preserving the legacy selected-event array", async () => { + await render(); + await settled(); + expect(mocks.loadMeeting).toHaveBeenCalledTimes(2); + expect(data.meetings).toHaveLength(2); + expect(legacy).toHaveLength(1); + expect(legacy[0]).toMatchObject({ + id: "when2meet-shared", + title: "shared", + start: toCalendarLocalDate("2026-06-15T12:00:00Z"), + }); + }); + + it("exposes partial detail failures with healthy meetings and retries lists/details", async () => { + mocks.loadMeeting.mockImplementation((slug: string) => + slug === "other" + ? Promise.reject(new Error("Failed")) + : Promise.resolve({ slug, name: slug, selected_time: null }), + ); + await render(); + await settled(); + expect(data.meetings).toHaveLength(1); + expect(data.isError).toBe(true); + expect(data.errors[0].sourceId).toBe("when2meet:other"); + await act(async () => { + await data.refetch(); + }); + expect(mocks.refetchList).toHaveBeenCalledTimes(2); + expect(mocks.loadMeeting).toHaveBeenCalledTimes(4); + }); + + it("exposes list errors and treats disabled queries as not pending", async () => { + mocks.listError = true; + await render(); + await settled(); + expect(data.errors.map((error) => error.sourceId)).toEqual([ + "when2meet:owned", + "when2meet:participating", + ]); + mocks.enabled = false; + mocks.listError = false; + await render(); + expect(data.isPending).toBe(false); + }); +}); diff --git a/src/components/calendar/useWhen2MeetCalendarEvents.ts b/src/components/calendar/useWhen2MeetCalendarEvents.ts index 2e5b5b02..45c0ec6a 100644 --- a/src/components/calendar/useWhen2MeetCalendarEvents.ts +++ b/src/components/calendar/useWhen2MeetCalendarEvents.ts @@ -4,31 +4,33 @@ import { useQueries } from "@tanstack/react-query"; import { useMemo } from "react"; import { when2MeetEventsToCalendarEvents } from "./when2meet-events.ts"; -export function useWhen2MeetCalendarEvents() { +/** Query-aware form for consumers that must distinguish empty data from failure. */ +export function useWhen2MeetCalendarData() { const { me } = useMe(); const isEnabled = !!me?.id; - - const { data: ownedMeetings = [] } = $when2meet.useQuery( + const ownedQuery = $when2meet.useQuery( "get", "/meetings/", {}, { enabled: isEnabled }, ); - - const { data: participatingMeetings = [] } = $when2meet.useQuery( + const participatingQuery = $when2meet.useQuery( "get", "/meetings/participating", {}, { enabled: isEnabled }, ); + const ownedMeetings = ownedQuery.data; + const participatingMeetings = participatingQuery.data; const meetingSummaries = useMemo(() => { - const bySlug = new Map(); - - for (const meeting of [...ownedMeetings, ...participatingMeetings]) { + const bySlug = new Map[number]>(); + for (const meeting of [ + ...(ownedMeetings ?? []), + ...(participatingMeetings ?? []), + ]) { bySlug.set(meeting.slug, meeting); } - return [...bySlug.values()]; }, [ownedMeetings, participatingMeetings]); @@ -40,12 +42,32 @@ export function useWhen2MeetCalendarEvents() { enabled: isEnabled, })), }); + const meetings = useMemo( + () => meetingQueries.flatMap((query) => (query.data ? [query.data] : [])), + [meetingQueries], + ); + const queries = [ownedQuery, participatingQuery, ...meetingQueries]; + const sourceIds = [ + "when2meet:owned", + "when2meet:participating", + ...meetingSummaries.map((meeting) => `when2meet:${meeting.slug}`), + ]; - return useMemo(() => { - const events = meetingQueries - .map((query) => query.data) - .filter((event): event is NonNullable => !!event); + return { + meetings, + isPending: isEnabled && queries.some((query) => query.isPending), + isError: queries.some((query) => query.isError), + errors: queries.flatMap((query, index) => + query.isError ? [{ sourceId: sourceIds[index], error: query.error }] : [], + ), + refetch: async () => { + if (isEnabled) await Promise.all(queries.map((query) => query.refetch())); + }, + }; +} - return when2MeetEventsToCalendarEvents(events); - }, [meetingQueries]); +/** Backwards-compatible event-array API for the full calendar and overlays. */ +export function useWhen2MeetCalendarEvents() { + const { meetings } = useWhen2MeetCalendarData(); + return useMemo(() => when2MeetEventsToCalendarEvents(meetings), [meetings]); } diff --git a/src/components/dashboard/ActivitySchedule.tsx b/src/components/dashboard/ActivitySchedule.tsx new file mode 100644 index 00000000..73e3f5df --- /dev/null +++ b/src/components/dashboard/ActivitySchedule.tsx @@ -0,0 +1,126 @@ +import { cn } from "@/lib/ui/cn"; +import { useState } from "react"; +import { + type DashboardActivity, + formatActivityTime, + getActivityStatus, +} from "./activities.ts"; + +/** Shared presentation only: each widget owns its data and visible time range. */ +export function ActivitySchedule({ + activities, + nowMs, + isPending, + isError, + onRetry, + emptyMessage, + showDates = false, +}: { + activities: DashboardActivity[]; + nowMs: number; + isPending: boolean; + isError: boolean; + onRetry: () => Promise; + emptyMessage: string; + showDates?: boolean; +}) { + const [isRetrying, setIsRetrying] = useState(false); + const nextActivity = activities[0]; + + async function handleRetry() { + setIsRetrying(true); + try { + await onRetry(); + } finally { + setIsRetrying(false); + } + } + + return ( +
+ {isError && ( +
+ Some activities could not be loaded. + +
+ )} + {isPending ? ( +
+
+ {[0, 1, 2].map((index) => ( +
+
+
+
+ ))} +
+ ) : nextActivity ? ( + <> +
+ + {getActivityStatus(nextActivity, nowMs)} + + {isError && ( + + Schedule may be incomplete + + )} +
+
    + {activities.map((activity) => { + const inProgress = activity.start.getTime() <= nowMs; + return ( +
  • + +
    +

    + {activity.title} +

    +

    + {showDates && + `${activity.start.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" })}, `} + {formatActivityTime(activity)} +

    + {activity.location && ( +

    + {activity.location} +

    + )} +
    +
  • + ); + })} +
+ + ) : !isError ? ( +

{emptyMessage}

+ ) : null} +
+ ); +} diff --git a/src/components/dashboard/CalendarWidget.test.ts b/src/components/dashboard/CalendarWidget.test.ts new file mode 100644 index 00000000..85301668 --- /dev/null +++ b/src/components/dashboard/CalendarWidget.test.ts @@ -0,0 +1,85 @@ +import { act, createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CalendarWidget } from "./CalendarWidget.tsx"; +import type { DashboardActivity } from "./activities.ts"; + +const state = vi.hoisted(() => ({ + now: new Date(2026, 0, 10, 12).getTime(), + activities: [] as DashboardActivity[], + isPending: false, + isError: false, + refetch: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/components/calendar/useDashboardCalendar.ts", () => ({ + useDashboardCalendar: () => state, +})); +vi.mock("@/lib/utils/use-now.ts", () => ({ useNowMS: () => state.now })); +vi.mock("@tanstack/react-router", () => ({ + Link: ({ + to, + children, + ...props + }: { + to: string; + children: import("react").ReactNode; + }) => createElement("a", { href: to, ...props }, children), +})); +let root: Root; +let container: HTMLDivElement; +async function render() { + await act(async () => root.render(createElement(CalendarWidget))); +} +beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + state.isError = false; + state.isPending = false; + state.activities = []; + state.refetch.mockClear(); + container = document.createElement("div"); + root = createRoot(container); +}); +afterEach(async () => { + await act(async () => root.unmount()); +}); + +describe("CalendarWidget", () => { + it("renders five remaining activities, live status and calendar link", async () => { + state.activities = Array.from({ length: 8 }, (_, index) => ({ + id: String(index), + title: `Activity ${index}`, + location: "Room 108", + start: new Date(state.now + (index - 1) * 60_000), + end: new Date(state.now + (index + 1) * 60_000), + })).reverse(); + await render(); + expect(container.querySelectorAll("li")).toHaveLength(5); + expect(container.querySelector("li")?.textContent).toContain("Activity 0"); + expect(container.textContent).toContain("In process"); + expect(container.querySelector("a")?.getAttribute("href")).toBe( + "/calendar", + ); + expect(container.querySelector("a")?.textContent).toContain( + "Go to calendar", + ); + }); + it("shows loading and empty states separately", async () => { + state.isPending = true; + await render(); + expect(container.querySelector(".skeleton")).not.toBeNull(); + expect(container.textContent).not.toContain("No more activities"); + state.isPending = false; + await render(); + expect(container.textContent).toContain("No more activities today"); + }); + it("shows failures with retry, not a misleading empty state", async () => { + state.isError = true; + await render(); + expect(container.textContent).toContain( + "Some activities could not be loaded", + ); + expect(container.textContent).not.toContain("No more activities today"); + await act(async () => container.querySelector("button")?.click()); + expect(state.refetch).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/components/dashboard/CalendarWidget.tsx b/src/components/dashboard/CalendarWidget.tsx new file mode 100644 index 00000000..3a712ce9 --- /dev/null +++ b/src/components/dashboard/CalendarWidget.tsx @@ -0,0 +1,46 @@ +import { useDashboardCalendar } from "@/components/calendar/useDashboardCalendar.ts"; +import { useNowMS } from "@/lib/utils/use-now.ts"; +import { Link } from "@tanstack/react-router"; +import { ActivitySchedule } from "./ActivitySchedule.tsx"; +import { getUpcomingActivities } from "./activities.ts"; + +export function CalendarWidget() { + const nowMs = useNowMS(true); + const { activities, isPending, isError, refetch } = + useDashboardCalendar(nowMs); + const upcoming = getUpcomingActivities(activities, nowMs, 5); + + return ( +
+
+ +
+

Calendar

+

+ Today ·{" "} + {new Date(nowMs).toLocaleDateString("en-US", { + weekday: "short", + month: "short", + day: "numeric", + })} +

+
+
+ + + Go to calendar + + +
+ ); +} diff --git a/src/components/dashboard/DashboardPage.tsx b/src/components/dashboard/DashboardPage.tsx index 510032b4..ed7fa65c 100644 --- a/src/components/dashboard/DashboardPage.tsx +++ b/src/components/dashboard/DashboardPage.tsx @@ -7,7 +7,7 @@ import { PwaWidget } from "@/components/dashboard/PwaWidget.tsx"; import { SportsWidget } from "@/components/dashboard/SportsWidget.tsx"; import { MusicRoomWidget } from "@/components/dashboard/MusicRoomWidget.tsx"; import { TelegramReconnectWidget } from "@/components/dashboard/TelegramReconnectWidget.tsx"; -import { Link } from "@tanstack/react-router"; +import { CalendarWidget } from "./CalendarWidget.tsx"; import Links from "./links/Links.tsx"; export function DashboardPage() { @@ -31,6 +31,7 @@ export function DashboardPage() {
+
@@ -39,14 +40,6 @@ export function DashboardPage() {
- - - Go to calendar - -
); diff --git a/src/components/dashboard/activities.test.ts b/src/components/dashboard/activities.test.ts new file mode 100644 index 00000000..ce1d0f38 --- /dev/null +++ b/src/components/dashboard/activities.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { + type DashboardActivity, + formatActivityTime, + getActivityStatus, + getUpcomingActivities, +} from "./activities.ts"; + +const now = new Date(2026, 0, 10, 12).getTime(); +function activity( + startMinutes: number, + endMinutes = startMinutes + 60, +): DashboardActivity { + return { + id: String(startMinutes), + title: "Activity", + start: new Date(now + startMinutes * 60_000), + end: new Date(now + endMinutes * 60_000), + }; +} + +describe("dashboard activity selection", () => { + it("keeps ongoing activities, excludes ended and invalid intervals, and sorts without mutating input", () => { + const input = [ + activity(10), + activity(-30), + activity(-60, 0), + activity(0, 0), + activity(20, 10), + { ...activity(0), end: new Date(NaN) }, + ]; + expect(getUpcomingActivities(input, now, 5).map((item) => item.id)).toEqual( + ["-30", "10"], + ); + expect(input[0].id).toBe("10"); + }); + it("does not let all-day reminders hide timed activities or their status", () => { + const reminders = Array.from({ length: 5 }, (_, index) => ({ + ...activity(-720, 720), + id: `reminder-${index}`, + allDay: true, + })); + const upcoming = getUpcomingActivities( + [...reminders, activity(-30), activity(60)], + now, + 5, + ); + expect(upcoming[0].id).toBe("-30"); + expect(upcoming[1].id).toBe("60"); + expect(getActivityStatus(upcoming[0], now)).toBe("In process"); + expect(upcoming).toHaveLength(5); + }); + it("limits calendar and sport lists to five and three", () => { + const input = Array.from({ length: 8 }, (_, index) => activity(index * 10)); + expect(getUpcomingActivities(input, now, 5)).toHaveLength(5); + expect(getUpcomingActivities(input, now, 3)).toHaveLength(3); + }); +}); + +describe("live activity status", () => { + it("transitions exactly at start and end", () => { + const item = activity(43); + expect(getActivityStatus(item, now)).toBe("Starts in 43 min"); + expect(getActivityStatus(item, item.start.getTime())).toBe("In process"); + expect(getActivityStatus(item, item.end.getTime())).toBe("Finished"); + }); + it("rounds up short waits and formats hours and days", () => { + expect(getActivityStatus(activity(0.01), now)).toBe("Starts in 1 min"); + expect(getActivityStatus(activity(60), now)).toBe("Starts in 1 h"); + expect(getActivityStatus(activity(130), now)).toBe("Starts in 2 h 10 min"); + expect(getActivityStatus(activity(1500), now)).toBe("Starts in 1 d 1 h"); + }); + it("handles all-day activities explicitly", () => { + const item = { ...activity(-30), allDay: true }; + expect(getActivityStatus(item, now)).toBe("All day"); + expect(formatActivityTime(item)).toBe("All day"); + }); + it("shows the end date when activities cross midnight", () => { + const item = { + ...activity(0), + start: new Date(2026, 0, 10, 23), + end: new Date(2026, 0, 11, 1), + }; + expect(formatActivityTime(item)).toBe("23:00–Jan 11, 01:00"); + }); +}); diff --git a/src/components/dashboard/activities.ts b/src/components/dashboard/activities.ts new file mode 100644 index 00000000..b0e54981 --- /dev/null +++ b/src/components/dashboard/activities.ts @@ -0,0 +1,71 @@ +export type DashboardActivity = { + id: string; + title: string; + start: Date; + end: Date; + location?: string; + allDay?: boolean; +}; + +export function getUpcomingActivities( + activities: T[], + nowMs: number, + limit: number, +): T[] { + return ( + activities + .filter( + ({ start, end }) => + Number.isFinite(start.getTime()) && + Number.isFinite(end.getTime()) && + end.getTime() > start.getTime() && + end.getTime() > nowMs, + ) + // Prioritize timed activities so all-day reminders cannot hide every class + // or mask the ongoing/next activity's status. Each group stays chronological. + .sort( + (left, right) => + Number(!!left.allDay) - Number(!!right.allDay) || + left.start.getTime() - right.start.getTime(), + ) + .slice(0, limit) + ); +} + +export function getActivityStatus(activity: DashboardActivity, nowMs: number) { + if (activity.end.getTime() <= nowMs) return "Finished"; + if (activity.start.getTime() <= nowMs) { + return activity.allDay ? "All day" : "In process"; + } + + const minutes = Math.ceil((activity.start.getTime() - nowMs) / 60_000); + if (minutes < 60) return `Starts in ${minutes} min`; + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + if (hours < 24) { + return `Starts in ${hours} h${remainingMinutes ? ` ${remainingMinutes} min` : ""}`; + } + const days = Math.floor(hours / 24); + const remainingHours = hours % 24; + return `Starts in ${days} d${remainingHours ? ` ${remainingHours} h` : ""}`; +} + +export function formatActivityTime(activity: DashboardActivity) { + if (activity.allDay) return "All day"; + const options: Intl.DateTimeFormatOptions = { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }; + const startTime = activity.start.toLocaleTimeString("en-GB", options); + const endTime = activity.end.toLocaleTimeString("en-GB", options); + const crossesDay = + activity.start.toDateString() !== activity.end.toDateString(); + const endDate = crossesDay + ? activity.end.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }) + ", " + : ""; + return `${startTime}–${endDate}${endTime}`; +} From 3ea43e681cfd1afbd36081dab702b1ffc41fc85f Mon Sep 17 00:00:00 2001 From: SerafimSoldatov Date: Mon, 28 Sep 2026 11:23:34 +0300 Subject: [PATCH 04/13] feat(dashboard): show sport check-ins and prioritize calendar Show three checked-in sessions with live status and official-site navigation, preserve sport hours and deadlines, and place Calendar before Sports. Validate responsive layouts and timed transitions with a mocked browser smoke test. Closes one-zero-eight/website#236 --- e2e/dashboard.smoke.mjs | 248 ++++++++++++++++++ src/components/dashboard/DashboardPage.tsx | 4 +- src/components/dashboard/SportsWidget.test.ts | 151 +++++++++++ src/components/dashboard/SportsWidget.tsx | 205 +++++++++++---- .../dashboard/sport-activities.test.ts | 117 +++++++++ src/components/dashboard/sport-activities.ts | 35 +++ 6 files changed, 702 insertions(+), 58 deletions(-) create mode 100644 e2e/dashboard.smoke.mjs create mode 100644 src/components/dashboard/SportsWidget.test.ts create mode 100644 src/components/dashboard/sport-activities.test.ts create mode 100644 src/components/dashboard/sport-activities.ts diff --git a/e2e/dashboard.smoke.mjs b/e2e/dashboard.smoke.mjs new file mode 100644 index 00000000..66ab1463 --- /dev/null +++ b/e2e/dashboard.smoke.mjs @@ -0,0 +1,248 @@ +// Run after `pnpm run build`: node e2e/dashboard.smoke.mjs +// Serves the production bundle through Playwright routing, without starting a +// server or sending requests to real services. All account/API data is synthetic. +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { chromium } from "@playwright/test"; +import { loadEnv } from "vite"; + +const root = process.cwd(); +const env = loadEnv("production", root, "VITE_"); +const now = new Date("2026-06-15T09:00:00Z"); +const user = { + id: 123, + innopolis_info: { + name: "Dashboard Test Student", + email: "student@example.test", + is_student: true, + }, + telegram_info: { username: "test_student" }, +}; +const token = `test.${Buffer.from(JSON.stringify({ exp: 4102444800 })).toString("base64url")}.test`; +const emptyIcs = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR"; +const iso = (minutes) => + new Date(now.getTime() + minutes * 60_000).toISOString(); +const icsDate = (minutes) => + iso(minutes).replaceAll(/[-:]/g, "").replace(".000", ""); +const calendarIcs = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + ...Array.from({ length: 7 }, (_, index) => [ + "BEGIN:VEVENT", + `UID:class-${index}`, + `SUMMARY:${index === 0 ? "Distributed systems seminar" : `Daily calendar activity ${index}`}`, + "LOCATION:University room 108", + `DTSTART:${icsDate(index * 60 - 15)}`, + `DTEND:${icsDate(index * 60 + 45)}`, + "END:VEVENT", + ]).flat(), + "END:VCALENDAR", +].join("\r\n"); +const trainings = Array.from({ length: 5 }, (_, index) => ({ + title: index === 0 ? "Not checked in" : `Sport session ${index}`, + start: iso(index * 60 - 17), + end: iso(index * 60 + 43), + allDay: false, + extendedProps: { + id: index, + checked_in: index !== 0, + training_class: "Sports complex · Training hall", + }, +})); +const contentTypes = { + ".html": "text/html", + ".js": "text/javascript", + ".css": "text/css", + ".json": "application/json", + ".svg": "image/svg+xml", + ".png": "image/png", + ".woff2": "font/woff2", + ".woff": "font/woff", +}; +const browser = await chromium.launch({ + executablePath: process.env.CHROMIUM_PATH || undefined, + headless: true, +}); +try { + for (const width of [360, 768, 1440]) { + for (const theme of ["light", "dark"]) { + const context = await browser.newContext({ + viewport: { width, height: 1100 }, + serviceWorkers: "block", + timezoneId: "Europe/Moscow", + }); + const page = await context.newPage(); + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.clock.install({ time: now }); + await page.addInitScript( + ({ token, user, theme }) => { + localStorage.setItem("accessToken", JSON.stringify(token)); + localStorage.setItem("accessTokenSport", JSON.stringify(token)); + localStorage.setItem("user", JSON.stringify(user)); + localStorage.setItem("theme", JSON.stringify(theme)); + }, + { token, user, theme }, + ); + await page.route("**/*", async (route) => { + const url = new URL(route.request().url()); + const matchingApi = Object.entries(env).find( + ([key, value]) => + key.endsWith("API_URL") && + url.href.startsWith(value.replace(/\/$/, "") + "/"), + ); + if (matchingApi) { + const [service, base] = matchingApi; + const endpoint = url.pathname.slice( + new URL(base).pathname.replace(/\/$/, "").length, + ); + let data = []; + if (service.includes("ACCOUNTS")) + data = endpoint === "/users/me" ? user : {}; + if (service.includes("INNOSPORT")) { + if (endpoint === "/profile/student") data = { id: 123 }; + else if (endpoint.endsWith("/hours")) + data = { + ongoing_semester: { + id_sem: 1, + hours_not_self: 14, + hours_self_not_debt: 2, + hours_self_debt: 0, + hours_sem_max: 30, + debt: 0, + }, + }; + else if (endpoint === "/semester") + data = [{ id: 1, end: "2026-08-31" }]; + else if (endpoint === "/calendar/trainings") data = trainings; + } + if (service === "VITE_SCHEDULE_API_URL") { + if (endpoint === "/users/me") + data = { + id: 123, + favorite_event_groups: [1], + hidden_event_groups: [], + sports_hidden: false, + music_room_hidden: true, + moodle_hidden: true, + linked_calendars: {}, + }; + else if (endpoint === "/event-groups/") + data = { + event_groups: [ + { + id: 1, + name: "B25", + alias: "test", + tags: [], + created_at: now.toISOString(), + updated_at: now.toISOString(), + }, + ], + }; + else if (endpoint === "/users/me/predefined") + data = { event_groups: [1] }; + else if (endpoint.endsWith(".ics")) + return route.fulfill({ + contentType: "text/calendar", + body: endpoint === "/test.ics" ? calendarIcs : emptyIcs, + }); + } + return route.fulfill({ json: data }); + } + if (url.origin === "http://dashboard.test") { + const relative = + url.pathname === "/" ? "index.html" : url.pathname.slice(1); + const file = path.resolve(root, "dist", relative); + if (file.startsWith(path.resolve(root, "dist") + path.sep)) { + try { + return await route.fulfill({ + contentType: + contentTypes[path.extname(file)] || + "application/octet-stream", + body: await readFile(file), + }); + } catch { + /* Treat unknown local paths as missing assets. */ + } + } + } + return route.fulfill({ status: 404, body: "Mock: no route" }); + }); + await page.goto("http://dashboard.test/"); + const calendar = page.locator("section").filter({ + has: page.getByRole("heading", { name: "Calendar", exact: true }), + }); + const sports = page.locator("section").filter({ + has: page.getByRole("heading", { name: "Sports", exact: true }), + }); + await calendar.getByText("Distributed systems seminar").waitFor(); + assert.equal(await calendar.locator("li").count(), 5); + assert.equal( + await calendar.getByText("In process", { exact: true }).count(), + 1, + ); + await sports.getByText("Sport session 1").waitFor(); + assert.equal(await sports.locator("li").count(), 3); + assert.equal(await sports.getByText("Not checked in").count(), 0); + assert.equal( + await sports.getByText("Starts in 43 min", { exact: true }).count(), + 1, + ); + assert.equal( + await page + .getByRole("link", { name: "Go to calendar", exact: true }) + .count(), + 1, + ); + assert.equal( + await sports + .getByRole("link", { name: "Go to official site" }) + .getAttribute("href"), + "https://sport.innopolis.university/profile/", + ); + await page.evaluate( + (theme) => document.documentElement.setAttribute("data-theme", theme), + theme, + ); + const calendarBox = await calendar.boundingBox(); + const sportBox = await sports.boundingBox(); + assert.ok(calendarBox.y <= sportBox.y); + if (width === 1440) assert.ok(calendarBox.x < sportBox.x); + if (width === 360) assert.ok(calendarBox.y < sportBox.y); + assert.ok( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth, + ), + `Horizontal overflow at ${width}`, + ); + assert.deepEqual(errors, []); + if (process.env.DASHBOARD_SCREENSHOT_DIR) + await page.screenshot({ + path: path.join( + process.env.DASHBOARD_SCREENSHOT_DIR, + `dashboard-${width}-${theme}.png`, + ), + fullPage: true, + }); + await page.clock.fastForward(43 * 60_000); + await sports.getByText("In process", { exact: true }).waitFor(); + await page.clock.fastForward(60 * 60_000); + await page.waitForFunction( + () => + ![...document.querySelectorAll("section li")].some((row) => + row.textContent.includes("Sport session 1"), + ), + ); + assert.equal(await sports.locator("li").count(), 3); + assert.deepEqual(errors, []); + console.log( + `PASS ${width}px ${theme}: limits, live transitions, order, links, no overflow or browser exceptions`, + ); + await context.close(); + } + } +} finally { + await browser.close(); +} diff --git a/src/components/dashboard/DashboardPage.tsx b/src/components/dashboard/DashboardPage.tsx index ed7fa65c..cb3e07bb 100644 --- a/src/components/dashboard/DashboardPage.tsx +++ b/src/components/dashboard/DashboardPage.tsx @@ -29,9 +29,9 @@ export function DashboardPage() {
-
- +
+
diff --git a/src/components/dashboard/SportsWidget.test.ts b/src/components/dashboard/SportsWidget.test.ts new file mode 100644 index 00000000..4e6d5f46 --- /dev/null +++ b/src/components/dashboard/SportsWidget.test.ts @@ -0,0 +1,151 @@ +import { act, createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SportsWidget } from "./SportsWidget.tsx"; + +const state = vi.hoisted(() => ({ + token: "test-token" as string | null, + now: new Date(2026, 5, 15, 12).getTime(), + pending: "", + failed: "", + refetch: vi.fn().mockResolvedValue(undefined), + queryCalls: [] as { path: string; enabled?: boolean }[], +})); +vi.mock("@/api/helpers/sport-access-token.ts", () => ({ + useMySportAccessToken: () => [state.token], +})); +vi.mock("@/lib/utils/use-now.ts", () => ({ useNowMS: () => state.now })); +vi.mock("@/api/sport", () => ({ + $sport: { + useQuery: ( + _method: string, + path: string, + _init: unknown, + options: { enabled?: boolean; select?: (data: unknown) => unknown } = {}, + ) => { + state.queryCalls.push({ path, enabled: options.enabled }); + let data: unknown; + if (path === "/users/me") data = { user_id: 123 }; + else if (path === "/semesters/current") data = { end: "2026-08-31" }; + else if (path === "/students/{student_id}/hours-summary") + data = { + hours_from_groups: 10, + self_sport_hours: 6, + required_hours: 30, + debt: 3, + }; + else if (path === "/users/me/schedule") + data = Array.from({ length: 5 }, (_, index) => ({ + training: { + id: index, + start: new Date(state.now + index * 60_000).toISOString(), + end: new Date(state.now + (index + 30) * 60_000).toISOString(), + is_all_day: false, + training_location: { id: 1, name: "Sport hall" }, + display_name: `Training ${index}`, + sport_name: null, + group_name: null, + training_custom_name: null, + is_accredited: false, + is_club: false, + is_paid: false, + sport_id: null, + group_id: 1, + checkins_count: 0, + max_checkins: 10, + }, + checked_in: index !== 0, + can_check_in: true, + can_grade: false, + can_edit: false, + })); + const isPending = state.pending === path || options.enabled === false; + const isError = state.failed === path; + return { + data: + isPending || isError + ? undefined + : options.select + ? options.select(data) + : data, + isPending, + isError, + isFetching: false, + refetch: state.refetch, + }; + }, + }, +})); +let root: Root; +let container: HTMLDivElement; +async function render() { + await act(async () => root.render(createElement(SportsWidget))); +} +beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + state.token = "test-token"; + state.pending = ""; + state.failed = ""; + state.queryCalls = []; + state.refetch.mockClear(); + container = document.createElement("div"); + root = createRoot(container); +}); +afterEach(async () => { + await act(async () => root.unmount()); +}); + +describe("SportsWidget", () => { + it("renders only three checked-in sessions, status, hours/debt, deadline and navigation", async () => { + await render(); + expect(container.querySelectorAll("li")).toHaveLength(3); + expect(container.textContent).not.toContain("Training 0"); + expect(container.textContent).toContain("Starts in 1 min"); + expect(container.textContent).toContain("16 / 30 +3 (debt)"); + expect(container.textContent).toContain("August 31, 2026"); + expect(container.querySelector("a")?.getAttribute("href")).toBe( + "https://sport.innopolis.university/profile/", + ); + }); + it("disables requests and hides the card without a sport token", async () => { + state.token = null; + await render(); + expect(container.textContent).toBe(""); + expect(state.queryCalls.every((query) => query.enabled === false)).toBe( + true, + ); + }); + it("waits for profile before requesting check-ins", async () => { + state.pending = "/users/me"; + await render(); + expect(container.querySelector(".skeleton")).not.toBeNull(); + expect( + state.queryCalls.find((query) => query.path === "/users/me/schedule") + ?.enabled, + ).toBe(false); + }); + it("reports profile failure and allows retry instead of a permanent skeleton", async () => { + state.failed = "/users/me"; + await render(); + expect(container.textContent).toContain( + "Could not load your sport profile", + ); + expect(container.querySelector(".skeleton")).toBeNull(); + await act(async () => container.querySelector("button")?.click()); + expect(state.refetch).toHaveBeenCalledOnce(); + }); + it("keeps check-ins visible if hours fail, and hours visible if check-ins fail", async () => { + state.failed = "/students/{student_id}/hours-summary"; + await render(); + expect(container.querySelectorAll("li")).toHaveLength(3); + expect(container.textContent).toContain( + "Sport hours or deadline could not be loaded", + ); + state.failed = "/users/me/schedule"; + await render(); + expect(container.textContent).toContain( + "Some activities could not be loaded", + ); + expect(container.textContent).toContain("16 / 30"); + }); +}); diff --git a/src/components/dashboard/SportsWidget.tsx b/src/components/dashboard/SportsWidget.tsx index ffeabae7..94c7bf05 100644 --- a/src/components/dashboard/SportsWidget.tsx +++ b/src/components/dashboard/SportsWidget.tsx @@ -1,83 +1,176 @@ +import { useMySportAccessToken } from "@/api/helpers/sport-access-token.ts"; import { $sport } from "@/api/sport"; import { useNowMS } from "@/lib/utils/use-now.ts"; -import { useEffect } from "react"; -import { useLocalStorage } from "usehooks-ts"; +import { ActivitySchedule } from "./ActivitySchedule.tsx"; +import { getUpcomingActivities } from "./activities.ts"; +import { + getSportCalendarRange, + normalizeSportActivities, +} from "./sport-activities.ts"; export function SportsWidget() { - const [widgetShown, setWidgetShown] = useLocalStorage("widget-sports", false); - const { data: profile, isPending: profileIsPending } = $sport.useQuery( + const [sportToken] = useMySportAccessToken(); + const profileQuery = $sport.useQuery( "get", "/users/me", + {}, + { enabled: !!sportToken }, ); - const hasSportProfile = !!profile; - const { data: hours } = $sport.useQuery( + const profile = profileQuery.data; + const hoursQuery = $sport.useQuery( "get", "/students/{student_id}/hours-summary", { params: { path: { student_id: Number(profile?.user_id) } } }, - { enabled: !!profile }, + { enabled: !!profile && !!sportToken }, ); - const { data: currentSemester } = $sport.useQuery( + const semesterQuery = $sport.useQuery( "get", "/semesters/current", {}, - { enabled: !!profile }, + { enabled: !!profile && !!sportToken }, + ); + const nowMs = useNowMS(!!sportToken); + const range = getSportCalendarRange(nowMs); + const trainingsQuery = $sport.useQuery( + "get", + "/users/me/schedule", + { params: { query: range } }, + { + enabled: !!profile && !!sportToken, + select: normalizeSportActivities, + staleTime: 30_000, + refetchInterval: 60_000, + }, ); - const nowMs = useNowMS(hasSportProfile); - - useEffect(() => { - setWidgetShown((v) => (v && profileIsPending) || hasSportProfile); - }, [setWidgetShown, profileIsPending, hasSportProfile]); - - if (!hasSportProfile || !hours || !currentSemester) { - if (!widgetShown) return null; - return ( -
- ); - } + if (!sportToken) return null; - const earnedHours = hours.hours_from_groups + hours.self_sport_hours; - const semesterHours = hours.required_hours; - const debtHours = hours.debt; - - const deadline = new Date(currentSemester?.end || "2025-05-04"); - const daysLeft = Math.max( - 0, - Math.ceil((deadline.getTime() - nowMs) / (1000 * 60 * 60 * 24)), - ); + const hours = hoursQuery.data; + const earnedHours = hours + ? hours.hours_from_groups + hours.self_sport_hours + : undefined; + const currentSemester = semesterQuery.data; + const deadline = currentSemester?.end + ? new Date(currentSemester.end) + : undefined; + const hasDeadline = deadline && Number.isFinite(deadline.getTime()); + const daysLeft = hasDeadline + ? Math.max(0, Math.ceil((deadline.getTime() - nowMs) / 86_400_000)) + : undefined; + const upcoming = getUpcomingActivities(trainingsQuery.data ?? [], nowMs, 3); return ( -
- -
-

- - - Sports:{" "} - - {earnedHours} / {semesterHours} - {debtHours ? `+${debtHours} (debt)` : null} hours - - -

-

- Deadline:{" "} - {deadline.toLocaleDateString("en-US", { - month: "long", - day: "numeric", - year: "numeric", - })}{" "} - ({daysLeft} days left) -

+
+
+ +
+

Sports

+

+ Your check-ins · Next 7 days +

+
+
+ {profileQuery.isError ? ( +
+ Could not load your sport profile. + +
+ ) : ( + <> + trainingsQuery.refetch()} + emptyMessage="No upcoming check-ins in the next 7 days" + showDates + /> +
+ {hoursQuery.isError || semesterQuery.isError ? ( +
+ Sport hours or deadline could not be loaded. + +
+ ) : hoursQuery.isPending || semesterQuery.isPending ? ( + <> +
+
+ + ) : ( + <> + {hours && ( +

+ + {earnedHours} / {hours.required_hours} + {hours.debt ? ` +${hours.debt} (debt)` : ""} + {" "} + hours +

+ )} +

+ {hasDeadline ? ( + <> + Deadline:{" "} + {deadline.toLocaleDateString("en-US", { + month: "long", + day: "numeric", + year: "numeric", + })}{" "} + ({daysLeft} days left) + + ) : ( + "Semester deadline unavailable" + )} +

+ + )} +
+ + )} + -
+
); } diff --git a/src/components/dashboard/sport-activities.test.ts b/src/components/dashboard/sport-activities.test.ts new file mode 100644 index 00000000..33cae502 --- /dev/null +++ b/src/components/dashboard/sport-activities.test.ts @@ -0,0 +1,117 @@ +import type { SchemaTrainingInfoPersonalSchema } from "@/api/sport/types.ts"; +import { describe, expect, it } from "vitest"; +import { getUpcomingActivities } from "./activities.ts"; +import { + getSportCalendarRange, + normalizeSportActivities, +} from "./sport-activities.ts"; + +const now = new Date("2026-06-15T10:00:00+03:00").getTime(); +function training( + id: number, + checkedIn: boolean, + startOffset = 0, +): SchemaTrainingInfoPersonalSchema { + return { + training: { + id, + start: new Date(now + startOffset * 60_000).toISOString(), + end: new Date(now + (startOffset + 60) * 60_000).toISOString(), + is_all_day: false, + training_location: { id: 1, name: "Sport complex" }, + display_name: `Training ${id}`, + sport_name: null, + group_name: null, + training_custom_name: null, + is_accredited: false, + is_club: false, + is_paid: false, + sport_id: null, + group_id: 1, + checkins_count: 0, + max_checkins: 10, + }, + checked_in: checkedIn, + can_check_in: true, + can_grade: false, + can_edit: false, + }; +} + +describe("sport calendar adapter", () => { + it("maps only checked-in trainings to dashboard activities", () => { + const activities = normalizeSportActivities([ + training(1, true), + training(2, false), + ]); + expect(activities).toHaveLength(1); + expect(activities[0]).toMatchObject({ + id: "1", + title: "Training 1", + location: "Sport complex", + }); + expect(activities[0].start.getTime()).toBe(now); + }); + + it("accepts empty lists and nullable locations", () => { + expect(normalizeSportActivities([])).toEqual([]); + const [activity] = normalizeSportActivities([ + { + ...training(1, true), + training: { ...training(1, true).training, training_location: null }, + }, + ]); + expect(activity.location).toBeUndefined(); + }); + + it("excludes ended sessions, keeps ongoing ones and limits checked-in list to three", () => { + const response = [ + training(1, true, 180), + training(2, true, -30), + training(3, true, -60), + training(4, true, 120), + training(5, false), + training(6, true, 60), + ]; + expect( + getUpcomingActivities(normalizeSportActivities(response), now, 3).map( + (item) => item.id, + ), + ).toEqual(["2", "6", "4"]); + }); +}); + +describe("sport query range", () => { + it("retains an overnight check-in when the backend marks only starts in range", () => { + const time = new Date(2026, 5, 16, 0, 10).getTime(); + const start = new Date(2026, 5, 15, 23, 30); + const end = new Date(2026, 5, 16, 0, 30); + const range = getSportCalendarRange(time); + const response = [ + { + ...training(1, true), + training: { + ...training(1, true).training, + start: start.toISOString(), + end: end.toISOString(), + }, + checked_in: + start >= new Date(range.start) && start <= new Date(range.end), + }, + ]; + expect( + getUpcomingActivities(normalizeSportActivities(response), time, 3), + ).toHaveLength(1); + }); + + it("is stable within a local day and includes earlier starts for ongoing check-ins", () => { + const time = new Date(2026, 5, 15, 14).getTime(); + const range = getSportCalendarRange(time); + expect(range).toEqual(getSportCalendarRange(time + 60_000)); + expect(new Date(range.start)).toEqual(new Date(2026, 5, 14)); + expect(new Date(range.end)).toEqual(new Date(2026, 5, 22)); + expect( + getSportCalendarRange(new Date(2026, 5, 16).getTime()).start, + ).not.toBe(range.start); + }); +}); diff --git a/src/components/dashboard/sport-activities.ts b/src/components/dashboard/sport-activities.ts new file mode 100644 index 00000000..6dfe67c1 --- /dev/null +++ b/src/components/dashboard/sport-activities.ts @@ -0,0 +1,35 @@ +import type { SchemaTrainingInfoPersonalSchema } from "@/api/sport/types.ts"; +import { sportTrainingTitle } from "@/components/sport/sport-training-label.ts"; +import { toScheduleApiDateTime } from "@/components/sport/sport-week-utils.ts"; +import type { DashboardActivity } from "./activities.ts"; + +/** Checked-in trainings that still accept attendance, as dashboard activities. */ +export function normalizeSportActivities( + rows: SchemaTrainingInfoPersonalSchema[], +): DashboardActivity[] { + return rows + .filter((row) => row.checked_in && row.training.max_checkins > 0) + .map((row) => ({ + id: String(row.training.id), + title: sportTrainingTitle(row), + start: new Date(row.training.start), + end: new Date(row.training.end), + allDay: row.training.is_all_day, + location: row.training.training_location?.name ?? undefined, + })); +} + +/** Day-stable query parameters include ongoing sessions without refetching every tick. */ +export function getSportCalendarRange(nowMs: number) { + const start = new Date(nowMs); + start.setHours(0, 0, 0, 0); + const end = new Date(start); + end.setDate(end.getDate() + 7); + // The API flags check-ins by training START within the query range, even for + // overlapping sessions. Include yesterday to preserve overnight check-ins. + start.setDate(start.getDate() - 1); + return { + start: toScheduleApiDateTime(start), + end: toScheduleApiDateTime(end), + }; +} From 887c72335d0c18489eeb3ccd1924a9b46ccd6f74 Mon Sep 17 00:00:00 2001 From: SerafimSoldatov Date: Wed, 14 Oct 2026 00:08:46 +0300 Subject: [PATCH 05/13] feat(dashboard): restore countdown widget --- .../dashboard/CountdownWidget.test.ts | 27 ++++++ src/components/dashboard/CountdownWidget.tsx | 85 +++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 src/components/dashboard/CountdownWidget.test.ts create mode 100644 src/components/dashboard/CountdownWidget.tsx diff --git a/src/components/dashboard/CountdownWidget.test.ts b/src/components/dashboard/CountdownWidget.test.ts new file mode 100644 index 00000000..6910cf0c --- /dev/null +++ b/src/components/dashboard/CountdownWidget.test.ts @@ -0,0 +1,27 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; +import { CountdownWidget } from "./CountdownWidget.tsx"; + +const state = vi.hoisted(() => ({ nowMs: Date.parse("2026-06-15T09:00:00Z") })); +vi.mock("@/lib/utils/use-now.ts", () => ({ useNowMS: () => state.nowMs })); + +describe("restored CountdownWidget", () => { + it("shows the next annual event rather than an expired dated event", () => { + state.nowMs = Date.parse("2026-06-15T09:00:00Z"); + const html = renderToStaticMarkup(createElement(CountdownWidget)); + expect(html).toContain("New Year countdown"); + expect(html).not.toContain("Introduction to"); + expect(html).toContain("days"); + }); + it("handles year rollover and pluralizes a final second", () => { + state.nowMs = Date.parse("2026-12-31T23:59:59+03:00"); + expect(renderToStaticMarkup(createElement(CountdownWidget))).toContain( + "0 days, 0 hours, 0 minutes, 1 second", + ); + state.nowMs += 1000; + const html = renderToStaticMarkup(createElement(CountdownWidget)); + expect(html).toContain("Summer countdown"); + expect(html).toContain("151 days, 0 hours, 0 minutes, 0 seconds"); + }); +}); diff --git a/src/components/dashboard/CountdownWidget.tsx b/src/components/dashboard/CountdownWidget.tsx new file mode 100644 index 00000000..925f9dda --- /dev/null +++ b/src/components/dashboard/CountdownWidget.tsx @@ -0,0 +1,85 @@ +import Logo108 from "@/components/icons/Logo108.tsx"; +import { cn } from "@/lib/ui/cn"; +import { useNowMS } from "@/lib/utils/use-now.ts"; +import type { ReactNode } from "react"; + +type CountdownEventTemplate = { + dateTemplate: string; + name: string | ReactNode; + iconClass: string; +}; +type CountdownEvent = CountdownEventTemplate & { date: Date }; + +// A leading * repeats the event annually, in the timestamp's timezone. +const countdownEventTemplates: CountdownEventTemplate[] = [ + { + dateTemplate: "*-01-01T00:00:00+03:00", + name: "New Year countdown", + iconClass: "icon-[twemoji--christmas-tree]", + }, + { + dateTemplate: "*-06-01T00:00:00+03:00", + name: "Summer countdown", + iconClass: "icon-[twemoji--sun]", + }, + { + dateTemplate: "2026-02-06T19:00+03:00", + name: ( + + Introduction to countdown + + ), + iconClass: "icon-[twemoji--purple-heart]", + }, +]; + +function getNextCountdownEvent(now: Date): CountdownEvent | undefined { + return countdownEventTemplates + .flatMap((template) => { + const dates = template.dateTemplate.startsWith("*") + ? [now.getFullYear(), now.getFullYear() + 1].map((year) => + template.dateTemplate.replace("*", String(year)), + ) + : [template.dateTemplate]; + return dates.map((date) => ({ ...template, date: new Date(date) })); + }) + .filter((event) => event.date > now) + .sort((left, right) => left.date.getTime() - right.date.getTime())[0]; +} + +function formatCountdown(secondsLeft: number) { + const units = [ + [Math.floor(secondsLeft / 86_400), "day"], + [Math.floor((secondsLeft / 3600) % 24), "hour"], + [Math.floor((secondsLeft / 60) % 60), "minute"], + [Math.floor(secondsLeft % 60), "second"], + ] as const; + return units + .map(([value, unit]) => `${value} ${unit}${value === 1 ? "" : "s"}`) + .join(", "); +} + +export function CountdownWidget() { + const nowMs = useNowMS(true, 1000); + const nextEvent = getNextCountdownEvent(new Date(nowMs)); + if (!nextEvent) return null; + + return ( +
+ +
+

+ + {nextEvent.name} +

+

+ {formatCountdown((nextEvent.date.getTime() - nowMs) / 1000)} +

+
+
+ ); +} From 46ef6c7a379f33dcb4a288cfd4da30c2894b09ab Mon Sep 17 00:00:00 2001 From: SerafimSoldatov Date: Wed, 14 Oct 2026 00:09:11 +0300 Subject: [PATCH 06/13] refactor(dashboard): add error handling and retry to academic calendar --- .../dashboard/AcademicCalendarWidget.tsx | 69 ++++++++++++++----- .../dashboard/academic-calendar.tsx | 27 +++++--- 2 files changed, 71 insertions(+), 25 deletions(-) diff --git a/src/components/dashboard/AcademicCalendarWidget.tsx b/src/components/dashboard/AcademicCalendarWidget.tsx index 18c9353f..1e73f115 100644 --- a/src/components/dashboard/AcademicCalendarWidget.tsx +++ b/src/components/dashboard/AcademicCalendarWidget.tsx @@ -2,8 +2,7 @@ import { AcademicCalendar, useMyAcademicCalendar, } from "@/components/dashboard/academic-calendar.tsx"; -import { useEffect } from "react"; -import { useLocalStorage } from "usehooks-ts"; +import { useState } from "react"; export function AcademicCalendarCard({ academicCalendar, @@ -34,23 +33,61 @@ export function AcademicCalendarCard({ } export function AcademicCalendarWidget() { - const [widgetShown, setWidgetShown] = useLocalStorage( - "widget-academic-calendar", - false, - ); + const { academicCalendar, isPending, isError, refetch } = + useMyAcademicCalendar(); + const [isRetrying, setIsRetrying] = useState(false); - const { academicCalendar, isPending } = useMyAcademicCalendar(); + async function handleRetry() { + setIsRetrying(true); + try { + await refetch(); + } finally { + setIsRetrying(false); + } + } - useEffect(() => { - setWidgetShown((v) => (v && isPending) || !!academicCalendar); - }, [setWidgetShown, isPending, academicCalendar]); + if (academicCalendar) { + return ; + } - if (!academicCalendar) { - if (!widgetShown) return null; - return ( -
- ); + if (isPending && !isError) { + return
; } - return ; + return ( +
+ +
+

Academic calendar

+ {isError ? ( +
+ Could not load your academic calendar. + +
+ ) : ( +

+ No academic calendar found for your study group. +

+ )} + + View academic calendar on Eduwiki + + +
+
+ ); } diff --git a/src/components/dashboard/academic-calendar.tsx b/src/components/dashboard/academic-calendar.tsx index 87983aec..1fc09b33 100644 --- a/src/components/dashboard/academic-calendar.tsx +++ b/src/components/dashboard/academic-calendar.tsx @@ -93,14 +93,18 @@ export function findAcademicCalendarByGroups( } export function useMyAcademicCalendar() { - const { data: eventGroups, isPending: isPending1 } = $schedule.useQuery( - "get", - "/event-groups/", - ); - const { data: predefined, isPending: isPending2 } = $schedule.useQuery( - "get", - "/users/me/predefined", - ); + const { + data: eventGroups, + isPending: isPending1, + isError: isError1, + refetch: refetchGroups, + } = $schedule.useQuery("get", "/event-groups/"); + const { + data: predefined, + isPending: isPending2, + isError: isError2, + refetch: refetchPredefined, + } = $schedule.useQuery("get", "/users/me/predefined"); const groups = predefined?.event_groups ?.map( @@ -110,5 +114,10 @@ export function useMyAcademicCalendar() { ?.filter((v) => v) as string[]; const academicCalendar = findAcademicCalendarByGroups(groups || []); - return { academicCalendar, isPending: isPending1 || isPending2 }; + return { + academicCalendar, + isPending: isPending1 || isPending2, + isError: isError1 || isError2, + refetch: () => Promise.all([refetchGroups(), refetchPredefined()]), + }; } From 8641a2b013fa548c48c383e6e4ef377be95bf895 Mon Sep 17 00:00:00 2001 From: SerafimSoldatov Date: Wed, 14 Oct 2026 00:09:37 +0300 Subject: [PATCH 07/13] refactor(dashboard): improve activity schedule layout and date grouping --- .../calendar/useDashboardCalendar.test.ts | 23 ++-- .../calendar/useDashboardCalendar.ts | 5 +- src/components/dashboard/ActivitySchedule.tsx | 128 +++++++++--------- .../dashboard/CalendarWidget.test.ts | 17 +-- src/components/dashboard/CalendarWidget.tsx | 6 +- src/components/dashboard/SportsWidget.test.ts | 83 ++++++++---- src/components/dashboard/SportsWidget.tsx | 92 +++++-------- src/components/dashboard/activities.test.ts | 40 +++++- src/components/dashboard/activities.ts | 43 ++++-- 9 files changed, 251 insertions(+), 186 deletions(-) diff --git a/src/components/calendar/useDashboardCalendar.test.ts b/src/components/calendar/useDashboardCalendar.test.ts index c6afe9cb..a0179280 100644 --- a/src/components/calendar/useDashboardCalendar.test.ts +++ b/src/components/calendar/useDashboardCalendar.test.ts @@ -23,7 +23,7 @@ vi.mock("@/api/schedule", () => ({ id: 123, favorite_event_groups: [], hidden_event_groups: [], - music_room_hidden: true, + music_room_hidden: false, sports_hidden: false, moodle_hidden: true, linked_calendars: {}, @@ -63,7 +63,7 @@ const ics = [ "VERSION:2.0", "BEGIN:VEVENT", "UID:day", - "SUMMARY:Workshop", + "SUMMARY:Music room", "DTSTART:20260615T100000", "DTEND:20260615T110000", "RRULE:FREQ=DAILY;COUNT=2", @@ -107,7 +107,7 @@ beforeEach(() => { mocks.loadFeed .mockReset() .mockImplementation((path: string) => - Promise.resolve(path.includes("events") ? ics : emptyIcs), + Promise.resolve(path.includes("music-room") ? ics : emptyIcs), ); mocks.refetchMetadata.mockReset().mockResolvedValue(undefined); mocks.refetchMeetings.mockReset().mockResolvedValue(undefined); @@ -171,7 +171,7 @@ describe("useDashboardCalendar", () => { ]); }); - it("waits for selection before requesting typed text feeds, excluding sport", async () => { + it("waits for selection before requesting typed text feeds, excluding sport, workshops and room bookings", async () => { mocks.metadataPending = true; await render(); expect(result.isPending).toBe(true); @@ -184,12 +184,15 @@ describe("useDashboardCalendar", () => { expect(mocks.loadFeed).toHaveBeenCalledWith("/users/me/events.ics", { parseAs: "text", }); - expect(mocks.loadFeed).toHaveBeenCalledWith("/users/me/room-bookings.ics", { + expect(mocks.loadFeed).toHaveBeenCalledWith("/users/me/music-room.ics", { parseAs: "text", }); expect( - mocks.loadFeed.mock.calls.some(([path]) => - String(path).includes("sport"), + mocks.loadFeed.mock.calls.some( + ([path]) => + String(path).includes("sport") || + String(path).includes("workshops") || + String(path).includes("room-bookings"), ), ).toBe(false); }); @@ -215,7 +218,7 @@ describe("useDashboardCalendar", () => { it("retains successful data alongside HTTP, parse and meeting failures", async () => { mocks.loadFeed.mockImplementation((path: string) => - path.includes("events") + path.includes("music-room") ? Promise.resolve(ics) : Promise.reject(new Error("HTTP failure")), ); @@ -225,7 +228,7 @@ describe("useDashboardCalendar", () => { expect(result.activities).toHaveLength(1); expect(result.isError).toBe(true); expect(result.errors.map((error) => error.sourceId)).toEqual([ - "room-bookings", + "events", "when2meet:owned", ]); mocks.loadFeed.mockResolvedValue("not an ICS document"); @@ -233,7 +236,7 @@ describe("useDashboardCalendar", () => { await result.refetch(); }); await settled(); - expect(result.errors.some((error) => error.sourceId === "events")).toBe( + expect(result.errors.some((error) => error.sourceId === "music-room")).toBe( true, ); }); diff --git a/src/components/calendar/useDashboardCalendar.ts b/src/components/calendar/useDashboardCalendar.ts index a6341a4b..5141be26 100644 --- a/src/components/calendar/useDashboardCalendar.ts +++ b/src/components/calendar/useDashboardCalendar.ts @@ -52,7 +52,7 @@ export function dashboardCalendarQueryOptions(source: CalendarSource) { /** * All selected non-sport activities overlapping today's local day. The caller * owns ticking nowMs, not-ended filtering, sorting, and the visible-item limit. - * isError includes partial failures; successful activities remain available. + * Feed failures are non-blocking; only source-selection failures surface as errors. */ export function useDashboardCalendar(nowMs: number) { const queryClient = useQueryClient(); @@ -68,6 +68,9 @@ export function useDashboardCalendar(nowMs: number) { predefinedQuery.data.event_groups, groupsQuery.data, { excludeSport: true }, + ).filter( + (source) => + source.kind !== "workshops" && source.kind !== "room-bookings", ); }, [userQuery.data, groupsQuery.data, predefinedQuery.data]); diff --git a/src/components/dashboard/ActivitySchedule.tsx b/src/components/dashboard/ActivitySchedule.tsx index 73e3f5df..c4820867 100644 --- a/src/components/dashboard/ActivitySchedule.tsx +++ b/src/components/dashboard/ActivitySchedule.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { type DashboardActivity, formatActivityTime, - getActivityStatus, + formatScheduleDayHeader, } from "./activities.ts"; /** Shared presentation only: each widget owns its data and visible time range. */ @@ -11,23 +11,28 @@ export function ActivitySchedule({ activities, nowMs, isPending, - isError, + isError = false, onRetry, - emptyMessage, - showDates = false, }: { activities: DashboardActivity[]; nowMs: number; isPending: boolean; - isError: boolean; - onRetry: () => Promise; - emptyMessage: string; - showDates?: boolean; + isError?: boolean; + onRetry?: () => Promise; }) { const [isRetrying, setIsRetrying] = useState(false); - const nextActivity = activities[0]; + const activityGroups = new Map(); + for (const activity of [...activities].sort( + (left, right) => left.start.getTime() - right.start.getTime(), + )) { + const day = activity.start.toDateString(); + const group = activityGroups.get(day) ?? []; + group.push(activity); + activityGroups.set(day, group); + } async function handleRetry() { + if (!onRetry) return; setIsRetrying(true); try { await onRetry(); @@ -36,6 +41,8 @@ export function ActivitySchedule({ } } + if (!isPending && !isError && activities.length === 0) return null; + return (
{isError && ( @@ -55,7 +62,7 @@ export function ActivitySchedule({
)} - {isPending ? ( + {isPending && activities.length === 0 ? (
{[0, 1, 2].map((index) => ( @@ -65,62 +72,55 @@ export function ActivitySchedule({
))}
- ) : nextActivity ? ( - <> -
- - {getActivityStatus(nextActivity, nowMs)} - - {isError && ( - - Schedule may be incomplete - + ) : ( + [...activityGroups].map(([day, group], groupIndex) => ( +
0 && "pt-1", )} + > +

+ {formatScheduleDayHeader(group[0].start, nowMs)} +

+
    + {group.map((activity) => { + const inProgress = + activity.start.getTime() <= nowMs && + activity.end.getTime() > nowMs; + return ( +
  • +
    + + {formatActivityTime(activity)} + + + {activity.title} + + {activity.location && ( + + {activity.location} + + )} +
    +
  • + ); + })} +
-
    - {activities.map((activity) => { - const inProgress = activity.start.getTime() <= nowMs; - return ( -
  • - -
    -

    - {activity.title} -

    -

    - {showDates && - `${activity.start.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" })}, `} - {formatActivityTime(activity)} -

    - {activity.location && ( -

    - {activity.location} -

    - )} -
    -
  • - ); - })} -
- - ) : !isError ? ( -

{emptyMessage}

- ) : null} + )) + )}
); } diff --git a/src/components/dashboard/CalendarWidget.test.ts b/src/components/dashboard/CalendarWidget.test.ts index 85301668..dc753ba3 100644 --- a/src/components/dashboard/CalendarWidget.test.ts +++ b/src/components/dashboard/CalendarWidget.test.ts @@ -55,7 +55,7 @@ describe("CalendarWidget", () => { await render(); expect(container.querySelectorAll("li")).toHaveLength(5); expect(container.querySelector("li")?.textContent).toContain("Activity 0"); - expect(container.textContent).toContain("In process"); + expect(container.querySelector(".border-error")).not.toBeNull(); expect(container.querySelector("a")?.getAttribute("href")).toBe( "/calendar", ); @@ -63,23 +63,24 @@ describe("CalendarWidget", () => { "Go to calendar", ); }); - it("shows loading and empty states separately", async () => { + it("shows loading but no message or empty schedule container after an empty response", async () => { state.isPending = true; await render(); expect(container.querySelector(".skeleton")).not.toBeNull(); expect(container.textContent).not.toContain("No more activities"); state.isPending = false; await render(); - expect(container.textContent).toContain("No more activities today"); + expect(container.querySelector(".skeleton")).toBeNull(); + expect(container.textContent).not.toContain("No more activities today"); + expect(container.querySelector("section")?.children).toHaveLength(2); + expect(container.querySelector("p")?.textContent).toBe("Sat, Jan 10"); + expect(container.textContent).not.toContain("Today"); }); - it("shows failures with retry, not a misleading empty state", async () => { + it("does not show an error banner even if loading failed", async () => { state.isError = true; await render(); - expect(container.textContent).toContain( + expect(container.textContent).not.toContain( "Some activities could not be loaded", ); - expect(container.textContent).not.toContain("No more activities today"); - await act(async () => container.querySelector("button")?.click()); - expect(state.refetch).toHaveBeenCalledOnce(); }); }); diff --git a/src/components/dashboard/CalendarWidget.tsx b/src/components/dashboard/CalendarWidget.tsx index 3a712ce9..68b2462d 100644 --- a/src/components/dashboard/CalendarWidget.tsx +++ b/src/components/dashboard/CalendarWidget.tsx @@ -6,8 +6,7 @@ import { getUpcomingActivities } from "./activities.ts"; export function CalendarWidget() { const nowMs = useNowMS(true); - const { activities, isPending, isError, refetch } = - useDashboardCalendar(nowMs); + const { activities, isPending, refetch } = useDashboardCalendar(nowMs); const upcoming = getUpcomingActivities(activities, nowMs, 5); return ( @@ -17,7 +16,6 @@ export function CalendarWidget() {

Calendar

- Today ·{" "} {new Date(nowMs).toLocaleDateString("en-US", { weekday: "short", month: "short", @@ -30,9 +28,7 @@ export function CalendarWidget() { activities={upcoming} nowMs={nowMs} isPending={isPending} - isError={isError} onRetry={refetch} - emptyMessage="No more activities today" /> ({ now: new Date(2026, 5, 15, 12).getTime(), pending: "", failed: "", + emptyTrainings: false, + zeroHours: false, refetch: vi.fn().mockResolvedValue(undefined), queryCalls: [] as { path: string; enabled?: boolean }[], })); @@ -29,36 +31,39 @@ vi.mock("@/api/sport", () => ({ else if (path === "/semesters/current") data = { end: "2026-08-31" }; else if (path === "/students/{student_id}/hours-summary") data = { - hours_from_groups: 10, - self_sport_hours: 6, + hours_from_groups: state.zeroHours ? 0 : 10, + self_sport_hours: state.zeroHours ? 0 : 6, required_hours: 30, - debt: 3, + debt: state.zeroHours ? 0 : 3, }; else if (path === "/users/me/schedule") - data = Array.from({ length: 5 }, (_, index) => ({ - training: { - id: index, - start: new Date(state.now + index * 60_000).toISOString(), - end: new Date(state.now + (index + 30) * 60_000).toISOString(), - is_all_day: false, - training_location: { id: 1, name: "Sport hall" }, - display_name: `Training ${index}`, - sport_name: null, - group_name: null, - training_custom_name: null, - is_accredited: false, - is_club: false, - is_paid: false, - sport_id: null, - group_id: 1, - checkins_count: 0, - max_checkins: 10, - }, - checked_in: index !== 0, - can_check_in: true, - can_grade: false, - can_edit: false, - })); + data = Array.from( + { length: state.emptyTrainings ? 0 : 5 }, + (_, index) => ({ + training: { + id: index, + start: new Date(state.now + index * 60_000).toISOString(), + end: new Date(state.now + (index + 30) * 60_000).toISOString(), + is_all_day: false, + training_location: { id: 1, name: "Sport hall" }, + display_name: `Training ${index}`, + sport_name: null, + group_name: null, + training_custom_name: null, + is_accredited: false, + is_club: false, + is_paid: false, + sport_id: null, + group_id: 1, + checkins_count: 0, + max_checkins: 10, + }, + checked_in: index !== 0, + can_check_in: true, + can_grade: false, + can_edit: false, + }), + ); const isPending = state.pending === path || options.enabled === false; const isError = state.failed === path; return { @@ -86,6 +91,8 @@ beforeEach(() => { state.token = "test-token"; state.pending = ""; state.failed = ""; + state.emptyTrainings = false; + state.zeroHours = false; state.queryCalls = []; state.refetch.mockClear(); container = document.createElement("div"); @@ -100,13 +107,31 @@ describe("SportsWidget", () => { await render(); expect(container.querySelectorAll("li")).toHaveLength(3); expect(container.textContent).not.toContain("Training 0"); - expect(container.textContent).toContain("Starts in 1 min"); + expect(container.textContent).toContain("12:01–12:31"); expect(container.textContent).toContain("16 / 30 +3 (debt)"); - expect(container.textContent).toContain("August 31, 2026"); + expect(container.textContent).toContain("77 days left"); expect(container.querySelector("a")?.getAttribute("href")).toBe( "https://sport.innopolis.university/profile/", ); }); + it("puts zero hours in the header and omits empty schedule and bot messaging", async () => { + state.emptyTrainings = true; + state.zeroHours = true; + await render(); + const header = container.querySelector("h2")?.closest(".flex-wrap"); + expect(header?.textContent).toContain("0 / 30 hours"); + expect(container.querySelectorAll("li")).toHaveLength(0); + expect(container.querySelector("section")?.children).toHaveLength(3); + expect(container.textContent).not.toContain("Your check-ins"); + expect(container.textContent).not.toContain("No upcoming check-ins"); + expect(container.textContent).not.toContain("Sports bot"); + expect(container.querySelectorAll("a")).toHaveLength(1); + expect(container.querySelector("a")?.textContent).toBe("Go to Sports"); + expect(container.querySelector("a span")?.className).toContain( + "icon-[material-symbols--arrow-forward]", + ); + expect(container.textContent).toContain("77 days left"); + }); it("disables requests and hides the card without a sport token", async () => { state.token = null; await render(); diff --git a/src/components/dashboard/SportsWidget.tsx b/src/components/dashboard/SportsWidget.tsx index 94c7bf05..c1213a08 100644 --- a/src/components/dashboard/SportsWidget.tsx +++ b/src/components/dashboard/SportsWidget.tsx @@ -1,5 +1,6 @@ import { useMySportAccessToken } from "@/api/helpers/sport-access-token.ts"; import { $sport } from "@/api/sport"; + import { useNowMS } from "@/lib/utils/use-now.ts"; import { ActivitySchedule } from "./ActivitySchedule.tsx"; import { getUpcomingActivities } from "./activities.ts"; @@ -61,14 +62,30 @@ export function SportsWidget() { return (

-
- -
-

Sports

-

- Your check-ins · Next 7 days -

+
+
+ +
+

Sports

+ {daysLeft !== undefined && ( +

+ {daysLeft} days left +

+ )} +
+ {!profileQuery.isError && + (hours ? ( +

+ + {earnedHours} / {hours.required_hours} + {hours.debt ? ` +${hours.debt} (debt)` : ""} + {" "} + hours +

+ ) : hoursQuery.isPending ? ( +
+ ) : null)}
{profileQuery.isError ? (
@@ -94,10 +111,8 @@ export function SportsWidget() { isPending={profileQuery.isPending || trainingsQuery.isPending} isError={trainingsQuery.isError} onRetry={() => trainingsQuery.refetch()} - emptyMessage="No upcoming check-ins in the next 7 days" - showDates /> -
+
{hoursQuery.isError || semesterQuery.isError ? (
Sport hours or deadline could not be loaded. @@ -120,57 +135,18 @@ export function SportsWidget() {
) : hoursQuery.isPending || semesterQuery.isPending ? ( - <> -
-
- - ) : ( - <> - {hours && ( -

- - {earnedHours} / {hours.required_hours} - {hours.debt ? ` +${hours.debt} (debt)` : ""} - {" "} - hours -

- )} -

- {hasDeadline ? ( - <> - Deadline:{" "} - {deadline.toLocaleDateString("en-US", { - month: "long", - day: "numeric", - year: "numeric", - })}{" "} - ({daysLeft} days left) - - ) : ( - "Semester deadline unavailable" - )} -

- - )} +
+ ) : null}
)} - + + Go to Sports + +
); } diff --git a/src/components/dashboard/activities.test.ts b/src/components/dashboard/activities.test.ts index ce1d0f38..ed0dd159 100644 --- a/src/components/dashboard/activities.test.ts +++ b/src/components/dashboard/activities.test.ts @@ -75,12 +75,48 @@ describe("live activity status", () => { expect(getActivityStatus(item, now)).toBe("All day"); expect(formatActivityTime(item)).toBe("All day"); }); - it("shows the end date when activities cross midnight", () => { + it("formats time as start–end even when activities cross midnight", () => { const item = { ...activity(0), start: new Date(2026, 0, 10, 23), end: new Date(2026, 0, 11, 1), }; - expect(formatActivityTime(item)).toBe("23:00–Jan 11, 01:00"); + expect(formatActivityTime(item)).toBe("23:00–01:00"); + }); +}); + +describe("schedule day headers", () => { + it("formats ordinal numbers accurately", async () => { + const { getOrdinalSuffix } = await import("./activities.ts"); + expect(getOrdinalSuffix(1)).toBe("1st"); + expect(getOrdinalSuffix(2)).toBe("2nd"); + expect(getOrdinalSuffix(3)).toBe("3rd"); + expect(getOrdinalSuffix(4)).toBe("4th"); + expect(getOrdinalSuffix(11)).toBe("11th"); + expect(getOrdinalSuffix(12)).toBe("12th"); + expect(getOrdinalSuffix(13)).toBe("13th"); + expect(getOrdinalSuffix(21)).toBe("21st"); + expect(getOrdinalSuffix(22)).toBe("22nd"); + expect(getOrdinalSuffix(23)).toBe("23rd"); + expect(getOrdinalSuffix(24)).toBe("24th"); + expect(getOrdinalSuffix(31)).toBe("31st"); + }); + + it("formats Today, Tomorrow and other day headers with full month and ordinal", async () => { + const { formatScheduleDayHeader } = await import("./activities.ts"); + const testNow = new Date(2026, 6, 24, 12).getTime(); // Monday, July 24, 2026 + const todayDate = new Date(2026, 6, 24, 15); + const tomorrowDate = new Date(2026, 6, 25, 10); + const futureDate = new Date(2026, 6, 26, 10); + + expect(formatScheduleDayHeader(todayDate, testNow)).toBe( + "Today, Fri, 24th of July", + ); + expect(formatScheduleDayHeader(tomorrowDate, testNow)).toBe( + "Tomorrow, Sat, 25th of July", + ); + expect(formatScheduleDayHeader(futureDate, testNow)).toBe( + "Sun, 26th of July", + ); }); }); diff --git a/src/components/dashboard/activities.ts b/src/components/dashboard/activities.ts index b0e54981..c3e48732 100644 --- a/src/components/dashboard/activities.ts +++ b/src/components/dashboard/activities.ts @@ -59,13 +59,38 @@ export function formatActivityTime(activity: DashboardActivity) { }; const startTime = activity.start.toLocaleTimeString("en-GB", options); const endTime = activity.end.toLocaleTimeString("en-GB", options); - const crossesDay = - activity.start.toDateString() !== activity.end.toDateString(); - const endDate = crossesDay - ? activity.end.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - }) + ", " - : ""; - return `${startTime}–${endDate}${endTime}`; + return `${startTime}–${endTime}`; +} + +export function getOrdinalSuffix(day: number): string { + if (day >= 11 && day <= 13) return `${day}th`; + switch (day % 10) { + case 1: + return `${day}st`; + case 2: + return `${day}nd`; + case 3: + return `${day}rd`; + default: + return `${day}th`; + } +} + +export function formatScheduleDayHeader(date: Date, nowMs: number): string { + const today = new Date(nowMs); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + + const weekday = date.toLocaleDateString("en-US", { weekday: "short" }); + const dayOrdinal = getOrdinalSuffix(date.getDate()); + const month = date.toLocaleDateString("en-US", { month: "long" }); + const formattedDate = `${weekday}, ${dayOrdinal} of ${month}`; + + if (date.toDateString() === today.toDateString()) { + return `Today, ${formattedDate}`; + } + if (date.toDateString() === tomorrow.toDateString()) { + return `Tomorrow, ${formattedDate}`; + } + return formattedDate; } From 8a78294158fa7fb078e0253665a88f960e1fac63 Mon Sep 17 00:00:00 2001 From: SerafimSoldatov Date: Wed, 14 Oct 2026 00:09:47 +0300 Subject: [PATCH 08/13] feat(dashboard): add room bookings widget with recommendations --- .../dashboard/BookingsWidget.test.ts | 132 ++++++++ src/components/dashboard/BookingsWidget.tsx | 176 ++++++++++ .../dashboard/booking-recommendations.test.ts | 314 ++++++++++++++++++ .../dashboard/booking-recommendations.ts | 288 ++++++++++++++++ .../useBookingRecommendations.test.ts | 200 +++++++++++ .../dashboard/useBookingRecommendations.ts | 99 ++++++ 6 files changed, 1209 insertions(+) create mode 100644 src/components/dashboard/BookingsWidget.test.ts create mode 100644 src/components/dashboard/BookingsWidget.tsx create mode 100644 src/components/dashboard/booking-recommendations.test.ts create mode 100644 src/components/dashboard/booking-recommendations.ts create mode 100644 src/components/dashboard/useBookingRecommendations.test.ts create mode 100644 src/components/dashboard/useBookingRecommendations.ts diff --git a/src/components/dashboard/BookingsWidget.test.ts b/src/components/dashboard/BookingsWidget.test.ts new file mode 100644 index 00000000..09fd3c08 --- /dev/null +++ b/src/components/dashboard/BookingsWidget.test.ts @@ -0,0 +1,132 @@ +import { act, createElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { BookingRecommendation } from "./booking-recommendations.ts"; +import { BookingsWidget } from "./BookingsWidget.tsx"; + +const state = vi.hoisted(() => ({ + nowMs: Date.parse("2026-06-15T09:00:00Z"), + entries: [] as BookingRecommendation[], + isPending: false, + isError: false, + isFetching: false, + refetch: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("./useBookingRecommendations.ts", () => ({ + useBookingRecommendations: () => state, +})); +vi.mock("@/lib/utils/use-now.ts", () => ({ useNowMS: () => state.nowMs })); +vi.mock("@tanstack/react-router", () => ({ + Link: ({ + to, + params, + children, + ...props + }: { + to: string; + params?: { room: string }; + children: import("react").ReactNode; + }) => + createElement( + "a", + { href: params ? to.replace("$room", params.room) : to, ...props }, + children, + ), +})); +let root: Root; +let container: HTMLDivElement; +async function render() { + await act(async () => root.render(createElement(BookingsWidget))); +} +beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + state.entries = []; + state.isPending = false; + state.isError = false; + state.isFetching = false; + state.refetch.mockClear(); + container = document.createElement("div"); + root = createRoot(container); +}); +afterEach(async () => { + await act(async () => root.unmount()); +}); + +describe("BookingsWidget", () => { + it("links each room and distinguishes owned, finite free and horizon-only availability", async () => { + state.entries = [ + { + kind: "booked", + roomId: "101", + roomTitle: "Room 101", + booking: { + id: "mine", + room_id: "101", + title: "Study session", + start: new Date(state.nowMs - 60_000).toISOString(), + end: new Date(state.nowMs + 60_000).toISOString(), + attendees: null, + outlook_booking_id: null, + outlook_entry_id: null, + }, + }, + { + kind: "available", + roomId: "102", + roomTitle: "Room 102", + availableUntilMs: state.nowMs + 86_400_000, + nextBookingKnown: false, + }, + { + kind: "available", + roomId: "103", + roomTitle: "Room 103", + availableUntilMs: state.nowMs + 2 * 3600_000, + nextBookingKnown: true, + }, + { + kind: "available", + roomId: "104", + roomTitle: "Room 104", + availableUntilMs: state.nowMs + 2 * 3600_000 + 23 * 60_000, + nextBookingKnown: true, + }, + ]; + await render(); + expect(container.querySelectorAll("li")).toHaveLength(4); + expect(container.textContent).toContain("Today"); + expect(container.textContent).toContain("Available rooms"); + expect(container.textContent).not.toContain("Possible bookings"); + expect(container.textContent).toContain("Room 101"); + expect(container.textContent).toContain("Study session"); + expect(container.textContent).not.toContain("Booked by you"); + expect(container.textContent).toContain("Free for at least 1d"); + expect(container.textContent).toContain("Free for 2h"); + expect(container.textContent).toContain("Free for 2h 23min"); + expect(container.querySelector("li a")?.getAttribute("href")).toBe( + "/room-booking/rooms/101", + ); + expect(container.querySelector("section > a")?.getAttribute("href")).toBe( + "/room-booking", + ); + expect(container.querySelector("section > a")?.textContent).toBe( + "Go to room booking", + ); + }); + it("shows a skeleton while loading and no empty-state filler", async () => { + state.isPending = true; + await render(); + expect(container.querySelectorAll(".skeleton")).toHaveLength(6); + state.isPending = false; + await render(); + expect(container.querySelector("section")?.children).toHaveLength(2); + }); + it("keeps error/retry visible rather than describing unavailable data as free rooms", async () => { + state.isError = true; + await render(); + expect(container.textContent).toContain("could not be verified"); + expect(container.textContent).not.toContain("Free for"); + await act(async () => container.querySelector("button")?.click()); + expect(state.refetch).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/components/dashboard/BookingsWidget.tsx b/src/components/dashboard/BookingsWidget.tsx new file mode 100644 index 00000000..d30f82ee --- /dev/null +++ b/src/components/dashboard/BookingsWidget.tsx @@ -0,0 +1,176 @@ +import { sanitizeBookingTitle } from "@/components/room-booking/utils.ts"; +import { cn } from "@/lib/ui/cn"; +import { useNowMS } from "@/lib/utils/use-now.ts"; +import { Link } from "@tanstack/react-router"; +import { formatActivityTime, formatScheduleDayHeader } from "./activities.ts"; +import { useBookingRecommendations } from "./useBookingRecommendations.ts"; + +function formatFreeDuration(milliseconds: number) { + const days = Math.floor(milliseconds / 86_400_000); + const hours = Math.floor((milliseconds % 86_400_000) / 3_600_000); + const minutes = Math.floor((milliseconds % 3_600_000) / 60_000); + return ( + [ + days ? `${days}d` : "", + hours ? `${hours}h` : "", + minutes ? `${minutes}min` : "", + ] + .filter(Boolean) + .join(" ") || "<1min" + ); +} + +export function BookingsWidget() { + const nowMs = useNowMS(true); + const { entries, isPending, isError, isFetching, refetch } = + useBookingRecommendations(nowMs); + + const myBookings = entries.filter((entry) => entry.kind === "booked"); + const possibleBookings = entries.filter( + (entry) => entry.kind === "available", + ); + + const bookingGroups = new Map(); + for (const entry of myBookings) { + const startDate = new Date(entry.booking.start); + const day = startDate.toDateString(); + const group = bookingGroups.get(day) ?? []; + group.push(entry); + bookingGroups.set(day, group); + } + + return ( +
+
+ +

Room booking

+
+ {isError && ( +
+ Some bookings or room availability could not be verified. + +
+ )} + {entries.length > 0 && ( +
+ {[...bookingGroups].map(([day, group], groupIndex) => ( +
0 && "pt-1", + )} + > +

+ {formatScheduleDayHeader( + new Date(group[0].booking.start), + nowMs, + )} +

+
    + {group.map((entry) => { + const startMs = Date.parse(entry.booking.start); + const endMs = Date.parse(entry.booking.end); + const inProgress = startMs <= nowMs && endMs > nowMs; + const activity = { + id: entry.booking.id, + title: entry.booking.title, + start: new Date(entry.booking.start), + end: new Date(entry.booking.end), + }; + + return ( +
  • + + + {formatActivityTime(activity)} + + + {entry.roomTitle} + + {entry.booking.title && ( + + {sanitizeBookingTitle(entry.booking.title)} + + )} + +
  • + ); + })} +
+
+ ))} + {possibleBookings.length > 0 && ( +
+

+ Available rooms +

+
    + {possibleBookings.map((entry) => ( +
  • + + + {entry.roomTitle} + + + Free for {entry.nextBookingKnown ? "" : "at least "} + {formatFreeDuration(entry.availableUntilMs - nowMs)} + + +
  • + ))} +
+
+ )} +
+ )} + {isPending && ( +
+ {Array.from( + { length: Math.min(3, 5 - entries.length) }, + (_, index) => ( +
+
+
+
+ ), + )} +
+ )} + + Go to room booking + + +
+ ); +} diff --git a/src/components/dashboard/booking-recommendations.test.ts b/src/components/dashboard/booking-recommendations.test.ts new file mode 100644 index 00000000..2ede46c9 --- /dev/null +++ b/src/components/dashboard/booking-recommendations.test.ts @@ -0,0 +1,314 @@ +import { describe, expect, it } from "vitest"; +import type { roomBookingTypes } from "@/api/room-booking"; +import { + BookingStatus, + RoomAccess_levelAnyOf0 as Access, +} from "@/api/room-booking/types.ts"; +import { + buildBookingRecommendations, + type BookingRecommendationsInput, +} from "./booking-recommendations.ts"; + +const nowMs = Date.parse("2026-05-11T09:00:00Z"); // Monday noon in Moscow. +const minute = 60_000; +const rangeEndMs = nowMs + 14 * 24 * 60 * minute; + +function room( + id: string, + overrides: Partial = {}, +): roomBookingTypes.SchemaRoom { + return { + id, + title: `Room ${id}`, + short_name: id, + access_level: Access.yellow, + restrict_daytime: false, + ...overrides, + }; +} + +function booking( + roomId: string, + startMinutes: number, + endMinutes: number, + overrides: Partial = {}, +): roomBookingTypes.SchemaBooking { + return { + id: `${roomId}:${startMinutes}:${endMinutes}`, + room_id: roomId, + start: new Date(nowMs + startMinutes * minute).toISOString(), + end: new Date(nowMs + endMinutes * minute).toISOString(), + title: "Meeting", + outlook_booking_id: null, + outlook_entry_id: null, + attendees: null, + ...overrides, + }; +} + +function recommend(overrides: Partial = {}) { + return buildBookingRecommendations({ + rooms: [room("A")], + myBookings: [], + allBookings: [], + myAccessList: [], + role: { is_student: true }, + nowMs, + rangeEndMs, + ...overrides, + }); +} + +function attendee( + roomId: string | null, + status: roomBookingTypes.SchemaAttendee["status"], +): roomBookingTypes.SchemaAttendee { + return { email: "room@example.com", assosiated_room_id: roomId, status }; +} + +describe("booking recommendations", () => { + it("ranks free-now rooms by longest window and resolves ties by title/id", () => { + const rooms = [room("D"), room("C"), room("B"), room("A")]; + rooms[0].title = rooms[1].title = "First"; + const allBookings = [booking("B", 10, 30), booking("A", 30, 50)]; + const originalRooms = structuredClone(rooms); + const originalBookings = structuredClone(allBookings); + expect( + recommend({ rooms, allBookings }).map((item) => item.roomId), + ).toEqual(["C", "D", "A", "B"]); + expect(rooms).toEqual(originalRooms); + expect(allBookings).toEqual(originalBookings); + }); + + it("puts current and upcoming own rooms first, deduplicates rooms and ignores ended bookings", () => { + const current = booking("C", -10, 10); + const futureA = booking("A", 20, 40); + const result = recommend({ + rooms: [room("A"), room("B"), room("C"), room("D")], + myBookings: [ + futureA, + booking("B", -20, 0), + booking("C", -5, 15), + current, + current, + ], + }); + expect(result.map((item) => [item.kind, item.roomId])).toEqual([ + ["booked", "C"], + ["booked", "A"], + ["available", "B"], + ["available", "D"], + ]); + expect(result[0]).toMatchObject({ booking: current }); + expect(result[1]).toMatchObject({ booking: futureA }); + }); + + it("caps the entire result at five even with more than five own rooms", () => { + const rooms = Array.from({ length: 8 }, (_, i) => room(String(i))); + expect(recommend({ rooms })).toHaveLength(5); + const myBookings = rooms.map((item) => booking(item.id, -5, 5)); + const result = recommend({ rooms, myBookings: myBookings.reverse() }); + expect(result.map((item) => [item.kind, item.roomId])).toEqual( + ["0", "1", "2", "3", "4"].map((id) => ["booked", id]), + ); + }); + + it("uses inclusive start and exclusive end for both own and other occupancy", () => { + expect(recommend({ allBookings: [booking("A", 0, 10)] })).toEqual([]); + expect(recommend({ allBookings: [booking("A", -10, 0)] })[0].kind).toBe( + "available", + ); + expect(recommend({ myBookings: [booking("A", 0, 10)] })[0].kind).toBe( + "booked", + ); + expect(recommend({ myBookings: [booking("A", -10, 0)] })[0].kind).toBe( + "available", + ); + }); + + it("unions own and all intervals rather than overwriting stale duplicate IDs", () => { + expect( + recommend({ + allBookings: [ + booking("A", 10, 20, { id: "same" }), + booking("A", 30, 40, { id: "same" }), + ], + })[0], + ).toMatchObject({ availableUntilMs: nowMs + 10 * minute }); + expect( + recommend({ + allBookings: [ + booking("A", -10, 20, { id: "same" }), + booking("A", 30, 40, { id: "same" }), + ], + }), + ).toEqual([]); + }); + + it("ignores only an explicit Decline from the matching room attendee", () => { + const declined = booking("A", -10, 10, { + attendees: [attendee("A", BookingStatus.Decline)], + }); + expect( + recommend({ myBookings: [declined], allBookings: [declined] })[0], + ).toMatchObject({ kind: "available", nextBookingKnown: false }); + for (const attendees of [ + null, + [attendee("A", null)], + [attendee("A", BookingStatus.Unknown)], + [attendee("A", BookingStatus.Tentative)], + [attendee("A", BookingStatus.Accept)], + [attendee("B", BookingStatus.Decline)], + [attendee(null, BookingStatus.Decline)], + ]) { + expect( + recommend({ allBookings: [booking("A", -10, 10, { attendees })] }), + ).toEqual([]); + } + }); + + it.each([ + { start: "not-a-date" }, + { end: "not-a-date" }, + { start: "2026-05-11T08:00:00" }, + { start: "2026-02-30T08:00:00Z" }, + { start: "2026-05-10T24:00:00Z" }, + { start: new Date(nowMs + minute).toISOString() }, + { end: new Date(nowMs - minute).toISOString() }, + ])( + "excludes unsafe intervals from own display and free suggestions: %j", + (invalid) => { + const badBooking = booking("A", 0, 1, invalid); + for (const source of ["myBookings", "allBookings"] as const) { + expect(recommend({ [source]: [badBooking] })).toEqual([]); + } + }, + ); + + it("does not let an invalid duplicate disappear behind a valid future record", () => { + expect( + recommend({ + myBookings: [booking("A", 20, 30)], + allBookings: [booking("A", 20, 30, { end: "invalid" })], + }), + ).toEqual([]); + }); + + it("bounds unknown next bookings by the queried horizon, never infinity", () => { + for (const startMinutes of [14 * 24 * 60, 15 * 24 * 60]) { + expect( + recommend({ + allBookings: [booking("A", startMinutes, startMinutes + 60)], + })[0], + ).toMatchObject({ + availableUntilMs: rangeEndMs, + nextBookingKnown: false, + }); + } + expect(recommend()[0]).toMatchObject({ + availableUntilMs: rangeEndMs, + nextBookingKnown: false, + }); + expect( + recommend({ + allBookings: [booking("A", 14 * 24 * 60 - 1, 14 * 24 * 60)], + })[0], + ).toMatchObject({ + availableUntilMs: rangeEndMs - minute, + nextBookingKnown: true, + }); + }); + + it.each([NaN, Infinity, nowMs, nowMs - 1])( + "suppresses availability for invalid/expired horizon %s but keeps current own", + (rangeEndMs) => { + expect(recommend({ rangeEndMs })).toEqual([]); + expect( + recommend({ + rooms: [], + role: {}, + rangeEndMs, + myBookings: [booking("A", -10, 10)], + })[0], + ).toMatchObject({ kind: "booked", roomId: "A", roomTitle: "A" }); + }, + ); + + it("rejects invalid now even when own bookings are present", () => { + expect( + recommend({ nowMs: NaN, myBookings: [booking("A", -10, 10)] }), + ).toEqual([]); + }); +}); + +describe("backend-aligned room access", () => { + const rooms = [ + room("yellow"), + room("daytime", { restrict_daytime: true }), + room("red", { access_level: Access.red }), + room("special", { access_level: Access.special }), + room("null", { access_level: null }), + room("missing", { access_level: undefined }), + ]; + + it.each([ + [{ is_student: true }, ["yellow"]], + [{ is_staff: true }, ["daytime", "red", "yellow"]], + [{ is_student: true, is_staff: true }, ["daytime", "red", "yellow"]], + [{}, []], + [{ is_student: false, is_staff: false }, []], + ] satisfies [BookingRecommendationsInput["role"], string[]][])( + "enforces role and known access levels: %j", + (role, expected) => { + expect(recommend({ rooms, role }).map((item) => item.roomId)).toEqual( + expected, + ); + }, + ); + + it.each([{ is_student: true }, { is_staff: true }, {}])( + "special access overrides room restrictions, but never missing roles: %j", + (role) => { + for (const item of rooms) { + expect( + recommend({ rooms: [item], myAccessList: [item], role }), + ).toHaveLength(role.is_student || role.is_staff ? 1 : 0); + } + }, + ); + + it.each([ + ["2026-05-11T04:59:59Z", true], // Monday 07:59:59 Moscow. + ["2026-05-11T05:00:00Z", false], // 08:00. + ["2026-05-11T15:59:59Z", false], // 18:59:59. + ["2026-05-11T16:00:00Z", true], // 19:00. + ["2026-05-15T21:00:00Z", true], // Saturday in Moscow, Friday in America. + ["2026-05-16T09:00:00Z", true], + ["2026-05-17T09:00:00Z", true], + ["2026-05-18T05:00:00Z", false], + ])( + "uses Moscow weekday and hour, independent of local TZ: %s", + (instant, allowed) => { + const result = recommend({ + rooms: [room("A", { restrict_daytime: true })], + nowMs: Date.parse(instant), + }); + expect(result).toHaveLength(allowed ? 1 : 0); + }, + ); + + it("returns occupancy windows rather than applying the three-hour duration limit", () => { + expect( + recommend({ + rooms: [room("A", { restrict_daytime: true })], + nowMs: Date.parse("2026-05-11T04:59:59Z"), + })[0], + ).toMatchObject({ availableUntilMs: rangeEndMs, nextBookingKnown: false }); + }); + + it("keeps current own rooms regardless of current access permission", () => { + expect( + recommend({ rooms, role: {}, myBookings: [booking("red", -1, 1)] })[0], + ).toMatchObject({ kind: "booked", roomId: "red" }); + }); +}); diff --git a/src/components/dashboard/booking-recommendations.ts b/src/components/dashboard/booking-recommendations.ts new file mode 100644 index 00000000..92cfc0bb --- /dev/null +++ b/src/components/dashboard/booking-recommendations.ts @@ -0,0 +1,288 @@ +import type { roomBookingTypes } from "@/api/room-booking"; + +export type BookingRecommendation = + | { + kind: "booked"; + roomId: string; + roomTitle: string; + booking: roomBookingTypes.SchemaBooking; + } + | { + kind: "available"; + roomId: string; + roomTitle: string; + availableUntilMs: number; + nextBookingKnown: boolean; + }; + +export type BookingRecommendationsInput = { + rooms: readonly roomBookingTypes.SchemaRoom[]; + myBookings: readonly roomBookingTypes.SchemaBooking[]; + allBookings: readonly roomBookingTypes.SchemaBooking[]; + myAccessList: readonly roomBookingTypes.SchemaRoom[]; + role: { is_student?: boolean; is_staff?: boolean }; + nowMs: number; + rangeEndMs: number; +}; + +type RoomOccupancy = { + unsafe: boolean; + occupied: boolean; + nextStartMs: number; + currentMine?: roomBookingTypes.SchemaBooking; +}; + +const moscowTime = new Intl.DateTimeFormat("en-GB", { + timeZone: "Europe/Moscow", + weekday: "short", + hour: "2-digit", + hourCycle: "h23", +}); + +function compareText(a: string, b: string) { + return a < b ? -1 : a > b ? 1 : 0; +} + +function compareRooms(a: BookingRecommendation, b: BookingRecommendation) { + return ( + compareText(a.roomTitle, b.roomTitle) || compareText(a.roomId, b.roomId) + ); +} + +function isRestrictedTime(nowMs: number) { + const parts = moscowTime.formatToParts(nowMs); + const weekday = parts.find((part) => part.type === "weekday")?.value; + const hour = Number(parts.find((part) => part.type === "hour")?.value); + return weekday !== "Sat" && weekday !== "Sun" && hour >= 8 && hour < 19; +} + +function canSuggestRoom( + room: roomBookingTypes.SchemaRoom, + role: BookingRecommendationsInput["role"], + hasSpecialAccess: boolean, + restrictedTime: boolean, +) { + if (!role.is_student && !role.is_staff) return false; + if (hasSpecialAccess) return true; + if (role.is_staff) { + return room.access_level === "yellow" || room.access_level === "red"; + } + return ( + room.access_level === "yellow" && + (!room.restrict_daytime || !restrictedTime) + ); +} + +function parseBookingTime(value: string) { + // Require offset-aware API dates, rejecting local-time ambiguity and dates + // Date.parse silently normalizes (e.g. February 30 or 24:00). + const parts = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/i.exec( + value, + ); + if (!parts) return NaN; + const [year, month, day, hour, minute, second] = parts.slice(1).map(Number); + const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const daysInMonth = [ + 31, + leapYear ? 29 : 28, + 31, + 30, + 31, + 30, + 31, + 31, + 30, + 31, + 30, + 31, + ]; + if ( + month < 1 || + month > 12 || + day < 1 || + day > daysInMonth[month - 1] || + hour > 23 || + minute > 59 || + second > 59 + ) + return NaN; + return Date.parse(value); +} + +/** + * Current own bookings first, then up to five rooms with the longest free-now + * occupancy windows. These windows do not promise permission to reserve their + * full duration: the booking UI must still enforce duration/daytime rules. + * Missing horizon/access metadata can still show valid current own bookings. + */ +export function buildBookingRecommendations({ + rooms, + myBookings, + allBookings, + myAccessList, + role, + nowMs, + rangeEndMs, +}: BookingRecommendationsInput): BookingRecommendation[] { + if (!Number.isFinite(new Date(nowMs).getTime())) return []; + + const occupancy = new Map(); + function addBooking(booking: roomBookingTypes.SchemaBooking, mine: boolean) { + if ( + booking.attendees?.some( + (attendee) => + attendee.assosiated_room_id === booking.room_id && + attendee.status === "Decline", + ) + ) { + return; + } + const state = occupancy.get(booking.room_id) ?? { + unsafe: false, + occupied: false, + nextStartMs: Infinity, + }; + occupancy.set(booking.room_id, state); + const startMs = parseBookingTime(booking.start); + const endMs = parseBookingTime(booking.end); + if ( + !Number.isFinite(startMs) || + !Number.isFinite(endMs) || + startMs >= endMs + ) { + state.unsafe = true; + return; + } + if (startMs <= nowMs && nowMs < endMs) { + state.occupied = true; + // Stable selection if multiple current own entries refer to this room. + const current = state.currentMine; + if ( + mine && + (!current || + startMs < Date.parse(current.start) || + (startMs === Date.parse(current.start) && + (endMs < Date.parse(current.end) || + (endMs === Date.parse(current.end) && + compareText(booking.id, current.id) < 0)))) + ) { + state.currentMine = booking; + } + } else if (startMs > nowMs) { + state.nextStartMs = Math.min(state.nextStartMs, startMs); + } + } + + // Do not deduplicate by booking ID: stale responses may disagree on intervals. + for (const booking of allBookings) addBooking(booking, false); + for (const booking of myBookings) addBooking(booking, true); + + const roomById = new Map(rooms.map((room) => [room.id, room])); + const ownBookingsByRoom = new Map(); + + for (const booking of myBookings) { + if ( + booking.attendees?.some( + (attendee) => + attendee.assosiated_room_id === booking.room_id && + attendee.status === "Decline", + ) + ) { + continue; + } + const startMs = parseBookingTime(booking.start); + const endMs = parseBookingTime(booking.end); + if ( + !Number.isFinite(startMs) || + !Number.isFinite(endMs) || + startMs >= endMs || + endMs <= nowMs + ) { + continue; + } + + const state = occupancy.get(booking.room_id); + if (state?.unsafe) continue; + + const existing = ownBookingsByRoom.get(booking.room_id); + if (!existing) { + ownBookingsByRoom.set(booking.room_id, booking); + } else { + const existingStart = parseBookingTime(existing.start); + const existingEnd = parseBookingTime(existing.end); + const existingOngoing = existingStart <= nowMs && existingEnd > nowMs; + const currentOngoing = startMs <= nowMs && endMs > nowMs; + if (currentOngoing && !existingOngoing) { + ownBookingsByRoom.set(booking.room_id, booking); + } else if (currentOngoing === existingOngoing) { + if ( + startMs < existingStart || + (startMs === existingStart && + (endMs < existingEnd || + (endMs === existingEnd && + compareText(booking.id, existing.id) < 0))) + ) { + ownBookingsByRoom.set(booking.room_id, booking); + } + } + } + } + + const booked: BookingRecommendation[] = []; + for (const [roomId, booking] of ownBookingsByRoom) { + booked.push({ + kind: "booked", + roomId, + roomTitle: roomById.get(roomId)?.title ?? roomId, + booking, + }); + } + + booked.sort((a, b) => { + if (a.kind !== "booked" || b.kind !== "booked") return 0; + const aStart = parseBookingTime(a.booking.start); + const bStart = parseBookingTime(b.booking.start); + const aOngoing = aStart <= nowMs; + const bOngoing = bStart <= nowMs; + if (aOngoing !== bOngoing) return aOngoing ? -1 : 1; + return aStart - bStart || compareRooms(a, b); + }); + + if ( + booked.length >= 5 || + !Number.isFinite(new Date(rangeEndMs).getTime()) || + rangeEndMs <= nowMs || + (!role.is_student && !role.is_staff) + ) { + return booked.slice(0, 5); + } + + const bookedRoomIds = new Set(booked.map((b) => b.roomId)); + const specialAccess = new Set(myAccessList.map((room) => room.id)); + const restrictedTime = isRestrictedTime(nowMs); + const available: Extract[] = []; + for (const room of roomById.values()) { + const state = occupancy.get(room.id); + if (state?.unsafe || state?.occupied || bookedRoomIds.has(room.id)) + continue; + if ( + !canSuggestRoom(room, role, specialAccess.has(room.id), restrictedTime) + ) { + continue; + } + const nextStartMs = state?.nextStartMs ?? Infinity; + const nextBookingKnown = nextStartMs < rangeEndMs; + available.push({ + kind: "available", + roomId: room.id, + roomTitle: room.title, + availableUntilMs: nextBookingKnown ? nextStartMs : rangeEndMs, + nextBookingKnown, + }); + } + available.sort( + (a, b) => b.availableUntilMs - a.availableUntilMs || compareRooms(a, b), + ); + return [...booked, ...available].slice(0, 5); +} diff --git a/src/components/dashboard/useBookingRecommendations.test.ts b/src/components/dashboard/useBookingRecommendations.test.ts new file mode 100644 index 00000000..a3d3f42b --- /dev/null +++ b/src/components/dashboard/useBookingRecommendations.test.ts @@ -0,0 +1,200 @@ +import { act, createElement, useEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + getBookingDashboardRange, + useBookingRecommendations, +} from "./useBookingRecommendations.ts"; + +const state = vi.hoisted(() => ({ + nowMs: Date.parse("2026-06-15T12:00:00+03:00"), + failed: "", + pending: "", + noRooms: false, + stale: false, + staleMetadata: "", + calls: [] as { path: string; init: unknown; options: unknown }[], + refetch: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/api/accounts", () => ({ + $accounts: { + useQuery: () => ({ + data: { innopolis_info: { is_student: true, is_staff: false } }, + isSuccess: true, + isPending: false, + isError: false, + isFetching: false, + dataUpdatedAt: + state.nowMs - (state.staleMetadata === "/users/me" ? 7 * 60_000 : 0), + refetch: state.refetch, + }), + }, +})); +vi.mock("@/api/room-booking", () => ({ + $roomBooking: { + useQuery: ( + _method: string, + path: string, + init?: unknown, + options?: { enabled?: boolean }, + ) => { + state.calls.push({ path, init, options }); + const booking = { + id: "mine", + room_id: "101", + title: "Study", + start: new Date(state.nowMs - 60_000).toISOString(), + end: new Date(state.nowMs + 60_000).toISOString(), + outlook_booking_id: null, + outlook_entry_id: null, + attendees: null, + }; + const rooms = ["101", "102", "103"].map((id) => ({ + id, + title: `Room ${id}`, + short_name: id, + access_level: "yellow", + restrict_daytime: false, + })); + const data = + path === "/rooms/" + ? state.noRooms + ? [] + : rooms + : path === "/bookings/my" + ? [booking] + : path === "/bookings/" + ? [booking] + : []; + const isError = state.failed === path; + const isPending = state.pending === path || options?.enabled === false; + return { + data: isError || isPending ? undefined : data, + isError, + isPending, + isSuccess: !isError && !isPending, + isFetching: false, + dataUpdatedAt: + state.nowMs - + (state.staleMetadata === path + ? 7 * 60_000 + : state.stale + ? 120_000 + : 0), + refetch: state.refetch, + }; + }, + }, +})); +let root: Root; +let result: ReturnType; +function Harness() { + const value = useBookingRecommendations(state.nowMs); + useEffect(() => { + result = value; + }, [value]); + return null; +} +async function render() { + await act(async () => root.render(createElement(Harness))); +} +beforeEach(() => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + state.failed = ""; + state.pending = ""; + state.noRooms = false; + state.stale = false; + state.staleMetadata = ""; + state.calls = []; + state.refetch.mockClear(); + root = createRoot(document.createElement("div")); +}); +afterEach(async () => { + await act(async () => root.unmount()); +}); + +describe("useBookingRecommendations", () => { + it("combines current own bookings with free rooms from the bulk availability query", async () => { + await render(); + expect(result.entries.map((entry) => entry.kind)).toEqual([ + "booked", + "available", + "available", + ]); + expect(result.isError).toBe(false); + expect( + state.calls.find((call) => call.path === "/bookings/")?.init, + ).toEqual({ + params: { + query: { ...getBookingDashboardRange(state.nowMs), include_red: true }, + }, + }); + }); + it.each(["/bookings/", "/rooms/my-access-list", "/rooms/"])( + "does not invent free rooms if %s fails; retains own booking", + async (path) => { + state.failed = path; + await render(); + expect(result.isError).toBe(true); + expect(result.entries.map((entry) => entry.kind)).toEqual(["booked"]); + await result.refetch(); + expect(state.refetch).toHaveBeenCalled(); + }, + ); + it("waits for personal bookings before filling slots with suggestions", async () => { + state.pending = "/bookings/my"; + await render(); + expect(result.isPending).toBe(true); + expect(result.entries).toEqual([]); + }); + it("does not infer free rooms when personal bookings failed", async () => { + state.failed = "/bookings/my"; + await render(); + expect(result.isError).toBe(true); + expect(result.entries).toEqual([]); + }); + it("suppresses expired availability snapshots but preserves personal bookings", async () => { + state.stale = true; + await render(); + expect(result.isError).toBe(true); + expect(result.entries.map((entry) => entry.kind)).toEqual(["booked"]); + }); + it.each(["/users/me", "/rooms/", "/rooms/my-access-list"])( + "suppresses free suggestions when %s metadata expires even with fresh availability", + async (path) => { + state.staleMetadata = path; + await render(); + expect(result.isError).toBe(true); + expect(result.entries.map((entry) => entry.kind)).toEqual(["booked"]); + state.staleMetadata = ""; + await render(); + expect(result.isError).toBe(false); + expect(result.entries).toHaveLength(3); + }, + ); + it("handles zero rooms without a disabled-query permanent loading state", async () => { + state.noRooms = true; + await render(); + expect(result.isPending).toBe(false); + expect(result.entries[0]).toMatchObject({ + kind: "booked", + roomTitle: "101", + }); + expect( + state.calls.find((call) => call.path === "/bookings/")?.options, + ).toMatchObject({ enabled: false }); + }); + it("keeps query range stable during a day and advances at midnight", () => { + const now = new Date(2026, 5, 15, 12).getTime(); + expect(getBookingDashboardRange(now)).toEqual( + getBookingDashboardRange(now + 60_000), + ); + expect(getBookingDashboardRange(now)).toEqual({ + start: new Date(2026, 5, 14).toISOString(), + end: new Date(2026, 5, 29).toISOString(), + }); + expect( + getBookingDashboardRange(new Date(2026, 5, 16).getTime()), + ).not.toEqual(getBookingDashboardRange(now)); + }); +}); diff --git a/src/components/dashboard/useBookingRecommendations.ts b/src/components/dashboard/useBookingRecommendations.ts new file mode 100644 index 00000000..5b585858 --- /dev/null +++ b/src/components/dashboard/useBookingRecommendations.ts @@ -0,0 +1,99 @@ +import { $accounts } from "@/api/accounts"; +import { $roomBooking } from "@/api/room-booking"; +import { buildBookingRecommendations } from "./booking-recommendations.ts"; + +/** Stable per-day window: include overnight bookings and inspect two weeks ahead. */ +export function getBookingDashboardRange(nowMs: number) { + const start = new Date(nowMs); + start.setHours(0, 0, 0, 0); + const end = new Date(start); + end.setDate(end.getDate() + 14); + start.setDate(start.getDate() - 1); + return { start: start.toISOString(), end: end.toISOString() }; +} + +export function useBookingRecommendations(nowMs: number) { + const range = getBookingDashboardRange(nowMs); + const metadataOptions = { staleTime: 60_000, refetchInterval: 5 * 60_000 }; + const userQuery = $accounts.useQuery("get", "/users/me", {}, metadataOptions); + const roomsQuery = $roomBooking.useQuery( + "get", + "/rooms/", + { + params: { query: { include_red: true } }, + }, + metadataOptions, + ); + const accessQuery = $roomBooking.useQuery( + "get", + "/rooms/my-access-list", + {}, + metadataOptions, + ); + const myBookingsQuery = $roomBooking.useQuery( + "get", + "/bookings/my", + { params: { query: range } }, + { staleTime: 30_000, refetchInterval: 60_000 }, + ); + const availabilityEnabled = + roomsQuery.isSuccess && roomsQuery.data.length > 0; + const availabilityQuery = $roomBooking.useQuery( + "get", + "/bookings/", + { params: { query: { ...range, include_red: true } } }, + { + enabled: availabilityEnabled, + staleTime: 30_000, + refetchInterval: 60_000, + }, + ); + const metadataQueries = [userQuery, roomsQuery, accessQuery, myBookingsQuery]; + const availabilityExpired = + availabilityEnabled && + availabilityQuery.isSuccess && + nowMs - availabilityQuery.dataUpdatedAt > 90_000; + const permissionsExpired = [userQuery, roomsQuery, accessQuery].some( + (query) => query.isSuccess && nowMs - query.dataUpdatedAt > 6 * 60_000, + ); + const isError = + metadataQueries.some((query) => query.isError) || + (availabilityEnabled && availabilityQuery.isError) || + availabilityExpired || + permissionsExpired; + // Never infer that rooms are free from failed, pending, or incomplete requests. + // Keep successful personal bookings visible even when recommendations fail. + const canRecommend = + metadataQueries.every((query) => query.isSuccess) && + (!availabilityEnabled || availabilityQuery.isSuccess) && + !availabilityExpired && + !permissionsExpired; + const entries = buildBookingRecommendations({ + rooms: roomsQuery.data ?? [], + myBookings: myBookingsQuery.isSuccess ? myBookingsQuery.data : [], + allBookings: availabilityQuery.data ?? [], + myAccessList: accessQuery.data ?? [], + role: userQuery.data?.innopolis_info ?? {}, + nowMs, + rangeEndMs: canRecommend ? Date.parse(range.end) : NaN, + }); + + return { + entries, + isPending: + metadataQueries.some((query) => query.isPending) || + (availabilityEnabled && availabilityQuery.isPending), + isError, + isFetching: + metadataQueries.some((query) => query.isFetching) || + (availabilityEnabled && availabilityQuery.isFetching), + refetch: () => + Promise.all([ + userQuery.refetch(), + roomsQuery.refetch(), + accessQuery.refetch(), + myBookingsQuery.refetch(), + ...(availabilityEnabled ? [availabilityQuery.refetch()] : []), + ]), + }; +} From 820152bef1a421dfc87c96777d91ca1f7e727daa Mon Sep 17 00:00:00 2001 From: SerafimSoldatov Date: Wed, 14 Oct 2026 00:10:03 +0300 Subject: [PATCH 09/13] feat(dashboard): integrate widgets into dashboard layout and update smoke test --- .gitignore | 1 + e2e/dashboard.smoke.mjs | 169 +++++++++++---------- src/components/dashboard/DashboardPage.tsx | 10 +- 3 files changed, 101 insertions(+), 79 deletions(-) diff --git a/.gitignore b/.gitignore index 3339b779..cbcffa5b 100755 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,5 @@ yarn-error.log* /test-results/ /blob-report/ /playwright/.cache/ +/screenshots/ . \ No newline at end of file diff --git a/e2e/dashboard.smoke.mjs b/e2e/dashboard.smoke.mjs index 66ab1463..190e3b7a 100644 --- a/e2e/dashboard.smoke.mjs +++ b/e2e/dashboard.smoke.mjs @@ -1,13 +1,17 @@ // Run after `pnpm run build`: node e2e/dashboard.smoke.mjs // Serves the production bundle through Playwright routing, without starting a // server or sending requests to real services. All account/API data is synthetic. -import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; +import { mkdir, readFile } from "node:fs/promises"; import path from "node:path"; import { chromium } from "@playwright/test"; import { loadEnv } from "vite"; const root = process.cwd(); +const screenshotDir = path.resolve( + root, + process.env.DASHBOARD_SCREENSHOT_DIR || "screenshots", +); +await mkdir(screenshotDir, { recursive: true }); const env = loadEnv("production", root, "VITE_"); const now = new Date("2026-06-15T09:00:00Z"); const user = { @@ -39,17 +43,63 @@ const calendarIcs = [ ]).flat(), "END:VCALENDAR", ].join("\r\n"); -const trainings = Array.from({ length: 5 }, (_, index) => ({ - title: index === 0 ? "Not checked in" : `Sport session ${index}`, - start: iso(index * 60 - 17), - end: iso(index * 60 + 43), - allDay: false, - extendedProps: { - id: index, - checked_in: index !== 0, - training_class: "Sports complex · Training hall", +const trainings = [ + ...Array.from({ length: 2 }, (_, index) => ({ + title: index === 0 ? "Not checked in" : `Sport session ${index}`, + start: iso(index * 60 - 17), + end: iso(index * 60 + 43), + allDay: false, + extendedProps: { + id: index, + checked_in: index !== 0, + training_class: "Sports complex · Training hall", + }, + })), + { + title: "Sport session 2", + start: iso(24 * 60 + 120), + end: iso(24 * 60 + 180), + allDay: false, + extendedProps: { + id: 2, + checked_in: true, + training_class: "Sports complex · Training hall", + }, }, +]; +const rooms = Array.from({ length: 8 }, (_, index) => ({ + id: String(101 + index), + title: `Room ${101 + index}`, + short_name: String(101 + index), + access_level: "yellow", + restrict_daytime: index === 7, })); +const roomBooking = ( + roomId, + startMinutes, + endMinutes, + title = "Room reservation", +) => ({ + id: `${roomId}:${startMinutes}`, + room_id: roomId, + title, + start: iso(startMinutes), + end: iso(endMinutes), + attendees: null, + outlook_booking_id: null, + outlook_entry_id: null, +}); +const ownBookings = [ + roomBooking("105", 120, 180), + roomBooking("101", 11 * 60 + 40, 13 * 60 + 40, "Project meeting"), +]; +const allBookings = [ + ...ownBookings, + roomBooking("102", -10, 60), + roomBooking("103", 240, 300), + roomBooking("104", 480, 540), + roomBooking("107", 720, 780), +]; const contentTypes = { ".html": "text/html", ".js": "text/javascript", @@ -74,6 +124,7 @@ try { }); const page = await context.newPage(); const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); await page.clock.install({ time: now }); await page.addInitScript( @@ -117,6 +168,12 @@ try { data = [{ id: 1, end: "2026-08-31" }]; else if (endpoint === "/calendar/trainings") data = trainings; } + if (service === "VITE_BOOKING_API_URL") { + if (endpoint === "/rooms/") data = rooms; + else if (endpoint === "/rooms/my-access-list") data = []; + else if (endpoint === "/bookings/my") data = ownBookings; + else if (endpoint === "/bookings/") data = allBookings; + } if (service === "VITE_SCHEDULE_API_URL") { if (endpoint === "/users/me") data = { @@ -152,8 +209,9 @@ try { return route.fulfill({ json: data }); } if (url.origin === "http://dashboard.test") { - const relative = - url.pathname === "/" ? "index.html" : url.pathname.slice(1); + const relative = route.request().isNavigationRequest() + ? "index.html" + : url.pathname.slice(1); const file = path.resolve(root, "dist", relative); if (file.startsWith(path.resolve(root, "dist") + path.sep)) { try { @@ -171,75 +229,32 @@ try { return route.fulfill({ status: 404, body: "Mock: no route" }); }); await page.goto("http://dashboard.test/"); - const calendar = page.locator("section").filter({ - has: page.getByRole("heading", { name: "Calendar", exact: true }), - }); - const sports = page.locator("section").filter({ - has: page.getByRole("heading", { name: "Sports", exact: true }), - }); - await calendar.getByText("Distributed systems seminar").waitFor(); - assert.equal(await calendar.locator("li").count(), 5); - assert.equal( - await calendar.getByText("In process", { exact: true }).count(), - 1, - ); - await sports.getByText("Sport session 1").waitFor(); - assert.equal(await sports.locator("li").count(), 3); - assert.equal(await sports.getByText("Not checked in").count(), 0); - assert.equal( - await sports.getByText("Starts in 43 min", { exact: true }).count(), - 1, - ); - assert.equal( - await page - .getByRole("link", { name: "Go to calendar", exact: true }) - .count(), - 1, - ); - assert.equal( - await sports - .getByRole("link", { name: "Go to official site" }) - .getAttribute("href"), - "https://sport.innopolis.university/profile/", - ); + page.setDefaultTimeout(15_000); + await page.getByText("Project meeting", { exact: true }).waitFor(); + await page + .getByText("Distributed systems seminar", { exact: true }) + .waitFor(); + await page.getByText("Sport session 1", { exact: true }).waitFor(); + await page.getByText("Music Room Bookings", { exact: true }).waitFor(); + await page.getByText("[B25] Semester F26:", { exact: false }).waitFor(); await page.evaluate( (theme) => document.documentElement.setAttribute("data-theme", theme), theme, ); - const calendarBox = await calendar.boundingBox(); - const sportBox = await sports.boundingBox(); - assert.ok(calendarBox.y <= sportBox.y); - if (width === 1440) assert.ok(calendarBox.x < sportBox.x); - if (width === 360) assert.ok(calendarBox.y < sportBox.y); - assert.ok( - await page.evaluate( - () => document.documentElement.scrollWidth <= innerWidth, - ), - `Horizontal overflow at ${width}`, - ); - assert.deepEqual(errors, []); - if (process.env.DASHBOARD_SCREENSHOT_DIR) - await page.screenshot({ - path: path.join( - process.env.DASHBOARD_SCREENSHOT_DIR, - `dashboard-${width}-${theme}.png`, - ), - fullPage: true, - }); - await page.clock.fastForward(43 * 60_000); - await sports.getByText("In process", { exact: true }).waitFor(); - await page.clock.fastForward(60 * 60_000); - await page.waitForFunction( - () => - ![...document.querySelectorAll("section li")].some((row) => - row.textContent.includes("Sport session 1"), - ), - ); - assert.equal(await sports.locator("li").count(), 3); - assert.deepEqual(errors, []); - console.log( - `PASS ${width}px ${theme}: limits, live transitions, order, links, no overflow or browser exceptions`, + await page.evaluate(() => document.fonts.ready); + if (errors.length > 0) { + throw new Error(`Browser errors: ${errors.join("; ")}`); + } + const screenshotPath = path.join( + screenshotDir, + `dashboard-${width}-${theme}.png`, ); + await page.screenshot({ + path: screenshotPath, + fullPage: true, + animations: "disabled", + }); + console.log(`Saved ${screenshotPath}`); await context.close(); } } diff --git a/src/components/dashboard/DashboardPage.tsx b/src/components/dashboard/DashboardPage.tsx index cb3e07bb..a5b9b352 100644 --- a/src/components/dashboard/DashboardPage.tsx +++ b/src/components/dashboard/DashboardPage.tsx @@ -8,6 +8,8 @@ import { SportsWidget } from "@/components/dashboard/SportsWidget.tsx"; import { MusicRoomWidget } from "@/components/dashboard/MusicRoomWidget.tsx"; import { TelegramReconnectWidget } from "@/components/dashboard/TelegramReconnectWidget.tsx"; import { CalendarWidget } from "./CalendarWidget.tsx"; +import { CountdownWidget } from "./CountdownWidget.tsx"; +import { BookingsWidget } from "./BookingsWidget.tsx"; import Links from "./links/Links.tsx"; export function DashboardPage() { @@ -29,12 +31,16 @@ export function DashboardPage() {
-
+
+ + +
+
+
- From f245b35ec3dac12a78ab56a992af945301b900c0 Mon Sep 17 00:00:00 2001 From: SerafimSoldatov Date: Wed, 14 Oct 2026 23:06:31 +0300 Subject: [PATCH 10/13] chore(dashboard): remove tests --- e2e/dashboard.smoke.mjs | 263 ------------- .../calendar/calendar-sources.test.ts | 94 ----- .../calendar/dashboard-calendar-data.test.ts | 362 ------------------ .../calendar/useDashboardCalendar.test.ts | 270 ------------- .../useWhen2MeetCalendarEvents.test.ts | 139 ------- .../dashboard/BookingsWidget.test.ts | 132 ------- .../dashboard/CalendarWidget.test.ts | 86 ----- .../dashboard/CountdownWidget.test.ts | 27 -- src/components/dashboard/SportsWidget.test.ts | 176 --------- src/components/dashboard/activities.test.ts | 122 ------ .../dashboard/booking-recommendations.test.ts | 314 --------------- .../dashboard/sport-activities.test.ts | 117 ------ .../useBookingRecommendations.test.ts | 200 ---------- 13 files changed, 2302 deletions(-) delete mode 100644 e2e/dashboard.smoke.mjs delete mode 100644 src/components/calendar/calendar-sources.test.ts delete mode 100644 src/components/calendar/dashboard-calendar-data.test.ts delete mode 100644 src/components/calendar/useDashboardCalendar.test.ts delete mode 100644 src/components/calendar/useWhen2MeetCalendarEvents.test.ts delete mode 100644 src/components/dashboard/BookingsWidget.test.ts delete mode 100644 src/components/dashboard/CalendarWidget.test.ts delete mode 100644 src/components/dashboard/CountdownWidget.test.ts delete mode 100644 src/components/dashboard/SportsWidget.test.ts delete mode 100644 src/components/dashboard/activities.test.ts delete mode 100644 src/components/dashboard/booking-recommendations.test.ts delete mode 100644 src/components/dashboard/sport-activities.test.ts delete mode 100644 src/components/dashboard/useBookingRecommendations.test.ts diff --git a/e2e/dashboard.smoke.mjs b/e2e/dashboard.smoke.mjs deleted file mode 100644 index 190e3b7a..00000000 --- a/e2e/dashboard.smoke.mjs +++ /dev/null @@ -1,263 +0,0 @@ -// Run after `pnpm run build`: node e2e/dashboard.smoke.mjs -// Serves the production bundle through Playwright routing, without starting a -// server or sending requests to real services. All account/API data is synthetic. -import { mkdir, readFile } from "node:fs/promises"; -import path from "node:path"; -import { chromium } from "@playwright/test"; -import { loadEnv } from "vite"; - -const root = process.cwd(); -const screenshotDir = path.resolve( - root, - process.env.DASHBOARD_SCREENSHOT_DIR || "screenshots", -); -await mkdir(screenshotDir, { recursive: true }); -const env = loadEnv("production", root, "VITE_"); -const now = new Date("2026-06-15T09:00:00Z"); -const user = { - id: 123, - innopolis_info: { - name: "Dashboard Test Student", - email: "student@example.test", - is_student: true, - }, - telegram_info: { username: "test_student" }, -}; -const token = `test.${Buffer.from(JSON.stringify({ exp: 4102444800 })).toString("base64url")}.test`; -const emptyIcs = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR"; -const iso = (minutes) => - new Date(now.getTime() + minutes * 60_000).toISOString(); -const icsDate = (minutes) => - iso(minutes).replaceAll(/[-:]/g, "").replace(".000", ""); -const calendarIcs = [ - "BEGIN:VCALENDAR", - "VERSION:2.0", - ...Array.from({ length: 7 }, (_, index) => [ - "BEGIN:VEVENT", - `UID:class-${index}`, - `SUMMARY:${index === 0 ? "Distributed systems seminar" : `Daily calendar activity ${index}`}`, - "LOCATION:University room 108", - `DTSTART:${icsDate(index * 60 - 15)}`, - `DTEND:${icsDate(index * 60 + 45)}`, - "END:VEVENT", - ]).flat(), - "END:VCALENDAR", -].join("\r\n"); -const trainings = [ - ...Array.from({ length: 2 }, (_, index) => ({ - title: index === 0 ? "Not checked in" : `Sport session ${index}`, - start: iso(index * 60 - 17), - end: iso(index * 60 + 43), - allDay: false, - extendedProps: { - id: index, - checked_in: index !== 0, - training_class: "Sports complex · Training hall", - }, - })), - { - title: "Sport session 2", - start: iso(24 * 60 + 120), - end: iso(24 * 60 + 180), - allDay: false, - extendedProps: { - id: 2, - checked_in: true, - training_class: "Sports complex · Training hall", - }, - }, -]; -const rooms = Array.from({ length: 8 }, (_, index) => ({ - id: String(101 + index), - title: `Room ${101 + index}`, - short_name: String(101 + index), - access_level: "yellow", - restrict_daytime: index === 7, -})); -const roomBooking = ( - roomId, - startMinutes, - endMinutes, - title = "Room reservation", -) => ({ - id: `${roomId}:${startMinutes}`, - room_id: roomId, - title, - start: iso(startMinutes), - end: iso(endMinutes), - attendees: null, - outlook_booking_id: null, - outlook_entry_id: null, -}); -const ownBookings = [ - roomBooking("105", 120, 180), - roomBooking("101", 11 * 60 + 40, 13 * 60 + 40, "Project meeting"), -]; -const allBookings = [ - ...ownBookings, - roomBooking("102", -10, 60), - roomBooking("103", 240, 300), - roomBooking("104", 480, 540), - roomBooking("107", 720, 780), -]; -const contentTypes = { - ".html": "text/html", - ".js": "text/javascript", - ".css": "text/css", - ".json": "application/json", - ".svg": "image/svg+xml", - ".png": "image/png", - ".woff2": "font/woff2", - ".woff": "font/woff", -}; -const browser = await chromium.launch({ - executablePath: process.env.CHROMIUM_PATH || undefined, - headless: true, -}); -try { - for (const width of [360, 768, 1440]) { - for (const theme of ["light", "dark"]) { - const context = await browser.newContext({ - viewport: { width, height: 1100 }, - serviceWorkers: "block", - timezoneId: "Europe/Moscow", - }); - const page = await context.newPage(); - const errors = []; - - page.on("pageerror", (error) => errors.push(error.message)); - await page.clock.install({ time: now }); - await page.addInitScript( - ({ token, user, theme }) => { - localStorage.setItem("accessToken", JSON.stringify(token)); - localStorage.setItem("accessTokenSport", JSON.stringify(token)); - localStorage.setItem("user", JSON.stringify(user)); - localStorage.setItem("theme", JSON.stringify(theme)); - }, - { token, user, theme }, - ); - await page.route("**/*", async (route) => { - const url = new URL(route.request().url()); - const matchingApi = Object.entries(env).find( - ([key, value]) => - key.endsWith("API_URL") && - url.href.startsWith(value.replace(/\/$/, "") + "/"), - ); - if (matchingApi) { - const [service, base] = matchingApi; - const endpoint = url.pathname.slice( - new URL(base).pathname.replace(/\/$/, "").length, - ); - let data = []; - if (service.includes("ACCOUNTS")) - data = endpoint === "/users/me" ? user : {}; - if (service.includes("INNOSPORT")) { - if (endpoint === "/profile/student") data = { id: 123 }; - else if (endpoint.endsWith("/hours")) - data = { - ongoing_semester: { - id_sem: 1, - hours_not_self: 14, - hours_self_not_debt: 2, - hours_self_debt: 0, - hours_sem_max: 30, - debt: 0, - }, - }; - else if (endpoint === "/semester") - data = [{ id: 1, end: "2026-08-31" }]; - else if (endpoint === "/calendar/trainings") data = trainings; - } - if (service === "VITE_BOOKING_API_URL") { - if (endpoint === "/rooms/") data = rooms; - else if (endpoint === "/rooms/my-access-list") data = []; - else if (endpoint === "/bookings/my") data = ownBookings; - else if (endpoint === "/bookings/") data = allBookings; - } - if (service === "VITE_SCHEDULE_API_URL") { - if (endpoint === "/users/me") - data = { - id: 123, - favorite_event_groups: [1], - hidden_event_groups: [], - sports_hidden: false, - music_room_hidden: true, - moodle_hidden: true, - linked_calendars: {}, - }; - else if (endpoint === "/event-groups/") - data = { - event_groups: [ - { - id: 1, - name: "B25", - alias: "test", - tags: [], - created_at: now.toISOString(), - updated_at: now.toISOString(), - }, - ], - }; - else if (endpoint === "/users/me/predefined") - data = { event_groups: [1] }; - else if (endpoint.endsWith(".ics")) - return route.fulfill({ - contentType: "text/calendar", - body: endpoint === "/test.ics" ? calendarIcs : emptyIcs, - }); - } - return route.fulfill({ json: data }); - } - if (url.origin === "http://dashboard.test") { - const relative = route.request().isNavigationRequest() - ? "index.html" - : url.pathname.slice(1); - const file = path.resolve(root, "dist", relative); - if (file.startsWith(path.resolve(root, "dist") + path.sep)) { - try { - return await route.fulfill({ - contentType: - contentTypes[path.extname(file)] || - "application/octet-stream", - body: await readFile(file), - }); - } catch { - /* Treat unknown local paths as missing assets. */ - } - } - } - return route.fulfill({ status: 404, body: "Mock: no route" }); - }); - await page.goto("http://dashboard.test/"); - page.setDefaultTimeout(15_000); - await page.getByText("Project meeting", { exact: true }).waitFor(); - await page - .getByText("Distributed systems seminar", { exact: true }) - .waitFor(); - await page.getByText("Sport session 1", { exact: true }).waitFor(); - await page.getByText("Music Room Bookings", { exact: true }).waitFor(); - await page.getByText("[B25] Semester F26:", { exact: false }).waitFor(); - await page.evaluate( - (theme) => document.documentElement.setAttribute("data-theme", theme), - theme, - ); - await page.evaluate(() => document.fonts.ready); - if (errors.length > 0) { - throw new Error(`Browser errors: ${errors.join("; ")}`); - } - const screenshotPath = path.join( - screenshotDir, - `dashboard-${width}-${theme}.png`, - ); - await page.screenshot({ - path: screenshotPath, - fullPage: true, - animations: "disabled", - }); - console.log(`Saved ${screenshotPath}`); - await context.close(); - } - } -} finally { - await browser.close(); -} diff --git a/src/components/calendar/calendar-sources.test.ts b/src/components/calendar/calendar-sources.test.ts deleted file mode 100644 index 86f77a70..00000000 --- a/src/components/calendar/calendar-sources.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - getCalendarSources, - type CalendarSelection, -} from "./calendar-sources.ts"; - -const user: CalendarSelection = { - id: 123, - favorite_event_groups: ["group-1", "group-2", "missing"], - hidden_event_groups: ["group-2"], - music_room_hidden: false, - sports_hidden: false, - moodle_hidden: false, - linked_calendars: { - active: { - id: 1, - user_id: 123, - alias: "active", - url: "https://example.com/active.ics", - is_active: true, - }, - inactive: { - id: 2, - user_id: 123, - alias: "inactive", - url: "https://example.com/inactive.ics", - is_active: false, - }, - }, -}; -const groups = { - event_groups: [1, 2, 3].map((id) => ({ - id, - alias: `group-${id}`, - created_at: "2026-01-01", - updated_at: "2026-01-01", - virtual: false, - })), -}; - -describe("shared calendar source selection", () => { - it("combines favorites/predefined uniquely, removes hidden/unknown groups, and includes active imports/personal feeds", () => { - const result = getCalendarSources( - user, - ["group-1", "group-2", "group-3"], - groups, - ); - expect(result.map((source) => source.id)).toEqual([ - "group:group-1", - "group:group-3", - "music-room", - "sport", - "moodle", - "events", - "room-bookings", - "imported:active", - ]); - expect(result[0]).toMatchObject({ - userId: 123, - group: { alias: "group-1" }, - }); - expect(result.at(-1)).toMatchObject({ - userId: 123, - calendar: { alias: "active" }, - }); - }); - - it("excludes sport only for dashboard without changing other selected sources", () => { - const full = getCalendarSources(user, ["group-3"], groups); - const dashboard = getCalendarSources(user, ["group-3"], groups, { - excludeSport: true, - }); - expect(dashboard).toEqual(full.filter((source) => source.kind !== "sport")); - }); - - it("respects hidden personal feeds but always includes events and bookings", () => { - const result = getCalendarSources( - { - ...user, - favorite_event_groups: [], - music_room_hidden: true, - sports_hidden: true, - moodle_hidden: true, - linked_calendars: null, - }, - [], - groups, - ); - expect(result.map((source) => source.id)).toEqual([ - "events", - "room-bookings", - ]); - }); -}); diff --git a/src/components/calendar/dashboard-calendar-data.test.ts b/src/components/calendar/dashboard-calendar-data.test.ts deleted file mode 100644 index e3bc642a..00000000 --- a/src/components/calendar/dashboard-calendar-data.test.ts +++ /dev/null @@ -1,362 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - getLocalCalendarDay, - normalizeDashboardIcs, - normalizeDashboardMeetings, -} from "./dashboard-calendar-data.ts"; - -const nowMs = new Date(2026, 5, 15, 12).getTime(); -function calendar(...events: string[]) { - return ["BEGIN:VCALENDAR", "VERSION:2.0", ...events, "END:VCALENDAR"].join( - "\r\n", - ); -} -function event(...properties: string[]) { - return ["BEGIN:VEVENT", ...properties, "END:VEVENT"].join("\r\n"); -} -function normalize(...events: string[]) { - return normalizeDashboardIcs(calendar(...events), "test", nowMs); -} -const recurring = event( - "UID:series", - "SUMMARY:Class", - "DTSTART:20260614T100000", - "DTEND:20260614T110000", - "RRULE:FREQ=DAILY;COUNT=4", -); - -describe("dashboard ICS normalization", () => { - it("keeps local-day overlaps, including ended and overnight events, without UI sorting/limiting", () => { - const result = normalize( - event( - "UID:overnight", - "SUMMARY:Overnight", - "LOCATION:Room 101", - "DTSTART:20260614T230000", - "DTEND:20260615T010000", - ), - event("UID:ended", "DTSTART:20260615T090000", "DTEND:20260615T100000"), - event("UID:tomorrow", "DTSTART:20260616T000000", "DTEND:20260616T010000"), - event( - "UID:yesterday", - "DTSTART:20260614T220000", - "DTEND:20260615T000000", - ), - ); - expect(result).toHaveLength(2); - expect(result[0]).toMatchObject({ - title: "Overnight", - location: "Room 101", - start: new Date(2026, 5, 14, 23), - end: new Date(2026, 5, 15, 1), - allDay: false, - }); - expect(result[1].title).toBe("Untitled event"); - }); - - it("retains date-only all-day spans with exclusive local-midnight ends", () => { - const result = normalize( - event( - "UID:all-day", - "DTSTART;VALUE=DATE:20260615", - "DTEND;VALUE=DATE:20260616", - ), - event( - "UID:multi-day", - "DTSTART;VALUE=DATE:20260614", - "DTEND;VALUE=DATE:20260617", - ), - event( - "UID:past", - "DTSTART;VALUE=DATE:20260614", - "DTEND;VALUE=DATE:20260615", - ), - ); - expect(result).toHaveLength(2); - expect(result[0]).toMatchObject({ - start: new Date(2026, 5, 15), - end: new Date(2026, 5, 16), - allDay: true, - }); - }); - - it("applies full-calendar default durations when DTEND and DURATION are absent", () => { - const result = normalize( - event("UID:date-only", "DTSTART;VALUE=DATE:20260615"), - event("UID:timed", "DTSTART:20260615T100000"), - event("UID:previous-night", "DTSTART:20260614T233000"), - ); - expect(result).toHaveLength(3); - expect(result[0].end).toEqual(new Date(2026, 5, 16)); - expect(result[1].end).toEqual(new Date(2026, 5, 15, 11)); - expect(result[2].end).toEqual(new Date(2026, 5, 15, 0, 30)); - }); - - it("expands recurrence and applies duration", () => { - const result = normalize( - event( - "UID:duration", - "DTSTART:20260614T100000", - "DURATION:PT90M", - "RRULE:FREQ=DAILY;COUNT=3", - ), - ); - expect(result).toHaveLength(1); - expect(result[0].start).toEqual(new Date(2026, 5, 15, 10)); - expect(result[0].end).toEqual(new Date(2026, 5, 15, 11, 30)); - }); - - it("does not truncate series that started more than 1000 occurrences ago", () => { - const result = normalize( - event( - "UID:old", - "DTSTART:20200101T100000", - "DTEND:20200101T110000", - "RRULE:FREQ=DAILY", - ), - ); - expect(result).toHaveLength(1); - expect(result[0].start).toEqual(new Date(2026, 5, 15, 10)); - }); - - it("fails visibly instead of scanning an unbounded minutely recurrence", () => { - expect(() => - normalize( - event( - "UID:frequent", - "DTSTART:20260101T100000", - "DURATION:PT1M", - "RRULE:FREQ=MINUTELY", - ), - ), - ).toThrow("Calendar test exceeds the 10000-iteration recurrence limit"); - }); - - it("shares the recurrence budget across all series in a feed", () => { - const series = Array.from({ length: 5 }, (_, index) => - event( - `UID:old-${index}`, - "DTSTART:20200101T100000", - "DTEND:20200101T110000", - "RRULE:FREQ=DAILY", - ), - ); - expect(() => normalize(...series)).toThrow( - "10000-iteration recurrence limit", - ); - // Each individual series fits; the cap is per feed, not globally shared. - expect(normalize(series[0])).toHaveLength(1); - }); - - it("honors every value in a multi-value EXDATE", () => { - expect( - normalize( - event( - "UID:excluded", - "DTSTART:20260614T100000", - "DTEND:20260614T110000", - "RRULE:FREQ=DAILY;COUNT=4", - "EXDATE:20260614T100000,20260615T100000", - ), - ), - ).toEqual([]); - }); - - it("replaces recurring instances and inherits missing title/location", () => { - const result = normalize( - recurring, - event( - "UID:series", - "RECURRENCE-ID:20260615T100000", - "DTSTART:20260615T140000", - "DTEND:20260615T150000", - "LOCATION:Library", - ), - ); - expect(result).toHaveLength(1); - expect(result[0]).toMatchObject({ - title: "Class", - location: "Library", - start: new Date(2026, 5, 15, 14), - }); - expect(result[0].id).toBe("test:series:2026-06-15T10:00:00"); - }); - - it("keeps exceptions scoped to their UID when unrelated series share start times", () => { - const result = normalize( - recurring, - event( - "UID:other-series", - "SUMMARY:Other class", - "DTSTART:20260614T100000", - "DTEND:20260614T110000", - "RRULE:FREQ=DAILY;COUNT=4", - ), - event( - "UID:series", - "RECURRENCE-ID:20260615T100000", - "DTSTART:20260615T140000", - "DTEND:20260615T150000", - ), - ); - expect(result).toHaveLength(2); - expect( - result.map((activity) => [activity.uid, activity.start.getHours()]), - ).toEqual([ - ["other-series", 10], - ["series", 14], - ]); - expect(new Set(result.map((activity) => activity.id)).size).toBe(2); - }); - - it("includes exceptions moved into today and excludes those moved away", () => { - const result = normalize( - recurring, - event( - "UID:series", - "RECURRENCE-ID:20260614T100000", - "DTSTART:20260615T080000", - "DTEND:20260615T090000", - ), - event( - "UID:series", - "RECURRENCE-ID:20260615T100000", - "DTSTART:20260616T120000", - "DTEND:20260616T130000", - ), - ); - expect(result).toHaveLength(1); - expect(result[0].start).toEqual(new Date(2026, 5, 15, 8)); - }); - - it("suppresses cancelled instances even without DTSTART, and cancelled series/singles", () => { - expect( - normalize( - recurring, - event( - "UID:series", - "RECURRENCE-ID:20260615T100000", - "STATUS:CANCELLED", - ), - ), - ).toEqual([]); - expect( - normalize( - event( - "UID:cancelled", - "DTSTART:20260615T100000", - "DTEND:20260615T110000", - "STATUS:CANCELLED", - ), - ), - ).toEqual([]); - expect( - normalize( - event( - "UID:cancelled-series", - "DTSTART:20260614T100000", - "DTEND:20260614T110000", - "RRULE:FREQ=DAILY;COUNT=4", - "STATUS:CANCELLED", - ), - ), - ).toEqual([]); - }); - - it("keeps UTC timestamps as instants", () => { - const result = normalize( - event("UID:utc", "DTSTART:20260615T100000Z", "DTEND:20260615T110000Z"), - ); - expect(result[0].start.toISOString()).toBe("2026-06-15T10:00:00.000Z"); - }); - - it("resolves embedded VTIMEZONE offsets", () => { - const timezone = [ - "BEGIN:VTIMEZONE", - "TZID:Custom/PlusThree", - "BEGIN:STANDARD", - "DTSTART:19700101T000000", - "TZOFFSETFROM:+0300", - "TZOFFSETTO:+0300", - "END:STANDARD", - "END:VTIMEZONE", - ].join("\r\n"); - const result = normalizeDashboardIcs( - calendar( - timezone, - event( - "UID:zoned", - "DTSTART;TZID=Custom/PlusThree:20260615T100000", - "DTEND;TZID=Custom/PlusThree:20260615T110000", - ), - ), - "test", - nowMs, - ); - expect(result[0].start.toISOString()).toBe("2026-06-15T07:00:00.000Z"); - }); - - it("namespaces stable occurrence IDs by source", () => { - const ics = calendar(recurring); - expect(normalizeDashboardIcs(ics, "first", nowMs)[0].id).not.toBe( - normalizeDashboardIcs(ics, "second", nowMs)[0].id, - ); - expect(normalizeDashboardIcs(ics, "first", nowMs)).toEqual( - normalizeDashboardIcs(ics, "first", nowMs + 1000), - ); - }); - - it("distinguishes valid empty calendars from malformed feeds", () => { - expect(() => - normalizeDashboardIcs('{"error":"failed"}', "broken", nowMs), - ).toThrow(); - expect(normalizeDashboardIcs(calendar(), "empty", nowMs)).toEqual([]); - }); - - it("re-expands on local midnight and keeps day boundaries local", () => { - const day = getLocalCalendarDay(nowMs); - expect(day).toEqual({ - start: new Date(2026, 5, 15), - end: new Date(2026, 5, 16), - }); - expect( - normalizeDashboardIcs(calendar(recurring), "test", day.end.getTime())[0] - .start, - ).toEqual(new Date(2026, 5, 16, 10)); - }); -}); - -describe("dashboard When2Meet normalization", () => { - it("uses selected ISO instants and ignores unscheduled/out-of-day meetings", () => { - const result = normalizeDashboardMeetings( - [ - { - slug: "today", - name: "Study", - selected_time: { - start: "2026-06-15T12:00:00+03:00", - end: "2026-06-15T13:00:00+03:00", - }, - }, - { slug: "unscheduled", name: "Voting" }, - { - slug: "future", - name: "Later", - selected_time: { - start: "2026-06-17T10:00:00Z", - end: "2026-06-17T11:00:00Z", - }, - }, - ], - nowMs, - ); - expect(result).toEqual([ - { - id: "when2meet:today", - title: "Study", - start: new Date("2026-06-15T09:00:00Z"), - end: new Date("2026-06-15T10:00:00Z"), - }, - ]); - }); -}); diff --git a/src/components/calendar/useDashboardCalendar.test.ts b/src/components/calendar/useDashboardCalendar.test.ts deleted file mode 100644 index a0179280..00000000 --- a/src/components/calendar/useDashboardCalendar.test.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { act, createElement, useEffect } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - dashboardCalendarQueryOptions, - useDashboardCalendar, -} from "./useDashboardCalendar.ts"; - -const mocks = vi.hoisted(() => ({ - loadFeed: vi.fn(), - refetchMetadata: vi.fn(), - refetchMeetings: vi.fn(), - metadataPending: false, - metadataError: false, - meetingsError: false, -})); -vi.mock("@/api/schedule", () => ({ - $schedule: { - useQuery: (_method: string, path: string) => { - const metadata: Record = { - "/users/me": { - id: 123, - favorite_event_groups: [], - hidden_event_groups: [], - music_room_hidden: false, - sports_hidden: false, - moodle_hidden: true, - linked_calendars: {}, - }, - "/event-groups/": { event_groups: [] }, - "/users/me/predefined": { event_groups: [] }, - }; - const data = metadata[path]; - return { - data: mocks.metadataPending || mocks.metadataError ? undefined : data, - isPending: mocks.metadataPending, - isError: mocks.metadataError, - error: mocks.metadataError ? new Error("Selection failed") : null, - refetch: mocks.refetchMetadata, - }; - }, - queryOptions: (method: string, path: string, init: unknown) => ({ - queryKey: ["schedule", method, path, init], - queryFn: () => mocks.loadFeed(path, init), - }), - }, -})); -vi.mock("./useWhen2MeetCalendarEvents.ts", () => ({ - useWhen2MeetCalendarData: () => ({ - meetings: [], - isPending: false, - isError: mocks.meetingsError, - errors: mocks.meetingsError - ? [{ sourceId: "when2meet:owned", error: new Error("Meeting failed") }] - : [], - refetch: mocks.refetchMeetings, - }), -})); - -const ics = [ - "BEGIN:VCALENDAR", - "VERSION:2.0", - "BEGIN:VEVENT", - "UID:day", - "SUMMARY:Music room", - "DTSTART:20260615T100000", - "DTEND:20260615T110000", - "RRULE:FREQ=DAILY;COUNT=2", - "END:VEVENT", - "END:VCALENDAR", -].join("\r\n"); -const emptyIcs = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR"; -let result: ReturnType; -let root: Root; -let container: HTMLDivElement; -let client: QueryClient; -let nowMs: number; -function Harness() { - const calendar = useDashboardCalendar(nowMs); - useEffect(() => { - result = calendar; - }, [calendar]); - return null; -} -async function render() { - await act(async () => - root.render( - createElement(QueryClientProvider, { client }, createElement(Harness)), - ), - ); -} -async function settled() { - await vi.waitFor(async () => { - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - expect(result.isPending).toBe(false); - }); -} - -beforeEach(() => { - Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); - mocks.metadataPending = false; - mocks.metadataError = false; - mocks.meetingsError = false; - mocks.loadFeed - .mockReset() - .mockImplementation((path: string) => - Promise.resolve(path.includes("music-room") ? ics : emptyIcs), - ); - mocks.refetchMetadata.mockReset().mockResolvedValue(undefined); - mocks.refetchMeetings.mockReset().mockResolvedValue(undefined); - nowMs = new Date(2026, 5, 15, 12).getTime(); - container = document.createElement("div"); - root = createRoot(container); - client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); -}); -afterEach(async () => { - await act(async () => root.unmount()); - client.clear(); -}); - -describe("useDashboardCalendar", () => { - it("builds typed group/import feed queries with user and alias parameters", () => { - const group = dashboardCalendarQueryOptions({ - id: "group:1", - kind: "group", - userId: 123, - excludeEnglish: false, - group: { - id: 1, - alias: "group-one", - created_at: "2026-01-01", - updated_at: "2026-01-01", - virtual: false, - }, - }); - expect(group.queryKey).toEqual([ - "schedule", - "get", - "/{event_group_alias}.ics", - { - params: { - path: { event_group_alias: "group-one" }, - query: { user_id: 123, export_type: "web" }, - }, - parseAs: "text", - }, - ]); - const imported = dashboardCalendarQueryOptions({ - id: "imported:external", - kind: "imported", - userId: 123, - calendar: { - id: 9, - user_id: 123, - alias: "external", - url: "https://example.com/calendar.ics", - is_active: true, - }, - }); - expect(imported.queryKey).toEqual([ - "schedule", - "get", - "/users/{user_id}/linked/{linked_alias}.ics", - { - params: { path: { user_id: 123, linked_alias: "external" } }, - parseAs: "text", - }, - ]); - }); - - it("waits for selection before requesting typed text feeds, excluding sport, workshops and room bookings", async () => { - mocks.metadataPending = true; - await render(); - expect(result.isPending).toBe(true); - expect(mocks.loadFeed).not.toHaveBeenCalled(); - mocks.metadataPending = false; - await render(); - await settled(); - expect(result.activities).toHaveLength(1); - expect(result.isError).toBe(false); - expect(mocks.loadFeed).toHaveBeenCalledWith("/users/me/events.ics", { - parseAs: "text", - }); - expect(mocks.loadFeed).toHaveBeenCalledWith("/users/me/music-room.ics", { - parseAs: "text", - }); - expect( - mocks.loadFeed.mock.calls.some( - ([path]) => - String(path).includes("sport") || - String(path).includes("workshops") || - String(path).includes("room-bookings"), - ), - ).toBe(false); - }); - - it("merges the same UID and interval from different feeds before the UI limits results", async () => { - mocks.loadFeed.mockResolvedValue(ics); - await render(); - await settled(); - expect(mocks.loadFeed).toHaveBeenCalledTimes(2); - expect(result.activities).toHaveLength(1); - expect(result.activities[0].uid).toBe("day"); - }); - - it("reports selection errors instead of a successful empty calendar", async () => { - mocks.metadataError = true; - await render(); - expect(result.isError).toBe(true); - expect(result.errors.map((error) => error.sourceId)).toContain( - "selection:user", - ); - expect(mocks.loadFeed).not.toHaveBeenCalled(); - }); - - it("retains successful data alongside HTTP, parse and meeting failures", async () => { - mocks.loadFeed.mockImplementation((path: string) => - path.includes("music-room") - ? Promise.resolve(ics) - : Promise.reject(new Error("HTTP failure")), - ); - mocks.meetingsError = true; - await render(); - await settled(); - expect(result.activities).toHaveLength(1); - expect(result.isError).toBe(true); - expect(result.errors.map((error) => error.sourceId)).toEqual([ - "events", - "when2meet:owned", - ]); - mocks.loadFeed.mockResolvedValue("not an ICS document"); - await act(async () => { - await result.refetch(); - }); - await settled(); - expect(result.errors.some((error) => error.sourceId === "music-room")).toBe( - true, - ); - }); - - it("refetches metadata, feeds and meetings, and recovers from errors", async () => { - mocks.loadFeed.mockRejectedValue(new Error("Offline")); - await render(); - await settled(); - expect(result.isError).toBe(true); - mocks.loadFeed.mockResolvedValue(emptyIcs); - await act(async () => { - await result.refetch(); - }); - await settled(); - expect(result.isError).toBe(false); - expect(mocks.refetchMetadata).toHaveBeenCalledTimes(3); - expect(mocks.refetchMeetings).toHaveBeenCalledOnce(); - expect(result.activities).toEqual([]); - }); - - it("reloads and re-expands for the new day when nowMs crosses local midnight", async () => { - await render(); - await settled(); - expect(result.activities[0].start).toEqual(new Date(2026, 5, 15, 10)); - nowMs = new Date(2026, 5, 16).getTime(); - await render(); - await settled(); - expect(result.activities[0].start).toEqual(new Date(2026, 5, 16, 10)); - expect(mocks.loadFeed).toHaveBeenCalledTimes(4); - }); -}); diff --git a/src/components/calendar/useWhen2MeetCalendarEvents.test.ts b/src/components/calendar/useWhen2MeetCalendarEvents.test.ts deleted file mode 100644 index 671f860b..00000000 --- a/src/components/calendar/useWhen2MeetCalendarEvents.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { act, createElement, useEffect } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - useWhen2MeetCalendarData, - useWhen2MeetCalendarEvents, -} from "./useWhen2MeetCalendarEvents.ts"; -import { toCalendarLocalDate } from "./when2meet-events.ts"; - -const mocks = vi.hoisted(() => ({ - loadMeeting: vi.fn(), - refetchList: vi.fn(), - enabled: true, - listError: false, -})); -vi.mock("@/api/accounts/user.ts", () => ({ - useMe: () => ({ me: mocks.enabled ? { id: "123" } : undefined }), -})); -vi.mock("@/api/when2meet", () => ({ - $when2meet: { - useQuery: (_method: string, path: string) => ({ - data: - path === "/meetings/" - ? [{ slug: "shared" }] - : [{ slug: "shared" }, { slug: "other" }], - isPending: !mocks.enabled, - isError: mocks.listError, - error: mocks.listError ? new Error("List failed") : null, - refetch: mocks.refetchList, - }), - queryOptions: ( - _method: string, - _path: string, - init: { params: { path: { meeting_ref: string } } }, - ) => ({ - queryKey: ["meeting", init.params.path.meeting_ref], - queryFn: () => mocks.loadMeeting(init.params.path.meeting_ref), - }), - }, -})); - -let data: ReturnType; -let legacy: ReturnType; -let root: Root; -let client: QueryClient; -function Harness() { - const queryData = useWhen2MeetCalendarData(); - const calendarEvents = useWhen2MeetCalendarEvents(); - useEffect(() => { - data = queryData; - legacy = calendarEvents; - }, [queryData, calendarEvents]); - return null; -} -async function render() { - await act(async () => - root.render( - createElement(QueryClientProvider, { client }, createElement(Harness)), - ), - ); -} -async function settled() { - await vi.waitFor(async () => { - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - expect(data.isPending).toBe(false); - }); -} -beforeEach(() => { - Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); - mocks.enabled = true; - mocks.listError = false; - mocks.refetchList.mockReset().mockResolvedValue(undefined); - mocks.loadMeeting.mockReset().mockImplementation((slug: string) => - Promise.resolve({ - slug, - name: slug, - selected_time: - slug === "shared" - ? { start: "2026-06-15T12:00:00Z", end: "2026-06-15T13:00:00Z" } - : null, - }), - ); - root = createRoot(document.createElement("div")); - client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); -}); -afterEach(async () => { - await act(async () => root.unmount()); - client.clear(); -}); - -describe("When2Meet query-aware calendar wrapper", () => { - it("deduplicates owned/participating meetings while preserving the legacy selected-event array", async () => { - await render(); - await settled(); - expect(mocks.loadMeeting).toHaveBeenCalledTimes(2); - expect(data.meetings).toHaveLength(2); - expect(legacy).toHaveLength(1); - expect(legacy[0]).toMatchObject({ - id: "when2meet-shared", - title: "shared", - start: toCalendarLocalDate("2026-06-15T12:00:00Z"), - }); - }); - - it("exposes partial detail failures with healthy meetings and retries lists/details", async () => { - mocks.loadMeeting.mockImplementation((slug: string) => - slug === "other" - ? Promise.reject(new Error("Failed")) - : Promise.resolve({ slug, name: slug, selected_time: null }), - ); - await render(); - await settled(); - expect(data.meetings).toHaveLength(1); - expect(data.isError).toBe(true); - expect(data.errors[0].sourceId).toBe("when2meet:other"); - await act(async () => { - await data.refetch(); - }); - expect(mocks.refetchList).toHaveBeenCalledTimes(2); - expect(mocks.loadMeeting).toHaveBeenCalledTimes(4); - }); - - it("exposes list errors and treats disabled queries as not pending", async () => { - mocks.listError = true; - await render(); - await settled(); - expect(data.errors.map((error) => error.sourceId)).toEqual([ - "when2meet:owned", - "when2meet:participating", - ]); - mocks.enabled = false; - mocks.listError = false; - await render(); - expect(data.isPending).toBe(false); - }); -}); diff --git a/src/components/dashboard/BookingsWidget.test.ts b/src/components/dashboard/BookingsWidget.test.ts deleted file mode 100644 index 09fd3c08..00000000 --- a/src/components/dashboard/BookingsWidget.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { act, createElement } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { BookingRecommendation } from "./booking-recommendations.ts"; -import { BookingsWidget } from "./BookingsWidget.tsx"; - -const state = vi.hoisted(() => ({ - nowMs: Date.parse("2026-06-15T09:00:00Z"), - entries: [] as BookingRecommendation[], - isPending: false, - isError: false, - isFetching: false, - refetch: vi.fn().mockResolvedValue(undefined), -})); -vi.mock("./useBookingRecommendations.ts", () => ({ - useBookingRecommendations: () => state, -})); -vi.mock("@/lib/utils/use-now.ts", () => ({ useNowMS: () => state.nowMs })); -vi.mock("@tanstack/react-router", () => ({ - Link: ({ - to, - params, - children, - ...props - }: { - to: string; - params?: { room: string }; - children: import("react").ReactNode; - }) => - createElement( - "a", - { href: params ? to.replace("$room", params.room) : to, ...props }, - children, - ), -})); -let root: Root; -let container: HTMLDivElement; -async function render() { - await act(async () => root.render(createElement(BookingsWidget))); -} -beforeEach(() => { - Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); - state.entries = []; - state.isPending = false; - state.isError = false; - state.isFetching = false; - state.refetch.mockClear(); - container = document.createElement("div"); - root = createRoot(container); -}); -afterEach(async () => { - await act(async () => root.unmount()); -}); - -describe("BookingsWidget", () => { - it("links each room and distinguishes owned, finite free and horizon-only availability", async () => { - state.entries = [ - { - kind: "booked", - roomId: "101", - roomTitle: "Room 101", - booking: { - id: "mine", - room_id: "101", - title: "Study session", - start: new Date(state.nowMs - 60_000).toISOString(), - end: new Date(state.nowMs + 60_000).toISOString(), - attendees: null, - outlook_booking_id: null, - outlook_entry_id: null, - }, - }, - { - kind: "available", - roomId: "102", - roomTitle: "Room 102", - availableUntilMs: state.nowMs + 86_400_000, - nextBookingKnown: false, - }, - { - kind: "available", - roomId: "103", - roomTitle: "Room 103", - availableUntilMs: state.nowMs + 2 * 3600_000, - nextBookingKnown: true, - }, - { - kind: "available", - roomId: "104", - roomTitle: "Room 104", - availableUntilMs: state.nowMs + 2 * 3600_000 + 23 * 60_000, - nextBookingKnown: true, - }, - ]; - await render(); - expect(container.querySelectorAll("li")).toHaveLength(4); - expect(container.textContent).toContain("Today"); - expect(container.textContent).toContain("Available rooms"); - expect(container.textContent).not.toContain("Possible bookings"); - expect(container.textContent).toContain("Room 101"); - expect(container.textContent).toContain("Study session"); - expect(container.textContent).not.toContain("Booked by you"); - expect(container.textContent).toContain("Free for at least 1d"); - expect(container.textContent).toContain("Free for 2h"); - expect(container.textContent).toContain("Free for 2h 23min"); - expect(container.querySelector("li a")?.getAttribute("href")).toBe( - "/room-booking/rooms/101", - ); - expect(container.querySelector("section > a")?.getAttribute("href")).toBe( - "/room-booking", - ); - expect(container.querySelector("section > a")?.textContent).toBe( - "Go to room booking", - ); - }); - it("shows a skeleton while loading and no empty-state filler", async () => { - state.isPending = true; - await render(); - expect(container.querySelectorAll(".skeleton")).toHaveLength(6); - state.isPending = false; - await render(); - expect(container.querySelector("section")?.children).toHaveLength(2); - }); - it("keeps error/retry visible rather than describing unavailable data as free rooms", async () => { - state.isError = true; - await render(); - expect(container.textContent).toContain("could not be verified"); - expect(container.textContent).not.toContain("Free for"); - await act(async () => container.querySelector("button")?.click()); - expect(state.refetch).toHaveBeenCalledOnce(); - }); -}); diff --git a/src/components/dashboard/CalendarWidget.test.ts b/src/components/dashboard/CalendarWidget.test.ts deleted file mode 100644 index dc753ba3..00000000 --- a/src/components/dashboard/CalendarWidget.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { act, createElement } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { CalendarWidget } from "./CalendarWidget.tsx"; -import type { DashboardActivity } from "./activities.ts"; - -const state = vi.hoisted(() => ({ - now: new Date(2026, 0, 10, 12).getTime(), - activities: [] as DashboardActivity[], - isPending: false, - isError: false, - refetch: vi.fn().mockResolvedValue(undefined), -})); -vi.mock("@/components/calendar/useDashboardCalendar.ts", () => ({ - useDashboardCalendar: () => state, -})); -vi.mock("@/lib/utils/use-now.ts", () => ({ useNowMS: () => state.now })); -vi.mock("@tanstack/react-router", () => ({ - Link: ({ - to, - children, - ...props - }: { - to: string; - children: import("react").ReactNode; - }) => createElement("a", { href: to, ...props }, children), -})); -let root: Root; -let container: HTMLDivElement; -async function render() { - await act(async () => root.render(createElement(CalendarWidget))); -} -beforeEach(() => { - Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); - state.isError = false; - state.isPending = false; - state.activities = []; - state.refetch.mockClear(); - container = document.createElement("div"); - root = createRoot(container); -}); -afterEach(async () => { - await act(async () => root.unmount()); -}); - -describe("CalendarWidget", () => { - it("renders five remaining activities, live status and calendar link", async () => { - state.activities = Array.from({ length: 8 }, (_, index) => ({ - id: String(index), - title: `Activity ${index}`, - location: "Room 108", - start: new Date(state.now + (index - 1) * 60_000), - end: new Date(state.now + (index + 1) * 60_000), - })).reverse(); - await render(); - expect(container.querySelectorAll("li")).toHaveLength(5); - expect(container.querySelector("li")?.textContent).toContain("Activity 0"); - expect(container.querySelector(".border-error")).not.toBeNull(); - expect(container.querySelector("a")?.getAttribute("href")).toBe( - "/calendar", - ); - expect(container.querySelector("a")?.textContent).toContain( - "Go to calendar", - ); - }); - it("shows loading but no message or empty schedule container after an empty response", async () => { - state.isPending = true; - await render(); - expect(container.querySelector(".skeleton")).not.toBeNull(); - expect(container.textContent).not.toContain("No more activities"); - state.isPending = false; - await render(); - expect(container.querySelector(".skeleton")).toBeNull(); - expect(container.textContent).not.toContain("No more activities today"); - expect(container.querySelector("section")?.children).toHaveLength(2); - expect(container.querySelector("p")?.textContent).toBe("Sat, Jan 10"); - expect(container.textContent).not.toContain("Today"); - }); - it("does not show an error banner even if loading failed", async () => { - state.isError = true; - await render(); - expect(container.textContent).not.toContain( - "Some activities could not be loaded", - ); - }); -}); diff --git a/src/components/dashboard/CountdownWidget.test.ts b/src/components/dashboard/CountdownWidget.test.ts deleted file mode 100644 index 6910cf0c..00000000 --- a/src/components/dashboard/CountdownWidget.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { createElement } from "react"; -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it, vi } from "vitest"; -import { CountdownWidget } from "./CountdownWidget.tsx"; - -const state = vi.hoisted(() => ({ nowMs: Date.parse("2026-06-15T09:00:00Z") })); -vi.mock("@/lib/utils/use-now.ts", () => ({ useNowMS: () => state.nowMs })); - -describe("restored CountdownWidget", () => { - it("shows the next annual event rather than an expired dated event", () => { - state.nowMs = Date.parse("2026-06-15T09:00:00Z"); - const html = renderToStaticMarkup(createElement(CountdownWidget)); - expect(html).toContain("New Year countdown"); - expect(html).not.toContain("Introduction to"); - expect(html).toContain("days"); - }); - it("handles year rollover and pluralizes a final second", () => { - state.nowMs = Date.parse("2026-12-31T23:59:59+03:00"); - expect(renderToStaticMarkup(createElement(CountdownWidget))).toContain( - "0 days, 0 hours, 0 minutes, 1 second", - ); - state.nowMs += 1000; - const html = renderToStaticMarkup(createElement(CountdownWidget)); - expect(html).toContain("Summer countdown"); - expect(html).toContain("151 days, 0 hours, 0 minutes, 0 seconds"); - }); -}); diff --git a/src/components/dashboard/SportsWidget.test.ts b/src/components/dashboard/SportsWidget.test.ts deleted file mode 100644 index 05ad98bb..00000000 --- a/src/components/dashboard/SportsWidget.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { act, createElement } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { SportsWidget } from "./SportsWidget.tsx"; - -const state = vi.hoisted(() => ({ - token: "test-token" as string | null, - now: new Date(2026, 5, 15, 12).getTime(), - pending: "", - failed: "", - emptyTrainings: false, - zeroHours: false, - refetch: vi.fn().mockResolvedValue(undefined), - queryCalls: [] as { path: string; enabled?: boolean }[], -})); -vi.mock("@/api/helpers/sport-access-token.ts", () => ({ - useMySportAccessToken: () => [state.token], -})); -vi.mock("@/lib/utils/use-now.ts", () => ({ useNowMS: () => state.now })); -vi.mock("@/api/sport", () => ({ - $sport: { - useQuery: ( - _method: string, - path: string, - _init: unknown, - options: { enabled?: boolean; select?: (data: unknown) => unknown } = {}, - ) => { - state.queryCalls.push({ path, enabled: options.enabled }); - let data: unknown; - if (path === "/users/me") data = { user_id: 123 }; - else if (path === "/semesters/current") data = { end: "2026-08-31" }; - else if (path === "/students/{student_id}/hours-summary") - data = { - hours_from_groups: state.zeroHours ? 0 : 10, - self_sport_hours: state.zeroHours ? 0 : 6, - required_hours: 30, - debt: state.zeroHours ? 0 : 3, - }; - else if (path === "/users/me/schedule") - data = Array.from( - { length: state.emptyTrainings ? 0 : 5 }, - (_, index) => ({ - training: { - id: index, - start: new Date(state.now + index * 60_000).toISOString(), - end: new Date(state.now + (index + 30) * 60_000).toISOString(), - is_all_day: false, - training_location: { id: 1, name: "Sport hall" }, - display_name: `Training ${index}`, - sport_name: null, - group_name: null, - training_custom_name: null, - is_accredited: false, - is_club: false, - is_paid: false, - sport_id: null, - group_id: 1, - checkins_count: 0, - max_checkins: 10, - }, - checked_in: index !== 0, - can_check_in: true, - can_grade: false, - can_edit: false, - }), - ); - const isPending = state.pending === path || options.enabled === false; - const isError = state.failed === path; - return { - data: - isPending || isError - ? undefined - : options.select - ? options.select(data) - : data, - isPending, - isError, - isFetching: false, - refetch: state.refetch, - }; - }, - }, -})); -let root: Root; -let container: HTMLDivElement; -async function render() { - await act(async () => root.render(createElement(SportsWidget))); -} -beforeEach(() => { - Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); - state.token = "test-token"; - state.pending = ""; - state.failed = ""; - state.emptyTrainings = false; - state.zeroHours = false; - state.queryCalls = []; - state.refetch.mockClear(); - container = document.createElement("div"); - root = createRoot(container); -}); -afterEach(async () => { - await act(async () => root.unmount()); -}); - -describe("SportsWidget", () => { - it("renders only three checked-in sessions, status, hours/debt, deadline and navigation", async () => { - await render(); - expect(container.querySelectorAll("li")).toHaveLength(3); - expect(container.textContent).not.toContain("Training 0"); - expect(container.textContent).toContain("12:01–12:31"); - expect(container.textContent).toContain("16 / 30 +3 (debt)"); - expect(container.textContent).toContain("77 days left"); - expect(container.querySelector("a")?.getAttribute("href")).toBe( - "https://sport.innopolis.university/profile/", - ); - }); - it("puts zero hours in the header and omits empty schedule and bot messaging", async () => { - state.emptyTrainings = true; - state.zeroHours = true; - await render(); - const header = container.querySelector("h2")?.closest(".flex-wrap"); - expect(header?.textContent).toContain("0 / 30 hours"); - expect(container.querySelectorAll("li")).toHaveLength(0); - expect(container.querySelector("section")?.children).toHaveLength(3); - expect(container.textContent).not.toContain("Your check-ins"); - expect(container.textContent).not.toContain("No upcoming check-ins"); - expect(container.textContent).not.toContain("Sports bot"); - expect(container.querySelectorAll("a")).toHaveLength(1); - expect(container.querySelector("a")?.textContent).toBe("Go to Sports"); - expect(container.querySelector("a span")?.className).toContain( - "icon-[material-symbols--arrow-forward]", - ); - expect(container.textContent).toContain("77 days left"); - }); - it("disables requests and hides the card without a sport token", async () => { - state.token = null; - await render(); - expect(container.textContent).toBe(""); - expect(state.queryCalls.every((query) => query.enabled === false)).toBe( - true, - ); - }); - it("waits for profile before requesting check-ins", async () => { - state.pending = "/users/me"; - await render(); - expect(container.querySelector(".skeleton")).not.toBeNull(); - expect( - state.queryCalls.find((query) => query.path === "/users/me/schedule") - ?.enabled, - ).toBe(false); - }); - it("reports profile failure and allows retry instead of a permanent skeleton", async () => { - state.failed = "/users/me"; - await render(); - expect(container.textContent).toContain( - "Could not load your sport profile", - ); - expect(container.querySelector(".skeleton")).toBeNull(); - await act(async () => container.querySelector("button")?.click()); - expect(state.refetch).toHaveBeenCalledOnce(); - }); - it("keeps check-ins visible if hours fail, and hours visible if check-ins fail", async () => { - state.failed = "/students/{student_id}/hours-summary"; - await render(); - expect(container.querySelectorAll("li")).toHaveLength(3); - expect(container.textContent).toContain( - "Sport hours or deadline could not be loaded", - ); - state.failed = "/users/me/schedule"; - await render(); - expect(container.textContent).toContain( - "Some activities could not be loaded", - ); - expect(container.textContent).toContain("16 / 30"); - }); -}); diff --git a/src/components/dashboard/activities.test.ts b/src/components/dashboard/activities.test.ts deleted file mode 100644 index ed0dd159..00000000 --- a/src/components/dashboard/activities.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - type DashboardActivity, - formatActivityTime, - getActivityStatus, - getUpcomingActivities, -} from "./activities.ts"; - -const now = new Date(2026, 0, 10, 12).getTime(); -function activity( - startMinutes: number, - endMinutes = startMinutes + 60, -): DashboardActivity { - return { - id: String(startMinutes), - title: "Activity", - start: new Date(now + startMinutes * 60_000), - end: new Date(now + endMinutes * 60_000), - }; -} - -describe("dashboard activity selection", () => { - it("keeps ongoing activities, excludes ended and invalid intervals, and sorts without mutating input", () => { - const input = [ - activity(10), - activity(-30), - activity(-60, 0), - activity(0, 0), - activity(20, 10), - { ...activity(0), end: new Date(NaN) }, - ]; - expect(getUpcomingActivities(input, now, 5).map((item) => item.id)).toEqual( - ["-30", "10"], - ); - expect(input[0].id).toBe("10"); - }); - it("does not let all-day reminders hide timed activities or their status", () => { - const reminders = Array.from({ length: 5 }, (_, index) => ({ - ...activity(-720, 720), - id: `reminder-${index}`, - allDay: true, - })); - const upcoming = getUpcomingActivities( - [...reminders, activity(-30), activity(60)], - now, - 5, - ); - expect(upcoming[0].id).toBe("-30"); - expect(upcoming[1].id).toBe("60"); - expect(getActivityStatus(upcoming[0], now)).toBe("In process"); - expect(upcoming).toHaveLength(5); - }); - it("limits calendar and sport lists to five and three", () => { - const input = Array.from({ length: 8 }, (_, index) => activity(index * 10)); - expect(getUpcomingActivities(input, now, 5)).toHaveLength(5); - expect(getUpcomingActivities(input, now, 3)).toHaveLength(3); - }); -}); - -describe("live activity status", () => { - it("transitions exactly at start and end", () => { - const item = activity(43); - expect(getActivityStatus(item, now)).toBe("Starts in 43 min"); - expect(getActivityStatus(item, item.start.getTime())).toBe("In process"); - expect(getActivityStatus(item, item.end.getTime())).toBe("Finished"); - }); - it("rounds up short waits and formats hours and days", () => { - expect(getActivityStatus(activity(0.01), now)).toBe("Starts in 1 min"); - expect(getActivityStatus(activity(60), now)).toBe("Starts in 1 h"); - expect(getActivityStatus(activity(130), now)).toBe("Starts in 2 h 10 min"); - expect(getActivityStatus(activity(1500), now)).toBe("Starts in 1 d 1 h"); - }); - it("handles all-day activities explicitly", () => { - const item = { ...activity(-30), allDay: true }; - expect(getActivityStatus(item, now)).toBe("All day"); - expect(formatActivityTime(item)).toBe("All day"); - }); - it("formats time as start–end even when activities cross midnight", () => { - const item = { - ...activity(0), - start: new Date(2026, 0, 10, 23), - end: new Date(2026, 0, 11, 1), - }; - expect(formatActivityTime(item)).toBe("23:00–01:00"); - }); -}); - -describe("schedule day headers", () => { - it("formats ordinal numbers accurately", async () => { - const { getOrdinalSuffix } = await import("./activities.ts"); - expect(getOrdinalSuffix(1)).toBe("1st"); - expect(getOrdinalSuffix(2)).toBe("2nd"); - expect(getOrdinalSuffix(3)).toBe("3rd"); - expect(getOrdinalSuffix(4)).toBe("4th"); - expect(getOrdinalSuffix(11)).toBe("11th"); - expect(getOrdinalSuffix(12)).toBe("12th"); - expect(getOrdinalSuffix(13)).toBe("13th"); - expect(getOrdinalSuffix(21)).toBe("21st"); - expect(getOrdinalSuffix(22)).toBe("22nd"); - expect(getOrdinalSuffix(23)).toBe("23rd"); - expect(getOrdinalSuffix(24)).toBe("24th"); - expect(getOrdinalSuffix(31)).toBe("31st"); - }); - - it("formats Today, Tomorrow and other day headers with full month and ordinal", async () => { - const { formatScheduleDayHeader } = await import("./activities.ts"); - const testNow = new Date(2026, 6, 24, 12).getTime(); // Monday, July 24, 2026 - const todayDate = new Date(2026, 6, 24, 15); - const tomorrowDate = new Date(2026, 6, 25, 10); - const futureDate = new Date(2026, 6, 26, 10); - - expect(formatScheduleDayHeader(todayDate, testNow)).toBe( - "Today, Fri, 24th of July", - ); - expect(formatScheduleDayHeader(tomorrowDate, testNow)).toBe( - "Tomorrow, Sat, 25th of July", - ); - expect(formatScheduleDayHeader(futureDate, testNow)).toBe( - "Sun, 26th of July", - ); - }); -}); diff --git a/src/components/dashboard/booking-recommendations.test.ts b/src/components/dashboard/booking-recommendations.test.ts deleted file mode 100644 index 2ede46c9..00000000 --- a/src/components/dashboard/booking-recommendations.test.ts +++ /dev/null @@ -1,314 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { roomBookingTypes } from "@/api/room-booking"; -import { - BookingStatus, - RoomAccess_levelAnyOf0 as Access, -} from "@/api/room-booking/types.ts"; -import { - buildBookingRecommendations, - type BookingRecommendationsInput, -} from "./booking-recommendations.ts"; - -const nowMs = Date.parse("2026-05-11T09:00:00Z"); // Monday noon in Moscow. -const minute = 60_000; -const rangeEndMs = nowMs + 14 * 24 * 60 * minute; - -function room( - id: string, - overrides: Partial = {}, -): roomBookingTypes.SchemaRoom { - return { - id, - title: `Room ${id}`, - short_name: id, - access_level: Access.yellow, - restrict_daytime: false, - ...overrides, - }; -} - -function booking( - roomId: string, - startMinutes: number, - endMinutes: number, - overrides: Partial = {}, -): roomBookingTypes.SchemaBooking { - return { - id: `${roomId}:${startMinutes}:${endMinutes}`, - room_id: roomId, - start: new Date(nowMs + startMinutes * minute).toISOString(), - end: new Date(nowMs + endMinutes * minute).toISOString(), - title: "Meeting", - outlook_booking_id: null, - outlook_entry_id: null, - attendees: null, - ...overrides, - }; -} - -function recommend(overrides: Partial = {}) { - return buildBookingRecommendations({ - rooms: [room("A")], - myBookings: [], - allBookings: [], - myAccessList: [], - role: { is_student: true }, - nowMs, - rangeEndMs, - ...overrides, - }); -} - -function attendee( - roomId: string | null, - status: roomBookingTypes.SchemaAttendee["status"], -): roomBookingTypes.SchemaAttendee { - return { email: "room@example.com", assosiated_room_id: roomId, status }; -} - -describe("booking recommendations", () => { - it("ranks free-now rooms by longest window and resolves ties by title/id", () => { - const rooms = [room("D"), room("C"), room("B"), room("A")]; - rooms[0].title = rooms[1].title = "First"; - const allBookings = [booking("B", 10, 30), booking("A", 30, 50)]; - const originalRooms = structuredClone(rooms); - const originalBookings = structuredClone(allBookings); - expect( - recommend({ rooms, allBookings }).map((item) => item.roomId), - ).toEqual(["C", "D", "A", "B"]); - expect(rooms).toEqual(originalRooms); - expect(allBookings).toEqual(originalBookings); - }); - - it("puts current and upcoming own rooms first, deduplicates rooms and ignores ended bookings", () => { - const current = booking("C", -10, 10); - const futureA = booking("A", 20, 40); - const result = recommend({ - rooms: [room("A"), room("B"), room("C"), room("D")], - myBookings: [ - futureA, - booking("B", -20, 0), - booking("C", -5, 15), - current, - current, - ], - }); - expect(result.map((item) => [item.kind, item.roomId])).toEqual([ - ["booked", "C"], - ["booked", "A"], - ["available", "B"], - ["available", "D"], - ]); - expect(result[0]).toMatchObject({ booking: current }); - expect(result[1]).toMatchObject({ booking: futureA }); - }); - - it("caps the entire result at five even with more than five own rooms", () => { - const rooms = Array.from({ length: 8 }, (_, i) => room(String(i))); - expect(recommend({ rooms })).toHaveLength(5); - const myBookings = rooms.map((item) => booking(item.id, -5, 5)); - const result = recommend({ rooms, myBookings: myBookings.reverse() }); - expect(result.map((item) => [item.kind, item.roomId])).toEqual( - ["0", "1", "2", "3", "4"].map((id) => ["booked", id]), - ); - }); - - it("uses inclusive start and exclusive end for both own and other occupancy", () => { - expect(recommend({ allBookings: [booking("A", 0, 10)] })).toEqual([]); - expect(recommend({ allBookings: [booking("A", -10, 0)] })[0].kind).toBe( - "available", - ); - expect(recommend({ myBookings: [booking("A", 0, 10)] })[0].kind).toBe( - "booked", - ); - expect(recommend({ myBookings: [booking("A", -10, 0)] })[0].kind).toBe( - "available", - ); - }); - - it("unions own and all intervals rather than overwriting stale duplicate IDs", () => { - expect( - recommend({ - allBookings: [ - booking("A", 10, 20, { id: "same" }), - booking("A", 30, 40, { id: "same" }), - ], - })[0], - ).toMatchObject({ availableUntilMs: nowMs + 10 * minute }); - expect( - recommend({ - allBookings: [ - booking("A", -10, 20, { id: "same" }), - booking("A", 30, 40, { id: "same" }), - ], - }), - ).toEqual([]); - }); - - it("ignores only an explicit Decline from the matching room attendee", () => { - const declined = booking("A", -10, 10, { - attendees: [attendee("A", BookingStatus.Decline)], - }); - expect( - recommend({ myBookings: [declined], allBookings: [declined] })[0], - ).toMatchObject({ kind: "available", nextBookingKnown: false }); - for (const attendees of [ - null, - [attendee("A", null)], - [attendee("A", BookingStatus.Unknown)], - [attendee("A", BookingStatus.Tentative)], - [attendee("A", BookingStatus.Accept)], - [attendee("B", BookingStatus.Decline)], - [attendee(null, BookingStatus.Decline)], - ]) { - expect( - recommend({ allBookings: [booking("A", -10, 10, { attendees })] }), - ).toEqual([]); - } - }); - - it.each([ - { start: "not-a-date" }, - { end: "not-a-date" }, - { start: "2026-05-11T08:00:00" }, - { start: "2026-02-30T08:00:00Z" }, - { start: "2026-05-10T24:00:00Z" }, - { start: new Date(nowMs + minute).toISOString() }, - { end: new Date(nowMs - minute).toISOString() }, - ])( - "excludes unsafe intervals from own display and free suggestions: %j", - (invalid) => { - const badBooking = booking("A", 0, 1, invalid); - for (const source of ["myBookings", "allBookings"] as const) { - expect(recommend({ [source]: [badBooking] })).toEqual([]); - } - }, - ); - - it("does not let an invalid duplicate disappear behind a valid future record", () => { - expect( - recommend({ - myBookings: [booking("A", 20, 30)], - allBookings: [booking("A", 20, 30, { end: "invalid" })], - }), - ).toEqual([]); - }); - - it("bounds unknown next bookings by the queried horizon, never infinity", () => { - for (const startMinutes of [14 * 24 * 60, 15 * 24 * 60]) { - expect( - recommend({ - allBookings: [booking("A", startMinutes, startMinutes + 60)], - })[0], - ).toMatchObject({ - availableUntilMs: rangeEndMs, - nextBookingKnown: false, - }); - } - expect(recommend()[0]).toMatchObject({ - availableUntilMs: rangeEndMs, - nextBookingKnown: false, - }); - expect( - recommend({ - allBookings: [booking("A", 14 * 24 * 60 - 1, 14 * 24 * 60)], - })[0], - ).toMatchObject({ - availableUntilMs: rangeEndMs - minute, - nextBookingKnown: true, - }); - }); - - it.each([NaN, Infinity, nowMs, nowMs - 1])( - "suppresses availability for invalid/expired horizon %s but keeps current own", - (rangeEndMs) => { - expect(recommend({ rangeEndMs })).toEqual([]); - expect( - recommend({ - rooms: [], - role: {}, - rangeEndMs, - myBookings: [booking("A", -10, 10)], - })[0], - ).toMatchObject({ kind: "booked", roomId: "A", roomTitle: "A" }); - }, - ); - - it("rejects invalid now even when own bookings are present", () => { - expect( - recommend({ nowMs: NaN, myBookings: [booking("A", -10, 10)] }), - ).toEqual([]); - }); -}); - -describe("backend-aligned room access", () => { - const rooms = [ - room("yellow"), - room("daytime", { restrict_daytime: true }), - room("red", { access_level: Access.red }), - room("special", { access_level: Access.special }), - room("null", { access_level: null }), - room("missing", { access_level: undefined }), - ]; - - it.each([ - [{ is_student: true }, ["yellow"]], - [{ is_staff: true }, ["daytime", "red", "yellow"]], - [{ is_student: true, is_staff: true }, ["daytime", "red", "yellow"]], - [{}, []], - [{ is_student: false, is_staff: false }, []], - ] satisfies [BookingRecommendationsInput["role"], string[]][])( - "enforces role and known access levels: %j", - (role, expected) => { - expect(recommend({ rooms, role }).map((item) => item.roomId)).toEqual( - expected, - ); - }, - ); - - it.each([{ is_student: true }, { is_staff: true }, {}])( - "special access overrides room restrictions, but never missing roles: %j", - (role) => { - for (const item of rooms) { - expect( - recommend({ rooms: [item], myAccessList: [item], role }), - ).toHaveLength(role.is_student || role.is_staff ? 1 : 0); - } - }, - ); - - it.each([ - ["2026-05-11T04:59:59Z", true], // Monday 07:59:59 Moscow. - ["2026-05-11T05:00:00Z", false], // 08:00. - ["2026-05-11T15:59:59Z", false], // 18:59:59. - ["2026-05-11T16:00:00Z", true], // 19:00. - ["2026-05-15T21:00:00Z", true], // Saturday in Moscow, Friday in America. - ["2026-05-16T09:00:00Z", true], - ["2026-05-17T09:00:00Z", true], - ["2026-05-18T05:00:00Z", false], - ])( - "uses Moscow weekday and hour, independent of local TZ: %s", - (instant, allowed) => { - const result = recommend({ - rooms: [room("A", { restrict_daytime: true })], - nowMs: Date.parse(instant), - }); - expect(result).toHaveLength(allowed ? 1 : 0); - }, - ); - - it("returns occupancy windows rather than applying the three-hour duration limit", () => { - expect( - recommend({ - rooms: [room("A", { restrict_daytime: true })], - nowMs: Date.parse("2026-05-11T04:59:59Z"), - })[0], - ).toMatchObject({ availableUntilMs: rangeEndMs, nextBookingKnown: false }); - }); - - it("keeps current own rooms regardless of current access permission", () => { - expect( - recommend({ rooms, role: {}, myBookings: [booking("red", -1, 1)] })[0], - ).toMatchObject({ kind: "booked", roomId: "red" }); - }); -}); diff --git a/src/components/dashboard/sport-activities.test.ts b/src/components/dashboard/sport-activities.test.ts deleted file mode 100644 index 33cae502..00000000 --- a/src/components/dashboard/sport-activities.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import type { SchemaTrainingInfoPersonalSchema } from "@/api/sport/types.ts"; -import { describe, expect, it } from "vitest"; -import { getUpcomingActivities } from "./activities.ts"; -import { - getSportCalendarRange, - normalizeSportActivities, -} from "./sport-activities.ts"; - -const now = new Date("2026-06-15T10:00:00+03:00").getTime(); -function training( - id: number, - checkedIn: boolean, - startOffset = 0, -): SchemaTrainingInfoPersonalSchema { - return { - training: { - id, - start: new Date(now + startOffset * 60_000).toISOString(), - end: new Date(now + (startOffset + 60) * 60_000).toISOString(), - is_all_day: false, - training_location: { id: 1, name: "Sport complex" }, - display_name: `Training ${id}`, - sport_name: null, - group_name: null, - training_custom_name: null, - is_accredited: false, - is_club: false, - is_paid: false, - sport_id: null, - group_id: 1, - checkins_count: 0, - max_checkins: 10, - }, - checked_in: checkedIn, - can_check_in: true, - can_grade: false, - can_edit: false, - }; -} - -describe("sport calendar adapter", () => { - it("maps only checked-in trainings to dashboard activities", () => { - const activities = normalizeSportActivities([ - training(1, true), - training(2, false), - ]); - expect(activities).toHaveLength(1); - expect(activities[0]).toMatchObject({ - id: "1", - title: "Training 1", - location: "Sport complex", - }); - expect(activities[0].start.getTime()).toBe(now); - }); - - it("accepts empty lists and nullable locations", () => { - expect(normalizeSportActivities([])).toEqual([]); - const [activity] = normalizeSportActivities([ - { - ...training(1, true), - training: { ...training(1, true).training, training_location: null }, - }, - ]); - expect(activity.location).toBeUndefined(); - }); - - it("excludes ended sessions, keeps ongoing ones and limits checked-in list to three", () => { - const response = [ - training(1, true, 180), - training(2, true, -30), - training(3, true, -60), - training(4, true, 120), - training(5, false), - training(6, true, 60), - ]; - expect( - getUpcomingActivities(normalizeSportActivities(response), now, 3).map( - (item) => item.id, - ), - ).toEqual(["2", "6", "4"]); - }); -}); - -describe("sport query range", () => { - it("retains an overnight check-in when the backend marks only starts in range", () => { - const time = new Date(2026, 5, 16, 0, 10).getTime(); - const start = new Date(2026, 5, 15, 23, 30); - const end = new Date(2026, 5, 16, 0, 30); - const range = getSportCalendarRange(time); - const response = [ - { - ...training(1, true), - training: { - ...training(1, true).training, - start: start.toISOString(), - end: end.toISOString(), - }, - checked_in: - start >= new Date(range.start) && start <= new Date(range.end), - }, - ]; - expect( - getUpcomingActivities(normalizeSportActivities(response), time, 3), - ).toHaveLength(1); - }); - - it("is stable within a local day and includes earlier starts for ongoing check-ins", () => { - const time = new Date(2026, 5, 15, 14).getTime(); - const range = getSportCalendarRange(time); - expect(range).toEqual(getSportCalendarRange(time + 60_000)); - expect(new Date(range.start)).toEqual(new Date(2026, 5, 14)); - expect(new Date(range.end)).toEqual(new Date(2026, 5, 22)); - expect( - getSportCalendarRange(new Date(2026, 5, 16).getTime()).start, - ).not.toBe(range.start); - }); -}); diff --git a/src/components/dashboard/useBookingRecommendations.test.ts b/src/components/dashboard/useBookingRecommendations.test.ts deleted file mode 100644 index a3d3f42b..00000000 --- a/src/components/dashboard/useBookingRecommendations.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { act, createElement, useEffect } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - getBookingDashboardRange, - useBookingRecommendations, -} from "./useBookingRecommendations.ts"; - -const state = vi.hoisted(() => ({ - nowMs: Date.parse("2026-06-15T12:00:00+03:00"), - failed: "", - pending: "", - noRooms: false, - stale: false, - staleMetadata: "", - calls: [] as { path: string; init: unknown; options: unknown }[], - refetch: vi.fn().mockResolvedValue(undefined), -})); -vi.mock("@/api/accounts", () => ({ - $accounts: { - useQuery: () => ({ - data: { innopolis_info: { is_student: true, is_staff: false } }, - isSuccess: true, - isPending: false, - isError: false, - isFetching: false, - dataUpdatedAt: - state.nowMs - (state.staleMetadata === "/users/me" ? 7 * 60_000 : 0), - refetch: state.refetch, - }), - }, -})); -vi.mock("@/api/room-booking", () => ({ - $roomBooking: { - useQuery: ( - _method: string, - path: string, - init?: unknown, - options?: { enabled?: boolean }, - ) => { - state.calls.push({ path, init, options }); - const booking = { - id: "mine", - room_id: "101", - title: "Study", - start: new Date(state.nowMs - 60_000).toISOString(), - end: new Date(state.nowMs + 60_000).toISOString(), - outlook_booking_id: null, - outlook_entry_id: null, - attendees: null, - }; - const rooms = ["101", "102", "103"].map((id) => ({ - id, - title: `Room ${id}`, - short_name: id, - access_level: "yellow", - restrict_daytime: false, - })); - const data = - path === "/rooms/" - ? state.noRooms - ? [] - : rooms - : path === "/bookings/my" - ? [booking] - : path === "/bookings/" - ? [booking] - : []; - const isError = state.failed === path; - const isPending = state.pending === path || options?.enabled === false; - return { - data: isError || isPending ? undefined : data, - isError, - isPending, - isSuccess: !isError && !isPending, - isFetching: false, - dataUpdatedAt: - state.nowMs - - (state.staleMetadata === path - ? 7 * 60_000 - : state.stale - ? 120_000 - : 0), - refetch: state.refetch, - }; - }, - }, -})); -let root: Root; -let result: ReturnType; -function Harness() { - const value = useBookingRecommendations(state.nowMs); - useEffect(() => { - result = value; - }, [value]); - return null; -} -async function render() { - await act(async () => root.render(createElement(Harness))); -} -beforeEach(() => { - Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); - state.failed = ""; - state.pending = ""; - state.noRooms = false; - state.stale = false; - state.staleMetadata = ""; - state.calls = []; - state.refetch.mockClear(); - root = createRoot(document.createElement("div")); -}); -afterEach(async () => { - await act(async () => root.unmount()); -}); - -describe("useBookingRecommendations", () => { - it("combines current own bookings with free rooms from the bulk availability query", async () => { - await render(); - expect(result.entries.map((entry) => entry.kind)).toEqual([ - "booked", - "available", - "available", - ]); - expect(result.isError).toBe(false); - expect( - state.calls.find((call) => call.path === "/bookings/")?.init, - ).toEqual({ - params: { - query: { ...getBookingDashboardRange(state.nowMs), include_red: true }, - }, - }); - }); - it.each(["/bookings/", "/rooms/my-access-list", "/rooms/"])( - "does not invent free rooms if %s fails; retains own booking", - async (path) => { - state.failed = path; - await render(); - expect(result.isError).toBe(true); - expect(result.entries.map((entry) => entry.kind)).toEqual(["booked"]); - await result.refetch(); - expect(state.refetch).toHaveBeenCalled(); - }, - ); - it("waits for personal bookings before filling slots with suggestions", async () => { - state.pending = "/bookings/my"; - await render(); - expect(result.isPending).toBe(true); - expect(result.entries).toEqual([]); - }); - it("does not infer free rooms when personal bookings failed", async () => { - state.failed = "/bookings/my"; - await render(); - expect(result.isError).toBe(true); - expect(result.entries).toEqual([]); - }); - it("suppresses expired availability snapshots but preserves personal bookings", async () => { - state.stale = true; - await render(); - expect(result.isError).toBe(true); - expect(result.entries.map((entry) => entry.kind)).toEqual(["booked"]); - }); - it.each(["/users/me", "/rooms/", "/rooms/my-access-list"])( - "suppresses free suggestions when %s metadata expires even with fresh availability", - async (path) => { - state.staleMetadata = path; - await render(); - expect(result.isError).toBe(true); - expect(result.entries.map((entry) => entry.kind)).toEqual(["booked"]); - state.staleMetadata = ""; - await render(); - expect(result.isError).toBe(false); - expect(result.entries).toHaveLength(3); - }, - ); - it("handles zero rooms without a disabled-query permanent loading state", async () => { - state.noRooms = true; - await render(); - expect(result.isPending).toBe(false); - expect(result.entries[0]).toMatchObject({ - kind: "booked", - roomTitle: "101", - }); - expect( - state.calls.find((call) => call.path === "/bookings/")?.options, - ).toMatchObject({ enabled: false }); - }); - it("keeps query range stable during a day and advances at midnight", () => { - const now = new Date(2026, 5, 15, 12).getTime(); - expect(getBookingDashboardRange(now)).toEqual( - getBookingDashboardRange(now + 60_000), - ); - expect(getBookingDashboardRange(now)).toEqual({ - start: new Date(2026, 5, 14).toISOString(), - end: new Date(2026, 5, 29).toISOString(), - }); - expect( - getBookingDashboardRange(new Date(2026, 5, 16).getTime()), - ).not.toEqual(getBookingDashboardRange(now)); - }); -}); From 6244c0b7692ae5683bd37d887b7c9d84c7c3694d Mon Sep 17 00:00:00 2001 From: SerafimSoldatov Date: Wed, 14 Oct 2026 23:40:53 +0300 Subject: [PATCH 11/13] refactor(dashboard): revert academic calendar widget to skeleton loading --- .../dashboard/AcademicCalendarWidget.tsx | 69 +++++-------------- .../dashboard/academic-calendar.tsx | 27 +++----- 2 files changed, 25 insertions(+), 71 deletions(-) diff --git a/src/components/dashboard/AcademicCalendarWidget.tsx b/src/components/dashboard/AcademicCalendarWidget.tsx index 1e73f115..18c9353f 100644 --- a/src/components/dashboard/AcademicCalendarWidget.tsx +++ b/src/components/dashboard/AcademicCalendarWidget.tsx @@ -2,7 +2,8 @@ import { AcademicCalendar, useMyAcademicCalendar, } from "@/components/dashboard/academic-calendar.tsx"; -import { useState } from "react"; +import { useEffect } from "react"; +import { useLocalStorage } from "usehooks-ts"; export function AcademicCalendarCard({ academicCalendar, @@ -33,61 +34,23 @@ export function AcademicCalendarCard({ } export function AcademicCalendarWidget() { - const { academicCalendar, isPending, isError, refetch } = - useMyAcademicCalendar(); - const [isRetrying, setIsRetrying] = useState(false); + const [widgetShown, setWidgetShown] = useLocalStorage( + "widget-academic-calendar", + false, + ); - async function handleRetry() { - setIsRetrying(true); - try { - await refetch(); - } finally { - setIsRetrying(false); - } - } + const { academicCalendar, isPending } = useMyAcademicCalendar(); - if (academicCalendar) { - return ; - } + useEffect(() => { + setWidgetShown((v) => (v && isPending) || !!academicCalendar); + }, [setWidgetShown, isPending, academicCalendar]); - if (isPending && !isError) { - return
; + if (!academicCalendar) { + if (!widgetShown) return null; + return ( +
+ ); } - return ( -
- -
-

Academic calendar

- {isError ? ( -
- Could not load your academic calendar. - -
- ) : ( -

- No academic calendar found for your study group. -

- )} - - View academic calendar on Eduwiki - - -
-
- ); + return ; } diff --git a/src/components/dashboard/academic-calendar.tsx b/src/components/dashboard/academic-calendar.tsx index 1fc09b33..87983aec 100644 --- a/src/components/dashboard/academic-calendar.tsx +++ b/src/components/dashboard/academic-calendar.tsx @@ -93,18 +93,14 @@ export function findAcademicCalendarByGroups( } export function useMyAcademicCalendar() { - const { - data: eventGroups, - isPending: isPending1, - isError: isError1, - refetch: refetchGroups, - } = $schedule.useQuery("get", "/event-groups/"); - const { - data: predefined, - isPending: isPending2, - isError: isError2, - refetch: refetchPredefined, - } = $schedule.useQuery("get", "/users/me/predefined"); + const { data: eventGroups, isPending: isPending1 } = $schedule.useQuery( + "get", + "/event-groups/", + ); + const { data: predefined, isPending: isPending2 } = $schedule.useQuery( + "get", + "/users/me/predefined", + ); const groups = predefined?.event_groups ?.map( @@ -114,10 +110,5 @@ export function useMyAcademicCalendar() { ?.filter((v) => v) as string[]; const academicCalendar = findAcademicCalendarByGroups(groups || []); - return { - academicCalendar, - isPending: isPending1 || isPending2, - isError: isError1 || isError2, - refetch: () => Promise.all([refetchGroups(), refetchPredefined()]), - }; + return { academicCalendar, isPending: isPending1 || isPending2 }; } From 96ff61420477e87fb09b7307adef1505edc2c3bd Mon Sep 17 00:00:00 2001 From: LainsKnight <103204098+SerafimSoldatov@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:57:52 +0300 Subject: [PATCH 12/13] Potential fix for pull request finding 'CodeQL / Incomplete string escaping or encoding' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- src/components/dashboard/CountdownWidget.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/dashboard/CountdownWidget.tsx b/src/components/dashboard/CountdownWidget.tsx index 925f9dda..3e7d863d 100644 --- a/src/components/dashboard/CountdownWidget.tsx +++ b/src/components/dashboard/CountdownWidget.tsx @@ -38,7 +38,7 @@ function getNextCountdownEvent(now: Date): CountdownEvent | undefined { .flatMap((template) => { const dates = template.dateTemplate.startsWith("*") ? [now.getFullYear(), now.getFullYear() + 1].map((year) => - template.dateTemplate.replace("*", String(year)), + template.dateTemplate.replaceAll("*", String(year)), ) : [template.dateTemplate]; return dates.map((date) => ({ ...template, date: new Date(date) })); From 52ae3842ee0ad55d854050078be2d0f8a65e1a8d Mon Sep 17 00:00:00 2001 From: SerafimSoldatov Date: Thu, 15 Oct 2026 01:28:05 +0300 Subject: [PATCH 13/13] fix(general): resolve post-rebase integration and validation issues Preserve calendar selection and error handling, include the full sport check-in window, and ignore declined room reservations. Correct conditional dashboard columns and hook dependencies. Restore Vitest coverage for legacy when2meet assertions and fix mobile-bar typing. Align the Playwright fixture host with its base URL and fix the invalid placeholder color utility. --- src/app/styles.css | 2 +- .../calendar/useDashboardCalendar.ts | 20 +- src/components/dashboard/CalendarWidget.tsx | 4 +- src/components/dashboard/DashboardPage.tsx | 2 +- .../dashboard/booking-recommendations.ts | 7 +- src/components/dashboard/sport-activities.ts | 3 +- src/components/room-booking/tv/RoomTvPage.tsx | 6 +- .../timetable/CreateClassModal.tsx | 14 +- src/components/web-print/PrintPage.tsx | 17 +- src/components/web-print/ScanPage.tsx | 14 +- .../when2meet/MeetingMobileBar.test.ts | 4 +- src/components/when2meet/MeetingMobileBar.tsx | 4 +- .../when2meet/utils/api-slots.test.ts | 106 ++-- .../when2meet/utils/slots-heatmap.test.ts | 497 +++++++++--------- vite.e2e.config.ts | 1 + 15 files changed, 366 insertions(+), 335 deletions(-) diff --git a/src/app/styles.css b/src/app/styles.css index 4a550cea..a8c59bac 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -136,7 +136,7 @@ input::placeholder, textarea::placeholder { - color: var(--color-base/50); /* Make input placeholder gray */ + @apply text-base-content/50; } } diff --git a/src/components/calendar/useDashboardCalendar.ts b/src/components/calendar/useDashboardCalendar.ts index 5141be26..2d9a1315 100644 --- a/src/components/calendar/useDashboardCalendar.ts +++ b/src/components/calendar/useDashboardCalendar.ts @@ -69,8 +69,7 @@ export function useDashboardCalendar(nowMs: number) { groupsQuery.data, { excludeSport: true }, ).filter( - (source) => - source.kind !== "workshops" && source.kind !== "room-bookings", + (source) => source.kind !== "events" && source.kind !== "room-bookings", ); }, [userQuery.data, groupsQuery.data, predefinedQuery.data]); @@ -84,8 +83,21 @@ export function useDashboardCalendar(nowMs: number) { staleTime: 60_000, // Stable selectors avoid re-expanding old recurring series on every tick. // TanStack exposes parser failures as query errors alongside HTTP errors. - select: (ics: string) => - normalizeDashboardIcs(ics, source.id, dayStartMs), + select: (ics: string) => { + const activities = normalizeDashboardIcs( + ics, + source.id, + dayStartMs, + ); + if (source.kind !== "group" || !source.excludeEnglish) + return activities; + return activities.filter((activity) => { + const summary = activity.title.trim().toLowerCase(); + return !["english", "foreign language", "иностранный язык"].some( + (name) => summary.includes(name), + ); + }); + }, }; }), [sources, dayStartMs], diff --git a/src/components/dashboard/CalendarWidget.tsx b/src/components/dashboard/CalendarWidget.tsx index 68b2462d..2015de18 100644 --- a/src/components/dashboard/CalendarWidget.tsx +++ b/src/components/dashboard/CalendarWidget.tsx @@ -6,7 +6,8 @@ import { getUpcomingActivities } from "./activities.ts"; export function CalendarWidget() { const nowMs = useNowMS(true); - const { activities, isPending, refetch } = useDashboardCalendar(nowMs); + const { activities, isPending, isError, refetch } = + useDashboardCalendar(nowMs); const upcoming = getUpcomingActivities(activities, nowMs, 5); return ( @@ -28,6 +29,7 @@ export function CalendarWidget() { activities={upcoming} nowMs={nowMs} isPending={isPending} + isError={isError} onRetry={refetch} />
-
+
diff --git a/src/components/dashboard/booking-recommendations.ts b/src/components/dashboard/booking-recommendations.ts index 92cfc0bb..89f6145e 100644 --- a/src/components/dashboard/booking-recommendations.ts +++ b/src/components/dashboard/booking-recommendations.ts @@ -1,4 +1,5 @@ import type { roomBookingTypes } from "@/api/room-booking"; +import { BookingStatus } from "@/api/room-booking/types.ts"; export type BookingRecommendation = | { @@ -130,10 +131,11 @@ export function buildBookingRecommendations({ const occupancy = new Map(); function addBooking(booking: roomBookingTypes.SchemaBooking, mine: boolean) { if ( + booking.room_response === BookingStatus.Decline || booking.attendees?.some( (attendee) => attendee.assosiated_room_id === booking.room_id && - attendee.status === "Decline", + attendee.status === BookingStatus.Decline, ) ) { return; @@ -183,10 +185,11 @@ export function buildBookingRecommendations({ for (const booking of myBookings) { if ( + booking.room_response === BookingStatus.Decline || booking.attendees?.some( (attendee) => attendee.assosiated_room_id === booking.room_id && - attendee.status === "Decline", + attendee.status === BookingStatus.Decline, ) ) { continue; diff --git a/src/components/dashboard/sport-activities.ts b/src/components/dashboard/sport-activities.ts index 6dfe67c1..e549bd41 100644 --- a/src/components/dashboard/sport-activities.ts +++ b/src/components/dashboard/sport-activities.ts @@ -24,7 +24,8 @@ export function getSportCalendarRange(nowMs: number) { const start = new Date(nowMs); start.setHours(0, 0, 0, 0); const end = new Date(start); - end.setDate(end.getDate() + 7); + // Include the entire seventh day ahead while keeping the query day-stable. + end.setDate(end.getDate() + 8); // The API flags check-ins by training START within the query range, even for // overlapping sessions. Include yesterday to preserve overnight check-ins. start.setDate(start.getDate() - 1); diff --git a/src/components/room-booking/tv/RoomTvPage.tsx b/src/components/room-booking/tv/RoomTvPage.tsx index e9c0d66c..6cd67eba 100644 --- a/src/components/room-booking/tv/RoomTvPage.tsx +++ b/src/components/room-booking/tv/RoomTvPage.tsx @@ -10,8 +10,8 @@ import { sanitizeBookingTitle } from "../utils.ts"; import { TvCalendar } from "./TvCalendar.tsx"; import { TvRoomQr } from "./TvRoomQr.tsx"; -function getTodayRange() { - const start = new Date(); +function getTodayRange(now: Date) { + const start = new Date(now.getTime()); start.setHours(0, 0, 0, 0); const end = new Date(start.getTime() + 7 * T.Day); return { start, end }; @@ -45,7 +45,7 @@ function findCurrentBooking( export function RoomTvPage({ id }: { id: string }) { const calendarRef = useRef(null); const [now, setNow] = useState(() => new Date()); - const { start, end } = useMemo(() => getTodayRange(), [now]); + const { start, end } = useMemo(() => getTodayRange(now), [now]); const { data: bookings, diff --git a/src/components/schedule-assistant/timetable/CreateClassModal.tsx b/src/components/schedule-assistant/timetable/CreateClassModal.tsx index c8d0808d..1c70f348 100644 --- a/src/components/schedule-assistant/timetable/CreateClassModal.tsx +++ b/src/components/schedule-assistant/timetable/CreateClassModal.tsx @@ -533,6 +533,12 @@ export function CreateClassModal({ selectedCourse?.section_code, ]); + const draftWeeklyStartTime = weeklySlots[seriesBaselineWeekly]?.start_time; + const draftWeeklyWeekday = weeklySlots[seriesBaselineWeekly]?.weekday; + const draftOccurrenceDate = occurrences[seriesBaselineOccurrences]?.date; + const draftOccurrenceStartTime = + occurrences[seriesBaselineOccurrences]?.start_time; + useEffect(() => { if (!open || !audienceValue.length) return; const draftIndex = @@ -683,10 +689,10 @@ export function CreateClassModal({ selectedCourse?.instructors, seriesBaselineOccurrences, seriesBaselineWeekly, - weeklySlots[seriesBaselineWeekly]?.start_time, - weeklySlots[seriesBaselineWeekly]?.weekday, - occurrences[seriesBaselineOccurrences]?.date, - occurrences[seriesBaselineOccurrences]?.start_time, + draftWeeklyStartTime, + draftWeeklyWeekday, + draftOccurrenceDate, + draftOccurrenceStartTime, ]); function handleClose() { diff --git a/src/components/web-print/PrintPage.tsx b/src/components/web-print/PrintPage.tsx index 297ddc26..7d13d665 100644 --- a/src/components/web-print/PrintPage.tsx +++ b/src/components/web-print/PrintPage.tsx @@ -117,13 +117,16 @@ export function PrintPage() { "/print/cancel", ); - function getPrinterStatus(cupsName: string) { - return rawStatuses?.find((status) => status.printer.cups_name === cupsName); - } + const getPrinterStatus = useCallback( + (cupsName: string) => + rawStatuses?.find((status) => status.printer.cups_name === cupsName), + [rawStatuses], + ); - function isPrinterOffline(cupsName: string) { - return getPrinterStatus(cupsName)?.offline === true; - } + const isPrinterOffline = useCallback( + (cupsName: string) => getPrinterStatus(cupsName)?.offline === true, + [getPrinterStatus], + ); useEffect(() => { if (!rawPrinters?.length) return; @@ -141,7 +144,7 @@ export function PrintPage() { setSession({ printerName: (firstOnline ?? rawPrinters[0]).cups_name, }); - }, [rawPrinters, rawStatuses, printerName, setSession]); + }, [rawPrinters, isPrinterOffline, printerName, setSession]); const actualPapersCount = preparedFilePagesCount ? calcPrintJobActualPapersCount( diff --git a/src/components/web-print/ScanPage.tsx b/src/components/web-print/ScanPage.tsx index 63632efa..680a4cc2 100644 --- a/src/components/web-print/ScanPage.tsx +++ b/src/components/web-print/ScanPage.tsx @@ -17,7 +17,7 @@ import { ScanningOptionsSides, } from "@/api/printers/types.ts"; import { cn } from "@/lib/ui/cn"; -import { useEffect, useState, type ReactNode } from "react"; +import { useCallback, useEffect, useState, type ReactNode } from "react"; /** Fake scan duration: ~6.5s per 100 dpi + optional warmup. */ const SCAN_FAKE_MS_PER_100_DPI = 6500; @@ -85,12 +85,12 @@ export function ScanPage() { "/scan/manual/delete_file", ); - function isScannerOffline(name: string) { - return ( + const isScannerOffline = useCallback( + (name: string) => rawStatuses?.find((status) => status.scanner.name === name)?.offline === - true - ); - } + true, + [rawStatuses], + ); useEffect(() => { if (!rawScanners?.length) return; @@ -106,7 +106,7 @@ export function ScanPage() { (scanner) => !isScannerOffline(scanner.name), ); setSession({ scannerName: (firstOnline ?? rawScanners[0]).name }); - }, [rawScanners, rawStatuses, scannerName, setSession]); + }, [rawScanners, isScannerOffline, scannerName, setSession]); const selectedScannerOffline = !!scannerName && isScannerOffline(scannerName); diff --git a/src/components/when2meet/MeetingMobileBar.test.ts b/src/components/when2meet/MeetingMobileBar.test.ts index 4f977fe7..e10f086b 100644 --- a/src/components/when2meet/MeetingMobileBar.test.ts +++ b/src/components/when2meet/MeetingMobileBar.test.ts @@ -16,14 +16,14 @@ describe("MeetingMobileBar", () => { participantRowWidth = 200; participantTextWidth = 300; vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockImplementation( - function () { + function (this: HTMLElement) { return this.hasAttribute("data-slot-participants-row") ? participantRowWidth : 0; }, ); vi.spyOn(HTMLElement.prototype, "scrollWidth", "get").mockImplementation( - function () { + function (this: HTMLElement) { return this.hasAttribute("data-slot-participants-measure") ? participantTextWidth : 0; diff --git a/src/components/when2meet/MeetingMobileBar.tsx b/src/components/when2meet/MeetingMobileBar.tsx index 6dbb1e31..c75c50a2 100644 --- a/src/components/when2meet/MeetingMobileBar.tsx +++ b/src/components/when2meet/MeetingMobileBar.tsx @@ -66,7 +66,7 @@ export function MeetingMobileBar({ return; } - function updateOverflowMeasurement() { + const updateOverflowMeasurement = () => { const isOverflowing = participantMeasure.scrollWidth > participantRow.clientWidth; @@ -80,7 +80,7 @@ export function MeetingMobileBar({ return { key: participantMeasurementKey, isOverflowing }; }); - } + }; updateOverflowMeasurement(); diff --git a/src/components/when2meet/utils/api-slots.test.ts b/src/components/when2meet/utils/api-slots.test.ts index 2cd33e62..487f3f72 100644 --- a/src/components/when2meet/utils/api-slots.test.ts +++ b/src/components/when2meet/utils/api-slots.test.ts @@ -1,61 +1,57 @@ +import { describe, expect, it } from "vitest"; import { deriveTimeRangeFromSlots, meetingTimeToSlotKeys, slotKeysToMeetingTime, } from "./api-slots.ts"; -function assert(condition: boolean, message: string) { - if (!condition) { - throw new Error(message); - } -} - -assert( - deriveTimeRangeFromSlots([]).start === "08:00" && - deriveTimeRangeFromSlots([]).end === "19:00", - "Expected default time range for empty slots", -); - -assert( - deriveTimeRangeFromSlots(["10:00", "09:30", "10:30"]).start === "09:30" && - deriveTimeRangeFromSlots(["10:00", "09:30", "10:30"]).end === "11:00", - "Expected derived range from slot times", -); - -assert( - deriveTimeRangeFromSlots(["23:00", "23:30"]).end === "24:00", - "Expected end time to cap at 24:00", -); - -const selectedMeetingTime = slotKeysToMeetingTime( - ["2026-07-03_10:00"], - ["10:00", "10:30", "11:00"], -); - -assert( - selectedMeetingTime?.start === "2026-07-03T07:00:00.000Z", - "Expected selected meeting start to preserve Europe/Moscow wall-clock time", -); - -assert( - selectedMeetingTime?.end === "2026-07-03T07:30:00.000Z", - "Expected single selected slot to end after exactly 30 minutes", -); - -const selectedSlotKeys = meetingTimeToSlotKeys( - { - start: "2026-07-03T07:00:00.000Z", - end: "2026-07-03T08:00:00.000Z", - }, - ["2026-07-03"], - ["10:00", "10:30", "11:00"], -); - -assert( - selectedSlotKeys.has("2026-07-03_10:00") && - selectedSlotKeys.has("2026-07-03_10:30") && - !selectedSlotKeys.has("2026-07-03_11:00"), - "Expected backend meeting time to map back to covered 30-minute slots", -); - -console.log("api-slots.test.ts passed"); +describe("deriveTimeRangeFromSlots", () => { + it("returns the default time range for empty slots", () => { + expect(deriveTimeRangeFromSlots([])).toEqual({ + start: "08:00", + end: "19:00", + }); + }); + + it("derives the range from unordered slot times", () => { + expect(deriveTimeRangeFromSlots(["10:00", "09:30", "10:30"])).toEqual({ + start: "09:30", + end: "11:00", + }); + }); + + it("caps the end time at 24:00", () => { + expect(deriveTimeRangeFromSlots(["23:00", "23:30"]).end).toBe("24:00"); + }); +}); + +describe("slotKeysToMeetingTime", () => { + it("preserves Europe/Moscow wall-clock time for a single 30-minute slot", () => { + const selectedMeetingTime = slotKeysToMeetingTime( + ["2026-07-03_10:00"], + ["10:00", "10:30", "11:00"], + ); + + expect(selectedMeetingTime).toEqual({ + start: "2026-07-03T07:00:00.000Z", + end: "2026-07-03T07:30:00.000Z", + }); + }); +}); + +describe("meetingTimeToSlotKeys", () => { + it("maps backend meeting time to covered 30-minute slots with an exclusive end", () => { + const selectedSlotKeys = meetingTimeToSlotKeys( + { + start: "2026-07-03T07:00:00.000Z", + end: "2026-07-03T08:00:00.000Z", + }, + ["2026-07-03"], + ["10:00", "10:30", "11:00"], + ); + + expect(selectedSlotKeys).toEqual( + new Set(["2026-07-03_10:00", "2026-07-03_10:30"]), + ); + }); +}); diff --git a/src/components/when2meet/utils/slots-heatmap.test.ts b/src/components/when2meet/utils/slots-heatmap.test.ts index cfe9e24d..5dc6dff2 100644 --- a/src/components/when2meet/utils/slots-heatmap.test.ts +++ b/src/components/when2meet/utils/slots-heatmap.test.ts @@ -1,3 +1,4 @@ +import { describe, expect, it } from "vitest"; import { getSlotAvailabilityRatio, getSlotHeatmapAppearance, @@ -14,252 +15,258 @@ import { userHasExplicitAvailability, } from "./participants.ts"; -function assert(condition: boolean, message: string) { - if (!condition) { - throw new Error(message); - } -} +describe("getSlotAvailabilityRatio", () => { + it.each([ + [0, 10, 0], + [4, 8, 0.5], + [10, 10, 1], + [12, 10, 1], + ])("returns ratio %s/%s clamped to %s", (count, maxCount, expectedRatio) => { + expect(getSlotAvailabilityRatio(count, maxCount)).toBe(expectedRatio); + }); +}); -function expectPercent(count: number, maxCount: number, percent: number) { - const appearance = getSlotHeatmapAppearance(count, maxCount); +describe("getSlotHeatmapAppearance", () => { + it("keeps the base background without an inline style for empty slots", () => { + const emptyAppearance = getSlotHeatmapAppearance(0, 10); - assert( - appearance.style?.backgroundColor === + expect(emptyAppearance.className).toBe("bg-base-100 hover:bg-primary/10"); + expect(emptyAppearance.style).toBeUndefined(); + }); + + it.each([ + [1, 1, 75], + [1, 2, 38], + [1, 3, 25], + [1, 10, 8], + [3, 10, 23], + [5, 10, 38], + [7, 10, 53], + [10, 10, 75], + [3, 7, 32], + [50, 100, 38], + [99, 100, 74], + ])("mixes %s/%s availability at %s%% opacity", (count, maxCount, percent) => { + const appearance = getSlotHeatmapAppearance(count, maxCount); + + expect(appearance.style?.backgroundColor).toBe( `color-mix(in oklch, var(--color-primary) ${percent}%, transparent)`, - `count=${count}/${maxCount}: expected ${percent}% mix, got ${appearance.style?.backgroundColor}`, - ); -} - -assert(getSlotAvailabilityRatio(0, 10) === 0, "zero count → ratio 0"); -assert(getSlotAvailabilityRatio(4, 8) === 0.5, "4/8 → ratio 0.5"); -assert(getSlotAvailabilityRatio(10, 10) === 1, "full availability → ratio 1"); -assert( - getSlotAvailabilityRatio(12, 10) === 1, - "count above max clamps to ratio 1", -); - -const emptyAppearance = getSlotHeatmapAppearance(0, 10); -assert( - emptyAppearance.className === "bg-base-100 hover:bg-primary/10", - "empty slot keeps base background class", -); -assert(!emptyAppearance.style, "empty slot has no inline style"); - -expectPercent(1, 1, 75); -expectPercent(1, 2, 38); -expectPercent(1, 3, 25); -expectPercent(1, 10, 8); -expectPercent(3, 10, 23); -expectPercent(5, 10, 38); -expectPercent(7, 10, 53); -expectPercent(10, 10, 75); -expectPercent(3, 7, 32); -expectPercent(50, 100, 38); -expectPercent(99, 100, 74); - -const fullAppearance = getSlotHeatmapAppearance(25, 25); -assert( - fullAppearance.className === "text-primary-content", - "full availability uses primary content text color", -); - -const partialAppearance = getSlotHeatmapAppearance(12, 25); -assert( - !partialAppearance.className, - "partial availability does not force primary content text color", -); - -assert( - getSlotHeatmapAppearanceColorblindSafe(0, 10).className === "bg-base-100", - "colorblind-safe empty slot uses base background", -); -assert( - !getSlotHeatmapAppearanceColorblindSafe(0, 10).style, - "colorblind-safe empty slot has no inline style", -); -assert( - getSlotHeatmapAppearanceColorblindSafe(3, 10).className === "bg-primary/20", - "colorblind-safe low availability uses stepped class", -); -assert( - getSlotHeatmapAppearanceColorblindSafe(10, 10).className === - "bg-primary/75 text-primary-content", - "colorblind-safe full availability uses capped primary opacity", -); - -const users = [ - { id: "a", name: "A", slots: new Set(["2026-07-16_09:00"]) }, - { - id: "b", - name: "B", - slots: new Set(["2026-07-16_09:00", "2026-07-16_10:00"]), - }, - { id: "c", name: "C", slots: new Set(["2026-07-16_10:00"]) }, -]; -const allowedSlots = new Set([ - "2026-07-16_09:00", - "2026-07-16_09:30", - "2026-07-16_10:00", -]); -const viewedUserIds = new Set(["a", "b", "c"]); -const bestIntersection = getBestIntersection( - users, - ["2026-07-16"], - ["09:00", "09:30", "10:00"], - allowedSlots, - viewedUserIds, -); - -assert(bestIntersection.maxCount === 2, "best intersection count is 2"); -assert( - bestIntersection.slotKeys.has("2026-07-16_09:00"), - "09:00 is tied for best intersection", -); -assert( - bestIntersection.slotKeys.has("2026-07-16_10:00"), - "10:00 is tied for best intersection", -); -assert( - bestIntersection.slotKeys.size === 2, - "only tied slots are marked as best intersection", -); - -const strictIntersection = getIntersectionAtMinParticipants( - users, - ["2026-07-16"], - ["09:00", "09:30", "10:00"], - allowedSlots, - viewedUserIds, - 2, -); -assert( - strictIntersection.slotKeys.size === 2, - "maximum participant threshold keeps all tied best slots", -); - -const allAvailableSlots = getIntersectionAtMinParticipants( - users, - ["2026-07-16"], - ["09:00", "09:30", "10:00"], - allowedSlots, - viewedUserIds, - 1, -); -assert( - allAvailableSlots.slotKeys.has("2026-07-16_09:00") && - allAvailableSlots.slotKeys.has("2026-07-16_10:00") && - !allAvailableSlots.slotKeys.has("2026-07-16_09:30"), - "minimum participant threshold includes every non-empty slot", -); - -const emptyIntersection = getIntersectionAtMinParticipants( - [], - ["2026-07-16"], - ["09:00"], - new Set(["2026-07-16_09:00"]), - new Set(), - 1, -); -assert( - emptyIntersection.maxCount === 0 && emptyIntersection.slotKeys.size === 0, - "empty availability has no matching slots", -); - -const usersWithNonResponder = [ - ...users, - { id: "d", name: "D", slots: new Set() }, -]; -const bestIntersectionWithoutNonResponder = getBestIntersection( - usersWithNonResponder, - ["2026-07-16"], - ["09:00", "09:30", "10:00"], - allowedSlots, - new Set(["a", "b", "c", "d"]), -); - -assert( - bestIntersectionWithoutNonResponder.maxCount === bestIntersection.maxCount, - "participants without explicit slots do not increase intersection counts", -); - -assert( - !userHasExplicitAvailability( - { id: "d", name: "D", slots: new Set() }, - null, - new Set(), - ), - "empty availability is not explicit", -); -assert( - countExplicitSlotAvailability( - usersWithNonResponder, - new Set(["a", "b", "c", "d"]), - "2026-07-16_09:00", - null, - new Set(), - ) === 2, - "slot count ignores participants without explicit availability", -); - -assert( - countExplicitSlotAvailability( - usersWithNonResponder, - new Set(["a", "b", "c", "d"]), - "2026-07-16_09:00", - "a", - new Set(["2026-07-16_09:00"]), - "a", - ) === 1, - "slot count can exclude the participant whose availability is being edited", -); - -const explicitParticipants = getParticipantsWithExplicitSlot( - usersWithNonResponder, - "2026-07-16_09:00", - null, - new Set(), -); -assert( - explicitParticipants.length === 2, - "slot detail includes only participants with explicit availability", -); -assert( - explicitParticipants.every((user) => user.id === "a" || user.id === "b"), - "slot detail lists the correct participant names", -); - -const dates = ["2026-07-16", "2026-07-17", "2026-07-18", "2026-07-19"]; -const times = ["09:00", "09:30", "10:00", "10:30"]; - -assert( - getSlotKeysBetween("2026-07-16_09:00", "2026-07-16_09:00", dates, times).join( - ",", - ) === "2026-07-16_09:00", - "same slot returns itself", -); - -assert( - getSlotKeysBetween("2026-07-16_09:00", "2026-07-18_09:00", dates, times).join( - ",", - ) === "2026-07-16_09:00,2026-07-17_09:00,2026-07-18_09:00", - "horizontal drag fills skipped date cells", -); - -assert( - getSlotKeysBetween("2026-07-16_09:00", "2026-07-16_10:30", dates, times).join( - ",", - ) === "2026-07-16_09:00,2026-07-16_09:30,2026-07-16_10:00,2026-07-16_10:30", - "vertical drag fills skipped time cells", -); - -assert( - getSlotKeysBetween( + ); + }); + + it("uses primary content text color for full availability", () => { + expect(getSlotHeatmapAppearance(25, 25).className).toBe( + "text-primary-content", + ); + }); + + it("does not force primary content text color for partial availability", () => { + expect(getSlotHeatmapAppearance(12, 25).className).toBeUndefined(); + }); +}); + +describe("getSlotHeatmapAppearanceColorblindSafe", () => { + it("uses the base background without an inline style for empty slots", () => { + const emptyAppearance = getSlotHeatmapAppearanceColorblindSafe(0, 10); + + expect(emptyAppearance.className).toBe("bg-base-100"); + expect(emptyAppearance.style).toBeUndefined(); + }); + + it("uses a stepped class for low availability", () => { + expect(getSlotHeatmapAppearanceColorblindSafe(3, 10).className).toBe( + "bg-primary/20", + ); + }); + + it("caps primary opacity for full availability", () => { + expect(getSlotHeatmapAppearanceColorblindSafe(10, 10).className).toBe( + "bg-primary/75 text-primary-content", + ); + }); +}); + +describe("slot intersections and explicit availability", () => { + const users = [ + { id: "a", name: "A", slots: new Set(["2026-07-16_09:00"]) }, + { + id: "b", + name: "B", + slots: new Set(["2026-07-16_09:00", "2026-07-16_10:00"]), + }, + { id: "c", name: "C", slots: new Set(["2026-07-16_10:00"]) }, + ]; + const allowedSlots = new Set([ "2026-07-16_09:00", - "2026-07-18_10:30", - dates, - times, - ).includes("2026-07-17_09:30"), - "diagonal drag fills intermediate cells", -); - -console.log("when2meet heatmap opacity tests passed"); + "2026-07-16_09:30", + "2026-07-16_10:00", + ]); + const viewedUserIds = new Set(["a", "b", "c"]); + const usersWithNonResponder = [ + ...users, + { id: "d", name: "D", slots: new Set() }, + ]; + + it("marks only the tied slots with the best intersection count", () => { + const bestIntersection = getBestIntersection( + users, + ["2026-07-16"], + ["09:00", "09:30", "10:00"], + allowedSlots, + viewedUserIds, + ); + + expect(bestIntersection.maxCount).toBe(2); + expect(bestIntersection.slotKeys).toEqual( + new Set(["2026-07-16_09:00", "2026-07-16_10:00"]), + ); + }); + + it("keeps all tied best slots at the maximum participant threshold", () => { + const strictIntersection = getIntersectionAtMinParticipants( + users, + ["2026-07-16"], + ["09:00", "09:30", "10:00"], + allowedSlots, + viewedUserIds, + 2, + ); + + expect(strictIntersection.slotKeys).toEqual( + new Set(["2026-07-16_09:00", "2026-07-16_10:00"]), + ); + }); + + it("includes every non-empty slot at the minimum participant threshold", () => { + const allAvailableSlots = getIntersectionAtMinParticipants( + users, + ["2026-07-16"], + ["09:00", "09:30", "10:00"], + allowedSlots, + viewedUserIds, + 1, + ); + + expect(allAvailableSlots.slotKeys).toEqual( + new Set(["2026-07-16_09:00", "2026-07-16_10:00"]), + ); + }); + + it("returns no matching slots for empty availability", () => { + const emptyIntersection = getIntersectionAtMinParticipants( + [], + ["2026-07-16"], + ["09:00"], + new Set(["2026-07-16_09:00"]), + new Set(), + 1, + ); + + expect(emptyIntersection.maxCount).toBe(0); + expect(emptyIntersection.slotKeys.size).toBe(0); + }); + + it("does not increase intersection counts for participants without explicit slots", () => { + const bestIntersection = getBestIntersection( + users, + ["2026-07-16"], + ["09:00", "09:30", "10:00"], + allowedSlots, + viewedUserIds, + ); + const bestIntersectionWithoutNonResponder = getBestIntersection( + usersWithNonResponder, + ["2026-07-16"], + ["09:00", "09:30", "10:00"], + allowedSlots, + new Set(["a", "b", "c", "d"]), + ); + + expect(bestIntersectionWithoutNonResponder.maxCount).toBe( + bestIntersection.maxCount, + ); + }); + + it("does not treat empty availability as explicit", () => { + expect( + userHasExplicitAvailability( + { id: "d", name: "D", slots: new Set() }, + null, + new Set(), + ), + ).toBe(false); + }); + + it("ignores participants without explicit availability in slot counts", () => { + expect( + countExplicitSlotAvailability( + usersWithNonResponder, + new Set(["a", "b", "c", "d"]), + "2026-07-16_09:00", + null, + new Set(), + ), + ).toBe(2); + }); + + it("can exclude the participant whose availability is being edited", () => { + expect( + countExplicitSlotAvailability( + usersWithNonResponder, + new Set(["a", "b", "c", "d"]), + "2026-07-16_09:00", + "a", + new Set(["2026-07-16_09:00"]), + "a", + ), + ).toBe(1); + }); + + it("includes only the correct participants with explicit availability in slot details", () => { + const explicitParticipants = getParticipantsWithExplicitSlot( + usersWithNonResponder, + "2026-07-16_09:00", + null, + new Set(), + ); + + expect(explicitParticipants).toHaveLength(2); + expect( + explicitParticipants.every((user) => user.id === "a" || user.id === "b"), + ).toBe(true); + }); +}); + +describe("getSlotKeysBetween", () => { + const dates = ["2026-07-16", "2026-07-17", "2026-07-18", "2026-07-19"]; + const times = ["09:00", "09:30", "10:00", "10:30"]; + + it("returns the same slot itself", () => { + expect( + getSlotKeysBetween("2026-07-16_09:00", "2026-07-16_09:00", dates, times), + ).toEqual(["2026-07-16_09:00"]); + }); + + it("fills skipped date cells during a horizontal drag", () => { + expect( + getSlotKeysBetween("2026-07-16_09:00", "2026-07-18_09:00", dates, times), + ).toEqual(["2026-07-16_09:00", "2026-07-17_09:00", "2026-07-18_09:00"]); + }); + + it("fills skipped time cells during a vertical drag", () => { + expect( + getSlotKeysBetween("2026-07-16_09:00", "2026-07-16_10:30", dates, times), + ).toEqual([ + "2026-07-16_09:00", + "2026-07-16_09:30", + "2026-07-16_10:00", + "2026-07-16_10:30", + ]); + }); + + it("fills intermediate cells during a diagonal drag", () => { + expect( + getSlotKeysBetween("2026-07-16_09:00", "2026-07-18_10:30", dates, times), + ).toContain("2026-07-17_09:30"); + }); +}); diff --git a/vite.e2e.config.ts b/vite.e2e.config.ts index 83537aeb..3ce99871 100644 --- a/vite.e2e.config.ts +++ b/vite.e2e.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ }, }, server: { + host: "127.0.0.1", port: 5199, strictPort: true, },