Aggiunta trasposizione

feat/schema
Francesco Baldino 11 months ago
parent a34d36257b
commit 7efb311a61

@ -1,5 +1,5 @@
import { Courses } from './view/Courses.jsx' import { Courses } from './view/Courses.jsx'
import { WorkWeekGrid as Schedule } from './view/WorkWeekGrid.jsx' import { Schedule } from './view/Schedule.jsx'
export const MODE_COURSES = 'course' export const MODE_COURSES = 'course'
export const MODE_WORKWEEK = 'work-week' export const MODE_WORKWEEK = 'work-week'
@ -12,12 +12,31 @@ const viewModeMap = {
[MODE_SCHEDULE]: Schedule, [MODE_SCHEDULE]: Schedule,
} }
export const EventsView = ({ mode, ...viewProps }) => { // export const EventsView = ({ mode, ...viewProps }) => {
const Mode = viewModeMap[mode] // const Mode = viewModeMap[mode]
//
// return (
// <div class="events-view">
// <Mode {...viewProps} />
// </div>
// )
// }
return ( export const EventsView = ({ mode, source, ...viewProps }) => {
<div class="events-view"> // const Mode = viewModeMap[mode]
<Mode {...viewProps} />
</div>
) if (source === 'orario') {
return (
<div class="events-view">
<Schedule source={source} {...viewProps} />
</div>
)
} else {
return (
<div class="events-view">
<Courses source={source} {...viewProps} />
</div>
)
}
} }

@ -3,7 +3,7 @@ import { MODE_COURSES, MODE_SCHEDULE, MODE_WORKWEEK, MODE_WORKWEEK_GRID } from '
import { Help } from './Help.jsx' import { Help } from './Help.jsx'
import { Icon } from './Icon.jsx' import { Icon } from './Icon.jsx'
export const HamburgerMenu = ({ onClose, mode, setMode, source, setSource, theme, setTheme }) => { export const HamburgerMenu = ({ onClose, theme, setTheme }) => {
return ( return (
<div class="menu"> <div class="menu">
<div class="header"> <div class="header">

@ -2,47 +2,29 @@ import { Icon } from './Icon.jsx'
export const Help = ({}) => ( export const Help = ({}) => (
<> <>
<h3>Selezione Corsi</h3> <h3>Visualizzazione Corsi</h3>
<p> <p>
Puoi selezionare dei corsi cliccandoli, e nascondere gli altri con il tasto <Icon name="visibility" /> che Per visualizzare i corsi che ti interessano nella tabella
comparirà in basso a destra. dell'orario, devi prima selezionarli. Puoi selezionare dei corsi
cliccandoli, cercandoli nelle sezioni Primo anno (<b>I</b>), Secondo
anno (<b>II</b>), Terzo anno (<b>III</b>), Magistrale (<b>M</b>), o
Tutti.
</p> </p>
<p> <p>
La selezione effettuata verrà preservata su tutte le modalità di visualizzazione disponibili, permettendo di individuare eventuali sovrapposizioni. Una volta compiuta la selezione, è possibile vedere la tabella delle
lezioni andando nella visualizzazione Orario
</p> </p>
<p> <p>
I corsi sono raggruppati per: primo, secondo, terzo anno e magistrale (rispettivamente <b>I</b>, <b>II</b>, <b>III</b> ed <b>M</b> su mobile). Per via di eventuali preferenze personali, è possibile cambiare
Altrimenti, puoi scegliere fra tutti i corsi disponibili cliccando su <b>Tutti</b>{' '} l'orientazione della tabella Orario trasponendola, utilizzando il
(tasto <Icon name="apps" /> su mobile). pulsante Trasponi (
</p> <Icon name="switch_left" style="transform: rotate(-45deg)" />)
<h3>Mobile</h3>
<p>
Da mobile, ci sono tre visualizzazioni possibili:
<ul>
<li>
<Icon name="list" /> <b>Corsi</b> mostra una lista dei corsi in ordine
alfabetico con ogni lezione per corso
</li>
<li>
<Icon name="calendar_view_month" /> <b>Schema</b> mostra uno schema compatto dei
corsi selezionati
</li>
<li>
<Icon name="calendar_view_day" />
<b>Giorno</b> una visualizzazione giornaliera della settimana
</li>
</ul>
</p>
<h3>Desktop</h3>
<p>
Da desktop, si può inoltre visualizzare l'orario settimanale esteso cliccando su <b>Settimana</b>, che
mostrerà un calendario con i corsi attualmente selezionati.
</p> </p>
<h3>Stampa</h3> <h3>Stampa</h3>
<p> <p>
Da desktop puoi stampare l'orario attualmente visibile con il bottone{' '} Da desktop puoi stampare l'orario attualmente visibile con il
<Icon name="print" /> (è consigliato controllare le opzioni di stampa per ottenere un bottone <Icon name="print" /> (è consigliato controllare le opzioni
risultato soddisfacente). di stampa per ottenere un risultato soddisfacente).
</p> </p>
<h3>Bug &amp; Contatti</h3> <h3>Bug &amp; Contatti</h3>
<p> <p>
@ -51,8 +33,10 @@ export const Help = ({}) => (
https://git.phc.dm.unipi.it/phc/orario https://git.phc.dm.unipi.it/phc/orario
</a> </a>
, contattarci all'indirizzo{' '} , contattarci all'indirizzo{' '}
<a href="mailto:macchinisti@lists.dm.unipi.it">macchinisti@lists.dm.unipi.it</a> oppure <a href="mailto:macchinisti@lists.dm.unipi.it">
passare direttamente in PHC. macchinisti@lists.dm.unipi.it
</a>{' '}
oppure passare direttamente in PHC.
</p> </p>
</> </>
) )

@ -12,12 +12,23 @@ export const OptionBar = ({ source, setSource }) => {
{ value: 'anno-2', label: 'II' }, { value: 'anno-2', label: 'II' },
{ value: 'anno-3', label: 'III' }, { value: 'anno-3', label: 'III' },
{ value: 'magistrale', label: 'M' }, { value: 'magistrale', label: 'M' },
{ value: 'tutti', label: <Icon name="apps" />, icon: true }, { value: 'tutti', label: 'Tutti' },
]} ]}
value={source} value={source}
setValue={setSource} setValue={setSource}
/> />
</div> </div>
<CompoundButton
options={[
{
value: 'orario',
label: <Icon name="calendar_month" />,
icon: true,
},
]}
value={source}
setValue={setSource}
/>
</div> </div>
</div> </div>
) )

