From 1c977c1d76efbaff4d8e91b85324aa5580c4ba66 Mon Sep 17 00:00:00 2001 From: Brian Richter Date: Thu, 7 May 2026 23:22:28 -0700 Subject: [PATCH 01/14] chore(test): wire bun test + happy-dom for component tests Co-Authored-By: Claude Sonnet 4.6 --- bun.lock | 21 +++++++++++++++++---- bunfig.toml | 2 ++ package.json | 1 + src/__tests__/bun-smoke.test.tsx | 20 ++++++++++++++++++++ src/__tests__/setup.ts | 7 +++++++ 5 files changed, 47 insertions(+), 4 deletions(-) create mode 100644 bunfig.toml create mode 100644 src/__tests__/bun-smoke.test.tsx create mode 100644 src/__tests__/setup.ts diff --git a/bun.lock b/bun.lock index 98df3f2..1f87504 100644 --- a/bun.lock +++ b/bun.lock @@ -38,6 +38,7 @@ "devDependencies": { "@capacitor/assets": "^3.0.5", "@eslint/js": "^9.39.1", + "@happy-dom/global-registrator": "^20.9.0", "@napi-rs/canvas": "^0.1.90", "@tailwindcss/vite": "^4.1.18", "@types/node": "^24.10.1", @@ -206,6 +207,8 @@ "@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="], + "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.9.0", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.9.0" } }, "sha512-lBW6/m5BIFl3pMuWPNN0lIOYw9LMCmPfix53ExS3FBi4E+NELEljQ3xH6aAV9IYiQRfn9YIIgzzMrD0vIcD7tw=="], + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], @@ -574,6 +577,10 @@ "@types/web-push": ["@types/web-push@3.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ=="], + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.53.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.53.1", "@typescript-eslint/type-utils": "8.53.1", "@typescript-eslint/utils": "8.53.1", "@typescript-eslint/visitor-keys": "8.53.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.53.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-cFYYFZ+oQFi6hUnBTbLRXfTJiaQtYE3t4O692agbBl+2Zy+eqSKWtPjhPXJu1G7j4RLjKgeJPDdq3EqOwmX5Ag=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.53.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.53.1", "@typescript-eslint/types": "8.53.1", "@typescript-eslint/typescript-estree": "8.53.1", "@typescript-eslint/visitor-keys": "8.53.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg=="], @@ -1004,7 +1011,7 @@ "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], - "entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], @@ -1154,6 +1161,8 @@ "handlebars": ["handlebars@4.7.9", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ=="], + "happy-dom": ["happy-dom@20.9.0", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ=="], + "hard-rejection": ["hard-rejection@2.1.0", "", {}, "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], @@ -1898,6 +1907,8 @@ "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -1916,7 +1927,7 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "ws": ["ws@8.17.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="], + "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], "xcode": ["xcode@3.0.1", "", { "dependencies": { "simple-plist": "^1.1.0", "uuid": "^7.0.3" } }, "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA=="], @@ -2172,6 +2183,8 @@ "dir-glob/path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], + "dom-serializer/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], + "elementtree/sax": ["sax@1.1.4", "", {}, "sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg=="], "error-ex/is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], @@ -2186,6 +2199,8 @@ "ethers/tslib": ["tslib@2.7.0", "", {}, "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA=="], + "ethers/ws": ["ws@8.17.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="], + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], @@ -2300,8 +2315,6 @@ "viem/@scure/bip32": ["@scure/bip32@1.7.0", "", { "dependencies": { "@noble/curves": "~1.9.0", "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw=="], - "viem/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], - "widest-line/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], "xcode/uuid": ["uuid@7.0.3", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg=="], diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..37ff1f9 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[test] +preload = ["./src/__tests__/setup.ts"] diff --git a/package.json b/package.json index a7c03a7..fc1e1d8 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "devDependencies": { "@capacitor/assets": "^3.0.5", "@eslint/js": "^9.39.1", + "@happy-dom/global-registrator": "^20.9.0", "@napi-rs/canvas": "^0.1.90", "@tailwindcss/vite": "^4.1.18", "@types/node": "^24.10.1", diff --git a/src/__tests__/bun-smoke.test.tsx b/src/__tests__/bun-smoke.test.tsx new file mode 100644 index 0000000..d65dc1d --- /dev/null +++ b/src/__tests__/bun-smoke.test.tsx @@ -0,0 +1,20 @@ +import { test, expect } from "bun:test"; +import { createRoot } from "react-dom/client"; +import { act } from "react"; + +test("bun + happy-dom + react 19 renders into the DOM", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + + const root = createRoot(container); + act(() => { + root.render(
ok
); + }); + + const node = container.querySelector("[data-testid='ok']"); + expect(node).not.toBeNull(); + expect(node?.textContent).toBe("ok"); + + act(() => root.unmount()); + container.remove(); +}); diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts new file mode 100644 index 0000000..f262cf2 --- /dev/null +++ b/src/__tests__/setup.ts @@ -0,0 +1,7 @@ +import { GlobalRegistrator } from "@happy-dom/global-registrator"; + +GlobalRegistrator.register({ url: "http://localhost/" }); + +// Tell React 19 that `act()` is supported in this test environment. +// Without this, React emits a noisy console warning on every act() call. +(globalThis as Record)["IS_REACT_ACT_ENVIRONMENT"] = true; From 9ec1655ed5e03961229ba37cd5eacaacfd854854 Mon Sep 17 00:00:00 2001 From: Brian Richter Date: Thu, 7 May 2026 23:36:57 -0700 Subject: [PATCH 02/14] feat(explorer): pure logic for row derivation, filters, sort, URL params --- scripts/explorer.test.mjs | 472 ++++++++++++++++++++++++++++++++++++++ src/lib/explorer.ts | 238 +++++++++++++++++++ 2 files changed, 710 insertions(+) create mode 100644 scripts/explorer.test.mjs create mode 100644 src/lib/explorer.ts diff --git a/scripts/explorer.test.mjs b/scripts/explorer.test.mjs new file mode 100644 index 0000000..5ad91b8 --- /dev/null +++ b/scripts/explorer.test.mjs @@ -0,0 +1,472 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, rm } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; +import { build } from "esbuild"; + +const outdir = "tmp/explorer-test"; + +async function loadExplorer() { + await rm(outdir, { recursive: true, force: true }); + await mkdir(outdir, { recursive: true }); + await build({ + entryPoints: ["src/lib/explorer.ts"], + outfile: `${outdir}/explorer.mjs`, + bundle: true, + platform: "node", + format: "esm", + target: "node20", + external: ["convex/*"], + }); + return import( + `${pathToFileURL(`${process.cwd()}/${outdir}/explorer.mjs`).href}?t=${Date.now()}` + ); +} + +const explorer = await loadExplorer(); + +// Helpers to build raw input shapes ---------------------------------------- + +function makeList(over = {}) { + return { + _id: "list1", + _creationTime: 1000, + assetDid: "did:peer:list1", + name: "Untitled list", + ownerDid: "did:webvh:owner", + ...over, + }; +} + +function makeSite(over = {}) { + return { + _id: "site1", + _creationTime: 500, + ownerDid: "did:webvh:owner", + scid: "scid1", + did: "did:webvh:scid1.boop.ad", + primaryHostnameId: "host1", + fileId: "file1", + createdAt: 500, + updatedAt: 500, + ...over, + }; +} + +function makeHostname(over = {}) { + return { + _id: "host1", + siteId: "site1", + hostname: "brisk-paper-07.boop.ad", + kind: "boop_sub", + status: "active", + isPrimary: true, + createdAt: 500, + updatedAt: 500, + ...over, + }; +} + +// deriveExplorerRow -------------------------------------------------------- + +test("empty input → empty rows", () => { + const rows = explorer.deriveExplorerRows({ + lists: [], + sites: [], + hostnamesBySite: new Map(), + publicationsByList: new Map(), + anchorsByList: new Map(), + activitiesByList: new Map(), + assigneesByList: new Map(), + }); + assert.equal(rows.length, 0); +}); + +test("one list + one site → 2 rows ordered by updatedAt desc", () => { + const rows = explorer.deriveExplorerRows({ + lists: [makeList({ _id: "L", _creationTime: 100, name: "Older list" })], + sites: [makeSite({ _id: "S", _creationTime: 50, updatedAt: 999 })], + hostnamesBySite: new Map([["S", [makeHostname({ siteId: "S" })]]]), + publicationsByList: new Map(), + anchorsByList: new Map(), + activitiesByList: new Map(), + assigneesByList: new Map(), + }); + assert.equal(rows.length, 2); + assert.equal(rows[0].source, "site"); // updatedAt 999 + assert.equal(rows[1].source, "list"); // _creationTime 100 +}); + +test("updatedAt: list with no activities → list._creationTime", () => { + const rows = explorer.deriveExplorerRows({ + lists: [makeList({ _id: "L", _creationTime: 7777 })], + sites: [], + hostnamesBySite: new Map(), + publicationsByList: new Map(), + anchorsByList: new Map(), + activitiesByList: new Map(), + assigneesByList: new Map(), + }); + assert.equal(rows[0].updatedAt, 7777); +}); + +test("updatedAt: list with newer activity wins", () => { + const rows = explorer.deriveExplorerRows({ + lists: [makeList({ _id: "L", _creationTime: 100 })], + sites: [], + hostnamesBySite: new Map(), + publicationsByList: new Map(), + anchorsByList: new Map(), + activitiesByList: new Map([["L", { createdAt: 9999 }]]), + assigneesByList: new Map(), + }); + assert.equal(rows[0].updatedAt, 9999); +}); + +test("site missing primary hostname → title falls back to 'Untitled site'", () => { + const rows = explorer.deriveExplorerRows({ + lists: [], + sites: [makeSite({ _id: "S", primaryHostnameId: undefined })], + hostnamesBySite: new Map(), + publicationsByList: new Map(), + anchorsByList: new Map(), + activitiesByList: new Map(), + assigneesByList: new Map(), + }); + assert.equal(rows[0].title, "Untitled site"); + assert.equal(rows[0].identifier, null); +}); + +// Layer / identifier derivation -------------------------------------------- + +test("published list (active publication) → did:webvh + identifier", () => { + const rows = explorer.deriveExplorerRows({ + lists: [makeList({ _id: "L" })], + sites: [], + hostnamesBySite: new Map(), + publicationsByList: new Map([ + ["L", { status: "active", webvhDid: "did:webvh:list-L" }], + ]), + anchorsByList: new Map(), + activitiesByList: new Map(), + assigneesByList: new Map(), + }); + assert.equal(rows[0].layer, "did:webvh"); + assert.equal(rows[0].identifier, "did:webvh:list-L"); +}); + +test("list with no publication → did:peer + null identifier", () => { + const rows = explorer.deriveExplorerRows({ + lists: [makeList({ _id: "L" })], + sites: [], + hostnamesBySite: new Map(), + publicationsByList: new Map(), + anchorsByList: new Map(), + activitiesByList: new Map(), + assigneesByList: new Map(), + }); + assert.equal(rows[0].layer, "did:peer"); + assert.equal(rows[0].identifier, null); +}); + +test("list with unpublished publication → treated as did:peer", () => { + const rows = explorer.deriveExplorerRows({ + lists: [makeList({ _id: "L" })], + sites: [], + hostnamesBySite: new Map(), + publicationsByList: new Map([ + ["L", { status: "unpublished", webvhDid: "did:webvh:list-L" }], + ]), + anchorsByList: new Map(), + activitiesByList: new Map(), + assigneesByList: new Map(), + }); + assert.equal(rows[0].layer, "did:peer"); + assert.equal(rows[0].identifier, null); +}); + +// Verification derivation -------------------------------------------------- + +test("confirmed bitcoin anchor → verification 'anchored'", () => { + const rows = explorer.deriveExplorerRows({ + lists: [makeList({ _id: "L" })], + sites: [], + hostnamesBySite: new Map(), + publicationsByList: new Map(), + anchorsByList: new Map([ + ["L", [{ status: "confirmed", confirmedAt: 100, txid: "tx1" }]], + ]), + activitiesByList: new Map(), + assigneesByList: new Map(), + }); + assert.equal(rows[0].verification, "anchored"); + assert.equal(rows[0].anchorTxId, "tx1"); +}); + +test("multiple confirmed anchors → most recent confirmedAt wins", () => { + const rows = explorer.deriveExplorerRows({ + lists: [makeList({ _id: "L" })], + sites: [], + hostnamesBySite: new Map(), + publicationsByList: new Map(), + anchorsByList: new Map([ + ["L", [ + { status: "confirmed", confirmedAt: 100, txid: "older" }, + { status: "confirmed", confirmedAt: 500, txid: "newer" }, + ]], + ]), + activitiesByList: new Map(), + assigneesByList: new Map(), + }); + assert.equal(rows[0].anchorTxId, "newer"); +}); + +test("pending bitcoin anchor → verification 'pending'", () => { + const rows = explorer.deriveExplorerRows({ + lists: [makeList({ _id: "L" })], + sites: [], + hostnamesBySite: new Map(), + publicationsByList: new Map(), + anchorsByList: new Map([["L", [{ status: "pending" }]]]), + activitiesByList: new Map(), + assigneesByList: new Map(), + }); + assert.equal(rows[0].verification, "pending"); +}); + +test("publications.anchorTxId is ignored (only bitcoinAnchors counts)", () => { + const rows = explorer.deriveExplorerRows({ + lists: [makeList({ _id: "L" })], + sites: [], + hostnamesBySite: new Map(), + publicationsByList: new Map([ + ["L", { status: "active", webvhDid: "did:webvh:L", anchorTxId: "ghost", anchorStatus: "verified" }], + ]), + anchorsByList: new Map(), + activitiesByList: new Map(), + assigneesByList: new Map(), + }); + assert.equal(rows[0].verification, "none"); + assert.equal(rows[0].anchorTxId, undefined); +}); + +test("site primary custom + active → 'verified'", () => { + const rows = explorer.deriveExplorerRows({ + lists: [], + sites: [makeSite({ _id: "S", primaryHostnameId: "host1" })], + hostnamesBySite: new Map([ + ["S", [makeHostname({ _id: "host1", kind: "custom", cfStatus: "active", isPrimary: true })]], + ]), + publicationsByList: new Map(), + anchorsByList: new Map(), + activitiesByList: new Map(), + assigneesByList: new Map(), + }); + assert.equal(rows[0].verification, "verified"); +}); + +test("site primary boop_sub + non-primary custom pending → 'pending'", () => { + const rows = explorer.deriveExplorerRows({ + lists: [], + sites: [makeSite({ _id: "S", primaryHostnameId: "host1" })], + hostnamesBySite: new Map([ + ["S", [ + makeHostname({ _id: "host1", kind: "boop_sub", cfStatus: undefined, isPrimary: true }), + makeHostname({ _id: "host2", kind: "custom", cfStatus: "pending_validation", isPrimary: false }), + ]], + ]), + publicationsByList: new Map(), + anchorsByList: new Map(), + activitiesByList: new Map(), + assigneesByList: new Map(), + }); + assert.equal(rows[0].verification, "pending"); +}); + +test("site primary custom but cfStatus !== active → 'pending'", () => { + const rows = explorer.deriveExplorerRows({ + lists: [], + sites: [makeSite({ _id: "S", primaryHostnameId: "host1" })], + hostnamesBySite: new Map([ + ["S", [makeHostname({ _id: "host1", kind: "custom", cfStatus: "pending_issuance", isPrimary: true })]], + ]), + publicationsByList: new Map(), + anchorsByList: new Map(), + activitiesByList: new Map(), + assigneesByList: new Map(), + }); + assert.equal(rows[0].verification, "pending"); +}); + +test("site only boop_sub primary, no custom rows → 'none'", () => { + const rows = explorer.deriveExplorerRows({ + lists: [], + sites: [makeSite({ _id: "S" })], + hostnamesBySite: new Map([["S", [makeHostname()]]]), + publicationsByList: new Map(), + anchorsByList: new Map(), + activitiesByList: new Map(), + assigneesByList: new Map(), + }); + assert.equal(rows[0].verification, "none"); +}); + +test("site with primaryHostnameId === null and no custom rows → 'none' (no crash)", () => { + const rows = explorer.deriveExplorerRows({ + lists: [], + sites: [makeSite({ _id: "S", primaryHostnameId: undefined })], + hostnamesBySite: new Map(), + publicationsByList: new Map(), + anchorsByList: new Map(), + activitiesByList: new Map(), + assigneesByList: new Map(), + }); + assert.equal(rows[0].verification, "none"); +}); + +test("verification precedence: anchored > verified > pending > none", () => { + // Construct a site with both 'verified' and 'pending' signals to exercise the precedence path. + const rows = explorer.deriveExplorerRows({ + lists: [], + sites: [makeSite({ _id: "S", primaryHostnameId: "host1" })], + hostnamesBySite: new Map([ + ["S", [ + makeHostname({ _id: "host1", kind: "custom", cfStatus: "active", isPrimary: true }), + makeHostname({ _id: "host2", kind: "custom", cfStatus: "pending_validation", isPrimary: false }), + ]], + ]), + publicationsByList: new Map(), + anchorsByList: new Map(), + activitiesByList: new Map(), + assigneesByList: new Map(), + }); + assert.equal(rows[0].verification, "verified"); +}); + +// applyExplorerFilters ----------------------------------------------------- + +function rowsForFilterTests() { + return explorer.deriveExplorerRows({ + lists: [makeList({ _id: "L", name: "Groceries" })], + sites: [makeSite({ _id: "S", primaryHostnameId: "host1" })], + hostnamesBySite: new Map([ + ["S", [makeHostname({ _id: "host1", hostname: "essay.brian.dev", kind: "custom", cfStatus: "active", isPrimary: true })]], + ]), + publicationsByList: new Map(), + anchorsByList: new Map(), + activitiesByList: new Map(), + assigneesByList: new Map(), + }); +} + +test("filter by kind list-only → excludes sites", () => { + const filtered = explorer.applyExplorerFilters(rowsForFilterTests(), { + kind: ["list"], layer: [], verify: [], q: "", + }); + assert.equal(filtered.length, 1); + assert.equal(filtered[0].source, "list"); +}); + +test("filter by layer did:webvh → excludes did:peer", () => { + const filtered = explorer.applyExplorerFilters(rowsForFilterTests(), { + kind: [], layer: ["did:webvh"], verify: [], q: "", + }); + assert.equal(filtered.length, 1); + assert.equal(filtered[0].source, "site"); +}); + +test("filter by verification 'anchored' → only anchored rows pass", () => { + const rows = explorer.deriveExplorerRows({ + lists: [ + makeList({ _id: "L1", name: "Anchored list" }), + makeList({ _id: "L2", name: "Plain list" }), + ], + sites: [], + hostnamesBySite: new Map(), + publicationsByList: new Map(), + anchorsByList: new Map([["L1", [{ status: "confirmed", confirmedAt: 1, txid: "tx" }]]]), + activitiesByList: new Map(), + assigneesByList: new Map(), + }); + const filtered = explorer.applyExplorerFilters(rows, { + kind: [], layer: [], verify: ["anchored"], q: "", + }); + assert.equal(filtered.length, 1); + assert.equal(filtered[0].id, "list:L1"); +}); + +test("empty multi-select group → no filter applied (all pass)", () => { + const filtered = explorer.applyExplorerFilters(rowsForFilterTests(), { + kind: [], layer: [], verify: [], q: "", + }); + assert.equal(filtered.length, 2); +}); + +test("case-insensitive title search; matches identifier too", () => { + const rows = rowsForFilterTests(); + const byTitle = explorer.applyExplorerFilters(rows, { + kind: [], layer: [], verify: [], q: "GROC", + }); + assert.equal(byTitle.length, 1); + assert.equal(byTitle[0].source, "list"); + + const byIdentifier = explorer.applyExplorerFilters(rows, { + kind: [], layer: [], verify: [], q: "essay", + }); + assert.equal(byIdentifier.length, 1); + assert.equal(byIdentifier[0].source, "site"); +}); + +// compareExplorerRows ------------------------------------------------------ + +test("sort: updatedAt desc primary, id asc tiebreaker", () => { + const a = { id: "list:b", updatedAt: 100, createdAt: 1, title: "x" }; + const b = { id: "list:a", updatedAt: 100, createdAt: 1, title: "x" }; + const sorted = [a, b].sort(explorer.compareExplorerRows({ key: "updated", dir: "desc" })); + assert.equal(sorted[0].id, "list:a"); // tiebreaker: id ascending + assert.equal(sorted[1].id, "list:b"); +}); + +test("sort: title A-Z stable with id tiebreaker", () => { + const a = { id: "list:b", updatedAt: 1, createdAt: 1, title: "Apple" }; + const b = { id: "list:a", updatedAt: 1, createdAt: 1, title: "Apple" }; + const sorted = [a, b].sort(explorer.compareExplorerRows({ key: "title", dir: "asc" })); + assert.equal(sorted[0].id, "list:a"); +}); + +// URL param encode/decode -------------------------------------------------- + +test("encodeFiltersToParams: csv values for multi-select", () => { + const params = explorer.encodeFiltersToParams({ + kind: ["list", "site"], + layer: ["did:webvh"], + verify: [], + q: "foo", + }); + assert.equal(params.get("kind"), "list,site"); + assert.equal(params.get("layer"), "did:webvh"); + assert.equal(params.get("verify"), null); // empty arrays are omitted + assert.equal(params.get("q"), "foo"); +}); + +test("decodeFiltersFromParams: csv → arrays; empty string q omitted", () => { + const decoded = explorer.decodeFiltersFromParams( + new URLSearchParams("kind=list,site&q=foo") + ); + assert.deepEqual(decoded.kind, ["list", "site"]); + assert.deepEqual(decoded.layer, []); + assert.deepEqual(decoded.verify, []); + assert.equal(decoded.q, "foo"); +}); + +test("URL round-trip preserves commas in q via encoding", () => { + const original = { kind: [], layer: [], verify: [], q: "hello, world" }; + const params = explorer.encodeFiltersToParams(original); + const url = `?${params.toString()}`; + // Build new URLSearchParams from the URL string + const parsed = new URLSearchParams(url.slice(1)); + const decoded = explorer.decodeFiltersFromParams(parsed); + assert.equal(decoded.q, "hello, world"); +}); + +await rm(outdir, { recursive: true, force: true }); diff --git a/src/lib/explorer.ts b/src/lib/explorer.ts new file mode 100644 index 0000000..931e20e --- /dev/null +++ b/src/lib/explorer.ts @@ -0,0 +1,238 @@ +/** + * Pure logic for the Originals Explorer. + * + * NOTE: This module must remain Node-safe — no DOM globals (window, localStorage). + * DOM-coupled helpers live in src/lib/explorerColumns.ts. + */ + +export type ExplorerSource = "list" | "site"; +export type ExplorerLayer = "did:peer" | "did:webvh" | "did:btco"; +export type ExplorerVerification = "verified" | "anchored" | "pending" | "none"; + +export interface ExplorerRow { + id: string; + source: ExplorerSource; + sourceId: string; + title: string; + identifier: string | null; + layer: ExplorerLayer; + verification: ExplorerVerification; + collaborators?: number; + anchorTxId?: string; + createdAt: number; + updatedAt: number; +} + +export interface ExplorerFilters { + kind: ExplorerSource[]; + layer: ExplorerLayer[]; + verify: ExplorerVerification[]; + q: string; +} + +export type ExplorerSortKey = "updated" | "created" | "title"; +export type ExplorerSortDir = "asc" | "desc"; + +export interface ExplorerSort { + key: ExplorerSortKey; + dir: ExplorerSortDir; +} + +// Raw input shapes (mirroring Convex docs at runtime; keep loose for testability). +interface RawList { + _id: string; + _creationTime: number; + name: string; + ownerDid: string; + assetDid: string; +} + +interface RawSite { + _id: string; + _creationTime: number; + ownerDid: string; + primaryHostnameId?: string; + createdAt: number; + updatedAt: number; + did: string; +} + +interface RawHostname { + _id: string; + siteId: string; + hostname: string; + kind: "boop_sub" | "custom"; + cfStatus?: string; + isPrimary: boolean; +} + +interface RawPublication { + status: "active" | "unpublished"; + webvhDid: string; + anchorTxId?: string; + anchorStatus?: string; +} + +interface RawAnchor { + status: "pending" | "inscribed" | "confirmed" | "failed"; + confirmedAt?: number; + inscribedAt?: number; + _creationTime?: number; + txid?: string; +} + +interface RawActivity { + createdAt: number; +} + +export interface DeriveInput { + lists: RawList[]; + sites: RawSite[]; + hostnamesBySite: Map; + publicationsByList: Map; + anchorsByList: Map; + activitiesByList: Map; + assigneesByList: Map; +} + +export function deriveExplorerRows(input: DeriveInput): ExplorerRow[] { + const rows: ExplorerRow[] = []; + + for (const list of input.lists) { + rows.push(deriveListRow(list, input)); + } + for (const site of input.sites) { + rows.push(deriveSiteRow(site, input)); + } + + rows.sort(compareExplorerRows({ key: "updated", dir: "desc" })); + return rows; +} + +function deriveListRow(list: RawList, input: DeriveInput): ExplorerRow { + const pub = input.publicationsByList.get(list._id); + const isPublished = pub?.status === "active"; + + const anchors = input.anchorsByList.get(list._id) ?? []; + const confirmed = anchors + .filter((a) => a.status === "confirmed") + .sort((a, b) => anchorSortKey(b) - anchorSortKey(a))[0]; + + let verification: ExplorerVerification = "none"; + if (confirmed) { + verification = "anchored"; + } else if (anchors.some((a) => a.status === "pending" || a.status === "inscribed")) { + verification = "pending"; + } + + const activity = input.activitiesByList.get(list._id); + const updatedAt = Math.max(list._creationTime, activity?.createdAt ?? 0); + + return { + id: `list:${list._id}`, + source: "list", + sourceId: list._id, + title: list.name, + identifier: isPublished ? pub.webvhDid : null, + layer: isPublished ? "did:webvh" : "did:peer", + verification, + collaborators: input.assigneesByList.get(list._id), + anchorTxId: confirmed?.txid, + createdAt: list._creationTime, + updatedAt, + }; +} + +function anchorSortKey(a: RawAnchor): number { + return a.confirmedAt ?? a.inscribedAt ?? a._creationTime ?? 0; +} + +function deriveSiteRow(site: RawSite, input: DeriveInput): ExplorerRow { + const allHostnames = input.hostnamesBySite.get(site._id) ?? []; + const primary = + site.primaryHostnameId != null + ? allHostnames.find((h) => h._id === site.primaryHostnameId) + : undefined; + + const title = primary?.hostname ?? "Untitled site"; + const identifier = primary?.hostname ?? null; + + const primaryIsActiveCustom = + primary != null && primary.kind === "custom" && primary.cfStatus === "active"; + const anyCustomPending = allHostnames.some( + (h) => h.kind === "custom" && h.cfStatus !== "active", + ); + + let verification: ExplorerVerification = "none"; + if (primaryIsActiveCustom) { + verification = "verified"; + } else if (anyCustomPending) { + verification = "pending"; + } + + return { + id: `site:${site._id}`, + source: "site", + sourceId: site._id, + title, + identifier, + layer: "did:webvh", + verification, + createdAt: site._creationTime, + updatedAt: site.updatedAt, + }; +} + +export function applyExplorerFilters(rows: ExplorerRow[], f: ExplorerFilters): ExplorerRow[] { + const q = f.q.trim().toLowerCase(); + return rows.filter((row) => { + if (f.kind.length > 0 && !f.kind.includes(row.source)) return false; + if (f.layer.length > 0 && !f.layer.includes(row.layer)) return false; + if (f.verify.length > 0 && !f.verify.includes(row.verification)) return false; + if (q.length > 0) { + const haystack = `${row.title} ${row.identifier ?? ""}`.toLowerCase(); + if (!haystack.includes(q)) return false; + } + return true; + }); +} + +export function compareExplorerRows(sort: ExplorerSort) { + return (a: ExplorerRow, b: ExplorerRow): number => { + let primary = 0; + if (sort.key === "updated") primary = a.updatedAt - b.updatedAt; + else if (sort.key === "created") primary = a.createdAt - b.createdAt; + else primary = a.title.localeCompare(b.title); + + if (sort.dir === "desc") primary = -primary; + if (primary !== 0) return primary; + + // Tiebreaker: id ascending (always) + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; + }; +} + +// URL params --------------------------------------------------------------- + +export function encodeFiltersToParams(f: ExplorerFilters): URLSearchParams { + const params = new URLSearchParams(); + if (f.kind.length > 0) params.set("kind", f.kind.join(",")); + if (f.layer.length > 0) params.set("layer", f.layer.join(",")); + if (f.verify.length > 0) params.set("verify", f.verify.join(",")); + if (f.q.length > 0) params.set("q", f.q); + return params; +} + +const VALID_KINDS = new Set(["list", "site"]); +const VALID_LAYERS = new Set(["did:peer", "did:webvh", "did:btco"]); +const VALID_VERIFY = new Set(["verified", "anchored", "pending", "none"]); + +export function decodeFiltersFromParams(p: URLSearchParams): ExplorerFilters { + const split = (raw: string | null) => (raw ? raw.split(",") : []); + return { + kind: split(p.get("kind")).filter((v): v is ExplorerSource => VALID_KINDS.has(v as ExplorerSource)), + layer: split(p.get("layer")).filter((v): v is ExplorerLayer => VALID_LAYERS.has(v as ExplorerLayer)), + verify: split(p.get("verify")).filter((v): v is ExplorerVerification => VALID_VERIFY.has(v as ExplorerVerification)), + q: p.get("q") ?? "", + }; +} From e3dc9f83200b304bd42102bab740a8d84db4ed37 Mon Sep 17 00:00:00 2001 From: Brian Richter Date: Fri, 8 May 2026 00:07:55 -0700 Subject: [PATCH 03/14] chore(test): exclude bun test files from app tsconfig Tests use bun:test types via the bun runtime; including them under tsc -b trips TS2307 because @types/bun is not installed (and we'd rather not pull it in just to silence type errors on test-only code). Tests are run by bun, not vite, so excluding them from the app build keeps tsc -b green. Co-Authored-By: Claude Opus 4.7 (1M context) --- tsconfig.app.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tsconfig.app.json b/tsconfig.app.json index fb44ff4..c9e5a46 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -28,5 +28,6 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/__tests__", "src/**/*.test.ts", "src/**/*.test.tsx"] } From 5bef8c34e7a1de4023f15d3a241004082fd7a4b0 Mon Sep 17 00:00:00 2001 From: Brian Richter Date: Fri, 8 May 2026 00:14:56 -0700 Subject: [PATCH 04/14] feat(explorer): column toggle persistence with corrupt-storage resilience --- src/lib/explorerColumns.test.ts | 34 +++++++++++++++++++++++++ src/lib/explorerColumns.ts | 45 +++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 src/lib/explorerColumns.test.ts create mode 100644 src/lib/explorerColumns.ts diff --git a/src/lib/explorerColumns.test.ts b/src/lib/explorerColumns.test.ts new file mode 100644 index 0000000..028a5d3 --- /dev/null +++ b/src/lib/explorerColumns.test.ts @@ -0,0 +1,34 @@ +import { test, expect, beforeEach } from "bun:test"; +import { + loadColumnPrefs, + saveColumnPrefs, + DEFAULT_COLUMN_PREFS, + type ColumnPrefs, +} from "./explorerColumns"; + +const KEY = "boop:explorer:columns"; + +beforeEach(() => { + localStorage.clear(); +}); + +test("loadColumnPrefs returns defaults when storage is empty", () => { + expect(loadColumnPrefs()).toEqual(DEFAULT_COLUMN_PREFS); +}); + +test("saveColumnPrefs round-trips through localStorage", () => { + const next: ColumnPrefs = { identifier: true, collaborators: false, anchorTxidPrefix: true }; + saveColumnPrefs(next); + expect(localStorage.getItem(KEY)).toBe(JSON.stringify(next)); + expect(loadColumnPrefs()).toEqual(next); +}); + +test("loadColumnPrefs falls back to defaults on corrupt JSON", () => { + localStorage.setItem(KEY, "{not json"); + expect(loadColumnPrefs()).toEqual(DEFAULT_COLUMN_PREFS); +}); + +test("loadColumnPrefs falls back to defaults on partial/invalid shape", () => { + localStorage.setItem(KEY, JSON.stringify({ identifier: "yes" })); + expect(loadColumnPrefs()).toEqual(DEFAULT_COLUMN_PREFS); +}); diff --git a/src/lib/explorerColumns.ts b/src/lib/explorerColumns.ts new file mode 100644 index 0000000..89791a2 --- /dev/null +++ b/src/lib/explorerColumns.ts @@ -0,0 +1,45 @@ +/** + * Column toggle persistence for the Originals Explorer. + * + * DOM-coupled (uses localStorage). Tested under bun test + happy-dom. + * NOT bundled into Node-side scripts. + */ + +const STORAGE_KEY = "boop:explorer:columns"; + +export interface ColumnPrefs { + identifier: boolean; + collaborators: boolean; + anchorTxidPrefix: boolean; +} + +export const DEFAULT_COLUMN_PREFS: ColumnPrefs = { + identifier: false, + collaborators: false, + anchorTxidPrefix: false, +}; + +function isValidColumnPrefs(value: unknown): value is ColumnPrefs { + if (!value || typeof value !== "object") return false; + const v = value as Record; + return ( + typeof v.identifier === "boolean" && + typeof v.collaborators === "boolean" && + typeof v.anchorTxidPrefix === "boolean" + ); +} + +export function loadColumnPrefs(): ColumnPrefs { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return DEFAULT_COLUMN_PREFS; + const parsed = JSON.parse(raw); + return isValidColumnPrefs(parsed) ? parsed : DEFAULT_COLUMN_PREFS; + } catch { + return DEFAULT_COLUMN_PREFS; + } +} + +export function saveColumnPrefs(prefs: ColumnPrefs): void { + localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs)); +} From 4c535c64f7b3ef1e3c1ff35abf78d8ee799e666d Mon Sep 17 00:00:00 2001 From: Brian Richter Date: Fri, 8 May 2026 00:19:41 -0700 Subject: [PATCH 05/14] feat(explorer): listOwnedOriginals Convex query joining 7 source tables Co-Authored-By: Claude Sonnet 4.6 --- convex/originals.ts | 107 +++++++++++++++++++ scripts/originals-query.test.mjs | 172 +++++++++++++++++++++++++++++++ 2 files changed, 279 insertions(+) create mode 100644 convex/originals.ts create mode 100644 scripts/originals-query.test.mjs diff --git a/convex/originals.ts b/convex/originals.ts new file mode 100644 index 0000000..f5e43d2 --- /dev/null +++ b/convex/originals.ts @@ -0,0 +1,107 @@ +/** + * Originals Explorer — unified read-only query. + * + * Joins lists, sites, siteHostnames, publications, bitcoinAnchors, activities, + * itemAssignees into a single ExplorerRow[] sorted by updatedAt desc with + * id-ascending tiebreaker. Pure derivation logic lives in src/lib/explorer.ts. + */ + +import { v } from "convex/values"; +import { query } from "./_generated/server"; +import { + deriveExplorerRows, + type DeriveInput, + type ExplorerRow, +} from "../src/lib/explorer"; + +export const listOwnedOriginals = query({ + args: { ownerDid: v.string() }, + handler: async (ctx, args): Promise => { + const [lists, sites] = await Promise.all([ + ctx.db + .query("lists") + .withIndex("by_owner", (q) => q.eq("ownerDid", args.ownerDid)) + .collect(), + ctx.db + .query("sites") + .withIndex("by_owner", (q) => q.eq("ownerDid", args.ownerDid)) + .collect(), + ]); + + // Per-site joins: hostnames. + const hostnamesBySite = new Map(); + await Promise.all( + sites.map(async (site) => { + const hostnames = await ctx.db + .query("siteHostnames") + .withIndex("by_site", (q) => q.eq("siteId", site._id)) + .collect(); + hostnamesBySite.set(site._id, hostnames); + }), + ); + + // Per-list joins: publication, anchors, latest activity, assignee count. + const publicationsByList = new Map(); + const anchorsByList = new Map(); + const activitiesByList = new Map(); + const assigneesByList = new Map(); + + await Promise.all( + lists.map(async (list) => { + const [pub, anchors, latestActivity, assignees] = await Promise.all([ + ctx.db + .query("publications") + .withIndex("by_list", (q) => q.eq("listId", list._id)) + .first(), + ctx.db + .query("bitcoinAnchors") + .withIndex("by_list", (q) => q.eq("listId", list._id)) + .collect(), + ctx.db + .query("activities") + .withIndex("by_list_created", (q) => q.eq("listId", list._id)) + .order("desc") + .first(), + ctx.db + .query("itemAssignees") + .withIndex("by_list", (q) => q.eq("listId", list._id)) + .collect(), + ]); + + if (pub) publicationsByList.set(list._id, pub); + if (anchors.length > 0) anchorsByList.set(list._id, anchors); + if (latestActivity) activitiesByList.set(list._id, { createdAt: latestActivity.createdAt }); + + const uniqueAssignees = new Set(); + for (const a of assignees) uniqueAssignees.add(a.assigneeDid); + if (uniqueAssignees.size > 0) assigneesByList.set(list._id, uniqueAssignees.size); + }), + ); + + const input: DeriveInput = { + lists: lists.map((l) => ({ + _id: l._id, + _creationTime: l._creationTime, + name: l.name, + ownerDid: l.ownerDid, + assetDid: l.assetDid, + })), + sites: sites.map((s) => ({ + _id: s._id, + _creationTime: s._creationTime, + ownerDid: s.ownerDid, + primaryHostnameId: s.primaryHostnameId, + createdAt: s.createdAt, + updatedAt: s.updatedAt, + did: s.did, + })), + hostnamesBySite, + publicationsByList, + anchorsByList, + activitiesByList, + assigneesByList, + }; + + return deriveExplorerRows(input); + }, +}); diff --git a/scripts/originals-query.test.mjs b/scripts/originals-query.test.mjs new file mode 100644 index 0000000..e173ced --- /dev/null +++ b/scripts/originals-query.test.mjs @@ -0,0 +1,172 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, rm } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; +import { build } from "esbuild"; + +const outdir = "tmp/originals-query-test"; + +async function loadModule() { + await rm(outdir, { recursive: true, force: true }); + await mkdir(outdir, { recursive: true }); + await build({ + entryPoints: ["./convex/originals.ts"], + outfile: `${outdir}/originals.mjs`, + bundle: true, + platform: "node", + format: "esm", + target: "node20", + external: ["convex/*"], + }); + return import( + `${pathToFileURL(`${process.cwd()}/${outdir}/originals.mjs`).href}?t=${Date.now()}` + ); +} + +const mod = await loadModule(); +const handler = mod.listOwnedOriginals._handler ?? mod.listOwnedOriginals.handler; + +// In-memory ctx.db mock matching Convex's surface. +function makeDb(tables) { + const all = new Map(); + for (const [name, rows] of Object.entries(tables)) { + for (const row of rows) all.set(row._id, row); + } + return { + query(tableName) { + return makeQuery(tables[tableName] ?? []); + }, + async get(id) { + return all.get(id) ?? null; + }, + }; +} + +function makeQuery(rows) { + let working = rows.slice(); + let order = "asc"; + return { + withIndex(_name, fn) { + const ops = []; + const builder = { + eq(field, value) { ops.push(["eq", field, value]); return builder; }, + }; + fn(builder); + working = working.filter((r) => + ops.every(([op, field, value]) => op === "eq" && r[field] === value) + ); + return this; + }, + order(dir) { order = dir; return this; }, + async collect() { + const sorted = working.slice().sort((a, b) => a._creationTime - b._creationTime); + return order === "desc" ? sorted.reverse() : sorted; + }, + async first() { + const all = await this.collect(); + return all[0] ?? null; + }, + }; +} + +test("ownerDid filter applied", async () => { + const ctx = { + db: makeDb({ + lists: [ + { _id: "L1", _creationTime: 1, name: "Mine", ownerDid: "me", assetDid: "did:peer:L1" }, + { _id: "L2", _creationTime: 1, name: "Theirs", ownerDid: "other", assetDid: "did:peer:L2" }, + ], + sites: [], + siteHostnames: [], + publications: [], + bitcoinAnchors: [], + activities: [], + itemAssignees: [], + }), + }; + const rows = await handler(ctx, { ownerDid: "me" }); + assert.equal(rows.length, 1); + assert.equal(rows[0].title, "Mine"); +}); + +test("joins all source tables and produces rows in updatedAt desc order", async () => { + const ctx = { + db: makeDb({ + lists: [{ _id: "L1", _creationTime: 100, name: "A list", ownerDid: "me", assetDid: "did:peer:L1" }], + sites: [{ _id: "S1", _creationTime: 50, ownerDid: "me", scid: "scid", did: "did:webvh:s1", primaryHostnameId: "H1", fileId: "F1", createdAt: 50, updatedAt: 9999 }], + siteHostnames: [{ _id: "H1", siteId: "S1", hostname: "brisk-paper-07.boop.ad", kind: "boop_sub", status: "active", isPrimary: true, createdAt: 50, updatedAt: 50 }], + publications: [{ _id: "P1", listId: "L1", webvhDid: "did:webvh:L1", status: "active", publishedAt: 200, publishedByDid: "me" }], + bitcoinAnchors: [{ _id: "A1", listId: "L1", status: "confirmed", confirmedAt: 300, txid: "tx-abc", contentHash: "h", requestedByDid: "me", createdAt: 300 }], + activities: [{ _id: "ACT1", listId: "L1", actorDid: "me", type: "list_updated", createdAt: 500 }], + itemAssignees: [ + { _id: "IA1", itemId: "I1", listId: "L1", assigneeDid: "u1", assignedByDid: "me", assignedAt: 1 }, + { _id: "IA2", itemId: "I2", listId: "L1", assigneeDid: "u1", assignedByDid: "me", assignedAt: 1 }, + { _id: "IA3", itemId: "I3", listId: "L1", assigneeDid: "u2", assignedByDid: "me", assignedAt: 1 }, + ], + }), + }; + const rows = await handler(ctx, { ownerDid: "me" }); + assert.equal(rows.length, 2); + assert.equal(rows[0].source, "site"); + assert.equal(rows[1].source, "list"); + assert.equal(rows[1].layer, "did:webvh"); + assert.equal(rows[1].verification, "anchored"); + assert.equal(rows[1].anchorTxId, "tx-abc"); + assert.equal(rows[1].collaborators, 2); + assert.equal(rows[1].updatedAt, 500); +}); + +test("missing optional joins do not crash", async () => { + const ctx = { + db: makeDb({ + lists: [{ _id: "L1", _creationTime: 100, name: "Bare", ownerDid: "me", assetDid: "did:peer:L1" }], + sites: [], + siteHostnames: [], + publications: [], + bitcoinAnchors: [], + activities: [], + itemAssignees: [], + }), + }; + const rows = await handler(ctx, { ownerDid: "me" }); + assert.equal(rows[0].layer, "did:peer"); + assert.equal(rows[0].verification, "none"); + assert.equal(rows[0].collaborators, undefined); +}); + +test("a site never reports anchored", async () => { + const ctx = { + db: makeDb({ + lists: [], + sites: [{ _id: "S1", _creationTime: 50, ownerDid: "me", scid: "scid", did: "did:webvh:s1", primaryHostnameId: "H1", fileId: "F1", createdAt: 50, updatedAt: 50 }], + siteHostnames: [{ _id: "H1", siteId: "S1", hostname: "brisk-paper-07.boop.ad", kind: "boop_sub", status: "active", isPrimary: true, createdAt: 50, updatedAt: 50 }], + publications: [], + bitcoinAnchors: [], + activities: [], + itemAssignees: [], + }), + }; + const rows = await handler(ctx, { ownerDid: "me" }); + assert.notEqual(rows[0].verification, "anchored"); +}); + +test("multiple confirmed anchors → most recent confirmedAt wins", async () => { + const ctx = { + db: makeDb({ + lists: [{ _id: "L1", _creationTime: 100, name: "Anchored", ownerDid: "me", assetDid: "did:peer:L1" }], + sites: [], + siteHostnames: [], + publications: [], + bitcoinAnchors: [ + { _id: "A1", listId: "L1", status: "confirmed", confirmedAt: 100, txid: "older", contentHash: "h", requestedByDid: "me", createdAt: 1 }, + { _id: "A2", listId: "L1", status: "confirmed", confirmedAt: 500, txid: "newer", contentHash: "h", requestedByDid: "me", createdAt: 2 }, + ], + activities: [], + itemAssignees: [], + }), + }; + const rows = await handler(ctx, { ownerDid: "me" }); + assert.equal(rows[0].anchorTxId, "newer"); +}); + +await rm(outdir, { recursive: true, force: true }); From b53eb60e3cc768f07bcd8bfd9198c0b590d8bea1 Mon Sep 17 00:00:00 2001 From: Brian Richter Date: Fri, 8 May 2026 00:23:37 -0700 Subject: [PATCH 06/14] fix(explorer): tighten Map types from any to Doc Lint review caught three @typescript-eslint/no-explicit-any errors on the per-site/per-list join Maps. Convex's auto-generated Doc types are the right fit and require no schema changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- convex/originals.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/convex/originals.ts b/convex/originals.ts index f5e43d2..65feabf 100644 --- a/convex/originals.ts +++ b/convex/originals.ts @@ -8,6 +8,7 @@ import { v } from "convex/values"; import { query } from "./_generated/server"; +import type { Doc } from "./_generated/dataModel"; import { deriveExplorerRows, type DeriveInput, @@ -29,7 +30,7 @@ export const listOwnedOriginals = query({ ]); // Per-site joins: hostnames. - const hostnamesBySite = new Map(); + const hostnamesBySite = new Map[]>(); await Promise.all( sites.map(async (site) => { const hostnames = await ctx.db @@ -41,8 +42,8 @@ export const listOwnedOriginals = query({ ); // Per-list joins: publication, anchors, latest activity, assignee count. - const publicationsByList = new Map(); - const anchorsByList = new Map(); + const publicationsByList = new Map>(); + const anchorsByList = new Map[]>(); const activitiesByList = new Map(); const assigneesByList = new Map(); From 2c513c22fd9e2293080167376e178a443dafb62a Mon Sep 17 00:00:00 2001 From: Brian Richter Date: Fri, 8 May 2026 00:28:09 -0700 Subject: [PATCH 07/14] feat(explorer): URL-synced filter/sort hook with debounced search Co-Authored-By: Claude Sonnet 4.6 --- bun.lock | 23 +++++ package.json | 1 + src/hooks/useExplorerFilters.test.tsx | 77 +++++++++++++++ src/hooks/useExplorerFilters.ts | 132 ++++++++++++++++++++++++++ 4 files changed, 233 insertions(+) create mode 100644 src/hooks/useExplorerFilters.test.tsx create mode 100644 src/hooks/useExplorerFilters.ts diff --git a/bun.lock b/bun.lock index 1f87504..37220be 100644 --- a/bun.lock +++ b/bun.lock @@ -41,6 +41,7 @@ "@happy-dom/global-registrator": "^20.9.0", "@napi-rs/canvas": "^0.1.90", "@tailwindcss/vite": "^4.1.18", + "@testing-library/react": "^16.3.2", "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", @@ -97,6 +98,8 @@ "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], + "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], "@babel/traverse": ["@babel/traverse@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.6", "@babel/template": "^7.28.6", "@babel/types": "^7.28.6", "debug": "^4.3.1" } }, "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg=="], @@ -517,6 +520,10 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="], + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], + + "@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="], + "@trapezedev/gradle-parse": ["@trapezedev/gradle-parse@7.1.3", "", {}, "sha512-WQVF5pEJ5o/mUyvfGTG9nBKx9Te/ilKM3r2IT69GlbaooItT5ao7RyF1MUTBNjHLPk/xpGUY3c6PyVnjDlz0Vw=="], "@trapezedev/project": ["@trapezedev/project@7.1.3", "", { "dependencies": { "@ionic/utils-fs": "^3.1.5", "@ionic/utils-subprocess": "^2.1.8", "@prettier/plugin-xml": "^2.2.0", "@trapezedev/gradle-parse": "7.1.3", "@xmldom/xmldom": "^0.7.5", "conventional-changelog": "^3.1.4", "cross-spawn": "^7.0.3", "diff": "^5.1.0", "env-paths": "^3.0.0", "gradle-to-js": "^2.0.0", "ini": "^2.0.0", "kleur": "^4.1.5", "lodash": "^4.17.21", "mergexml": "^1.2.3", "plist": "^3.0.4", "prettier": "^2.7.1", "prompts": "^2.4.2", "replace": "^1.1.0", "tempy": "^1.0.1", "tmp": "^0.2.1", "ts-node": "^10.2.1", "xcode": "^3.0.1", "xml-js": "^1.6.11", "xpath": "^0.0.32", "yargs": "^17.2.1" } }, "sha512-GANh8Ey73MechZrryfJoILY9hBnWqzS6AdB53zuWBCBbaiImyblXT41fWdN6pB2f5+cNI2FAUxGfVhl+LeEVbQ=="], @@ -547,6 +554,8 @@ "@turnkey/webauthn-stamper": ["@turnkey/webauthn-stamper@0.6.0", "", { "dependencies": { "sha256-uint8array": "^0.10.7" } }, "sha512-jdN17QEnn7RBykEOhtKIialWmDjnDAH8DzbyITwn8jsKcwT1TBNYge89hTUTjbdsDLBAqQw8cHujPdy0RaAqvw=="], + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], @@ -689,6 +698,8 @@ "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + "array-ify": ["array-ify@1.0.0", "", {}, "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng=="], "array-union": ["array-union@2.1.0", "", {}, "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw=="], @@ -957,6 +968,8 @@ "del": ["del@6.1.1", "", { "dependencies": { "globby": "^11.0.1", "graceful-fs": "^4.2.4", "is-glob": "^4.0.1", "is-path-cwd": "^2.2.0", "is-path-inside": "^3.0.2", "p-map": "^4.0.0", "rimraf": "^3.0.2", "slash": "^3.0.0" } }, "sha512-ua8BhapfP0JUJKC/zV9yHHDW/rDoDxP4Zhn3AkA6/xT6gY7jYXJiaeyBZznYVujhZZET+UgcbZiQ7sN3WqcImg=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + "des.js": ["des.js@1.1.0", "", { "dependencies": { "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0" } }, "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg=="], "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], @@ -975,6 +988,8 @@ "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], + "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + "dom-serializer": ["dom-serializer@1.4.1", "", { "dependencies": { "domelementtype": "^2.0.1", "domhandler": "^4.2.0", "entities": "^2.0.0" } }, "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag=="], "domain-browser": ["domain-browser@4.22.0", "", {}, "sha512-IGBwjF7tNk3cwypFNH/7bfzBcgSCbaMOD3GsaY1AU/JRrnHnYgEM0+9kQt52iZxjNsjBtJYtao146V+f8jFZNw=="], @@ -1377,6 +1392,8 @@ "lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "make-error": ["make-error@1.3.6", "", {}, "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw=="], @@ -1573,6 +1590,8 @@ "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], + "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], @@ -1627,6 +1646,8 @@ "react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="], + "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], "react-router": ["react-router@7.12.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw=="], @@ -2265,6 +2286,8 @@ "prebuild-install/tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], + "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], "pvtsutils/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], diff --git a/package.json b/package.json index fc1e1d8..cfaecaa 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "@happy-dom/global-registrator": "^20.9.0", "@napi-rs/canvas": "^0.1.90", "@tailwindcss/vite": "^4.1.18", + "@testing-library/react": "^16.3.2", "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", diff --git a/src/hooks/useExplorerFilters.test.tsx b/src/hooks/useExplorerFilters.test.tsx new file mode 100644 index 0000000..ca17ea3 --- /dev/null +++ b/src/hooks/useExplorerFilters.test.tsx @@ -0,0 +1,77 @@ +import { test, expect, beforeEach, afterEach, spyOn } from "bun:test"; +import { renderHook, act } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import * as React from "react"; +import { useExplorerFilters } from "./useExplorerFilters"; +import * as columns from "../lib/explorerColumns"; + +beforeEach(() => { + localStorage.clear(); +}); + +function wrapper(initialEntries: string[]) { + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); +} + +test("URL search params round-trip into state", () => { + const { result } = renderHook(() => useExplorerFilters(), { + wrapper: wrapper(["/e?kind=list,site&layer=did:webvh&q=brisk"]), + }); + expect(result.current.filters.kind).toEqual(["list", "site"]); + expect(result.current.filters.layer).toEqual(["did:webvh"]); + expect(result.current.filters.q).toBe("brisk"); +}); + +test("Default filter state when no params present", () => { + const { result } = renderHook(() => useExplorerFilters(), { + wrapper: wrapper(["/e"]), + }); + expect(result.current.filters.kind).toEqual([]); + expect(result.current.filters.layer).toEqual([]); + expect(result.current.filters.verify).toEqual([]); + expect(result.current.filters.q).toBe(""); +}); + +test("toggleKindChip pushes a new history entry", () => { + const { result } = renderHook(() => useExplorerFilters(), { + wrapper: wrapper(["/e"]), + }); + act(() => result.current.toggleKindChip("list")); + expect(result.current.filters.kind).toEqual(["list"]); + // Toggling again removes + act(() => result.current.toggleKindChip("list")); + expect(result.current.filters.kind).toEqual([]); +}); + +test("setSearchQuery debounces and uses replace (history doesn't grow per keystroke)", async () => { + const { result } = renderHook(() => useExplorerFilters({ debounceMs: 30 }), { + wrapper: wrapper(["/e"]), + }); + act(() => result.current.setSearchQuery("a")); + act(() => result.current.setSearchQuery("ab")); + act(() => result.current.setSearchQuery("abc")); + // Ephemeral reflects immediately + expect(result.current.searchInput).toBe("abc"); + // URL hasn't been written yet + expect(result.current.filters.q).toBe(""); + await new Promise((r) => setTimeout(r, 60)); + // After debounce, URL/state catches up + expect(result.current.filters.q).toBe("abc"); +}); + +test("Hook delegates column writes to saveColumnPrefs", () => { + const spy = spyOn(columns, "saveColumnPrefs"); + try { + const { result } = renderHook(() => useExplorerFilters(), { + wrapper: wrapper(["/e"]), + }); + act(() => result.current.toggleColumn("identifier")); + expect(spy).toHaveBeenCalled(); + // And it actually round-trips through localStorage: + expect(columns.loadColumnPrefs().identifier).toBe(true); + } finally { + spy.mockRestore(); + } +}); diff --git a/src/hooks/useExplorerFilters.ts b/src/hooks/useExplorerFilters.ts new file mode 100644 index 0000000..0810f52 --- /dev/null +++ b/src/hooks/useExplorerFilters.ts @@ -0,0 +1,132 @@ +import { useCallback, useMemo, useRef, useState } from "react"; +import { useSearchParams } from "react-router-dom"; +import { + decodeFiltersFromParams, + encodeFiltersToParams, + type ExplorerFilters, + type ExplorerSource, + type ExplorerLayer, + type ExplorerVerification, + type ExplorerSort, +} from "../lib/explorer"; +import { + loadColumnPrefs, + saveColumnPrefs, + type ColumnPrefs, +} from "../lib/explorerColumns"; + +const DEFAULT_SORT: ExplorerSort = { key: "updated", dir: "desc" }; +const DEFAULT_DEBOUNCE_MS = 250; + +export interface UseExplorerFiltersOptions { + debounceMs?: number; +} + +export function useExplorerFilters(opts: UseExplorerFiltersOptions = {}) { + const [searchParams, setSearchParams] = useSearchParams(); + const debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS; + + const filters: ExplorerFilters = useMemo( + () => decodeFiltersFromParams(searchParams), + [searchParams], + ); + + // Ephemeral search input — tracks keystrokes before the debounce fires. + // We store the in-flight value as state and track the last-seen URL q in + // state too, so we can reset without a useEffect call. + const [searchInput, setSearchInput] = useState(filters.q); + const [prevUrlQ, setPrevUrlQ] = useState(filters.q); + const debounceTimer = useRef | null>(null); + + // Idiomatic derived-state reset: when the URL's q changes (e.g. back-button), + // synchronously reset the input to match. This is the pattern React docs + // recommend instead of useEffect for "getDerivedStateFromProps"-style logic. + if (filters.q !== prevUrlQ) { + setPrevUrlQ(filters.q); + setSearchInput(filters.q); + } + + const writeFilters = useCallback( + (next: ExplorerFilters, options: { replace?: boolean } = {}) => { + setSearchParams(encodeFiltersToParams(next), { replace: options.replace }); + }, + [setSearchParams], + ); + + const setSearchQuery = useCallback( + (q: string) => { + setSearchInput(q); + if (debounceTimer.current) clearTimeout(debounceTimer.current); + debounceTimer.current = setTimeout(() => { + writeFilters({ ...filters, q }, { replace: true }); + }, debounceMs); + }, + [filters, writeFilters, debounceMs], + ); + + const toggleArrayChip = useCallback( + (key: "kind" | "layer" | "verify", value: T) => { + const current = filters[key] as T[]; + const next = current.includes(value) + ? current.filter((v) => v !== value) + : [...current, value]; + writeFilters({ ...filters, [key]: next }); + }, + [filters, writeFilters], + ); + + const toggleKindChip = useCallback( + (v: ExplorerSource) => toggleArrayChip("kind", v), + [toggleArrayChip], + ); + const toggleLayerChip = useCallback( + (v: ExplorerLayer) => toggleArrayChip("layer", v), + [toggleArrayChip], + ); + const toggleVerifyChip = useCallback( + (v: ExplorerVerification) => toggleArrayChip("verify", v), + [toggleArrayChip], + ); + + // Sort lives in URL too (key + dir). Default if unset. + const sort: ExplorerSort = useMemo(() => { + const key = (searchParams.get("sort") as ExplorerSort["key"] | null) ?? DEFAULT_SORT.key; + const dir = (searchParams.get("dir") as ExplorerSort["dir"] | null) ?? DEFAULT_SORT.dir; + return { key, dir }; + }, [searchParams]); + + const setSort = useCallback( + (next: ExplorerSort) => { + const params = encodeFiltersToParams(filters); + params.set("sort", next.key); + params.set("dir", next.dir); + setSearchParams(params); + }, + [filters, setSearchParams], + ); + + // Columns: localStorage-backed via explorerColumns.ts. + const [columns, setColumns] = useState(() => loadColumnPrefs()); + + const toggleColumn = useCallback( + (key: keyof ColumnPrefs) => { + const next = { ...columns, [key]: !columns[key] }; + saveColumnPrefs(next); + setColumns(next); + }, + [columns], + ); + + return { + filters, + searchInput, + setSearchQuery, + toggleKindChip, + toggleLayerChip, + toggleVerifyChip, + sort, + setSort, + columns, + toggleColumn, + }; +} From 979cbcb98a68e964eac82b58e781a87db93790b6 Mon Sep 17 00:00:00 2001 From: Brian Richter Date: Fri, 8 May 2026 00:31:13 -0700 Subject: [PATCH 08/14] feat(explorer): KindBadge, LayerBadge, RowVerificationBadge atoms --- src/components/explorer/KindBadge.tsx | 13 +++++ src/components/explorer/LayerBadge.tsx | 9 ++++ .../explorer/RowVerificationBadge.tsx | 19 +++++++ src/components/explorer/badges.test.tsx | 54 +++++++++++++++++++ 4 files changed, 95 insertions(+) create mode 100644 src/components/explorer/KindBadge.tsx create mode 100644 src/components/explorer/LayerBadge.tsx create mode 100644 src/components/explorer/RowVerificationBadge.tsx create mode 100644 src/components/explorer/badges.test.tsx diff --git a/src/components/explorer/KindBadge.tsx b/src/components/explorer/KindBadge.tsx new file mode 100644 index 0000000..d729541 --- /dev/null +++ b/src/components/explorer/KindBadge.tsx @@ -0,0 +1,13 @@ +import type { ExplorerSource } from "../../lib/explorer"; + +export function KindBadge({ kind }: { kind: ExplorerSource }) { + const styles = + kind === "list" + ? "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200" + : "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200"; + return ( + + {kind} + + ); +} diff --git a/src/components/explorer/LayerBadge.tsx b/src/components/explorer/LayerBadge.tsx new file mode 100644 index 0000000..50cc6f1 --- /dev/null +++ b/src/components/explorer/LayerBadge.tsx @@ -0,0 +1,9 @@ +import type { ExplorerLayer } from "../../lib/explorer"; + +export function LayerBadge({ layer }: { layer: ExplorerLayer }) { + return ( + + {layer} + + ); +} diff --git a/src/components/explorer/RowVerificationBadge.tsx b/src/components/explorer/RowVerificationBadge.tsx new file mode 100644 index 0000000..172f1bf --- /dev/null +++ b/src/components/explorer/RowVerificationBadge.tsx @@ -0,0 +1,19 @@ +import type { ExplorerVerification } from "../../lib/explorer"; + +export function RowVerificationBadge({ verification }: { verification: ExplorerVerification }) { + if (verification === "none") return null; + + const config = { + verified: { icon: "✓", label: "Verified", color: "text-green-600 dark:text-green-400" }, + anchored: { icon: "⛓", label: "Anchored", color: "text-purple-600 dark:text-purple-400" }, + pending: { icon: "⏳", label: "Pending", color: "text-stone-500 dark:text-stone-400" }, + } as const; + + const { icon, label, color } = config[verification]; + return ( + + + {label} + + ); +} diff --git a/src/components/explorer/badges.test.tsx b/src/components/explorer/badges.test.tsx new file mode 100644 index 0000000..f3472f1 --- /dev/null +++ b/src/components/explorer/badges.test.tsx @@ -0,0 +1,54 @@ +import { test, expect } from "bun:test"; +import { createRoot } from "react-dom/client"; +import { act } from "react"; +import { KindBadge } from "./KindBadge"; +import { LayerBadge } from "./LayerBadge"; +import { RowVerificationBadge } from "./RowVerificationBadge"; + +function renderInto(node: React.ReactNode) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => root.render(<>{node})); + return { + container, + cleanup: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +test("KindBadge renders 'list' label", () => { + const { container, cleanup } = renderInto(); + expect(container.textContent).toContain("list"); + cleanup(); +}); + +test("KindBadge renders 'site' label", () => { + const { container, cleanup } = renderInto(); + expect(container.textContent).toContain("site"); + cleanup(); +}); + +test("LayerBadge renders did:peer / did:webvh / did:btco", () => { + for (const layer of ["did:peer", "did:webvh", "did:btco"] as const) { + const { container, cleanup } = renderInto(); + expect(container.textContent).toContain(layer); + cleanup(); + } +}); + +test("RowVerificationBadge renders nothing for 'none'", () => { + const { container, cleanup } = renderInto(); + expect(container.textContent).toBe(""); + cleanup(); +}); + +test("RowVerificationBadge renders for 'verified', 'anchored', 'pending'", () => { + for (const v of ["verified", "anchored", "pending"] as const) { + const { container, cleanup } = renderInto(); + expect(container.textContent?.length).toBeGreaterThan(0); + cleanup(); + } +}); From d98f0214304885a9539b24367cb04404a4ae4328 Mon Sep 17 00:00:00 2001 From: Brian Richter Date: Fri, 8 May 2026 00:33:56 -0700 Subject: [PATCH 09/14] feat(explorer): ExplorerRow with toggleable columns and verification badge Co-Authored-By: Claude Sonnet 4.6 --- src/components/explorer/ExplorerRow.test.tsx | 135 +++++++++++++++++++ src/components/explorer/ExplorerRow.tsx | 74 ++++++++++ 2 files changed, 209 insertions(+) create mode 100644 src/components/explorer/ExplorerRow.test.tsx create mode 100644 src/components/explorer/ExplorerRow.tsx diff --git a/src/components/explorer/ExplorerRow.test.tsx b/src/components/explorer/ExplorerRow.test.tsx new file mode 100644 index 0000000..636867a --- /dev/null +++ b/src/components/explorer/ExplorerRow.test.tsx @@ -0,0 +1,135 @@ +import { test, expect } from "bun:test"; +import { createRoot } from "react-dom/client"; +import { act } from "react"; +import { MemoryRouter, Routes, Route } from "react-router-dom"; +import { ExplorerRow } from "./ExplorerRow"; +import type { ExplorerRow as ExplorerRowType } from "../../lib/explorer"; +import { DEFAULT_COLUMN_PREFS } from "../../lib/explorerColumns"; + +const baseList: ExplorerRowType = { + id: "list:L1", + source: "list", + sourceId: "L1", + title: "Groceries", + identifier: null, + layer: "did:peer", + verification: "none", + createdAt: 1, + updatedAt: 1, +}; + +const baseSite: ExplorerRowType = { + id: "site:S1", + source: "site", + sourceId: "S1", + title: "brisk-paper-07.boop.ad", + identifier: "brisk-paper-07.boop.ad", + layer: "did:webvh", + verification: "verified", + createdAt: 1, + updatedAt: 1, +}; + +function renderRow(row: ExplorerRowType, columns = DEFAULT_COLUMN_PREFS) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render( + + + } /> + + , + ); + }); + return { + container, + cleanup: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +test("renders title", () => { + const { container, cleanup } = renderRow(baseList); + expect(container.textContent).toContain("Groceries"); + cleanup(); +}); + +test("renders kind badge and layer for list", () => { + const { container, cleanup } = renderRow(baseList); + expect(container.textContent).toContain("list"); + expect(container.textContent).toContain("did:peer"); + cleanup(); +}); + +test("renders verification badge when not 'none'", () => { + const { container, cleanup } = renderRow(baseSite); + expect(container.textContent).toContain("Verified"); + cleanup(); +}); + +test("does NOT render verification badge for 'none'", () => { + const { container, cleanup } = renderRow(baseList); + expect(container.textContent).not.toContain("Verified"); + expect(container.textContent).not.toContain("Anchored"); + expect(container.textContent).not.toContain("Pending"); + cleanup(); +}); + +test("clicking a list row navigates to /list/:id", () => { + const { container, cleanup } = renderRow(baseList); + const rowEl = container.querySelector("[data-row-id]") as HTMLElement; + expect(rowEl?.getAttribute("href")).toBe("/list/L1"); + cleanup(); +}); + +test("clicking a site row navigates to /s/:id", () => { + const { container, cleanup } = renderRow(baseSite); + const rowEl = container.querySelector("[data-row-id]") as HTMLElement; + expect(rowEl?.getAttribute("href")).toBe("/s/S1"); + cleanup(); +}); + +test("identifier column hidden by default", () => { + const { container, cleanup } = renderRow(baseSite, DEFAULT_COLUMN_PREFS); + expect(container.querySelector("[data-column='identifier']")).toBeNull(); + cleanup(); +}); + +test("identifier column visible when toggled on", () => { + const { container, cleanup } = renderRow(baseSite, { + ...DEFAULT_COLUMN_PREFS, + identifier: true, + }); + expect(container.querySelector("[data-column='identifier']")).not.toBeNull(); + cleanup(); +}); + +test("anchor txid column empty when verification !== 'anchored'", () => { + const { container, cleanup } = renderRow( + { ...baseSite, anchorTxId: "tx-abc" }, + { ...DEFAULT_COLUMN_PREFS, anchorTxidPrefix: true }, + ); + const col = container.querySelector("[data-column='anchorTxidPrefix']"); + expect(col).not.toBeNull(); + expect(col?.textContent).toBe(""); + cleanup(); +}); + +test("anchor txid column shows prefix when 'anchored'", () => { + const anchored: ExplorerRowType = { + ...baseList, + verification: "anchored", + anchorTxId: "abcdef0123456789", + }; + const { container, cleanup } = renderRow(anchored, { + ...DEFAULT_COLUMN_PREFS, + anchorTxidPrefix: true, + }); + const col = container.querySelector("[data-column='anchorTxidPrefix']"); + expect(col?.textContent).toContain("abcdef01"); + cleanup(); +}); diff --git a/src/components/explorer/ExplorerRow.tsx b/src/components/explorer/ExplorerRow.tsx new file mode 100644 index 0000000..bf7ea2b --- /dev/null +++ b/src/components/explorer/ExplorerRow.tsx @@ -0,0 +1,74 @@ +import { Link } from "react-router-dom"; +import type { ExplorerRow as ExplorerRowType } from "../../lib/explorer"; +import type { ColumnPrefs } from "../../lib/explorerColumns"; +import { KindBadge } from "./KindBadge"; +import { LayerBadge } from "./LayerBadge"; +import { RowVerificationBadge } from "./RowVerificationBadge"; + +interface Props { + row: ExplorerRowType; + columns: ColumnPrefs; +} + +function relativeTime(ts: number): string { + const diff = Date.now() - ts; + const day = 86_400_000; + if (diff < day) return "today"; + if (diff < 7 * day) return `${Math.floor(diff / day)}d ago`; + if (diff < 30 * day) return `${Math.floor(diff / (7 * day))}w ago`; + if (diff < 365 * day) return `${Math.floor(diff / (30 * day))}mo ago`; + return `${Math.floor(diff / (365 * day))}y ago`; +} + +function destinationFor(row: ExplorerRowType): string { + return row.source === "list" ? `/list/${row.sourceId}` : `/s/${row.sourceId}`; +} + +export function ExplorerRow({ row, columns }: Props) { + const Icon = row.source === "list" ? "📝" : "🌐"; + + return ( + + +
+
+ {row.title} +
+
+ + +
+
+
+ +
+
+ {relativeTime(row.updatedAt)} +
+ + {columns.identifier && ( +
+ {row.identifier ?? ""} +
+ )} + {columns.collaborators && ( +
+ {row.collaborators ?? 0} +
+ )} + {columns.anchorTxidPrefix && ( +
+ {row.verification === "anchored" && row.anchorTxId + ? row.anchorTxId.slice(0, 8) + : ""} +
+ )} + + ); +} From 321ec0a65b9932b4c4f10387f33936b093832e4f Mon Sep 17 00:00:00 2001 From: Brian Richter Date: Fri, 8 May 2026 00:36:20 -0700 Subject: [PATCH 10/14] feat(explorer): toolbar with search, sort, filter chips, columns menu --- .../explorer/ExplorerToolbar.test.tsx | 90 ++++++++++ src/components/explorer/ExplorerToolbar.tsx | 157 ++++++++++++++++++ 2 files changed, 247 insertions(+) create mode 100644 src/components/explorer/ExplorerToolbar.test.tsx create mode 100644 src/components/explorer/ExplorerToolbar.tsx diff --git a/src/components/explorer/ExplorerToolbar.test.tsx b/src/components/explorer/ExplorerToolbar.test.tsx new file mode 100644 index 0000000..610372e --- /dev/null +++ b/src/components/explorer/ExplorerToolbar.test.tsx @@ -0,0 +1,90 @@ +import { test, expect } from "bun:test"; +import { createRoot } from "react-dom/client"; +import { act } from "react"; +import { MemoryRouter } from "react-router-dom"; +import { ExplorerToolbar } from "./ExplorerToolbar"; +import { DEFAULT_COLUMN_PREFS } from "../../lib/explorerColumns"; + +function render(node: React.ReactNode) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => root.render({node})); + return { + container, + cleanup: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +test("clicking a kind chip toggles it on", () => { + const calls: string[] = []; + const { container, cleanup } = render( + {}} + onToggleKind={(v) => calls.push(`kind:${v}`)} + onToggleLayer={() => {}} + onToggleVerify={() => {}} + sort={{ key: "updated", dir: "desc" }} + onSort={() => {}} + columns={DEFAULT_COLUMN_PREFS} + onToggleColumn={() => {}} + />, + ); + const chip = container.querySelector("[data-chip='kind:list']") as HTMLButtonElement; + expect(chip).not.toBeNull(); + act(() => chip.click()); + expect(calls).toEqual(["kind:list"]); + cleanup(); +}); + +test("multi-select: chip with active state has aria-pressed=true", () => { + const { container, cleanup } = render( + {}} + onToggleKind={() => {}} + onToggleLayer={() => {}} + onToggleVerify={() => {}} + sort={{ key: "updated", dir: "desc" }} + onSort={() => {}} + columns={DEFAULT_COLUMN_PREFS} + onToggleColumn={() => {}} + />, + ); + expect(container.querySelector("[data-chip='kind:list']")?.getAttribute("aria-pressed")).toBe("true"); + expect(container.querySelector("[data-chip='kind:site']")?.getAttribute("aria-pressed")).toBe("true"); + expect(container.querySelector("[data-chip='layer:did:peer']")?.getAttribute("aria-pressed")).toBe("false"); + cleanup(); +}); + +test("columns menu: toggling a column calls onToggleColumn", () => { + const calls: string[] = []; + const { container, cleanup } = render( + {}} + onToggleKind={() => {}} + onToggleLayer={() => {}} + onToggleVerify={() => {}} + sort={{ key: "updated", dir: "desc" }} + onSort={() => {}} + columns={DEFAULT_COLUMN_PREFS} + onToggleColumn={(k) => calls.push(k)} + />, + ); + const trigger = container.querySelector("[data-columns-trigger]") as HTMLButtonElement; + expect(trigger).not.toBeNull(); + act(() => trigger.click()); + const item = container.querySelector("[data-column-toggle='identifier']") as HTMLButtonElement; + expect(item).not.toBeNull(); + act(() => item.click()); + expect(calls).toEqual(["identifier"]); + cleanup(); +}); diff --git a/src/components/explorer/ExplorerToolbar.tsx b/src/components/explorer/ExplorerToolbar.tsx new file mode 100644 index 0000000..4e61ec8 --- /dev/null +++ b/src/components/explorer/ExplorerToolbar.tsx @@ -0,0 +1,157 @@ +import { useState } from "react"; +import type { + ExplorerFilters, + ExplorerSource, + ExplorerLayer, + ExplorerVerification, + ExplorerSort, +} from "../../lib/explorer"; +import type { ColumnPrefs } from "../../lib/explorerColumns"; +import { SearchInput } from "../ui/SearchInput"; + +interface Props { + filters: ExplorerFilters; + searchInput: string; + onSearch: (q: string) => void; + onToggleKind: (v: ExplorerSource) => void; + onToggleLayer: (v: ExplorerLayer) => void; + onToggleVerify: (v: ExplorerVerification) => void; + sort: ExplorerSort; + onSort: (s: ExplorerSort) => void; + columns: ColumnPrefs; + onToggleColumn: (k: keyof ColumnPrefs) => void; +} + +const KIND_OPTIONS: { value: ExplorerSource; label: string }[] = [ + { value: "list", label: "List" }, + { value: "site", label: "Site" }, +]; +const LAYER_OPTIONS: { value: ExplorerLayer; label: string }[] = [ + { value: "did:peer", label: "peer" }, + { value: "did:webvh", label: "webvh" }, + { value: "did:btco", label: "btco" }, +]; +const VERIFY_OPTIONS: { value: ExplorerVerification; label: string }[] = [ + { value: "verified", label: "Verified" }, + { value: "anchored", label: "Anchored" }, + { value: "pending", label: "Pending" }, +]; + +const COLUMN_LABELS: Record = { + identifier: "Identifier", + collaborators: "Collaborators", + anchorTxidPrefix: "Anchor txid", +}; + +function Chip({ + group, value, label, active, onToggle, +}: { group: string; value: T; label: string; active: boolean; onToggle: () => void }) { + return ( + + ); +} + +export function ExplorerToolbar(props: Props) { + const [columnsOpen, setColumnsOpen] = useState(false); + + return ( +
+
+
+ +
+ +
+ + {columnsOpen && ( +
+ {(Object.keys(COLUMN_LABELS) as (keyof ColumnPrefs)[]).map((key) => ( + + ))} +
+ )} +
+
+ +
+ Kind: + {KIND_OPTIONS.map((o) => ( + props.onToggleKind(o.value)} + /> + ))} + Layer: + {LAYER_OPTIONS.map((o) => ( + props.onToggleLayer(o.value)} + /> + ))} + Verification: + {VERIFY_OPTIONS.map((o) => ( + props.onToggleVerify(o.value)} + /> + ))} +
+
+ ); +} From 895b8577f9a5e76f3267b2fb6afd4f652a200902 Mon Sep 17 00:00:00 2001 From: Brian Richter Date: Fri, 8 May 2026 00:39:02 -0700 Subject: [PATCH 11/14] feat(explorer): Explorer page wires query + toolbar + rows Manually patched convex/_generated/api.d.ts to add originals module (import + ApiFromModules entry) since npx convex codegen can't run without CONVEX_DEPLOYMENT. Adapted NoSearchResultsEmptyState call to pass query={filters.q} (component requires the prop). Co-Authored-By: Claude Sonnet 4.6 --- convex/_generated/api.d.ts | 2 + src/pages/Explorer.tsx | 130 +++++++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 src/pages/Explorer.tsx diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index e7ff918..5fcd914 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -45,6 +45,7 @@ import type * as lib_turnkeySigner from "../lib/turnkeySigner.js"; import type * as lists from "../lists.js"; import type * as listsHttp from "../listsHttp.js"; import type * as notificationActions from "../notificationActions.js"; +import type * as originals from "../originals.js"; import type * as notifications from "../notifications.js"; import type * as presence from "../presence.js"; import type * as presenceHttp from "../presenceHttp.js"; @@ -107,6 +108,7 @@ declare const fullApi: ApiFromModules<{ listsHttp: typeof listsHttp; notificationActions: typeof notificationActions; notifications: typeof notifications; + originals: typeof originals; presence: typeof presence; presenceHttp: typeof presenceHttp; publication: typeof publication; diff --git a/src/pages/Explorer.tsx b/src/pages/Explorer.tsx new file mode 100644 index 0000000..92378a9 --- /dev/null +++ b/src/pages/Explorer.tsx @@ -0,0 +1,130 @@ +import { useMemo } from "react"; +import { useQuery } from "convex/react"; +import { api } from "../../convex/_generated/api"; +import { useCurrentUser } from "../hooks/useCurrentUser"; +import { useOffline } from "../hooks/useOffline"; +import { useExplorerFilters } from "../hooks/useExplorerFilters"; +import { ExplorerToolbar } from "../components/explorer/ExplorerToolbar"; +import { ExplorerRow } from "../components/explorer/ExplorerRow"; +import { applyExplorerFilters, compareExplorerRows } from "../lib/explorer"; +import { Skeleton } from "../components/ui/Skeleton"; +import { NoSearchResultsEmptyState } from "../components/ui/EmptyState"; +import { Link } from "react-router-dom"; + +export function Explorer() { + const { did, isLoading } = useCurrentUser(); + const { isOnline } = useOffline(); + const { + filters, + searchInput, + setSearchQuery, + toggleKindChip, + toggleLayerChip, + toggleVerifyChip, + sort, + setSort, + columns, + toggleColumn, + } = useExplorerFilters(); + + const data = useQuery( + api.originals.listOwnedOriginals, + did ? { ownerDid: did } : "skip", + ); + + const filteredSorted = useMemo(() => { + if (!data) return null; + const filtered = applyExplorerFilters(data, filters); + return filtered.slice().sort(compareExplorerRows(sort)); + }, [data, filters, sort]); + + if (isLoading || !did) { + return ( +
+ + + + +
+ ); + } + + return ( +
+
+

+ Originals +

+

+ Everything you've signed into existence. +

+
+ + {!isOnline && ( +
+ You're offline. The Explorer needs a connection. +
+ )} + + + +
+ {filteredSorted === null ? ( +
+ + + +
+ ) : data && data.length === 0 ? ( +
+

+ No originals yet — create a list or publish a site to get started. +

+
+ + Create a list → + + + Publish a site → + +
+
+ ) : filteredSorted.length === 0 ? ( + + ) : ( +
+ {filteredSorted.map((row) => ( + + ))} +
+ )} +
+
+ ); +} From 66c28c9a31a9d46175f56d04649918747e6fdcc6 Mon Sep 17 00:00:00 2001 From: Brian Richter Date: Fri, 8 May 2026 00:41:05 -0700 Subject: [PATCH 12/14] feat(explorer): rename /sites routes to /s, add /e Explorer route + nav Co-Authored-By: Claude Sonnet 4.6 --- src/App.tsx | 9 ++++++--- src/pages/Sites.tsx | 4 ++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index b278fd8..90402a2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -28,6 +28,7 @@ const JoinList = lazy(() => import('./pages/JoinList').then(m => ({ default: m.J const PublicList = lazy(() => import('./pages/PublicList').then(m => ({ default: m.PublicList }))) const SharedListResource = lazy(() => import('./components/SharedListResource').then(m => ({ default: m.SharedListResource }))) const Profile = lazy(() => import('./pages/Profile').then(m => ({ default: m.Profile }))) +const Explorer = lazy(() => import('./pages/Explorer').then(m => ({ default: m.Explorer }))) const Templates = lazy(() => import('./pages/Templates').then(m => ({ default: m.Templates }))) const PriorityFocus = lazy(() => import('./pages/PriorityFocus').then(m => ({ default: m.PriorityFocus }))) const Pricing = lazy(() => import('./pages/Pricing').then(m => ({ default: m.Pricing }))) @@ -70,7 +71,8 @@ function AuthenticatedLayout({ children }: { children: React.ReactNode }) {