Initial prototype
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
//
|
||||
// Modes
|
||||
//
|
||||
|
||||
import { differenceInMinutes, format, getWeek, startOfDay } from 'date-fns'
|
||||
import { it } from 'date-fns/locale'
|
||||
|
||||
import _ from 'lodash'
|
||||
import { useEffect, useRef } from 'preact/hooks'
|
||||
import { layoutIntervals } from '../interval-layout.js'
|
||||
|
||||
function hashString(str, seed = 0) {
|
||||
let h1 = 0xdeadbeef ^ seed,
|
||||
h2 = 0x41c6ce57 ^ seed
|
||||
for (let i = 0, ch; i < str.length; i++) {
|
||||
ch = str.charCodeAt(i)
|
||||
h1 = Math.imul(h1 ^ ch, 2654435761)
|
||||
h2 = Math.imul(h2 ^ ch, 1597334677)
|
||||
}
|
||||
h1 = 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)
|
||||
}
|
||||
|
||||
const WorkWeekView = ({ 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,
|
||||
}))
|
||||
)
|
||||
)
|
||||
|
||||
console.log(rowLayouts)
|
||||
|
||||
const weekDays = ['Lunedì', 'Martedì', 'Mercoledì', 'Giovedì', 'Venerdì']
|
||||
|
||||
return (
|
||||
<div class="workweek-view">
|
||||
<div class="week">
|
||||
{weekDays.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 – 11:00
|
||||
</div>
|
||||
<div class="label" style={{ 'grid-column': '4 / span 2' }}>
|
||||
11:00 – 13:00
|
||||
</div>
|
||||
<div class="label" style={{ 'grid-column': '7 / span 2' }}>
|
||||
14:00 – 16:00
|
||||
</div>
|
||||
<div class="label" style={{ 'grid-column': '9 / span 2' }}>
|
||||
16:00 – 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.name)
|
||||
) %
|
||||
360) +
|
||||
'deg',
|
||||
}}
|
||||
ref={eventRef}
|
||||
>
|
||||
{_.startCase(_.lowerCase(event.data.name))
|
||||
.replaceAll('Ii', 'II')
|
||||
.replaceAll('Iii', 'III')
|
||||
.replaceAll(/\bE\b/g, 'e')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <Local />
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
{sortedKeys.map(blockStart => (
|
||||
<div
|
||||
class="stacked"
|
||||
style={{
|
||||
'--start':
|
||||
differenceInMinutes(
|
||||
stacked[blockStart][0].start,
|
||||
startOfDay(stacked[blockStart][0].start)
|
||||
) -
|
||||
60 * 8,
|
||||
}}
|
||||
>
|
||||
{stacked[blockStart].map(event => (
|
||||
<Event {...event} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{Object.values(eventsByWeekday).map(dayEvents => {
|
||||
return (
|
||||
<div class="day">
|
||||
{dayEvents.map(({ name, start, end }) => {
|
||||
return (
|
||||
<div
|
||||
class="event"
|
||||
style={{
|
||||
'--hue':
|
||||
(Math.abs(hashString('seed3' + name)) % 360) +
|
||||
'deg',
|
||||
'--start':
|
||||
differenceInMinutes(start, startOfDay(start)) -
|
||||
60 * 8,
|
||||
'--size': differenceInMinutes(end, start),
|
||||
}}
|
||||
>
|
||||
{_.startCase(_.lowerCase(name))
|
||||
.replaceAll('Ii', 'II')
|
||||
.replaceAll('Iii', 'III')}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
*/
|
||||
|
||||
const CourseView = ({ events }) => {
|
||||
const eventsByCourse = _.groupBy(events, 'name')
|
||||
return (
|
||||
<div class="course-view">
|
||||
<div class="wrap-container">
|
||||
{Object.entries(eventsByCourse).map(([name, courseEvents]) => (
|
||||
<div class="course">
|
||||
<div class="title">
|
||||
{_.startCase(_.lowerCase(name))
|
||||
.replaceAll('Ii', 'II')
|
||||
.replaceAll('Iii', 'III')}
|
||||
</div>
|
||||
<div class="docenti">{courseEvents[0].docenti.join(', ')}</div>
|
||||
<div class="events">
|
||||
{courseEvents.map(course => (
|
||||
<div class="event">
|
||||
{_.capitalize(format(course.start, 'EEEE', { locale: it }))}{' '}
|
||||
{format(course.start, 'H:mm')} –
|
||||
{format(course.end, 'H:mm')} {course.aula}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
//
|
||||
// EventsView
|
||||
//
|
||||
|
||||
const viewModeMap = {
|
||||
'work-week': WorkWeekView,
|
||||
'course': CourseView,
|
||||
}
|
||||
|
||||
export const EventsView = ({ mode, ...viewProps }) => {
|
||||
const Mode = viewModeMap[mode]
|
||||
|
||||
return (
|
||||
<div class="events-view">
|
||||
<Mode {...viewProps} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const Icon = ({ name }) => <span class="material-symbols-outlined">{name}</span>
|
||||
@@ -0,0 +1,28 @@
|
||||
export const Toolbar = ({ mode, setMode }) => {
|
||||
return (
|
||||
<div class="toolbar">
|
||||
<div class="item logo">PHC / Orari</div>
|
||||
<div class="options">
|
||||
<div class="item option">
|
||||
<div class="compound">
|
||||
<button
|
||||
class={'radio' + (mode === 'course' ? ' selected' : '')}
|
||||
onClick={() => setMode('course')}
|
||||
>
|
||||
Corso
|
||||
</button>
|
||||
<button
|
||||
class={'radio' + (mode === 'work-week' ? ' selected' : '')}
|
||||
onClick={() => setMode('work-week')}
|
||||
>
|
||||
Settimana
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item option">Opzione 2</div>
|
||||
<div class="item option">Opzione 3</div>
|
||||
<div class="item option">Opzione 4</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Returns a compact layout of a list of intervals
|
||||
*
|
||||
* Second options parameter
|
||||
* - `tight` places an interval with same ending as previous next to each other.
|
||||
*/
|
||||
export function layoutIntervals(intervals, { tight } = {}) {
|
||||
tight ??= true
|
||||
|
||||
const canPlaceInterval = tight
|
||||
? ({ start }, place) => place <= start
|
||||
: ({ start }, place) => place < start
|
||||
|
||||
if (intervals.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// sort intervals by ".start"
|
||||
intervals.sort((a, b) => a.start - b.start)
|
||||
|
||||
const stack = [{ lastEnd: -Infinity, intervals: [] }]
|
||||
|
||||
for (const interval of intervals) {
|
||||
const s = stack.find(level => canPlaceInterval(interval, level.lastEnd))
|
||||
|
||||
if (s) {
|
||||
s.intervals.push(interval)
|
||||
s.lastEnd = Math.max(s.lastEnd, interval.end)
|
||||
} else {
|
||||
stack.push({ lastEnd: interval.end, intervals: [interval] })
|
||||
}
|
||||
}
|
||||
|
||||
return stack.map(({ intervals }) => intervals)
|
||||
}
|
||||
|
||||
// //
|
||||
// // Testing...
|
||||
// //
|
||||
|
||||
// console.dir(
|
||||
// layoutIntervals([
|
||||
// { start: 0, end: 2 },
|
||||
// { start: 2, end: 4 },
|
||||
// { start: 1, end: 3 },
|
||||
// { start: 4, end: 6 },
|
||||
// { start: 3, end: 5 },
|
||||
// { start: 2, end: 4 },
|
||||
// ]),
|
||||
// { depth: null }
|
||||
// )
|
||||
@@ -0,0 +1,68 @@
|
||||
import _ from 'lodash'
|
||||
import { render } from 'preact'
|
||||
import { useEffect, useState } from 'preact/hooks'
|
||||
|
||||
import { EventsView } from './components/EventsView.jsx'
|
||||
import { Toolbar } from './components/Toolbar.jsx'
|
||||
|
||||
window._ = _
|
||||
|
||||
const App = ({}) => {
|
||||
const [eventi, setEventi] = useState([])
|
||||
useEffect(() => {
|
||||
// Directly copy-pasted from chrome Dev Tools
|
||||
// const request = fetch(
|
||||
// 'https://apache.prod.up.cineca.it/api/Impegni/getImpegniCalendarioPubblico',
|
||||
// {
|
||||
// headers: {
|
||||
// 'content-type': 'application/json;charset=UTF-8',
|
||||
// 'sec-fetch-dest': 'empty',
|
||||
// 'sec-fetch-mode': 'cors',
|
||||
// 'sec-fetch-site': 'same-site',
|
||||
// },
|
||||
// body: '{"mostraImpegniAnnullati":true,"mostraIndisponibilitaTotali":false,"linkCalendarioId":"6308e8ea0c34e703bb1f7e85","clienteId":"628de8b9b63679f193b87046","pianificazioneTemplate":false,"dataInizio":"2022-10-02T22:00:00.000Z","dataFine":"2022-10-07T22:00:00.000Z"}',
|
||||
// method: 'POST',
|
||||
// mode: 'cors',
|
||||
// credentials: 'omit',
|
||||
// }
|
||||
// )
|
||||
|
||||
const request = fetch('/data.json')
|
||||
|
||||
console.time('loading eventi')
|
||||
|
||||
request
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
console.timeEnd('loading eventi')
|
||||
window.data = data
|
||||
setEventi(data)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const [mode, setMode] = useState('work-week')
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toolbar {...{ mode, setMode }} />
|
||||
<EventsView
|
||||
mode={mode}
|
||||
start={new Date(2022, 10, 3)}
|
||||
events={eventi.map(({ nome, dataInizio, dataFine, docenti, aule }) => ({
|
||||
name: _.split(nome, '-', 1)[0].trim(),
|
||||
start: new Date(dataInizio),
|
||||
end: new Date(dataFine),
|
||||
docenti: docenti.map(({ nome, cognome }) =>
|
||||
_.startCase(_.lowerCase(nome) + ' ' + _.lowerCase(cognome))
|
||||
),
|
||||
aula: _.startCase(aule[0].codice.toLowerCase()).replace(
|
||||
/([A-Z]) ([1-9])/,
|
||||
'$1$2'
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
render(<App />, document.body)
|
||||
@@ -0,0 +1,349 @@
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body,
|
||||
html {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
// Theme
|
||||
|
||||
:root {
|
||||
--gray-000: #ffffff;
|
||||
--gray-300: #f0f0f0;
|
||||
--gray-350: #e8e8e8;
|
||||
--gray-500: #d8d8d8;
|
||||
--gray-900: #282828;
|
||||
|
||||
// --accent-100: #e6ceff;
|
||||
// --accent-400: #d0b8ef;
|
||||
// --accent-500: #aa82db;
|
||||
|
||||
--accent-100: hsl(221, 100%, 88%);
|
||||
--accent-400: hsl(221, 75%, 78%);
|
||||
--accent-500: hsl(221, 70%, 75%);
|
||||
}
|
||||
|
||||
// Elements
|
||||
|
||||
code {
|
||||
font-size: 90%;
|
||||
}
|
||||
|
||||
button,
|
||||
.button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
background: var(--gray-000);
|
||||
border: 2px solid var(--gray-350);
|
||||
border-radius: 0.5rem;
|
||||
|
||||
font: inherit;
|
||||
font-weight: 400;
|
||||
|
||||
padding: 0.5rem 1rem;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
position: relative;
|
||||
|
||||
&:hover::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
|
||||
inset: 0;
|
||||
background: #00000008;
|
||||
}
|
||||
}
|
||||
|
||||
// Components
|
||||
|
||||
.compound {
|
||||
display: flex;
|
||||
|
||||
button,
|
||||
.button {
|
||||
border-radius: 0;
|
||||
border-right: none;
|
||||
|
||||
&:first-child {
|
||||
border-top-left-radius: 0.5rem;
|
||||
border-bottom-left-radius: 0.5rem;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-top-right-radius: 0.5rem;
|
||||
border-bottom-right-radius: 0.5rem;
|
||||
border: 2px solid var(--gray-350);
|
||||
}
|
||||
|
||||
&.radio {
|
||||
&.selected {
|
||||
background: var(--accent-100);
|
||||
border-color: var(--accent-400);
|
||||
|
||||
& + .radio {
|
||||
border-left-color: var(--accent-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--gray-000);
|
||||
border: 1px solid var(--gray-500);
|
||||
border-radius: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
// Structure
|
||||
|
||||
body {
|
||||
background: var(--gray-000);
|
||||
color: var(--gray-900);
|
||||
|
||||
font-weight: 300;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
@extend .panel;
|
||||
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--gray-500);
|
||||
|
||||
height: 4rem;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.options {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.item {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.events-view {
|
||||
height: calc(100vh - 4rem);
|
||||
|
||||
.course-view {
|
||||
padding: 1rem;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
height: 100%;
|
||||
|
||||
overflow-y: scroll;
|
||||
|
||||
.wrap-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
gap: 1rem;
|
||||
|
||||
max-width: 1200px;
|
||||
|
||||
.course {
|
||||
@extend .panel;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
gap: 0.25rem;
|
||||
|
||||
flex-grow: 1;
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.docenti {
|
||||
font-size: 16px;
|
||||
}
|
||||
.events {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.workweek-view {
|
||||
--header-height: 3rem;
|
||||
--event-height: 2rem;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-rows: var(--header-height) 1fr;
|
||||
|
||||
overflow-y: scroll;
|
||||
|
||||
height: 100%;
|
||||
|
||||
.week {
|
||||
grid-column: 1 / 2;
|
||||
grid-row: 2 / 3;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.day {
|
||||
box-sizing: content-box;
|
||||
|
||||
height: calc(var(--size) * var(--event-height) + (var(--size) - 1) * 0.25rem);
|
||||
|
||||
border-top: 1px solid var(--gray-500);
|
||||
border-right: 1px solid var(--gray-500);
|
||||
padding: 0.5rem 1rem 0 0.5rem;
|
||||
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 1px solid var(--gray-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.events {
|
||||
grid-column: 2 / 3;
|
||||
grid-row: 1 / 3;
|
||||
|
||||
overflow-y: hidden;
|
||||
overflow-x: scroll;
|
||||
|
||||
// scrollbar-gutter: stable both-edges;
|
||||
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
|
||||
--hour-width: 7rem;
|
||||
|
||||
.header {
|
||||
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(--gray-500);
|
||||
|
||||
&:nth-child(2),
|
||||
&:nth-child(4) {
|
||||
border-right: 1px solid var(--gray-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
&:hover {
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.event {
|
||||
grid-row: var(--row) / span 1;
|
||||
grid-column: var(--start) / span var(--size);
|
||||
|
||||
background: hsl(var(--hue), 90%, 60%);
|
||||
color: #000;
|
||||
|
||||
border-radius: 0.25rem;
|
||||
|
||||
display: flex;
|
||||
padding: 0.4rem;
|
||||
|
||||
font-size: 15px;
|
||||
// font-weight: 500;
|
||||
|
||||
width: 100%;
|
||||
|
||||
min-height: var(--event-height);
|
||||
overflow: hidden;
|
||||
|
||||
position: relative;
|
||||
|
||||
line-height: 1.1;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
border-radius: 0.25rem;
|
||||
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
hsl(var(--hue), 90%, 60%) 0%,
|
||||
transparent 25%
|
||||
);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
z-index: 3;
|
||||
|
||||
position: absolute;
|
||||
overflow: visible;
|
||||
|
||||
&::after {
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
border-top: 1px solid var(--gray-500);
|
||||
padding: 0.25rem 0;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 1px solid var(--gray-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Utilities
|
||||
|
||||
span.bold {
|
||||
font-weight: 400;
|
||||
}
|
||||
Reference in New Issue
Block a user