Yujie Chen commited on
Commit
45f12d7
·
0 Parent(s):

Initial commit for Hugging Face Space

Browse files
Dockerfile ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+ WORKDIR /app
3
+ ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
4
+ RUN pip install --no-cache-dir flask==3.0.0 jinja2==3.1.3 itsdangerous==2.2.0 click==8.1.7
5
+ # copy common utilities from project root (build context is project root)
6
+ COPY ./common /app/common
7
+ # copy only the minerva-lite service files into /app
8
+ COPY ./minerva-lite /app
9
+ EXPOSE 8000
10
+ CMD ["python", "app.py"]
app.py ADDED
@@ -0,0 +1,280 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Flask, render_template, request, jsonify, redirect, url_for, session
2
+ import sqlite3, pathlib
3
+ from functools import wraps
4
+
5
+
6
+ APP = Flask(__name__)
7
+ APP.secret_key = "dev-minerva-lite" # session for term selection
8
+ ROOT = pathlib.Path(__file__).parent
9
+ DB_PATH = ROOT / "db" / "minerva.sqlite3"
10
+
11
+ STEP_DESTINATIONS = {
12
+ "home": {"endpoint": "registration_menu", "label": "Registration Menu"},
13
+ "search": {"endpoint": "search", "label": "Search Class Schedule"},
14
+ "add_drop": {"endpoint": "add_drop", "label": "Add or Drop Course Sections"},
15
+ "schedule": {"endpoint": "schedule", "label": "Student Schedule"},
16
+ "schedule_week": {"endpoint": "schedule_week", "label": "Weekly Schedule View"},
17
+ "accounts": {"endpoint": "accounts", "label": "Tuition Fee and Legal Status"},
18
+ }
19
+
20
+ STEP_KEYS_BY_ENDPOINT = {
21
+ "registration_menu": "home",
22
+ "search": "search",
23
+ "add_drop": "add_drop",
24
+ "schedule": "schedule",
25
+ "schedule_week": "schedule_week",
26
+ "accounts": "accounts",
27
+ }
28
+
29
+
30
+ def require_term(step_key):
31
+ def decorator(func):
32
+ @wraps(func)
33
+ def wrapper(*args, **kwargs):
34
+ if not session.get("term_confirmed"):
35
+ return redirect(url_for("select_term", next=step_key))
36
+ return func(*args, **kwargs)
37
+ return wrapper
38
+ return decorator
39
+
40
+ if not DB_PATH.exists():
41
+ import seed # side effect: creates DB
42
+
43
+ def q(sql, *params):
44
+ con = sqlite3.connect(DB_PATH)
45
+ con.row_factory = sqlite3.Row
46
+ cur = con.execute(sql, params)
47
+ rows = [dict(r) for r in cur.fetchall()]
48
+ con.close()
49
+ return rows
50
+
51
+
52
+ def execute(sql, *params):
53
+ with sqlite3.connect(DB_PATH) as con:
54
+ con.execute(sql, params)
55
+ con.commit()
56
+
57
+
58
+ @APP.get("/status")
59
+ def status():
60
+ return jsonify({"service": "minerva-lite", "ok": True})
61
+
62
+
63
+ @APP.get("/")
64
+ def registration_menu():
65
+ msg = request.args.get("msg", "")
66
+ return render_template("registration_menu.html", msg=msg)
67
+
68
+
69
+ @APP.get("/search")
70
+ @require_term("search")
71
+ def search():
72
+ subject = request.args.get("subject", "").strip().upper()
73
+ number = request.args.get("number", "").strip()
74
+ title = request.args.get("title", "").strip()
75
+ campus = request.args.get("campus", "").strip()
76
+ term = session.get("term", "Fall 2025")
77
+
78
+ sql = "SELECT * FROM courses WHERE 1=1"
79
+ args = []
80
+ if term:
81
+ sql += " AND term = ?"
82
+ args.append(term)
83
+ if subject:
84
+ sql += " AND subject = ?"
85
+ args.append(subject)
86
+ if number:
87
+ sql += " AND number LIKE ?"
88
+ args.append(f"%{number}%")
89
+ if title:
90
+ sql += " AND lower(title) LIKE ?"
91
+ args.append(f"%{title.lower()}%")
92
+ if campus:
93
+ sql += " AND campus = ?"
94
+ args.append(campus)
95
+ sql += " ORDER BY subject, number"
96
+ results = q(sql, *args)
97
+ return render_template("search.html", results=results)
98
+
99
+
100
+ @APP.post("/add")
101
+ def add_course():
102
+ crn = request.form.get("crn", "").strip()
103
+ if not crn:
104
+ return redirect(url_for("add_drop", msg="Enter a CRN."))
105
+ # Check if exists and open
106
+ rows = q("SELECT * FROM courses WHERE crn=?", crn)
107
+ if not rows:
108
+ return redirect(url_for("add_drop", msg="Invalid CRN."))
109
+ course = rows[0]
110
+ # Ensure CRN belongs to the currently selected term
111
+ current_term = session.get("term", "Fall 2025")
112
+ if course.get("term") != current_term:
113
+ return redirect(url_for("add_drop", msg=f"CRN not in {current_term} term."))
114
+ reg = q("SELECT * FROM registrations WHERE student_id=1 AND crn=?", crn)
115
+ if reg:
116
+ return redirect(url_for("add_drop", msg="Already registered."))
117
+ if course["seats_taken"] >= course["seats_total"]:
118
+ return redirect(url_for("add_drop", msg="Section full."))
119
+ execute("INSERT INTO registrations(student_id,crn) VALUES(1,?)", crn)
120
+ execute(
121
+ "UPDATE courses SET seats_taken = seats_taken + 1 WHERE crn=?",
122
+ crn,
123
+ )
124
+ return redirect(url_for("schedule", msg="Added CRN %s" % crn))
125
+
126
+
127
+ @APP.post("/drop")
128
+ def drop_course():
129
+ crn = request.form.get("crn", "").strip()
130
+ reg = q("SELECT * FROM registrations WHERE student_id=1 AND crn=?", crn)
131
+ if not reg:
132
+ return redirect(url_for("schedule", msg="Not registered."))
133
+ execute("DELETE FROM registrations WHERE student_id=1 AND crn=?", crn)
134
+ execute(
135
+ "UPDATE courses SET seats_taken = CASE WHEN seats_taken>0 THEN seats_taken-1 ELSE 0 END WHERE crn=?",
136
+ crn,
137
+ )
138
+ return redirect(url_for("schedule", msg="Dropped CRN %s" % crn))
139
+
140
+
141
+ @APP.get("/add-drop")
142
+ @require_term("add_drop")
143
+ def add_drop():
144
+ msg = request.args.get("msg", "")
145
+ current_term = session.get("term", "Fall 2025")
146
+ regs = q(
147
+ "SELECT c.* FROM registrations r JOIN courses c ON r.crn=c.crn WHERE r.student_id=1 AND c.term=? ORDER BY c.subject, c.number",
148
+ current_term,
149
+ )
150
+ return render_template("add_drop.html", regs=regs, msg=msg)
151
+
152
+
153
+ @APP.get("/schedule")
154
+ @require_term("schedule")
155
+ def schedule():
156
+ msg = request.args.get("msg", "")
157
+ current_term = session.get("term", "Fall 2025")
158
+ regs = q(
159
+ "SELECT c.* FROM registrations r JOIN courses c ON r.crn=c.crn WHERE r.student_id=1 AND c.term=? ORDER BY c.subject, c.number",
160
+ current_term,
161
+ )
162
+ return render_template("schedule.html", regs=regs, msg=msg)
163
+
164
+
165
+ def _parse_time_range(range_str):
166
+ try:
167
+ s, e = [t.strip() for t in range_str.split("-")]
168
+ sh, sm = [int(x) for x in s.split(":")]
169
+ eh, em = [int(x) for x in e.split(":")]
170
+ return sh * 60 + sm, eh * 60 + em
171
+ except Exception:
172
+ return None, None
173
+
174
+
175
+ @APP.get("/schedule-week")
176
+ @require_term("schedule_week")
177
+ def schedule_week():
178
+ # Build weekly event blocks for registered sections
179
+ current_term = session.get("term", "Fall")
180
+ regs = q(
181
+ "SELECT c.* FROM registrations r JOIN courses c ON r.crn=c.crn WHERE r.student_id=1 AND c.term=?",
182
+ current_term,
183
+ )
184
+ start_day_min = 8 * 60 # 08:00 grid start
185
+ end_day_min = 18 * 60 # 18:00 grid end
186
+ span = end_day_min - start_day_min
187
+ grid_px = 800 # matches CSS .grid-body height
188
+ day_map = {"M": 1, "T": 2, "W": 3, "R": 4, "F": 5}
189
+
190
+ events = []
191
+ for c in regs:
192
+ smin, emin = _parse_time_range(c.get("time") or "")
193
+ if smin is None:
194
+ continue
195
+ duration = max(emin - smin, 30)
196
+ # pixel-perfect placement using the same 800px grid height
197
+ top_px = max(int(round((smin - start_day_min) / span * grid_px)), 0)
198
+ height_px = max(int(round(duration / span * grid_px)), 2)
199
+ for ch in (c.get("days") or ""): # e.g., "MWF" or "TR"
200
+ if ch in day_map:
201
+ events.append({
202
+ "day_col": day_map[ch],
203
+ "title": f"{c['subject']} {c['number']}",
204
+ "subtitle": c["title"],
205
+ "time": c["time"],
206
+ "top_px": top_px,
207
+ "height_px": height_px,
208
+ "crn": c["crn"],
209
+ })
210
+
211
+ # Time labels every hour for the left rail
212
+ time_labels = []
213
+ for h in range(8, 18):
214
+ time_labels.append(f"{h:02d}:00")
215
+
216
+ return render_template(
217
+ "schedule_week.html",
218
+ events=events,
219
+ time_labels=time_labels,
220
+ )
221
+
222
+
223
+ @APP.get("/accounts")
224
+ @require_term("accounts")
225
+ def accounts():
226
+ return render_template("accounts.html")
227
+
228
+
229
+ @APP.post("/set-term")
230
+ def set_term():
231
+ term = request.form.get("term", "").strip().title()
232
+ if term not in ("Fall 2025", "Winter 2026", "Summer 2026"):
233
+ return redirect(url_for("registration_menu", msg="Invalid term."))
234
+ session["term"] = term
235
+ session["term_confirmed"] = True
236
+ next_key = request.form.get("next", "").strip()
237
+ if next_key in STEP_DESTINATIONS:
238
+ endpoint = STEP_DESTINATIONS[next_key]["endpoint"]
239
+ return redirect(url_for(endpoint))
240
+ # Redirect back to the referrer if possible
241
+ ref = request.headers.get("Referer")
242
+ return redirect(ref or url_for("registration_menu", msg=f"Term set to {term}"))
243
+
244
+
245
+ @APP.get("/select-term")
246
+ def select_term():
247
+ next_key = request.args.get("next", "search").strip()
248
+ destination = STEP_DESTINATIONS.get(next_key, STEP_DESTINATIONS["search"])
249
+ if request.args.get("reset"):
250
+ session["term_confirmed"] = False
251
+ return render_template(
252
+ "select_term.html",
253
+ next_key=next_key,
254
+ destination_label=destination["label"],
255
+ )
256
+
257
+
258
+ @APP.context_processor
259
+ def inject_term():
260
+ current_step_key = STEP_KEYS_BY_ENDPOINT.get(request.endpoint, "")
261
+ if request.endpoint == "select_term":
262
+ current_step_key = request.args.get("next", current_step_key or "search")
263
+ return {
264
+ "current_term": session.get("term", "Fall 2025"),
265
+ "terms": ["Fall 2025", "Winter 2026", "Summer 2026"],
266
+ "term_confirmed": session.get("term_confirmed", False),
267
+ "current_step_key": current_step_key,
268
+ }
269
+
270
+
271
+ @APP.get("/reset")
272
+ def reset():
273
+ if DB_PATH.exists():
274
+ DB_PATH.unlink()
275
+ import seed # re-seed
276
+ return jsonify({"reset": True})
277
+
278
+
279
+ if __name__ == "__main__":
280
+ APP.run(host="0.0.0.0", port=8000)
data/courses_seed.json ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "courses": [
3
+ {"crn": "10001", "subject": "COMP", "number": "202", "title": "Foundations of Programming", "credits": 3, "days": "MWF", "time": "10:00-10:50", "campus": "Downtown", "seats_total": 200, "seats_taken": 160},
4
+ {"crn": "10002", "subject": "COMP", "number": "250", "title": "Introduction to Computer Science", "credits": 3, "days": "TR", "time": "11:35-12:55", "campus": "Downtown", "seats_total": 180, "seats_taken": 175},
5
+ {"crn": "10003", "subject": "COMP", "number": "364", "title": "Bioinformatics", "credits": 3, "days": "TR", "time": "13:05-14:25", "campus": "Downtown", "seats_total": 90, "seats_taken": 55},
6
+ {"crn": "10004", "subject": "COMP", "number": "421", "title": "Database Systems", "credits": 3, "days": "MW", "time": "14:35-15:55", "campus": "Downtown", "seats_total": 120, "seats_taken": 120},
7
+ {"crn": "10005", "subject": "MATH", "number": "222", "title": "Calculus 3", "credits": 3, "days": "MWF", "time": "12:35-13:25", "campus": "Downtown", "seats_total": 250, "seats_taken": 230},
8
+ {"crn": "10006", "subject": "PHYS", "number": "101", "title": "Introductory Physics", "credits": 3, "days": "TR", "time": "08:35-09:55", "campus": "Downtown", "seats_total": 140, "seats_taken": 100},
9
+ {"crn": "10007", "subject": "ECON", "number": "230", "title": "Microeconomic Analysis", "credits": 3, "days": "MWF", "time": "09:35-10:25", "campus": "Downtown", "seats_total": 160, "seats_taken": 155},
10
+ {"crn": "10008", "subject": "BIOL", "number": "111", "title": "Principles of Biology", "credits": 3, "days": "TR", "time": "10:05-11:25", "campus": "Downtown", "seats_total": 180, "seats_taken": 90},
11
+ {"crn": "10009", "subject": "CHEM", "number": "120", "title": "General Chemistry", "credits": 3, "days": "MWF", "time": "11:35-12:25", "campus": "Downtown", "seats_total": 220, "seats_taken": 210},
12
+ {"crn": "10010", "subject": "ENGL", "number": "199", "title": "Introduction to Literature", "credits": 3, "days": "TR", "time": "14:35-15:55", "campus": "Downtown", "seats_total": 80, "seats_taken": 60},
13
+ {"crn": "10011", "subject": "PSYC", "number": "100", "title": "Introduction to Psychology", "credits": 3, "days": "MW", "time": "16:05-17:25", "campus": "Downtown", "seats_total": 240, "seats_taken": 220},
14
+ {"crn": "10012", "subject": "COMP", "number": "424", "title": "Artificial Intelligence", "credits": 3, "days": "TR", "time": "09:05-10:25", "campus": "Downtown", "seats_total": 120, "seats_taken": 80},
15
+ {"crn": "10013", "subject": "COMP", "number": "551", "title": "Applied Machine Learning", "credits": 3, "days": "TR", "time": "13:35-14:55", "campus": "Downtown", "seats_total": 90, "seats_taken": 70},
16
+ {"crn": "10014", "subject": "MATH", "number": "223", "title": "Linear Algebra", "credits": 3, "days": "MWF", "time": "11:35-12:25", "campus": "Downtown", "seats_total": 200, "seats_taken": 180},
17
+ {"crn": "10015", "subject": "STAT", "number": "260", "title": "Introduction to Statistics", "credits": 3, "days": "TR", "time": "12:35-13:55", "campus": "Downtown", "seats_total": 160, "seats_taken": 130},
18
+ {"crn": "10016", "subject": "ECON", "number": "340", "title": "Macroeconomic Theory", "credits": 3, "days": "MW", "time": "15:05-16:25", "campus": "Downtown", "seats_total": 100, "seats_taken": 85},
19
+ {"crn": "10017", "subject": "HIST", "number": "210", "title": "Modern World History", "credits": 3, "days": "MWF", "time": "13:35-14:25", "campus": "Downtown", "seats_total": 140, "seats_taken": 95},
20
+ {"crn": "10018", "subject": "PHYS", "number": "230", "title": "Electricity and Magnetism", "credits": 3, "days": "TR", "time": "16:05-17:25", "campus": "Downtown", "seats_total": 110, "seats_taken": 60},
21
+ {"crn": "10019", "subject": "ENGL", "number": "315", "title": "Creative Writing Workshop", "credits": 3, "days": "MW", "time": "09:05-10:25", "campus": "Downtown", "seats_total": 60, "seats_taken": 45}
22
+ ]
23
+ }
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ flask==3.0.0
2
+ Flask-Session
3
+ jinja2==3.1.3
4
+ itsdangerous==2.2.0
5
+ click==8.1.7
6
+ werkzeug==3.0.1
7
+ markupsafe==2.1.3
schema.sql ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ DROP TABLE IF EXISTS registrations;
2
+ DROP TABLE IF EXISTS students;
3
+ DROP TABLE IF EXISTS courses;
4
+
5
+ CREATE TABLE courses (
6
+ crn TEXT PRIMARY KEY,
7
+ subject TEXT NOT NULL,
8
+ number TEXT NOT NULL,
9
+ title TEXT NOT NULL,
10
+ credits REAL NOT NULL,
11
+ days TEXT,
12
+ time TEXT,
13
+ campus TEXT NOT NULL,
14
+ term TEXT NOT NULL,
15
+ seats_total INTEGER NOT NULL,
16
+ seats_taken INTEGER NOT NULL DEFAULT 0
17
+ );
18
+
19
+ CREATE TABLE students (
20
+ id INTEGER PRIMARY KEY,
21
+ name TEXT
22
+ );
23
+
24
+ CREATE TABLE registrations (
25
+ student_id INTEGER NOT NULL,
26
+ crn TEXT NOT NULL,
27
+ PRIMARY KEY (student_id, crn),
28
+ FOREIGN KEY(student_id) REFERENCES students(id),
29
+ FOREIGN KEY(crn) REFERENCES courses(crn)
30
+ );
seed.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, sqlite3, pathlib, random
2
+ from common.seed_utils import init_db, load_json
3
+
4
+
5
+ ROOT = pathlib.Path(__file__).parent
6
+ DB_PATH = ROOT / "db" / "minerva.sqlite3"
7
+ SCHEMA = ROOT / "schema.sql"
8
+ SEED_JSON = ROOT / "data" / "courses_seed.json"
9
+ SEED = int(os.environ.get("APP_SEED", 42))
10
+ random.seed(SEED)
11
+
12
+
13
+ init_db(DB_PATH, SCHEMA)
14
+ seed = load_json(SEED_JSON)
15
+
16
+ TERMS = ["Fall 2025", "Winter 2026", "Summer 2026"]
17
+ TERM_PREFIX = {"Fall 2025": "1", "Winter 2026": "2", "Summer 2026": "3"}
18
+
19
+
20
+ def _normalize_terms(terms_list):
21
+ # Accept plain season names or fully labeled ones and normalize
22
+ norm = []
23
+ for t in terms_list:
24
+ tl = str(t).strip()
25
+ low = tl.lower()
26
+ if tl in TERMS:
27
+ norm.append(tl)
28
+ elif "fall" in low:
29
+ norm.append("Fall 2025")
30
+ elif "winter" in low:
31
+ norm.append("Winter 2026")
32
+ elif "summer" in low:
33
+ norm.append("Summer 2026")
34
+ # Deduplicate while preserving order
35
+ out = []
36
+ for t in norm:
37
+ if t not in out:
38
+ out.append(t)
39
+ return out
40
+
41
+
42
+ def _pick_terms_for_course(crn_str):
43
+ # Deterministic selection: some courses in 1 term, some in 2, some in all 3
44
+ try:
45
+ base_seed = SEED + int("".join(ch for ch in crn_str if ch.isdigit()))
46
+ except Exception:
47
+ base_seed = SEED
48
+ rnd = random.Random(base_seed)
49
+ p = rnd.random()
50
+ if p < 0.4:
51
+ k = 1
52
+ elif p < 0.8:
53
+ k = 2
54
+ else:
55
+ k = 3
56
+ return rnd.sample(TERMS, k)
57
+
58
+ with sqlite3.connect(DB_PATH) as con:
59
+ # Seed one demo student
60
+ con.execute("INSERT INTO students(id, name) VALUES(1, ?)", ("Demo Student",))
61
+ # Seed courses with variable term availability
62
+ for c in seed["courses"]:
63
+ explicit_terms = _normalize_terms(c.get("terms", [])) if isinstance(c, dict) else []
64
+ terms_for_course = explicit_terms or _pick_terms_for_course(str(c.get("crn", "")))
65
+ for term in terms_for_course:
66
+ base_crn = str(c["crn"]) # from JSON
67
+ # Prefix CRN by term to ensure uniqueness across terms
68
+ crn = f"{TERM_PREFIX[term]}{base_crn}"
69
+ con.execute(
70
+ """
71
+ INSERT INTO courses(crn,subject,number,title,credits,days,time,campus,term,seats_total,seats_taken)
72
+ VALUES(?,?,?,?,?,?,?,?,?,?,?)
73
+ """,
74
+ (
75
+ crn,
76
+ c["subject"],
77
+ c["number"],
78
+ c["title"],
79
+ float(c["credits"]),
80
+ c.get("days", ""),
81
+ c.get("time", ""),
82
+ c.get("campus", "Downtown"),
83
+ term,
84
+ int(c.get("seats_total", 100)),
85
+ int(c.get("seats_taken", 0)),
86
+ ),
87
+ )
88
+ con.commit()
89
+ print("Minerva-Lite seeded.")
selectors.json ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "menu_home": "[data-test=menu-home]",
3
+ "menu_search": "[data-test=menu-search]",
4
+ "menu_adddrop": "[data-test=menu-adddrop]",
5
+ "menu_schedule": "[data-test=menu-schedule]",
6
+ "menu_schedule_week": "[data-test=menu-schedule-week]",
7
+ "change_term_link": "[data-test=change-term-link]",
8
+
9
+ "search_subject": "[data-test=search-subject]",
10
+ "search_number": "[data-test=search-number]",
11
+ "search_title": "[data-test=search-title]",
12
+ "search_campus": "[data-test=search-campus]",
13
+ "search_openonly": "[data-test=search-openonly]",
14
+ "search_button": "[data-test=search-button]",
15
+
16
+ "course_row": "[data-test=course-row]",
17
+ "course_crn": "[data-test=course-crn]",
18
+ "add_button": "[data-test=add-button]",
19
+
20
+ "add_crn_input": "[data-test=add-crn-input]",
21
+ "add_submit": "[data-test=add-submit]",
22
+ "schedule_row": "[data-test=schedule-row]",
23
+ "drop_button": "[data-test=drop-button]",
24
+
25
+ "notice": "[data-test=notice]",
26
+ "week_event": "[data-test=week-event]",
27
+ "term_select": "[data-test=term-select]",
28
+ "term_submit": "[data-test=term-submit]"
29
+ }
static/styles.css ADDED
@@ -0,0 +1,382 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root{
2
+ --brand:#b70000;
3
+ --brand-dark:#7a0000;
4
+ --bg:#f1f3f8;
5
+ --text:#1d1d1f;
6
+ --muted:#4e5a6b;
7
+ --border:#d4d8e0;
8
+ --surface:#fff;
9
+ --accent:#0d4f9a;
10
+ --notice-bg:#fff7c2;
11
+ --nav:#0c2340;
12
+ }
13
+
14
+ *{box-sizing:border-box}
15
+ body{
16
+ font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
17
+ margin:0;
18
+ color:var(--text);
19
+ background:var(--bg);
20
+ }
21
+ .minerva-skin{
22
+ min-height:100vh;
23
+ display:flex;
24
+ flex-direction:column;
25
+ background:linear-gradient(180deg,#e7ebf5 0%,#f7f8fb 160%);
26
+ }
27
+ .container{
28
+ max-width:1280px;
29
+ width:92vw;
30
+ margin:0 auto;
31
+ padding:0 18px;
32
+ }
33
+
34
+ .sr-only{
35
+ position:absolute;
36
+ width:1px;
37
+ height:1px;
38
+ padding:0;
39
+ margin:-1px;
40
+ overflow:hidden;
41
+ clip:rect(0,0,0,0);
42
+ border:0;
43
+ }
44
+
45
+ .minerva-header{
46
+ box-shadow:0 2px 6px rgba(12,19,31,.2);
47
+ background:#dcdfe6;
48
+ }
49
+ .hero-strip{
50
+ background:linear-gradient(135deg,#cfd4de 10%,#edf0f6 60%);
51
+ border-bottom:2px solid #b8bec8;
52
+ }
53
+ .hero-inner{
54
+ display:flex;
55
+ align-items:center;
56
+ justify-content:space-between;
57
+ min-height:90px;
58
+ padding:.85rem 16px;
59
+ gap:1.5rem;
60
+ }
61
+ .hero-brand{display:flex;align-items:center;gap:1rem}
62
+ .hero-logo{
63
+ background:#c02827;
64
+ color:#fff;
65
+ font-weight:600;
66
+ font-size:1.1rem;
67
+ padding:.5rem 1.1rem;
68
+ border-radius:6px;
69
+ text-transform:uppercase;
70
+ letter-spacing:.05em;
71
+ box-shadow:0 2px 4px rgba(0,0,0,.2);
72
+ }
73
+ .hero-wordmark-main{
74
+ font-size:1.8rem;
75
+ color:#1e1e1e;
76
+ font-weight:600;
77
+ }
78
+ .hero-wordmark-sub{
79
+ font-size:.8rem;
80
+ text-transform:uppercase;
81
+ letter-spacing:.08em;
82
+ color:#4b4f5a;
83
+ }
84
+ .hero-figure{
85
+ flex:0 0 180px;
86
+ height:90px;
87
+ background:linear-gradient(120deg,#920707,#f8c4c4);
88
+ border-radius:999px 0 0 999px;
89
+ box-shadow:inset 0 0 25px rgba(0,0,0,.2);
90
+ }
91
+ .tab-row{
92
+ background:linear-gradient(180deg,#f5f6fb 0%,#e1e6f0 100%);
93
+ border-bottom:1px solid #c2c7d3;
94
+ box-shadow:inset 0 1px 0 rgba(255,255,255,.7);
95
+ padding-bottom:.5rem;
96
+ margin-bottom:1.5rem;
97
+ }
98
+ .tab-nav{
99
+ display:flex;
100
+ align-items:center;
101
+ justify-content:center;
102
+ padding:0 16px;
103
+ }
104
+ .tab-links{
105
+ display:flex;
106
+ gap:.25rem;
107
+ flex-wrap:wrap;
108
+ justify-content:center;
109
+ }
110
+ .tab-links a{
111
+ min-width:150px;
112
+ text-align:center;
113
+ padding:.6rem 0;
114
+ text-transform:uppercase;
115
+ font-weight:600;
116
+ font-size:.85rem;
117
+ letter-spacing:.05em;
118
+ color:#1f2a44;
119
+ text-decoration:none;
120
+ border:1px solid #cfd5e2;
121
+ border-bottom:3px solid transparent;
122
+ border-top-left-radius:6px;
123
+ border-top-right-radius:6px;
124
+ background:linear-gradient(180deg,#fcfcff 0%,#eef1f6 100%);
125
+ box-shadow:0 1px 1px rgba(14,23,39,.08);
126
+ transition:all .2s ease;
127
+ }
128
+ .tab-links a:hover{
129
+ color:#b00000;
130
+ border-bottom-color:#b00000;
131
+ }
132
+ .tab-links a.is-active{
133
+ color:#960000;
134
+ background:#fff;
135
+ border-color:#b00000;
136
+ border-bottom-color:#fff;
137
+ box-shadow:0 4px 10px rgba(13,25,44,.12);
138
+ }
139
+
140
+ main{
141
+ padding:2rem 0;
142
+ flex:1;
143
+ }
144
+ .page{
145
+ background:var(--surface);
146
+ border:1px solid var(--border);
147
+ border-top:4px solid #f4c542;
148
+ box-shadow:0 8px 20px rgba(12,19,31,.12);
149
+ margin-top:-20px;
150
+ padding:2.25rem 1.5rem;
151
+ }
152
+ .page-heading{
153
+ display:flex;
154
+ align-items:center;
155
+ justify-content:space-between;
156
+ gap:1rem;
157
+ margin-bottom:1.5rem;
158
+ flex-wrap:wrap;
159
+ }
160
+ .page-heading h1{margin:0;color:#a10f0f}
161
+ .term-chip{
162
+ border:1px solid #d2d7e5;
163
+ background:#f4f5fb;
164
+ padding:.35rem .85rem;
165
+ border-radius:999px;
166
+ font-size:.85rem;
167
+ font-weight:600;
168
+ color:#1c2750;
169
+ text-transform:uppercase;
170
+ letter-spacing:.04em;
171
+ }
172
+ .term-footer{
173
+ background:#e0e4f0;
174
+ border-top:1px solid #c3c8d5;
175
+ margin-top:auto;
176
+ }
177
+ .term-footer-inner{
178
+ display:flex;
179
+ justify-content:center;
180
+ gap:.6rem;
181
+ font-size:.85rem;
182
+ padding:.65rem 16px;
183
+ color:#1f2a44;
184
+ flex-wrap:wrap;
185
+ }
186
+ .term-footer-inner a{
187
+ color:#940b0b;
188
+ font-weight:600;
189
+ text-decoration:none;
190
+ }
191
+ .term-footer-inner a:hover{text-decoration:underline}
192
+ .page-lead{display:flex;flex-direction:column;gap:1rem;margin-bottom:2rem}
193
+ .page-title{margin:0;color:#a10f0f;font-size:1.8rem;font-weight:700}
194
+
195
+ .term-gate{
196
+ border:1px solid var(--border);
197
+ background:#fff;
198
+ padding:2rem;
199
+ box-shadow:0 8px 20px rgba(15,23,38,.08);
200
+ }
201
+ .term-gate h1{margin-top:0;color:#a10f0f}
202
+ .term-gate-form{
203
+ display:flex;
204
+ flex-direction:column;
205
+ gap:1rem;
206
+ max-width:320px;
207
+ margin-top:1rem;
208
+ }
209
+
210
+ .highlight-panel{
211
+ border:1px solid var(--border);
212
+ padding:1rem;
213
+ border-radius:4px;
214
+ display:flex;
215
+ gap:1rem;
216
+ align-items:center;
217
+ font-size:.95rem;
218
+ line-height:1.5;
219
+ }
220
+ .highlight-blue{background:#e4f1ff;border-color:#b4d4fa}
221
+ .highlight-yellow{background:#fff8d9;border-color:#f3d98c}
222
+ .highlight-link{
223
+ color:#0c3f91;
224
+ font-weight:700;
225
+ text-decoration:none;
226
+ }
227
+ .highlight-link:hover{text-decoration:underline}
228
+
229
+ .content-panel{
230
+ border:1px solid var(--border);
231
+ padding:1.5rem;
232
+ margin-bottom:2rem;
233
+ background:#fff;
234
+ }
235
+ .guidance p{margin-top:0;margin-bottom:1rem;line-height:1.6}
236
+ .guidance a{color:#0d4f9a}
237
+ .inline-callout{
238
+ background:#fff3a6;
239
+ border-left:4px solid #f5c400;
240
+ padding:.75rem 1rem;
241
+ font-weight:600;
242
+ }
243
+
244
+ .step-grid{
245
+ display:flex;
246
+ flex-direction:column;
247
+ gap:1.2rem;
248
+ margin-bottom:2rem;
249
+ }
250
+ .step-card{
251
+ border:1px solid #cfd6e4;
252
+ background:#fdfdff;
253
+ padding:1.25rem;
254
+ box-shadow:0 2px 6px rgba(15,33,55,.08);
255
+ display:flex;
256
+ flex-direction:column;
257
+ gap:.75rem;
258
+ }
259
+ .step-card header{display:flex;gap:.8rem;align-items:center}
260
+ .step-number{
261
+ background:#f4c542;
262
+ color:#381d00;
263
+ font-weight:700;
264
+ min-width:54px;
265
+ text-align:center;
266
+ border-radius:999px;
267
+ padding:.3rem 0;
268
+ }
269
+ .step-card h2{
270
+ margin:0;
271
+ font-size:1.05rem;
272
+ color:#092352;
273
+ }
274
+ .step-card p{margin:0;line-height:1.45;font-size:.92rem;color:#2f3847}
275
+ .step-link{
276
+ margin-top:auto;
277
+ font-weight:600;
278
+ color:var(--accent);
279
+ text-decoration:none;
280
+ }
281
+ .step-link:hover{text-decoration:underline}
282
+
283
+ .support h3{margin-top:0;color:#0c2340}
284
+
285
+ .notice{background:var(--notice-bg);border:1px solid var(--border);padding:.6rem .8rem;border-radius:4px}
286
+ .muted{color:var(--muted)}
287
+
288
+ /* Forms */
289
+ label{font-size:.9rem;color:var(--muted);display:flex;flex-direction:column;gap:.25rem}
290
+ input[type=text], select{
291
+ border:1px solid var(--border);
292
+ padding:.45rem .5rem;border-radius:6px;background:var(--surface);
293
+ }
294
+ .form-grid{display:flex;gap:.6rem;flex-wrap:wrap;align-items:end;margin:.75rem 0 1rem}
295
+
296
+ .btn{border:1px solid var(--border);border-radius:6px;padding:.45rem .7rem;cursor:pointer;background:#fff}
297
+ .btn:hover{filter:brightness(0.98)}
298
+ .btn:disabled{opacity:.5;cursor:not-allowed}
299
+ .btn-primary{background:var(--brand);border-color:var(--brand);color:#fff}
300
+ .btn-danger{background:#a61b1b;border-color:#a61b1b;color:#fff}
301
+
302
+ /* Tables */
303
+ table{border-collapse:collapse;width:100%;background:var(--surface);border:1px solid var(--border)}
304
+ thead th{background:#f1f3f5;color:#111;text-align:left}
305
+ th,td{padding:.55rem .6rem;border-bottom:1px solid var(--border)}
306
+ tbody tr:nth-child(even){background:#fafbfc}
307
+ tbody tr:hover{background:#f5f7fb}
308
+
309
+ .seat-pill{display:inline-block;padding:.15rem .5rem;border-radius:999px;font-size:.78rem;border:1px solid var(--border)}
310
+ .seat-pill.open{background:#e8fff1;border-color:#a7e4bf;color:#186a3b}
311
+ .seat-pill.full{background:#ffe8e8;border-color:#f1b3b3;color:#7d1b1b}
312
+
313
+ /* Weekly schedule */
314
+ .week-grid{
315
+ display:grid;
316
+ grid-template-columns:80px 1fr;
317
+ gap:12px;
318
+ width:100%;
319
+ }
320
+ /* Left time rail: 10 rows (08:00–18:00) each 80px tall */
321
+ .times{display:grid;grid-template-rows:repeat(10,80px);padding-top:48px}
322
+ .time{font-size:.85rem;color:var(--muted);display:flex;align-items:center;justify-content:center}
323
+ .days{display:grid;grid-template-rows:48px 800px}
324
+ .days>.hdr{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;align-items:center}
325
+ .days>.hdr>div{font-weight:600;color:#111;display:flex;align-items:center;justify-content:center;height:48px}
326
+ .grid-body{position:relative;border:1px solid var(--border);background:var(--surface);border-radius:8px;overflow:hidden}
327
+ .grid-body{display:grid;grid-template-columns:repeat(5,1fr)}
328
+ .grid-body>.col{position:relative}
329
+ .grid-body>.col:not(:last-child){border-right:1px solid #e7edf5}
330
+ .grid-body::before{content:"";position:absolute;left:0;right:0;top:0;bottom:0;background:linear-gradient(to bottom, transparent 95%, #eef2f7 96%);background-size:100% 80px;pointer-events:none}
331
+ .grid-body{height:800px}
332
+ .event{position:absolute;left:0;right:0;margin:0 8px;border-radius:8px;padding:.3rem .4rem;background:#eef5ff;border:1px solid #cfe0ff;box-shadow:0 1px 0 rgba(0,0,0,.04)}
333
+ .event-title{font-size:.85rem;font-weight:600;line-height:1.1}
334
+ .event-sub{font-size:.78rem;color:var(--muted);line-height:1.1}
335
+ .event-time{font-size:.78rem;line-height:1.1}
336
+ .event-title,.event-sub,.event-time{overflow-wrap:anywhere}
337
+
338
+ /* Responsive adjustments */
339
+ @media (max-width: 1100px){
340
+ .container{
341
+ width:100%;
342
+ max-width:1100px;
343
+ padding:0 14px;
344
+ }
345
+ .hero-figure{flex:0 0 120px;height:70px}
346
+ }
347
+ @media (max-width: 900px){
348
+ .hero-inner{flex-direction:column;align-items:flex-start;text-align:left}
349
+ .hero-brand{flex-wrap:wrap}
350
+ .hero-figure{display:none}
351
+ .tab-nav{justify-content:flex-start}
352
+ .tab-links{
353
+ flex-wrap:nowrap;
354
+ overflow-x:auto;
355
+ justify-content:flex-start;
356
+ padding-bottom:.5rem;
357
+ }
358
+ .tab-links a{min-width:140px;font-size:.78rem}
359
+ .side-panel{flex-direction:row;flex-wrap:wrap;max-width:none}
360
+ .callout{flex:1 1 240px}
361
+ .highlight-panel{flex:1 1 260px}
362
+ }
363
+ @media (max-width: 700px){
364
+ .container{padding:0 12px}
365
+ .hero-wordmark-main{font-size:1.4rem}
366
+ .hero-wordmark-sub{font-size:.7rem}
367
+ .hero-logo{font-size:1rem}
368
+ .form-grid{flex-direction:column;align-items:stretch}
369
+ table{display:block;overflow-x:auto}
370
+ thead,tbody,tr,th,td{white-space:nowrap}
371
+ .step-card{padding:1rem}
372
+ .callout{flex-direction:column;align-items:flex-start}
373
+ .term-footer-inner{flex-direction:column;gap:.4rem;text-align:center}
374
+ }
375
+ @media (max-width: 520px){
376
+ .hero-brand{align-items:flex-start}
377
+ .tab-links a{min-width:120px;padding:.5rem .3rem}
378
+ .week-grid{grid-template-columns:1fr}
379
+ .times{display:none}
380
+ .grid-body{grid-template-columns:repeat(5, minmax(120px,1fr));overflow-x:auto}
381
+ .grid-body>.col:not(:last-child){border-right:1px solid #e7edf5}
382
+ }
templates/accounts.html ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% block content %}
3
+ <div class="page-heading">
4
+ <h1>Tuition Fee and Legal Status</h1>
5
+ {% if term_confirmed %}
6
+ <span class="term-chip">Term: {{ current_term }}</span>
7
+ {% endif %}
8
+ </div>
9
+ <section class="content-panel">
10
+ <p>
11
+ This simplified Minerva environment does not include full billing services, but you can use this page to confirm
12
+ that the correct term is active before reviewing statements in other systems.
13
+ </p>
14
+ <p class="muted">
15
+ Select Step 6 from the registration menu to return here after adjusting your term.
16
+ </p>
17
+ </section>
18
+ {% endblock %}
templates/add_drop.html ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% block content %}
3
+ <div class="page-heading">
4
+ <h1>Quick Add or Drop Course Sections</h1>
5
+ {% if term_confirmed %}
6
+ <span class="term-chip">Term: {{ current_term }}</span>
7
+ {% endif %}
8
+ </div>
9
+
10
+ <form method="POST" action="/add" class="form-grid" style="margin-bottom:1rem">
11
+ <label>CRN
12
+ <input data-test="add-crn-input" type="text" name="crn" placeholder="e.g., 12345" />
13
+ </label>
14
+ <button class="btn btn-primary" data-test="add-submit" type="submit">Add</button>
15
+ </form>
16
+
17
+ <h2>Registered Sections</h2>
18
+ <table>
19
+ <thead>
20
+ <tr>
21
+ <th>CRN</th><th>Subject</th><th>Number</th><th>Title</th><th>Schedule</th><th>Campus</th><th></th>
22
+ </tr>
23
+ </thead>
24
+ <tbody>
25
+ {% for c in regs %}
26
+ <tr data-test="schedule-row">
27
+ <td>{{ c['crn'] }}</td>
28
+ <td>{{ c['subject'] }}</td>
29
+ <td>{{ c['number'] }}</td>
30
+ <td>{{ c['title'] }}</td>
31
+ <td>{{ c['days'] }} {{ c['time'] }}</td>
32
+ <td>{{ c['campus'] }}</td>
33
+ <td>
34
+ <form method="POST" action="/drop">
35
+ <input type="hidden" name="crn" value="{{ c['crn'] }}" />
36
+ <button class="btn btn-danger" data-test="drop-button" type="submit">Drop</button>
37
+ </form>
38
+ </td>
39
+ </tr>
40
+ {% else %}
41
+ <tr><td colspan="7">No registered sections.</td></tr>
42
+ {% endfor %}
43
+ </tbody>
44
+ </table>
45
+ {% endblock %}
templates/base.html ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>{{ title or 'Minerva - Registration' }}</title>
7
+ <link rel="stylesheet" href="/static/styles.css" />
8
+ </head>
9
+ <body class="minerva-skin">
10
+ <header class="minerva-header">
11
+ <div class="hero-strip">
12
+ <div class="container hero-inner">
13
+ <div class="hero-brand" data-test="logo">
14
+ <div class="hero-wordmark">
15
+ <div class="hero-wordmark-main">Minerva</div>
16
+ <div class="hero-wordmark-sub">
17
+ Registration &amp; Student Records
18
+ </div>
19
+ </div>
20
+ </div>
21
+ <div class="hero-figure" aria-hidden="true"></div>
22
+ </div>
23
+ </div>
24
+ {% set current_path = request.path %} {% set pending_tab =
25
+ request.args.get('next') if request.endpoint == 'select_term' else '' %}
26
+ {% set change_term_next = pending_tab or current_step_key or 'search' %}
27
+ <div class="tab-row">
28
+ <div class="container tab-nav">
29
+ <nav class="tab-links">
30
+ <a
31
+ href="/"
32
+ data-test="menu-home"
33
+ class="{% if current_path == '/' %}is-active{% endif %}"
34
+ >Registration Menu</a
35
+ >
36
+ <a
37
+ href="{% if term_confirmed %}/search{% else %}/select-term?next=search{% endif %}"
38
+ data-test="menu-search"
39
+ class="{% if current_path.startswith('/search') or pending_tab=='search' %}is-active{% endif %}"
40
+ >Search Class</a
41
+ >
42
+ <a
43
+ href="{% if term_confirmed %}/add-drop{% else %}/select-term?next=add_drop{% endif %}"
44
+ data-test="menu-adddrop"
45
+ class="{% if current_path.startswith('/add-drop') or pending_tab=='add_drop' %}is-active{% endif %}"
46
+ >Add/Drop</a
47
+ >
48
+ <a
49
+ href="{% if term_confirmed %}/schedule{% else %}/select-term?next=schedule{% endif %}"
50
+ data-test="menu-schedule"
51
+ class="{% if (current_path.startswith('/schedule') and not current_path.startswith('/schedule-week')) or pending_tab=='schedule' %}is-active{% endif %}"
52
+ >Student Schedule</a
53
+ >
54
+ <a
55
+ href="{% if term_confirmed %}/schedule-week{% else %}/select-term?next=schedule_week{% endif %}"
56
+ data-test="menu-schedule-week"
57
+ class="{% if current_path.startswith('/schedule-week') or pending_tab=='schedule_week' %}is-active{% endif %}"
58
+ >Weekly View</a
59
+ >
60
+ </nav>
61
+ </div>
62
+ </div>
63
+ </header>
64
+ <main class="page container">
65
+ {% if msg %}
66
+ <p class="notice" data-test="notice">{{ msg }}</p>
67
+ {% endif %} {% block content %}{% endblock %}
68
+ </main>
69
+ <footer class="term-footer">
70
+ <div class="container term-footer-inner">
71
+ <span>Need to work in another term?</span>
72
+ <a
73
+ href="/select-term?reset=1&amp;next={{ change_term_next }}"
74
+ data-test="change-term-link"
75
+ >Change Term</a
76
+ >
77
+
78
+ </div>
79
+ </footer>
80
+ <!-- status link removed from UI; endpoint remains for health checks -->
81
+ </body>
82
+ </html>
templates/registration_menu.html ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% block content %}
3
+ <section class="page-lead">
4
+ <h1 class="page-title">Registration Menu</h1>
5
+ </section>
6
+
7
+ <section class="step-grid">
8
+ <article class="step-card">
9
+ <header>
10
+ <div class="step-number">Step 1</div>
11
+ <h2>Check Your Registration Eligibility and Verify Your Curriculum</h2>
12
+ </header>
13
+ <p>
14
+ Confirm that you have resolved all holds, outstanding transcripts, or advising requirements that could prevent
15
+ registration. Review your primary faculty, program, and concentration selections before continuing.
16
+ </p>
17
+ <a class="step-link" href="#" aria-label="Student Records Menu">Go to Student Records Menu</a>
18
+ </article>
19
+
20
+ <article class="step-card">
21
+ <header>
22
+ <div class="step-number">Step 2</div>
23
+ <h2>Search Class and Add Course Sections</h2>
24
+ </header>
25
+ <p>
26
+ Choose the schedule term, search by subject or CRN, and add courses to your worksheet. Visualize potential
27
+ conflicts and note seats that may be available on the waitlist (space permitting).
28
+ </p>
29
+ <a class="step-link" href="{% if term_confirmed %}/search{% else %}/select-term?next=search{% endif %}" data-test="menu-step-search">Launch Search Class</a>
30
+ </article>
31
+
32
+ <article class="step-card">
33
+ <header>
34
+ <div class="step-number">Step 3</div>
35
+ <h2>Quick Add or Drop Course Sections</h2>
36
+ </header>
37
+ <p>
38
+ Have a CRN ready? Use Quick Add to enter it directly, or drop from the list of registered sections. Select the
39
+ satisfactory/unsatisfactory grade mode when eligible.
40
+ </p>
41
+ <a class="step-link" href="{% if term_confirmed %}/add-drop{% else %}/select-term?next=add_drop{% endif %}" data-test="menu-step-adddrop">Open Add/Drop by CRN</a>
42
+ </article>
43
+
44
+ <article class="step-card">
45
+ <header>
46
+ <div class="step-number">Step 4</div>
47
+ <h2>View Student Schedule by Course Section</h2>
48
+ </header>
49
+ <p>
50
+ Display locations, meeting dates, instructors, and any notes attached to your registered sections as well as
51
+ waitlisted courses in one consolidated view.
52
+ </p>
53
+ <a class="step-link" href="{% if term_confirmed %}/schedule{% else %}/select-term?next=schedule{% endif %}" data-test="menu-step-schedule">View Student Schedule</a>
54
+ </article>
55
+
56
+ <article class="step-card">
57
+ <header>
58
+ <div class="step-number">Step 5</div>
59
+ <h2>View Personal Weekly Class Schedule</h2>
60
+ </header>
61
+ <p>
62
+ Review your classes in a weekly calendar grid. Print the schedule or save as PDF to confirm room locations on the
63
+ first day of class.
64
+ </p>
65
+ <a class="step-link" href="{% if term_confirmed %}/schedule-week{% else %}/select-term?next=schedule_week{% endif %}" data-test="menu-step-weekly">Weekly Schedule View</a>
66
+ </article>
67
+
68
+ <article class="step-card">
69
+ <header>
70
+ <div class="step-number">Step 6</div>
71
+ <h2>View Tuition Fee and Legal Status</h2>
72
+ </header>
73
+ <p>
74
+ Verify your status, outstanding balances, and legal statements for the term to avoid late fees or enrollment
75
+ interruptions.
76
+ </p>
77
+ <a class="step-link" href="{% if term_confirmed %}/accounts{% else %}/select-term?next=accounts{% endif %}">Go to Student Accounts</a>
78
+ </article>
79
+ </section>
80
+
81
+ {% endblock %}
templates/schedule.html ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% block content %}
3
+ <div class="page-heading">
4
+ <h1>Student Schedule</h1>
5
+ {% if term_confirmed %}
6
+ <span class="term-chip">Term: {{ current_term }}</span>
7
+ {% endif %}
8
+ </div>
9
+
10
+ <table>
11
+ <thead>
12
+ <tr>
13
+ <th>CRN</th><th>Subject</th><th>Number</th><th>Title</th><th>Schedule</th><th>Campus</th><th></th>
14
+ </tr>
15
+ </thead>
16
+ <tbody>
17
+ {% for c in regs %}
18
+ <tr data-test="schedule-row">
19
+ <td>{{ c['crn'] }}</td>
20
+ <td>{{ c['subject'] }}</td>
21
+ <td>{{ c['number'] }}</td>
22
+ <td>{{ c['title'] }}</td>
23
+ <td>{{ c['days'] }} {{ c['time'] }}</td>
24
+ <td>{{ c['campus'] }}</td>
25
+ <td>
26
+ <form method="POST" action="/drop">
27
+ <input type="hidden" name="crn" value="{{ c['crn'] }}" />
28
+ <button class="btn btn-danger" data-test="drop-button" type="submit">Drop</button>
29
+ </form>
30
+ </td>
31
+ </tr>
32
+ {% else %}
33
+ <tr><td colspan="7">No registered sections.</td></tr>
34
+ {% endfor %}
35
+ </tbody>
36
+ </table>
37
+ {% endblock %}
templates/schedule_week.html ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% block content %}
3
+ <div class="page-heading">
4
+ <h1>Weekly Schedule</h1>
5
+ {% if term_confirmed %}
6
+ <span class="term-chip">Term: {{ current_term }}</span>
7
+ {% endif %}
8
+ </div>
9
+
10
+ <div class="week-grid">
11
+ <div class="times">
12
+ {% for t in time_labels %}
13
+ <div class="time">{{ t }}</div>
14
+ {% endfor %}
15
+ </div>
16
+ <div class="days">
17
+ <div class="hdr">
18
+ <div>Mon</div>
19
+ <div>Tue</div>
20
+ <div>Wed</div>
21
+ <div>Thu</div>
22
+ <div>Fri</div>
23
+ </div>
24
+ <div class="grid-body">
25
+ {% for col in range(1,6) %}
26
+ <div class="col">
27
+ {% for e in events %}
28
+ {% if e.day_col == col %}
29
+ <div class="event" style="top: {{ e.top_px }}px; height: {{ e.height_px }}px" data-test="week-event">
30
+ <div class="event-title">{{ e.title }}</div>
31
+ <div class="event-sub">{{ e.subtitle }}</div>
32
+ <div class="event-time">{{ e.time }}</div>
33
+ </div>
34
+ {% endif %}
35
+ {% endfor %}
36
+ </div>
37
+ {% endfor %}
38
+ </div>
39
+ </div>
40
+ </div>
41
+
42
+ <p class="muted" style="margin-top:.5rem">Grid spans 08:00–18:00. Events are approximate visualizations.</p>
43
+ {% endblock %}
templates/search.html ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% block content %}
3
+ <div class="page-heading">
4
+ <h1>Search Class</h1>
5
+ {% if term_confirmed %}
6
+ <span class="term-chip">Term: {{ current_term }}</span>
7
+ {% endif %}
8
+ </div>
9
+
10
+ <form method="GET" action="/search" class="form-grid">
11
+ <label>Subject
12
+ <input data-test="search-subject" type="text" name="subject" value="{{ request.args.get('subject','') }}" placeholder="e.g., COMP" />
13
+ </label>
14
+ <label>Number
15
+ <input data-test="search-number" type="text" name="number" value="{{ request.args.get('number','') }}" placeholder="e.g., 250" />
16
+ </label>
17
+ <label>Title contains
18
+ <input data-test="search-title" type="text" name="title" value="{{ request.args.get('title','') }}" placeholder="e.g., data" />
19
+ </label>
20
+ <label>Campus
21
+ <input data-test="search-campus" type="text" name="campus" value="{{ request.args.get('campus','') }}" placeholder="e.g., Downtown" />
22
+ </label>
23
+ <button class="btn btn-primary" data-test="search-button" type="submit">Search</button>
24
+ </form>
25
+
26
+ <table>
27
+ <thead>
28
+ <tr>
29
+ <th>CRN</th>
30
+ <th>Subject</th>
31
+ <th>Number</th>
32
+ <th>Title</th>
33
+ <th>Days/Time</th>
34
+ <th>Campus</th>
35
+ <th>Seats</th>
36
+ <th></th>
37
+ </tr>
38
+ </thead>
39
+ <tbody>
40
+ {% for c in results %}
41
+ <tr data-test="course-row">
42
+ <td data-test="course-crn">{{ c['crn'] }}</td>
43
+ <td>{{ c['subject'] }}</td>
44
+ <td>{{ c['number'] }}</td>
45
+ <td>{{ c['title'] }}</td>
46
+ <td>{{ c['days'] }} {{ c['time'] }}</td>
47
+ <td>{{ c['campus'] }}</td>
48
+ <td>
49
+ <span class="seat-pill {{ 'full' if c['seats_taken']>=c['seats_total'] else 'open' }}">
50
+ {{ c['seats_taken'] }}/{{ c['seats_total'] }} {{ 'Full' if c['seats_taken']>=c['seats_total'] else 'Open' }}
51
+ </span>
52
+ </td>
53
+ <td>
54
+ <form method="POST" action="/add">
55
+ <input type="hidden" name="crn" value="{{ c['crn'] }}" />
56
+ <button class="btn btn-primary" data-test="add-button" type="submit" {% if c['seats_taken']>=c['seats_total'] %}disabled{% endif %}>Add</button>
57
+ </form>
58
+ </td>
59
+ </tr>
60
+ {% else %}
61
+ <tr><td colspan="8">No sections match your search.</td></tr>
62
+ {% endfor %}
63
+ </tbody>
64
+ </table>
65
+ {% endblock %}
templates/select_term.html ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% block content %}
3
+ <section class="term-gate">
4
+ <h1>Select Term</h1>
5
+ <p>
6
+ Before continuing to <strong>{{ destination_label }}</strong>, confirm the McGill term you wish to work with.
7
+ </p>
8
+ <form method="POST" action="/set-term" class="term-gate-form">
9
+ <input type="hidden" name="next" value="{{ next_key }}" />
10
+ <label>
11
+ Select a Term:
12
+ <select name="term" required data-test="term-select">
13
+ {% for t in terms %}
14
+ <option value="{{ t }}" {% if t==current_term %}selected{% endif %}>{{ t }}</option>
15
+ {% endfor %}
16
+ </select>
17
+ </label>
18
+ <button class="btn btn-primary" type="submit" data-test="term-submit">Continue</button>
19
+ </form>
20
+ </section>
21
+ {% endblock %}