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 (
{count === 0 ? ( -
Nothing due in the next 7 days
+
Nothing due in the next {windowDays} days
) : ( allTasks.map((t, i) => { const cfg = GRADE_CONFIG[t.grade] || FALLBACK_CFG diff --git a/extension/src/hooks/useSnapshot.js b/extension/src/hooks/useSnapshot.js index ce47547..365eaeb 100644 --- a/extension/src/hooks/useSnapshot.js +++ b/extension/src/hooks/useSnapshot.js @@ -51,16 +51,21 @@ export function useSnapshot({ isLoaded, isSignedIn, getToken }) { const cached = (await getLocal([SNAPSHOT_KEY]))[SNAPSHOT_KEY] const hasCache = !!cached?.data const fresh = hasCache && Date.now() - cached.ts < SNAPSHOT_TTL_MS + // The cache isn't keyed by window size, so a fresh 7-day snapshot must not + // satisfy a 14-day request — otherwise switching to the 2-week strip paints + // the stale 7-day data and silently drops days 8–14 (and their tasks). + const coversWindow = hasCache && (cached.data.days?.length || 0) >= numDays if (hasCache) { // Stale-while-revalidate: paint cached data instantly (any age), no - // blocking spinner. Skip the network entirely while still fresh. + // blocking spinner. Skip the network entirely while still fresh AND the + // cached window is at least as wide as the one we need. setDays(cached.data.days) setFeedback(cached.data.feedback || []) if (typeof cached.data.subscribed === 'boolean') setSubscribed(cached.data.subscribed) setStripLoading(false) setFooterSync(syncLabel(cached.ts)) - if (!force && fresh) return + if (!force && fresh && coversWindow) return setFooterSync('Refreshing…') // background revalidate cue } else { // First load on this device — nothing to show yet. diff --git a/routes/main.py b/routes/main.py index 716322f..603ef13 100644 --- a/routes/main.py +++ b/routes/main.py @@ -5,10 +5,13 @@ import requests from apscheduler.triggers.date import DateTrigger from flask import Blueprint, g, jsonify, request +from flask_limiter.util import get_remote_address from core.brief.builder import is_hidden from core.clerk_auth import require_clerk_auth from core.db import ( + delete_tasks_for_inactive_projects, + get_active_project_ids, get_capture_meta, get_feedback_entries, get_pending_tasks, @@ -196,8 +199,23 @@ def refresh_credential(): return {"ok": True} +def _ingest_rate_key(): + """Per-student rate-limit bucket for /ingest. + + The endpoint is unauthenticated and, in Docker and behind Railway's proxy, + every session arrives from one shared IP (the gateway / proxy). Keying the + limit on IP (the limiter default) therefore lets one busy session — whose + project sweep fans out into many small pushes — exhaust the limit for every + other student. Key on the body-supplied username so each student gets their + own bucket, falling back to IP when the body has no username (malformed + request, which the handler rejects anyway).""" + data = request.get_json(silent=True) or {} + username = (data.get("username") or "").strip() + return f"ingest:{username}" if username else get_remote_address() + + @main_bp.route("/ingest", methods=["POST"]) -@limiter.limit("120 per minute") +@limiter.limit("120 per minute", key_func=_ingest_rate_key) def ingest(): """Receive OnTrack data captured off the student's own session and store it. @@ -259,12 +277,27 @@ def ingest(): projects = [p for p in projects if p["project_id"] is not None] stored = upsert_projects(user_id, projects) prune_ended_projects(user_id, today.isoformat()) + # Drop tasks left behind by now-ended projects (past trimesters) so the + # snapshot/brief never read stale rows. Pairs with the project_tasks guard + # below — together they keep the DB to the active trimester even if an old + # extension build keeps sweeping every project. + removed = delete_tasks_for_inactive_projects(user_id) + if removed: + log.info("ingest: pruned %d task(s) for ended projects (user %s)", removed, user_id) return {"ok": True, "stored": stored} if kind == "project_tasks": project_id = payload.get("project_id") if project_id is None: return {"ok": False, "error": "missing project_id"}, 400 + # Reject pushes for past-trimester projects. The projects ingest only keeps + # active (non-ended) units, so once the user has any projects stored, a + # project_id absent from that set is an ended unit an older extension build + # is still sweeping — store nothing. The `active_ids` empty case allows the + # race where project_tasks arrives before the projects list has landed. + active_ids = get_active_project_ids(user_id) + if active_ids and project_id not in active_ids: + return {"ok": True, "stored": 0, "skipped": "inactive_project"} # Guard: enrich_tasks and append_missing_tasks both use # t["task_definition_id"] (hard key access). Drop any task dicts the # extension sent without that field before passing them in.