diff --git a/docs/api.md b/docs/api.md index 77cc973b8..687484bbe 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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=` — 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=` 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 diff --git a/internal/server/handlers_activity.go b/internal/server/handlers_activity.go index ab2cd26f7..add61c1b2 100644 --- a/internal/server/handlers_activity.go +++ b/internal/server/handlers_activity.go @@ -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 diff --git a/internal/server/task3615_activity_since_test.go b/internal/server/task3615_activity_since_test.go new file mode 100644 index 000000000..0ebf804d8 --- /dev/null +++ b/internal/server/task3615_activity_since_test.go @@ -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) + } + }) +} diff --git a/web/e2e/task-3615-catch-up-card.spec.ts b/web/e2e/task-3615-catch-up-card.spec.ts new file mode 100644 index 000000000..bd09cb9ee --- /dev/null +++ b/web/e2e/task-3615-catch-up-card.spec.ts @@ -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>, 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 { + 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); + } + }); +}); + diff --git a/web/src/lib/activity/activityFilters.test.ts b/web/src/lib/activity/activityFilters.test.ts index 6ed53a871..ed0bf9757 100644 --- a/web/src/lib/activity/activityFilters.test.ts +++ b/web/src/lib/activity/activityFilters.test.ts @@ -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 : ''); + }); +}); diff --git a/web/src/lib/activity/activityFilters.ts b/web/src/lib/activity/activityFilters.ts index cc5b80925..5fc78ecc3 100644 --- a/web/src/lib/activity/activityFilters.ts +++ b/web/src/lib/activity/activityFilters.ts @@ -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 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); } /** diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index 3f9be2233..347a28f5b 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -1,5 +1,6 @@ import type { BuiltinListEntry, + CatchUpResponse, BuiltinStateResponse, DecisionSettings, MCPSettings, @@ -2596,6 +2597,15 @@ export const api = { request(`/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(`/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. diff --git a/web/src/lib/components/activity/CatchUpCard.svelte b/web/src/lib/components/activity/CatchUpCard.svelte new file mode 100644 index 000000000..1769c6931 --- /dev/null +++ b/web/src/lib/components/activity/CatchUpCard.svelte @@ -0,0 +1,409 @@ + + +{#snippet lineRow(line: CatchUpLine)} +
  • + {line.ref} + {line.title} + {meta(line)} +
  • +{/snippet} + +{#if data && empty} +
    +

    {caughtUpLine}

    +
    +{:else if data} +
    +
    +

    {heading}

    + +
    + {#if markError} + + {:else if refreshError} + + {/if} + + {#if needsYouDegraded} +

    Needs you is unavailable right now.

    + {/if} + + {#each sections as s (s.id)} +
    + +
      + {#each s.section.lines as line, i (i)} + {@render lineRow(line)} + {/each} +
    + {#if s.section.total > s.section.lines.length} + {#if s.window} + … and {s.section.total - s.section.lines.length} more + {:else} + … and {s.section.total - s.section.lines.length} more + {/if} + {/if} +
    + {/each} + + {#if data.agents.total > 0} +
    + + {#each data.agents.groups as g, gi (gi)} +
    + + {g.agent} + {agentCounts(g.changes, g.items)} + +
      + {#each g.lines as line, i (i)} + {@render lineRow(line)} + {/each} +
    +
    + {/each} +
    + {/if} + + +
    +{/if} + + diff --git a/web/src/lib/components/activity/CatchUpCard.svelte.test.ts b/web/src/lib/components/activity/CatchUpCard.svelte.test.ts new file mode 100644 index 000000000..35680017e --- /dev/null +++ b/web/src/lib/components/activity/CatchUpCard.svelte.test.ts @@ -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 = {}) { + 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 { + 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); + }); +}); diff --git a/web/src/lib/eachKeyCensus.ts b/web/src/lib/eachKeyCensus.ts index 7a62d4ca2..d244f535d 100644 --- a/web/src/lib/eachKeyCensus.ts +++ b/web/src/lib/eachKeyCensus.ts @@ -45,6 +45,7 @@ export const EACH_KEY_CENSUS: Record {/if} + + + {#if dashboard.active_items.length > 0}
    diff --git a/web/src/routes/[username]/[workspace]/activity/+page.svelte b/web/src/routes/[username]/[workspace]/activity/+page.svelte index 2a1f927db..6856083d5 100644 --- a/web/src/routes/[username]/[workspace]/activity/+page.svelte +++ b/web/src/routes/[username]/[workspace]/activity/+page.svelte @@ -1,5 +1,6 @@