diff --git a/apps/users/account.py b/apps/users/account.py index 8a065757c..1eb3ff0ab 100644 --- a/apps/users/account.py +++ b/apps/users/account.py @@ -11,6 +11,7 @@ from wtforms import BooleanField, StringField, SubmitField from wtforms.validators import DataRequired +from apps.users.calendar import fetch_events from main import db from models.payment import Payment from models.purchase import Purchase @@ -82,8 +83,14 @@ def account() -> ResponseReturnValue: except Exception: app.logger.exception("Error fetching blog posts") + calendar = fetch_events(current_user, datetime.now(), config.event_end + timedelta(days=14)) + return render_template( - "account/main.html", blog_posts=blog_posts, now=datetime.now(), event_start=config.event_start + "account/main.html", + blog_posts=blog_posts, + calendar=calendar, + now=datetime.now(), + event_start=config.event_start, ) diff --git a/apps/users/calendar.py b/apps/users/calendar.py new file mode 100644 index 000000000..11df73929 --- /dev/null +++ b/apps/users/calendar.py @@ -0,0 +1,127 @@ +from collections.abc import Sequence +from dataclasses import dataclass +from datetime import datetime +from typing import Literal + +import pytz +from sqlalchemy import cast, select +from sqlalchemy.dialects.postgresql import INTERVAL +from sqlalchemy.orm import InstrumentedAttribute, with_parent + +from main import db +from models.content.schedule import Occurrence, ScheduleItem +from models.user import User +from models.volunteer.shift import Shift, ShiftEntry + + +@dataclass +class CalendarEntry: + type: Literal["volunteer_shift", "favourited_content", "owned_content"] + start_time: datetime + end_time: datetime + venue_name: str | None + venue_mapref: str | None + human_type: str = "Event" + conflict_priority: int = 99 + + def overlaps_with(self, start_time: datetime, end_time: datetime) -> bool: + """Does any part of this calendar entry occur between start_time and end_time?""" + return self.start_time < end_time and self.end_time > start_time + + def to_dict(self) -> dict[str, str | int | None]: + return { + "type": self.type, + "human_type": self.human_type, + "conflict_priority": self.conflict_priority, + "start_time": pytz.timezone("Europe/London") + .localize(self.start_time) + .strftime("%Y-%m-%dT%H:%M:00"), + "end_time": pytz.timezone("Europe/London").localize(self.end_time).strftime("%Y-%m-%dT%H:%M:00"), + "venue_name": self.venue_name, + "venue_mapref": self.venue_mapref, + } + + +class VolunteerShiftCalendarEntry(CalendarEntry): + type: Literal["volunteer_shift"] = "volunteer_shift" + human_type = "Volunteer Shift" + conflict_priority: int = 0 + shift: Shift + shift_entry: ShiftEntry + + def __init__(self, shift_entry: ShiftEntry): + self.shit_entry = shift_entry + self.shift = shift_entry.shift + self.title = self.shift.role.name + self.start_time = self.shift.start + self.end_time = self.shift.end + self.venue_name = self.shift.venue.name + self.venue_mapref = self.shift.venue.mapref + + +class OccurrenceCalendarEntry(CalendarEntry): + occurrence: Occurrence + + def __init__(self, occurrence: Occurrence): + # This is mostly here so that mypy knows scheduled_venue is set. + if not occurrence.scheduled or occurrence.scheduled_venue is None: + raise ValueError("Only scheduled content can result in calendar entries.") + + self.occurrence = occurrence + self.title = occurrence.schedule_item.title + self.start_time = occurrence.scheduled_time # type: ignore + self.end_time = occurrence.scheduled_end_time # type: ignore + self.venue_name = occurrence.scheduled_venue.name + self.venue_mapref = occurrence.scheduled_venue.map_link + + +class FavouritedScheduleItemCalendarEntry(OccurrenceCalendarEntry): + type: Literal["favourited_content"] = "favourited_content" + human_type = "Favourite" + conflict_priority: int = 2 + + +class OwnedScheduleItemCalendarEntry(OccurrenceCalendarEntry): + type: Literal["owned_content"] = "owned_content" + human_type = "Your Content" + conflict_priority: int = 1 + + +def _occurrences_with_parent( + user: User, start: datetime, end: datetime, join_via: InstrumentedAttribute[list[ScheduleItem]] +) -> Sequence[Occurrence]: + occurrence_end = Occurrence.scheduled_time + cast("1 minute", INTERVAL) * Occurrence.scheduled_duration + return db.session.scalars( + select(Occurrence) + .join(Occurrence.schedule_item) + .where(with_parent(user, join_via)) + .where(Occurrence.scheduled_time < end) + .where(occurrence_end > start) + ).all() + + +def fetch_events(user: User, start: datetime, end: datetime) -> Sequence[CalendarEntry]: + """Return a list of calendar entries for a user.""" + calendar: list[CalendarEntry] = [] + calendar += [ + VolunteerShiftCalendarEntry(shift) + for shift in db.session.scalars( + select(ShiftEntry) + .where(with_parent(user, User.shift_entries)) + .join(ShiftEntry.shift) + .where(Shift.start < end) + .where(Shift.end > start) + ) + ] + + calendar += [ + FavouritedScheduleItemCalendarEntry(occurrence) + for occurrence in _occurrences_with_parent(user, start, end, User.favourites) + ] + + calendar += [ + OwnedScheduleItemCalendarEntry(occurrence) + for occurrence in _occurrences_with_parent(user, start, end, User.schedule_items) + ] + + return sorted(calendar, key=lambda e: e.start_time) diff --git a/apps/volunteer/schedule.py b/apps/volunteer/schedule.py index 974973b74..74e1e20fd 100644 --- a/apps/volunteer/schedule.py +++ b/apps/volunteer/schedule.py @@ -1,4 +1,5 @@ from collections import defaultdict +from collections.abc import Sequence from datetime import datetime, timedelta from flask import ( @@ -17,6 +18,7 @@ from icalendar import Calendar, Event from sqlalchemy.orm import joinedload +from apps.users.calendar import CalendarEntry, fetch_events from main import db, get_or_404 from models import naive_utcnow from models.user import User, generate_api_token @@ -46,6 +48,24 @@ def _get_roles_with_user_data(user): return res +def _get_conflicts(shift: Shift, calendar: Sequence[CalendarEntry]) -> tuple[str, list[dict]]: + """Return (primary_conflict_type, conflict_details) for a shift. + + primary_conflict_type is the highest-priority conflict type (for CSS), or "" + if there are no conflicts. conflict_details is a list of dicts describing + each conflicting event. + """ + conflicts = sorted( + [event for event in calendar if event.overlaps_with(shift.start, shift.end)], + key=lambda c: c.conflict_priority, + ) + if not conflicts: + return "", [] + + details = [c.to_dict() for c in conflicts] + return conflicts[0].type, details + + def redirect_next_or_schedule(message: str | None = None) -> ResponseReturnValue: """ Set the flash if `message` is set, then redirect either to the URL in the @@ -83,13 +103,18 @@ def schedule(): active_day = default_day shifts = Shift.get_all_for_day(active_day) + if len(shifts) == 0: + # If there's no shifts nothing can conflict, so don't bother looking. + user_calendar = [] + else: + user_calendar = fetch_events(current_user, shifts[0].start, shifts[-1].end) by_time = defaultdict(lambda: []) for s in shifts: hour_key = s.start.strftime("%H:%M") - to_add = s.to_localtime_dict() + to_add["conflicts_with"], to_add["conflicts_detail"] = _get_conflicts(s, user_calendar) to_add["sign_up_url"] = url_for(".shift", shift_id=to_add["id"]) to_add["is_user_shift"] = current_user in s.volunteers by_time[hour_key].append(to_add) diff --git a/css/_account.scss b/css/_account.scss new file mode 100644 index 000000000..e5b533cfd --- /dev/null +++ b/css/_account.scss @@ -0,0 +1,29 @@ +.calendar { + width: 100%; + + th, + td { + padding: 4px 8px; + + &:first-child { + padding-left: 0; + } + &:last-child { + padding-right: 0; + } + } + + th.time, + td.time, + th.type, + td.type, + th.location, + td.location { + white-space: nowrap; + } + + th.event, + td.event { + width: 100%; + } +} diff --git a/css/_panel_grid.scss b/css/_panel_grid.scss index 3d1d1cb44..9df59fd50 100644 --- a/css/_panel_grid.scss +++ b/css/_panel_grid.scss @@ -31,6 +31,10 @@ } } + .panel-full-width { + grid-column: 1 / -1; + } + .panel-footer { background-color: $highlight-background; border-top: 1px solid rgba(0, 0, 0, 0.12); diff --git a/css/_variables.scss b/css/_variables.scss index 11b4b8c9c..4b521bc40 100644 --- a/css/_variables.scss +++ b/css/_variables.scss @@ -54,6 +54,7 @@ $form-element-text: #d5d6da; /* Alert/button element colours */ $success-background: $brand-2026-green; +$success-border: color.adjust($success-background, $lightness: -15%); $success-text: #000; $info-background: $brand-2026-mid-blue; diff --git a/css/main.scss b/css/main.scss index 05abeeaad..b74122702 100644 --- a/css/main.scss +++ b/css/main.scss @@ -25,6 +25,7 @@ @use "./_bar_training.scss"; @use "./_about.scss"; @use "./_wiki.scss"; +@use "./_account.scss"; @font-face { font-family: "Raleway"; diff --git a/css/volunteer_schedule.scss b/css/volunteer_schedule.scss index 889defde2..4a853668d 100644 --- a/css/volunteer_schedule.scss +++ b/css/volunteer_schedule.scss @@ -13,19 +13,143 @@ } } -table.shifts-table tr, -table.shifts-table th, -table.shifts-table td { - // !important because I'm a terrible (and very lazy) person - border-color: #4a4a4a !important; +#roles { + .toggle { + font-weight: bold; + } + .role-selector { + display: inline-block; + + input { + position: absolute; + opacity: 0; + } + + label { + padding: 2px 8px; + font-weight: normal; + border: 1px solid $highlight-2-border; + border-radius: 5px; + } + } + + input[type="checkbox"]:not(:checked) + label { + background-color: $main-background-text; + color: $main-background; + border-color: $main-background; + } + + input[type="checkbox"]:checked + label { + color: $success-text; + background-color: $success-background; + border-color: $success-border; + } } -table.shifts-table tbody td.hidden { - display: none; +table.shifts-table { + tr, + th, + td { + // !important because I'm a terrible (and very lazy) person + border-color: #4a4a4a !important; + vertical-align: middle !important; + } + + td.hidden, + td.mobile-only { + display: none; + } + + td.button { + padding: 5px; + } + + tr td.staffing .staffing-icon { + display: inline-block; + height: 1.5em; + width: 1.5em; + margin-top: -0.2em; + vertical-align: middle; + mask-size: contain; + mask-repeat: no-repeat; + mask-position: center; + + &.staffing-icon-understaffed { + background-color: $brand-2026-coral; + mask-image: url("../images/icons/alert-circle.svg"); + } + + &.staffing-icon-staffed { + background-color: $brand-2026-green; + mask-image: url("../images/icons/check-circle.svg"); + } + } + + td.conflicts { + text-align: center; + vertical-align: middle; + + &::after { + content: ""; + display: inline-block; + width: 1em; + height: 1em; + border-radius: 50%; + } + } + + tr[data-signed-up="True"] td.conflicts::after { + background-color: $brand-2026-light-blue; + } + + tr[data-conflicts-with="volunteer_shift"][data-signed-up="False"] + td.conflicts::after { + background-color: $brand-2026-coral; + } + + tr[data-conflicts-with="owned_content"][data-signed-up="False"] + td.conflicts::after, + tr[data-conflicts-with="favourited_content"][data-signed-up="False"] + td.conflicts::after { + background-color: $brand-2026-yellow; + } } -table.shifts-table tbody td.mobile-only { - display: none; +ul.key { + list-style: none; + padding-left: 0; + + li { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 4px; + + &::before { + content: ""; + display: inline-block; + width: 1em; + height: 1em; + border-radius: 50%; + flex-shrink: 0; + } + } + + #key-shift-signed-up::before { + background-color: $brand-2026-light-blue; + } + + #key-shift-conflict::before { + background-color: $brand-2026-coral; + } + + #key-content-conflict::before { + background-color: $brand-2026-yellow; + } + + #key-no-conflicts::before { + background-color: $highlight-background; + } } .role-admin-shift { @@ -145,6 +269,28 @@ table.shifts-table tbody td.mobile-only { td.staffing:before { content: "Signed up"; } + + td.conflicts { + display: block; + position: absolute; + top: 10px; + right: 10px; + padding: 0; + width: auto; + + &:before { + display: none; + } + } + + td.start_time:not(.hidden) ~ td.conflicts { + // Push below the start_time header: padding-top (5px) + 1.2em font + padding-bottom (5px) + card top padding (5px) + top: calc(1.2em + 25px); + } + } + + tr { + position: relative; } } } diff --git a/images/icons/alert-circle.svg b/images/icons/alert-circle.svg new file mode 100644 index 000000000..b49053a9f --- /dev/null +++ b/images/icons/alert-circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/blank-circle.svg b/images/icons/blank-circle.svg new file mode 100644 index 000000000..9802fd520 --- /dev/null +++ b/images/icons/blank-circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/check-circle.svg b/images/icons/check-circle.svg new file mode 100644 index 000000000..ec74fe7c4 --- /dev/null +++ b/images/icons/check-circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/js/volunteer_schedule.js b/js/volunteer_schedule.js index f2f726ec3..22472fe7a 100644 --- a/js/volunteer_schedule.js +++ b/js/volunteer_schedule.js @@ -1,5 +1,5 @@ const { verified } = require("@primer/octicons"); -const filterStorageKey = "volunteer-filters:v3"; +const filterStorageKey = "volunteer-filters:v4"; function saveFilters() { localStorage.setItem(filterStorageKey, JSON.stringify(getFilters())); @@ -16,10 +16,9 @@ function loadFilters() { savedFilters = JSON.stringify({ role_ids: interestedRoles, show_past: false, - signed_up: false, + signed_up: true, hide_full: true, hide_staffed: true, - colourful_mode: false, }); } @@ -31,7 +30,6 @@ function loadFilters() { document.getElementById("show_signed_up_only").checked = filters.signed_up; document.getElementById("hide_full").checked = filters.hide_full; document.getElementById("is_understaffed").checked = filters.hide_staffed; - document.getElementById("colourful_mode").checked = filters.colourful_mode; updateRowDisplay(); } @@ -44,7 +42,6 @@ function getFilters() { signed_up: document.getElementById("show_signed_up_only").checked, hide_full: document.getElementById("hide_full").checked, hide_staffed: document.getElementById("is_understaffed").checked, - colourful_mode: document.getElementById("colourful_mode").checked, }; return filters; } @@ -60,10 +57,11 @@ function getNodeData(node) { min_staff: parseInt(node.getAttribute("data-min-staff")), max_staff: parseInt(node.getAttribute("data-max-staff")), current_staff: parseInt(node.getAttribute("data-current-staff")), + conflicts_with: node.getAttribute("data-conflicts-with"), }; } -function shouldDisplayNode(node_data, filters) { +function shouldDisplayNode(node_data, filters, node) { // If the signed up shifts filter is active, or we're not set to show shifts // in the past then those take precedence over all others and we short // circuit everything else. @@ -80,8 +78,7 @@ function shouldDisplayNode(node_data, filters) { // Now run through the other filters and see if there's any other reasons to // filter out a shift. This is done by collecting a list of keys because it - // makes debugging easier (you can just console.log(filter_reasons, node_data) - // to get a view of why a shift isn't showing). + // makes debugging easier (they're attached to the node as data-filter-reasons). let filter_reasons = []; if (!filters["role_ids"].includes(node_data["role_id"])) { filter_reasons.push("role_id"); @@ -92,6 +89,11 @@ function shouldDisplayNode(node_data, filters) { if (filters["hide_staffed"] && node_data["staffed"]) { filter_reasons.push("staffed"); } + if (filter_reasons.length > 0) { + node.setAttribute("data-filter-reasons", filter_reasons.join(",")); + } else { + node.setAttribute("data-filter-reasons", ""); + } return filter_reasons.length === 0; } @@ -104,33 +106,18 @@ function spanStartTimeCell(firstNodeOfHour, rowCount) { } } -function rowClass(node_data) { - if (node_data.current_staff < node_data.min_staff) { - return "danger"; - } - - if (node_data.current_staff == node_data.max_staff) { - return "info"; - } - - return "warning"; -} - -function colourise_row(node, node_data, colourful_mode) { - ["danger", "warning", "info"].forEach((className) => - node.classList.remove(className), - ); - - if (!colourful_mode) { - return; - } - - node.classList.add(rowClass(node_data)); +function updateRoleList(role_ids) { + let roleNames = role_ids + .map((id) => document.getElementById(`role-${id}-label`).textContent.trim()) + .join(", "); + document.getElementById("role-list").textContent = roleNames; } function updateRowDisplay() { let filters = getFilters(); + updateRoleList(filters.role_ids); + // Hackery to do row spans. let currentHour = "null"; let firstNodeOfHour = null; @@ -142,7 +129,7 @@ function updateRowDisplay() { rows.forEach((node, idx) => { let node_data = getNodeData(node); - if (shouldDisplayNode(node_data, filters)) { + if (shouldDisplayNode(node_data, filters, node)) { if (node.getAttribute("data-shift-start") != currentHour) { // When we transition to a new hour we calculate how many rows // are shown for that hour, and span the first start time cell @@ -157,8 +144,6 @@ function updateRowDisplay() { node.querySelector(".start_time").classList.add("hidden"); } - colourise_row(node, node_data, filters.colourful_mode); - rowCount += 1; node.classList.remove("hidden"); @@ -174,30 +159,28 @@ function updateRowDisplay() { function init_volunteer_schedule() { loadFilters(); - document.getElementById("filters").style.display = ""; - document.getElementById("filters-toggle").addEventListener("click", () => { - $("#filters-body").toggle(); - }); + ["show_past", "show_signed_up_only", "hide_full", "is_understaffed"].forEach( + (id) => { + document.getElementById(id).addEventListener("change", () => { + saveFilters(); + updateRowDisplay(); + }); + }, + ); - [ - "show_past", - "show_signed_up_only", - "hide_full", - "is_understaffed", - "colourful_mode", - ].forEach((id) => { - document.getElementById(id).addEventListener("change", () => { - saveFilters(); - updateRowDisplay(); + ["filters", "roles"].forEach((panel) => { + document.getElementById(panel).style.display = ""; + document.getElementById(`${panel}-toggle`).addEventListener("click", () => { + $(`#${panel}-body`).toggle(); }); }); - document.querySelectorAll("input[data-role-id]").forEach((node) => + document.querySelectorAll("input[data-role-id]").forEach((node) => { node.addEventListener("change", () => { saveFilters(); updateRowDisplay(); - }), - ); + }); + }); document.getElementById("select-all-roles").addEventListener("click", () => { document @@ -242,6 +225,35 @@ function init_volunteer_schedule() { document.getElementById("select-day").addEventListener("change", (ev) => { document.location.replace(ev.target.value); }); + + function showConflictModal(details) { + const time = (iso) => + new Date(iso).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); + const msgEl = document.getElementById("conflict-modal-message"); + msgEl.innerHTML = ""; + details.forEach((conflict) => { + const p = document.createElement("p"); + const title = conflict.title || conflict.human_type; + p.textContent = `Conflicts with ${conflict.human_type.toLowerCase()}: ${title} (${time(conflict.start_time)}–${time(conflict.end_time)})`; + msgEl.appendChild(p); + }); + $("#conflict-modal").modal("show"); + } + + document.querySelectorAll("td.conflicts").forEach((cell) => { + const row = cell.closest("tr"); + if (!row.getAttribute("data-conflicts-with")) return; + cell.style.cursor = "pointer"; + cell.addEventListener("click", () => { + const details = JSON.parse( + row.getAttribute("data-conflicts-detail") || "[]", + ); + showConflictModal(details); + }); + }); } init_volunteer_schedule(); diff --git a/templates/account/main.html b/templates/account/main.html index 22f9abe90..37ad97182 100644 --- a/templates/account/main.html +++ b/templates/account/main.html @@ -5,6 +5,41 @@ {% include "account/_nav.html" %} {% set owned_tickets = current_user.get_owned_tickets(True, type="admission_ticket")|list %}
| Day | +Time | +Type | +Location | +Event | +
|---|---|---|---|---|
| {{ ns.current_day if new_day else "" }} | +{{ event.start_time.strftime("%H:%M") }}-{{ event.end_time.strftime("%H:%M") }} | +{{ event.human_type }} | +{{ event.venue_name }} | +{{ event.title }} | +
Select a day to sign up for shifts. You can also change which roles you're interested in.
+Select a day to sign up for shifts. You can also change which roles you're interested in.
-You can get a list of all your shifts as an iCal feed.
+You can get a list of all your shifts as an iCal feed.
#} {% if untrained_roles %}