@ -9,17 +9,21 @@ export const Popup = ({ title, children, onClose }) => {
class="popup-container" class="popup-container"
ref={popupLeaveRegionRef} ref={popupLeaveRegionRef}
onClick={e => onClick={e =>
popupLeaveRegionRef.current && popupLeaveRegionRef.current === e.target && onClose() popupLeaveRegionRef.current &&
popupLeaveRegionRef.current === e.target &&
onClose()
} }
> >
<div class="popup"> <div class="popup-wrapper">
<div class="header"> <div class="popup">
<div class="title">{title}</div> <div class="header">
<button class="flat icon" onClick={onClose}> <div class="title">{title}</div>
<Icon name="close" /> <button class="flat icon" onClick={onClose}>
</button> <Icon name="close" />
</button>
</div>
<div class="content">{children}</div>
</div> </div>
<div class="content">{children}</div>
</div> </div>
</div> </div>
) )

@ -4,7 +4,7 @@ import { MODE_COURSES } from './EventsView.jsx'
export const ToolOverlay = ({ mode, toggleMode, onClose }) => ( export const ToolOverlay = ({ mode, toggleMode, onClose }) => (
<div class="overlay"> <div class="overlay">
<button class="icon primary" onClick={toggleMode}> <button class="icon primary" onClick={toggleMode}>
<Icon name={mode === MODE_COURSES ? 'calendar_view_month' : 'list'} /> <Icon name={mode === MODE_COURSES ? 'calendar_month' : 'view_list'} />
</button> </button>
<button class="icon" onClick={onClose}> <button class="icon" onClick={onClose}>
<Icon name="close" /> <Icon name="close" />

@ -17,7 +17,8 @@ export const Toolbar = ({
</button> </button>
</div> </div>
<div class="item logo"> <div class="item logo">
<img src="logo-circuit-board.svg" alt="logo" /> / <span>Orario</span> <img src="logo-circuit-board.svg" alt="logo" /> /{' '}
<span>Orario</span>
</div> </div>
<div class="option-group"> <div class="option-group">
<div class="item option"> <div class="item option">
@ -33,6 +34,15 @@ export const Toolbar = ({
setValue={setSource} setValue={setSource}
/> />
</div> </div>
<div class="item option">
<CompoundButton
options={[
{ value: 'orario', label: 'Orario' },
]}
value={source}
setValue={setSource}
/>
</div>
</div> </div>
<div class="option-group"> <div class="option-group">
<div class="item option"> <div class="item option">
@ -43,9 +53,13 @@ export const Toolbar = ({
<div class="item option"> <div class="item option">
<button <button
class="icon" class="icon"
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')} onClick={() =>
setTheme(theme === 'dark' ? 'light' : 'dark')
}
> >
<Icon name={theme === 'dark' ? 'dark_mode' : 'light_mode'} /> <Icon
name={theme === 'dark' ? 'dark_mode' : 'light_mode'}
/>
</button> </button>
</div> </div>
<div class="item option"> <div class="item option">

@ -1,55 +1,295 @@
import { format } from 'date-fns' import { useEffect, useRef, useState } from 'preact/hooks'
import _ from 'lodash' import _ from 'lodash'
import { prettyCourseName, WEEK_DAYS, withClasses } from '../../utils.jsx' import { differenceInMinutes, startOfDay } from 'date-fns'
export const Schedule = ({ timetables, selection, setSelection }) => { import {
const events = timetables['tutti'] WEEK_DAYS_SHORT,
const selectionSet = new Set(selection) prettyCourseName,
usePersistentState,
} from '../../utils.jsx'
import { layoutEvents, layoutIntervals } from '../../interval-layout.js'
import { Popup } from '../Popup.jsx'
import { Icon } from '../Icon.jsx'
const TransposePopup = ({ onClose }) => {
return (
<Popup
title={
<>
<Icon name="info" /> Attenzione! La tabella è stata
trasposta!
</>
}
onClose={onClose}
>
<p>
A grande richiesta popolare abbiamo trasposto la tabella
dell'orario!
</p>
const visibleEvents = events.filter(e => selectionSet.has(e.id)) <p>
Assicurati quindi di leggerla correttamente (dall'alto verso il
basso invece che da sinistra verso destra).
</p>
<p>
Se preferisci utilizzare la versione vecchia, puoi utilizzare il
pulsante Trasponi{' '}
<Icon name="switch_left" style="transform: rotate(-45deg)" />{' '}
nell'origine della tabella per trasporla. Questa scelta verrà
salvata nei cookie e verrà ricordata in futuro
</p>
</Popup>
)
}
const NoCourseWarning = () => {
return (
<div class="no-courses-warning">
<p>Non hai ancora selezionato nessun corso.</p>
<p>
Clicca sui corsi nelle altre visuali per selezionarli e
visualizzarli nell'orario
</p>
</div>
)
}
const eventsByWeekday = _.mapValues( const Layout = ({ layout, day, colors }) => {
_.groupBy(visibleEvents, e => e.start.getDay()), console.log(layout)
dailyEvents => _.groupBy(dailyEvents, e => e.start.getHours()) return (
<>
{layout.map(block => (
<div
class="event-block-wrapper"
style={{
'--time-start': block.start,
'--time-end': block.end,
'--day-position': day,
}}
>
<div class="event-block">
{block.events.map((event, index) => (
<div
class="event-wrapper"
style={{
'--block-size': block.end - block.start,
'--size': event.end - event.start,
'--relative-start':
event.start - block.start,
'--index': event.index,
'--of': block.layers,
'--color': `var(--event-${
colors[event.id]
})`,
}}
>
<div class="event">
{event.aule.map(aula => (
<div>{aula.replace(/^Fib /, '')}</div>
))}
</div>
</div>
))}
</div>
</div>
))}
</>
) )
}
const ScheduleGrid = ({
orientation,
setOrientation,
weekStart,
weekEnd,
dayBlocksLayout,
colors,
}) => {
return ( return (
<div class="schedule-view"> <div
{Object.entries(eventsByWeekday).map(([index, dailyEvents]) => ( class={`grid ${orientation}`}
style={{
'--time-slots': weekEnd - weekStart,
}}
>
<div class="transpose-button">
<button
class="small"
onClick={() =>
setOrientation(
orientation === 'original'
? 'transposed'
: 'original'
)
}
>
<Icon
name="switch_left"
style="transform: rotate(-45deg)"
/>
</button>
</div>
{[1, 2, 3, 4, 5].map(n => (
<> <>
<div class="header giorno"> <div class="day-label" style={`--position: ${n + 1}`}>
<div class="inner">{WEEK_DAYS[index]}</div> {WEEK_DAYS_SHORT[n]}
</div> </div>
{Object.values(dailyEvents).map(events => ( <div class="day-line" style={`--position: ${n + 1}`}></div>
<> </>
<div class="header orario">{format(events[0].start, 'H:mm')}</div> ))}
{events.map(event => (
<div {[9, 11, 14, 16].map(n => (
class={withClasses([ <div
'event', class="time-label"
selectionSet.has(event.id) && 'selected', style={{
])} '--position': n * 2 - weekStart,
onClick={() => { }}
if (!selectionSet.has(event.id)) >
setSelection([...selection, event.id]) {n}-{n + 2}
else </div>
setSelection( ))}
selection.filter(selId => selId !== event.id) <div class="time-line" style="--position: 0"></div>
) {[9, 11, 13, 14, 16, 18].map(n => (
}} <>
> {n * 2 > weekStart && n * 2 < weekEnd && (
<div class="title">{prettyCourseName(event.name)}</div> <div
<div class="orario"> class="time-line"
{format(event.start, 'H:mm')} &ndash;{' '} style={{
{format(event.end, 'H:mm')} '--position': n * 2 - weekStart,
</div> }}
<div class="aula">{event.aule.join(', ')}</div> ></div>
</div> )}
))} </>
</> ))}
))}
{Object.entries(dayBlocksLayout).map(([day, layout]) => (
<Layout layout={layout} day={day} colors={colors} />
))}
</div>
)
}
const ScheduleLegend = ({ courses, colors }) => {
return (
<div class="legend">
{courses.map(course => (
<>
<div
class="color"
style={{
'--color': `var(--event-${colors[course.id]})`,
}}
></div>
<div class="name">{prettyCourseName(course.name)}</div>
</> </>
))} ))}
</div> </div>
) )
} }
const ScheduleCard = ({
orientation,
setOrientation,
weekStart,
weekEnd,
dayBlocksLayout,
courses,
colors,
}) => {
return (
<div class="schedule-card">
<ScheduleGrid
orientation={orientation}
setOrientation={setOrientation}
weekStart={weekStart}
weekEnd={weekEnd}
dayBlocksLayout={dayBlocksLayout}
colors={colors}
/>
<ScheduleLegend courses={courses} colors={colors} />
</div>
)
}
export const Schedule = ({ timetables, selection, setSelection }) => {
const [hasSeenTranspose, setHasSeenTranspose] = usePersistentState(
'transpose_info',
'false'
)
const [orientation, setOrientation] = usePersistentState(
'orientation',
'original'
)
const colorList = [
'red',
'purple',
'blue',
'yellow',
'green',
'orange',
'lightblue',
]
const allEvents = timetables['tutti']
const selectionSet = new Set(selection)
const events = allEvents
.filter(e => selectionSet.has(e.id))
.map(e => ({
...e,
day: e.start.getDay(),
start: differenceInMinutes(e.start, startOfDay(e.start)),
end: differenceInMinutes(e.end, startOfDay(e.start)),
}))
const weekStart = Math.min(...events.map(e => e.start), 9 * 60) / 30
const weekEnd = Math.max(...events.map(e => e.end), 18 * 60) / 30
const relativeEvents = events.map(e => ({
...e,
start: Math.round(e.start / 30 - weekStart),
end: Math.round(e.end / 30 - weekStart),
}))
const courses = _.uniqBy(events, event => event.id)
const colors = Object.fromEntries(
courses.map((course, index) => {
return [course.id, colorList[index % colorList.length]]
})
)
const base = {
1: [],
2: [],
3: [],
4: [],
5: [],
}
const eventsByWeekday = {
...base,
..._.groupBy(relativeEvents, event => event.day),
}
const dayBlocksLayout = _.mapValues(eventsByWeekday, dayEvents =>
layoutEvents(dayEvents)
)
return (
<>
{hasSeenTranspose === 'false' && (
<TransposePopup onClose={() => setHasSeenTranspose('true')} />
)}
<div class="schedule-view">
{selection.length === 0 && <NoCourseWarning />}
<ScheduleCard
orientation={orientation}
setOrientation={setOrientation}
weekStart={weekStart}
weekEnd={weekEnd}
dayBlocksLayout={dayBlocksLayout}
courses={courses}
colors={colors}
/>
</div>
</>
)
}

@ -1,157 +0,0 @@
import { useEffect, useRef, useState } from 'preact/hooks'
import _ from 'lodash'
import { differenceInMinutes, startOfDay } from 'date-fns'
import { hashString, prettyCourseName, WEEK_DAYS } from '../../utils.jsx'
import { layoutIntervals } from '../../interval-layout.js'
export const WorkWeek = ({ events, selection, setSelection, hideOtherCourses }) => {
const selectionSet = new Set(selection)
const eventsByWeekday = _.groupBy(
!hideOtherCourses ? events : events.filter(e => selectionSet.has(e.id)),
event => event.start.getDay()
)
// const dayIntervalLayout = _.mapValues(Object.assign(base, eventsByWeekday), events =>
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 [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 (
<div class="work-week-v-view" ref={element}>
<div class="pivot"></div>
<div class="left-header">
<div class="blocks">
<div
class="block skip-border"
style={{
'--start': 2 * (9 - 7),
'--size': 4,
}}
>
9:00 &ndash; 11:00
</div>
<div
class="block"
style={{
'--start': 2 * (11 - 7),
'--size': 4,
}}
>
11:00 &ndash; 13:00
</div>
<div
class="block skip-border"
style={{
'--start': 2 * (14 - 7),
'--size': 4,
}}
>
14:00 &ndash; 16:00
</div>
<div
class="block"
style={{
'--start': 2 * (16 - 7),
'--size': 4,
}}
>
16:00 &ndash; 18:00
</div>
<div
class="block"
style={{
'--start': 2 * (18 - 7),
'--size': 4,
}}
>
18:00 &ndash; 20:00
</div>
</div>
</div>
{Object.entries(dayIntervalLayout).map(([index, layout]) => (
<div class="day" style={{ '--size': Math.max(1, layout.length) }}>
<div class="top-header">{WEEK_DAYS[parseInt(index)]}</div>
<div class="events">
{layout.map((events, stackIndex) => (
<>
{events.map(event => (
<div
class={
'event' +
(currentlyHovered === event.data.id
? ' highlight'
: '') +
(selectionSet.has(event.data.id) ? ' selected' : '')
}
data-event-id={event.data.id}
style={{
'--start': event.start / 30 - 14,
'--stack': stackIndex + 1,
'--size': (event.end - event.start) / 30,
'--hue':
(Math.abs(hashString('seed3' + event.data.id)) %
360) +
'deg',
}}
onClick={() => {
if (!selectionSet.has(event.data.id))
setSelection([...selection, event.data.id])
else
setSelection(
selection.filter(id => event.data.id !== id)
)
}}
>
<div class="title">{prettyCourseName(event.data.name)}</div>
<div class="aula">{event.data.aule.join(', ')}</div>
</div>
))}
</>
))}
{/* Grid Tracks */}
{[4, 8, 12].map(i => (
<div class="grid-line-h" style={{ '--track': i }}></div>
))}
{[14, 18, 22].map(i => (
<div class="grid-line-h" style={{ '--track': i }}></div>
))}
</div>
</div>
))}
</div>
)
}

@ -1,227 +0,0 @@
import { useEffect, useRef, useState } from 'preact/hooks'
import _ from 'lodash'
import { differenceInMinutes, startOfDay } from 'date-fns'
import { prettyCourseName } from '../../utils.jsx'
import { layoutIntervals } from '../../interval-layout.js'
export const WorkWeekGrid = ({ timetables, selection, setSelection }) => {
const events = timetables['tutti']
const selectionSet = new Set(selection)
const colorList = ['red', 'purple', 'blue', 'yellow', 'green', 'orange', 'lightblue']
const courses = _.uniqBy(
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(
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 (
<div class="work-week-grid-view" ref={element}>
<div
class="grid"
style={{ '--days-length': daysLength, '--time-length': timeEnd - timeStart }}
>
{[7, 9, 11, 14, 16, 18].map(n => (
<>
{n + 2 > timeStart && n < timeEnd && (
<div class="time" style={{ '--offset': n - timeStart + 2 }}>
{n}-{n + 2}
</div>
)}
</>
))}
<div class="vline" style="--offset: 2"></div>
{[9, 11, 13, 14, 16, 18].map(n => (
<>
{n > timeStart && n < timeEnd && (
<div class="vline" style={{ '--offset': n - timeStart + 2 }}></div>
)}
</>
))}
<div class="day-name" style={{ '--line': 2 + dayOffsets[0] }}>
Lun
</div>
<div class="day-name" style={{ '--line': 2 + dayOffsets[1] }}>
Mar
</div>
<div class="day-name" style={{ '--line': 2 + dayOffsets[2] }}>
Mer
</div>
<div class="day-name" style={{ '--line': 2 + dayOffsets[3] }}>
Gio
</div>
<div class="day-name" style={{ '--line': 2 + dayOffsets[4] }}>
Ven
</div>
<div class="hline" style={{ '--line': 2 + dayOffsets[0] }}></div>
<div class="hline" style={{ '--line': 2 + dayOffsets[1] }}></div>
<div class="hline" style={{ '--line': 2 + dayOffsets[2] }}></div>
<div class="hline" style={{ '--line': 2 + dayOffsets[3] }}></div>
<div class="hline" style={{ '--line': 2 + dayOffsets[4] }}></div>
{Object.entries(dayIntervalLayout).map(([index, layout]) => (
<>
{layout.map((events, stackIndex) => (
<>
{events.map(event => (
<div
class={
'event' +
(currentlyHovered === event.data.id
? ' highlight'
: '') +
(selectionSet.has(event.data.id) ? ' selected' : '')
}
data-event-id={event.data.id}
style={{
'--line': 2 + dayOffsets[index - 1] + stackIndex,
'--offset': event.start / 60 - timeStart + 2,
'--length': (event.end - event.start) / 60,
'--color': `var(--bubble-${colors[event.data.id][0]})`,
'--border-color': `var(--bubble-border-${
colors[event.data.id][0]
})`,
'--highlight-color': `var(--bubble-highlight-${
colors[event.data.id][0]
})`,
}}
// onClick={() => {
// 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] !== '' && (
<div className="distinguisher">{`(${
colors[event.data.id][1]
})`}</div>
)}
{event.data.aule.join(', ')}
</div>
))}
</>
))}
</>
))}
</div>
<div class="legend">
{courses.map(course => (
<>
<div
class="color"
style={{
'--color': `var(--bubble-${colors[course.id][0]})`,
'--border-color': `var(--bubble-border-${colors[course.id][0]})`,
}}
>
{colors[course.id][1]}
</div>
<div class="name">{prettyCourseName(course.name)}</div>
</>
))}
</div>
</div>
)
}

@ -1,104 +0,0 @@
import { useEffect, useRef } from 'preact/hooks'
import _ from 'lodash'
import { differenceInMinutes, startOfDay } from 'date-fns'
import { hashString, prettyCourseName, WEEK_DAYS } from '../../utils.jsx'
import { layoutIntervals } from '../../interval-layout.js'
export const WorkWeekTranspose = ({ events }) => {
const eventsByWeekday = _.groupBy(events, event => event.start.getDay())
// For each weekday compute the interval layout
const rowLayouts = _.mapValues(eventsByWeekday, events =>
layoutIntervals(
events.map(e => ({
start: differenceInMinutes(e.start, startOfDay(e.start)),
end: differenceInMinutes(e.end, startOfDay(e.start)),
data: e,
}))
)
)
return (
<div class="work-week-h-view">
<div class="week">
{WEEK_DAYS.slice(1, 6).map((label, index) => (
<div class="day" style={{ '--size': rowLayouts[index + 1]?.length ?? 0 }}>
{label}
</div>
))}
</div>
<div class="events">
<div class="header">
<div class="label" style={{ 'grid-column': '2 / span 2' }}>
9:00 &ndash; 11:00
</div>
<div class="label" style={{ 'grid-column': '4 / span 2' }}>
11:00 &ndash; 13:00
</div>
<div class="label" style={{ 'grid-column': '7 / span 2' }}>
14:00 &ndash; 16:00
</div>
<div class="label" style={{ 'grid-column': '9 / span 2' }}>
16:00 &ndash; 18:00
</div>
</div>
<div class="days">
{Object.values(rowLayouts).map(layout => (
<div class="day" style={{ '--size': layout.length }}>
{layout.map((events, rowIndex) =>
events.map(event => {
const Local = () => {
const eventRef = useRef()
const updateMinWidth = () => {
eventRef.current.style.minWidth =
eventRef.current.offsetWidth + 'px'
}
useEffect(() => {
if (eventRef.current) {
setTimeout(updateMinWidth, 100)
window.addEventListener('resize', updateMinWidth)
return () => {
window.removeEventListener(
'resize',
updateMinWidth
)
}
}
}, [eventRef.current])
return (
<div
class="event"
style={{
'--row': rowIndex + 1,
'--start': event.start / 60 - 7,
'--size': (event.end - event.start) / 60,
'--hue':
(Math.abs(
hashString('seed3' + event.data.id)
) %
360) +
'deg',
}}
ref={eventRef}
>
{prettyCourseName(event.data.name)}
</div>
)
}
return <Local />
})
)}
</div>
))}
</div>
</div>
</div>
)
}

@ -34,6 +34,62 @@ export function layoutIntervals(intervals, { tight } = {}) {
return stack.map(({ intervals }) => intervals) return stack.map(({ intervals }) => intervals)
} }
function layoutBlockEvents(events) {
events.sort((a, b) => a.start - b.start)
let result = []
for (const event of events) {
let viableIndex = 0
while (
result.filter(
e =>
e.index === viableIndex &&
e.start < event.end &&
event.start < e.end
).length !== 0
) {
viableIndex += 1
}
result.push({ ...event, index: viableIndex })
}
return result
}
export function layoutEvents(events) {
const overlap = (event, block) =>
event.start < block.end && block.start < event.end
events.sort((a, b) => a.start - b.start)
let layout = []
for (const event of events) {
const blocks = layout.filter(block => overlap(event, block))
if (blocks.length > 0) {
layout = layout.filter(block => !overlap(event, block))
layout.push({
start: Math.min(
event.start,
...blocks.map(block => block.start)
),
end: Math.max(event.end, ...blocks.map(block => block.end)),
events: blocks.flatMap(block => block.events).concat([event]),
})
} else {
layout.push({ start: event.start, end: event.end, events: [event] })
}
}
return layout.map(block => {
const events = layoutBlockEvents(block.events)
return {
...block,
layers: Math.max(...events.map(event => event.index)) + 1,
events: events,
}
})
}
// // // //
// // Testing... // // Testing...
// // // //

@ -1,43 +1,44 @@
import _ from 'lodash' import _ from 'lodash'
import { render } from 'preact' import { render } from 'preact'
import { useEffect, useState } from 'preact/hooks' import { useEffect, useState } from 'preact/hooks'
import { ToolOverlay } from './components/CourseVisibility.jsx'
import { EventsView, MODE_COURSES, MODE_SCHEDULE } from './components/EventsView.jsx' // import { ToolOverlay } from './components/ToolOverlay.jsx'
//
// import {
// EventsView,
// MODE_COURSES,
// MODE_SCHEDULE,
// } from './components/EventsView.jsx'
import { Courses } from './components/view/Courses.jsx'
import { Schedule } from './components/view/Schedule.jsx'
import { HamburgerMenu } from './components/HamburgerMenu.jsx' import { HamburgerMenu } from './components/HamburgerMenu.jsx'
import { Help } from './components/Help.jsx' import { Help } from './components/Help.jsx'
import { Icon } from './components/Icon.jsx' import { Icon } from './components/Icon.jsx'
import { Popup } from './components/Popup.jsx' import { Popup } from './components/Popup.jsx'
import { Toolbar } from './components/Toolbar.jsx' import { Toolbar } from './components/Toolbar.jsx'
import { OptionBar } from './components/OptionBar.jsx' import { OptionBar } from './components/OptionBar.jsx'
import { prettyAulaName, prettyProfName, usePersistentState } from './utils.jsx' import {
prettyAulaName,
prettyProfName,
clearOldPersistentStates,
usePersistentState,
} from './utils.jsx'
// Che fanno queste due righe?
window._ = _ window._ = _
window.dataBuffer = {} window.dataBuffer = {}
const CALENDAR_IDS = {
'anno-1': ['64a7c1c651f079001d52e9c8'],
'anno-2': ['6308e2dc09352a0208fefdd9'],
'anno-3': ['6308e42a1df5cb026699ced4'],
'magistrale': ['64a7c7091ab813002c5d9ede'],
'tutti': [
'64a7c1c651f079001d52e9c8',
'6308e2dc09352a0208fefdd9',
'6308e42a1df5cb026699ced4',
'64a7c7091ab813002c5d9ede',
],
}
const TIMETABLE_IDS = { const TIMETABLE_IDS = {
'anno-1': '64a7c1c651f079001d52e9c8', 'anno-1': '64a7c1c651f079001d52e9c8',
'anno-2': '6308e2dc09352a0208fefdd9', 'anno-2': '6308e2dc09352a0208fefdd9',
'anno-3': '6308e42a1df5cb026699ced4', 'anno-3': '6308e42a1df5cb026699ced4',
'magistrale': '64a7c7091ab813002c5d9ede', 'magistrale': '64a7c7091ab813002c5d9ede',
} }
const DEFAULT_DATE_RANGE = { const DEFAULT_DATE_RANGE = {
from: '2023-10-02T00:00:00.000Z', from: '2023-10-09T00:00:00.000Z',
to: '2023-10-07T00:00:00.000Z', to: '2023-10-14T00:00:00.000Z',
} }
const DATE_RANGES = { const DATE_RANGES = {
@ -51,7 +52,10 @@ function specialEventPatches(eventi) {
// Il laboratorio del primo anno in realtà è in due canali separati // Il laboratorio del primo anno in realtà è in due canali separati
let i = 1 let i = 1
eventi.forEach(evento => { eventi.forEach(evento => {
if (evento.nome === 'LABORATORIO DI INTRODUZIONE ALLA MATEMATICA COMPUTAZIONALE') { if (
evento.nome ===
'LABORATORIO DI INTRODUZIONE ALLA MATEMATICA COMPUTAZIONALE'
) {
evento.nome += ` (${i})` evento.nome += ` (${i})`
i++ i++
} }
@ -61,18 +65,18 @@ function specialEventPatches(eventi) {
} }
function formatEvents(timetable) { function formatEvents(timetable) {
return timetable.map( return timetable.map(({ nome, dataInizio, dataFine, docenti, aule }) => {
({ nome, dataInizio, dataFine, docenti, aule }) => { return {
return { id: nome,
id: nome, name: _.split(nome, '-', 1)[0].trim(),
name: _.split(nome, '-', 1)[0].trim(), start: new Date(dataInizio),
start: new Date(dataInizio), end: new Date(dataFine),
end: new Date(dataFine), docenti: docenti.map(({ nome, cognome }) =>
docenti: docenti.map(({ nome, cognome }) => prettyProfName(nome, cognome)), prettyProfName(nome, cognome)
aule: aule.map(aula => prettyAulaName(aula.codice)), ),
} aule: aule.map(aula => prettyAulaName(aula.codice)),
} }
) })
} }
async function loadCalendari() { async function loadCalendari() {
@ -112,31 +116,43 @@ async function loadCalendari() {
req(TIMETABLE_IDS['magistrale']), req(TIMETABLE_IDS['magistrale']),
] ]
const results = await Promise.all(requests) const results = await Promise.all(requests)
const timetablesRaw = results.map(timetable => specialEventPatches(_.uniqBy(timetable, 'id'))) const timetablesRaw = results.map(timetable =>
specialEventPatches(_.uniqBy(timetable, 'id'))
)
const allRaw = specialEventPatches(_.uniqBy(_.concat(...results), 'id')) const allRaw = specialEventPatches(_.uniqBy(_.concat(...results), 'id'))
return { return {
'anno-1': formatEvents(timetablesRaw[0]), 'anno-1': formatEvents(timetablesRaw[0]),
'anno-2': formatEvents(timetablesRaw[1]), 'anno-2': formatEvents(timetablesRaw[1]),
'anno-3': formatEvents(timetablesRaw[2]), 'anno-3': formatEvents(timetablesRaw[2]),
'magistrale': formatEvents(timetablesRaw[3]), 'magistrale': formatEvents(timetablesRaw[3]),
'tutti': formatEvents(allRaw) 'tutti': formatEvents(allRaw),
} }
} }
const App = ({}) => { const App = ({}) => {
// Clear persistent states unless state_token corresponds to the one passed
// as the argument. Useful with breaking updates. Change this token if your
// (breaking) update needs a reset of persistent states to avoid crashes.
//
// Use any random string of your choice
clearOldPersistentStates('e73cba02')
// Data Sources // Data Sources
const [source, setSource] = usePersistentState('orario.source', 'magistrale') const [source, setSource] = usePersistentState('source', 'magistrale')
const [timetables, setTimetables] = useState(null) const [timetables, setTimetables] = useState(null)
useEffect(async () => { useEffect(async () => {
setTimetables(await loadCalendari()) setTimetables(await loadCalendari())
}, []) }, [])
// View Modes // View Modes
const [mode, setMode] = usePersistentState('orario.mode', MODE_COURSES) // const [mode, setMode] = usePersistentState('orario.mode', MODE_COURSES)
// Selection // Selection
const [selectedCourses, setSelectedCourses] = usePersistentState('orario.selection', []) const [selectedCourses, setSelectedCourses] = usePersistentState(
'selection',
[]
)
// Menus // Menus
const [helpVisible, setHelpVisible] = useState(false) const [helpVisible, setHelpVisible] = useState(false)
@ -146,8 +162,11 @@ const App = ({}) => {
const toolOverlayVisible = selectedCourses.length > 0 const toolOverlayVisible = selectedCourses.length > 0
const [theme, setTheme] = usePersistentState( const [theme, setTheme] = usePersistentState(
'orario.theme', 'theme',
window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' 'light'
// window.matchMedia('(prefers-color-scheme: dark)').matches
// ? 'dark'
// : 'light'
) )
document.body.classList.toggle('dark-mode', theme === 'dark') document.body.classList.toggle('dark-mode', theme === 'dark')
@ -156,8 +175,6 @@ const App = ({}) => {
<> <>
<Toolbar <Toolbar
{...{ {...{
mode,
setMode,
source, source,
setSource, setSource,
onShowMenu: () => setShowMobileMenu(true), onShowMenu: () => setShowMobileMenu(true),
@ -166,44 +183,53 @@ const App = ({}) => {
setTheme, setTheme,
}} }}
/> />
{mode === MODE_COURSES && ( <OptionBar
<OptionBar {...{
{...{ source,
mode, setSource,
setMode, onHelp: () => setHelpVisible(true),
source, }}
setSource, orizzontale
onHelp: () => setHelpVisible(true), />
}}orizzontale
/>
)}
{timetables && ( {timetables && (
<EventsView <div class="content">
mode={mode} {source === 'orario' ? (
selection={selectedCourses} <Schedule
setSelection={setSelectedCourses} selection={selectedCourses}
start={new Date(2022, 10, 3)} setSelection={setSelectedCourses}
source={source} start={new Date(2022, 10, 3)}
timetables={timetables} source={source}
/> timetables={timetables}
/>
) : (
<Courses
selection={selectedCourses}
setSelection={setSelectedCourses}
start={new Date(2022, 10, 3)}
source={source}
timetables={timetables}
/>
)}
</div>
)} )}
{toolOverlayVisible && ( {/* toolOverlayVisible && (
<ToolOverlay <ToolOverlay
mode={mode} mode={mode}
toggleMode={() => setMode(mode === MODE_COURSES ? MODE_SCHEDULE : MODE_COURSES)} toggleMode={() =>
setMode(
mode === MODE_COURSES ? MODE_SCHEDULE : MODE_COURSES
)
}
onClose={() => { onClose={() => {
setSelectedCourses([]) setSelectedCourses([])
setMode(MODE_COURSES) setMode(MODE_COURSES)
}} }}
/> />
)} ) */}
{showMobileMenu && ( {showMobileMenu && (
<HamburgerMenu <HamburgerMenu
{...{ {...{
mode,
setMode,
source,
setSource,
theme, theme,
setTheme, setTheme,
onClose: () => { onClose: () => {

@ -28,32 +28,14 @@ html {
--accent-500: #6cc16c; --accent-500: #6cc16c;
--accent-900: #244624; --accent-900: #244624;
--bubble-red: hsl(359, 100%, 92%); --event-red: hsl(359, 100%, 85%);
--bubble-purple: hsl(274, 100%, 92%); --event-purple: hsl(274, 100%, 85%);
--bubble-blue: hsl(241, 100%, 92%); --event-blue: hsl(241, 100%, 85%);
--bubble-yellow: hsl(50, 100%, 92%); --event-yellow: hsl(50, 100%, 85%);
--bubble-green: hsl(125, 100%, 92%); --event-green: hsl(125, 100%, 85%);
--bubble-orange: hsl(25, 100%, 92%); --event-orange: hsl(25, 100%, 85%);
--bubble-lightlightblue: hsl(176, 100%, 92%); --event-lightlightblue: hsl(176, 100%, 85%);
--bubble-lightblue: hsl(198, 100%, 92%); --event-lightblue: hsl(198, 100%, 85%);
--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; --bold-on-dark: 300;
} }
@ -76,29 +58,13 @@ body.dark-mode {
--accent-500: hsl(269, 40%, 70%); --accent-500: hsl(269, 40%, 70%);
--accent-900: hsl(269, 30%, 90%); --accent-900: hsl(269, 30%, 90%);
--bubble-red: hsl(359, 40%, 25%); --event-red: hsl(359, 40%, 31%);
--bubble-purple: hsl(274, 40%, 25%); --event-purple: hsl(274, 40%, 31%);
--bubble-blue: hsl(241, 40%, 25%); --event-blue: hsl(241, 40%, 31%);
--bubble-yellow: hsl(50, 40%, 25%); --event-yellow: hsl(50, 40%, 31%);
--bubble-green: hsl(125, 40%, 25%); --event-green: hsl(125, 40%, 31%);
--bubble-orange: hsl(25, 40%, 25%); --event-orange: hsl(25, 40%, 31%);
--bubble-lightblue: hsl(176, 40%, 25%); --event-lightblue: hsl(176, 40%, 31%);
--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; --bold-on-dark: 500;
} }
@ -214,6 +180,10 @@ button,
&.flat { &.flat {
border: none; border: none;
} }
&.small {
padding: 0.25rem;
}
} }
.input-combo { .input-combo {
@ -294,49 +264,60 @@ button,
max-width: 100%; max-width: 100%;
.popup { .popup-wrapper {
@extend .panel; max-width: 100%;
position: absolute; position: absolute;
top: 50%; top: 50%;
left: 50%; left: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
display: flex; padding: 1rem;
flex-direction: column;
gap: 1rem; .popup {
@extend .panel;
box-shadow: 0 0 1.5rem #00000033; // position: absolute;
// top: 50%;
// left: 50%;
// transform: translate(-50%, -50%);
.header {
display: flex; display: flex;
align-items: center; flex-direction: column;
justify-content: space-between; gap: 1rem;
user-select: none; box-shadow: 0 0 1.5rem #00000033;
.title { .header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; justify-content: space-between;
user-select: none;
.title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 24px; font-size: 24px;
}
} }
}
.content { .content {
@extend .text-block; @extend .text-block;
width: 60ch; width: 60ch;
max-width: 100%; max-width: 100%;
max-height: 60vh; height: fit-content;
max-height: 60vh;
overflow-y: auto; overflow-y: auto;
padding-bottom: 3rem; padding-bottom: 3rem;
} }
max-width: 100%; max-width: 100%;
}
} }
} }
@ -496,7 +477,7 @@ body {
} }
} }
.events-view { .content {
height: calc(100vh - 4rem); height: calc(100vh - 4rem);
@media screen and (max-width: $device-s-width), (pointer: coarse) { @media screen and (max-width: $device-s-width), (pointer: coarse) {
height: calc(100vh - 8rem); height: calc(100vh - 8rem);
@ -568,564 +549,226 @@ body {
} }
} }
.work-week-h-view { .schedule-view {
--header-height: 3rem; height: 100%;
--event-height: 2rem; width: 100%;
max-width: 57rem;
display: grid; margin: auto;
grid-template-columns: auto 1fr;
grid-template-rows: var(--header-height) 1fr;
overflow-y: scroll; padding: 1rem 0.5rem;
height: 100%; display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
.week { .no-courses-warning {
grid-column: 1 / 2; width: 100%;
grid-row: 2 / 3;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem;
align-items: center;
.day { p {
box-sizing: content-box; text-align: center;
// @media screen and (max-width: $device-s-width) {
// writing-mode: vertical-lr;
// }
height: calc(var(--size) * var(--event-height) + (var(--size) - 1) * 0.25rem);
border-top: 1px solid var(--border-500);
border-right: 1px solid var(--border-500);
padding: 0.5rem 1rem 0 0.5rem;
font-size: 18px;
font-weight: 400;
&:last-child {
border-bottom: 1px solid var(--border-500);
}
} }
} }
.schedule-card {
width: 100%;
.events { .grid {
grid-column: 2 / 3; width: 100%;
grid-row: 1 / 3;
overflow-y: hidden;
overflow-x: scroll;
// scrollbar-gutter: stable both-edges;
display: grid;
grid-template-rows: auto 1fr;
--hour-width: 7.5rem;
.header {
display: grid; display: grid;
grid-template-columns: repeat(12, var(--hour-width));
grid-template-rows: var(--header-height);
gap: 0.25rem;
.label {
display: grid;
place-content: center;
font-size: 18px;
font-weight: 400;
border-left: 1px solid var(--border-500); border: 1px solid var(--border-600);
border-radius: 10px 10px 0 0;
&:nth-child(2), @media screen and (max-width: $device-s-width),
&:nth-child(4) { (pointer: coarse) {
border-right: 1px solid var(--border-500); font-size: 12px;
}
} }
}
.days {
display: flex;
flex-direction: column;
.day {
display: grid;
// grid-template-columns: repeat(7, 0) repeat(12, 1fr) repeat(5, 0);
grid-template-columns: repeat(12, var(--hour-width));
grid-template-rows: repeat(var(--size), var(--event-height));
gap: 0.25rem;
position: relative; .transpose-button,
.day-label,
&:hover { .time-label {
z-index: 5; display: flex;
} align-items: center;
justify-content: center;
.event { padding: 0.5rem;
grid-row: var(--row) / span 1; }
grid-column: var(--start) / span var(--size);
$color: var(--bg-500);
background: $color;
color: var(--text-500);
border-radius: 0.25rem;
border: 1px solid var(--border-600);
display: flex; .transpose-button {
padding: 0.4rem; width: 4rem;
height: 3rem;
}
font-size: 15px; .event-block-wrapper {
padding: 0.125rem;
.event-block {
width: 100%; width: 100%;
height: 100%;
min-height: var(--event-height);
overflow: hidden;
position: relative; position: relative;
line-height: 1.1; .event-wrapper {
&::after {
content: '';
position: absolute; position: absolute;
inset: 0; top: 0%;
left: 0%;
border-radius: 0.25rem; padding: 0.125rem;
background: linear-gradient(to top, $color 0%, transparent 25%); .event {
} width: 100%;
height: 100%;
&:hover { display: flex;
z-index: 3; flex-direction: column;
align-items: center;
justify-content: center;
position: absolute; text-align: center;
overflow: visible;
&::after { background: var(--color);
background: none; border-radius: 10px;
font-weight: var(--bold-on-dark);
} }
} }
z-index: 2;
}
border-top: 1px solid var(--border-500);
padding: 0.25rem 0;
&:last-child {
border-bottom: 1px solid var(--border-500);
} }
} }
}
}
}
.work-week-v-view {
position: relative;
display: grid;
grid-template-columns: max-content repeat(5, minmax(auto, 1fr));
height: 100%;
overflow: scroll;
// --event-height: 4.75rem;
--event-height: 6rem;
.pivot {
height: 3rem;
grid-column: 1 / 2;
grid-row: 1 / 2;
position: sticky;
top: 0;
left: 0;
z-index: 2;
background: var(--bg-500);
border-bottom: 1px solid var(--border-500); &.original {
border-right: 1px solid var(--border-500); grid-template-columns: auto repeat(5, 1fr);
} grid-template-rows: min-content repeat(
var(--time-slots),
.left-header { 1fr
grid-column: 1 / 2; );
grid-row: 1 / 2;
.transpose-button,
display: grid; .day-label {
grid-column: var(--position);
position: sticky; grid-row: 1;
left: 0;
border-right: 1px solid var(--border-500);
background: var(--bg-500);
z-index: 1;
padding-top: 3rem;
.blocks {
display: grid;
grid-template-rows: repeat(24, calc(var(--event-height) / 2));
.block {
grid-row: var(--start) / span var(--size);
display: grid;
place-content: center;
padding: 0.5rem;
font-size: 16px;
font-weight: 400;
border-top: 1px solid var(--border-500);
@media screen and (max-width: $device-s-width), (pointer: coarse) {
writing-mode: vertical-lr;
} }
.time-label {
&:not(.skip-border) { min-height: 5rem;
height: calc(2 * var(--event-height) + 0.5px); grid-column: 1;
border-bottom: 1px solid var(--border-500); grid-row: calc(var(--position) + 2) / span 4;
} }
}
}
}
.day {
position: relative;
display: flex;
flex-direction: column;
.top-header {
position: sticky;
top: 0;
height: 3rem;
flex: 0 0 auto;
z-index: 1;
grid-column: span var(--size);
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 400;
background: var(--bg-500);
border-bottom: 1px solid var(--border-500);
}
// &:not(:last-child) {
border-right: 1px solid var(--border-500);
// }
.events {
position: relative;
display: grid;
grid-template-columns: repeat(var(--size), auto);
grid-template-rows: repeat(24, calc(var(--event-height) / 2));
padding: 0 0.25rem;
// gap: 1rem 0.5rem;
.event {
position: relative;
cursor: pointer;
grid-row: var(--start) / span var(--size);
grid-column: var(--stack) / span 1;
background: var(--bg-500);
border: 1px solid var(--border-600);
// border: 4px solid hsl(var(--hue), 80%, 80%);
border-radius: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.5rem 1rem 0.5rem 0.5rem;
margin: 0.5rem 0.25rem;
// box-sizing: content-box;
&.selected {
background: var(--bg-selected-500);
&::after { .day-line {
content: ''; grid-column: var(--position);
position: absolute; grid-row: 1/-1;
inset: -1px; width: 0px;
border-radius: 0.5rem; border-right: 1px solid var(--border-500);
}
.time-line {
grid-row: calc(var(--position) + 2);
grid-column: 1/-1;
height: 0px;
border-top: 1px dashed var(--border-500);
}
border: 3px solid var(--border-selected-500); .event-block-wrapper {
grid-row: calc(var(--time-start) + 2) /
calc(var(--time-end) + 2);
grid-column: calc(var(--day-position) + 1);
.event-block {
.event-wrapper {
width: calc(100% / var(--of));
height: calc(
100% * var(--size) / var(--block-size)
);
transform: translateX(calc(100% * var(--index)))
translateY(
calc(
100% * var(--relative-start) /
var(--size)
)
);
}
} }
} }
}
&.highlight { &.transposed {
background: var(--bg-highlight-500); grid-template-rows: auto repeat(5, 1fr);
border-color: var(--border-highlight-500); grid-template-columns: min-content repeat(
var(--time-slots),
1fr
);
.transpose-button,
.day-label {
grid-row: var(--position);
grid-column: 1;
} }
.day-label {
.title { min-height: 5rem;
font-size: 18px;
font-weight: 400;
} }
.time-label {
.aula { grid-row: 1;
font-size: 16px; grid-column: calc(var(--position) + 2) / span 4;
font-weight: 300;
} }
}
.grid-line-h {
grid-column: 1 / span var(--size);
grid-row: var(--track) / span 1;
height: 0px;
border-bottom: 1px dashed var(--border-500);
transform: translate(0, -1px);
// align-self: center; .day-line {
grid-row: var(--position);
position: relative; grid-column: 1/-1;
top: 0.5px; height: 0px;
left: -0.5rem; border-top: 1px solid var(--border-500);
width: calc(100% + 1rem); }
.time-line {
grid-column: calc(var(--position) + 2);
grid-row: 1/-1;
width: 0px;
border-right: 1px dashed var(--border-500);
}
z-index: -1; .event-block-wrapper {
grid-column: calc(var(--time-start) + 2) /
calc(var(--time-end) + 2);
grid-row: calc(var(--day-position) + 1);
.event-block {
.event-wrapper {
height: calc(100% / var(--of));
width: calc(
100% * var(--size) / var(--block-size)
);
transform: translateY(calc(100% * var(--index)))
translateX(
calc(
100% * var(--relative-start) /
var(--size)
)
);
}
}
}
} }
} }
}
}
.work-week-grid-view {
position: relative;
padding: 1rem;
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
overflow-y: auto;
.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: 1px solid var(--border-600);
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 { .legend {
display: flex; display: grid;
align-items: center; grid-template-columns: min-content 1fr;
justify-content: center; border: 1px solid var(--border-600);
padding: 0.5rem; border-radius: 0 0 10px 10px;
grid-column: 1; border-top: none;
grid-row: var(--line); padding: 1rem;
} gap: 0.5rem 1rem;
.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; align-items: center;
justify-content: 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: 1px solid var(--border-600);
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%; 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;
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
overflow-y: scroll; .name {
width: 100%;
height: 100%;
& > * {
max-width: 50rem;
}
.header {
position: relative;
width: 100%;
display: grid;
place-content: center;
&.giorno {
font-size: 24px;
font-weight: 400;
.inner {
background: var(--bg-500);
padding: 0 0.5rem;
} }
&::before { .color {
position: absolute; width: 2rem;
height: 1.5rem;
content: ''; background-color: var(--color);
top: 50%; border-radius: 10px;
left: 0;
right: 0;
height: 1px;
background: var(--border-600);
z-index: -1;
}
}
&.orario {
font-size: 18px;
font-weight: 300;
}
}
.event {
@extend .panel;
width: 100%;
.title {
font-size: 22px;
}
position: relative;
cursor: pointer;
@media screen and (min-width: $device-s-width), (pointer: fine) {
&:hover {
background: var(--bg-highlight-500);
border-color: var(--border-highlight-500);
}
}
&.selected {
background: var(--bg-selected-500);
&::after {
content: '';
position: absolute;
inset: -1px;
border-radius: 1rem;
border: 3px solid var(--border-selected-500);
} }
} }
} }

@ -12,6 +12,16 @@ export const WEEK_DAYS = [
'Sabato', 'Sabato',
] ]
export const WEEK_DAYS_SHORT = [
'Dom',
'Lun',
'Mar',
'Mer',
'Gio',
'Ven',
'Sab'
]
// Hashing // Hashing
export function hashString(str, seed = 0) { export function hashString(str, seed = 0) {
@ -22,8 +32,12 @@ export function hashString(str, seed = 0) {
h1 = Math.imul(h1 ^ ch, 2654435761) h1 = Math.imul(h1 ^ ch, 2654435761)
h2 = Math.imul(h2 ^ ch, 1597334677) h2 = Math.imul(h2 ^ ch, 1597334677)
} }
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909) h1 =
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909) Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^
Math.imul(h2 ^ (h2 >>> 13), 3266489909)
h2 =
Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^
Math.imul(h1 ^ (h1 >>> 13), 3266489909)
return 4294967296 * (2097151 & h2) + (h1 >>> 0) return 4294967296 * (2097151 & h2) + (h1 >>> 0)
} }
@ -36,7 +50,9 @@ export function prettyCourseName(name) {
.map(word => { .map(word => {
if (word.trim().length === 0) return word if (word.trim().length === 0) return word
return /(^del|^nel|^di$|^dei$|^con$|^alla$|^per$|^e$|^la$)/.test(word) return /(^del|^nel|^di$|^dei$|^con$|^alla$|^per$|^e$|^la$)/.test(
word
)
? word ? word
: word[0].toUpperCase() + word.slice(1) : word[0].toUpperCase() + word.slice(1)
}) })
@ -71,17 +87,28 @@ export const withClasses = classes =>
.map(([key]) => key) .map(([key]) => key)
.join(' ') .join(' ')
// LocalStorage stuff
export const clearOldPersistentStates = stateToken => {
const lastStateToken = localStorage.getItem('orario.state_token')
if (lastStateToken === null || lastStateToken !== stateToken) {
localStorage.clear()
localStorage.setItem('orario.state_token', stateToken)
}
}
// Hooks // Hooks
export const usePersistentState = (key, initialValue) => { export const usePersistentState = (key, initialValue) => {
const previousValue = localStorage.getItem(key) const previousValue = localStorage.getItem(`orario.${key}`)
const [value, setValue] = useState( const [value, setValue] = useState(
previousValue !== null ? JSON.parse(previousValue) : initialValue previousValue !== null ? JSON.parse(previousValue) : initialValue
) )
useEffect(() => { useEffect(() => {
localStorage.setItem(key, JSON.stringify(value)) localStorage.setItem(`orario.${key}`, JSON.stringify(value))
}, [value]) }, [value])
return [value, setValue] return [value, setValue]

Loading…
Cancel
Save