|
1 | 1 | { |
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 | + ] |
10 | 237 | } |
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 |
25 | 252 | } |
0 commit comments