diff --git a/backend/InTheKnow.Tests/HistoryTests.cs b/backend/InTheKnow.Tests/HistoryTests.cs new file mode 100644 index 0000000..ea3f373 --- /dev/null +++ b/backend/InTheKnow.Tests/HistoryTests.cs @@ -0,0 +1,225 @@ +using System.Net; +using System.Text.Json.Nodes; +using Xunit; +using static InTheKnow.Tests.LinkIdentityTests; + +namespace InTheKnow.Tests; + +// 010: GET /api/me/changes/{id}/history (contracts/api.md § Backend tests). +public class HistoryTests +{ + static CancellationToken Ct => TestContext.Current.CancellationToken; + static readonly DateTimeOffset D = FilterEngineTests.Detected; + + static JsonObject Ev(string name = "Lesson", int endDays = 1, string? status = "confirmed", string? type = null) => + FilterEngineTests.Ev(name, endDays, status, type); + + // One detected_change row for lesson `key`, found at `at`. + static async Task RecordAsync(TestDb db, long calendarId, string key, string kind, DateTimeOffset at, JsonObject? before = null, + JsonObject? after = null, string? field = null, JsonNode? oldValue = null, JsonNode? newValue = null) + { + var (o, n, b, a) = kind switch + { + "added" => (null, after, null, null), + "removed" => (before, null, null, null), + _ => (oldValue ?? "a", newValue ?? "b", before, after), + }; + await using var cmd = db.DataSource.CreateCommand(""" + INSERT INTO detected_change (calendar_id, event_key, kind, field, old_value, new_value, event_name, event_starts_at, detected_at, + event_before, event_after) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8, $9, $10) RETURNING id + """); + cmd.Parameters.Add(new() { Value = calendarId }); + cmd.Parameters.Add(new() { Value = key }); + cmd.Parameters.Add(new() { Value = kind }); + cmd.Parameters.Add(new() { Value = (object?)field ?? DBNull.Value, NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Text }); + foreach (var j in new[] { o, n }) + cmd.Parameters.Add(new() { Value = (object?)j?.ToJsonString() ?? DBNull.Value, NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Jsonb }); + cmd.Parameters.Add(new() { Value = (after ?? before)?["name"]?.GetValue() ?? "Lesson" }); + cmd.Parameters.Add(new() { Value = at }); + foreach (var j in new[] { b, a }) + cmd.Parameters.Add(new() { Value = (object?)j?.ToJsonString() ?? DBNull.Value, NpgsqlDbType = NpgsqlTypes.NpgsqlDbType.Jsonb }); + return (long)(await cmd.ExecuteScalarAsync(Ct))!; + } + + static Task SnapshotAsync(TestDb db, long calendarId, string key, string name) => db.ExecAsync(""" + INSERT INTO event_snapshot (calendar_id, event_key, name, starts_at, ends_at, status, fields) + VALUES ($1, $2, $3, '2026-10-02T08:00:00Z', '2026-10-02T09:30:00Z', 'confirmed', '{"rooms":["B2.14"]}') + """, calendarId, key, name); + + static async Task SeedAsync(ApiHost h, SignedIn who) => + (await h.Db.SeedCalendarAsync(Harness.Mt, [$"https://{Harness.Mt}/ical?{Guid.NewGuid():N}"], who.AccountId)).CalendarId; + + static Task HistoryAsync(SignedIn who, long id) => who.JsonAsync($"/api/me/changes/{id}/history"); + + static List<(long Id, string Outcome)> Items(JsonNode h) => + [.. h["items"]!.AsArray().Select(i => (i!["id"]!.GetValue(), i["outcome"]!.GetValue()))]; + + // The response minus the per-request trace id. + static async Task<(HttpStatusCode, string?, string)> NotFoundAsync(SignedIn who, string path) + { + var r = await who.Client.GetAsync(path, Ct); + var body = JsonNode.Parse(await r.Content.ReadAsStringAsync(Ct))!.AsObject(); + body.Remove("traceId"); + return (r.StatusCode, r.Content.Headers.ContentType?.ToString(), body.ToJsonString()); + } + + // FR-016, SC-005 + [Fact] + public async Task NotFoundIsTheSameForEveryCause() + { + await using var h = await ApiHost.CreateAsync(); + var (s1, s2, s3) = (await h.SignInAsync(), await h.SignInAsync(), await h.SignInAsync()); + var calendar = await SeedAsync(h, s1); + // s3 follows too, so the change outlives s1's links and the last case tests access, not deletion. + await h.Db.ExecAsync("INSERT INTO calendar_link (calendar_id, subscriber, url) VALUES ($1, $2, 'https://x.example/s3')", calendar, s3.AccountId); + var mine = await RecordAsync(h.Db, calendar, "k", "changed", D, Ev(), Ev(), "rooms"); + var deleted = await RecordAsync(h.Db, calendar, "k", "changed", D, Ev(), Ev(), "rooms"); + await h.Db.ExecAsync("DELETE FROM detected_change WHERE id = $1", deleted); + + var unknown = await NotFoundAsync(s1, $"/api/me/changes/{mine + 100}/history"); + Assert.Equal(HttpStatusCode.NotFound, unknown.Item1); + Assert.Contains("not_found", unknown.Item3); + Assert.Equal(unknown, await NotFoundAsync(s1, $"/api/me/changes/{deleted}/history")); + Assert.Equal(unknown, await NotFoundAsync(s2, $"/api/me/changes/{mine}/history")); + + await HistoryAsync(s1, mine); + Assert.Equal(HttpStatusCode.NoContent, (await s1.Client.DeleteAsync($"/api/me/calendars/{calendar}", Ct)).StatusCode); + Assert.Equal(unknown, await NotFoundAsync(s1, $"/api/me/changes/{mine}/history")); + await HistoryAsync(s3, mine); + Assert.Equal(HttpStatusCode.NotFound, (await s1.Client.GetAsync("/api/me/changes/abc/history", Ct)).StatusCode); + } + + // FR-009, FR-013: every row of the lesson, newest first, hidden ones included with their outcome. + [Fact] + public async Task EveryRowOfTheLessonNewestFirstWithOutcomes() + { + await using var h = await ApiHost.CreateAsync(); + var s1 = await h.SignInAsync(); + var calendar = await SeedAsync(h, s1); + var added = await RecordAsync(h.Db, calendar, "k", "added", D, after: Ev()); + var other = await RecordAsync(h.Db, calendar, "other", "changed", D, Ev(), Ev(), "rooms"); + var low = await RecordAsync(h.Db, calendar, "k", "changed", D.AddHours(1), Ev(status: "tentative"), Ev(), "status", "tentative", "confirmed"); + var hidden = await RecordAsync(h.Db, calendar, "k", "changed", D.AddHours(2), Ev(endDays: -1), Ev(endDays: -1), "rooms"); + + var body = await HistoryAsync(s1, low); + Assert.Equal([(hidden, "hidden"), (low, "lowPriority"), (added, "normal")], Items(body)); + Assert.DoesNotContain(other, Items(body).Select(i => i.Id)); + Assert.Equal(low, body["changeId"]!.GetValue()); + Assert.Equal(calendar, body["calendarId"]!.GetValue()); + Assert.Equal(Harness.Mt, body["calendarName"]!.GetValue()); + Assert.Equal(365, body["retentionDays"]!.GetValue()); + var item = body["items"]![0]!.AsObject(); + Assert.Equal(["id", "calendarId", "eventKey", "kind", "field", "oldValue", "newValue", "eventName", "eventStartsAt", "detectedAt", "outcome"], + item.Select(kv => kv.Key)); + + // The same item as the feed gives. + var feed = (await s1.JsonAsync("/api/me/changes?includeHidden=true"))["items"]!.AsArray().Single(i => i!["id"]!.GetValue() == hidden)!; + Assert.True(JsonNode.DeepEquals(feed, item)); + + // A hidden change opens its history too. + Assert.Equal(3, Items(await HistoryAsync(s1, hidden)).Count); + } + + [Fact] + public async Task TwoLinksGiveNoDuplicates() + { + await using var h = await ApiHost.CreateAsync(); + var s1 = await h.SignInAsync(); + var (calendar, _) = await h.Db.SeedCalendarAsync(Harness.Mt, [$"https://{Harness.Mt}/ical?a", $"https://{Harness.Mt}/ical?b"], s1.AccountId); + var id = await RecordAsync(h.Db, calendar, "k", "changed", D, Ev(), Ev(), "rooms"); + Assert.Equal([(id, "normal")], Items(await HistoryAsync(s1, id))); + } + + // SC-003 correctness: a lesson with 100 rows returns them all. + [Fact] + public async Task HundredRows() + { + await using var h = await ApiHost.CreateAsync(); + var s1 = await h.SignInAsync(); + var calendar = await SeedAsync(h, s1); + var ids = new List(); + for (var i = 0; i < 100; i++) ids.Add(await RecordAsync(h.Db, calendar, "k", "changed", D.AddHours(i), Ev(), Ev(), "rooms")); + Assert.Equal(ids.AsEnumerable().Reverse(), Items(await HistoryAsync(s1, ids[0])).Select(i => i.Id)); + } + + // Research R5 + [Fact] + public async Task CurrentDetails() + { + await using var h = await ApiHost.CreateAsync(); + var s1 = await h.SignInAsync(); + var calendar = await SeedAsync(h, s1); + + // present: from the snapshot. + await SnapshotAsync(h.Db, calendar, "p", "Now"); + var p = await RecordAsync(h.Db, calendar, "p", "changed", D, Ev("Then"), Ev("Then"), "rooms"); + var current = (await HistoryAsync(s1, p))["current"]!; + Assert.Equal("present", current["state"]!.GetValue()); + Assert.Equal("Now", current["event"]!["name"]!.GetValue()); + Assert.Equal("2026-10-02T08:00:00Z", current["event"]!["start"]!.GetValue()); + Assert.Equal("""["B2.14"]""", current["event"]!["fields"]!["rooms"]!.ToJsonString()); + Assert.Null(current["removedAt"]); + + // removed: from the removed row, with its time. + await RecordAsync(h.Db, calendar, "r", "added", D, after: Ev("Gone soon")); + var r = await RecordAsync(h.Db, calendar, "r", "removed", D.AddHours(1), Ev("Gone soon")); + current = (await HistoryAsync(s1, r))["current"]!; + Assert.Equal("removed", current["state"]!.GetValue()); + Assert.Equal("Gone soon", current["event"]!["name"]!.GetValue()); + Assert.Equal(D.AddHours(1), current["removedAt"]!.GetValue()); + + // present again after removal and reappearance. + await RecordAsync(h.Db, calendar, "r", "added", D.AddHours(2), after: Ev("Back")); + await SnapshotAsync(h.Db, calendar, "r", "Back"); + Assert.Equal("present", (await HistoryAsync(s1, r))["current"]!["state"]!.GetValue()); + + // gone: no snapshot row and no removal, as after a re-baseline drops it; the newest event object. + var g = await RecordAsync(h.Db, calendar, "g", "changed", D, Ev("Old name"), Ev("Last name"), "name", "Old name", "Last name"); + current = (await HistoryAsync(s1, g))["current"]!; + Assert.Equal("gone", current["state"]!.GetValue()); + Assert.Equal("Last name", current["event"]!["name"]!.GetValue()); + Assert.Null(current["removedAt"]); + } + + // Research R4: baseline_at from add-link and a features-change re-baseline, not from a normal check. + [Fact] + public async Task HistoryStartsAt() + { + await using var h = await ApiHost.CreateAsync(("InTheKnow:ChangeRetention", "90.00:00:00")); + var s1 = await h.SignInAsync(); + + // Unknown for a calendar baselined before the column existed. Not due, so it leaves the host budget to the checks below. + var (old, _) = await h.Db.SeedCalendarAsync(Harness.Mt, [$"https://{Harness.Mt}/ical?old"], s1.AccountId, DateTimeOffset.MaxValue); + var o = await RecordAsync(h.Db, old, "k", "changed", h.Time.GetUtcNow(), Ev(), Ev(), "rooms"); + var body = await HistoryAsync(s1, o); + Assert.Null(body["historyStartsAt"]); + Assert.Equal(90, body["retentionDays"]!.GetValue()); + + // Add-link baseline. + var url = MtLink(hex: "01"); + var added = JsonNode.Parse(await (await h.AddAsync(s1, url)).Content.ReadAsStringAsync(Ct))!; + var calendar = added["calendarId"]!.GetValue(); + var baseline = h.Time.GetUtcNow(); + var k = await RecordAsync(h.Db, calendar, "k", "changed", baseline, Ev(), Ev(), "rooms"); + Assert.Equal(baseline, (await HistoryAsync(s1, k))["historyStartsAt"]!.GetValue()); + + // A normal check leaves it. + h.Http.Map[new Uri(url).AbsoluteUri] = "mt-nl-edited.ics"; + h.Time.Advance(TimeSpan.FromMinutes(10)); + await h.Checker.RunDueChecksAsync(h.Time.GetUtcNow(), Ct).WaitAsync(TimeSpan.FromSeconds(30), Ct); + Assert.True(await h.Db.ScalarAsync("SELECT count(*) FROM detected_change WHERE calendar_id = $1 AND event_key <> 'k'", calendar) > 0); + Assert.Equal(baseline, (await HistoryAsync(s1, k))["historyStartsAt"]!.GetValue()); + + // A features change re-baselines. + await h.Db.ExecAsync("UPDATE calendar SET baseline_features = 'before' WHERE id = $1", calendar); + h.Time.Advance(TimeSpan.FromMinutes(10)); + var rebaseline = h.Time.GetUtcNow(); + await h.Checker.RunDueChecksAsync(rebaseline, Ct).WaitAsync(TimeSpan.FromSeconds(30), Ct); + Assert.Equal(rebaseline, (await HistoryAsync(s1, k))["historyStartsAt"]!.GetValue()); + + // Older than the retention period: the retention cut-off. + await h.Db.ExecAsync("UPDATE calendar SET baseline_at = $1 WHERE id = $2", h.Time.GetUtcNow() - TimeSpan.FromDays(100), calendar); + Assert.Equal(h.Time.GetUtcNow() - TimeSpan.FromDays(90), (await HistoryAsync(s1, k))["historyStartsAt"]!.GetValue()); + } +} diff --git a/backend/InTheKnow.Tests/OwnershipTests.cs b/backend/InTheKnow.Tests/OwnershipTests.cs index e9c0108..dbb435d 100644 --- a/backend/InTheKnow.Tests/OwnershipTests.cs +++ b/backend/InTheKnow.Tests/OwnershipTests.cs @@ -27,6 +27,7 @@ public class OwnershipTests ["GET /api/me/calendars"] = (null, null), ["DELETE /api/me/calendars/{calendarId:long}"] = (null, null), ["GET /api/me/changes"] = (null, null), + ["GET /api/me/changes/{id:long}/history"] = (null, "change"), ["GET /api/me/calendars/{calendarId:long}/filter"] = (null, null), ["PUT /api/me/calendars/{calendarId:long}/filter"] = ("""{"rules":[]}""", null), ["PUT /api/me/calendars/{calendarId:long}/filter/preset"] = ("""{"presetId":1}""", null), @@ -111,6 +112,10 @@ public async Task EveryMeRouteIsScopedToTheAccount(string method) var channel = JsonNode.Parse((await CallAsync(a.Client, "POST", "/api/me/channels", """{"type":"test","target":"a@example.nl"}""")).Item2)!["id"]!.GetValue(); var ids = new Dictionary { ["preset"] = preset, ["session"] = a.SessionId }; ids["method"] = await h.Db.ScalarAsync("SELECT id FROM sign_in_method WHERE account_id = $1", a.AccountId); + ids["change"] = await h.Db.ScalarAsync(""" + INSERT INTO detected_change (calendar_id, event_key, kind, field, old_value, new_value, event_name, event_starts_at, detected_at) + VALUES ($1, 'k', 'changed', 'name', '"a"', '"b"', 'Lesson', now(), now()) RETURNING id + """, calendar); // US2-1: A's rows carry A's id. foreach (var table in new[] { "calendar_link", "subscription_filter", "channel", "subscription_setting" }) diff --git a/backend/InTheKnow/Api.cs b/backend/InTheKnow/Api.cs index 08cf9b6..3aa1b4a 100644 --- a/backend/InTheKnow/Api.cs +++ b/backend/InTheKnow/Api.cs @@ -101,31 +101,19 @@ public static void MapApi(this WebApplication app) await using var r = await cmd.ExecuteReaderAsync(ct); for (read = 0; items.Count <= take && await r.ReadAsync(ct); read++) { - var (startsAt, detectedAt) = (r.GetDateTime(8), r.GetDateTime(9)); var c = ChangeView.Read(r); from = c.Id; var (filter, active) = filters[c.CalendarId]; var outcome = FilterEngine.Outcome(filter.Rules, c, active); if (outcome == Outcome.Hidden && includeHidden != "true") continue; - items.Add((c.Id, new - { - id = c.Id, - calendarId = c.CalendarId, - eventKey = c.EventKey, - kind = c.Kind, - field = c.Field, - oldValue = c.OldValue, - newValue = c.NewValue, - eventName = c.EventName, - eventStartsAt = startsAt, - detectedAt, - outcome = FilterEngine.Name(outcome), - })); + items.Add((c.Id, History.Item(c, outcome))); } } return Page(items, take); }); + me.MapHistory(); + Admin(app).MapGet("/calendars", async (string? status, bool? overdue, long? after, int? limit, NpgsqlDataSource db, TimeProvider time, CancellationToken ct) => { if (status is not (null or "ok" or "failing")) return Problem(400, "invalid_status"); diff --git a/backend/InTheKnow/Checker.cs b/backend/InTheKnow/Checker.cs index d56b948..2de1e19 100644 --- a/backend/InTheKnow/Checker.cs +++ b/backend/InTheKnow/Checker.cs @@ -186,7 +186,8 @@ async Task CheckAsync(Claim claim, CancellationToken ct) { var canonical = settings.CanonicalFeaturesFor(claim.Host); var changeCount = 0; - if (fence.BaselineFeatures != canonical) + var rebaseline = fence.BaselineFeatures != canonical; + if (rebaseline) { // Row 3: a baseline records no changes. await ExecAsync(conn, tx, "DELETE FROM event_snapshot WHERE calendar_id = $1", ct, claim.Id); @@ -202,10 +203,11 @@ async Task CheckAsync(Claim claim, CancellationToken ct) await ExecAsync(conn, tx, """ UPDATE calendar SET status = 'ok', consecutive_failures = 0, failing_since = NULL, last_ok = true, last_error = NULL, last_checked_at = $2, last_link_id = $3, last_event_count = $4, last_change_count = $5, snapshot_hash = $6, - horizon_end = $7, baseline_features = $8, next_check_at = $9, lease_id = NULL, feed_name = $10 + horizon_end = $7, baseline_features = $8, next_check_at = $9, lease_id = NULL, feed_name = $10, + baseline_at = CASE WHEN $11 THEN $2 ELSE baseline_at END WHERE id = $1 """, ct, claim.Id, claim.StartedAt, l.Id, states.Count, changeCount, hash!, horizon, canonical, claim.StartedAt + O.CheckInterval, - FeedParser.CleanName(parsed!.FeedName)); + FeedParser.CleanName(parsed!.FeedName), rebaseline); await ExecAsync(conn, tx, "UPDATE calendar_link SET flagged_at = NULL, last_used_at = $1 WHERE id = $2", ct, claim.StartedAt, l.Id); } await tx.CommitAsync(ct); diff --git a/backend/InTheKnow/History.cs b/backend/InTheKnow/History.cs new file mode 100644 index 0000000..1a634ab --- /dev/null +++ b/backend/InTheKnow/History.cs @@ -0,0 +1,118 @@ +using System.Security.Claims; +using System.Text.Json.Nodes; +using Npgsql; + +namespace InTheKnow; + +// GET /api/me/changes/{id}/history (010 contracts/api.md): every kept change of the lesson change `id` belongs to, +// with the lesson's current or last known details. Read-only. +static class History +{ + public static void MapHistory(this RouteGroupBuilder me) => + me.MapGet("/changes/{id:long}/history", async (ClaimsPrincipal user, long id, NpgsqlDataSource db, Presets presets, + Settings settings, TimeProvider time, CancellationToken ct) => + { + long calendarId; + string eventKey; + await using (var cmd = db.CreateCommand("SELECT calendar_id, event_key FROM detected_change WHERE id = $1")) + { + cmd.Parameters.Add(new() { Value = id }); + await using var r = await cmd.ExecuteReaderAsync(ct); + if (!await r.ReadAsync(ct)) return NotFound(); + (calendarId, eventKey) = (r.GetInt64(0), r.GetString(1)); + } + // FR-016: not following the timetable gives the same response as a missing change. + if (!(await presets.LoadFiltersAsync(user.AccountId(), ct, calendarId)).TryGetValue(calendarId, out var f)) return NotFound(); + + var rows = new List(); + await using (var cmd = db.CreateCommand($""" + SELECT {ChangeView.Columns} FROM detected_change c + WHERE c.calendar_id = $1 AND c.event_key = $2 ORDER BY c.id DESC + """)) + { + cmd.Parameters.Add(new() { Value = calendarId }); + cmd.Parameters.Add(new() { Value = eventKey }); + await using var r = await cmd.ExecuteReaderAsync(ct); + while (await r.ReadAsync(ct)) rows.Add(ChangeView.Read(r)); + } + if (rows.Count == 0) return NotFound(); // expired between the two reads + + string calendarName; + DateTimeOffset? baselineAt; + await using (var cmd = db.CreateCommand(""" + SELECT coalesce(ss.label, c.feed_name, c.host), c.baseline_at FROM calendar c + LEFT JOIN subscription_setting ss ON ss.calendar_id = c.id AND ss.subscriber = $2 + WHERE c.id = $1 + """)) + { + cmd.Parameters.Add(new() { Value = calendarId }); + cmd.Parameters.Add(new() { Value = user.AccountId() }); + await using var r = await cmd.ExecuteReaderAsync(ct); + if (!await r.ReadAsync(ct)) return NotFound(); + calendarName = r.GetString(0); + baselineAt = r.IsDBNull(1) ? null : r.GetFieldValue(1); + } + + var retention = settings.Options.ChangeRetention; + var cutoff = time.GetUtcNow() - retention; + return Results.Json(new + { + changeId = id, + calendarId, + calendarName, + current = await CurrentAsync(db, calendarId, eventKey, rows, ct), + historyStartsAt = baselineAt is { } b ? (b > cutoff ? b : cutoff).UtcDateTime : (DateTime?)null, + retentionDays = (int)retention.TotalDays, + items = rows.Select(c => Item(c, FilterEngine.Outcome(f.Filter.Rules, c, f.Active))), + }); + }); + + static IResult NotFound() => Api.Problem(404, "not_found"); + + // The item shape of GET /api/me/changes. + internal static object Item(ChangeView c, Outcome outcome) => new + { + id = c.Id, + calendarId = c.CalendarId, + eventKey = c.EventKey, + kind = c.Kind, + field = c.Field, + oldValue = c.OldValue, + newValue = c.NewValue, + eventName = c.EventName, + eventStartsAt = c.EventStartsAt.UtcDateTime, + detectedAt = c.DetectedAt.UtcDateTime, + outcome = FilterEngine.Name(outcome), + }; + + // Research R5: the snapshot row, else the removed row, else the newest row holding an event object. + static async Task CurrentAsync(NpgsqlDataSource db, long calendarId, string eventKey, List rows, CancellationToken ct) + { + await using (var cmd = db.CreateCommand(""" + SELECT name, starts_at, ends_at, status, fields::text FROM event_snapshot WHERE calendar_id = $1 AND event_key = $2 + """)) + { + cmd.Parameters.Add(new() { Value = calendarId }); + cmd.Parameters.Add(new() { Value = eventKey }); + await using var r = await cmd.ExecuteReaderAsync(ct); + if (await r.ReadAsync(ct)) + { + var e = new JsonObject + { + ["key"] = eventKey, + ["name"] = r.GetString(0), + ["start"] = FeedParser.Iso(r.GetFieldValue(1)), + ["end"] = FeedParser.Iso(r.GetFieldValue(2)), + ["status"] = r.IsDBNull(3) ? null : r.GetString(3), + ["fields"] = JsonNode.Parse(r.GetString(4)), + }; + return new { state = "present", @event = e, removedAt = (DateTime?)null }; + } + } + if (rows[0] is { Kind: "removed" } removed) + return new { state = "removed", @event = removed.OldValue, removedAt = (DateTime?)removed.DetectedAt.UtcDateTime }; + var last = rows.Select(c => c.EventAfter ?? (c.Kind == "added" ? c.NewValue : c.Kind == "removed" ? c.OldValue : null)) + .FirstOrDefault(e => e is JsonObject); + return new { state = "gone", @event = last, removedAt = (DateTime?)null }; + } +} diff --git a/backend/InTheKnow/Migrations/006_event_history.sql b/backend/InTheKnow/Migrations/006_event_history.sql new file mode 100644 index 0000000..4b89909 --- /dev/null +++ b/backend/InTheKnow/Migrations/006_event_history.sql @@ -0,0 +1,6 @@ +-- 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; diff --git a/backend/InTheKnow/Subscriptions.cs b/backend/InTheKnow/Subscriptions.cs index 6570493..e3eaede 100644 --- a/backend/InTheKnow/Subscriptions.cs +++ b/backend/InTheKnow/Subscriptions.cs @@ -57,8 +57,8 @@ public async Task AddLinkAsync(long subscriber, string url, Cance long? created; await using (var insert = new NpgsqlCommand(""" INSERT INTO calendar (identity_hash, host, next_check_at, baseline_features, snapshot_hash, horizon_end, - last_checked_at, last_ok, last_event_count, last_change_count, feed_name) - VALUES ($1, $2, $3, $4, $5, $6, $7, true, $8, 0, $9) + last_checked_at, last_ok, last_event_count, last_change_count, feed_name, baseline_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, true, $8, 0, $9, $7) ON CONFLICT (identity_hash) DO NOTHING RETURNING id """, conn, tx)) { diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts index 94ff35f..7512120 100644 --- a/frontend/src/app.d.ts +++ b/frontend/src/app.d.ts @@ -26,9 +26,12 @@ declare global { lang: Lang; me: Me | null; } - // A notice for the next page, handed over by an action that redirects there (a removal). + // A notice for the next page, handed over by an action that redirects there (a removal). `history` is the card + // over the feed (010 research R8): the data of /changes/{id}; `historyAll` the toggle set inside a history. interface PageState { notice?: string; + history?: { history: import('$lib/history').HistoryPage; openedId: number; all: boolean | null }; + historyAll?: boolean; } interface Error { message: string; diff --git a/frontend/src/lib/components/ChangeGroup.svelte b/frontend/src/lib/components/ChangeGroup.svelte index 5f3705d..3b70e3e 100644 --- a/frontend/src/lib/components/ChangeGroup.svelte +++ b/frontend/src/lib/components/ChangeGroup.svelte @@ -10,8 +10,9 @@ import { dicts } from '$lib/i18n'; import type { ChangeGroup } from '$lib/server/changes'; - // The design's ChangeNotice, for a group of changes to one event (research R11, R18). Not clickable, no unread dot. - let { group }: { group: ChangeGroup } = $props(); + // The design's ChangeNotice, for a group of changes to one event (research R11, R18). A link to the event's history + // (010 FR-004); the corner shows when the change was found (FR-001). No unread dot. + let { group, onopen }: { group: ChangeGroup; onopen?: (e: MouseEvent) => void } = $props(); const t = $derived(dicts[page.data.lang]); const icons = { 'plus-circle': CirclePlus, @@ -25,12 +26,12 @@ const quiet = $derived(group.items.every((i) => i.outcome !== 'normal')); - + diff --git a/frontend/src/lib/components/HistoryCard.svelte b/frontend/src/lib/components/HistoryCard.svelte new file mode 100644 index 0000000..f78e3d1 --- /dev/null +++ b/frontend/src/lib/components/HistoryCard.svelte @@ -0,0 +1,150 @@ + + + { + e.preventDefault(); + back(); + }} + onclose={() => { + // Only a close the browser did on its own; a close because Back cleared the state must not step back again. + if (page.state.history) back(); + }} +> +
+
+

