From 5e432ddd1b5c3e986a268efa6d09b9ca60db16d8 Mon Sep 17 00:00:00 2001 From: Joonas Nivala Date: Tue, 16 Sep 2025 20:59:57 +0300 Subject: [PATCH 01/15] refactor function into utils --- src/components/ServiceStatus.jsx | 26 +++++++++++++++++++++++--- src/components/SiteSearch.jsx | 5 +---- src/utils/modalUtils.jsx | 21 +++++++++++++++++++++ src/utils/textUtils.js | 3 +++ 4 files changed, 48 insertions(+), 7 deletions(-) create mode 100644 src/utils/modalUtils.jsx create mode 100644 src/utils/textUtils.js diff --git a/src/components/ServiceStatus.jsx b/src/components/ServiceStatus.jsx index da3270d16b4d..4cee07bc8bf2 100644 --- a/src/components/ServiceStatus.jsx +++ b/src/components/ServiceStatus.jsx @@ -1,9 +1,11 @@ import React, { useState } from 'react' import { useStatus } from '../hooks/useStatus' +import { useBookings } from '../hooks/useBookings.jsx'; import { mdiInformation, mdiClose, mdiAlert } from '@mdi/js'; -import { CCard, CCardTitle, CCardContent, CIcon } from '@cscfi/csc-ui-react'; +import { CCard, CCardTitle, CCardContent, CIcon, CButton } from '@cscfi/csc-ui-react'; import { StatusModal } from './StatusModal/StatusModal'; +import { BookingModal } from './bookingCalendar.jsx'; const StatusCard = (props) => { const isOnline = props.health; @@ -39,6 +41,7 @@ const StatusCard = (props) => { export const ServiceStatus = (props) => { const { status: statusList } = useStatus("https://fiqci-backend.2.rahtiapp.fi/devices/healthcheck"); + const { bookingData: bookingData } = useBookings("http://localhost:3000/bookings") const qcs = props["quantum-computers"] || []; const devicesWithStatus = (qcs.length === 0 || !Array.isArray(statusList)) @@ -51,7 +54,8 @@ export const ServiceStatus = (props) => { }; }); - const [modalOpen, setModalOpen] = useState(false); + const [bookingModalOpen, setBookingModalOpen] = useState(false) + const [modalOpen, setModalOpen] = useState(false); const [modalProps, setModalProps] = useState({}); const handleCardClick = (qc) => { setModalProps({ ...qc, devicesWithStatus }); @@ -87,11 +91,27 @@ export const ServiceStatus = (props) => { {props.alert?.type ? props.alert?.text : 'Loading...'}

-
+
+

Calendar

+

+ VTT devices can at times be reserved. At these times the queue will be paused. + Reservations can be viewed from this calendar. +

+ setBookingModalOpen(true)}>Open Calendar +
+ +

Devices

+
{devicesWithStatus.map((qc, index) => ( handleCardClick(qc)} /> ))} + +
+ {bookingModalOpen && ( + + )} + {modalOpen && ( )} diff --git a/src/components/SiteSearch.jsx b/src/components/SiteSearch.jsx index d73bb7dd0d99..8b10196ae0bd 100644 --- a/src/components/SiteSearch.jsx +++ b/src/components/SiteSearch.jsx @@ -6,6 +6,7 @@ import { CCardTitle, CCardContent, CCardActions } from '@cscfi/csc-ui-react'; import { prependBaseURL } from '../utils/url'; +import { capitalizeFirstLetter } from "../utils/textUtils"; const style = { "--_c-button-font-size": 14, @@ -107,10 +108,6 @@ function findItemByRef(ref, store) { return Object.values(store).flat().find(item => item.key.toLowerCase() === normalizedRef); } -function capitalizeFirstLetter(val) { - return String(val).charAt(0).toUpperCase() + String(val).slice(1); -} - const SearchBar = ({ setResults, setQuery, query }) => { const handleSearchBar = (e) => { const input = e.target.value; diff --git a/src/utils/modalUtils.jsx b/src/utils/modalUtils.jsx new file mode 100644 index 000000000000..dd420a2efeab --- /dev/null +++ b/src/utils/modalUtils.jsx @@ -0,0 +1,21 @@ +import { useState, useEffect } from "react"; + +export const useWindowSize = () => { + const [width, setWidth] = useState( + typeof window !== 'undefined' ? window.innerWidth : 0 + ); + + useEffect(() => { + const onResize = () => setWidth(window.innerWidth); + + window.addEventListener('resize', onResize); + // In case the window was resized before the listener attached + onResize(); + + return () => { + window.removeEventListener('resize', onResize); + }; + }, []); + + return { width }; +} \ No newline at end of file diff --git a/src/utils/textUtils.js b/src/utils/textUtils.js new file mode 100644 index 000000000000..f50d8b52cd8d --- /dev/null +++ b/src/utils/textUtils.js @@ -0,0 +1,3 @@ +export const capitalizeFirstLetter = (val) => { + return String(val).charAt(0).toUpperCase() + String(val).slice(1); +} \ No newline at end of file From e2f6eaf80b27c045f2442be3b0c2174234dfcd7f Mon Sep 17 00:00:00 2001 From: Joonas Nivala Date: Tue, 16 Sep 2025 21:00:59 +0300 Subject: [PATCH 02/15] add react-calendar and date-fns --- package-lock.json | 105 +++++++++++++++++++++++++++++++++++++++++++++- package.json | 4 +- 2 files changed, 107 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6b3600e31b78..da8294878228 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,13 +8,15 @@ "dependencies": { "@cscfi/csc-ui": "^2.3.0", "@cscfi/csc-ui-react": "^2.3.0", + "date-fns": "^4.1.0", "dompurify": "^3.2.4", "framer-motion": "^12.23.9", "front-matter": "^4.0.2", "gray-matter": "^4.0.3", "katex": "^0.16.21", "lunr": "^2.3.9", - "prismjs": "^1.30.0" + "prismjs": "^1.30.0", + "react-calendar": "^6.0.0" }, "devDependencies": { "@babel/core": "^7.26.0", @@ -2263,6 +2265,15 @@ } } }, + "node_modules/@wojtekmaj/date-utils": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@wojtekmaj/date-utils/-/date-utils-2.0.2.tgz", + "integrity": "sha512-Do66mSlSNifFFuo3l9gNKfRMSFi26CRuQMsDJuuKO/ekrDWuTTtE4ZQxoFCUOG+NgxnpSeBq/k5TY8ZseEzLpA==", + "license": "MIT", + "funding": { + "url": "https://github.com/wojtekmaj/date-utils?sponsor=1" + } + }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", @@ -2909,6 +2920,15 @@ "node": ">=6" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/code-block-writer": { "version": "13.0.3", "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", @@ -3114,6 +3134,16 @@ "license": "MIT", "peer": true }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", @@ -3730,6 +3760,18 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-user-locale": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/get-user-locale/-/get-user-locale-3.0.0.tgz", + "integrity": "sha512-iJfHSmdYV39UUBw7Jq6GJzeJxUr4U+S03qdhVuDsR9gCEnfbqLy9gYDJFBJQL1riqolFUKQvx36mEkp2iGgJ3g==", + "license": "MIT", + "dependencies": { + "memoize": "^10.0.0" + }, + "funding": { + "url": "https://github.com/wojtekmaj/get-user-locale?sponsor=1" + } + }, "node_modules/glob": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", @@ -4316,6 +4358,21 @@ "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==", "license": "MIT" }, + "node_modules/memoize": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/memoize/-/memoize-10.1.0.tgz", + "integrity": "sha512-MMbFhJzh4Jlg/poq1si90XRlTZRDHVqdlz2mPyGJ6kqMpyHUyVpDd5gpFAvVehW64+RA1eKE9Yt8aSLY7w2Kgg==", + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/memoize?sponsor=1" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -4368,6 +4425,18 @@ "node": ">= 0.6" } }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -5068,6 +5137,31 @@ "node": ">=0.10.0" } }, + "node_modules/react-calendar": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/react-calendar/-/react-calendar-6.0.0.tgz", + "integrity": "sha512-6wqaki3Us0DNDjZDr0DYIzhSFprNoy4FdPT9Pjy5aD2hJJVjtJwmdMT9VmrTUo949nlk35BOxehThxX62RkuRQ==", + "license": "MIT", + "dependencies": { + "@wojtekmaj/date-utils": "^2.0.2", + "clsx": "^2.0.0", + "get-user-locale": "^3.0.0", + "warning": "^4.0.0" + }, + "funding": { + "url": "https://github.com/wojtekmaj/react-calendar?sponsor=1" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-dom": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", @@ -6028,6 +6122,15 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/watchpack": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", diff --git a/package.json b/package.json index 4be2da2c4b69..6076744a295a 100644 --- a/package.json +++ b/package.json @@ -52,13 +52,15 @@ "dependencies": { "@cscfi/csc-ui": "^2.3.0", "@cscfi/csc-ui-react": "^2.3.0", + "date-fns": "^4.1.0", "dompurify": "^3.2.4", "framer-motion": "^12.23.9", "front-matter": "^4.0.2", "gray-matter": "^4.0.3", "katex": "^0.16.21", "lunr": "^2.3.9", - "prismjs": "^1.30.0" + "prismjs": "^1.30.0", + "react-calendar": "^6.0.0" }, "private": "true" } From d34d3d5132981a2f6da69442d5041e15f462bae9 Mon Sep 17 00:00:00 2001 From: Joonas Nivala Date: Tue, 16 Sep 2025 21:01:15 +0300 Subject: [PATCH 03/15] add calendar styles --- src/stylesheets/Calendar.css | 160 +++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 src/stylesheets/Calendar.css diff --git a/src/stylesheets/Calendar.css b/src/stylesheets/Calendar.css new file mode 100644 index 000000000000..5640fbc523e3 --- /dev/null +++ b/src/stylesheets/Calendar.css @@ -0,0 +1,160 @@ +.react-calendar { + width: 40%; + height: fit-content; + background: white; + border: 1px solid #e5e7eb; + font-family: 'Arial', 'Helvetica', sans-serif; + line-height: 1.125em; +} + +.react-calendar--doubleView { + width: 700px; +} + +.react-calendar--doubleView .react-calendar__viewContainer { + display: flex; + margin: -0.5em; +} + +.react-calendar--doubleView .react-calendar__viewContainer > * { + width: 50%; + margin: 0.5em; +} + +.react-calendar, +.react-calendar *, +.react-calendar *:before, +.react-calendar *:after { + -moz-box-sizing: border-box; + -webkit-box-sizing: border-box; + box-sizing: border-box; +} + +.react-calendar button { + margin: 0; + border: 0; + outline: none; +} + +.react-calendar button:enabled:hover { + cursor: pointer; +} + +.react-calendar__navigation { + display: flex; + height: 44px; + margin-bottom: 1em; +} + +.react-calendar__navigation button { + min-width: 44px; + background: none; +} + +.react-calendar__navigation button:disabled { + background-color: #f0f0f0; +} + +.react-calendar__navigation button:enabled:hover, +.react-calendar__navigation button:enabled:focus { + background-color: #e6e6e6; +} + +.react-calendar__month-view__weekdays { + text-align: center; + text-transform: uppercase; + font: inherit; + font-size: 0.75em; + font-weight: bold; + text-decoration: none !important; +} + +abbr:where([title]) { + text-decoration: none !important +} + +.react-calendar__month-view__weekdays__weekday { + padding: 0.5em; +} + +.react-calendar__month-view__weekNumbers .react-calendar__tile { + display: flex; + align-items: center; + justify-content: center; + font: inherit; + font-size: 0.75em; + font-weight: bold; +} + +.react-calendar__month-view__days__day--weekend { + color: #d10000; +} + +.react-calendar__month-view__days__day--neighboringMonth, +.react-calendar__decade-view__years__year--neighboringDecade, +.react-calendar__century-view__decades__decade--neighboringCentury { + color: #757575; +} + +.react-calendar__year-view .react-calendar__tile, +.react-calendar__decade-view .react-calendar__tile, +.react-calendar__century-view .react-calendar__tile { + padding: 2em 0.5em; +} + +.react-calendar__tile { + max-width: 100%; + padding: 10px 6.6667px; + background: none; + text-align: center; + font: inherit; + font-size: 0.833em; +} + +.react-calendar__tile:disabled { + background-color: #f0f0f0; + color: #ababab; +} + +.react-calendar__month-view__days__day--neighboringMonth:disabled, +.react-calendar__decade-view__years__year--neighboringDecade:disabled, +.react-calendar__century-view__decades__decade--neighboringCentury:disabled { + color: #cdcdcd; +} + +.react-calendar__tile:enabled:hover, +.react-calendar__tile:enabled:focus { + background-color: #e6e6e6; +} + +.react-calendar__tile--now { + background: #CCE6F1; +} + +.react-calendar__tile--now:enabled:hover, +.react-calendar__tile--now:enabled:focus { + background: #CCE6F1; +} + +.react-calendar__tile--hasActive { + background: #CCE6F1; +} + +.react-calendar__tile--hasActive:enabled:hover, +.react-calendar__tile--hasActive:enabled:focus { + background: #CCE6F1; +} + +.react-calendar__tile--active { + background: #006edc; + color: white; +} + +.react-calendar__tile--active:enabled:hover, +.react-calendar__tile--active:enabled:focus { + background: #1087ff; +} + +.react-calendar--selectRange .react-calendar__tile--hover { + background-color: #e6e6e6; +} From 93913fbb6d9a1aa977856f58d71890f4ddfab595 Mon Sep 17 00:00:00 2001 From: Joonas Nivala Date: Tue, 16 Sep 2025 21:01:29 +0300 Subject: [PATCH 04/15] hook for fething bookings --- src/hooks/useBookings.jsx | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/hooks/useBookings.jsx diff --git a/src/hooks/useBookings.jsx b/src/hooks/useBookings.jsx new file mode 100644 index 000000000000..4f55d3e3a1ae --- /dev/null +++ b/src/hooks/useBookings.jsx @@ -0,0 +1,24 @@ +import { useEffect, useState } from 'react' + +export const useBookings = (bookingUrl) => { + const [bookingData, setBookingData] = useState([]) + const [error, setError] = useState(null); + + useEffect(() => { + const fetchBookings = async () => { + const url = bookingUrl; + try { + const resp = await fetch (url); + const result = await resp.json(); + setBookingData(result?.data || []); + } catch (err) { + console.error(err); + setError(err); + } + } + + fetchBookings(); +}, [bookingUrl]); + + return { bookingData, error }; +} From befc7756950e4151857a223cf2941cba53b2a9b9 Mon Sep 17 00:00:00 2001 From: Joonas Nivala Date: Tue, 16 Sep 2025 21:01:42 +0300 Subject: [PATCH 05/15] booking calnedar modal --- src/components/bookingCalendar.jsx | 141 +++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 src/components/bookingCalendar.jsx diff --git a/src/components/bookingCalendar.jsx b/src/components/bookingCalendar.jsx new file mode 100644 index 000000000000..fc0e4123fc8a --- /dev/null +++ b/src/components/bookingCalendar.jsx @@ -0,0 +1,141 @@ +import React, { useState, useEffect, useCallback } from "react"; +import Calendar from "react-calendar"; +import "../stylesheets/Calendar.css"; +import { format, parseISO } from "date-fns"; +import { CModal, CCard, CCardTitle, CCardContent, CSelect, CCardActions, CButton } from '@cscfi/csc-ui-react'; +import { capitalizeFirstLetter } from "../utils/textUtils"; +import { useWindowSize } from "../utils/modalUtils"; + +export const BookingModal = (props) => { + const { bookingData } = props; + + const { width } = useWindowSize(); + let size; + + if (width >= 2600) size = 'large'; + else if (width >= 768) size = 'medium'; + else size = 'small'; + + const modalWidths = { small: '90vw', medium: '1400px', large: '50vw' } + + return ( + props.setIsModalOpen(e.detail)} + > + + + {props.name} + + + + +
+ props.setIsModalOpen(false)} text>Close +
+
+
+
+ ) +} + +// Helper to group bookings by date +const groupBookingsByDate = (bookings) => { + return bookings.reduce((acc, booking) => { + const dateKey = format(parseISO(booking.start_time), "yyyy-MM-dd"); + if (!acc[dateKey]) acc[dateKey] = []; + acc[dateKey].push(booking); + return acc; + }, {}); +} + +const BookingCalendar = (props) => { + const { bookingData } = props; + // Set initial selectedDate to today + const [selectedDate, setSelectedDate] = useState(new Date()); + const [selectedBooking, setSelectedBooking] = useState(null); + const [bookings, setBookings] = useState(bookingData) + const [bookingsByDate, setBookingsByDate] = useState(groupBookingsByDate(bookingData)) + const [filter, setFilter] = useState("all") + + useEffect(() => { + setBookings(bookingData) + }, [bookingData]) + + useEffect(() => { + setBookings(bookingData.filter(b => filter === "all" || b.device === filter)) + }, [filter]) + + useEffect(() => { + setBookingsByDate(groupBookingsByDate(bookings)) + }, [bookings]) + + const handleFilterChange = useCallback(selectedFilter => { + setFilter(selectedFilter.detail || "all"); + }, []) + + return ( +
+
+

Device:

+ + +
+
+ {/* Calendar */} + setSelectedDate(value)} + tileContent={({ date }) => + bookingsByDate[format(date, "yyyy-MM-dd")] ? ( + + ) : null + } + /> + + {/* Daily booking list */} + {selectedDate && ( +
+

+ Reservations on {format(selectedDate, "PPP")} +

+
    + {(bookingsByDate[format(selectedDate, "yyyy-MM-dd")] || []) + .filter(b => filter === "all" || b.device === filter) + .map( + (b) => ( +
  • setSelectedBooking(b)} + > + {capitalizeFirstLetter(b.device)} - {capitalizeFirstLetter(b.type)} ( + {format(parseISO(b.start_time), "HH:mm")}– + {format(parseISO(b.end_time), "HH:mm")}) +
  • + ) + )} +
+
+ )} +
+
+ ); +} From bd8094e98b9ff9ba658477e49c4a488740b1cd21 Mon Sep 17 00:00:00 2001 From: Joonas Nivala Date: Wed, 17 Sep 2025 14:33:17 +0300 Subject: [PATCH 06/15] handle multiday bookings --- src/hooks/useBookings.jsx | 47 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/src/hooks/useBookings.jsx b/src/hooks/useBookings.jsx index 4f55d3e3a1ae..8b887f77213e 100644 --- a/src/hooks/useBookings.jsx +++ b/src/hooks/useBookings.jsx @@ -10,7 +10,52 @@ export const useBookings = (bookingUrl) => { try { const resp = await fetch (url); const result = await resp.json(); - setBookingData(result?.data || []); + const splitBookings = (bookings) => { + const split = []; + bookings.forEach((booking) => { + const start = new Date(booking.start_time); + const end = new Date(booking.end_time); + + let current = new Date(start); + current.setHours(0, 0, 0, 0); + + const endDay = new Date(end); + endDay.setHours(0, 0, 0, 0); + + if (current.getTime() === endDay.getTime()) { + split.push(booking); + } else { + // Split across days + while (current <= endDay) { + let dayEnd = new Date(current); + dayEnd.setHours(23, 59, 59, 999); + + // Clamp to booking start/end + const splitStart = current.getTime() === new Date(start).setHours(0,0,0,0) + ? start + : new Date(current); + + const splitEnd = current.getTime() === endDay.getTime() + ? end + : dayEnd; + + split.push({ + ...booking, + id: `${booking.id}-${current.toISOString().slice(0,10)}`, + start_time: splitStart.toISOString(), + end_time: splitEnd.toISOString(), + }); + + current.setDate(current.getDate() + 1); + } + } + }); + return split; + }; + + const bookings = Array.isArray(result?.data) ? result.data : []; + const processedBookings = splitBookings(bookings); + setBookingData(processedBookings); } catch (err) { console.error(err); setError(err); From 548cfe107472562c29d812cb43eb942cdd71ea38 Mon Sep 17 00:00:00 2001 From: Joonas Nivala Date: Wed, 17 Sep 2025 14:33:25 +0300 Subject: [PATCH 07/15] typo --- src/components/Blogs.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Blogs.jsx b/src/components/Blogs.jsx index 5a3ce3b2e6fd..dbf4d4192b45 100644 --- a/src/components/Blogs.jsx +++ b/src/components/Blogs.jsx @@ -82,8 +82,8 @@ const FilterTheme = ({ selectedTheme, handleChangeTheme }) => ( value={selectedTheme} items={[ { name: 'HPC+QC+AI', value: 'HPC+QC+AI' }, - { name: 'Programming', value: 'programming' }, - { name: 'Algorithm', value: 'algorithm' }, + { name: 'Programming', value: 'Programming' }, + { name: 'Algorithm', value: 'Algorithm' }, { name: 'Technical', value: 'Technical' }, ]} placeholder='Choose a theme' From 5dac18124fd79e271aec1b8b254b619664d3801d Mon Sep 17 00:00:00 2001 From: Joonas Nivala Date: Wed, 17 Sep 2025 14:33:42 +0300 Subject: [PATCH 08/15] refactor --- src/components/StatusModal/StatusModal.jsx | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/src/components/StatusModal/StatusModal.jsx b/src/components/StatusModal/StatusModal.jsx index 0f2ca50b10a7..3fac9f6481d8 100644 --- a/src/components/StatusModal/StatusModal.jsx +++ b/src/components/StatusModal/StatusModal.jsx @@ -4,26 +4,7 @@ import { useState, useEffect } from 'react' import { CModal } from '@cscfi/csc-ui-react'; import { ModalContent } from './StatusModalConent'; - -export default function useWindowSize() { - const [width, setWidth] = useState( - typeof window !== 'undefined' ? window.innerWidth : 0 - ); - - useEffect(() => { - const onResize = () => setWidth(window.innerWidth); - - window.addEventListener('resize', onResize); - // In case the window was resized before the listener attached - onResize(); - - return () => { - window.removeEventListener('resize', onResize); - }; - }, []); - - return { width }; -} +import { useWindowSize } from '../../utils/modalUtils'; export const StatusModal = (props) => { const { isModalOpen, setIsModalOpen, ...modalProps } = props; From e13248411d4c65aab2ad631e7da90fece2a232bb Mon Sep 17 00:00:00 2001 From: Joonas Nivala Date: Wed, 17 Sep 2025 14:33:54 +0300 Subject: [PATCH 09/15] booking view --- src/components/bookingCalendar.jsx | 286 ++++++++++++++++++++++++----- 1 file changed, 243 insertions(+), 43 deletions(-) diff --git a/src/components/bookingCalendar.jsx b/src/components/bookingCalendar.jsx index fc0e4123fc8a..403a106ef700 100644 --- a/src/components/bookingCalendar.jsx +++ b/src/components/bookingCalendar.jsx @@ -1,8 +1,8 @@ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useRef } from "react"; import Calendar from "react-calendar"; import "../stylesheets/Calendar.css"; import { format, parseISO } from "date-fns"; -import { CModal, CCard, CCardTitle, CCardContent, CSelect, CCardActions, CButton } from '@cscfi/csc-ui-react'; +import { CModal, CCard, CCardTitle, CCardContent, CSelect, CCardActions, CButton, CTextField } from '@cscfi/csc-ui-react'; import { capitalizeFirstLetter } from "../utils/textUtils"; import { useWindowSize } from "../utils/modalUtils"; @@ -31,7 +31,7 @@ export const BookingModal = (props) => { {props.name} - + @@ -59,16 +59,20 @@ const BookingCalendar = (props) => { // Set initial selectedDate to today const [selectedDate, setSelectedDate] = useState(new Date()); const [selectedBooking, setSelectedBooking] = useState(null); + const [tooltip, setTooltip] = useState({ visible: false, x: 0, y: 0, content: '' }); + const gridRef = useRef(null); const [bookings, setBookings] = useState(bookingData) const [bookingsByDate, setBookingsByDate] = useState(groupBookingsByDate(bookingData)) - const [filter, setFilter] = useState("all") + const [filter, setFilter] = useState("All") + const [view, setView] = useState("List") + const [validDate, setValidDate] = useState(true) useEffect(() => { setBookings(bookingData) }, [bookingData]) useEffect(() => { - setBookings(bookingData.filter(b => filter === "all" || b.device === filter)) + setBookings(bookingData.filter(b => filter.toLowerCase() === "all" || b.device === filter.toLowerCase())) }, [filter]) useEffect(() => { @@ -76,32 +80,138 @@ const BookingCalendar = (props) => { }, [bookings]) const handleFilterChange = useCallback(selectedFilter => { - setFilter(selectedFilter.detail || "all"); + setFilter(selectedFilter.detail || 'All'); }, []) - return ( -
-
-

Device:

+ const handleViewChange = useCallback(selectedView => { + setView(selectedView.detail || 'List'); + }, []) + + // Helper to get reserved minutes for the selected day + const getReservedMinutes = () => { + // Use local time for all calculations + const selectedYear = selectedDate.getFullYear(); + const selectedMonth = selectedDate.getMonth(); + const selectedDay = selectedDate.getDate(); + // Start and end of the selected day in local time + const dayStart = new Date(selectedYear, selectedMonth, selectedDay, 0, 0, 0, 0).getTime(); + const dayEnd = new Date(selectedYear, selectedMonth, selectedDay, 23, 59, 59, 999).getTime(); + let reserved = Array.from({ length: 24 * 60 }, () => []); + (bookings || []).forEach(b => { + const start = parseISO(b.start_time); + const end = parseISO(b.end_time); + const bookingStart = start.getTime(); + const bookingEnd = end.getTime(); + // If booking overlaps this day at all (local time) + if (bookingEnd > dayStart && bookingStart < dayEnd) { + // Clamp booking to this day + const minStart = Math.max(bookingStart, dayStart); + const maxEnd = Math.min(bookingEnd, dayEnd + 1); // +1 to include last minute + // For each minute of the day + for (let i = 0; i < 24 * 60; i++) { + const minuteTime = new Date(selectedYear, selectedMonth, selectedDay, Math.floor(i / 60), i % 60).getTime(); + if (minuteTime >= minStart && minuteTime < maxEnd) { + reserved[i].push(b); + } + } + } + }); + return reserved; + }; + + const reservedMinutes = getReservedMinutes(); + + const handleDateChange = (value) => { + try { + const newDate = parseISO(value.detail); + if (isNaN(newDate) || newDate.toString() === 'Invalid Date') { + setValidDate(false); + setSelectedDate(new Date()); + console.log(newDate) + return; + } + setSelectedDate(newDate); + setValidDate(true); + console.log(newDate) + } catch { + console.log("here") + setValidDate(false); + } + } - { + let x = 0, y = 0; + if (event.touches && event.touches.length > 0) { + x = event.touches[0].clientX; + y = event.touches[0].clientY; + } else { + x = event.clientX; + y = event.clientY; + } + setTooltip({ visible: true, x, y, content }); + }; + + const hideTooltip = () => setTooltip({ ...tooltip, visible: false }); + + // Close tooltip on scroll (mobile) + useEffect(() => { + const handler = () => hideTooltip(); + window.addEventListener('scroll', handler, true); + return () => window.removeEventListener('scroll', handler, true); + }, []); + + return ( +
+
+ +
+ +
+
+ +
-
+
{/* Calendar */} setSelectedDate(value)} tileContent={({ date }) => bookingsByDate[format(date, "yyyy-MM-dd")] ? ( @@ -110,29 +220,119 @@ const BookingCalendar = (props) => { } /> - {/* Daily booking list */} + {/* Daily booking list or grid */} {selectedDate && ( -
-

+
+

Reservations on {format(selectedDate, "PPP")} -

-
    - {(bookingsByDate[format(selectedDate, "yyyy-MM-dd")] || []) - .filter(b => filter === "all" || b.device === filter) - .map( - (b) => ( -
  • setSelectedBooking(b)} +

+ {view === "List" ? ( +
    + {(bookingsByDate[format(selectedDate, "yyyy-MM-dd")] || []) + .filter(b => filter.toLowerCase() === "all" || b.device === filter.toLowerCase()) + .map( + (b) => ( +
  • setSelectedBooking(b)} + > + {capitalizeFirstLetter(b.device)} - {capitalizeFirstLetter(b.type)} ( + {format(parseISO(b.start_time), "HH:mm")}– + {format(parseISO(b.end_time), "HH:mm")}) +
  • + ) + )} +
+ ) : ( +
+ {/* Hour labels on the left */} +
+
+ {"00".toString().padStart(2, '0')} +
+
+ {"06".toString().padStart(2, '0')} +
+
+ {"12".toString().padStart(2, '0')} +
+
+ {"18".toString().padStart(2, '0')} +
+
+ {"23".toString().padStart(2, '0')} +
+
+ {/* Responsive grid: 24 rows (hours), 60 cols (minutes) */} +
+
+ {Array.from({ length: 24 }).map((_, hour) => ( +
+ {Array.from({ length: 60 }).map((_, min) => { + const i = hour * 60 + min; + const bookings = reservedMinutes[i]; + let background; + if (bookings.length === 0) { + background = '#e5e7eb'; + } else { + background = '#f87171'; + } + const tooltipContent = bookings.length + ? bookings.map(b => `${capitalizeFirstLetter(b.device)}: ${capitalizeFirstLetter(b.type)} (${format(parseISO(b.start_time), "HH:mm")}-${format(parseISO(b.end_time), "HH:mm")})`).join('\n') + : `${hour.toString().padStart(2, '0')}:${min.toString().padStart(2, '0')}`; + return ( +
{ setSelectedBooking(bookings.length === 1 ? bookings[0] : bookings); showTooltip(e, tooltipContent); } : undefined} + onMouseLeave={() => { setSelectedBooking(null); hideTooltip(); }} + onTouchStart={bookings.length ? (e) => { setSelectedBooking(bookings.length === 1 ? bookings[0] : bookings); showTooltip(e, tooltipContent); } : undefined} + onTouchEnd={() => { setTimeout(hideTooltip, 200); }} + onClick={bookings.length ? (e) => { setSelectedBooking(bookings.length === 1 ? bookings[0] : bookings); showTooltip(e, tooltipContent); } : undefined} + /> + ); + })} +
+ ))} +
+ {/* Tooltip overlay */} + {tooltip.visible && ( +
- {capitalizeFirstLetter(b.device)} - {capitalizeFirstLetter(b.type)} ( - {format(parseISO(b.start_time), "HH:mm")}– - {format(parseISO(b.end_time), "HH:mm")}) - - ) - )} - + {tooltip.content} +
+ )} +
+
+ )} +
)}
From bc1461d1e361eceb5305b6a4ae716fb5b8369837 Mon Sep 17 00:00:00 2001 From: Joonas Nivala Date: Wed, 17 Sep 2025 15:19:29 +0300 Subject: [PATCH 10/15] finetune tooltips --- src/components/bookingCalendar.jsx | 123 +++++++++++++++++++---------- 1 file changed, 82 insertions(+), 41 deletions(-) diff --git a/src/components/bookingCalendar.jsx b/src/components/bookingCalendar.jsx index 403a106ef700..9b77e22e6453 100644 --- a/src/components/bookingCalendar.jsx +++ b/src/components/bookingCalendar.jsx @@ -154,6 +154,24 @@ const BookingCalendar = (props) => { const hideTooltip = () => setTooltip({ ...tooltip, visible: false }); + // Close tooltip when user touches/clicks outside the tooltip + useEffect(() => { + if (!tooltip.visible) return; + const handlePointerDown = (e) => { + // If tooltip ref exists and click is outside, close + const tooltipEl = document.getElementById('booking-tooltip-overlay'); + if (tooltipEl && !tooltipEl.contains(e.target)) { + hideTooltip(); + } + }; + document.addEventListener('mousedown', handlePointerDown, true); + document.addEventListener('touchstart', handlePointerDown, true); + return () => { + document.removeEventListener('mousedown', handlePointerDown, true); + document.removeEventListener('touchstart', handlePointerDown, true); + }; + }, [tooltip.visible]); + // Close tooltip on scroll (mobile) useEffect(() => { const handler = () => hideTooltip(); @@ -163,48 +181,71 @@ const BookingCalendar = (props) => { return (
-
- -
- +
+ +
+ +
+
+ +
+
-
- +
+
+ +

Partially Reserved

+
+
+
+

Selected Day

+
+
+
+

Today

+
+ {view === "Grid" && ( +
+
+

Booked

+
+ )}
@@ -297,7 +338,6 @@ const BookingCalendar = (props) => { onMouseEnter={bookings.length ? (e) => { setSelectedBooking(bookings.length === 1 ? bookings[0] : bookings); showTooltip(e, tooltipContent); } : undefined} onMouseLeave={() => { setSelectedBooking(null); hideTooltip(); }} onTouchStart={bookings.length ? (e) => { setSelectedBooking(bookings.length === 1 ? bookings[0] : bookings); showTooltip(e, tooltipContent); } : undefined} - onTouchEnd={() => { setTimeout(hideTooltip, 200); }} onClick={bookings.length ? (e) => { setSelectedBooking(bookings.length === 1 ? bookings[0] : bookings); showTooltip(e, tooltipContent); } : undefined} /> ); @@ -308,6 +348,7 @@ const BookingCalendar = (props) => { {/* Tooltip overlay */} {tooltip.visible && (
{ boxShadow: '0 4px 16px rgba(0,0,0,0.18)', fontSize: '0.95rem', whiteSpace: 'pre-line', - pointerEvents: 'none', + pointerEvents: 'auto', maxWidth: '80vw', minWidth: '120px', wordBreak: 'break-word', From 3815c81dedc47d85e1ddab6bee5a4132a650685b Mon Sep 17 00:00:00 2001 From: Joonas Nivala Date: Wed, 17 Sep 2025 15:19:36 +0300 Subject: [PATCH 11/15] remove unused --- src/stylesheets/Calendar.css | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/stylesheets/Calendar.css b/src/stylesheets/Calendar.css index 5640fbc523e3..1c950ce4dbea 100644 --- a/src/stylesheets/Calendar.css +++ b/src/stylesheets/Calendar.css @@ -86,9 +86,6 @@ abbr:where([title]) { font-weight: bold; } -.react-calendar__month-view__days__day--weekend { - color: #d10000; -} .react-calendar__month-view__days__day--neighboringMonth, .react-calendar__decade-view__years__year--neighboringDecade, From c8045c9a60974ccf3f29af2b89000c569b2a7ed2 Mon Sep 17 00:00:00 2001 From: Joonas Nivala Date: Wed, 17 Sep 2025 15:31:26 +0300 Subject: [PATCH 12/15] typo --- src/components/Events.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/Events.jsx b/src/components/Events.jsx index ea558304243b..af4d261c5c4c 100644 --- a/src/components/Events.jsx +++ b/src/components/Events.jsx @@ -77,9 +77,9 @@ const FilterTheme = ({ selectedTheme, handleChangeTheme }) => ( value={selectedTheme} items={[ { name: 'HPC+QC+AI', value: 'HPC+QC+AI' }, - { name: 'Programming', value: 'programming' }, - { name: 'Webinar/Lecture', value: 'webinar/lecture' }, - { name: 'Course/Workshop', value: 'course/workshop' }, + { name: 'Programming', value: 'Programming' }, + { name: 'Webinar/Lecture', value: 'Webinar/Lecture' }, + { name: 'Course/Workshop', value: 'Course/Workshop' }, ]} placeholder='Choose a theme' onChangeValue={handleChangeTheme} From dc1364462b8ed8da10d8fcc2be3bb0d39728884b Mon Sep 17 00:00:00 2001 From: Jake Muff Date: Thu, 25 Sep 2025 22:44:30 +0300 Subject: [PATCH 13/15] Add framer-motion to package.json --- package-lock.json | 16 ++++++++-------- package.json | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index da8294878228..6fde4a206299 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "@cscfi/csc-ui-react": "^2.3.0", "date-fns": "^4.1.0", "dompurify": "^3.2.4", - "framer-motion": "^12.23.9", + "framer-motion": "^12.23.22", "front-matter": "^4.0.2", "gray-matter": "^4.0.3", "katex": "^0.16.21", @@ -3673,12 +3673,12 @@ } }, "node_modules/framer-motion": { - "version": "12.23.9", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.9.tgz", - "integrity": "sha512-TqEHXj8LWfQSKqfdr5Y4mYltYLw96deu6/K9kGDd+ysqRJPNwF9nb5mZcrLmybHbU7gcJ+HQar41U3UTGanbbQ==", + "version": "12.23.22", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.22.tgz", + "integrity": "sha512-ZgGvdxXCw55ZYvhoZChTlG6pUuehecgvEAJz0BHoC5pQKW1EC5xf1Mul1ej5+ai+pVY0pylyFfdl45qnM1/GsA==", "license": "MIT", "dependencies": { - "motion-dom": "^12.23.9", + "motion-dom": "^12.23.21", "motion-utils": "^12.23.6", "tslib": "^2.4.0" }, @@ -4478,9 +4478,9 @@ } }, "node_modules/motion-dom": { - "version": "12.23.9", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.9.tgz", - "integrity": "sha512-6Sv++iWS8XMFCgU1qwKj9l4xuC47Hp4+2jvPfyTXkqDg2tTzSgX6nWKD4kNFXk0k7llO59LZTPuJigza4A2K1A==", + "version": "12.23.21", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.21.tgz", + "integrity": "sha512-5xDXx/AbhrfgsQmSE7YESMn4Dpo6x5/DTZ4Iyy4xqDvVHWvFVoV+V2Ri2S/ksx+D40wrZ7gPYiMWshkdoqNgNQ==", "license": "MIT", "dependencies": { "motion-utils": "^12.23.6" diff --git a/package.json b/package.json index aecf674f6428..7a67b0a48431 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "@cscfi/csc-ui-react": "^2.3.0", "date-fns": "^4.1.0", "dompurify": "^3.2.4", - "framer-motion": "^12.23.9", + "framer-motion": "^12.23.22", "front-matter": "^4.0.2", "gray-matter": "^4.0.3", "katex": "^0.16.21", From 845a0bd1cd9547b26c9337d13a835fff55ab127d Mon Sep 17 00:00:00 2001 From: Joonas Nivala Date: Fri, 26 Sep 2025 11:38:01 +0300 Subject: [PATCH 14/15] edit wording --- src/components/ServiceStatus.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/ServiceStatus.jsx b/src/components/ServiceStatus.jsx index 4cee07bc8bf2..e4beddb487fe 100644 --- a/src/components/ServiceStatus.jsx +++ b/src/components/ServiceStatus.jsx @@ -92,12 +92,12 @@ export const ServiceStatus = (props) => {

-

Calendar

+

Reservations

VTT devices can at times be reserved. At these times the queue will be paused. - Reservations can be viewed from this calendar. + Reservations can be viewed from this calendar. Note that making reservations through FiQCI is not currently possible.

- setBookingModalOpen(true)}>Open Calendar + setBookingModalOpen(true)}>View Reservations

Devices

From 9804222ef005ee17e415e3f1aa4e32a16d524d8a Mon Sep 17 00:00:00 2001 From: Joonas Nivala Date: Fri, 26 Sep 2025 12:37:19 +0300 Subject: [PATCH 15/15] update bookings url --- src/components/ServiceStatus.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/ServiceStatus.jsx b/src/components/ServiceStatus.jsx index e4beddb487fe..d21af3be8158 100644 --- a/src/components/ServiceStatus.jsx +++ b/src/components/ServiceStatus.jsx @@ -41,7 +41,7 @@ const StatusCard = (props) => { export const ServiceStatus = (props) => { const { status: statusList } = useStatus("https://fiqci-backend.2.rahtiapp.fi/devices/healthcheck"); - const { bookingData: bookingData } = useBookings("http://localhost:3000/bookings") + const { bookingData: bookingData } = useBookings("https://fiqci-backend.2.rahtiapp.fi/bookings") const qcs = props["quantum-computers"] || []; const devicesWithStatus = (qcs.length === 0 || !Array.isArray(statusList)) @@ -109,7 +109,7 @@ export const ServiceStatus = (props) => {
{bookingModalOpen && ( - + )} {modalOpen && (