From d1ca8124e1707c813424098dc88080f1b035d58c Mon Sep 17 00:00:00 2001 From: Wjouter <34310151+Wjouter@users.noreply.github.com> Date: Wed, 11 Nov 2026 23:48:19 +0100 Subject: [PATCH 1/5] docs(010): spec and plan for event change history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec (with clarifications and design-review decisions), plan, research R1–R12, data model, API and page contracts, and quickstart for issue #23: found time in the feed, a history card over the feed and a full history page per lesson, with the filter toggle, the 20-entry card cap and the calendar-style current details. Co-Authored-By: Claude Opus 5.5 --- .../checklists/requirements.md | 38 ++ .../010-event-change-history/contracts/api.md | 108 ++++++ .../contracts/pages.md | 152 ++++++++ specs/010-event-change-history/data-model.md | 102 ++++++ specs/010-event-change-history/plan.md | 148 ++++++++ specs/010-event-change-history/quickstart.md | 117 ++++++ specs/010-event-change-history/research.md | 341 ++++++++++++++++++ specs/010-event-change-history/spec.md | 315 ++++++++++++++++ 8 files changed, 1321 insertions(+) create mode 100644 specs/010-event-change-history/checklists/requirements.md create mode 100644 specs/010-event-change-history/contracts/api.md create mode 100644 specs/010-event-change-history/contracts/pages.md create mode 100644 specs/010-event-change-history/data-model.md create mode 100644 specs/010-event-change-history/plan.md create mode 100644 specs/010-event-change-history/quickstart.md create mode 100644 specs/010-event-change-history/research.md create mode 100644 specs/010-event-change-history/spec.md diff --git a/specs/010-event-change-history/checklists/requirements.md b/specs/010-event-change-history/checklists/requirements.md new file mode 100644 index 0000000..4dec86b --- /dev/null +++ b/specs/010-event-change-history/checklists/requirements.md @@ -0,0 +1,38 @@ +# Specification Quality Checklist: Event change history + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-11-11 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- FR-002 resolved by the user: relative under 24 hours, exact date and time from 24 hours on, never "today" or + "yesterday". All items pass. +- The storage index for looking up one lesson's changes at whole-school scale is a plan concern (SC-003), not + in the spec. +- Spec 005's "change notices are not clickable" decision (research R11, R18) is reversed by FR-004. diff --git a/specs/010-event-change-history/contracts/api.md b/specs/010-event-change-history/contracts/api.md new file mode 100644 index 0000000..826c4a8 --- /dev/null +++ b/specs/010-event-change-history/contracts/api.md @@ -0,0 +1,108 @@ +# Contract: HTTP API addition + +Extends [001](../../001-ical-change-detection/contracts/api.md), [002](../../002-subscriber-change-filters/contracts/api.md) +and [005](../../005-sveltekit-frontend/contracts/api.md). Their conventions apply unchanged: + +- camelCase JSON and ISO 8601 UTC; +- Problem Details with a `reason`; +- a signed-in session on every `/api` route. + +The addition is one read-only route. Every existing route, parameter and response stays as it is. `GET +/api/me/changes` already returns `detectedAt`, which the feed now shows (FR-001), so it needs no change. + +## `GET /api/me/changes/{id}/history` + +Returns the history of the lesson that change `id` belongs to (research R2). + +**Path**: `id` is a change id (`long`). A non-numeric id does not match the route and gives the framework's plain +404. + +**Query**: none. + +### 200 + +```json +{ + "changeId": 4711, + "calendarId": 12, + "calendarName": "Class 3B", + "current": { + "state": "present", + "event": { + "key": "…", + "name": "Optische sensoren en actuatoren", + "start": "2027-01-06T08:00:00Z", + "end": "2027-01-06T09:30:00Z", + "status": "confirmed", + "fields": { "location": "B2.14", "rooms": ["B2.14"], "teachers": ["ABC"], "groups": ["3B"], "type": "lesson", + "description": "…", "details": { "Lecturer": "…" } } + }, + "removedAt": null + }, + "historyStartsAt": "2026-03-02T07:15:00Z", + "retentionDays": 365, + "items": [ + { "id": 4711, "calendarId": 12, "eventKey": "…", "kind": "changed", "field": "time", + "oldValue": { "start": "…", "end": "…" }, "newValue": { "start": "…", "end": "…" }, + "eventName": "Optische sensoren en actuatoren", "eventStartsAt": "…", "detectedAt": "2026-11-11T07:19:03Z", + "outcome": "normal" } + ] +} +``` + +| Field | Meaning | +|---|---| +| `changeId` | the requested id, echoed back so the view can mark the entry it was opened from (FR-006) | +| `calendarId` | already exposed by the feed. It is not a new identifier (FR-005d). | +| `calendarName` | `coalesce(label, feed_name, host)` for this student, the same name `/api/me/calendars` gives (FR-007) | +| `current` | the lesson's current or last known details. See [data-model.md § Lesson](../data-model.md#lesson-event) and research R5. | +| `historyStartsAt` | `max(calendar.baseline_at, now − ChangeRetention)`, or `null` when `baseline_at` is null (research R4) | +| `retentionDays` | `ChangeRetention` in whole days (research R11) | +| `items` | **every** kept row of the lesson, newest first (`id DESC`), in the same item shape as `GET /api/me/changes`, hidden rows included, each with its `outcome` under the student's current filter | + +`items` is never empty: the requested change is always one of its rows. The route has no paging, `limit` or +`includeHidden`. The frontend groups, filters and caps the list (research R2, R6, R7, R10). + +### Errors + +| Status | `reason` | When | +|---|---|---| +| `401` | (as every `/api` route) | no session | +| `404` | `not_found` | there is no change with this id (never existed, expired, or deleted with its timetable) **or** the signed-in student does not follow its timetable | + +The two 404 cases come from the same `Problem(404, "not_found")` call, so their status, headers and body are +identical (FR-016, SC-005). The response never says which case applied. + +### Behaviour + +1. `SELECT calendar_id, event_key FROM detected_change WHERE id = $1`. No row gives a 404. +2. `presets.LoadFiltersAsync(user.AccountId(), ct, calendarId)`. No entry for the calendar gives a 404. A student + with several links to one timetable is one row of the `subscription` view, so they get each row once. +3. Rows: `SELECT {ChangeView.Columns} FROM detected_change c WHERE c.calendar_id = $1 AND c.event_key = $2 ORDER BY + c.id DESC`, using the index from migration 006. Each row's `outcome` comes from `FilterEngine`. +4. `current` per research R5. `calendarName`, `baseline_at` and the retention period are read in one query on + `calendar` joined to `subscription_setting`. + +Read-only: no table is written and no security event is logged. + +### Backend tests (`HistoryTests`) + +- 404 parity: an unknown id, a deleted change, and a change in a timetable the caller does not follow return + identical status and body. After the caller removes their last link, the same change gives 404. +- Order and completeness: three checks on one lesson return all rows, newest first. Rows of another lesson in the + same calendar are not included. +- Outcomes: a `hide` rule marks the matching row `hidden` and keeps it in `items`. A `lowPriority` rule gives + `lowPriority`. +- Two links to one timetable return no duplicate rows. +- `current`: + - `present`: from the snapshot. + - `removed`: with `removedAt`, from the removed row. + - `present` again after removal and reappearance. + - `gone`: after a features-change re-baseline drops the lesson. +- `historyStartsAt`: + - null for a calendar with no `baseline_at`; + - the add-link baseline time for a new calendar; + - the re-baseline time after a features change; + - unchanged by a normal check; + - `now − retention` when the baseline is older. +- `retentionDays` follows `ChangeRetention`. diff --git a/specs/010-event-change-history/contracts/pages.md b/specs/010-event-change-history/contracts/pages.md new file mode 100644 index 0000000..bc5c0a6 --- /dev/null +++ b/specs/010-event-change-history/contracts/pages.md @@ -0,0 +1,152 @@ +# Contract: Pages, card and feed changes + +Extends [005 contracts/pages.md](../../005-sveltekit-frontend/contracts/pages.md). It replaces 005 research R11 and +R18 on one point only: change notices are now links (FR-004). + +## Addresses + +| Address | Sign-in | Shows | +|---|---|---| +| `/changes/{id}` | required (as `/changes`) | the full history page of the lesson of change `{id}` | +| `/changes/{id}?all=1` | required | the same, toggle on: all changes shown, hidden ones badged | +| `/changes/{id}?all=0` | required | the same, toggle off: filtered, even when opened from a hidden change | + +- `{id}` must match `^[1-9]\d{0,17}$`. Anything else gives the 404 page. +- `all` accepts only `0` or `1`. Any other value counts as absent, which means the default rule in research R7. +- An API 404 renders the existing `+error.svelte` with the standard "not found" text, the same for every cause + (FR-016). +- The address holds only the change id, which the feed already uses, plus `all` (FR-005d). +- When the session has expired, the page redirects to sign-in as other student pages do, with `next` set to this + address. + +## Feed changes (`/` and `/changes`) + +`ChangeGroup` changes as follows: + +- **The notice is a link**: ``, so it is focusable and opens with Enter + (FR-004). Its heading and sentences stay inside it. +- **`group.href`**: `/changes/{id}` (research R9), plus `?all=1` when the feed URL has `showHidden=1`. It is built + in `group()` on the server, so `/` and `/changes` agree. `/` has no `showHidden`, so it never adds `all`. +- **Corner time**: `` replaces + `fmt.dateTime(eventStartsAt)` (FR-001 to FR-003, research R1). +- **No change to the sentence**: it keeps the lesson's own time, for example "New event: Wed 6 Jan 2027 + 16:00–17:30". + +`Feed` handles clicks. A plain primary click opens the card. Any modifier key, middle-click or other button is left +to the browser (research R8). + +## Card (shallow route over the feed) + +- **Open**: `preloadData(href)` returns `type: 'loaded'` with `status: 200`, then + `pushState(href, { history: data })`. Any other result falls back to `goto(href)`. While `page.state.history` is set, `Feed` renders `HistoryCard`. +- **Frame**: a native modal `` with an `aria-labelledby` heading (the lesson name), an **Open as page** + link (`href` = the current card URL, a full load) and a close button (`aria-label` = the existing "Close" text). + - Wide screens: centred, at most about 720 px wide, at most 90 vh high, scrolling inside. + - Below 600 px: fills the viewport. +- **Close**: the button, Esc (`cancel`) and a backdrop click (wide only) all call `history.back()`. Popstate clears + the state and an effect closes the dialog. `onclose` steps back only while the state is still set, so it never + steps back twice. Focus returns to the opening link (FR-005a). +- **Forward**: forward reopens the card. A reload of the card URL loads the full page (FR-005b). +- **Toggle**: shallow `replaceState` with the new `all`. It makes no request, adds no history step, and does not + touch the feed's `showHidden` (FR-014a, scenario 9). +- **Cap**: the card shows at most 20 entries, plus "N older changes not shown" with a link to the page. When the + opened entry falls beyond the cap, it also shows "The change you opened is further back" (FR-009a). + +## Full page `/changes/{id}` + +- **Loading**: `+page.server.ts` loads `GET /api/me/changes/{id}/history` and builds the entries on the server + (texts, rows, found times), like `loadFeed`. The `historyView` filter and cap logic runs in the component, so the + toggle needs no reload. +- **Layout**: `PageIntro` (the lesson name, with the timetable name under it), a "Back to changes" link to + `/changes`, then `` with no cap. +- **Opened entry**: the opened entry is marked as under § Shared content › Entries (`aria-current="true"`), and + scrolled into view after load (FR-006). +- **Toggle**: same rule as the card. It uses shallow `replaceState` on the page URL. + +## Shared content `` + +From top to bottom: + +1. **Current details** (FR-007, FR-008), shaped like a calendar event: + - **Date tile**, about 52 px wide. It shows the start date: the month in a brand-coloured strip, then the day + number and the short weekday. A visually hidden full date is added for screen readers. A lesson that spans + several days shows its start date, and the time line shows the range (`fmt.range`). + - **Event block**, next to the tile: a soft-tinted block with three lines: + 1. the name; + 2. start–end time · location; + 3. teachers · groups · type. + + A part with no value is left out of its line. + - **More details**: a disclosure button, collapsed at first, with a target of at least 44 px. It has + `aria-expanded`, and a chevron that turns when open. Its label reads "More details" while collapsed and "Fewer + details" while open. It holds a + compact label/value list: status word, description, rooms when they differ from the location, every `details` + label and value, and the timetable name. A detail with no value is left out. + - **States**: + - `present`: a tinted block, a brand date strip, no badge. + - `removed`: a grey date strip, a dashed grey block, the name struck through, a "Removed" badge, and a third + line "Removed {found time}". + - `gone`: the same dimmed look, with a "No longer in the timetable" badge and no time. +2. **Changes heading row**, by research R7. There is no separate panel: the "Changes, newest first" heading has + the controls on its right, and they wrap under it at phone width (FR-021). + - A checkbox labelled "Show all" (FR-014). The checkbox is about 18 px, but the whole label is a target of at + least 44 px. + - Next to it, one short muted line, when it applies: + - "N hidden by your filters" while rows are left out (FR-015); + - "The change you opened is hidden by your filters" (FR-014b). + - When every entry is hidden: only "All changes of this lesson are hidden by your filters", with no checkbox. +3. **Entries**, newest first, on a light grey ground (`--surface-subtle`). Each entry is a card (FR-021): + - **Card**: 8 px rounded corners, a 1 px `--border-default` border, `--shadow-1`, and a 4 px bar on the leading + edge: + - blue (`--status-info`) for changed; + - green (`--status-new`) for new; + - red (`--status-cancelled`) for removed; + - grey (`--grey-400`) for low priority. + - **Hidden entry**: `--grey-100` background, a dashed `--border-strong` border, a grey bar and no shadow. + - **Opened entry** (FR-006): + - Normally: a `--brand-soft` background, a 2 px `--brand` border with a 6 px leading bar, and the label "You + opened this change" at the end of the header line. + - Removed and opened: it keeps the removed look. The background is `--grey-100`, the name is greyed and struck + through, and the bar is red. Only the brand-coloured label marks it as opened. + - Inside each card: + - A kind badge (New / Removed / Changed, using the existing tone colours) with the found time, relative and + exact both visible (research R1). + - The lesson name at that moment. + - Low-priority and hidden badges per row, as in the feed. With the toggle off, hidden rows are left out of a + mixed entry. + - Added and removed entries: the start–end sentence. + - Changed entries: one line per changed detail, `label · before → after` (research R6): + - **Before** is a chip struck through on `--red-100` with `--red-500` text. **After** is a bold chip on + `--green-100` with `--green-700` text. Both pairs pass 4.5:1. + - Visually hidden "Before:" and "After:" labels go in front of the values. + - Short values never break; on a narrow screen the after value moves to the next line whole. + - A same-day time change shows only the hours, with the date once below. +4. **History start note** when the oldest kept entry is not an `added` entry (FR-011, research R4): + - "This lesson was already in the timetable before its recorded history starts", plus " ({date})" when + `historyStartsAt` is known. + - The note never names the oldest entry as the first appearance. +5. **More** (card only): see Cap. +6. **Retention note** (FR-012), always last: "Changes older than {period} are not kept." + +All colours come from the existing tokens, in both themes. Badges always carry text, so meaning never depends on +colour alone. At 360 px the page has no sideways scroll (FR-019). + +## New texts (en and nl, FR-018) + +`changes.found` (relative and exact are built with `Intl`, so no text keys for the units), plus the `history.*` +texts: + +- title / back link; +- open as page; +- current: "Now in the timetable", "Last known details", "More details" / "Fewer details", removed (with time) and no longer in the + timetable; +- the detail labels: time, status, location, rooms, teachers, groups, type, description, name; +- the kinds: new, removed, changed; +- the hidden value labels "Before:" and "After:", and the time note "Same day: {date}"; +- show all; +- the messages: N hidden, opened hidden, all hidden, and "You opened this change"; +- history start, with and without a date; +- N older not shown, opened further back; +- the retention note. + +The existing i18n parity test enforces that both languages have every key (SC-006). diff --git a/specs/010-event-change-history/data-model.md b/specs/010-event-change-history/data-model.md new file mode 100644 index 0000000..2ef6d7c --- /dev/null +++ b/specs/010-event-change-history/data-model.md @@ -0,0 +1,102 @@ +# Data model: Event change history + +## Schema change: migration `006_event_history.sql` + +```sql +-- 010: a lesson's history is one index range scan (research R3). +CREATE INDEX ON detected_change (calendar_id, event_key, id); + +-- 010: when the current snapshot's baseline was taken, so history can say where it starts (research R4). +-- NULL for calendars baselined before this migration: the start is unknown. +ALTER TABLE calendar ADD COLUMN baseline_at timestamptz NULL; +``` + +Writes to `baseline_at`: + +| Where | When | Value | +|---|---|---| +| `Subscriptions` add-link, `INSERT INTO calendar … baseline_features …` | a new calendar's first snapshot | the insert's `now` | +| `Checker.CheckAsync` success `UPDATE calendar …` | only on row 3 (`fence.BaselineFeatures != canonical`) | `claim.StartedAt` | + +No other path writes the snapshot wholesale (checked: `baseline_features` and `DELETE FROM event_snapshot` occur only +there and in Differ's per-key deletes). Regroup moves links, not snapshots. A calendar regroup creates has no +`baseline_features`, so its first check is a row 3 baseline and sets `baseline_at`. + +**Personal data**: none added. `baseline_at` describes a calendar check, not a person (FR-017). + +## Existing data read (unchanged) + +| Table | Used for | +|---|---| +| `detected_change` | every kept row of `(calendar_id, event_key)`, read through `ChangeView` | +| `event_snapshot` | the lesson's current details when it is in the timetable | +| `calendar` | `baseline_at`; the name is resolved as in `/api/me/calendars` (`coalesce(label, feed_name, host)`) | +| `subscription` (view) + `subscription_filter` + `preset` | access check and outcomes, through `Presets.LoadFiltersAsync(user, ct, calendarId)` | + +Rows expire by `detected_at < now − ChangeRetention` (existing `RunRetentionAsync`). Nothing in this feature +extends that period. + +## Entities + +### Lesson (event) + +The key is `(calendar_id, event_key)` (001). It is never put in a URL. The view reaches it through any of its +change ids. + +`current` in the API, built per research R5: + +| Field | Type | Notes | +|---|---|---| +| `state` | `"present" \| "removed" \| "gone"` | `present`: an `event_snapshot` row exists. `removed`: the newest row is `removed`. `gone`: neither. | +| `event` | object | the event object form from `Differ.EventObject`: `name`, `start`, `end`, `status`, `fields` (`location`, `rooms`, `teachers`, `groups`, `type`, `description`, `details`, …). From the `event_after` source, `fields.details` is missing. | +| `removedAt` | ISO time \| null | only for `removed` | + +### Detected change (row) + +The existing table, unchanged. The API returns the same per-row shape as `GET /api/me/changes` items, including +`outcome`. + +### History entry (frontend, derived) + +All rows with the same `detectedAt` (one check), in id order (research R6). + +| Field | Notes | +|---|---| +| `key` | `detectedAt` + first row id | +| `ids` | the row ids. The entry is "opened" when it contains `openedId`. | +| `kind` | `added` / `removed` / `changed` | +| `found` | `{ text, exact, iso }` from `fmt.found`; the history entry shows `text` and `exact` visibly | +| `name` | `eventName` of the first row | +| `outcome` | the mildest row outcome: normal, then lowPriority, then hidden. `hidden` means every row is hidden. | +| `summary` | for added: `addedAt(range)`; for removed: `removed` | +| `rows` | for changed: `{ label, before, after, note, wrap, outcome }[]`. One **Time** line: hours only plus a date `note` when the date stays, dates in both values when it moved. Sets are joined, `details.X` keeps label X, and free text has `wrap`. | + +### History view (frontend, derived, `historyView`) + +Inputs: entries, `all: boolean | null` from the URL, `openedId`, `cap: number | null`. + +| Output | Meaning | +|---|---| +| `showAll` | the effective toggle state (research R7 table) | +| `toggle` | `false` when every entry is hidden (no toggle offered) | +| `shown` | entries after the toggle filter and the cap. With the toggle off, hidden rows are dropped and an entry with no rows left is left out. | +| `more` | how many entries the cap removed (card only) | +| `hiddenCount` | rows left out by the filter (FR-015) | +| `openedHidden` | the opened entry is hidden and currently left out (FR-014b message) | +| `allHidden` | every entry is hidden (FR-014b message) | +| `openedBeyondCap` | the opened entry is cut by the card cap (edge case) | +| `startsBeforeHistory` | the oldest kept entry is not `added` (FR-011 message, research R4) | + +### Subscriber filters + +These are unchanged. The outcome per row is computed by `FilterEngine.Outcome` with the student's current filter +for that calendar, at request time. + +## State transitions + +None new. A lesson's `current.state` follows from the existing snapshot and change rows: + +```text +present ──(removed by a check)──▶ removed ──(appears again)──▶ present +present ──(re-baseline drops it, or it leaves the horizon)──▶ gone +``` diff --git a/specs/010-event-change-history/plan.md b/specs/010-event-change-history/plan.md new file mode 100644 index 0000000..2152a99 --- /dev/null +++ b/specs/010-event-change-history/plan.md @@ -0,0 +1,148 @@ +# Implementation Plan: Event change history + +**Branch**: `010-event-change-history` | **Date**: 2026-11-11 | **Spec**: [spec.md](./spec.md) + +**Input**: Feature specification from `/specs/010-event-change-history/spec.md` + +## Summary + +The time in the top corner of each feed change now shows when the change was found (`detectedAt`, which the API +already returns). It is relative below 24 hours ("2 hours ago") and an exact date and time after that. The full +value is in a `