Spaces:
Running
Running
| """The student's own classes, as dated calendar occurrences. | |
| This is what the Grand Calendar's **My classes** layer draws: the one layer that | |
| isn't collected campus data but the student's own courses, expanded out of the | |
| syllabi they have confirmed — class meetings, office hours, exams and due dates. | |
| `payload()` is a pure function of (syllabi, calendar), with no clock and no | |
| storage, so the expansion is testable at a fixed date against a fixed calendar. | |
| Four rules it inherits from the rest of the syllabus pipeline: | |
| - **`reviewed` is the gate.** Only records the student confirmed are expanded | |
| (`schema.ship_ready`). An unreviewed parse is counted, never drawn — a wrong exam | |
| date a student plans around is worse than no exam date. | |
| - **Never invent a recurrence.** A weekly meeting is only placed inside a term whose | |
| anchors were actually derived from the academic calendar. A term the university | |
| hasn't published class dates for (`spring-2027`, today) yields *no* meetings and an | |
| entry in `unplaced` instead, so the calendar can say why rather than drawing a | |
| plausible, wrong grid. | |
| - **Exams and due dates are absolute.** They carry their own ISO date, so they are | |
| placed as-is even when the term anchors are missing or the date sits outside them. | |
| The student confirmed that date; the review screen already questioned the odd ones. | |
| - **Nothing is dropped in silence.** A meeting with no days, an exam with no date, a | |
| whole term with no anchors — each becomes an `unplaced` row the calendar can | |
| explain, because "my midterm isn't on here" with no reason given is the failure | |
| mode that costs trust. | |
| **Why this is server-side** when `app/calendar.js` otherwise reads `/kb/` straight | |
| from the browser: the recurrence needs the term anchors, and those are derived in | |
| `syllabus/terms.py` from title text (`"Undergraduate examinations and reading days, | |
| Dec. 11-19"`) by rules a JavaScript copy would drift from within one term. One | |
| implementation, in the language the tests are in. | |
| Deliberately separate from `today.py`, which also reads syllabi: that answers "what | |
| is on *today*, what is due *this week*" and carries a dashboard's suppression and | |
| urgency rules; this answers "place every occurrence on a grid". Both gate on | |
| `ship_ready`, and both format times as `HH:MM` for their surface to render. | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| from datetime import date, timedelta | |
| from .syllabus import schema as syllabus_schema | |
| from .syllabus import terms as syllabus_terms | |
| log = logging.getLogger("foresight.schedule") | |
| # The kinds of thing a syllabus puts on a calendar. `class` and `office_hours` | |
| # recur weekly and need term anchors; `exam` and `assignment` are single dated | |
| # points and don't. | |
| CLASS = "class" | |
| OFFICE_HOURS = "office_hours" | |
| EXAM = "exam" | |
| ASSIGNMENT = "assignment" | |
| RECURRING = (CLASS, OFFICE_HOURS) | |
| # Why something couldn't be placed. Codes, not sentences: the wording lives with the | |
| # rest of the calendar's copy in `app/calendar.js`. | |
| NO_TERM_DATES = "no_term_dates" # the university hasn't published the term yet | |
| NO_DAYS = "no_days" # a recurring row with nothing to recur on | |
| NO_DATE = "no_date" # an exam or assignment with no usable date | |
| # Safety bounds, not product limits. Eight syllabi with a couple of meeting rows each | |
| # over a 15-week term lands near 500 occurrences, so hitting either of these means a | |
| # bad anchor (a `classes_end` years out) rather than a busy student — and a runaway | |
| # loop is how a bad date turns into a hung request. | |
| MAX_OCCURRENCES = 3000 | |
| MAX_TERM_DAYS = 220 | |
| # --- helpers ---------------------------------------------------------------- | |
| def _day(value) -> date | None: | |
| try: | |
| return date.fromisoformat(str(value)[:10]) | |
| except (TypeError, ValueError): | |
| return None | |
| def _rows(value) -> list: | |
| """A section's rows, or nothing — stored records come from client-editable JSON.""" | |
| return value if isinstance(value, list) else [] | |
| def _text(value) -> str | None: | |
| value = value.strip() if isinstance(value, str) else None | |
| return value or None | |
| def _where(row: dict) -> str | None: | |
| """The most specific location a syllabus row actually carries. | |
| Same rule as `today.py`: the verbatim string a student can read off their | |
| syllabus beats the room we parsed out of it. | |
| """ | |
| return _text(row.get("location_raw")) or _text(row.get("room")) | |
| def _labeled(code: str | None, name: str | None) -> str: | |
| """"BSCI 1510 — Midterm 1", or whichever half exists.""" | |
| if code and name: | |
| return f"{code} — {name}" | |
| return code or name or "" | |
| def _meeting_span(bounds: dict) -> tuple[date | None, date | None]: | |
| """The first and last day a weekly meeting may be placed on, or (None, None). | |
| Classes stop at the last day of classes: the exam period is not class time, and a | |
| final has its own date on the syllabus. When the calendar published no last day of | |
| classes, the day before exams begin is the next best truth and the end of the | |
| semester the last resort — but a missing *start* is never guessed around, because | |
| there is nothing to count a week from. | |
| """ | |
| begin = _day(bounds.get("classes_begin")) | |
| end = _day(bounds.get("classes_end")) | |
| if end is None: | |
| exams = _day(bounds.get("exams_begin")) | |
| end = exams - timedelta(days=1) if exams else _day(bounds.get("term_end")) | |
| if begin is None or end is None or end < begin: | |
| return None, None | |
| if (end - begin).days > MAX_TERM_DAYS: | |
| log.warning("schedule: %s spans %d days (%s → %s) — refusing to expand " | |
| "weekly meetings against anchors that far apart", | |
| bounds.get("term"), (end - begin).days, begin, end) | |
| return None, None | |
| return begin, end | |
| def _break_ranges(bounds: dict) -> list[tuple[date, date]]: | |
| """Fall break, Thanksgiving, spring break — days classes don't meet.""" | |
| out = [] | |
| for brk in bounds.get("breaks") or []: | |
| start, end = _day(brk.get("start")), _day(brk.get("end")) | |
| if start and end and end >= start: | |
| out.append((start, end)) | |
| return out | |
| def _meeting_days(days, span: tuple[date, date], | |
| breaks: list[tuple[date, date]]) -> list[date]: | |
| """Every day in the term this weekly slot actually meets.""" | |
| wanted = {syllabus_schema.DAYS.index(d) for d in _rows(days) | |
| if d in syllabus_schema.DAYS} | |
| if not wanted: | |
| return [] | |
| out, day = [], span[0] | |
| while day <= span[1]: | |
| if day.weekday() in wanted and not any(s <= day <= e for s, e in breaks): | |
| out.append(day) | |
| day += timedelta(days=1) | |
| return out | |
| # --- occurrences ------------------------------------------------------------ | |
| def _course(record: dict) -> dict: | |
| """The fields every occurrence of a course carries, so a popover can name it.""" | |
| return { | |
| "syllabus_id": record.get("id"), | |
| "course_code": record.get("course_code"), | |
| "course_title": _text(record.get("course_title")), | |
| "term": record.get("term"), | |
| } | |
| def _occurrence(kind: str, series: str, day: date, record: dict, *, title: str, | |
| start=None, end=None, location=None, building_slug=None, | |
| room=None, weight=None, instructor=None, days=None, | |
| through: date | None = None) -> dict: | |
| """One dated thing on the grid. | |
| `date`/`start`/`end` stay split — a calendar day and a 24-hour `HH:MM` — rather | |
| than joined into a timestamp, because `app/calendar.js` reads its dates and times | |
| straight out of strings to keep a 9:10 class from drifting into the browser's | |
| timezone. Handing it an offset it would have to strip is how that leaks. | |
| """ | |
| return { | |
| "uid": f"{series}:{day.isoformat()}", | |
| "series": series, | |
| "kind": kind, | |
| "title": title, | |
| "date": day.isoformat(), | |
| "start": start, | |
| "end": end, | |
| "location": location, | |
| "building_slug": building_slug, | |
| "room": room, | |
| "weight": weight, | |
| "instructor": instructor, | |
| # Only on the recurring kinds, so a popover can say "meets Tue, Thu, through | |
| # Dec 9" instead of describing one occurrence as if it were the whole course. | |
| "days": list(days) if days else None, | |
| "through": through.isoformat() if through else None, | |
| **_course(record), | |
| } | |
| def _unplaced(record: dict, reason: str, what: str, label: str | None = None) -> dict: | |
| return { | |
| "course_code": record.get("course_code"), | |
| "term": record.get("term"), | |
| "term_label": label, | |
| "reason": reason, | |
| "what": what, | |
| } | |
| def _recurring(record: dict, span, breaks, term_label) -> tuple[list, list]: | |
| """Class meetings and office hours, expanded across the term.""" | |
| items, unplaced = [], [] | |
| instructor = record.get("instructor") if isinstance(record.get("instructor"), dict) else {} | |
| who = _text(instructor.get("name")) | |
| code = record.get("course_code") | |
| # The chip says the course code — it has to fit in a month-view cell, and the | |
| # title, instructor and room are one click away in the popover. | |
| heading = code or _text(record.get("course_title")) or "Class" | |
| slots = [(CLASS, i, row, heading) | |
| for i, row in enumerate(_rows(record.get("meetings"))) | |
| if isinstance(row, dict)] | |
| slots += [(OFFICE_HOURS, i, row, | |
| f"{code} office hours" if code else "Office hours") | |
| for i, row in enumerate(_rows(instructor.get("office_hours_slots"))) | |
| if isinstance(row, dict)] | |
| for kind, i, row, title in slots: | |
| what = "weekly meetings" if kind == CLASS else "office hours" | |
| if not _rows(row.get("days")): | |
| # Nothing to recur on. A slot the parser read a time but no days from is | |
| # exactly what the review screen's `missing_days` flag is about, so point | |
| # the student back at it rather than dropping the class off the calendar. | |
| unplaced.append(_unplaced(record, NO_DAYS, what, term_label)) | |
| continue | |
| if span[0] is None: | |
| unplaced.append(_unplaced(record, NO_TERM_DATES, what, term_label)) | |
| continue | |
| # A slot with days but no time is placed as an all-day chip: the student | |
| # confirmed the days, and "Mon Wed Fri, time unknown" is true and useful. | |
| # Office hours fall back to the instructor's office, as `schema.py` does. | |
| location = _where(row) or ( | |
| _text(instructor.get("office")) if kind == OFFICE_HOURS else None) | |
| building = row.get("building_slug") or ( | |
| instructor.get("office_building_slug") if kind == OFFICE_HOURS else None) | |
| series = f"{kind}:{record.get('id')}:{i}" | |
| for day in _meeting_days(row.get("days"), span, breaks): | |
| items.append(_occurrence( | |
| kind, series, day, record, title=title, | |
| start=_text(row.get("start")), end=_text(row.get("end")), | |
| location=location, building_slug=building, | |
| room=_text(row.get("room")), instructor=who, | |
| days=row.get("days"), through=span[1])) | |
| return items, unplaced | |
| def _coursework(record: dict, term_label) -> tuple[list, list]: | |
| """Exams and assignment due dates — dated points, placed as written.""" | |
| items, unplaced = [], [] | |
| code = record.get("course_code") | |
| sections = ( | |
| (EXAM, "exams", "date", "Exam"), | |
| (ASSIGNMENT, "assignments", "due", "Assignment"), | |
| ) | |
| for kind, section, date_field, fallback in sections: | |
| for i, row in enumerate(_rows(record.get(section))): | |
| if not isinstance(row, dict): | |
| continue | |
| name = _text(row.get("title")) or fallback | |
| day = _day(row.get(date_field)) | |
| if day is None: | |
| unplaced.append(_unplaced(record, NO_DATE, name, term_label)) | |
| continue | |
| items.append(_occurrence( | |
| kind, f"{kind}:{record.get('id')}:{i}", day, record, | |
| title=_labeled(code, name), | |
| start=_text(row.get("start")), end=_text(row.get("end")), | |
| location=_where(row), building_slug=row.get("building_slug"), | |
| room=_text(row.get("room")), weight=_text(row.get("weight")))) | |
| return items, unplaced | |
| # --- where the classes are ------------------------------------------------- | |
| def _place_entry(kind: str, record: dict, row: dict, instructor: dict, room) -> dict: | |
| """One thing that happens in one building, for the campus map's pin card.""" | |
| return { | |
| "kind": kind, | |
| "course_code": record.get("course_code"), | |
| "course_title": _text(record.get("course_title")), | |
| "term": record.get("term"), | |
| "days": [d for d in _rows(row.get("days")) if d in syllabus_schema.DAYS], | |
| "start": _text(row.get("start")), | |
| "end": _text(row.get("end")), | |
| "room": room, | |
| "instructor": _text(instructor.get("name")) if kind == OFFICE_HOURS else None, | |
| } | |
| def _places(ready: list[dict]) -> tuple[list[dict], list[dict]]: | |
| """Where the student's classes and office hours are, grouped by building. | |
| The campus map's My classes layer: `places` are pins it can show, `unmapped` are | |
| the locations it can't and has to say so about. | |
| Three deliberate choices: | |
| - **Derived from the records, not from `items`.** A room is a fact the syllabus | |
| states; it doesn't depend on whether we could place the term's dates. A course in | |
| a term the university hasn't published still has somewhere it meets. | |
| - **Classes and office hours only.** An exam room is a place too, but this layer | |
| answers "where do I go, week to week" — an exam hall is one date the calendar | |
| already carries, and pinning it would put a room a student visits once beside the | |
| three they visit weekly. | |
| - **A location that didn't resolve is reported, never guessed.** `building_slug` is | |
| null whenever the gazetteer wasn't confident (`schema.resolve_location`), and a | |
| wrong pin is worse than a missing one — so the text goes to `unmapped` for the map | |
| to show as words instead of a pin. | |
| """ | |
| by_slug: dict[str, dict] = {} | |
| unmapped: list[dict] = [] | |
| for record in ready: | |
| instructor = record.get("instructor") if isinstance(record.get("instructor"), dict) else {} | |
| office = _text(instructor.get("office")) | |
| rows = [(CLASS, row) for row in _rows(record.get("meetings")) | |
| if isinstance(row, dict)] | |
| rows += [(OFFICE_HOURS, row) for row in _rows(instructor.get("office_hours_slots")) | |
| if isinstance(row, dict)] | |
| for kind, row in rows: | |
| raw, slug, room = _where(row), row.get("building_slug"), _text(row.get("room")) | |
| if kind == OFFICE_HOURS and not raw: | |
| # A slot with no room of its own happens in the office, exactly as the | |
| # calendar places it. | |
| raw, slug, room = (office, instructor.get("office_building_slug"), | |
| _text(instructor.get("office_room"))) | |
| if not raw and not slug: | |
| continue # a row with no location says nothing here | |
| entry = _place_entry(kind, record, row, instructor, room) | |
| if not slug: | |
| unmapped.append({"course_code": record.get("course_code"), | |
| "kind": kind, "location": raw, | |
| "term": record.get("term")}) | |
| continue | |
| place = by_slug.setdefault(slug, {"building_slug": slug, "location": raw, | |
| "entries": []}) | |
| place["entries"].append(entry) | |
| for place in by_slug.values(): | |
| place["entries"].sort(key=lambda e: (e["kind"] != CLASS, e["course_code"] or "", | |
| e["start"] or "")) | |
| return ([by_slug[s] for s in sorted(by_slug)], unmapped) | |
| def _term_summary(bounds: dict, span) -> dict: | |
| """What the calendar may say about a term whose courses it is drawing.""" | |
| return { | |
| "term": bounds.get("term"), | |
| "label": bounds.get("label"), | |
| "classes_begin": bounds.get("classes_begin"), | |
| "classes_end": bounds.get("classes_end"), | |
| "exams_begin": bounds.get("exams_begin"), | |
| "exams_end": bounds.get("exams_end"), | |
| "breaks": bounds.get("breaks") or [], | |
| # Whether weekly meetings could be placed at all — the honest version of | |
| # "why is my Spring 2027 class not on here". | |
| "placeable": span[0] is not None, | |
| } | |
| def _course_summary(record: dict, span) -> dict: | |
| """One row for the calendar's course list, so the layer has a legend. | |
| Times stay `HH:MM`; the frontend formats them with the same `fmtTime` it uses on | |
| every other chip, so one calendar never shows two clock styles. | |
| """ | |
| instructor = record.get("instructor") if isinstance(record.get("instructor"), dict) else {} | |
| meetings = [{"days": [d for d in _rows(row.get("days")) | |
| if d in syllabus_schema.DAYS], | |
| "start": _text(row.get("start")), | |
| "end": _text(row.get("end")), | |
| "location": _where(row)} | |
| for row in _rows(record.get("meetings")) if isinstance(row, dict)] | |
| return { | |
| **_course(record), | |
| "instructor": _text(instructor.get("name")), | |
| "meetings": meetings, | |
| "exams": len([r for r in _rows(record.get("exams")) if isinstance(r, dict)]), | |
| "assignments": len([r for r in _rows(record.get("assignments")) | |
| if isinstance(r, dict)]), | |
| "placeable": span[0] is not None, | |
| } | |
| def payload(syllabi: list | None, calendar: list | None = None) -> dict: | |
| """Everything the My classes layer draws, from the student's confirmed syllabi. | |
| Returns: | |
| {"items": [occurrence, ...], # every dated thing, sorted | |
| "courses": [{...}], # one row per confirmed course | |
| "terms": [{...}], # the anchors each course was placed against | |
| "places": [{...}], # buildings their classes are in (the map) | |
| "unplaced": [{...}], # what couldn't be placed, and why | |
| "unmapped": [{...}], # locations that resolved to no building | |
| "unreviewed": <int>} # uploaded, saved, awaiting confirmation | |
| Two surfaces read this: the calendar's grid takes `items`, the campus map's My | |
| classes layer takes `places`. One endpoint rather than two because it is one | |
| question — "where does this student have to be, and when" — and a second route | |
| would re-read the same file and re-derive the same anchors. | |
| Everything is returned for every term the student has a syllabus for, not a date | |
| window: a term is a few hundred occurrences, and the calendar navigates freely in | |
| both directions, so a window would mean a request per swipe of the month arrow. | |
| """ | |
| records = syllabi if isinstance(syllabi, list) else [] | |
| ready = syllabus_schema.ship_ready(records) | |
| cal = calendar if calendar is not None else syllabus_terms.load_calendar() | |
| anchors: dict[str, dict] = {} | |
| spans: dict[str, tuple] = {} | |
| def bounds_for(term: str) -> tuple[dict, tuple]: | |
| if term not in anchors: | |
| anchors[term] = syllabus_terms.bounds(term, cal) | |
| spans[term] = _meeting_span(anchors[term]) | |
| return anchors[term], spans[term] | |
| items: list[dict] = [] | |
| courses: list[dict] = [] | |
| unplaced: list[dict] = [] | |
| for record in ready: | |
| bounds, span = bounds_for(record.get("term") or "") | |
| courses.append(_course_summary(record, span)) | |
| rows, missed = _recurring(record, span, _break_ranges(bounds), bounds.get("label")) | |
| items += rows | |
| unplaced += missed | |
| rows, missed = _coursework(record, bounds.get("label")) | |
| items += rows | |
| unplaced += missed | |
| if len(items) > MAX_OCCURRENCES: | |
| # Truncating in silence would read as "your term ends in October", so say it | |
| # here and let the caller's log carry it. | |
| log.warning("schedule: %d occurrences from %d syllabi — truncated to %d", | |
| len(items), len(ready), MAX_OCCURRENCES) | |
| items = items[:MAX_OCCURRENCES] | |
| items.sort(key=lambda i: (i["date"], i["start"] or "", i["title"])) | |
| places, unmapped = _places(ready) | |
| return { | |
| "items": items, | |
| "courses": courses, | |
| "terms": [_term_summary(anchors[t], spans[t]) for t in sorted(anchors) if t], | |
| "places": places, | |
| "unmapped": unmapped, | |
| # Same thing said twice is noise: one line per (course, reason, what). | |
| "unplaced": list({tuple(sorted(u.items())): u for u in unplaced}.values()), | |
| # Saved but not confirmed. The calendar shows nothing from these — and a | |
| # student who uploaded a syllabus and never finished the review deserves to be | |
| # told that, rather than wondering where their classes went. | |
| "unreviewed": len(records) - len(ready), | |
| } | |