Skip to content

Commit 2017949

Browse files
authored
Refactor schedule layout to use CSS grid (#14)
* Refactor schedule to use CSS grid layout * Fix schedule service tests to adapt new flat day structure * Fix floating point error with rows height calculation * Use fixed heights for schedule session tiles
1 parent 77f138d commit 2017949

6 files changed

Lines changed: 368 additions & 182 deletions

File tree

‎backend/news/14.feature‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Refactor schedule to use CSS grid layout @datakurre

‎backend/src/collective/techevent/services/schedule/get.py‎

Lines changed: 66 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from collective.techevent.behaviors.schedule import IScheduleSlot
22
from collective.techevent.utils.vocabularies import get_vocabulary_for_attr
33
from copy import deepcopy
4+
from datetime import datetime
45
from datetime import timedelta
56
from dateutil.parser import parse
67
from plone import api
@@ -59,42 +60,86 @@ def process_trainings(slots: list[dict]) -> list[dict]:
5960
return response
6061

6162

63+
def round_time(dt: datetime) -> datetime:
64+
"""Round datetime up to the next quarter (00, 15, 30, 45), ignoring seconds and microseconds."""
65+
minute = ((dt.minute + 14) // 15) * 15
66+
if minute == 60:
67+
dt = dt.replace(hour=(dt.hour + 1) % 24, minute=0, second=0, microsecond=0)
68+
else:
69+
dt = dt.replace(minute=minute, second=0, microsecond=0)
70+
return dt
71+
72+
73+
def time_slot(value: datetime) -> str:
74+
return f"time-{round_time(value).strftime('%H%M')}"
75+
76+
6277
def group_slots(slots: list[dict], rooms_vocab: dict[str, str]) -> list[dict]:
6378
response = []
6479
days = {}
80+
81+
# Pre-process training slots to split long sessions
6582
slots = process_trainings(slots)
6683
for slot in slots:
6784
start = slot.get("start", "")
6885
if not start:
6986
continue
70-
day = start[0:10]
71-
hour = start
72-
room = slot["room"][0]["token"] if slot.get("room") else "_all_"
73-
if day not in days:
74-
days[day] = {}
75-
day_info = days[day]
76-
if hour not in day_info:
77-
day_info[hour] = {}
78-
day_info[hour][room] = slot
87+
day = start[0:10] # Extract date part (YYYY-MM-DD)
88+
days.setdefault(day, []).append(slot)
89+
90+
# Convert grouped days dict to a sorted list of dicts
7991
response = dict_as_sorted_list(days)
8092
for day in response:
8193
rooms = set()
82-
day["items"] = dict_as_sorted_list(day["items"], enforceIso=True)
83-
for hour in day["items"]:
84-
types = set()
85-
for room in hour["items"]:
86-
rooms.add(room)
87-
for slot in hour["items"].values():
88-
slot_category = slot.get("slot_category")
89-
if not slot_category or slot_category == "activity":
90-
slot_category = slot.get("@type")
91-
types.add(slot_category)
92-
hour["types"] = list(types)
93-
# Separate rooms into those not in rooms_vocab and those in rooms_vocab
94+
95+
# Collect all room tokens and slot types for the day
96+
for slot in day["items"]:
97+
room_tokens = (
98+
[r.get("token") for r in slot.get("room", [])]
99+
if slot.get("room")
100+
else []
101+
)
102+
rooms.update(room_tokens or ["_all_"])
103+
104+
# Order rooms: first those not in vocab, then those in vocab order
94105
other_rooms = [room for room in rooms if room not in rooms_vocab]
95106
vocab_rooms = [room for room in rooms_vocab if room in rooms] # in vocab order
96107
ordered_rooms = other_rooms + vocab_rooms
97108
day["rooms"] = [[room, rooms_vocab.get(room, room)] for room in ordered_rooms]
109+
110+
# Assign grid positioning for each slot (for UI layout)
111+
for slot in day["items"]:
112+
start_dt = parse(slot.get("start")) if slot.get("start") else None
113+
end_dt = parse(slot.get("end")) if slot.get("end") else None
114+
room_tokens = (
115+
[r.get("token") for r in slot.get("room", [])]
116+
if slot.get("room")
117+
else []
118+
)
119+
# Assign gridColumn: which track/room the slot belongs to
120+
if slot.get("@type") == "Keynote":
121+
slot["gridColumn"] = "room-1 / room-all"
122+
elif room_tokens:
123+
# Use room-# with index+1 of the slot's room in vocab_rooms (not ordered_rooms)
124+
token = room_tokens[0]
125+
if token in vocab_rooms:
126+
track_index = vocab_rooms.index(token) + 1
127+
slot["gridColumn"] = f"room-{track_index}"
128+
else:
129+
slot["gridColumn"] = "room-1"
130+
else:
131+
# If no room, span all tracks
132+
slot["gridColumn"] = "room-1 / room-all"
133+
# Assign gridRow: time slot range for the slot
134+
if start_dt and end_dt:
135+
slot["gridRow"] = f"{time_slot(start_dt)} / {time_slot(end_dt)}"
136+
else:
137+
slot["gridRow"] = ""
138+
# Assign gridHeight: duration of the slot in grid units
139+
slot["gridHeight"] = round(
140+
((end_dt - start_dt).seconds // 60 if start_dt and end_dt else 0) / 15
141+
)
142+
98143
return response
99144

100145

‎backend/tests/services/test_service_schedule.py‎

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
from collective.techevent.services.schedule.get import round_time
2+
from collective.techevent.services.schedule.get import time_slot
13
from collective.techevent.utils import find_event_root
4+
from datetime import datetime
25
from plone import api
36

47
import pytest
@@ -9,8 +12,7 @@ def total_event_count(response: dict) -> int:
912
"""Return total number of events in the schedule."""
1013
count = 0
1114
for day in response.get("items", []):
12-
for hour in day.get("items", []):
13-
count += len(hour.get("items", []))
15+
count += len(day.get("items", []))
1416
return count
1517

1618

@@ -97,3 +99,38 @@ def test_schedule_room_order(self, api_manager_request):
9799
assert len(days) > 1
98100
rooms = [x[-1] for x in days[1]["rooms"]]
99101
assert rooms == ["_all_", "Beta Room", "Main Room"]
102+
103+
104+
@pytest.mark.parametrize(
105+
"dt,expected",
106+
[
107+
(datetime(2025, 8, 29, 10, 1), datetime(2025, 8, 29, 10, 15)),
108+
(datetime(2025, 8, 29, 10, 14), datetime(2025, 8, 29, 10, 15)),
109+
(datetime(2025, 8, 29, 10, 15), datetime(2025, 8, 29, 10, 15)),
110+
(datetime(2025, 8, 29, 10, 16), datetime(2025, 8, 29, 10, 30)),
111+
(datetime(2025, 8, 29, 10, 29), datetime(2025, 8, 29, 10, 30)),
112+
(datetime(2025, 8, 29, 10, 30), datetime(2025, 8, 29, 10, 30)),
113+
(datetime(2025, 8, 29, 10, 31), datetime(2025, 8, 29, 10, 45)),
114+
(datetime(2025, 8, 29, 10, 44), datetime(2025, 8, 29, 10, 45)),
115+
(datetime(2025, 8, 29, 10, 45), datetime(2025, 8, 29, 10, 45)),
116+
(datetime(2025, 8, 29, 10, 46), datetime(2025, 8, 29, 11, 00)),
117+
(datetime(2025, 8, 29, 10, 59), datetime(2025, 8, 29, 11, 00)),
118+
(datetime(2025, 8, 29, 11, 00), datetime(2025, 8, 29, 11, 00)),
119+
(datetime(2025, 8, 29, 11, 00, 1), datetime(2025, 8, 29, 11, 00)),
120+
],
121+
)
122+
def test_round_time_quarters(dt, expected) -> None:
123+
assert round_time(dt) == expected
124+
125+
126+
@pytest.mark.parametrize(
127+
"dt,expected",
128+
[
129+
(datetime(2025, 8, 29, 9, 0), "time-0900"),
130+
(datetime(2025, 8, 29, 15, 45), "time-1545"),
131+
(datetime(2025, 8, 29, 15, 45, 1), "time-1545"),
132+
(datetime(2025, 8, 29, 15, 50), "time-1600"),
133+
],
134+
)
135+
def test_time_slot(dt, expected) -> None:
136+
assert time_slot(dt) == expected
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Refactor schedule to use CSS grid layout @datakurre

0 commit comments

Comments
 (0)