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/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/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.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.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.ts b/src/components/calendar/useDashboardCalendar.ts new file mode 100644 index 00000000..2d9a1315 --- /dev/null +++ b/src/components/calendar/useDashboardCalendar.ts @@ -0,0 +1,157 @@ +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. + * Feed failures are non-blocking; only source-selection failures surface as errors. + */ +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 }, + ).filter( + (source) => source.kind !== "events" && source.kind !== "room-bookings", + ); + }, [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) => { + 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], + ); + 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.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..c4820867 --- /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, + formatScheduleDayHeader, +} from "./activities.ts"; + +/** Shared presentation only: each widget owns its data and visible time range. */ +export function ActivitySchedule({ + activities, + nowMs, + isPending, + isError = false, + onRetry, +}: { + activities: DashboardActivity[]; + nowMs: number; + isPending: boolean; + isError?: boolean; + onRetry?: () => Promise; +}) { + const [isRetrying, setIsRetrying] = useState(false); + 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(); + } finally { + setIsRetrying(false); + } + } + + if (!isPending && !isError && activities.length === 0) return null; + + return ( +
+ {isError && ( +
+ Some activities could not be loaded. + +
+ )} + {isPending && activities.length === 0 ? ( +
+
+ {[0, 1, 2].map((index) => ( +
+
+
+
+ ))} +
+ ) : ( + [...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} + + )} +
    +
  • + ); + })} +
+
+ )) + )} +
+ ); +} 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/CalendarWidget.tsx b/src/components/dashboard/CalendarWidget.tsx new file mode 100644 index 00000000..2015de18 --- /dev/null +++ b/src/components/dashboard/CalendarWidget.tsx @@ -0,0 +1,44 @@ +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

+

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

+
+
+ + + Go to calendar + + +
+ ); +} diff --git a/src/components/dashboard/CountdownWidget.tsx b/src/components/dashboard/CountdownWidget.tsx index 0921073d..3e7d863d 100644 --- a/src/components/dashboard/CountdownWidget.tsx +++ b/src/components/dashboard/CountdownWidget.tsx @@ -1,5 +1,6 @@ -import { useNowMS } from "@/lib/utils/use-now.ts"; 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 = { @@ -7,19 +8,9 @@ type CountdownEventTemplate = { name: string | ReactNode; iconClass: string; }; +type CountdownEvent = CountdownEventTemplate & { date: Date }; -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. - */ +// A leading * repeats the event annually, in the timestamp's timezone. const countdownEventTemplates: CountdownEventTemplate[] = [ { dateTemplate: "*-01-01T00:00:00+03:00", @@ -34,107 +25,60 @@ const countdownEventTemplates: CountdownEventTemplate[] = [ { dateTemplate: "2026-02-06T19:00+03:00", name: ( - <> - Introduction to 108 countdown - - Introduction to - - 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; +function getNextCountdownEvent(now: Date): CountdownEvent | undefined { + return countdownEventTemplates + .flatMap((template) => { + const dates = template.dateTemplate.startsWith("*") + ? [now.getFullYear(), now.getFullYear() + 1].map((year) => + template.dateTemplate.replaceAll("*", 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]; } -/** - * 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" : ""}` - ); -}; +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; - } - - const deltaTimeSeconds = (nextEvent.date.getTime() - nowMs) / 1000; + if (!nextEvent) return null; return ( -
+