diff --git a/core/db.py b/core/db.py index c0829f8..9434b6a 100644 --- a/core/db.py +++ b/core/db.py @@ -697,6 +697,34 @@ def get_unit_code(user_id: int, project_id: int) -> str | None: return row[0] if row else None +def get_active_project_ids(user_id: int) -> set[int]: + """Project IDs currently stored for the user. The projects ingest only keeps + non-ended units (and prune_ended_projects drops the rest), so this is the set + of *active* projects — used to reject task pushes for past-trimester projects + that an older extension build would otherwise still sweep.""" + with _connection() as conn: + cur = conn.cursor() + cur.execute(f"SELECT project_id FROM projects WHERE user_id = {_P}", (user_id,)) + return {row[0] for row in cur.fetchall()} + + +def delete_tasks_for_inactive_projects(user_id: int) -> int: + """Delete task rows whose project is no longer in the user's active projects + (e.g. past-trimester tasks left behind after prune_ended_projects). Returns the + number of rows removed.""" + with _connection() as conn: + cur = conn.cursor() + cur.execute( + f"""DELETE FROM tasks + WHERE user_id = {_P} + AND project_id NOT IN ( + SELECT project_id FROM projects WHERE user_id = {_P} + )""", + (user_id, user_id), + ) + return cur.rowcount + + def get_pending_tasks(user_id: int, today_iso: str, end_iso: str) -> list[dict]: """Tasks due in the inclusive window [today, end], ordered by deadline. Status filtering (HIDE_SET) is applied by the brief layer, not here — this just bounds diff --git a/core/ontrack/normalize.py b/core/ontrack/normalize.py index 2d6f273..1f684df 100644 --- a/core/ontrack/normalize.py +++ b/core/ontrack/normalize.py @@ -6,8 +6,6 @@ from __future__ import annotations -from datetime import date - _GRADE_LABELS = { 0: "P (Pass)", 1: "C (Credit)", @@ -29,20 +27,25 @@ def enrich_tasks(tasks: list[dict], task_defs: list[dict]) -> None: ) t["target_grade_label"] = _GRADE_LABELS.get(t.get("target_grade"), "P (Pass)") t["due_date"] = t.get("due_date") or td.get("target_date") or td.get("due_date") - t["deadline"] = t.get("deadline") or td.get("due_date") + # Rank/window by the target_date the student actually sees in OnTrack, not + # the hard due_date (often the late-penalty cutoff / end of trimester). Fall + # back to due_date when a definition has no target. + t["deadline"] = t.get("deadline") or td.get("target_date") or td.get("due_date") t["status_label"] = t.get("status_label") or t.get("status", "").replace("_", " ").title() def append_missing_tasks(tasks: list[dict], task_defs: list[dict]) -> None: - """Synthesise not-yet-started task rows for any released definition the - student hasn't engaged with yet. Mutates ``tasks`` in place.""" + """Synthesise not-yet-started task rows for any definition the student hasn't + engaged with yet. Mutates ``tasks`` in place. + + Includes definitions whose ``start_date`` is still in the future: this is a + plan-ahead brief, so an upcoming task should surface as soon as it has a + target date. The deadline window (in the snapshot/brief) is what bounds which + of these actually display, so synthesising them here never over-shows.""" submitted_def_ids = {t["task_definition_id"] for t in tasks} - today = date.today().isoformat() for td in task_defs: if td["id"] in submitted_def_ids: continue - if td.get("start_date", "0000") > today: - continue tasks.append( { "id": None, @@ -54,7 +57,7 @@ def append_missing_tasks(tasks: list[dict], task_defs: list[dict]) -> None: "target_grade": td.get("target_grade"), "target_grade_label": _GRADE_LABELS.get(td.get("target_grade"), "P (Pass)"), "due_date": td.get("target_date") or td.get("due_date"), - "deadline": td.get("due_date"), + "deadline": td.get("target_date") or td.get("due_date"), "submission_date": None, "completion_date": None, "extensions": 0, diff --git a/extension/public/background.js b/extension/public/background.js index 9244655..45af830 100644 --- a/extension/public/background.js +++ b/extension/public/background.js @@ -67,11 +67,43 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { return true; // keep message channel open for async response }); +// Dedup identical captures: OnTrack's SPA re-fetches the same project/unit data +// on every navigation, so without this the extension re-POSTs unchanged payloads +// and a single browsing session can trip the server's /ingest rate limit. Keyed +// by kind + the captured identifier; value is a hash of the payload so a real +// data change still pushes. Lives in the (ephemeral) service-worker scope — a +// worker restart just allows one harmless re-send. +const lastIngestHash = new Map(); + +function ingestDedupKey(kind, payload) { + const p = payload || {}; + if (kind === "project_tasks") return `project_tasks:${p.project_id}`; + if (kind === "feedback") return `feedback:${p.project_id}:${p.task_def_id}`; + return kind; // "projects" — one per session +} + +// Small, fast, collision-tolerant string hash (djb2). Exact equality isn't +// required — a missed dedup only costs one extra POST. +function hashPayload(payload) { + const s = JSON.stringify(payload); + let h = 5381; + for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0; + return `${s.length}:${h}`; +} + // Forward captured OnTrack data to /ingest. Separate listener so the token path // above stays untouched; both run on every message and ignore kinds not theirs. chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { if (msg.type !== "ingest") return false; + const dedupKey = ingestDedupKey(msg.kind, msg.payload); + const hash = hashPayload(msg.payload); + if (lastIngestHash.get(dedupKey) === hash) { + sendResponse({ ok: true, deduped: true }); + return true; + } + lastIngestHash.set(dedupKey, hash); + fetch(`${APP_URL}/ingest`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -83,7 +115,12 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { }) .then((r) => r.json()) .then(() => sendResponse({ ok: true })) - .catch(() => sendResponse({ ok: false })); + .catch(() => { + // Push failed — drop the cached hash so the next identical capture retries + // instead of being silently deduped away. + lastIngestHash.delete(dedupKey); + sendResponse({ ok: false }); + }); return true; // async response }); diff --git a/extension/public/injected.js b/extension/public/injected.js index c3e45d6..9e32cb4 100644 --- a/extension/public/injected.js +++ b/extension/public/injected.js @@ -80,6 +80,7 @@ function sweepProjectTasks(projects) { if (_swept || !lastToken || !lastUsername) return; _swept = true; + const today = new Date().toISOString().slice(0, 10); const headers = { "Auth-Token": lastToken, "Username": lastUsername, @@ -87,6 +88,13 @@ }; projects.forEach(function (proj) { if (!proj.id) return; + // /api/projects returns every project the student has ever enrolled in, + // across all trimesters. Sweeping them all floods /ingest with stale tasks + // (tripping the server rate limit) and crowds out the current trimester's + // data. Skip ended units — same filter the server applies when storing the + // projects list — so the sweep only fetches active units. + const endDate = proj.unit && proj.unit.end_date; + if (endDate && endDate < today) return; const url = `${location.origin}/api/projects/${proj.id}`; // Use window.fetch (our overridden version) so the response flows through // handleData automatically — no manual wiring needed. diff --git a/extension/src/components/TaskList.jsx b/extension/src/components/TaskList.jsx index 0809578..ab13f99 100644 --- a/extension/src/components/TaskList.jsx +++ b/extension/src/components/TaskList.jsx @@ -51,13 +51,14 @@ export default function TaskList({ days }) { .sort((a, b) => a.offset - b.offset) const count = allTasks.length + const windowDays = (days || []).length || 7 return (