diff --git a/src/components/EventsView.jsx b/src/components/EventsView.jsx index 9a83bbf..507d210 100644 --- a/src/components/EventsView.jsx +++ b/src/components/EventsView.jsx @@ -1,16 +1,19 @@ import { Course } from './view/Course.jsx' import { Schedule } from './view/Schedule.jsx' import { WorkWeek } from './view/WorkWeek.jsx' +import { WorkWeekGrid } from './view/WorkWeekGrid.jsx' import { WorkWeekTranspose } from './view/WorkWeekTranspose.jsx' export const MODE_COURSE = 'course' export const MODE_WORKWEEK = 'work-week' export const MODE_SCHEDULE = 'schedule' +export const MODE_WORKWEEK_GRID = 'work-week-grid' export const MODE_WORKWEEK_TRANSPOSE = 'work-week-transpose' const viewModeMap = { [MODE_COURSE]: Course, [MODE_WORKWEEK]: WorkWeek, + [MODE_WORKWEEK_GRID]: WorkWeekGrid, [MODE_WORKWEEK_TRANSPOSE]: WorkWeekTranspose, [MODE_SCHEDULE]: Schedule, } diff --git a/src/components/HamburgerMenu.jsx b/src/components/HamburgerMenu.jsx index 19ef2eb..5f9ff71 100644 --- a/src/components/HamburgerMenu.jsx +++ b/src/components/HamburgerMenu.jsx @@ -1,5 +1,5 @@ import { ComboBox } from './ComboBox.jsx' -import { MODE_COURSE, MODE_SCHEDULE, MODE_WORKWEEK } from './EventsView.jsx' +import { MODE_COURSE, MODE_SCHEDULE, MODE_WORKWEEK, MODE_WORKWEEK_GRID } from './EventsView.jsx' import { Help } from './Help.jsx' import { Icon } from './Icon.jsx' @@ -43,6 +43,7 @@ export const HamburgerMenu = ({ onClose, mode, setMode, source, setSource, theme { value: MODE_COURSE, label: 'Corsi' }, { value: MODE_SCHEDULE, label: 'Giornaliera' }, { value: MODE_WORKWEEK, label: 'Settimana' }, + { value: MODE_WORKWEEK_GRID, label: 'Schema' }, ]} /> diff --git a/src/components/Toolbar.jsx b/src/components/Toolbar.jsx index 25e8568..c4ba083 100644 --- a/src/components/Toolbar.jsx +++ b/src/components/Toolbar.jsx @@ -1,5 +1,5 @@ import { CompoundButton } from './CompoundButton.jsx' -import { MODE_COURSE, MODE_SCHEDULE, MODE_WORKWEEK } from './EventsView.jsx' +import { MODE_COURSE, MODE_SCHEDULE, MODE_WORKWEEK, MODE_WORKWEEK_GRID } from './EventsView.jsx' import { Icon } from './Icon.jsx' export const Toolbar = ({ @@ -43,6 +43,7 @@ export const Toolbar = ({ options={[ { value: MODE_COURSE, label: 'Corsi' }, { value: MODE_WORKWEEK, label: 'Settimana' }, + { value: MODE_WORKWEEK_GRID, label: 'Schema' }, { value: MODE_SCHEDULE, label: 'Giorno' }, ]} value={mode} diff --git a/src/components/view/WorkWeekGrid.jsx b/src/components/view/WorkWeekGrid.jsx new file mode 100644 index 0000000..2d6167b --- /dev/null +++ b/src/components/view/WorkWeekGrid.jsx @@ -0,0 +1,200 @@ +import { useEffect, useRef, useState } from 'preact/hooks' + +import _ from 'lodash' +import { differenceInMinutes, startOfDay } from 'date-fns' + +import { hashString, normalizeCourseName, WEEK_DAYS } from '../../utils.jsx' +import { layoutIntervals } from '../../interval-layout.js' + +export const WorkWeekGrid = ({ events, selection, setSelection, hideOtherCourses }) => { + const selectionSet = new Set(selection) + + const colorList = [ + 'red', + 'purple', + 'blue', + 'yellow', + 'green', + 'orange', + 'lightblue', + ]; + + const courses = _.uniqBy(!hideOtherCourses ? events : events.filter(e => selectionSet.has(e.id)), event => event.id); + const colors = Object.fromEntries(courses.map((course, index) => { + return [ + course.id, + [ + colorList[index % colorList.length], + courses.length <= colorList.length ? '' : String.fromCharCode(65 + Math.floor(index / colorList.length)) + ] + ]; + })); + + const base = { + 1: [], + 2: [], + 3: [], + 4: [], + 5: [], + } + const eventsByWeekday = { + ... base, + ... _.groupBy( + !hideOtherCourses ? events : events.filter(e => selectionSet.has(e.id)), + event => event.start.getDay() + ) + } + + const dayIntervalLayout = _.mapValues(eventsByWeekday, events => + layoutIntervals( + events.map(e => ({ + start: differenceInMinutes(e.start, startOfDay(e.start)), + end: differenceInMinutes(e.end, startOfDay(e.start)), + data: e, + })) + ) + ) + const startsAndEnds = Object.entries(dayIntervalLayout).flatMap(([index, layout]) => layout.flatMap(events => events.map(event => [event.start, event.end]))); + const minStart = Math.min(...startsAndEnds.map(([s, e]) => s)) / 60; + const maxEnd = Math.max(...startsAndEnds.map(([s, e]) => e)) / 60; + // const timeStart = minStart < 9 ? 7 : + // minStart < 11 ? 9 : + // minStart < 13 ? 11 : + // minStart < 14 ? 13 : + // minStart < 16 ? 14 : + // minStart < 18 ? 16 : + // 18; + // const timeEnd = maxEnd > 18 ? 20 : + // maxEnd > 16 ? 18 : + // maxEnd > 14 ? 16 : + // maxEnd > 13 ? 14 : + // maxEnd > 11 ? 13 : + // maxEnd > 9 ? 11 : + // 9; + const timeStart = minStart < 9 ? 7 : 9; + const timeEnd = maxEnd > 18 ? 20 : 18; + + + const daySizes = Object.entries(dayIntervalLayout).map(([index, layout]) => Math.max(1, layout.length)); + const dayOffsets = daySizes.map((v, i) => { + let sum = 0; + for(let j = 0; j < i; j++) { + sum += daySizes[j]; + } + return sum; + }); + const daysLength = daySizes[4] + dayOffsets[4]; + + const [currentlyHovered, setCurrentlyHovered] = useState(null) + const element = useRef() + + useEffect(() => { + if (element.current) { + const l = e => { + const $event = e.target.closest('.event') + if ($event) { + setCurrentlyHovered($event.dataset.eventId) + } else { + setCurrentlyHovered(null) + } + } + + element.current.addEventListener('mousemove', l) + element.current.addEventListener('mouseleave', l) + + return () => { + element.current.removeEventListener('mousemove', l) + element.current.removeEventListener('mouseleave', l) + } + } + }, [element.current]) + + return ( +
+
+ {[7, 9, 11, 14, 16, 18].map(n => + <> + {n + 2 > timeStart && n < timeEnd && ( +
{n}-{n+2}
+ )} + + )} +
+ {[9, 11, 13, 14, 16, 18].map(n => + <> + {n > timeStart && n < timeEnd && ( +
+ )} + + )} + +
Lun
+
Mar
+
Mer
+
Gio
+
Ven
+ +
+
+
+
+
+ + {Object.entries(dayIntervalLayout).map(([index, layout]) => ( + <> + {layout.map((events, stackIndex) => ( + <> + {events.map(event => ( +
{ + if (!selectionSet.has(event.data.id)) + setSelection([...selection, event.data.id]) + else + setSelection( + selection.filter(id => event.data.id !== id) + ) + }} + > + {colors[event.data.id][1] !== '' && ( +
{`(${colors[event.data.id][1]})`}
+ )} + {event.data.aula}
+ ))} + + ))} + + ))} +
+
+ {courses.map(course => ( + <> +
+ {colors[course.id][1]} +
+
{normalizeCourseName(course.name)}
+ + ))} +
+
+ ) +} diff --git a/src/main.jsx b/src/main.jsx index be40c47..cd033fe 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -3,7 +3,7 @@ import { render } from 'preact' import { useEffect, useState } from 'preact/hooks' import { ToolOverlay } from './components/CourseVisibility.jsx' -import { EventsView, MODE_SCHEDULE } from './components/EventsView.jsx' +import { EventsView, MODE_WORKWEEK_GRID } from './components/EventsView.jsx' import { HamburgerMenu } from './components/HamburgerMenu.jsx' import { Help } from './components/Help.jsx' import { Icon } from './components/Icon.jsx' @@ -69,11 +69,11 @@ async function loadEventi(ids) { const App = ({}) => { // Data Sources - const [source, setSource] = useState('magistrale') + const [source, setSource] = useState('anno-1') const [eventi, setEventi] = useState([]) // View Modes - const [mode, setMode] = useState(MODE_SCHEDULE) + const [mode, setMode] = useState(MODE_WORKWEEK_GRID) // Selection const [selectedCourses, setSelectedCourses] = useState([]) diff --git a/src/styles/main.scss b/src/styles/main.scss index 5ed4220..3017251 100644 --- a/src/styles/main.scss +++ b/src/styles/main.scss @@ -27,6 +27,36 @@ html { --accent-100: #d6ffc2; --accent-500: #6cc16c; --accent-900: #244624; + + + --bubble-red: hsl(359, 100%, 92%); + --bubble-purple: hsl(274, 100%, 92%); + --bubble-blue: hsl(241, 100%, 92%); + --bubble-yellow: hsl(50, 100%, 92%); + --bubble-green: hsl(125, 100%, 92%); + --bubble-orange: hsl(25, 100%, 92%); + --bubble-lightlightblue: hsl(176, 100%, 92%); + --bubble-lightblue: hsl(198, 100%, 92%); + + --bubble-highlight-red: hsl(359, 100%, 85%); + --bubble-highlight-purple: hsl(274, 100%, 85%); + --bubble-highlight-blue: hsl(241, 100%, 85%); + --bubble-highlight-yellow: hsl(50, 100%, 85%); + --bubble-highlight-green: hsl(125, 100%, 85%); + --bubble-highlight-orange: hsl(25, 100%, 85%); + --bubble-highlight-lightlightblue: hsl(176, 100%, 85%); + --bubble-highlight-lightblue: hsl(198, 100%, 85%); + + --bubble-border-red: hsl(359, 75%, 51%); + --bubble-border-purple: hsl(274, 75%, 51%); + --bubble-border-blue: hsl(241, 75%, 51%); + --bubble-border-yellow: hsl(50, 75%, 51%); + --bubble-border-green: hsl(125, 75%, 51%); + --bubble-border-orange: hsl(25, 75%, 51%); + --bubble-border-lightlightblue: hsl(176, 75%, 51%); + --bubble-border-lightblue: hsl(198, 75%, 51%); + + --bold-on-dark: 300; } body.dark-mode { @@ -43,6 +73,33 @@ body.dark-mode { --accent-100: hsl(269, 50%, 50%); --accent-500: hsl(269, 40%, 70%); --accent-900: hsl(269, 30%, 90%); + + + --bubble-red: hsl(359, 40%, 25%); + --bubble-purple: hsl(274, 40%, 25%); + --bubble-blue: hsl(241, 40%, 25%); + --bubble-yellow: hsl(50, 40%, 25%); + --bubble-green: hsl(125, 40%, 25%); + --bubble-orange: hsl(25, 40%, 25%); + --bubble-lightblue: hsl(176, 40%, 25%); + + --bubble-border-red: hsl(359, 75%, 51%); + --bubble-border-purple: hsl(274, 75%, 51%); + --bubble-border-blue: hsl(241, 75%, 51%); + --bubble-border-yellow: hsl(50, 75%, 51%); + --bubble-border-green: hsl(125, 75%, 51%); + --bubble-border-orange: hsl(25, 75%, 51%); + --bubble-border-lightblue: hsl(176, 75%, 51%); + + --bubble-highlight-red: hsl(359, 40%, 31%); + --bubble-highlight-purple: hsl(274, 40%, 31%); + --bubble-highlight-blue: hsl(241, 40%, 31%); + --bubble-highlight-yellow: hsl(50, 40%, 31%); + --bubble-highlight-green: hsl(125, 40%, 31%); + --bubble-highlight-orange: hsl(25, 40%, 31%); + --bubble-highlight-lightblue: hsl(176, 40%, 31%); + + --bold-on-dark: 500; } $device-s-width: 600px; @@ -811,6 +868,121 @@ body { } } + .work-week-grid-view { + position: relative; + padding: 1rem; + + display: flex; + flex-direction: column; + align-items: center; + height: 100%; + overflow: scroll; + + .grid { + width: 100%; + max-width: 55rem; + display: grid; + grid-template-columns: 3rem repeat(var(--time-length), 1fr); + grid-template-rows: 2rem repeat(var(--days-length), 1fr); + border: 2px solid var(--border-500); + border-radius: 10px 10px 0 0; + + + @media screen and (max-width: $device-s-width), (pointer: coarse) { + font-size: 12px; + } + + .time { + display: flex; + align-items: center; + justify-content: center; + padding: 0.5rem; + grid-row: 1; + grid-column: var(--offset) / span 2; + } + .day-name { + display: flex; + align-items: center; + justify-content: center; + padding: 0.5rem; + grid-column: 1; + grid-row: var(--line); + } + + .event { + display: flex; + flex-direction: row; + @media screen and (max-width: $device-s-width), (pointer: coarse) { + flex-direction: column; + } + gap: 0.25rem; + align-items: center; + justify-content: center; + margin: 0.25rem; + padding: 0.5rem; + grid-row: var(--line); + grid-column: var(--offset) / span var(--length); + background-color: var(--color); + border: 3px solid var(--color); + border-radius: 10px; + font-weight: var(--bold-on-dark); + text-align: center; + + &.highlight { + background-color: var(--highlight-color); + border: 3px solid var(--highlight-color); + } + &.selected { + border: 3px solid var(--border-color); + } + } + + .hline { + grid-column: 1 / 13; + grid-row: var(--line); + height: 0px; + border-bottom: 1px solid var(--border-500); + } + + .vline { + grid-column: var(--offset); + grid-row: 1 / calc(2 + var(--days-length)); + width: 0px; + border-right: 1px dashed var(--border-500); + } + } + .legend { + max-width: 55rem; + display: grid; + grid-template-columns: min-content 1fr; + border: 2px solid var(--border-500); + border-radius: 0 0 10px 10px; + border-top: none; + padding: 1rem; + gap: 0.5rem 1rem; + align-items: center; + justify-content: center; + width: 100%; + + .name { + width: 100%; + } + + .color { + display: flex; + justify-content: center; + align-items: center; + font-weight: 500; + width: 2rem; + height: 1.5rem; + background-color: var(--color); + border: 2px solid var(--border-color); + border-radius: 10px; + font-weight: var(--bold-on-dark); + } + } + } + .schedule-view { padding: 1rem;