File size: 21,319 Bytes
acb3189
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
"""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),
    }