{data.history.name}

+ {data.history.calendarName} +
+ + + +
+
+ +
+
+ + diff --git a/frontend/src/lib/history.test.ts b/frontend/src/lib/history.test.ts new file mode 100644 index 0000000..2d335d2 --- /dev/null +++ b/frontend/src/lib/history.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; +import { historyHref, historyView, opensCard, parseAll, parseId, type HistoryEntry, type Outcome } from './history'; + +let n = 0; +/** One entry with one row per outcome, newest first in the arrays below. */ +const entry = (outcomes: Outcome[], kind: HistoryEntry['kind'] = 'changed'): HistoryEntry => { + const ids = outcomes.map(() => ++n); + return { + key: `k${ids[0]}`, + ids, + outcomes, + kind, + found: { text: '', exact: '', iso: '', relative: true }, + name: 'Optics', + outcome: outcomes.includes('normal') ? 'normal' : outcomes.includes('lowPriority') ? 'lowPriority' : 'hidden', + summary: null, + rows: kind === 'changed' ? outcomes.map((outcome) => ({ label: 'Name', before: 'a', after: 'b', note: null, wrap: true, outcome })) : [] + }; +}; +const view = (entries: HistoryEntry[], openedId: number, all: boolean | null = null, cap: number | null = null) => + historyView(entries, { all, openedId, cap }); + +describe('historyView: opened change not hidden (research R7 row 1)', () => { + const make = () => [entry(['normal']), entry(['hidden', 'normal']), entry(['hidden']), entry(['normal'], 'added')]; + + it('drops hidden rows and entries with none left, and counts them', () => { + for (const all of [null, false]) { + const es = make(); + const v = view(es, es[0].ids[0], all); + expect(v.shown.map((e) => e.key)).toEqual([es[0].key, es[1].key, es[3].key]); + expect(v.shown[1].rows.map((r) => r.outcome)).toEqual(['normal']); + expect([v.hiddenCount, v.showAll, v.toggle, v.openedHidden, v.allHidden]).toEqual([2, false, true, false, false]); + } + }); + + it('caps after the filter and says when the opened entry is further back', () => { + const es = make(); + const v = view(es, es[3].ids[0], null, 2); + expect(v.shown.map((e) => e.key)).toEqual([es[0].key, es[1].key]); + expect([v.more, v.openedBeyondCap]).toEqual([1, true]); + expect(view(es, es[0].ids[0], null, 2).openedBeyondCap).toBe(false); + expect(view(es, es[0].ids[0], null, null)).toMatchObject({ more: 0, openedBeyondCap: false }); + }); + + it('says the history starts before the oldest entry unless that entry is added (FR-011)', () => { + expect(view([entry(['normal']), entry(['normal'], 'added')], n).startsBeforeHistory).toBe(false); + expect(view([entry(['normal']), entry(['normal'])], n).startsBeforeHistory).toBe(true); + // In the baseline, removed, then back: the reappearance is not the oldest entry. + const back = [entry(['normal'], 'added'), entry(['normal'], 'removed')]; + expect(view(back, back[0].ids[0]).startsBeforeHistory).toBe(true); + // Judged over every kept entry: a hidden oldest added entry still counts with the toggle off. + const es = [entry(['normal']), entry(['hidden'], 'added')]; + const v = view(es, es[0].ids[0]); + expect([v.shown.length, v.startsBeforeHistory]).toEqual([1, false]); + }); +}); + +// T027: the remaining rows of research R7's table. +describe('historyView: the toggle states (FR-014, FR-014a, FR-014b)', () => { + it('shows everything with all=1', () => { + const es = [entry(['normal']), entry(['hidden'])]; + const v = view(es, es[0].ids[0], true); + expect([v.shown.length, v.showAll, v.toggle, v.hiddenCount]).toEqual([2, true, true, 0]); + }); + + it('shows everything when the opened change is hidden, unless all=0', () => { + const es = [entry(['normal']), entry(['hidden'])]; + for (const all of [null, true]) { + const v = view(es, es[1].ids[0], all); + expect([v.shown.length, v.showAll, v.toggle, v.openedHidden]).toEqual([2, true, true, false]); + } + const off = view(es, es[1].ids[0], false); + expect([off.shown.length, off.showAll, off.openedHidden, off.hiddenCount]).toEqual([1, false, true, 1]); + }); + + it('offers no toggle when every entry is hidden', () => { + const es = [entry(['hidden']), entry(['hidden', 'hidden'])]; + for (const all of [null, false, true]) { + const v = view(es, es[0].ids[0], all); + expect([v.shown.length, v.showAll, v.toggle, v.allHidden, v.openedHidden]).toEqual([2, true, false, true, false]); + } + }); + + it('counts hidden entries toward the cap with the toggle on', () => { + const es = [entry(['hidden']), entry(['normal']), entry(['normal'])]; + const v = view(es, es[1].ids[0], true, 2); + expect([v.shown.map((e) => e.key), v.more]).toEqual([[es[0].key, es[1].key], 1]); + }); +}); + +describe('addresses (contracts/pages.md § Addresses)', () => { + it('accepts only a positive digit id', () => { + expect(parseId('4711')).toBe('4711'); + expect(parseId('999999999999999999')).toBe('999999999999999999'); + for (const bad of ['0', '0123', '-1', '1e3', '12a', '', '1234567890123456789', ' 1', undefined]) expect(parseId(bad)).toBeNull(); + }); + + it('reads all as 0 or 1 only', () => { + expect([parseAll('1'), parseAll('0')]).toEqual([true, false]); + for (const other of [null, '', 'true', '2', 'yes']) expect(parseAll(other)).toBeNull(); + }); + + it('keeps the toggle in the address only when it is set', () => { + expect([historyHref(5, null), historyHref(5, true), historyHref(5, false)]).toEqual(['/changes/5', '/changes/5?all=1', '/changes/5?all=0']); + }); +}); + +describe('opensCard (FR-005b)', () => { + const click = { button: 0, ctrlKey: false, metaKey: false, shiftKey: false, altKey: false, defaultPrevented: false }; + it('opens the card only for a plain primary click', () => { + expect(opensCard(click)).toBe(true); + for (const k of ['ctrlKey', 'metaKey', 'shiftKey', 'altKey', 'defaultPrevented'] as const) expect(opensCard({ ...click, [k]: true })).toBe(false); + expect(opensCard({ ...click, button: 1 })).toBe(false); + }); +}); diff --git a/frontend/src/lib/history.ts b/frontend/src/lib/history.ts new file mode 100644 index 0000000..51e95d4 --- /dev/null +++ b/frontend/src/lib/history.ts @@ -0,0 +1,116 @@ +// The history view of one lesson (010): the shapes the server builds and the pure view rules the components run, so the +// filter toggle needs no request (research R7). Client-safe: nothing here may import from $lib/server. + +export type Outcome = 'normal' | 'lowPriority' | 'hidden'; +/** When something was found: `text` is relative under 24 hours (`relative`), `exact` always has the year. */ +export type Found = { text: string; exact: string; iso: string; relative: boolean }; + +/** One changed detail of an entry: `label · before → after` (research R6). */ +export type HistoryRow = { label: string; before: string; after: string; note: string | null; wrap: boolean; outcome: Outcome }; + +/** All rows of one check, in id order (research R6, data-model.md § History entry). */ +export type HistoryEntry = { + key: string; + ids: number[]; + /** The outcome of each row, in `ids` order. */ + outcomes: Outcome[]; + kind: 'added' | 'removed' | 'changed'; + found: Found; + name: string; + /** The mildest row outcome: hidden only when every row is hidden. */ + outcome: Outcome; + /** The sentence of an added or removed entry. */ + summary: string | null; + rows: HistoryRow[]; +}; + +export type CurrentDetails = { + state: 'present' | 'removed' | 'gone'; + name: string; + tile: { month: string; day: string; weekday: string; full: string } | null; + /** Line 2: start–end · location. */ + when: string | null; + location: string | null; + /** Line 3: teachers · groups · type. */ + people: string[]; + more: { label: string; value: string }[]; + removedAt: Found | null; +}; + +export type HistoryPage = { + changeId: number; + calendarName: string; + name: string; + current: CurrentDetails; + /** Newest first. */ + entries: HistoryEntry[]; + historyStart: string | null; + retention: string; +}; + +export const mildest = (outcomes: Outcome[]): Outcome => + outcomes.includes('normal') ? 'normal' : outcomes.includes('lowPriority') ? 'lowPriority' : 'hidden'; + +/** `{id}` of /changes/{id}: digits only, no leading zero (contracts/pages.md § Addresses). */ +export const parseId = (v: string | null | undefined): string | null => (v && /^[1-9]\d{0,17}$/.test(v) ? v : null); + +/** `all`: 1 forces show all, 0 forces filtered, anything else is the default rule (research R7). */ +export const parseAll = (v: string | null | undefined): boolean | null => (v === '1' ? true : v === '0' ? false : null); + +/** The address of a history, keeping the toggle when it is set. */ +export const historyHref = (id: number, all: boolean | null) => `/changes/${id}${all === null ? '' : `?all=${all ? 1 : 0}`}`; + +export type HistoryView = { + showAll: boolean; + /** False when every entry is hidden: no toggle is offered. */ + toggle: boolean; + shown: HistoryEntry[]; + more: number; + hiddenCount: number; + openedHidden: boolean; + allHidden: boolean; + openedBeyondCap: boolean; + startsBeforeHistory: boolean; +}; + +/** Research R7's table, the FR-009a cap and the FR-011 rule. `entries` are newest first. */ +export function historyView(entries: HistoryEntry[], opts: { all: boolean | null; openedId: number; cap: number | null }): HistoryView { + const opened = entries.find((e) => e.ids.includes(opts.openedId)); + const openedRowHidden = opened ? opened.outcomes[opened.ids.indexOf(opts.openedId)] === 'hidden' : false; + const allHidden = entries.length > 0 && entries.every((e) => e.outcome === 'hidden'); + const showAll = allHidden || (openedRowHidden ? opts.all !== false : opts.all === true); + + let hiddenCount = 0; + const filtered = showAll + ? entries + : entries.flatMap((e) => { + const dropped = e.outcomes.filter((o) => o === 'hidden').length; + hiddenCount += dropped; + if (e.outcome === 'hidden') return []; + return dropped ? [{ ...e, rows: e.rows.filter((r) => r.outcome !== 'hidden') }] : [e]; + }); + const shown = opts.cap === null ? filtered : filtered.slice(0, opts.cap); + const openedAt = opened ? filtered.findIndex((e) => e.key === opened.key) : -1; + return { + showAll, + toggle: !allHidden, + shown, + more: filtered.length - shown.length, + hiddenCount, + openedHidden: openedRowHidden && !showAll, + allHidden, + openedBeyondCap: openedAt >= shown.length, + // Judged over every kept entry, so a reappearance is never shown as the first appearance (FR-011, research R4). + startsBeforeHistory: entries.length > 0 && entries[entries.length - 1].kind !== 'added' + }; +} + +/** A plain primary click opens the card; anything else is left to the browser (FR-005b, research R8). */ +export const opensCard = (e: { + button: number; + ctrlKey: boolean; + metaKey: boolean; + shiftKey: boolean; + altKey: boolean; + defaultPrevented: boolean; +}): boolean => e.button === 0 && !e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey && !e.defaultPrevented; diff --git a/frontend/src/lib/i18n/en.ts b/frontend/src/lib/i18n/en.ts index c602bc4..4a897b5 100644 --- a/frontend/src/lib/i18n/en.ts +++ b/frontend/src/lib/i18n/en.ts @@ -252,6 +252,46 @@ export const en = { empty: 'No changes yet. When something in your calendars changes, it shows up here.', all: 'All changes' }, + history: { + title: 'Change history', + back: 'Back to changes', + openAsPage: 'Open as page', + current: 'Now in the timetable', + lastKnown: 'Last known details', + moreDetails: 'More details', + fewerDetails: 'Fewer details', + andMore: (items: string, n: number) => `${items} and ${n} more`, + removed: 'Removed', + removedAt: (when: string) => `Removed ${when}`, + gone: 'No longer in the timetable', + labels: { + time: 'Time', + status: 'Status', + location: 'Location', + rooms: 'Rooms', + teachers: 'Teachers', + groups: 'Groups', + type: 'Type', + description: 'Description', + name: 'Name', + calendar: 'Timetable' + }, + kinds: { added: 'New', removed: 'Removed', changed: 'Changed' }, + before: 'Before:', + after: 'After:', + sameDay: (date: string) => `Same day: ${date}`, + heading: 'Changes, newest first', + showAll: 'Show all', + hiddenCount: (n: number) => (n === 1 ? '1 change hidden by your filters' : `${n} changes hidden by your filters`), + openedHidden: 'The change you opened is hidden by your filters', + allHidden: 'All changes of this lesson are hidden by your filters', + opened: 'You opened this change', + start: 'This lesson was already in the timetable before its recorded history starts.', + startAt: (date: string) => `This lesson was already in the timetable before its recorded history starts (${date}).`, + more: (n: number) => (n === 1 ? '1 older change not shown' : `${n} older changes not shown`), + furtherBack: 'The change you opened is further back', + retention: (period: string) => `Changes older than ${period} are not kept.` + }, calendars: { title: 'Calendars', intro: 'The calendar links InTheKnow checks for you.', diff --git a/frontend/src/lib/i18n/nl.ts b/frontend/src/lib/i18n/nl.ts index dcb2217..ac8f329 100644 --- a/frontend/src/lib/i18n/nl.ts +++ b/frontend/src/lib/i18n/nl.ts @@ -253,6 +253,46 @@ export const nl: Dict = { empty: 'Nog geen wijzigingen. Als er iets in je agenda’s verandert, zie je het hier.', all: 'Alle wijzigingen' }, + history: { + title: 'Wijzigingsgeschiedenis', + back: 'Terug naar wijzigingen', + openAsPage: 'Open als pagina', + current: 'Nu in het rooster', + lastKnown: 'Laatst bekende gegevens', + moreDetails: 'Meer details', + fewerDetails: 'Minder details', + andMore: (items: string, n: number) => `${items} en nog ${n}`, + removed: 'Verwijderd', + removedAt: (when: string) => `Verwijderd ${when}`, + gone: 'Niet meer in het rooster', + labels: { + time: 'Tijd', + status: 'Status', + location: 'Locatie', + rooms: 'Lokalen', + teachers: 'Docenten', + groups: 'Klassen', + type: 'Soort', + description: 'Beschrijving', + name: 'Naam', + calendar: 'Rooster' + }, + kinds: { added: 'Nieuw', removed: 'Verwijderd', changed: 'Gewijzigd' }, + before: 'Voor:', + after: 'Na:', + sameDay: (date: string) => `Zelfde dag: ${date}`, + heading: 'Wijzigingen, nieuwste eerst', + showAll: 'Toon alles', + hiddenCount: (n: number) => (n === 1 ? '1 wijziging verborgen door je filters' : `${n} wijzigingen verborgen door je filters`), + openedHidden: 'De wijziging die je opende is verborgen door je filters', + allHidden: 'Alle wijzigingen van deze les zijn verborgen door je filters', + opened: 'Deze wijziging opende je', + start: 'Deze les stond al in het rooster voordat de bewaarde geschiedenis begint.', + startAt: (date: string) => `Deze les stond al in het rooster voordat de bewaarde geschiedenis begint (${date}).`, + more: (n: number) => (n === 1 ? '1 oudere wijziging niet getoond' : `${n} oudere wijzigingen niet getoond`), + furtherBack: 'De wijziging die je opende staat verder terug', + retention: (period: string) => `Wijzigingen ouder dan ${period} worden niet bewaard.` + }, calendars: { title: 'Agenda’s', intro: 'De agendalinks die InTheKnow voor je controleert.', diff --git a/frontend/src/lib/server/changes.test.ts b/frontend/src/lib/server/changes.test.ts index 9b8bd83..48e156b 100644 --- a/frontend/src/lib/server/changes.test.ts +++ b/frontend/src/lib/server/changes.test.ts @@ -92,7 +92,25 @@ describe('group', () => { ]; const groups = group(items, [cal(12, 'Rooster')], t, fmt); expect(groups.map((g) => g.items.length)).toEqual([3, 1]); - expect(groups[0].when).toBe('Thu 1 Oct 08:30'); + // 010 FR-001: the corner shows when the change was found, not the event's start. + expect(groups[0].found).toEqual(fmt.found('2026-09-24T19:30:02Z')); + expect(groups[0].found.text).not.toBe(fmt.dateTime(items[0].eventStartsAt)); + expect(groups[0].found.text).toBe('Thu 24 Sept 21:30'); + expect(groups[0].found.iso).toBe('2026-09-24T19:30:02Z'); + expect(groups[0].found.exact).toBe('Thu 24 Sept 2026 21:30'); + }); + + // 010 research R9. + it('links to the first row the student sees', () => { + const cals = [cal(12, 'R')]; + const [a, b] = [change('changed', 'name', 'a', 'b', { outcome: 'hidden' }), change('changed', 'type', 'a', 'b')]; + expect(group([a, b], cals, t, fmt)[0].href).toBe(`/changes/${b.id}`); + const [c, d] = [change('changed', 'name', 'a', 'b', { outcome: 'hidden' }), change('changed', 'type', 'a', 'b', { outcome: 'hidden' })]; + expect(group([c, d], cals, t, fmt)[0].href).toBe(`/changes/${c.id}`); + const [e] = [change('changed', 'name', 'a', 'b', { outcome: 'lowPriority' })]; + expect(group([e], cals, t, fmt)[0].href).toBe(`/changes/${e.id}`); + expect(group([e], cals, t, fmt, false)[0].href).not.toContain('all='); + expect(group([c, d], cals, t, fmt, true)[0].href).toBe(`/changes/${c.id}?all=1`); }); it('takes the most severe tone and keeps outcomes per item', () => { diff --git a/frontend/src/lib/server/changes.ts b/frontend/src/lib/server/changes.ts index 231d57a..ba82cf8 100644 --- a/frontend/src/lib/server/changes.ts +++ b/frontend/src/lib/server/changes.ts @@ -2,6 +2,7 @@ import type { RequestEvent } from '@sveltejs/kit'; import { checkFailureMessage } from '$lib/codes'; import type { Dict } from '$lib/i18n'; import { api, must } from './api'; +import type { Found } from '$lib/history'; import { formatter, type Formatter } from './format'; // Change sentences, grouping and tones (research R11). Built on the server; components only print the strings. @@ -38,7 +39,10 @@ export type Icon = 'plus-circle' | 'x-circle' | 'map-pin' | 'clock' | 'check-cir export type ChangeGroup = { key: string; eventName: string; - when: string; + /** When InTheKnow found the change (010 FR-001). */ + found: Found; + /** The history of the event: /changes/{id}, with ?all=1 when the feed shows hidden changes (010 research R9). */ + href: string; calendarName: string | null; tone: Tone; /** The icon of the change that gave the group its tone. */ @@ -46,14 +50,14 @@ export type ChangeGroup = { items: { sentence: string; outcome: Outcome; icon: Icon }[]; }; -const text = (v: unknown, t: Dict) => (v === null || v === undefined || v === '' ? t.changes.none : String(v)); -const list = (v: unknown): string[] => (Array.isArray(v) ? v.map(String) : []); -const span = (v: unknown) => (v && typeof v === 'object' ? (v as { start?: string; end?: string }) : {}); +export const text = (v: unknown, t: Dict) => (v === null || v === undefined || v === '' ? t.changes.none : String(v)); +export const list = (v: unknown): string[] => (Array.isArray(v) ? v.map(String) : []); +export const span = (v: unknown) => (v && typeof v === 'object' ? (v as { start?: string; end?: string }) : {}); -function statusWord(v: unknown, t: Dict): string { +export function statusWord(v: unknown, t: Dict): string { return v === 'confirmed' || v === 'tentative' || v === 'cancelled' ? t.changes.status[v] : text(v, t); } -function typeWord(v: unknown, t: Dict): string { +export function typeWord(v: unknown, t: Dict): string { return v === 'lesson' || v === 'test' || v === 'holiday' || v === 'appointment' ? t.changes.types[v] : text(v, t); } @@ -126,9 +130,9 @@ export function icon(c: Change): Icon { const severity: Tone[] = ['cancelled', 'moved', 'new', 'changed']; /** Consecutive changes of one event in one check form one group (001 detected-change "Grouping"). */ -export function group(items: Change[], calendars: Calendar[], t: Dict, fmt: Formatter): ChangeGroup[] { +export function group(items: Change[], calendars: Calendar[], t: Dict, fmt: Formatter, showHidden = false): ChangeGroup[] { const names = new Map(calendars.map((c) => [c.calendarId, c.name])); - const groups: (Omit & { tones: Tone[] })[] = []; + const groups: (Omit & { tones: Tone[]; ids: { id: number; outcome: Outcome }[] })[] = []; let last = ''; for (const c of items) { const key = `${c.calendarId} ${c.eventKey} ${c.detectedAt}`; @@ -136,20 +140,25 @@ export function group(items: Change[], calendars: Calendar[], t: Dict, fmt: Form groups.push({ key: `${key} ${c.id}`, eventName: c.eventName, - when: fmt.dateTime(c.eventStartsAt), + found: fmt.found(c.detectedAt), calendarName: calendars.length > 1 ? (names.get(c.calendarId) ?? null) : null, tones: [], + ids: [], items: [] }); last = key; } const g = groups[groups.length - 1]; g.tones.push(tone(c)); + g.ids.push({ id: c.id, outcome: c.outcome }); g.items.push({ sentence: describe(c, t, fmt), outcome: c.outcome, icon: icon(c) }); } - return groups.map(({ tones, ...g }) => { + return groups.map(({ tones, ids, ...g }) => { const t = severity.find((s) => tones.includes(s)) ?? 'changed'; - return { ...g, tone: t, icon: g.items[tones.indexOf(t)].icon }; + // A row the student sees, so a mixed group never forces "show all" (research R9). + const opened = (ids.find((i) => i.outcome !== 'hidden') ?? ids[0]).id; + const href = `/changes/${opened}${showHidden ? '?all=1' : ''}`; + return { ...g, href, tone: t, icon: g.items[tones.indexOf(t)].icon }; }); } @@ -198,5 +207,5 @@ export async function loadFeed(event: RequestEvent, opts: { before?: number; sho const page = must(event, changes); const cals = must(event, calendars); const fmt = formatter(lang, tz); - return { groups: group(page.items, cals, t, fmt), next: page.next, calendars: cals.map((c) => calendarView(c, t, fmt)) }; + return { groups: group(page.items, cals, t, fmt, opts.showHidden), next: page.next, calendars: cals.map((c) => calendarView(c, t, fmt)) }; } diff --git a/frontend/src/lib/server/format.test.ts b/frontend/src/lib/server/format.test.ts index fcd36f4..27e84c8 100644 --- a/frontend/src/lib/server/format.test.ts +++ b/frontend/src/lib/server/format.test.ts @@ -38,4 +38,55 @@ describe('formatter', () => { expect(formatter('nl', 'UTC').wait(90)).toBe('2 minuten'); expect(formatter('nl', 'UTC').wait(30)).toBe('30 seconden'); }); + + // 010 FR-001, FR-002, research R1. + describe('found', () => { + const at = (seconds: number) => new Date(now.getTime() - seconds * 1000).toISOString(); + const banned = ['yesterday', 'today', 'gisteren', 'vandaag']; + + it('is relative under 24 hours and exact from 24 hours, in both languages', () => { + const en = formatter('en', 'Europe/Amsterdam', now); + const nl = formatter('nl', 'Europe/Amsterdam', now); + expect(en.found(at(59)).text).toBe('59 seconds ago'); + expect(en.found(at(59 * 60 + 59)).text).toBe('59 minutes ago'); + expect(en.found(at(23 * 3600 + 59 * 60)).text).toBe('23 hours ago'); + expect(en.found(at(24 * 3600)).text).toBe(en.dateTime(at(24 * 3600))); + expect(nl.found(at(59)).text).toBe('59 seconden geleden'); + expect(nl.found(at(90)).text).toBe('1 minuut geleden'); + expect(nl.found(at(23 * 3600 + 59 * 60)).text).toBe('23 uur geleden'); + expect(nl.found(at(24 * 3600)).text).toBe(nl.dateTime(at(24 * 3600))); + }); + + it('never says yesterday or today, and clamps a time just ahead of ours', () => { + for (const lang of ['en', 'nl'] as const) { + const f = formatter(lang, 'Europe/Amsterdam', now); + for (const s of [0, 1, 3600, 86399, 86400, 86400 * 2, 86400 * 400]) + for (const word of banned) expect(f.found(at(s)).text.toLowerCase()).not.toContain(word); + expect(f.found(at(-5)).text).toBe(f.found(at(0)).text); + } + }); + + it('shows the year for another year, and keeps the exact value with the year', () => { + const f = formatter('en', 'Europe/Amsterdam', now); + const lastYear = '2025-11-10T08:19:00Z'; + expect(f.found(lastYear).text).toBe('Mon 10 Nov 2025 09:19'); + expect(f.found(at(60)).exact).toBe(f.fullDateTime(at(60))); + expect(f.found(at(60)).iso).toBe(at(60)); + expect([f.found(at(86399)).relative, f.found(at(86400)).relative]).toEqual([true, false]); + }); + }); + + it('fullDateTime always has the year', () => { + expect(formatter('en', 'Europe/Amsterdam', now).fullDateTime('2026-10-01T06:30:00Z')).toBe('Thu 1 Oct 2026 08:30'); + expect(formatter('nl', 'Europe/Amsterdam', now).fullDateTime('2026-10-01T06:30:00Z')).toBe('do 1 okt 2026 08:30'); + }); + + // 010 FR-012, research R11. + it('words the retention period', () => { + expect(formatter('en', 'UTC').retention(365)).toBe('1 year'); + expect(formatter('en', 'UTC').retention(730)).toBe('2 years'); + expect(formatter('en', 'UTC').retention(90)).toBe('90 days'); + expect(formatter('nl', 'UTC').retention(365)).toBe('1 jaar'); + expect(formatter('nl', 'UTC').retention(90)).toBe('90 dagen'); + }); }); diff --git a/frontend/src/lib/server/format.ts b/frontend/src/lib/server/format.ts index a207571..919a3d4 100644 --- a/frontend/src/lib/server/format.ts +++ b/frontend/src/lib/server/format.ts @@ -23,13 +23,44 @@ export function formatter(lang: Lang, timeZone: string, now: Date = new Date()) const p = parts(new Date(iso), { hour: '2-digit', minute: '2-digit' }); return `${p.hour}:${p.minute}`; }; - const unit = (n: number, u: 'second' | 'minute' | 'hour') => + const unit = (n: number, u: 'second' | 'minute' | 'hour' | 'day' | 'year') => new Intl.NumberFormat(locale, { style: 'unit', unit: u, unitDisplay: 'long' }).format(n); + const dateTime = (iso: string) => `${date(iso)} ${time(iso)}`; + /** Date with the year always, and the time: the exact value behind a relative one. */ + const fullDateTime = (iso: string) => { + const p = parts(new Date(iso), { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' }); + return `${p.weekday} ${p.day} ${p.month} ${p.year} ${time(iso)}`; + }; + // 'always' never words "yesterday" or "today" (010 FR-002, research R1). + const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'always' }); return { date, time, - dateTime: (iso: string) => `${date(iso)} ${time(iso)}`, + dateTime, + fullDateTime, + /** The parts of a calendar date tile (010 contracts/pages.md § Current details). */ + tile: (iso: string) => { + const p = parts(new Date(iso), { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' }); + return { month: p.month, day: p.day, weekday: p.weekday, full: `${p.weekday} ${p.day} ${p.month} ${p.year}` }; + }, + /** When something was found: relative under 24 hours, else the date and time (010 research R1). */ + found: (iso: string) => { + // Clamped: the API's clock may be a little ahead of ours. + const s = Math.max(0, Math.floor((now.getTime() - new Date(iso).getTime()) / 1000)); + const relative = s < 86400; + const text = + s < 60 + ? rtf.format(-s, 'second') + : s < 3600 + ? rtf.format(-Math.floor(s / 60), 'minute') + : s < 86400 + ? rtf.format(-Math.floor(s / 3600), 'hour') + : dateTime(iso); + return { text, exact: fullDateTime(iso), iso, relative }; + }, + /** The retention period: whole years, else days (010 research R11). */ + retention: (days: number) => (days > 0 && days % 365 === 0 ? unit(days / 365, 'year') : unit(days, 'day')), range: (startIso: string, endIso: string) => date(startIso) === date(endIso) ? `${date(startIso)} ${time(startIso)}–${time(endIso)}` diff --git a/frontend/src/lib/server/history.test.ts b/frontend/src/lib/server/history.test.ts new file mode 100644 index 0000000..24f98d3 --- /dev/null +++ b/frontend/src/lib/server/history.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest'; +import { dicts } from '$lib/i18n'; +import type { Change } from './changes'; +import { formatter } from './format'; +import { build, entries, row, type HistoryData } from './history'; + +const now = new Date('2026-11-12T12:00:00Z'); +const t = dicts.en; +const fmt = formatter('en', 'Europe/Amsterdam', now); +let id = 100; +const change = (kind: Change['kind'], field: string | null, oldValue: unknown, newValue: unknown, more: Partial = {}): Change => ({ + id: ++id, + calendarId: 12, + eventKey: 'K', + kind, + field, + oldValue, + newValue, + eventName: 'Optics', + eventStartsAt: '2027-01-06T08:00:00Z', + detectedAt: '2026-11-12T10:00:00Z', + outcome: 'normal', + ...more +}); +const event = { name: 'Optics', start: '2027-01-06T08:00:00Z', end: '2027-01-06T09:30:00Z', status: 'confirmed', fields: {} }; + +describe('entries (research R6)', () => { + it('groups rows of one check into one entry in id order, newest entry first', () => { + const [a, b] = [change('changed', 'name', 'x', 'y'), change('changed', 'rooms', ['A'], ['B'])]; + const c = change('changed', 'type', 'lesson', 'test', { detectedAt: '2026-11-12T11:00:00Z' }); + const list = entries([c, b, a], t, fmt); // the API's id DESC order + expect(list.map((e) => e.ids)).toEqual([[c.id], [a.id, b.id]]); + expect(list[1].rows.map((r) => r.label)).toEqual(['Name', 'Rooms']); + expect(list[1].found.text).toBe('2 hours ago'); + expect(list[1].name).toBe('Optics'); + }); + + it('takes added, else removed, else changed as the kind, with the start–end sentence', () => { + const added = entries([change('added', null, null, event)], t, fmt)[0]; + expect([added.kind, added.summary, added.rows]).toEqual(['added', t.changes.addedAt(fmt.range(event.start, event.end)), []]); + const removed = entries([change('removed', null, event, null)], t, fmt)[0]; + expect([removed.kind, removed.summary]).toEqual(['removed', t.changes.removed]); + expect(entries([change('changed', 'name', 'a', 'b')], t, fmt)[0].kind).toBe('changed'); + }); + + // T028: outcomes stay per row; the entry takes the mildest. + it('keeps each row’s outcome and gives the entry the mildest', () => { + const at = { detectedAt: '2026-11-12T09:00:00Z' }; + const mixed = entries( + [change('changed', 'name', 'a', 'b', { ...at, outcome: 'hidden' }), change('changed', 'type', 'a', 'b', { ...at, outcome: 'lowPriority' })], + t, + fmt + )[0]; + expect(mixed.rows.map((r) => r.outcome)).toEqual(['hidden', 'lowPriority']); + expect(mixed.outcomes).toEqual(['hidden', 'lowPriority']); + expect(mixed.outcome).toBe('lowPriority'); + const withNormal = entries([change('changed', 'name', 'a', 'b', { ...at, outcome: 'hidden' }), change('changed', 'type', 'a', 'b', at)], t, fmt)[0]; + expect(withNormal.outcome).toBe('normal'); + const allHidden = entries([change('changed', 'name', 'a', 'b', { ...at, outcome: 'hidden' }), change('changed', 'type', 'a', 'b', { ...at, outcome: 'hidden' })], t, fmt)[0]; + expect(allHidden.outcome).toBe('hidden'); + }); +}); + +describe('row (research R6)', () => { + it('shows only the hours for a same-day time change, with the date once', () => { + const r = row(change('changed', 'time', { start: '2027-01-06T12:30:00Z', end: '2027-01-06T14:00:00Z' }, { start: '2027-01-06T08:00:00Z', end: '2027-01-06T09:30:00Z' }), t, fmt); + expect([r.label, r.before, r.after, r.note, r.wrap]).toEqual(['Time', '13:30–15:00', '09:00–10:30', 'Same day: Wed 6 Jan 2027', false]); + }); + + it('shows the dates when the day moved, with the year only for another year', () => { + const r = row(change('changed', 'time', { start: '2026-12-01T12:30:00Z', end: '2026-12-01T14:00:00Z' }, { start: '2026-12-02T08:00:00Z', end: '2026-12-02T09:30:00Z' }), t, fmt); + expect([r.before, r.after, r.note]).toEqual(['Tue 1 Dec 13:30–15:00', 'Wed 2 Dec 09:00–10:30', null]); + const next = row(change('changed', 'time', { start: '2026-12-31T12:30:00Z', end: '2026-12-31T14:00:00Z' }, { start: '2027-01-04T08:00:00Z', end: '2027-01-04T09:30:00Z' }), t, fmt); + expect(next.before).not.toContain('2026'); + expect(next.after).toContain('2027'); + }); + + it('joins sets, prints none for empty values, and words status and type', () => { + expect(row(change('changed', 'teachers', ['A'], ['A', 'B']), t, fmt)).toMatchObject({ label: 'Teachers', before: 'A', after: 'A, B' }); + expect(row(change('changed', 'groups', ['3B'], []), t, fmt)).toMatchObject({ label: 'Groups', before: '3B', after: t.changes.none }); + expect(row(change('changed', 'location', 'B2.14', null), t, fmt)).toMatchObject({ label: 'Location', after: t.changes.none }); + expect(row(change('changed', 'status', 'tentative', 'cancelled'), t, fmt)).toMatchObject({ before: 'tentative', after: 'cancelled' }); + expect(row(change('changed', 'status', 'tentative', 'cancelled'), dicts.nl, fmt)).toMatchObject({ label: 'Status', before: 'voorlopig', after: 'vervallen' }); + expect(row(change('changed', 'type', 'lesson', 'test'), t, fmt)).toMatchObject({ label: 'Type', before: 'lesson', after: 'test' }); + }); + + it('lets free text wrap and keeps detail labels as they are', () => { + expect(row(change('changed', 'name', 'a', 'b'), t, fmt).wrap).toBe(true); + expect(row(change('changed', 'description', 'a', 'b'), t, fmt).wrap).toBe(true); + expect(row(change('changed', 'details.Opmerking', null, 'Bring a laptop'), t, fmt)).toMatchObject({ + label: 'Opmerking', + before: t.changes.none, + after: 'Bring a laptop', + wrap: true + }); + }); +}); + +describe('build', () => { + const data = (more: Partial = {}): HistoryData => ({ + changeId: 1, + calendarId: 12, + calendarName: 'Class 3B', + current: { + state: 'present', + event: { ...event, fields: { location: 'B2.14', rooms: ['B2.14', 'B2.15'], teachers: ['ABC'], groups: ['3B'], type: 'lesson', details: { Lecturer: 'Jansen' } } }, + removedAt: null + }, + historyStartsAt: '2026-03-02T07:15:00Z', + retentionDays: 365, + items: [change('changed', 'name', 'a', 'b')], + ...more + }); + + it('shapes the current details as a calendar event', () => { + const h = build(data(), t, fmt); + expect(h.name).toBe('Optics'); + expect(h.current.tile).toEqual({ month: 'Jan', day: '6', weekday: 'Wed', full: 'Wed 6 Jan 2027' }); + expect([h.current.when, h.current.location, h.current.people]).toEqual(['09:00–10:30', 'B2.14', ['ABC', '3B', 'lesson']]); + expect(h.current.more).toEqual([ + { label: 'Status', value: 'confirmed' }, + { label: 'Rooms', value: 'B2.14, B2.15' }, + { label: 'Lecturer', value: 'Jansen' }, + { label: 'Timetable', value: 'Class 3B' } + ]); + expect([h.historyStart, h.retention, h.calendarName]).toEqual(['Mon 2 Mar', '1 year', 'Class 3B']); + }); + + it('shortens long lists in the summary and moves the full lists to More details', () => { + const rooms = ['R1', 'R2', 'R3', 'R4', 'R5']; + const groups = Array.from({ length: 20 }, (_, i) => `G${i + 1}`); + const h = build(data({ current: { state: 'present', event: { ...event, fields: { rooms, teachers: ['A', 'B', 'C', 'D'], groups } }, removedAt: null } }), t, fmt); + // Four items stay whole: "and 1 more" would save nothing. + expect([h.current.location, h.current.people]).toEqual(['R1, R2, R3 and 2 more', ['A, B, C, D', 'G1, G2, G3 and 17 more']]); + expect(h.current.more.slice(1, 3)).toEqual([ + { label: 'Rooms', value: rooms.join(', ') }, + { label: 'Groups', value: groups.join(', ') } + ]); + }); + + it('gives the removal time only for a removed lesson, and falls back to the entry name without an event', () => { + const removed = build(data({ current: { state: 'removed', event, removedAt: '2026-11-12T11:00:00Z' } }), t, fmt); + expect(removed.current.removedAt?.text).toBe('1 hour ago'); + const gone = build(data({ current: { state: 'gone', event: null, removedAt: null }, historyStartsAt: null }), t, fmt); + expect([gone.current.state, gone.current.removedAt, gone.current.tile, gone.name, gone.historyStart]).toEqual(['gone', null, null, 'Optics', null]); + }); +}); diff --git a/frontend/src/lib/server/history.ts b/frontend/src/lib/server/history.ts new file mode 100644 index 0000000..13721c6 --- /dev/null +++ b/frontend/src/lib/server/history.ts @@ -0,0 +1,150 @@ +import type { RequestEvent } from '@sveltejs/kit'; +import type { Dict } from '$lib/i18n'; +import { mildest, type CurrentDetails, type HistoryEntry, type HistoryPage, type HistoryRow } from '$lib/history'; +import { api, must } from './api'; +import { describe, list, span, statusWord, text, typeWord, type Change } from './changes'; +import { formatter, type Formatter } from './format'; + +// The history of one lesson (010): GET /api/me/changes/{id}/history, built into entries and texts on the server like +// the feed. The toggle and cap run in the component (lib/history.ts). + +type EventObject = { name?: string; start?: string; end?: string; status?: string | null; fields?: Record }; +export type HistoryData = { + changeId: number; + calendarId: number; + calendarName: string; + current: { state: 'present' | 'removed' | 'gone'; event: EventObject | null; removedAt: string | null }; + historyStartsAt: string | null; + retentionDays: number; + /** Newest first. */ + items: Change[]; +}; + +const joined = (v: unknown, t: Dict) => (Array.isArray(v) ? (v.length ? list(v).join(', ') : t.changes.none) : text(v, t)); +const nonEmpty = (v: unknown) => !(v === null || v === undefined || v === '' || (Array.isArray(v) && v.length === 0)); + +/** One row of a changed entry (research R6). */ +export function row(c: Change, t: Dict, fmt: Formatter): HistoryRow { + const field = c.field ?? ''; + const labels = t.history.labels; + const base = { note: null, wrap: false, outcome: c.outcome }; + switch (field) { + case 'time': { + const [a, b] = [span(c.oldValue), span(c.newValue)]; + if (!(a.start && a.end && b.start && b.end)) return { ...base, label: labels.time, before: t.changes.none, after: t.changes.none }; + const days = [a.start, a.end, b.start, b.end].map(fmt.date); + if (days.every((d) => d === days[0])) + return { + ...base, + label: labels.time, + before: `${fmt.time(a.start)}–${fmt.time(a.end)}`, + after: `${fmt.time(b.start)}–${fmt.time(b.end)}`, + note: t.history.sameDay(days[0]) + }; + return { ...base, label: labels.time, before: fmt.range(a.start, a.end), after: fmt.range(b.start, b.end) }; + } + case 'status': + return { ...base, label: labels.status, before: statusWord(c.oldValue, t), after: statusWord(c.newValue, t) }; + case 'type': + return { ...base, label: labels.type, before: typeWord(c.oldValue, t), after: typeWord(c.newValue, t) }; + case 'rooms': + case 'teachers': + case 'groups': + case 'location': + return { ...base, label: labels[field], before: joined(c.oldValue, t), after: joined(c.newValue, t) }; + case 'name': + case 'description': + return { ...base, wrap: true, label: labels[field], before: text(c.oldValue, t), after: text(c.newValue, t) }; + } + const label = field.startsWith('details.') ? field.slice('details.'.length) : field; + return { ...base, wrap: true, label, before: joined(c.oldValue, t), after: joined(c.newValue, t) }; +} + +/** Rows found in one check form one entry, rows in id order, entries newest first (research R6). */ +export function entries(items: Change[], t: Dict, fmt: Formatter): HistoryEntry[] { + const byCheck = new Map(); + for (const c of items) byCheck.set(c.detectedAt, [...(byCheck.get(c.detectedAt) ?? []), c]); + return [...byCheck.values()] + .map((rows) => rows.toSorted((a, b) => a.id - b.id)) + .map((rows): HistoryEntry => { + const added = rows.find((c) => c.kind === 'added'); + const removed = rows.find((c) => c.kind === 'removed'); + const kind = added ? 'added' : removed ? 'removed' : 'changed'; + const outcomes = rows.map((c) => c.outcome); + return { + key: `${rows[0].detectedAt} ${rows[0].id}`, + ids: rows.map((c) => c.id), + outcomes, + kind, + found: fmt.found(rows[0].detectedAt), + name: rows[0].eventName, + outcome: mildest(outcomes), + summary: added ? describe(added, t, fmt) : removed ? describe(removed, t, fmt) : null, + rows: kind === 'changed' ? rows.map((c) => row(c, t, fmt)) : [] + }; + }) + .toSorted((a, b) => Math.max(...b.ids) - Math.max(...a.ids)); +} + +// A list longer than this plus one shows its first items and "and N more" (never "and 1 more"). +const SUMMARY_ITEMS = 3; + +/** The lesson as a calendar event (FR-007, FR-008, contracts/pages.md § Current details). */ +export function current(data: HistoryData, fallbackName: string, t: Dict, fmt: Formatter): CurrentDetails { + const e = data.current.event ?? {}; + const f = e.fields ?? {}; + const labels = t.history.labels; + // Holidays list every room and class: the summary keeps the first few, More details has them all. + const short = (v: unknown) => { + const items = list(v); + return items.length > SUMMARY_ITEMS + 1 ? t.history.andMore(items.slice(0, SUMMARY_ITEMS).join(', '), items.length - SUMMARY_ITEMS) : items.join(', '); + }; + const isShort = (v: unknown) => list(v).length > SUMMARY_ITEMS + 1; + const location = nonEmpty(f.location) ? text(f.location, t) : nonEmpty(f.rooms) ? short(f.rooms) : null; + const when = e.start && e.end ? (fmt.date(e.start) === fmt.date(e.end) ? `${fmt.time(e.start)}–${fmt.time(e.end)}` : fmt.range(e.start, e.end)) : null; + const people = [ + nonEmpty(f.teachers) ? short(f.teachers) : null, + nonEmpty(f.groups) ? short(f.groups) : null, + nonEmpty(f.type) ? typeWord(f.type, t) : null + ].filter((x): x is string => x !== null); + const more: { label: string; value: string }[] = []; + if (nonEmpty(e.status)) more.push({ label: labels.status, value: statusWord(e.status, t) }); + if (nonEmpty(f.description)) more.push({ label: labels.description, value: text(f.description, t) }); + if (nonEmpty(f.rooms) && list(f.rooms).join(', ') !== location) more.push({ label: labels.rooms, value: list(f.rooms).join(', ') }); + if (nonEmpty(f.teachers) && isShort(f.teachers)) more.push({ label: labels.teachers, value: list(f.teachers).join(', ') }); + if (nonEmpty(f.groups) && isShort(f.groups)) more.push({ label: labels.groups, value: list(f.groups).join(', ') }); + const details = f.details && typeof f.details === 'object' ? (f.details as Record) : {}; + for (const [label, value] of Object.entries(details)) if (nonEmpty(value)) more.push({ label, value: joined(value, t) }); + more.push({ label: labels.calendar, value: data.calendarName }); + return { + state: data.current.state, + name: e.name ?? fallbackName, + tile: e.start ? fmt.tile(e.start) : null, + when, + location, + people, + more, + removedAt: data.current.state === 'removed' && data.current.removedAt ? fmt.found(data.current.removedAt) : null + }; +} + +export function build(data: HistoryData, t: Dict, fmt: Formatter): HistoryPage { + const list = entries(data.items, t, fmt); + const cur = current(data, list[0]?.name ?? '', t, fmt); + return { + changeId: data.changeId, + calendarName: data.calendarName, + name: cur.name, + current: cur, + entries: list, + historyStart: data.historyStartsAt ? fmt.date(data.historyStartsAt) : null, + retention: fmt.retention(data.retentionDays) + }; +} + +/** The history for a page or card. A 404 is the same "not found" page for every cause (FR-016). */ +export async function loadHistory(event: RequestEvent, id: string): Promise { + const { t, lang, tz } = event.locals; + const data = must(event, await api(event, 'GET', `/api/me/changes/${id}/history`)); + return build(data, t, formatter(lang, tz)); +} diff --git a/frontend/src/routes/changes/[id]/+page.server.ts b/frontend/src/routes/changes/[id]/+page.server.ts new file mode 100644 index 0000000..e0ffc2e --- /dev/null +++ b/frontend/src/routes/changes/[id]/+page.server.ts @@ -0,0 +1,12 @@ +import { error } from '@sveltejs/kit'; +import { parseAll, parseId } from '$lib/history'; +import { loadHistory } from '$lib/server/history'; + +// The full history page of the lesson of change {id} (010 contracts/pages.md § Full page). An expired session goes to +// sign-in with `next` set to this address through api(); every 404 cause shows the same page (FR-016). +export const load = async (event) => { + const id = parseId(event.params.id); + if (id === null) error(404, { kind: 'notFound', message: event.locals.t.errors.notFoundTitle }); + const all = parseAll(event.url.searchParams.get('all')); + return { history: await loadHistory(event, id), openedId: Number(id), all }; +}; diff --git a/frontend/src/routes/changes/[id]/+page.svelte b/frontend/src/routes/changes/[id]/+page.svelte new file mode 100644 index 0000000..5a6f54a --- /dev/null +++ b/frontend/src/routes/changes/[id]/+page.svelte @@ -0,0 +1,35 @@ + + +{data.history.name} · {t.history.title} · InTheKnow + + +
+ +
+ +
+
+ + diff --git a/frontend/src/routes/changes/[id]/page.server.test.ts b/frontend/src/routes/changes/[id]/page.server.test.ts new file mode 100644 index 0000000..332e5a6 --- /dev/null +++ b/frontend/src/routes/changes/[id]/page.server.test.ts @@ -0,0 +1,60 @@ +import { isHttpError, isRedirect, type RequestEvent } from '@sveltejs/kit'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { fakeEvent, jsonResponse } from '$lib/server/fake'; +import { load } from './+page.server'; + +// 010 contracts/pages.md § Addresses: the id and `all` rules, and one "not found" for every cause (FR-016). +const fetchMock = vi.fn(); +beforeEach(() => { + vi.stubGlobal('fetch', fetchMock); + fetchMock.mockReset(); + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); +afterEach(() => vi.restoreAllMocks()); + +const run = (id: string, query = '') => { + const { event } = fakeEvent({ url: `/changes/${id}${query}`, params: { id }, cookies: { itk_session: 'T' } }); + return (load as unknown as (e: RequestEvent) => Promise<{ openedId: number; all: boolean | null }>)(event); +}; +const body = { + changeId: 7, + calendarId: 1, + calendarName: 'Class 3B', + current: { state: 'gone', event: null, removedAt: null }, + historyStartsAt: null, + retentionDays: 365, + items: [] +}; + +describe('/changes/[id] load', () => { + it('gives the not-found page for an id that is not a positive digit string, without asking the API', async () => { + for (const bad of ['0', '007', 'abc', '1e3', '-4']) { + const thrown = await run(bad).catch((e) => e); + expect(isHttpError(thrown) && thrown.status).toBe(404); + expect(thrown.body.kind).toBe('notFound'); + } + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('reads all as 0 or 1 only', async () => { + for (const [query, all] of [['?all=1', true], ['?all=0', false], ['?all=yes', null], ['', null]] as const) { + fetchMock.mockResolvedValueOnce(jsonResponse(200, body)); + const data = await run('7', query); + expect([data.openedId, data.all]).toEqual([7, all]); + } + expect(fetchMock.mock.calls[0][0]).toMatch(/\/api\/me\/changes\/7\/history$/); + }); + + it('shows the same not-found page for every API 404', async () => { + fetchMock.mockResolvedValue(jsonResponse(404, { status: 404, reason: 'not_found' })); + const thrown = await run('7').catch((e) => e); + expect(isHttpError(thrown) && thrown.status).toBe(404); + expect(thrown.body).toEqual({ kind: 'notFound', message: (await run('8').catch((e) => e)).body.message }); + }); + + it('sends an expired session to sign-in with this address as next', async () => { + fetchMock.mockResolvedValue(jsonResponse(401, { status: 401, reason: 'not_signed_in' })); + const thrown = await run('7', '?all=1').catch((e) => e); + expect(isRedirect(thrown) && thrown.location).toBe('/sign-in?next=%2Fchanges%2F7%3Fall%3D1'); + }); +}); 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..199f24b --- /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. 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..46aa554 --- /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`, judged over all kept entries before the toggle filter (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..f9e41e0 --- /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 `