"""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": } # 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), }