feat(web): catch-up card, Mark caught up, and See everything into Activity (TASK-3615, PR B) (#2226)

* wip(web): catch-up card on the workspace home, Mark caught up, and See everything into Activity (TASK-3615, PR B)

The card renders GET /catch-up above Active Work, fenced on identity and
keyed on (user, workspace). Mark caught up PUTs /me/seen and refetches.
See everything opens Activity in Audit with ?since= set to the window's
start, so the activity endpoint's since now also accepts an RFC3339
instant (shape item 5, moved from PR A to PR B by the lead's ruling).

Claude-Session: https://claude.ai/code/session_011qEFJW3TjuR5tnGGG1cv2G

* test(e2e): catch-up card flow against the real binary (TASK-3615, PR B)

Claude-Session: https://claude.ai/code/session_011qEFJW3TjuR5tnGGG1cv2G

* test(e2e): register the catch-up teammate with the admin session, not its token (TASK-3615, PR B)

Claude-Session: https://claude.ai/code/session_011qEFJW3TjuR5tnGGG1cv2G

* fix(web): catch-up r1 fixes: since validation matches Go, loads are ordered, a failed refetch keeps the card, a mounted Activity page applies a linked view (TASK-3615, PR B)

- sinceFromUrl range-checks the fields Go's time.Parse checks instead of
  trusting Date.parse, which rolls 2026-02-30 over and accepts T24:00:00
  (and refused offsets Go takes, up to +-24:60). The table's verdicts are
  pasted from go1.26.
- CatchUpCard numbers its loads, so A->B->A cannot let the first A answer
  paint over the second.
- A mark that lands but whose refetch fails keeps the card and says the mark
  worked, instead of clearing it.
- The Activity page applies a changed ?view= on a mounted page, as it
  already did for ?since=, still without persisting it.
- The e2e waits for a new second before Mark, since the window floor is
  inclusive at second precision.

Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW

* fix(web): clearing the since chip or Clear filters drops since from the URL (TASK-3615, PR B)

Clearing only filterSince left ?since= in the URL, so a reload re-applied a
cleared filter, and re-following the same link changed no URL value, so the
URL effect never re-ran. Both clear paths now navigate (replacing history)
to the URL without since. The shallow replaceState would leave page.url,
and so the derived since, stale. The route has no load function, so the
navigation re-fetches nothing.

Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW

* fix(web): a clear whose navigation rejects still drops since from the address bar; the e2e reload leg asserts on the request (TASK-3615, PR B)

r3 findings. clearSince falls back to the shallow replaceState when its goto
rejects, so a reload stays cleared. A superseded goto needs nothing, since
the newer navigation decides the URL. The e2e reload leg now checks the
reloaded feed request carries no since, instead of a chip absence that
passes before a late effect could add one.

Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW

* fix(web): choosing Live or Audit drops view from the URL, so a reload keeps the choice (TASK-3615, PR B)

r4 Medium, the same class as the since fix: a Live or Audit click cleared
viewFromUrl but left ?view= in the URL, so a reload or a revisit re-applied
the linked view over the user's choice. The page reads two URL values, since
and view, and every path that clears or overrides either now drops it from
the URL through one helper.

Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW

* fix(server): refuse an activity since whose UTC instant passes year 9999 (TASK-3615, PR B)

r5 finding. An offset can carry 9999-12-31 into year 10000, and the store
compares the cutoff as RFC3339 text, so "10000-..." sorted below every row
and opened the window to everything. The handler now refuses it with 400
invalid_since, and sinceFromUrl mirrors the bound so such a link loads the
unfiltered feed instead of an error. The new rows' verdicts come from a Go
probe of the handler's condition. docs/api.md says so.

Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW

* test(web): census entry for CatchUpCard's keyed sections each (TASK-3615, PR B)

eachKeyCensus.test.ts requires every keyed each to name why its keys cannot
repeat. CatchUpCard's sections come from a four-entry literal with distinct
ids, only filtered.

Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
This commit is contained in:
xarmian
2026-10-18 14:42:40 -04:00
committed by GitHub
parent 9dc679e786
commit ed559deb4d
14 changed files with 1318 additions and 9 deletions
+1 -1
View File
@@ -76,7 +76,7 @@ REST API at `/api/v1/`. Key endpoints:
- `GET /workspaces/{ws}/catch-up` — the caller's "catch me up" digest (TASK-3615). Returns `{window: {since, until, has_cursor, capped_at_ceiling}, needs_you, finished, new_and_reopened, comments, agents, degraded?}`. Each section is `{total, lines: [{ref, title, collection, what, actor?, agent?, at?}]}` with at most 5 lines; `agents` is `{total, groups: [{agent, changes, items, lines}]}`. The window runs from `max(cursor, now - 14 days)` to now, compared at second precision with an INCLUSIVE lower bound (an event in the same second as the cursor is shown again rather than hidden). With no cursor the window is the 14-day ceiling; the GET never writes one. The caller's own actions are left out: `finished` and the reopens in `new_and_reopened` read their actor from the activity row of the same item and second, and are dropped only when every such row is the caller's (no row, or another person's, keeps the line). Agent work is grouped by agent name and keeps the caller's own agents. `needs_you` is current state: attention-set `needs_human` items, then open, non-reference items assigned to the caller that a visible, unfinished item blocks; a failing decision provider returns `degraded: ["needs_you"]`. Every line and total is limited to what the caller may see (collection access with the app-token ceiling, and item grants). 401 when signed out
- `PUT /workspaces/{ws}/me/seen` — mark the workspace caught up: sets the caller's cursor to now and returns `{seen_at}`. No body (a client-supplied time could hide unseen work), and the cursor never moves backwards. Per user and workspace; any member may call it
- `GET /workspaces/{ws}/report` — the windowed Insights report: `window` (day | week | 2wk | month), `offset` (periods back), `collections` (slugs), `include_items`, and since TASK-3524 `tz` (an IANA zone such as `America/Los_Angeles`). With `tz` the throughput buckets are the viewer's local days (local hours in the day window), keyed in that zone, and the response echoes `tz`; without it they are UTC, byte for byte as before, which is what the CLI and MCP get. An unknown zone, or `Local` (the server's zone), answers 400 `invalid_tz`. The binary embeds the zone database, so this works on hosts without one. Nothing caches the response; a cache added later must key on `tz` with the other parameters and the caller's visibility scope
- `GET /workspaces/{ws}/activity` — workspace activity feed (enriched with item titles + change details). Filters `action`, `actor` (`user` | `agent`), `source`, `since` (YYYY-MM-DD) and, since TASK-2219, `collection` (a collection slug: activity on items currently in that collection). All are applied in the store query before `limit`, so every page is full and the `before`/`before_id` keyset cursor walks the filtered feed. A `collection` that names no live collection, or one the caller cannot see, answers `[]`, not 404, so it is not an existence oracle
- `GET /workspaces/{ws}/activity` — workspace activity feed (enriched with item titles + change details). Filters `action`, `actor` (`user` | `agent`), `source`, `since` (YYYY-MM-DD, or since TASK-3615 an RFC3339 instant, normalised to UTC at second precision with the fraction dropped, so the floor rounds down; an instant past year 9999 in UTC is refused `400 invalid_since`) and, since TASK-2219, `collection` (a collection slug: activity on items currently in that collection). All are applied in the store query before `limit`, so every page is full and the `before`/`before_id` keyset cursor walks the filtered feed. A `collection` that names no live collection, or one the caller cannot see, answers `[]`, not 404, so it is not an existence oracle
- `GET/POST/DELETE /workspaces/{ws}/webhooks` — webhook management
- `PATCH/DELETE /workspaces/{ws}/items/{slug}/comments/{id}` — comment edit/delete addressed through its item (TASK-2695): 404 unless the comment is on that item, then the same handler, ACL and events as `/workspaces/{ws}/comments/{id}` (edit author-only, delete item-edit). A delete of a comment that still has replies leaves a TOMBSTONE on either route (BUG-3252, 204 like any delete): the body is emptied and the reactions removed, author and timestamps are kept, the comment serialises with `deleted: true`, and its replies keep their parent. An edit, reply or reaction addressed to a tombstone answers 409 `comment_deleted`, deleting it again answers 404, and deleting its last reply removes it in the same transaction. Tombstones are left out of the decision trail and the workspace export. A server before the tombstone answered 409 `comment_has_replies` instead, and the CLI and MCP still classify that code. The CLI and every MCP transport use this form; `GET /server/capabilities` advertises it as `item_scoped_comment_writes`. Every serialised comment carries a derived `edited` bool
- `GET /workspaces/{ws}/changes?since=<unix_ms>` — the web sync delta: items updated at or after `since` (archived ones included; their ids are also in `deleted`), with full bodies, and `server_time`, the next `since`. **Bounded and paged by a cursor** (BUG-3791). `limit` defaults to, and is capped at, 1000, as on the item lists. When more remain the body says `has_more: true` with `next_cursor`; ask again with `?cursor=<next_cursor>` alone (a `since` beside it must equal the walk's). The cursor is a position in the workspace's write order (`seq`, then id), so a change committed during the walk comes back in a later page, and a row can appear twice. Every page repeats the FIRST page's `server_time`, which is a safe next `since` only once a page says `has_more: false`. Visibility is applied before the window, so a page's count and `has_more` never reflect rows the caller cannot see. **Contract change:** a client that ignores `has_more` gets the first 1000 changes of a longer delta and loses the rest when it advances to `server_time`; the web client (`walkChanges`) walks every page, and fails to a full refresh past 50
+11 -1
View File
@@ -43,10 +43,20 @@ func (s *Server) handleListWorkspaceActivity(w http.ResponseWriter, r *http.Requ
// since=YYYY-MM-DD restricts to activity on or after that date. Applied
// in the store query so LIMIT counts post-filter rows. Backs
// `pad project activity --since` and the pad_project.activity MCP action.
// An RFC3339 instant is accepted too (TASK-3615): the catch-up card's
// "See everything" carries its exact window. The store normalises it to
// UTC at second precision, so a fraction rounds the floor down and an
// offset is converted, never read as a UTC wall time.
if sinceStr := r.URL.Query().Get("since"); sinceStr != "" {
parsed, err := time.Parse("2006-01-02", sinceStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_since", "`since` must be a date in YYYY-MM-DD format")
parsed, err = time.Parse(time.RFC3339, sinceStr)
}
// The store compares the cutoff as RFC3339 text, so a UTC instant past
// year 9999 ("10000-…") would sort below every row and open the window
// to everything. An offset can carry 9999-12-31 there (TASK-3615 PR B r5).
if err != nil || parsed.UTC().Year() > 9999 {
writeError(w, http.StatusBadRequest, "invalid_since", "`since` must be a date in YYYY-MM-DD format or an RFC3339 timestamp")
return
}
params.Since = parsed
@@ -0,0 +1,97 @@
package server
import (
"fmt"
"net/http"
"net/url"
"sort"
"strings"
"testing"
"github.com/PerpetualSoftware/pad/internal/models"
)
// TASK-3615 PR B: the activity feed's `since` accepts an RFC3339 instant as
// well as a date, so the catch-up card's "See everything" can open its exact
// window. Three rows are pinned 30 seconds apart. Every leg names the rows it
// expects, and a control leg one second later shows the filter actually bites.
func TestTASK3615_ActivitySinceAcceptsRFC3339(t *testing.T) {
srv := testServer(t)
f := newTask3615Fixture(t, srv)
a := f.create(t, f.aliceTok, "tasks", "since A")
b := f.create(t, f.aliceTok, "tasks", "since B")
c := f.create(t, f.aliceTok, "tasks", "since C")
for it, ts := range map[string]string{
a.ID: "2026-10-01T10:00:00Z",
b.ID: "2026-10-01T10:00:30Z",
c.ID: "2026-10-01T10:01:00Z",
} {
if _, err := srv.store.DB().Exec(fmt.Sprintf("UPDATE activities SET created_at = '%s' WHERE document_id = '%s'", ts, it)); err != nil {
t.Fatalf("pin activity: %v", err)
}
}
name := map[string]string{a.ID: "A", b.ID: "B", c.ID: "C"}
feed := func(t *testing.T, since string) (int, string) {
t.Helper()
rr := doRequestWithCookie(srv, "GET", "/api/v1/workspaces/"+f.ws.Slug+"/activity?since="+url.QueryEscape(since), nil, f.aliceTok)
if rr.Code != http.StatusOK {
return rr.Code, rr.Body.String()
}
var acts []models.Activity
parseJSON(t, rr, &acts)
seen := map[string]bool{}
for _, act := range acts {
if n, ok := name[act.DocumentID]; ok {
seen[n] = true
}
}
var got []string
for n := range seen {
got = append(got, n)
}
sort.Strings(got)
return rr.Code, strings.Join(got, ",")
}
for _, tc := range []struct{ leg, since, want string }{
{"date form unchanged", "2026-10-01", "A,B,C"},
{"Z instant", "2026-10-01T10:00:30Z", "B,C"},
// The same instant as the Z leg. Read as a UTC wall time, 12:00:30
// would be after every row and the answer would be empty.
{"offset instant is converted, not read as UTC", "2026-10-01T12:00:30+02:00", "B,C"},
// A fraction is dropped and the floor rounds DOWN, so B, stamped in
// that same second, is re-shown and never hidden.
{"fraction rounds the floor down", "2026-10-01T10:00:30.900Z", "B,C"},
{"control: one second later drops B", "2026-10-01T10:00:31Z", "C"},
} {
t.Run(tc.leg, func(t *testing.T) {
code, got := feed(t, tc.since)
if code != http.StatusOK || got != tc.want {
t.Fatalf("since=%s: code %d, got %q, want %q", tc.since, code, got, tc.want)
}
})
}
t.Run("garbage is refused 400 invalid_since", func(t *testing.T) {
code, body := feed(t, "yesterday")
if code != http.StatusBadRequest || !strings.Contains(body, "invalid_since") {
t.Fatalf("code %d body %s", code, body)
}
})
// An offset can carry the UTC instant into year 10000, whose cutoff text
// "10000-…" sorts below every row, so the window would open to everything
// (PR B r5). The control is the same wall time one hour earlier, which
// stays in 9999 and is accepted.
t.Run("a since whose UTC instant passes year 9999 is refused 400 invalid_since", func(t *testing.T) {
code, body := feed(t, "9999-12-31T23:59:59-01:00")
if code != http.StatusBadRequest || !strings.Contains(body, "invalid_since") {
t.Fatalf("code %d body %s", code, body)
}
if code, got := feed(t, "9999-12-31T22:59:59-01:00"); code != http.StatusOK || got != "" {
t.Fatalf("control: code %d, got %q, want 200 with no rows", code, got)
}
})
}
+136
View File
@@ -0,0 +1,136 @@
import { request as pwRequest } from '@playwright/test';
import { test, expect } from './fixtures';
import { browserLogin } from './lib/collab-helpers';
import { createWorkspace, deleteWorkspace } from './lib/attachment-viewer';
import type { APIRequestContext } from '@playwright/test';
import type { SuiteFixture } from './fixtures';
/**
* TASK-3615 PR B: the catch-up card on the workspace home, end to end against
* the real binary. The digest leaves out the viewer's own actions, so a
* TEAMMATE seeds the work: the admin then sees it under "New and reopened",
* follows See everything into Activity (Audit, since chip), comes back, marks
* caught up, and the card collapses and stays collapsed across a reload,
* because the cursor now lives on the server.
*/
const PASSWORD = 'Playwright-CatchUp-2026!';
function authJson(fixture: SuiteFixture) {
return { Authorization: `Bearer ${fixture.apiToken}`, 'Content-Type': 'application/json' };
}
async function ok(resp: Awaited<ReturnType<APIRequestContext['get']>>, what: string) {
if (!resp.ok()) throw new Error(`${what}: ${resp.status()} ${await resp.text()}`);
return resp;
}
/** Register a teammate, invite them to `ws`, accept as them, and return their bearer context. */
async function teammate(fixture: SuiteFixture, request: APIRequestContext, ws: string): Promise<APIRequestContext> {
const username = `catchup${Date.now()}`;
const email = `${username}@example.com`;
// Account creation is platform administration: the admin's SESSION, not
// its API token, which is refused 403 session_required (BUG-3361).
const admin = await pwRequest.newContext({
baseURL: fixture.baseURL,
extraHTTPHeaders: { Authorization: `Bearer ${fixture.adminSessionToken}` },
});
try {
await ok(
await admin.post('/api/v1/auth/register', {
data: { email, username, name: 'Catch-up Teammate', password: PASSWORD },
}),
'register teammate'
);
} finally {
await admin.dispose();
}
const session = await pwRequest.newContext({ baseURL: fixture.baseURL });
let token = '';
try {
await ok(await session.post('/api/v1/auth/login', { data: { email, password: PASSWORD } }), 'login teammate');
const csrf = (await session.storageState()).cookies.find((c) => c.name === 'pad_csrf' || c.name === '__Host-pad_csrf');
if (!csrf) throw new Error('teammate login issued no pad_csrf cookie');
const resp = await ok(
await session.post('/api/v1/auth/tokens', {
headers: { 'X-CSRF-Token': csrf.value },
data: { name: 'catch-up-e2e', expires_in: 1 },
}),
'teammate token'
);
token = ((await resp.json()) as { token: string }).token;
} finally {
await session.dispose();
}
await ok(
await request.post(`/api/v1/workspaces/${ws}/members/invite`, { headers: authJson(fixture), data: { email, role: 'editor' } }),
'invite teammate'
);
const mate = await pwRequest.newContext({ baseURL: fixture.baseURL, extraHTTPHeaders: { Authorization: `Bearer ${token}` } });
const list = (await (await ok(await mate.get('/api/v1/me/invitations'), 'list invitations')).json()) as {
invitations: { id: string; workspace_slug: string }[];
};
const inv = list.invitations.find((i) => i.workspace_slug === ws);
if (!inv) throw new Error(`no pending invitation to ${ws}`);
await ok(await mate.post(`/api/v1/me/invitations/${inv.id}/accept`), 'accept invitation');
return mate;
}
test.describe('catch-up card (TASK-3615)', () => {
test.beforeEach(({}, testInfo) => {
test.skip(testInfo.project.name !== 'desktop-chromium', 'one desktop browser is enough for the card flow');
});
test('a teammate’s new work shows, See everything opens the window in Audit, and Mark caught up sticks', async ({ page, fixture, request }) => {
const { slug: ws } = await createWorkspace(fixture, request, 'Catch up');
let mate: APIRequestContext | undefined;
try {
mate = await teammate(fixture, request, ws);
for (const title of ['Catch-up alpha', 'Catch-up beta']) {
await ok(await mate.post(`/api/v1/workspaces/${ws}/collections/tasks/items`, { data: { title } }), `create ${title}`);
}
// The window's floor is inclusive at second precision, so a mark in
// the same second as these writes would show them again. Server and
// browser share this box's clock (TASK-3615 PR B r1).
const madeInSecond = Math.floor(Date.now() / 1000);
await browserLogin(page);
await page.goto(`/${fixture.adminUsername}/${ws}`);
const card = page.getByTestId('catch-up-card');
await expect(card).toBeVisible();
const fresh = page.getByTestId('catch-up-section-new_and_reopened');
await expect(fresh).toContainText('Catch-up alpha');
await expect(fresh).toContainText('Catch-up beta');
await page.getByTestId('catch-up-see-everything').click();
await expect(page).toHaveURL(/\/activity\?since=.+&view=audit$/);
await expect(page.getByTestId('activity-since-chip')).toBeVisible();
await expect(page.getByRole('button', { name: 'Audit' })).toHaveClass(/selected/);
// Clearing the chip drops since from the URL, so a reload stays cleared.
await page.getByRole('button', { name: 'Remove the since filter' }).click();
await expect(page).not.toHaveURL(/since=/);
// Assert on the reloaded feed request itself: a chip's absence passes
// before a late effect could add it (PR B r3).
const reloadedFeed = page.waitForRequest((r) => /\/api\/v1\/workspaces\/[^/]+\/activity(\?|$)/.test(r.url()));
await page.reload();
expect(new URL((await reloadedFeed).url()).searchParams.has('since')).toBe(false);
await expect(page).not.toHaveURL(/since=/);
await expect(page.getByRole('button', { name: 'Audit' })).toBeVisible();
await expect(page.getByTestId('activity-since-chip')).toHaveCount(0);
await page.goto(`/${fixture.adminUsername}/${ws}`);
await expect.poll(() => Math.floor(Date.now() / 1000), { timeout: 3000 }).toBeGreaterThan(madeInSecond);
await page.getByTestId('catch-up-mark').click();
await expect(page.getByTestId('catch-up-caught-up')).toContainText("You're caught up · nothing new since");
await expect(page.getByTestId('catch-up-section-new_and_reopened')).toHaveCount(0);
await page.reload();
await expect(page.getByTestId('catch-up-caught-up')).toBeVisible();
await expect(page.getByTestId('catch-up-section-new_and_reopened')).toHaveCount(0);
} finally {
await mate?.dispose();
await deleteWorkspace(fixture, request, ws);
}
});
});
+51 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { ACTOR_OPTIONS, activityFilterParams, hasActivityFilters, actorBadgeTitle } from './activityFilters';
import { ACTOR_OPTIONS, activityFilterParams, hasActivityFilters, actorBadgeTitle, sinceFromUrl } from './activityFilters';
const none = { action: '', source: '', actor: '', collection: '' };
@@ -40,3 +40,53 @@ describe('actorBadgeTitle (audit C117)', () => {
expect(actorBadgeTitle('agent', 'Wren', true)).toBe('Wren (agent)');
});
});
describe('since (TASK-3615)', () => {
it('is sent when set and counts as a filter', () => {
expect(activityFilterParams({ ...none, since: '2026-10-17T18:40:00Z' })).toEqual({ since: '2026-10-17T18:40:00Z' });
expect(hasActivityFilters({ ...none, since: '2026-10-17T18:40:00Z' })).toBe(true);
expect(activityFilterParams({ ...none, since: '' })).toEqual({});
});
it('sinceFromUrl keeps only the forms the server accepts', () => {
for (const ok of ['2026-10-17', '2026-10-17T18:40:00Z', '2026-10-17T18:40:00.5Z', '2026-10-17T20:40:00+02:00']) {
expect(sinceFromUrl(ok)).toBe(ok);
}
for (const bad of [null, '', 'yesterday', '2026-10-17T18:40', '2026-10-17 18:40:00Z', '2026-13-45']) {
expect(sinceFromUrl(bad)).toBe('');
}
});
// Verdicts pasted from Go 1.26 (time.Parse "2006-01-02" then
// time.RFC3339, the handler's order), not derived from this code. Every
// "refused" row here is one Date.parse accepts or normalises, or a range
// edge (TASK-3615 PR B r1).
it.each([
['2026-02-30', false],
['2026-02-29', false],
['2026-04-31', false],
['1900-02-29', false],
['2024-02-29', true],
['2000-02-29', true],
['0000-02-29', true],
['2026-02-28T24:00:00Z', false],
['2026-02-28T23:59:60Z', false],
['2026-02-28T12:00:00+24:00', true],
['2026-02-28T12:00:00+24:60', true],
['2026-02-28T12:00:00-24:60', true],
['2026-02-28T12:00:00+05:60', true],
['2026-02-28T12:00:00+25:00', false],
['2026-02-28T12:00:00+24:61', false],
['2026-02-28T12:00:00+00:99', false],
['0000-01-01T00:00:00Z', true],
['9999-12-31T23:59:59Z', true],
['2026-02-28T12:00:00.123456789123Z', true],
// Go parses these; the handler refuses a UTC year past 9999 (PR B r5).
['9999-12-31T23:59:59-01:00', false],
['9999-12-30T23:30:00-24:60', false],
['9999-12-31T22:59:59-01:00', true],
['9999-12-31T23:59:59+01:00', true]
])('sinceFromUrl(%s) matches Go: accepted=%s', (raw, accepted) => {
expect(sinceFromUrl(raw)).toBe(accepted ? raw : '');
});
});
+45 -1
View File
@@ -14,6 +14,49 @@ export interface ActivityFilters {
actor: string;
/** A collection slug, or ''. */
collection: string;
/**
* A lower bound on the feed, or '' / absent (TASK-3615). Set from the
* catch-up card's "See everything" link, which carries its window's exact
* RFC3339 start; the server also takes a bare YYYY-MM-DD.
*/
since?: string;
}
const SINCE_FORM =
/^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|([+-])(\d{2}):(\d{2})))?$/;
function daysIn(year: number, month: number): number {
if (month === 2) return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0 ? 29 : 28;
return [4, 6, 9, 11].includes(month) ? 30 : 31;
}
/**
* The `since` a URL may set, or ''. Anything the server would refuse with
* 400 invalid_since is dropped here, so a hand-edited link shows the
* unfiltered feed instead of a load error.
*
* The ranges are Go's time.Parse ones (go1.26), checked by hand and never
* through Date.parse: V8 rolls 2026-02-30 over to March 2 and accepts
* T24:00:00, where Go refuses both. Go does accept offsets up to ±24:60
* (TASK-3615 PR B r1). The server also refuses an instant whose UTC year
* passes 9999, which an offset can reach from 9999-12-30 or -31 (PR B r5).
*/
export function sinceFromUrl(raw: string | null): string {
const m = raw ? SINCE_FORM.exec(raw) : null;
if (!raw || !m) return '';
const [year, month, day] = [Number(m[1]), Number(m[2]), Number(m[3])];
if (month < 1 || month > 12 || day < 1 || day > daysIn(year, month)) return '';
if (m[4] !== undefined) {
if (Number(m[4]) > 23 || Number(m[5]) > 59 || Number(m[6]) > 59) return '';
if (m[7] !== undefined) {
const [oh, om] = [Number(m[8]), Number(m[9])];
if (oh > 24 || om > 60) return '';
// UTC = local − offset, so a negative offset moves the instant later.
const utcMinutes = Number(m[4]) * 60 + Number(m[5]) - (m[7] === '+' ? 1 : -1) * (oh * 60 + om);
if (year === 9999 && month === 12 && day + Math.floor(utcMinutes / 1440) > 31) return '';
}
}
return raw;
}
export const ACTOR_OPTIONS: { value: string; label: string }[] = [
@@ -28,11 +71,12 @@ export function activityFilterParams(f: ActivityFilters): Record<string, string>
if (f.source) params.source = f.source;
if (f.actor) params.actor = f.actor;
if (f.collection) params.collection = f.collection;
if (f.since) params.since = f.since;
return params;
}
export function hasActivityFilters(f: ActivityFilters): boolean {
return !!(f.action || f.source || f.actor || f.collection);
return !!(f.action || f.source || f.actor || f.collection || f.since);
}
/**
+10
View File
@@ -1,5 +1,6 @@
import type {
BuiltinListEntry,
CatchUpResponse,
BuiltinStateResponse,
DecisionSettings,
MCPSettings,
@@ -2596,6 +2597,15 @@ export const api = {
request<DashboardResponse>(`/workspaces/${ws}/dashboard`)
},
// ── Catch-up digest (TASK-3615) ──────────────────────────────────────────
// `get` never moves the cursor; only `markCaughtUp` does, and it takes no
// body because the server stamps the time.
catchUp: {
get: (ws: string) => request<CatchUpResponse>(`/workspaces/${ws}/catch-up`),
markCaughtUp: (ws: string) =>
request<{ seen_at: string }>(`/workspaces/${ws}/me/seen`, { method: 'PUT' })
},
// ── Project intelligence: next / standup / changelog (PLAN-1888 / TASK-1894) ──
// Same data `pad project next|standup|changelog` computes — see the Go
// handlers in internal/server/handlers_project_intel.go.
@@ -0,0 +1,409 @@
<script lang="ts">
// "Since you were last here" (TASK-3615, design DOC-3616): the caller's
// catch-up digest on the workspace home. GET /catch-up never moves the
// cursor; only Mark caught up does, and the card then refetches.
//
// IDENTITY FENCE, the BUG-3084 family's shape: the digest is about ONE
// person, so a load that lands after a sign-in or sign-out must not paint
// the previous user's digest. The load effect is keyed on
// (sessionUserId, wsSlug) and drops the data before reloading (the
// BUG-3701 class: key it on the route's workspace), and every commit point
// checks both the identity epoch captured at entry and that the key is
// still the one the work started under.
//
// Lines and agent groups are keyed by POSITION, not by ref or name: a
// section is not de-duplicated by item on every path, and a repeated key
// throws in production builds and blanks the view (BUG-3538 / TASK-3539).
// The lists are replaced whole on every load, so a position key costs
// nothing.
//
// LOAD ORDER: the key alone cannot order loads, since A→B→A returns to a
// key an older A load still holds. Each load takes a sequence number and
// only the newest may commit (TASK-3615 PR B r1).
import { untrack } from 'svelte';
import { api } from '$lib/api/client';
import { authStore } from '$lib/stores/auth.svelte';
import { relativeTime } from '$lib/utils/markdown';
import type { CatchUpLine, CatchUpResponse, CatchUpSection } from '$lib/types';
interface Props {
username: string;
wsSlug: string;
}
let { username, wsSlug }: Props = $props();
let sessionUserId = $derived(authStore.userId);
const key = $derived(`${sessionUserId}\n${wsSlug}`);
let data = $state<CatchUpResponse | null>(null);
let marking = $state(false);
let markError = $state(false);
// The mark landed but the refetch after it failed: the cursor moved, so
// "couldn't mark" would be false, and clearing the card would hide that
// the press worked.
let refreshError = $state(false);
let loadSeq = 0;
function captureIdentity(): number {
return authStore.identityEpoch;
}
function stillCurrent(captured: number, startedKey: string): boolean {
return authStore.identityEpoch === captured && key === startedKey;
}
// A failed load clears the card unless keepOnError (the refetch after a
// mark). A superseded one changes nothing.
async function load(
ws: string,
startedKey: string,
captured: number,
keepOnError = false
): Promise<'committed' | 'failed' | 'superseded'> {
const seq = ++loadSeq;
const committable = () => seq === loadSeq && stillCurrent(captured, startedKey);
try {
const res = await api.catchUp.get(ws);
if (!committable()) return 'superseded';
data = res;
return 'committed';
} catch {
// The card is optional: a failed load leaves the home page as it was
// and never shows an error over it. The next key change retries.
if (!committable()) return 'superseded';
if (!keepOnError) data = null;
return 'failed';
}
}
$effect(() => {
const k = key;
const ws = wsSlug;
data = null;
marking = false;
markError = false;
refreshError = false;
if (!ws || !sessionUserId) return;
untrack(() => void load(ws, k, captureIdentity()));
});
async function markCaughtUp() {
const captured = captureIdentity();
const startedKey = key;
const ws = wsSlug;
marking = true;
markError = false;
refreshError = false;
try {
await api.catchUp.markCaughtUp(ws);
} catch {
if (stillCurrent(captured, startedKey)) {
markError = true;
marking = false;
}
return;
}
try {
if (!stillCurrent(captured, startedKey)) return;
if ((await load(ws, startedKey, captured, true)) === 'failed') refreshError = true;
} finally {
if (stillCurrent(captured, startedKey)) marking = false;
}
}
const sections = $derived.by(() => {
if (!data) return [];
const list: { id: string; label: string; section: CatchUpSection; window: boolean }[] = [
{ id: 'needs_you', label: 'Needs you', section: data.needs_you, window: false },
{ id: 'finished', label: 'Finished', section: data.finished, window: true },
{ id: 'new_and_reopened', label: 'New and reopened', section: data.new_and_reopened, window: true },
{ id: 'comments', label: 'Comments', section: data.comments, window: true }
];
return list.filter((s) => s.section.total > 0);
});
const needsYouDegraded = $derived(data?.degraded?.includes('needs_you') ?? false);
const empty = $derived(!!data && sections.length === 0 && data.agents.total === 0 && !needsYouDegraded);
const seeEverything = $derived(
data
? `/${username}/${wsSlug}/activity?since=${encodeURIComponent(data.window.since)}&view=audit`
: ''
);
function clock(iso: string): string {
const d = new Date(iso);
const sameDay = d.toDateString() === new Date().toDateString();
return sameDay
? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
: d.toLocaleString([], { weekday: 'long', hour: '2-digit', minute: '2-digit' });
}
const heading = $derived.by(() => {
if (!data) return '';
if (!data.window.has_cursor) return 'In the last 14 days';
if (data.window.capped_at_ceiling) return 'Since you were last here · the last 14 days';
return `Since you were last here · ${clock(data.window.since)}`;
});
const caughtUpLine = $derived.by(() => {
if (!data) return '';
if (!data.window.has_cursor) return "You're caught up · nothing new in the last 14 days";
return `You're caught up · nothing new since ${clock(data.window.since)}`;
});
function meta(line: CatchUpLine): string {
const parts = [line.what];
const who = line.agent || line.actor;
if (who) parts.push(who);
if (line.at) parts.push(relativeTime(line.at));
return parts.filter(Boolean).join(' · ');
}
function agentCounts(changes: number, items: number): string {
return `${changes} ${changes === 1 ? 'change' : 'changes'} across ${items} ${items === 1 ? 'item' : 'items'}`;
}
function itemHref(line: CatchUpLine): string {
return `/${username}/${wsSlug}/${line.collection}/${line.ref}`;
}
</script>
{#snippet lineRow(line: CatchUpLine)}
<li class="line">
<a class="line-ref" href={itemHref(line)}>{line.ref}</a>
<a class="line-title" href={itemHref(line)}>{line.title}</a>
<span class="line-meta">{meta(line)}</span>
</li>
{/snippet}
{#if data && empty}
<section class="catch-up caught-up" data-testid="catch-up-card" aria-label="Catch up">
<p class="caught-up-line" data-testid="catch-up-caught-up">{caughtUpLine}</p>
</section>
{:else if data}
<section class="catch-up" data-testid="catch-up-card" aria-labelledby="catch-up-heading">
<header class="catch-up-header">
<h2 id="catch-up-heading" class="catch-up-title">{heading}</h2>
<button
class="mark-caught-up"
data-testid="catch-up-mark"
disabled={marking}
onclick={markCaughtUp}
>
{marking ? 'Marking…' : 'Mark caught up'}
</button>
</header>
{#if markError}
<p class="mark-error" role="alert">Couldn't mark this workspace caught up. Try again.</p>
{:else if refreshError}
<p class="mark-error" role="alert" data-testid="catch-up-refresh-error">
Marked caught up, but this card couldn't refresh. Reload to see it.
</p>
{/if}
{#if needsYouDegraded}
<p class="degraded" data-testid="catch-up-degraded">Needs you is unavailable right now.</p>
{/if}
{#each sections as s (s.id)}
<div class="catch-up-section" data-testid="catch-up-section-{s.id}">
<h3 class="section-label">{s.label} ({s.section.total})</h3>
<ul class="lines">
{#each s.section.lines as line, i (i)}
{@render lineRow(line)}
{/each}
</ul>
{#if s.section.total > s.section.lines.length}
{#if s.window}
<a class="more" href={seeEverything}>… and {s.section.total - s.section.lines.length} more</a>
{:else}
<span class="more">… and {s.section.total - s.section.lines.length} more</span>
{/if}
{/if}
</div>
{/each}
{#if data.agents.total > 0}
<div class="catch-up-section" data-testid="catch-up-section-agents">
<h3 class="section-label">Agents</h3>
{#each data.agents.groups as g, gi (gi)}
<details class="agent-group">
<summary>
<span class="agent-name">{g.agent}</span>
<span class="agent-counts">{agentCounts(g.changes, g.items)}</span>
</summary>
<ul class="lines">
{#each g.lines as line, i (i)}
{@render lineRow(line)}
{/each}
</ul>
</details>
{/each}
</div>
{/if}
<footer class="catch-up-footer">
<a class="see-everything" href={seeEverything} data-testid="catch-up-see-everything">See everything →</a>
</footer>
</section>
{/if}
<style>
.catch-up {
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 0.75rem 1rem;
margin-bottom: 1.5rem;
}
.caught-up {
padding: 0.5rem 1rem;
}
.caught-up-line {
margin: 0;
font-size: 0.875rem;
color: var(--text-secondary);
}
.catch-up-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.catch-up-title {
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
color: var(--text-primary);
}
.mark-caught-up {
border: 1px solid var(--border);
border-radius: 0.375rem;
background: none;
color: var(--text-primary);
font-size: 0.8125rem;
padding: 0.25rem 0.625rem;
cursor: pointer;
white-space: nowrap;
}
.mark-caught-up:disabled {
cursor: default;
opacity: 0.6;
}
.mark-error,
.degraded {
margin: 0.5rem 0 0;
font-size: 0.8125rem;
color: var(--text-secondary);
}
.catch-up-section {
margin-top: 0.75rem;
}
.section-label {
margin: 0 0 0.25rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-secondary);
}
.lines {
list-style: none;
margin: 0;
padding: 0;
}
.line {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 0.75rem;
align-items: baseline;
padding: 0.125rem 0 0.125rem 0.75rem;
font-size: 0.875rem;
}
.line-ref {
font-family: var(--font-mono, monospace);
font-size: 0.8125rem;
color: var(--text-secondary);
text-decoration: none;
}
.line-title {
color: var(--text-primary);
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.line-title:hover,
.line-ref:hover {
text-decoration: underline;
}
.line-meta {
color: var(--text-muted);
font-size: 0.8125rem;
white-space: nowrap;
}
.more {
display: inline-block;
padding-left: 0.75rem;
font-size: 0.8125rem;
color: var(--text-muted);
}
.agent-group summary {
display: flex;
gap: 0.75rem;
padding: 0.125rem 0 0.125rem 0.75rem;
font-size: 0.875rem;
cursor: pointer;
}
.agent-name {
font-weight: 500;
color: var(--text-primary);
}
.agent-counts {
color: var(--text-muted);
}
.catch-up-footer {
margin-top: 0.75rem;
text-align: right;
}
.see-everything {
font-size: 0.8125rem;
color: var(--accent-primary);
text-decoration: none;
}
@media (max-width: 640px) {
.catch-up-header {
flex-wrap: wrap;
}
.line {
grid-template-columns: auto 1fr;
}
.line-meta {
grid-column: 2;
white-space: normal;
}
}
</style>
@@ -0,0 +1,243 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { cleanup, render, fireEvent } from '@testing-library/svelte';
import { tick } from 'svelte';
import { bindReactiveEpoch, bindReactiveUserId, isEpochReactive } from '../../../test/identityEpochMock.svelte';
// TASK-3615 PR B: the catch-up card. Every load is HELD until the test
// resolves it, so a leg can move the identity or the workspace in between.
// Each refusal leg has a control, because "nothing rendered" is also what a
// card that never loaded looks like.
const auth = vi.hoisted(() => {
const hook = {
read: null as null | (() => number),
write: null as null | ((n: number) => void),
readUser: null as null | (() => string),
writeUser: null as null | ((id: string) => void),
};
return {
__hook: hook,
get identityEpoch() { return hook.read ? hook.read() : 0; },
get userId() { return hook.readUser ? hook.readUser() : 'u1'; },
bumpEpoch() { hook.write?.((hook.read ? hook.read() : 0) + 1); },
setUserId(id: string) { hook.writeUser?.(id); },
};
});
vi.mock('$lib/stores/auth.svelte', () => ({ authStore: auth }));
type Held = { ws: string; resolve: (v: unknown) => void; reject: (e: unknown) => void };
const gets = vi.hoisted(() => [] as Held[]);
const marks = vi.hoisted(() => [] as string[]);
vi.mock('$lib/api/client', () => ({
api: {
catchUp: {
get: vi.fn((ws: string) => new Promise((resolve, reject) => {
gets.push({ ws, resolve: resolve as (v: unknown) => void, reject });
})),
markCaughtUp: vi.fn(async (ws: string) => {
marks.push(ws);
return { seen_at: '2026-10-18T12:00:00Z' };
}),
},
},
}));
import CatchUpCard from './CatchUpCard.svelte';
bindReactiveEpoch(auth.__hook);
bindReactiveUserId(auth.__hook);
const empty = () => ({ total: 0, lines: [] });
const line = (ref: string, title: string, what: string) => ({ ref, title, collection: 'tasks', what, actor: 'Rook', at: '2026-10-18T10:00:00Z' });
function digest(over: Record<string, unknown> = {}) {
return {
window: { since: '2026-10-17T18:40:00Z', until: '2026-10-18T12:00:00Z', has_cursor: true, capped_at_ceiling: false },
needs_you: empty(),
finished: empty(),
new_and_reopened: empty(),
comments: empty(),
agents: { total: 0, groups: [] },
...over,
};
}
const busy = () =>
digest({
needs_you: { total: 1, lines: [line('BUG-1', 'Settings export', 'needs a decision')] },
finished: {
total: 7,
lines: ['TASK-1', 'TASK-2', 'TASK-3', 'TASK-4', 'TASK-5'].map((r) => line(r, `title ${r}`, 'finished')),
},
agents: {
total: 14,
groups: [{ agent: 'claude-code', changes: 14, items: 5, lines: [line('TASK-9', 'agent work', 'updated')] }],
},
});
async function flush() {
for (let i = 0; i < 5; i++) {
await Promise.resolve();
await tick();
}
}
async function nextGet(n: number): Promise<Held> {
for (let i = 0; i < 50 && gets.length <= n; i++) await flush();
if (gets.length <= n) throw new Error(`load ${n} never started`);
return gets[n];
}
beforeEach(() => {
gets.length = 0;
marks.length = 0;
auth.setUserId('u1');
});
afterEach(() => cleanup());
describe('CatchUpCard (TASK-3615)', () => {
it('the identity mock is reactive, or the fence legs below measure nothing', () => {
expect(isEpochReactive(() => auth.identityEpoch, () => auth.bumpEpoch())).toBe(true);
});
it('renders the non-empty sections in order, with totals, "and N more", and See everything to the exact window', async () => {
const r = render(CatchUpCard, { props: { username: 'dave', wsSlug: 'pad' } });
(await nextGet(0)).resolve(busy());
await flush();
const ids = [...r.container.querySelectorAll('[data-testid^="catch-up-section-"]')].map((el) => el.getAttribute('data-testid'));
expect(ids).toEqual(['catch-up-section-needs_you', 'catch-up-section-finished', 'catch-up-section-agents']);
expect(r.getByTestId('catch-up-section-finished').textContent).toContain('Finished (7)');
const more = r.getByTestId('catch-up-section-finished').querySelector('a.more');
expect(more?.textContent).toContain('and 2 more');
const want = '/dave/pad/activity?since=2026-10-17T18%3A40%3A00Z&view=audit';
expect(more?.getAttribute('href')).toBe(want);
expect(r.getByTestId('catch-up-see-everything').getAttribute('href')).toBe(want);
// The item line links to the item by ref.
const ref = r.getByTestId('catch-up-section-needs_you').querySelector('a.line-ref');
expect(ref?.getAttribute('href')).toBe('/dave/pad/tasks/BUG-1');
// Agent groups start collapsed (DOC-3616: collapsed on mobile).
const group = r.getByTestId('catch-up-section-agents').querySelector('details');
expect(group?.open).toBe(false);
expect(group?.textContent).toContain('14 changes across 5 items');
});
it('an empty digest is one caught-up line, and the no-cursor form names the 14-day window', async () => {
const r = render(CatchUpCard, { props: { username: 'dave', wsSlug: 'pad' } });
(await nextGet(0)).resolve(digest());
await flush();
expect(r.getByTestId('catch-up-caught-up').textContent).toMatch(/^You're caught up · nothing new since /);
expect(r.queryByTestId('catch-up-mark')).toBeNull();
cleanup();
const r2 = render(CatchUpCard, { props: { username: 'dave', wsSlug: 'pad' } });
(await nextGet(1)).resolve(digest({ window: { since: '2026-10-04T12:00:00Z', until: '2026-10-18T12:00:00Z', has_cursor: false, capped_at_ceiling: true } }));
await flush();
expect(r2.getByTestId('catch-up-caught-up').textContent).toBe("You're caught up · nothing new in the last 14 days");
});
it('a degraded needs_you is said, not shown as caught up', async () => {
const r = render(CatchUpCard, { props: { username: 'dave', wsSlug: 'pad' } });
(await nextGet(0)).resolve(digest({ degraded: ['needs_you'] }));
await flush();
expect(r.queryByTestId('catch-up-caught-up')).toBeNull();
expect(r.getByTestId('catch-up-degraded')).toBeTruthy();
});
it('Mark caught up PUTs once, then refetches and collapses', async () => {
const r = render(CatchUpCard, { props: { username: 'dave', wsSlug: 'pad' } });
(await nextGet(0)).resolve(busy());
await flush();
await fireEvent.click(r.getByTestId('catch-up-mark'));
const refetch = await nextGet(1);
expect(marks).toEqual(['pad']);
expect(refetch.ws).toBe('pad');
refetch.resolve(digest());
await flush();
expect(r.getByTestId('catch-up-caught-up')).toBeTruthy();
});
it('a load that lands after the identity moved does not paint (control: the same load without the move does)', async () => {
const r = render(CatchUpCard, { props: { username: 'dave', wsSlug: 'pad' } });
const held = await nextGet(0);
auth.bumpEpoch();
held.resolve(busy());
await flush();
expect(r.queryByTestId('catch-up-card')).toBeNull();
cleanup();
const c = render(CatchUpCard, { props: { username: 'dave', wsSlug: 'pad' } });
(await nextGet(1)).resolve(busy());
await flush();
expect(c.getByTestId('catch-up-card')).toBeTruthy();
});
it('a sign-in as someone else drops the card and reloads for them', async () => {
const r = render(CatchUpCard, { props: { username: 'dave', wsSlug: 'pad' } });
(await nextGet(0)).resolve(busy());
await flush();
expect(r.getByTestId('catch-up-card')).toBeTruthy();
auth.setUserId('u2');
auth.bumpEpoch();
await flush();
expect(r.queryByTestId('catch-up-card')).toBeNull();
(await nextGet(1)).resolve(digest());
await flush();
expect(r.getByTestId('catch-up-caught-up')).toBeTruthy();
});
it("a workspace switch drops the old digest, and the old workspace's late answer never paints", async () => {
const r = render(CatchUpCard, { props: { username: 'dave', wsSlug: 'pad' } });
const old = await nextGet(0);
await r.rerender({ username: 'dave', wsSlug: 'other' });
const fresh = await nextGet(1);
expect(fresh.ws).toBe('other');
old.resolve(busy());
await flush();
expect(r.queryByTestId('catch-up-card')).toBeNull();
fresh.resolve(digest());
await flush();
expect(r.getByTestId('catch-up-caught-up')).toBeTruthy();
});
it('A→B→A: the first A answer, landing last, does not paint over the second (PR B r1)', async () => {
const r = render(CatchUpCard, { props: { username: 'dave', wsSlug: 'pad' } });
const firstA = await nextGet(0);
await r.rerender({ username: 'dave', wsSlug: 'other' });
await nextGet(1);
await r.rerender({ username: 'dave', wsSlug: 'pad' });
const secondA = await nextGet(2);
secondA.resolve(digest());
await flush();
// Control for this leg is the caught-up line here: the second A did
// paint, so a busy card below would be the first A's stale answer.
expect(r.getByTestId('catch-up-caught-up')).toBeTruthy();
firstA.resolve(busy());
await flush();
expect(r.getByTestId('catch-up-caught-up')).toBeTruthy();
expect(r.queryByTestId('catch-up-section-finished')).toBeNull();
});
it('a mark that lands but whose refetch fails keeps the card and says so (PR B r1)', async () => {
const r = render(CatchUpCard, { props: { username: 'dave', wsSlug: 'pad' } });
(await nextGet(0)).resolve(busy());
await flush();
await fireEvent.click(r.getByTestId('catch-up-mark'));
(await nextGet(1)).reject(new Error('refetch failed'));
await flush();
expect(marks).toEqual(['pad']);
expect(r.getByTestId('catch-up-section-finished')).toBeTruthy();
expect(r.getByTestId('catch-up-refresh-error').textContent).toContain('Marked caught up');
expect(r.queryByText("Couldn't mark this workspace caught up. Try again.")).toBeNull();
});
it('a section that repeats a ref renders both lines instead of throwing (BUG-3538 class)', async () => {
const r = render(CatchUpCard, { props: { username: 'dave', wsSlug: 'pad' } });
(await nextGet(0)).resolve(
digest({ needs_you: { total: 2, lines: [line('BUG-1', 'x', 'needs a decision'), line('BUG-1', 'x', 'blocked')] } })
);
await flush();
expect(r.getByTestId('catch-up-section-needs_you').querySelectorAll('li.line')).toHaveLength(2);
});
});
+1
View File
@@ -45,6 +45,7 @@ export const EACH_KEY_CENSUS: Record<string, { reason: EachKeyReason; note: stri
"lib/components/RelationBacklinksPanel.svelte :: each groups as group (group.key)": { reason: 'object-or-map-keys', note: "SvelteMap keyed by `field_key` (l.110) | Map keys." },
"lib/components/ShareDialog.svelte :: each grants as grant (grant.id)": { reason: 'db-primary-key', note: "grant ids are primary keys (ListCollectionGrants / ListItemGrants); a repeat grant to the same user is refused 409, and a create appends the new grant's freshly minted id" },
"lib/components/ShareDialog.svelte :: each shareLinks as link (link.id)": { reason: 'db-primary-key', note: "`ListShareLinks` (primary key) plus prepend of a new link | `CreateShareLink` always mints `newID()` (store/share_links.go:53)." },
"lib/components/activity/CatchUpCard.svelte :: each sections as s (s.id)": { reason: 'static-literal', note: "`sections` $derived (CatchUpCard.svelte) | a four-entry literal with distinct ids needs_you, finished, new_and_reopened, comments, only filtered (TASK-3615)" },
"lib/components/activity/EpisodeFeed.svelte :: each earlierEpisodes as ep (ep.key)": { reason: 'position-or-counter', note: "episode key embeds the opening activity id" },
"lib/components/activity/EpisodeFeed.svelte :: each liveEpisodes as ep (ep.key)": { reason: 'position-or-counter', note: "episode key embeds the opening activity id" },
"lib/components/admin/UserActivityTab.svelte :: each events as ev (ev.id)": { reason: 'deduped', note: "`ListUserActivity` with keyset paging | Primary key; pages are joined with `appendUnique`." },
+39
View File
@@ -1925,6 +1925,45 @@ export interface DashboardSuggestion {
reason: string;
}
/** One line of the catch-up digest (TASK-3615; GET /workspaces/{ws}/catch-up). */
export interface CatchUpLine {
ref: string;
title: string;
collection: string;
what: string;
actor?: string;
agent?: string;
at?: string;
}
/** A catch-up section: at most 5 lines, and the total the lines were cut from. */
export interface CatchUpSection {
total: number;
lines: CatchUpLine[];
}
export interface CatchUpAgentGroup {
agent: string;
changes: number;
items: number;
lines: CatchUpLine[];
}
/**
* The caller's "catch me up" digest. The window runs from
* max(cursor, now - 14 days) to now; `has_cursor` is false until the first
* Mark caught up, and the GET never writes one.
*/
export interface CatchUpResponse {
window: { since: string; until: string; has_cursor: boolean; capped_at_ceiling: boolean };
needs_you: CatchUpSection;
finished: CatchUpSection;
new_and_reopened: CatchUpSection;
comments: CatchUpSection;
agents: { total: number; groups: CatchUpAgentGroup[] };
degraded?: string[];
}
export interface DashboardResponse {
summary: {
total_items: number;
@@ -27,6 +27,7 @@
import { collectionTracksWork } from '$lib/types';
import { statusState } from '$lib/collections/childProgress';
import PartialImportBanner from '$lib/components/PartialImportBanner.svelte';
import CatchUpCard from '$lib/components/activity/CatchUpCard.svelte';
let wsSlug = $derived(page.params.workspace ?? '');
let username = $derived(page.params.username ?? '');
@@ -772,6 +773,9 @@
</section>
{/if}
<!-- Catch-up digest (TASK-3615): above Active Work, per DOC-3616. -->
<CatchUpCard {username} {wsSlug} />
<!-- 3. Active Work -->
{#if dashboard.active_items.length > 0}
<section class="section">
@@ -1,5 +1,6 @@
<script lang="ts">
import { page } from '$app/state';
import { goto, replaceState } from '$app/navigation';
import { browser } from '$app/environment';
import { onMount, onDestroy, untrack } from 'svelte';
import { collapseReorderBatches, type CollapsedActivity } from '$lib/activity/reorderBatches';
@@ -19,7 +20,7 @@
import ContentError from '$lib/components/common/ContentError.svelte';
import { loadFailure } from '$lib/api/loadFailure';
import EpisodeFeed from '$lib/components/activity/EpisodeFeed.svelte';
import { ACTOR_OPTIONS, activityFilterParams, hasActivityFilters, actorBadgeTitle } from '$lib/activity/activityFilters';
import { ACTOR_OPTIONS, activityFilterParams, hasActivityFilters, actorBadgeTitle, sinceFromUrl } from '$lib/activity/activityFilters';
import type { Activity, Collection, FieldDef } from '$lib/types';
import { parseSchema } from '$lib/types';
import ActivityChangeValue from '$lib/components/timeline/ActivityChangeValue.svelte';
@@ -70,9 +71,50 @@
let filterSource = $state('');
let filterActor = $state('');
let filterCollection = $state('');
// TASK-3615: the catch-up card's "See everything" opens this page with
// ?since=<its window's start>&view=audit. There is no control to SET it
// here; the chip below shows it and clears it. Re-read whenever the URL's
// value changes, so a second See everything on a mounted page applies.
let filterSince = $state('');
const urlSince = $derived(sinceFromUrl(page.url.searchParams.get('since')));
$effect(() => {
filterSince = urlSince;
});
// A link-set value the user clears or overrides also leaves the URL: both
// `since` (chip ×, Clear filters) and `view` (a Live or Audit click). Kept
// in the URL, a reload re-applies the dropped value, and re-following the
// same link changes no URL value, so the effect that reads it never
// re-runs. A real (replacing) navigation, not the shallow replaceState:
// that one leaves page.url, and the values derived from it, stale
// (TASK-3615 PR B, lead sweep 2026-10-18, r4). This route has no load
// function, so the navigation re-fetches nothing. A superseded goto
// needs nothing (the newer navigation decides the URL). A rejected one
// falls back to the shallow replaceState, so the address bar, and with
// it a reload, at least stays cleared (PR B r3).
function dropFromUrl(name: 'since' | 'view') {
if (!page.url.searchParams.has(name)) return;
const url = new URL(page.url);
url.searchParams.delete(name);
goto(url, { replaceState: true, keepFocus: true, noScroll: true }).catch(() => {
try {
replaceState(url, page.state);
} catch {
// The router is not up; nothing else can carry the clear.
}
});
}
function clearSince() {
filterSince = '';
dropFromUrl('since');
}
function chooseView(v: 'live' | 'audit') {
viewFromUrl = false;
view = v;
dropFromUrl('view');
}
// Every filter is applied by the server (TASK-2219), so the first load,
// Load more and the live head re-read all send the same query.
const filters = $derived({ action: filterAction, source: filterSource, actor: filterActor, collection: filterCollection });
const filters = $derived({ action: filterAction, source: filterSource, actor: filterActor, collection: filterCollection, since: filterSince });
const filtered = $derived(hasActivityFilters(filters));
// View mode: episode cards (Live) vs the raw audit timeline. Persisted so
@@ -85,17 +127,44 @@
// browser on the runtime this repo actually develops on.)
let view = $state<'live' | 'audit'>('live');
let viewRestored = false;
// A `?view=` from a link (TASK-3615) wins over the stored choice, but is
// not persisted: following a link is not choosing a view. A click on the
// toggle clears it, and that choice is persisted as before.
let viewFromUrl = $state(false);
// The `?view=` onMount applied. A later See everything on the mounted
// page changes it, and the effect below applies that, as the since effect
// does for `?since=` (TASK-3615 PR B r1).
let appliedLinkedView: string | null = null;
const urlView = $derived(page.url.searchParams.get('view'));
onMount(() => {
const stored = localStorage.getItem('pad-activity-view');
if (stored === 'audit' || stored === 'live') view = stored;
const linked = page.url.searchParams.get('view');
appliedLinkedView = linked;
if (linked === 'audit' || linked === 'live') {
view = linked;
viewFromUrl = true;
}
viewRestored = true;
});
$effect(() => {
const linked = urlView;
untrack(() => {
if (!viewRestored || linked === appliedLinkedView) return;
appliedLinkedView = linked;
if (linked === 'audit' || linked === 'live') {
view = linked;
viewFromUrl = true;
}
});
});
$effect(() => {
// Persist only after the restore read, or the SSR default clobbers
// the stored choice before onMount can apply it.
if (browser && viewRestored) {
if (browser && viewRestored && !viewFromUrl) {
localStorage.setItem('pad-activity-view', view);
}
});
@@ -124,6 +193,7 @@
filterSource;
filterActor;
filterCollection;
filterSince;
// UNTRACKED past the dependencies named above: loadActivities reads
// the identity epoch for its fence (TASK-2203), and tracked, that
// read would add an identity reload the layout already owns.
@@ -430,14 +500,14 @@
<button
class="view-segment"
class:selected={view === 'live'}
onclick={() => (view = 'live')}
onclick={() => chooseView('live')}
>
Live
</button>
<button
class="view-segment"
class:selected={view === 'audit'}
onclick={() => (view = 'audit')}
onclick={() => chooseView('audit')}
>
Audit
</button>
@@ -485,6 +555,19 @@
</select>
</div>
{#if filterSince}
<span class="since-chip" data-testid="activity-since-chip">
Since {new Date(filterSince).toLocaleString()}
<button
class="since-chip-clear"
aria-label="Remove the since filter"
onclick={clearSince}
>
×
</button>
</span>
{/if}
{#if filtered}
<button
class="clear-filters"
@@ -493,6 +576,7 @@
filterSource = '';
filterActor = '';
filterCollection = '';
clearSince();
}}
>
Clear filters
@@ -707,6 +791,27 @@
background: var(--accent-primary);
color: white;
}
.since-chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 0.25rem 0.125rem 0.5rem;
border: 1px solid var(--border);
border-radius: 999px;
font-size: 0.8125rem;
color: var(--text-secondary);
}
.since-chip-clear {
border: none;
background: none;
color: inherit;
cursor: pointer;
font-size: 1rem;
line-height: 1;
padding: 0 0.25rem;
}
.clear-filters {
background: none;
border: none;
@@ -34,6 +34,22 @@ vi.mock('$lib/stores/auth.svelte', () => ({
},
}));
vi.mock('$app/state', async () => ({ page: (await import('../../../../test/mocks/reactivePage.svelte')).page }));
// goto moves the reactive page.url, as a real navigation does.
const navs = vi.hoisted(() => [] as Array<{ url: string; opts: Record<string, unknown> }>);
const navMode = vi.hoisted(() => ({ reject: false, shallow: [] as string[] }));
vi.mock('$app/navigation', async () => {
const { page: p } = await import('../../../../test/mocks/reactivePage.svelte');
return {
goto: vi.fn(async (url: string | URL, opts: Record<string, unknown> = {}) => {
navs.push({ url: String(url), opts });
if (navMode.reject) throw new Error('navigation failed');
p.url = new URL(String(url));
}),
replaceState: vi.fn((url: string | URL) => {
navMode.shallow.push(String(url));
}),
};
});
vi.mock('$lib/stores/workspaceIndexEntry', () => ({ enterWorkspaceIndex: vi.fn(async () => {}) }));
vi.mock('$lib/services/sse.svelte', () => ({ sseService: { onItemEvent: () => () => {} } }));
vi.mock('$lib/scroll/restore.svelte', () => ({
@@ -48,6 +64,9 @@ const settle = () => new Promise((r) => setTimeout(r, 20));
beforeEach(() => {
calls.params = [];
navs.length = 0;
navMode.reject = false;
navMode.shallow = [];
try { localStorage.setItem('pad-activity-view', 'audit'); } catch {}
page.params = { username: 'dave', workspace: 'ws' };
page.url = new URL('http://localhost/dave/ws/activity');
@@ -87,3 +106,145 @@ describe('Activity filters reach the server (TASK-2219)', () => {
expect(screen.getAllByText('Dana, via CLI').length).toBeGreaterThan(0);
});
});
describe("the catch-up card's See everything link (TASK-3615)", () => {
it('?since= reaches the request, shows as a chip, and the chip clears it', async () => {
page.url = new URL('http://localhost/dave/ws/activity?since=2026-10-17T18%3A40%3A00Z&view=audit');
render(ActivityPage);
await screen.findByRole('button', { name: 'Load more activity' });
await settle();
expect(last()).toMatchObject({ since: '2026-10-17T18:40:00Z' });
expect(screen.getByTestId('activity-since-chip')).toBeTruthy();
await fireEvent.click(screen.getByRole('button', { name: 'Remove the since filter' }));
await settle();
expect(last()).not.toHaveProperty('since');
expect(screen.queryByTestId('activity-since-chip')).toBeNull();
});
it('a ?since= the server would refuse is dropped, so the feed loads unfiltered', async () => {
page.url = new URL('http://localhost/dave/ws/activity?since=yesterday');
render(ActivityPage);
await screen.findByRole('button', { name: 'Load more activity' });
expect(last()).not.toHaveProperty('since');
expect(screen.queryByTestId('activity-since-chip')).toBeNull();
});
it('?view=audit opens Audit without overwriting the stored choice; a click is still persisted', async () => {
localStorage.setItem('pad-activity-view', 'live');
page.url = new URL('http://localhost/dave/ws/activity?view=audit');
render(ActivityPage);
await screen.findByRole('button', { name: 'Load more activity' });
await settle();
expect(screen.getByRole('button', { name: 'Audit' }).classList.contains('selected')).toBe(true);
expect(localStorage.getItem('pad-activity-view')).toBe('live');
await fireEvent.click(screen.getByRole('button', { name: 'Audit' }));
await settle();
expect(localStorage.getItem('pad-activity-view')).toBe('audit');
});
for (const how of ['the chip ×', 'Clear filters'] as const) {
it(`${how} drops since from the URL: a reload stays cleared, and the same link re-applies (PR B, lead sweep)`, async () => {
const link = 'http://localhost/dave/ws/activity?since=2026-10-17T18%3A40%3A00Z&view=audit';
page.url = new URL(link);
render(ActivityPage);
await screen.findByRole('button', { name: 'Load more activity' });
await settle();
// Control: the link applied.
expect(last()).toMatchObject({ since: '2026-10-17T18:40:00Z' });
if (how === 'the chip ×') await fireEvent.click(screen.getByRole('button', { name: 'Remove the since filter' }));
else await fireEvent.click(screen.getByRole('button', { name: 'Clear filters' }));
await settle();
expect(navs).toHaveLength(1);
expect(navs[0].opts).toMatchObject({ replaceState: true });
expect(new URL(navs[0].url).searchParams.has('since')).toBe(false);
expect(new URL(navs[0].url).searchParams.get('view')).toBe('audit');
expect(last()).not.toHaveProperty('since');
// The same link followed again re-applies the filter.
page.url = new URL(link);
await settle();
expect(last()).toMatchObject({ since: '2026-10-17T18:40:00Z' });
expect(screen.getByTestId('activity-since-chip')).toBeTruthy();
// Clear again, then reload: the cleared URL loads with no chip and no since.
await fireEvent.click(screen.getByRole('button', { name: 'Remove the since filter' }));
await settle();
const cleared = page.url.href;
cleanup();
calls.params = [];
page.url = new URL(cleared);
render(ActivityPage);
await screen.findByRole('button', { name: 'Load more activity' });
await settle();
expect(calls.params.length).toBeGreaterThan(0);
expect(last()).not.toHaveProperty('since');
expect(screen.queryByTestId('activity-since-chip')).toBeNull();
});
}
it('a clear whose navigation rejects still drops since from the address bar (PR B r3)', async () => {
page.url = new URL('http://localhost/dave/ws/activity?since=2026-10-17T18%3A40%3A00Z&view=audit');
render(ActivityPage);
await screen.findByRole('button', { name: 'Load more activity' });
await settle();
navMode.reject = true;
await fireEvent.click(screen.getByRole('button', { name: 'Remove the since filter' }));
await settle();
expect(navs).toHaveLength(1);
expect(navMode.shallow).toHaveLength(1);
expect(new URL(navMode.shallow[0]).searchParams.has('since')).toBe(false);
expect(new URL(navMode.shallow[0]).searchParams.get('view')).toBe('audit');
expect(last()).not.toHaveProperty('since');
});
it('choosing a view drops view from the URL: a reload keeps the choice, and the same link re-applies (PR B r4)', async () => {
localStorage.setItem('pad-activity-view', 'live');
const link = 'http://localhost/dave/ws/activity?since=2026-10-17T18%3A40%3A00Z&view=audit';
page.url = new URL(link);
render(ActivityPage);
await screen.findByRole('button', { name: 'Load more activity' });
await settle();
// Control: the link's view applied.
expect(screen.getByRole('button', { name: 'Audit' }).classList.contains('selected')).toBe(true);
await fireEvent.click(screen.getByRole('button', { name: 'Live' }));
await settle();
expect(navs).toHaveLength(1);
expect(navs[0].opts).toMatchObject({ replaceState: true });
expect(new URL(navs[0].url).searchParams.has('view')).toBe(false);
expect(new URL(navs[0].url).searchParams.get('since')).toBe('2026-10-17T18:40:00Z');
expect(localStorage.getItem('pad-activity-view')).toBe('live');
// Reload: the choice holds, because the URL no longer overrides it.
const chosen = page.url.href;
cleanup();
page.url = new URL(chosen);
render(ActivityPage);
await screen.findByRole('button', { name: 'Load more activity' });
await settle();
expect(screen.getByRole('button', { name: 'Live' }).classList.contains('selected')).toBe(true);
// The same link followed again re-applies its view.
page.url = new URL(link);
await settle();
expect(screen.getByRole('button', { name: 'Audit' }).classList.contains('selected')).toBe(true);
});
it('a See everything followed on an already-mounted page applies its view too, unpersisted (PR B r1)', async () => {
localStorage.setItem('pad-activity-view', 'live');
render(ActivityPage);
await screen.findByRole('button', { name: 'Load more activity' });
await settle();
// Control: mounted in the stored Live view.
expect(screen.getByRole('button', { name: 'Audit' }).classList.contains('selected')).toBe(false);
page.url = new URL('http://localhost/dave/ws/activity?since=2026-10-17T18%3A40%3A00Z&view=audit');
await settle();
expect(last()).toMatchObject({ since: '2026-10-17T18:40:00Z' });
expect(screen.getByRole('button', { name: 'Audit' }).classList.contains('selected')).toBe(true);
expect(localStorage.getItem('pad-activity-view')).toBe('live');
});
});