Skip to content

Commit 79ff35a

Browse files
Add calendar
1 parent 6fc4625 commit 79ff35a

File tree

2 files changed

+252
-22
lines changed

2 files changed

+252
-22
lines changed

calendar.ipynb

Lines changed: 249 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,252 @@
11
{
2-
"cells": [
3-
{
4-
"cell_type": "code",
5-
"execution_count": null,
6-
"id": "79c9d2ef",
7-
"metadata": {
8-
"vscode": {
9-
"languageId": "plaintext"
2+
"cells": [
3+
{
4+
"cell_type": "markdown",
5+
"id": "c5cffd6e",
6+
"metadata": {},
7+
"source": [
8+
"# Calendar\n",
9+
"\n",
10+
"Upcoming Research Coding Community workshops and events are organised below.\n"
11+
]
12+
},
13+
{
14+
"cell_type": "markdown",
15+
"id": "f176285a",
16+
"metadata": {},
17+
"source": [
18+
"Events are read from `data/events.csv`. Ensure the file has the columns `title`, `club`, `start`, `end`, `location`, and `description`, with date-times in ISO format such as `2024-10-07 13:00`. Only events scheduled from today onward are shown.\n"
19+
]
20+
},
21+
{
22+
"cell_type": "code",
23+
"execution_count": null,
24+
"id": "52b5423b",
25+
"metadata": {
26+
"tags": [
27+
"remove-input"
28+
]
29+
},
30+
"outputs": [],
31+
"source": [
32+
"import csv\n",
33+
"import html\n",
34+
"from collections import defaultdict\n",
35+
"from datetime import datetime, timedelta\n",
36+
"from pathlib import Path\n",
37+
"from uuid import uuid4\n",
38+
"\n",
39+
"from IPython.display import HTML, Markdown, display\n",
40+
"\n",
41+
"DATA_PATH = Path('data') / 'events.csv'\n",
42+
"REQUIRED_COLUMNS = ['title', 'club', 'start', 'end', 'location', 'description']\n",
43+
"PALETTE = ['#1976d2', '#2e7d32', '#8e24aa', '#ef6c00', '#00838f', '#5d4037']\n",
44+
"\n",
45+
"def load_events(path: Path):\n",
46+
" if not path.exists():\n",
47+
" display(Markdown(f\"Warning: `{path}` is missing. Add it to populate the calendar.\"))\n",
48+
" return []\n",
49+
"\n",
50+
" with path.open('r', encoding='utf-8-sig') as handle:\n",
51+
" reader = csv.DictReader(handle)\n",
52+
" if not reader.fieldnames:\n",
53+
" display(Markdown('Warning: events file has no header row.'))\n",
54+
" return []\n",
55+
"\n",
56+
" missing = [col for col in REQUIRED_COLUMNS if col not in reader.fieldnames]\n",
57+
" if missing:\n",
58+
" display(Markdown('Warning: events file is missing columns: ' + ', '.join(missing)))\n",
59+
" return []\n",
60+
"\n",
61+
" events = []\n",
62+
" for row in reader:\n",
63+
" try:\n",
64+
" start = datetime.fromisoformat(row['start'].strip())\n",
65+
" end = datetime.fromisoformat(row['end'].strip())\n",
66+
" except (ValueError, AttributeError):\n",
67+
" continue\n",
68+
" if end <= start:\n",
69+
" continue\n",
70+
" events.append({\n",
71+
" 'title': row['title'].strip() or 'Untitled event',\n",
72+
" 'club': row['club'].strip() or 'Community',\n",
73+
" 'start': start,\n",
74+
" 'end': end,\n",
75+
" 'location': row['location'].strip() or 'Location to be confirmed',\n",
76+
" 'description': row['description'].strip(),\n",
77+
" })\n",
78+
"\n",
79+
" today = datetime.today().replace(hour=0, minute=0, second=0, microsecond=0)\n",
80+
" future_events = [evt for evt in events if evt['end'] >= today]\n",
81+
" future_events.sort(key=lambda item: item['start'])\n",
82+
" if not future_events:\n",
83+
" display(Markdown('No upcoming events were found.'))\n",
84+
" return future_events\n",
85+
"\n",
86+
"def allocate_colors(events):\n",
87+
" club_map = {}\n",
88+
" for evt in events:\n",
89+
" if evt['club'] not in club_map:\n",
90+
" club_map[evt['club']] = PALETTE[len(club_map) % len(PALETTE)]\n",
91+
" return club_map\n",
92+
"\n",
93+
"def month_start(date_obj):\n",
94+
" return date_obj.replace(day=1, hour=0, minute=0, second=0, microsecond=0)\n",
95+
"\n",
96+
"def build_months(events):\n",
97+
" months = defaultdict(list)\n",
98+
" for evt in events:\n",
99+
" key = month_start(evt['start'])\n",
100+
" months[key].append(evt)\n",
101+
" return dict(sorted(months.items()))\n",
102+
"\n",
103+
"def render_month(month_key, month_events, colors):\n",
104+
" month_label = month_key.strftime('%B %Y')\n",
105+
" start_of_grid = month_key - timedelta(days=month_key.weekday())\n",
106+
" days = [start_of_grid + timedelta(days=offset) for offset in range(42)]\n",
107+
"\n",
108+
" html_days = []\n",
109+
" today_date = datetime.today().date()\n",
110+
" events_by_day = defaultdict(list)\n",
111+
" for evt in month_events:\n",
112+
" events_by_day[evt['start'].date()].append(evt)\n",
113+
"\n",
114+
" for day in days:\n",
115+
" classes = ['calendar-day']\n",
116+
" if day.month != month_key.month:\n",
117+
" classes.append('outside')\n",
118+
" if day.date() == today_date:\n",
119+
" classes.append('today')\n",
120+
" day_events = events_by_day.get(day.date(), [])\n",
121+
"\n",
122+
" event_html = []\n",
123+
" for evt in day_events:\n",
124+
" color = colors.get(evt['club'], '#1976d2')\n",
125+
" start_time = evt['start'].strftime('%H:%M')\n",
126+
" end_time = evt['end'].strftime('%H:%M')\n",
127+
" bits = [f\"<span class=\"time\">{html.escape(start_time)}-{html.escape(end_time)}</span>\"]\n",
128+
" bits.append(f\"<span class=\"title\">{html.escape(evt['title'])}</span>\")\n",
129+
" if evt['location']:\n",
130+
" bits.append(f\"<span class=\"location\">{html.escape(evt['location'])}</span>\")\n",
131+
" event_html.append(\n",
132+
" \"<div class=\"calendar-event\" style=\"background-color: {color};\">\" + ' '.join(bits) + \"</div>\"\n",
133+
" )\n",
134+
"\n",
135+
" cell = [\n",
136+
" f\"<div class=\"date-label\">{day.day}</div>\",\n",
137+
" *event_html\n",
138+
" ]\n",
139+
" html_days.append(f\"<div class=\"{' '.join(classes)}\">{''.join(cell)}</div>\")\n",
140+
"\n",
141+
" return month_label, ''.join(html_days)\n",
142+
"\n",
143+
"def build_calendar(events):\n",
144+
" if not events:\n",
145+
" return\n",
146+
"\n",
147+
" months = build_months(events)\n",
148+
" colors = allocate_colors(events)\n",
149+
" container_id = f\"calendar-{uuid4().hex}\"\n",
150+
"\n",
151+
" month_blocks = []\n",
152+
" month_options = []\n",
153+
"\n",
154+
" for month_key, month_events in months.items():\n",
155+
" label, grid_html = render_month(month_key, month_events, colors)\n",
156+
" value = month_key.strftime('%Y-%m')\n",
157+
" month_options.append(f\"<option value=\"{value}\">{html.escape(label)}</option>\")\n",
158+
" month_blocks.append(\n",
159+
" f\"<div class=\"calendar-month\" data-month=\"{value}\">\"\n",
160+
" f\"<div class=\"calendar-grid\">{grid_html}</div>\"\n",
161+
" f\"</div>\"\n",
162+
" )\n",
163+
"\n",
164+
" legend_items = []\n",
165+
" for club, color in colors.items():\n",
166+
" legend_items.append(\n",
167+
" f\"<span><span class=\"color-swatch\" style=\"background-color: {color};\"></span>{html.escape(club)}</span>\"\n",
168+
" )\n",
169+
"\n",
170+
" style_block = \"\"\"\n",
171+
"<style>\n",
172+
".calendar-container { font-family: 'Inter', 'Helvetica', 'Arial', sans-serif; margin: 1rem 0; }\n",
173+
".calendar-controls { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; }\n",
174+
".calendar-controls label { font-weight: 600; }\n",
175+
".calendar-month { display: none; }\n",
176+
".calendar-month.active { display: block; }\n",
177+
".calendar-grid { display: grid; grid-template-columns: repeat(7, minmax(130px, 1fr)); border: 1px solid #d0d7de; border-bottom: none; border-right: none; }\n",
178+
".calendar-day { border-right: 1px solid #d0d7de; border-bottom: 1px solid #d0d7de; min-height: 120px; padding: 6px; box-sizing: border-box; font-size: 0.8rem; background-color: #fff; }\n",
179+
".calendar-day:last-child { border-right: none; }\n",
180+
".calendar-day.outside { background-color: #f6f8fa; color: #657786; }\n",
181+
".calendar-day.today { box-shadow: inset 0 0 0 2px #1976d2; }\n",
182+
".date-label { font-weight: 600; margin-bottom: 0.25rem; }\n",
183+
".calendar-event { color: #fff; border-radius: 4px; padding: 4px 6px; margin-bottom: 4px; line-height: 1.2; }\n",
184+
".calendar-event .time { display: block; font-weight: 600; }\n",
185+
".calendar-event .title { display: block; }\n",
186+
".calendar-event .location { display: block; font-size: 0.72rem; opacity: 0.9; }\n",
187+
".calendar-legend { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-top: 0.75rem; font-size: 0.85rem; align-items: center; }\n",
188+
".calendar-legend .color-swatch { width: 12px; height: 12px; border-radius: 50%; display: inline-block; }\n",
189+
"@media (max-width: 900px) { .calendar-grid { grid-template-columns: repeat(2, minmax(150px, 1fr)); } }\n",
190+
"</style>\n",
191+
"\"\"\"\n",
192+
"\n",
193+
" script_block = f\"\"\"\n",
194+
"<script>\n",
195+
"(function() {{\n",
196+
" var container = document.getElementById('{container_id}');\n",
197+
" if (!container) return;\n",
198+
" var select = container.querySelector('select');\n",
199+
" var months = container.querySelectorAll('.calendar-month');\n",
200+
" function showMonth(value) {{\n",
201+
" months.forEach(function(block) {{\n",
202+
" if (block.dataset.month === value) {{\n",
203+
" block.classList.add('active');\n",
204+
" }} else {{\n",
205+
" block.classList.remove('active');\n",
206+
" }}\n",
207+
" }});\n",
208+
" }}\n",
209+
" select.addEventListener('change', function(evt) {{ showMonth(evt.target.value); }});\n",
210+
" if (select.value) {{\n",
211+
" showMonth(select.value);\n",
212+
" }} else if (months.length) {{\n",
213+
" select.value = months[0].dataset.month;\n",
214+
" showMonth(select.value);\n",
215+
" }}\n",
216+
"}})();\n",
217+
"</script>\n",
218+
"\"\"\"\n",
219+
"\n",
220+
" html_block = (\n",
221+
" style_block +\n",
222+
" f\"<div id=\"{container_id}\" class=\"calendar-container\">\" +\n",
223+
" f\"<div class=\"calendar-controls\"><label for=\"{container_id}-select\">Month:</label>\" +\n",
224+
" f\"<select id=\"{container_id}-select\" aria-label=\"Select month\">{''.join(month_options)}</select>\" +\n",
225+
" \"</div>\" +\n",
226+
" ''.join(month_blocks) +\n",
227+
" (f\"<div class=\"calendar-legend\">{''.join(legend_items)}</div>\" if legend_items else '') +\n",
228+
" \"</div>\" +\n",
229+
" script_block\n",
230+
" )\n",
231+
"\n",
232+
" display(HTML(html_block))\n",
233+
"\n",
234+
"events = load_events(DATA_PATH)\n",
235+
"build_calendar(events)\n"
236+
]
10237
}
11-
},
12-
"outputs": [],
13-
"source": [
14-
"# Calendar "
15-
]
16-
}
17-
],
18-
"metadata": {
19-
"language_info": {
20-
"name": "python"
21-
}
22-
},
23-
"nbformat": 4,
24-
"nbformat_minor": 5
238+
],
239+
"metadata": {
240+
"kernelspec": {
241+
"display_name": "Python 3",
242+
"language": "python",
243+
"name": "python3"
244+
},
245+
"language_info": {
246+
"name": "python",
247+
"pygments_lexer": "ipython3"
248+
}
249+
},
250+
"nbformat": 4,
251+
"nbformat_minor": 5
25252
}

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ python = "^3.11"
1212
jupyter-book = ">=1.0.0"
1313
jupyterquiz = ">=2.0.6"
1414
ipykernel = "*"
15+
pandas = "^2.3.3"
16+
plotly = "^6.3.1"
17+
ipywidgets = "^8.1.7"
1518

1619
[build-system]
1720
requires = ["poetry-core"]

0 commit comments

Comments
 (0)