From f5008a26f39b5379d489ba0394a8bf543c6f727e Mon Sep 17 00:00:00 2001 From: mattakamatsu Date: Mon, 28 Sep 2026 23:53:23 -0700 Subject: [PATCH 1/3] Add the Meeting Tasks prototype: carried-over next actions for sync pages Ports dg-team's roam/render meeting-tasks component into an installable extension. The roam/render code block becomes a short shim that hands the extension an element to render into, so existing invocations and templates keep working. Addresses the performance review of the roam/render version: - Reads use data.async.* and run after the widget renders, not during Roam's render. - Widgets on the same page share one set of reads. - Four list-bound Datalog queries (full attribute scans in DataScript) become pull_many calls; the meeting and task scans start from reverse references. On Sync / All Hands a widget went from 1.6-2.0 s of blocking work to about 390 ms after render. - The collapsed section is read and rendered only when opened, and block references in item text are read in one batch. - TypeScript modules separate reads (roam.ts), rules (model.ts, text.ts) and display (view.tsx). Output matches the old component on all 20 widgets on the three dg-team sync pages, apart from the fixes listed in the CHANGELOG. Co-Authored-By: Claude Fable 5.1 --- pnpm-lock.yaml | 345 +++++++++++++++--- prototypes/roam-meeting-tasks/CHANGELOG.md | 19 + prototypes/roam-meeting-tasks/README.md | 166 +++++++++ prototypes/roam-meeting-tasks/package.json | 28 ++ prototypes/roam-meeting-tasks/src/args.ts | 40 ++ prototypes/roam-meeting-tasks/src/config.ts | 47 +++ prototypes/roam-meeting-tasks/src/index.ts | 66 ++++ .../roam-meeting-tasks/src/lifecycle.ts | 51 +++ prototypes/roam-meeting-tasks/src/load.ts | 101 +++++ prototypes/roam-meeting-tasks/src/model.ts | 288 +++++++++++++++ prototypes/roam-meeting-tasks/src/mount.ts | 77 ++++ prototypes/roam-meeting-tasks/src/roam.ts | 240 ++++++++++++ prototypes/roam-meeting-tasks/src/shim.ts | 57 +++ prototypes/roam-meeting-tasks/src/styles.ts | 42 +++ prototypes/roam-meeting-tasks/src/text.ts | 91 +++++ prototypes/roam-meeting-tasks/src/view.tsx | 233 ++++++++++++ .../roam-meeting-tasks/tailwind.config.cjs | 6 + .../roam-meeting-tasks/tests/bundle.spec.ts | 77 ++++ .../roam-meeting-tasks/tests/fixtures.ts | 197 ++++++++++ .../roam-meeting-tasks/tests/interop.spec.ts | 53 +++ prototypes/roam-meeting-tasks/tests/setup.ts | 2 + .../roam-meeting-tasks/tests/shim.spec.ts | 109 ++++++ .../roam-meeting-tasks/tests/text.spec.ts | 91 +++++ .../roam-meeting-tasks/tests/widget.spec.tsx | 286 +++++++++++++++ .../roam-meeting-tasks/tsconfig.check.json | 19 + prototypes/roam-meeting-tasks/tsconfig.json | 16 + .../roam-meeting-tasks/vitest.config.ts | 20 + 27 files changed, 2713 insertions(+), 54 deletions(-) create mode 100644 prototypes/roam-meeting-tasks/CHANGELOG.md create mode 100644 prototypes/roam-meeting-tasks/README.md create mode 100644 prototypes/roam-meeting-tasks/package.json create mode 100644 prototypes/roam-meeting-tasks/src/args.ts create mode 100644 prototypes/roam-meeting-tasks/src/config.ts create mode 100644 prototypes/roam-meeting-tasks/src/index.ts create mode 100644 prototypes/roam-meeting-tasks/src/lifecycle.ts create mode 100644 prototypes/roam-meeting-tasks/src/load.ts create mode 100644 prototypes/roam-meeting-tasks/src/model.ts create mode 100644 prototypes/roam-meeting-tasks/src/mount.ts create mode 100644 prototypes/roam-meeting-tasks/src/roam.ts create mode 100644 prototypes/roam-meeting-tasks/src/shim.ts create mode 100644 prototypes/roam-meeting-tasks/src/styles.ts create mode 100644 prototypes/roam-meeting-tasks/src/text.ts create mode 100644 prototypes/roam-meeting-tasks/src/view.tsx create mode 100644 prototypes/roam-meeting-tasks/tailwind.config.cjs create mode 100644 prototypes/roam-meeting-tasks/tests/bundle.spec.ts create mode 100644 prototypes/roam-meeting-tasks/tests/fixtures.ts create mode 100644 prototypes/roam-meeting-tasks/tests/interop.spec.ts create mode 100644 prototypes/roam-meeting-tasks/tests/setup.ts create mode 100644 prototypes/roam-meeting-tasks/tests/shim.spec.ts create mode 100644 prototypes/roam-meeting-tasks/tests/text.spec.ts create mode 100644 prototypes/roam-meeting-tasks/tests/widget.spec.tsx create mode 100644 prototypes/roam-meeting-tasks/tsconfig.check.json create mode 100644 prototypes/roam-meeting-tasks/tsconfig.json create mode 100644 prototypes/roam-meeting-tasks/vitest.config.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a184f59..2de4429 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,10 +47,10 @@ importers: version: 30.0.1 roamjs-components: specifier: 'catalog:' - version: 0.89.0(dd2f1f144015bc13ec70826008c78270) + version: 0.89.0(a108def2d5f55c34ad4d8a697ea8381c) use-sync-external-store: specifier: 'catalog:' - version: 1.6.0(react@17.0.2) + version: 1.6.0(react@18.2.0) vitest: specifier: 'catalog:' version: 4.1.10(@types/node@26.2.0)(jsdom@30.0.1)(msw@2.15.0(@types/node@26.2.0)(typescript@7.0.2))(vite@8.2.1(@types/node@26.2.0)(esbuild@0.17.14)(jiti@1.21.7)(sass@1.103.1)(yaml@2.9.0)) @@ -59,10 +59,10 @@ importers: dependencies: roamjs-components: specifier: 'catalog:' - version: 0.89.0(5222ab9d8d0f15037684080314cfeeb5) + version: 0.89.0(25f1eaefe7300d3403328892d4fb02ee) use-sync-external-store: specifier: 'catalog:' - version: 1.6.0(react@17.0.2) + version: 1.6.0(react@18.2.0) devDependencies: '@discoursegraphs/extension-base': specifier: workspace:* @@ -78,10 +78,10 @@ importers: dependencies: roamjs-components: specifier: 'catalog:' - version: 0.89.0(5222ab9d8d0f15037684080314cfeeb5) + version: 0.89.0(320e230ed0fb108a7ffb1c4252bad912) use-sync-external-store: specifier: 'catalog:' - version: 1.6.0(react@17.0.2) + version: 1.6.0(react@18.2.0) devDependencies: '@discoursegraphs/extension-base': specifier: workspace:* @@ -103,10 +103,10 @@ importers: dependencies: roamjs-components: specifier: 'catalog:' - version: 0.89.0(7b650c56182331394426bf3631cd9dff) + version: 0.89.0(3b15e0e8ac67b29e0263f2bbe5b35557) use-sync-external-store: specifier: 'catalog:' - version: 1.6.0(react@17.0.2) + version: 1.6.0(react@18.2.0) devDependencies: '@discoursegraphs/extension-base': specifier: workspace:* @@ -124,6 +124,43 @@ importers: specifier: 'catalog:' version: 4.1.10(@types/node@22.20.2)(jsdom@30.0.1)(msw@2.15.0(@types/node@22.20.2)(typescript@5.9.3))(vite@8.2.1(@types/node@22.20.2)(esbuild@0.25.12)(jiti@1.21.7)(sass@1.103.1)(yaml@2.9.0)) + prototypes/roam-meeting-tasks: + dependencies: + roamjs-components: + specifier: 'catalog:' + version: 0.89.0(3b15e0e8ac67b29e0263f2bbe5b35557) + use-sync-external-store: + specifier: 'catalog:' + version: 1.6.0(react@18.2.0) + devDependencies: + '@discoursegraphs/extension-base': + specifier: workspace:* + version: link:../../packages/extension-base + '@types/node': + specifier: ^22.0.0 + version: 22.20.2 + '@types/react': + specifier: 18.2.79 + version: 18.2.79 + '@types/react-dom': + specifier: 18.2.25 + version: 18.2.25 + jsdom: + specifier: 'catalog:' + version: 30.0.1 + react: + specifier: 18.2.0 + version: 18.2.0 + react-dom: + specifier: 18.2.0 + version: 18.2.0(react@18.2.0) + typescript: + specifier: ^5.9.0 + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.1.10(@types/node@22.20.2)(jsdom@30.0.1)(msw@2.15.0(@types/node@22.20.2)(typescript@5.9.3))(vite@8.2.1(@types/node@22.20.2)(esbuild@0.25.12)(jiti@1.21.7)(sass@1.103.1)(yaml@2.9.0)) + prototypes/zotero-roam-mcp: dependencies: '@blueprintjs/core': @@ -1605,9 +1642,15 @@ packages: '@types/react-dom@17.0.13': resolution: {integrity: sha512-wEP+B8hzvy6ORDv1QBhcQia4j6ea4SFIBttHYpXKPFZRviBvknq0FRh3VrIxeXUmsPkwuXVZrVGG7KUVONmXCQ==} + '@types/react-dom@18.2.25': + resolution: {integrity: sha512-o/V48vf4MQh7juIKZU2QGDfli6p1+OOi5oXx36Hffpc9adsHeXjVp8rHuPkjd8VT8sOJ2Zp05HR7CdpGTIUFUA==} + '@types/react@17.0.39': resolution: {integrity: sha512-UVavlfAxDd/AgAacMa60Azl7ygyQNRwC/DsHZmKgNvPmRR5p70AJ5Q9EAmL2NWOJmeV+vVUI4IAP7GZrN8h8Ug==} + '@types/react@18.2.79': + resolution: {integrity: sha512-RwGAGXPl9kSXwdNTafkOEuFrTBD5SA2B3iEB96xi8+xu5ddUa/cpvyVCSNn+asgLCTHkb5ZxN8gbuibYJi4s1w==} + '@types/scheduler@0.26.0': resolution: {integrity: sha512-WFHp9YUJQ6CKshqoC37iOlHnQSmxNc795UhB26CyBBttrN9svdIrUjl/NjnNmfcwtncN0h/0PPAFWv9ovP8mLA==} @@ -3372,6 +3415,11 @@ packages: peerDependencies: react: 17.0.2 + react-dom@18.2.0: + resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} + peerDependencies: + react: ^18.2.0 + react-error-boundary@3.1.4: resolution: {integrity: sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==} engines: {node: '>=10', npm: '>=6'} @@ -3412,6 +3460,10 @@ packages: resolution: {integrity: sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==} engines: {node: '>=0.10.0'} + react@18.2.0: + resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} + engines: {node: '>=0.10.0'} + read-cache@1.0.0: resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} @@ -3563,6 +3615,9 @@ packages: scheduler@0.20.2: resolution: {integrity: sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==} + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + scite-badge@https://codeload.github.com/scitedotai/scite-badge/tar.gz/80bda7fa058531a1b3f107b91f43d0cb35b6375f: resolution: {tarball: https://codeload.github.com/scitedotai/scite-badge/tar.gz/80bda7fa058531a1b3f107b91f43d0cb35b6375f} version: 8.0.6 @@ -4539,6 +4594,23 @@ snapshots: react-transition-group: 2.9.0(react-dom@17.0.2(react@17.0.2))(react@17.0.2) tslib: 2.3.1 + '@blueprintjs/core@3.54.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + '@blueprintjs/colors': 4.2.1 + '@blueprintjs/icons': 3.33.0 + '@juggle/resize-observer': 3.4.0 + '@types/dom4': 2.0.4 + classnames: 2.5.1 + dom4: 2.1.6 + normalize.css: 8.0.1 + popper.js: 1.16.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-lifecycles-compat: 3.0.4 + react-popper: 1.3.11(react@18.2.0) + react-transition-group: 2.9.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + tslib: 2.3.1 + '@blueprintjs/datetime@3.24.1(react-dom@17.0.2(react@17.0.2))(react@17.0.2)': dependencies: '@blueprintjs/core': 3.54.0(react-dom@17.0.2(react@17.0.2))(react@17.0.2) @@ -4549,6 +4621,16 @@ snapshots: react-lifecycles-compat: 3.0.4 tslib: 2.3.1 + '@blueprintjs/datetime@3.24.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + '@blueprintjs/core': 3.54.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + classnames: 2.5.1 + react: 18.2.0 + react-day-picker: 7.4.9(react@18.2.0) + react-dom: 18.2.0(react@18.2.0) + react-lifecycles-compat: 3.0.4 + tslib: 2.3.1 + '@blueprintjs/icons@3.33.0': dependencies: classnames: 2.5.1 @@ -4575,6 +4657,14 @@ snapshots: react-dom: 17.0.2(react@17.0.2) tslib: 2.3.1 + '@blueprintjs/select@3.19.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + '@blueprintjs/core': 3.54.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + classnames: 2.5.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + tslib: 2.3.1 + '@bramus/specificity@2.4.2': dependencies: css-tree: 3.2.1 @@ -4849,6 +4939,13 @@ snapshots: react: 17.0.2 warning: 4.0.3 + '@hypnosphi/create-react-context@0.3.1(prop-types@15.8.1)(react@18.2.0)': + dependencies: + gud: 1.0.0 + prop-types: 15.8.1 + react: 18.2.0 + warning: 4.0.3 + '@inquirer/ansi@2.0.7': {} '@inquirer/confirm@6.3.0(@types/node@22.20.2)': @@ -5149,11 +5246,26 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.63.1': optional: true - '@samepage/scripts@0.74.5(@aws-sdk/client-lambda@3.1111.0)(@aws-sdk/client-s3@3.1111.0)(@samepage/testing@0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@17.0.13)(@types/react@17.0.39)(react-dom@17.0.2(react@17.0.2))(react@17.0.2))(@testing-library/user-event@14.6.4(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@30.0.1)(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@22.20.2)(typescript@5.9.3)))(archiver@5.3.2)(axios@1.20.0(debug@4.4.3))(debug@4.4.3)(dotenv@16.6.1)(esbuild@0.25.12)(patch-package@6.5.1)(tailwindcss@3.3.2(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@22.20.2)(typescript@5.9.3)))(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@22.20.2)(typescript@5.9.3))(zod@4.1.11)': + '@samepage/scripts@0.74.5(@aws-sdk/client-lambda@3.1111.0)(@aws-sdk/client-s3@3.1111.0)(@samepage/testing@0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@17.0.13)(@types/react@17.0.39)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@testing-library/user-event@14.6.4(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@30.0.1)(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2)))(archiver@5.3.2)(axios@1.20.0(debug@4.4.3))(debug@4.4.3)(dotenv@16.6.1)(esbuild@0.25.12)(patch-package@6.5.1)(tailwindcss@3.3.2(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2)))(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2))(zod@4.1.11)': dependencies: '@aws-sdk/client-lambda': 3.1111.0 '@aws-sdk/client-s3': 3.1111.0 - '@samepage/testing': 0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@17.0.13)(@types/react@17.0.39)(react-dom@17.0.2(react@17.0.2))(react@17.0.2))(@testing-library/user-event@14.6.4(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@30.0.1)(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@22.20.2)(typescript@5.9.3)) + '@samepage/testing': 0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@17.0.13)(@types/react@17.0.39)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@testing-library/user-event@14.6.4(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@30.0.1)(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2)) + archiver: 5.3.2 + axios: 1.20.0(debug@4.4.3) + debug: 4.4.3 + dotenv: 16.6.1 + esbuild: 0.25.12 + patch-package: 6.5.1 + tailwindcss: 3.3.2(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2)) + ts-node: 10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2) + zod: 4.1.11 + + '@samepage/scripts@0.74.5(@aws-sdk/client-lambda@3.1111.0)(@aws-sdk/client-s3@3.1111.0)(@samepage/testing@0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.2.25)(@types/react@18.2.79)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@testing-library/user-event@14.6.4(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@30.0.1)(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@22.20.2)(typescript@5.9.3)))(archiver@5.3.2)(axios@1.20.0(debug@4.4.3))(debug@4.4.3)(dotenv@16.6.1)(esbuild@0.25.12)(patch-package@6.5.1)(tailwindcss@3.3.2(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@22.20.2)(typescript@5.9.3)))(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@22.20.2)(typescript@5.9.3))(zod@4.1.11)': + dependencies: + '@aws-sdk/client-lambda': 3.1111.0 + '@aws-sdk/client-s3': 3.1111.0 + '@samepage/testing': 0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.2.25)(@types/react@18.2.79)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@testing-library/user-event@14.6.4(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@30.0.1)(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@22.20.2)(typescript@5.9.3)) archiver: 5.3.2 axios: 1.20.0(debug@4.4.3) debug: 4.4.3 @@ -5164,11 +5276,11 @@ snapshots: ts-node: 10.9.2(@swc/core@1.16.1)(@types/node@22.20.2)(typescript@5.9.3) zod: 4.1.11 - '@samepage/scripts@0.74.5(@aws-sdk/client-lambda@3.1111.0)(@aws-sdk/client-s3@3.1111.0)(@samepage/testing@0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@17.0.13)(@types/react@17.0.39)(react-dom@17.0.2(react@17.0.2))(react@17.0.2))(@testing-library/user-event@14.6.4(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@30.0.1)(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2)))(archiver@5.3.2)(axios@1.20.0(debug@4.4.3))(debug@4.4.3)(dotenv@16.6.1)(esbuild@0.17.14)(patch-package@6.5.1)(tailwindcss@3.3.2(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2)))(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2))(zod@4.1.11)': + '@samepage/scripts@0.74.5(@aws-sdk/client-lambda@3.1111.0)(@aws-sdk/client-s3@3.1111.0)(@samepage/testing@0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.2.25)(@types/react@18.2.79)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@testing-library/user-event@14.6.4(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@30.0.1)(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2)))(archiver@5.3.2)(axios@1.20.0(debug@4.4.3))(debug@4.4.3)(dotenv@16.6.1)(esbuild@0.17.14)(patch-package@6.5.1)(tailwindcss@3.3.2(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2)))(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2))(zod@4.1.11)': dependencies: '@aws-sdk/client-lambda': 3.1111.0 '@aws-sdk/client-s3': 3.1111.0 - '@samepage/testing': 0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@17.0.13)(@types/react@17.0.39)(react-dom@17.0.2(react@17.0.2))(react@17.0.2))(@testing-library/user-event@14.6.4(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@30.0.1)(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2)) + '@samepage/testing': 0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.2.25)(@types/react@18.2.79)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@testing-library/user-event@14.6.4(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@30.0.1)(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2)) archiver: 5.3.2 axios: 1.20.0(debug@4.4.3) debug: 4.4.3 @@ -5179,11 +5291,11 @@ snapshots: ts-node: 10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2) zod: 4.1.11 - '@samepage/scripts@0.74.5(@aws-sdk/client-lambda@3.1111.0)(@aws-sdk/client-s3@3.1111.0)(@samepage/testing@0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@17.0.13)(@types/react@17.0.39)(react-dom@17.0.2(react@17.0.2))(react@17.0.2))(@testing-library/user-event@14.6.4(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@30.0.1)(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2)))(archiver@5.3.2)(axios@1.20.0(debug@4.4.3))(debug@4.4.3)(dotenv@16.6.1)(esbuild@0.25.12)(patch-package@6.5.1)(tailwindcss@3.3.2(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2)))(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2))(zod@4.1.11)': + '@samepage/scripts@0.74.5(@aws-sdk/client-lambda@3.1111.0)(@aws-sdk/client-s3@3.1111.0)(@samepage/testing@0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.2.25)(@types/react@18.2.79)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@testing-library/user-event@14.6.4(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@30.0.1)(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2)))(archiver@5.3.2)(axios@1.20.0(debug@4.4.3))(debug@4.4.3)(dotenv@16.6.1)(esbuild@0.25.12)(patch-package@6.5.1)(tailwindcss@3.3.2(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2)))(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2))(zod@4.1.11)': dependencies: '@aws-sdk/client-lambda': 3.1111.0 '@aws-sdk/client-s3': 3.1111.0 - '@samepage/testing': 0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@17.0.13)(@types/react@17.0.39)(react-dom@17.0.2(react@17.0.2))(react@17.0.2))(@testing-library/user-event@14.6.4(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@30.0.1)(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2)) + '@samepage/testing': 0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.2.25)(@types/react@18.2.79)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@testing-library/user-event@14.6.4(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@30.0.1)(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2)) archiver: 5.3.2 axios: 1.20.0(debug@4.4.3) debug: 4.4.3 @@ -5194,10 +5306,22 @@ snapshots: ts-node: 10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2) zod: 4.1.11 - '@samepage/testing@0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@17.0.13)(@types/react@17.0.39)(react-dom@17.0.2(react@17.0.2))(react@17.0.2))(@testing-library/user-event@14.6.4(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@30.0.1)(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@22.20.2)(typescript@5.9.3))': + '@samepage/testing@0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@17.0.13)(@types/react@17.0.39)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@testing-library/user-event@14.6.4(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@30.0.1)(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2))': + dependencies: + '@playwright/test': 1.29.0 + '@testing-library/react': 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@17.0.13)(@types/react@17.0.39)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@testing-library/user-event': 14.6.4(@testing-library/dom@10.4.1) + '@types/jsdom': 20.0.1 + c8: 7.14.0 + debug: 4.4.3 + dotenv: 16.6.1 + jsdom: 30.0.1 + ts-node: 10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2) + + '@samepage/testing@0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.2.25)(@types/react@18.2.79)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@testing-library/user-event@14.6.4(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@30.0.1)(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@22.20.2)(typescript@5.9.3))': dependencies: '@playwright/test': 1.29.0 - '@testing-library/react': 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@17.0.13)(@types/react@17.0.39)(react-dom@17.0.2(react@17.0.2))(react@17.0.2) + '@testing-library/react': 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.2.25)(@types/react@18.2.79)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@testing-library/user-event': 14.6.4(@testing-library/dom@10.4.1) '@types/jsdom': 20.0.1 c8: 7.14.0 @@ -5206,10 +5330,10 @@ snapshots: jsdom: 30.0.1 ts-node: 10.9.2(@swc/core@1.16.1)(@types/node@22.20.2)(typescript@5.9.3) - '@samepage/testing@0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@17.0.13)(@types/react@17.0.39)(react-dom@17.0.2(react@17.0.2))(react@17.0.2))(@testing-library/user-event@14.6.4(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@30.0.1)(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2))': + '@samepage/testing@0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.2.25)(@types/react@18.2.79)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@testing-library/user-event@14.6.4(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@30.0.1)(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2))': dependencies: '@playwright/test': 1.29.0 - '@testing-library/react': 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@17.0.13)(@types/react@17.0.39)(react-dom@17.0.2(react@17.0.2))(react@17.0.2) + '@testing-library/react': 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.2.25)(@types/react@18.2.79)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@testing-library/user-event': 14.6.4(@testing-library/dom@10.4.1) '@types/jsdom': 20.0.1 c8: 7.14.0 @@ -5434,16 +5558,26 @@ snapshots: react: 17.0.2 react-dom: 17.0.2(react@17.0.2) - '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@17.0.13)(@types/react@17.0.39)(react-dom@17.0.2(react@17.0.2))(react@17.0.2)': + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@17.0.13)(@types/react@17.0.39)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: '@babel/runtime': 7.29.7 '@testing-library/dom': 10.4.1 - react: 17.0.2 - react-dom: 17.0.2(react@17.0.2) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) optionalDependencies: '@types/react': 17.0.39 '@types/react-dom': 17.0.13 + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.2.25)(@types/react@18.2.79)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + optionalDependencies: + '@types/react': 18.2.79 + '@types/react-dom': 18.2.25 + '@testing-library/user-event@14.5.2(@testing-library/dom@10.4.0)': dependencies: '@testing-library/dom': 10.4.0 @@ -5517,12 +5651,21 @@ snapshots: dependencies: '@types/react': 17.0.39 + '@types/react-dom@18.2.25': + dependencies: + '@types/react': 18.2.79 + '@types/react@17.0.39': dependencies: '@types/prop-types': 15.7.15 '@types/scheduler': 0.26.0 csstype: 3.2.3 + '@types/react@18.2.79': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 + '@types/scheduler@0.26.0': {} '@types/set-cookie-parser@2.4.10': @@ -7047,10 +7190,10 @@ snapshots: make-error@1.3.6: {} - marked-react@1.1.2(react@17.0.2): + marked-react@1.1.2(react@18.2.0): dependencies: marked: 4.0.16 - react: 17.0.2 + react: 18.2.0 marked@4.0.16: {} @@ -7429,6 +7572,11 @@ snapshots: prop-types: 15.8.1 react: 17.0.2 + react-day-picker@7.4.9(react@18.2.0): + dependencies: + prop-types: 15.8.1 + react: 18.2.0 + react-dom@17.0.2(react@17.0.2): dependencies: loose-envify: 1.4.0 @@ -7436,6 +7584,12 @@ snapshots: react: 17.0.2 scheduler: 0.20.2 + react-dom@18.2.0(react@18.2.0): + dependencies: + loose-envify: 1.4.0 + react: 18.2.0 + scheduler: 0.23.2 + react-error-boundary@3.1.4(react@17.0.2): dependencies: '@babel/runtime': 7.29.7 @@ -7460,6 +7614,17 @@ snapshots: typed-styles: 0.0.7 warning: 4.0.3 + react-popper@1.3.11(react@18.2.0): + dependencies: + '@babel/runtime': 7.29.7 + '@hypnosphi/create-react-context': 0.3.1(prop-types@15.8.1)(react@18.2.0) + deep-equal: 1.1.2 + popper.js: 1.16.1 + prop-types: 15.8.1 + react: 18.2.0 + typed-styles: 0.0.7 + warning: 4.0.3 + react-popper@2.3.0(@popperjs/core@2.11.8)(react-dom@17.0.2(react@17.0.2))(react@17.0.2): dependencies: '@popperjs/core': 2.11.8 @@ -7477,11 +7642,24 @@ snapshots: react-dom: 17.0.2(react@17.0.2) react-lifecycles-compat: 3.0.4 + react-transition-group@2.9.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + dependencies: + dom-helpers: 3.4.0 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-lifecycles-compat: 3.0.4 + react@17.0.2: dependencies: loose-envify: 1.4.0 object-assign: 4.1.1 + react@18.2.0: + dependencies: + loose-envify: 1.4.0 + read-cache@1.0.0: dependencies: pify: 2.3.0 @@ -7573,20 +7751,20 @@ snapshots: dependencies: glob: 7.2.3 - roamjs-components@0.89.0(5222ab9d8d0f15037684080314cfeeb5): + roamjs-components@0.89.0(25f1eaefe7300d3403328892d4fb02ee): dependencies: - '@blueprintjs/core': 3.54.0(react-dom@17.0.2(react@17.0.2))(react@17.0.2) - '@blueprintjs/datetime': 3.24.1(react-dom@17.0.2(react@17.0.2))(react@17.0.2) - '@blueprintjs/select': 3.19.1(react-dom@17.0.2(react@17.0.2))(react@17.0.2) - '@samepage/scripts': 0.74.5(@aws-sdk/client-lambda@3.1111.0)(@aws-sdk/client-s3@3.1111.0)(@samepage/testing@0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@17.0.13)(@types/react@17.0.39)(react-dom@17.0.2(react@17.0.2))(react@17.0.2))(@testing-library/user-event@14.6.4(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@30.0.1)(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2)))(archiver@5.3.2)(axios@1.20.0(debug@4.4.3))(debug@4.4.3)(dotenv@16.6.1)(esbuild@0.25.12)(patch-package@6.5.1)(tailwindcss@3.3.2(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2)))(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2))(zod@4.1.11) + '@blueprintjs/core': 3.54.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@blueprintjs/datetime': 3.24.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@blueprintjs/select': 3.19.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@samepage/scripts': 0.74.5(@aws-sdk/client-lambda@3.1111.0)(@aws-sdk/client-s3@3.1111.0)(@samepage/testing@0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.2.25)(@types/react@18.2.79)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@testing-library/user-event@14.6.4(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@30.0.1)(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2)))(archiver@5.3.2)(axios@1.20.0(debug@4.4.3))(debug@4.4.3)(dotenv@16.6.1)(esbuild@0.25.12)(patch-package@6.5.1)(tailwindcss@3.3.2(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2)))(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2))(zod@4.1.11) '@types/crypto-js': 4.1.1 '@types/cytoscape': 3.31.0 '@types/file-saver': 2.0.5 '@types/jsdom': 20.0.1 '@types/marked': 4.3.2 '@types/nanoid': 2.0.0 - '@types/react': 17.0.39 - '@types/react-dom': 17.0.13 + '@types/react': 18.2.79 + '@types/react-dom': 18.2.25 '@types/use-sync-external-store': 0.0.3 chrono-node: 2.3.0 color: 4.2.3 @@ -7602,13 +7780,13 @@ snapshots: jsdom: 30.0.1 jszip: 3.10.0 marked: 4.0.16 - marked-react: 1.1.2(react@17.0.2) + marked-react: 1.1.2(react@18.2.0) nanoid: 3.3.18 - react: 17.0.2 - react-dom: 17.0.2(react@17.0.2) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) refractor: 3.6.0 tslib: 2.8.1 - use-sync-external-store: 1.6.0(react@17.0.2) + use-sync-external-store: 1.6.0(react@18.2.0) xregexp: 5.1.2 transitivePeerDependencies: - '@aws-sdk/client-lambda' @@ -7624,12 +7802,12 @@ snapshots: - ts-node - zod - roamjs-components@0.89.0(7b650c56182331394426bf3631cd9dff): + roamjs-components@0.89.0(320e230ed0fb108a7ffb1c4252bad912): dependencies: - '@blueprintjs/core': 3.54.0(react-dom@17.0.2(react@17.0.2))(react@17.0.2) - '@blueprintjs/datetime': 3.24.1(react-dom@17.0.2(react@17.0.2))(react@17.0.2) - '@blueprintjs/select': 3.19.1(react-dom@17.0.2(react@17.0.2))(react@17.0.2) - '@samepage/scripts': 0.74.5(@aws-sdk/client-lambda@3.1111.0)(@aws-sdk/client-s3@3.1111.0)(@samepage/testing@0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@17.0.13)(@types/react@17.0.39)(react-dom@17.0.2(react@17.0.2))(react@17.0.2))(@testing-library/user-event@14.6.4(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@30.0.1)(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@22.20.2)(typescript@5.9.3)))(archiver@5.3.2)(axios@1.20.0(debug@4.4.3))(debug@4.4.3)(dotenv@16.6.1)(esbuild@0.25.12)(patch-package@6.5.1)(tailwindcss@3.3.2(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@22.20.2)(typescript@5.9.3)))(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@22.20.2)(typescript@5.9.3))(zod@4.1.11) + '@blueprintjs/core': 3.54.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@blueprintjs/datetime': 3.24.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@blueprintjs/select': 3.19.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@samepage/scripts': 0.74.5(@aws-sdk/client-lambda@3.1111.0)(@aws-sdk/client-s3@3.1111.0)(@samepage/testing@0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@17.0.13)(@types/react@17.0.39)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@testing-library/user-event@14.6.4(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@30.0.1)(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2)))(archiver@5.3.2)(axios@1.20.0(debug@4.4.3))(debug@4.4.3)(dotenv@16.6.1)(esbuild@0.25.12)(patch-package@6.5.1)(tailwindcss@3.3.2(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2)))(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2))(zod@4.1.11) '@types/crypto-js': 4.1.1 '@types/cytoscape': 3.31.0 '@types/file-saver': 2.0.5 @@ -7653,13 +7831,13 @@ snapshots: jsdom: 30.0.1 jszip: 3.10.0 marked: 4.0.16 - marked-react: 1.1.2(react@17.0.2) + marked-react: 1.1.2(react@18.2.0) nanoid: 3.3.18 - react: 17.0.2 - react-dom: 17.0.2(react@17.0.2) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) refractor: 3.6.0 tslib: 2.8.1 - use-sync-external-store: 1.6.0(react@17.0.2) + use-sync-external-store: 1.6.0(react@18.2.0) xregexp: 5.1.2 transitivePeerDependencies: - '@aws-sdk/client-lambda' @@ -7675,20 +7853,20 @@ snapshots: - ts-node - zod - roamjs-components@0.89.0(dd2f1f144015bc13ec70826008c78270): + roamjs-components@0.89.0(3b15e0e8ac67b29e0263f2bbe5b35557): dependencies: - '@blueprintjs/core': 3.54.0(react-dom@17.0.2(react@17.0.2))(react@17.0.2) - '@blueprintjs/datetime': 3.24.1(react-dom@17.0.2(react@17.0.2))(react@17.0.2) - '@blueprintjs/select': 3.19.1(react-dom@17.0.2(react@17.0.2))(react@17.0.2) - '@samepage/scripts': 0.74.5(@aws-sdk/client-lambda@3.1111.0)(@aws-sdk/client-s3@3.1111.0)(@samepage/testing@0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@17.0.13)(@types/react@17.0.39)(react-dom@17.0.2(react@17.0.2))(react@17.0.2))(@testing-library/user-event@14.6.4(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@30.0.1)(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2)))(archiver@5.3.2)(axios@1.20.0(debug@4.4.3))(debug@4.4.3)(dotenv@16.6.1)(esbuild@0.17.14)(patch-package@6.5.1)(tailwindcss@3.3.2(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2)))(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2))(zod@4.1.11) + '@blueprintjs/core': 3.54.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@blueprintjs/datetime': 3.24.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@blueprintjs/select': 3.19.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@samepage/scripts': 0.74.5(@aws-sdk/client-lambda@3.1111.0)(@aws-sdk/client-s3@3.1111.0)(@samepage/testing@0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.2.25)(@types/react@18.2.79)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@testing-library/user-event@14.6.4(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@30.0.1)(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@22.20.2)(typescript@5.9.3)))(archiver@5.3.2)(axios@1.20.0(debug@4.4.3))(debug@4.4.3)(dotenv@16.6.1)(esbuild@0.25.12)(patch-package@6.5.1)(tailwindcss@3.3.2(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@22.20.2)(typescript@5.9.3)))(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@22.20.2)(typescript@5.9.3))(zod@4.1.11) '@types/crypto-js': 4.1.1 '@types/cytoscape': 3.31.0 '@types/file-saver': 2.0.5 '@types/jsdom': 20.0.1 '@types/marked': 4.3.2 '@types/nanoid': 2.0.0 - '@types/react': 17.0.39 - '@types/react-dom': 17.0.13 + '@types/react': 18.2.79 + '@types/react-dom': 18.2.25 '@types/use-sync-external-store': 0.0.3 chrono-node: 2.3.0 color: 4.2.3 @@ -7704,13 +7882,64 @@ snapshots: jsdom: 30.0.1 jszip: 3.10.0 marked: 4.0.16 - marked-react: 1.1.2(react@17.0.2) + marked-react: 1.1.2(react@18.2.0) nanoid: 3.3.18 - react: 17.0.2 - react-dom: 17.0.2(react@17.0.2) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) refractor: 3.6.0 tslib: 2.8.1 - use-sync-external-store: 1.6.0(react@17.0.2) + use-sync-external-store: 1.6.0(react@18.2.0) + xregexp: 5.1.2 + transitivePeerDependencies: + - '@aws-sdk/client-lambda' + - '@aws-sdk/client-s3' + - '@samepage/testing' + - archiver + - axios + - debug + - dotenv + - esbuild + - patch-package + - tailwindcss + - ts-node + - zod + + roamjs-components@0.89.0(a108def2d5f55c34ad4d8a697ea8381c): + dependencies: + '@blueprintjs/core': 3.54.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@blueprintjs/datetime': 3.24.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@blueprintjs/select': 3.19.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@samepage/scripts': 0.74.5(@aws-sdk/client-lambda@3.1111.0)(@aws-sdk/client-s3@3.1111.0)(@samepage/testing@0.74.5(@playwright/test@1.29.0)(@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.2.25)(@types/react@18.2.79)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@testing-library/user-event@14.6.4(@testing-library/dom@10.4.1))(@types/jsdom@20.0.1)(c8@7.14.0)(debug@4.4.3)(dotenv@16.6.1)(jsdom@30.0.1)(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2)))(archiver@5.3.2)(axios@1.20.0(debug@4.4.3))(debug@4.4.3)(dotenv@16.6.1)(esbuild@0.17.14)(patch-package@6.5.1)(tailwindcss@3.3.2(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2)))(ts-node@10.9.2(@swc/core@1.16.1)(@types/node@26.2.0)(typescript@7.0.2))(zod@4.1.11) + '@types/crypto-js': 4.1.1 + '@types/cytoscape': 3.31.0 + '@types/file-saver': 2.0.5 + '@types/jsdom': 20.0.1 + '@types/marked': 4.3.2 + '@types/nanoid': 2.0.0 + '@types/react': 18.2.79 + '@types/react-dom': 18.2.25 + '@types/use-sync-external-store': 0.0.3 + chrono-node: 2.3.0 + color: 4.2.3 + crypto-js: 3.1.9-1 + cytoscape: 3.34.1 + date-fns: 2.30.0 + edn-data: 1.2.2 + file-saver: 2.0.2 + fuzzy: 0.1.3 + hast-util-to-html: 7.1.3 + idb: 6.1.5 + insect: 5.9.1 + jsdom: 30.0.1 + jszip: 3.10.0 + marked: 4.0.16 + marked-react: 1.1.2(react@18.2.0) + nanoid: 3.3.18 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + refractor: 3.6.0 + tslib: 2.8.1 + use-sync-external-store: 1.6.0(react@18.2.0) xregexp: 5.1.2 transitivePeerDependencies: - '@aws-sdk/client-lambda' @@ -7813,6 +8042,10 @@ snapshots: loose-envify: 1.4.0 object-assign: 4.1.1 + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + scite-badge@https://codeload.github.com/scitedotai/scite-badge/tar.gz/80bda7fa058531a1b3f107b91f43d0cb35b6375f: dependencies: '@popperjs/core': 2.11.8 @@ -8254,6 +8487,10 @@ snapshots: dependencies: react: 17.0.2 + use-sync-external-store@1.6.0(react@18.2.0): + dependencies: + react: 18.2.0 + util-deprecate@1.0.2: {} util@0.12.5: diff --git a/prototypes/roam-meeting-tasks/CHANGELOG.md b/prototypes/roam-meeting-tasks/CHANGELOG.md new file mode 100644 index 0000000..5d088cd --- /dev/null +++ b/prototypes/roam-meeting-tasks/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog + +## 0.1.0 - 2026-09-29 + +Port of the dg-team `roam/render` meeting-tasks component into an installable extension. + +- The widget's code moves out of the graph. The `roam/render` code block now holds a short shim + that hands the extension an element to render into, so existing invocations keep working. +- Graph reads use `data.async.*` and run after the widget renders instead of during Roam's render. +- Widgets on the same page share one set of reads. +- Four list-bound Datalog queries are replaced with `pull_many`; the meeting and task scans start + from reverse references. On `Sync / All Hands` one widget went from 1.6 to 2.0 s of blocking + work to about 390 ms after render, and later widgets on the page reuse the first one's reads. +- The collapsed section is read and rendered only when opened. Block references in item text are + read in one batch instead of one query each. +- Checkbox writes use `data.block.update`, update every widget showing the task, and revert if the + write fails. +- Fixes: date brackets in the "From last meeting" label, block references nested inside other + references, wrapper chains, and forcing `meeting` mode outside a meeting. diff --git a/prototypes/roam-meeting-tasks/README.md b/prototypes/roam-meeting-tasks/README.md new file mode 100644 index 0000000..688ad89 --- /dev/null +++ b/prototypes/roam-meeting-tasks/README.md @@ -0,0 +1,166 @@ +# Meeting Tasks + +The next actions carried over from past meetings, and the other open tasks on a recurring-meeting +page, shown inline wherever the page's widget sits. + +## Status + +Internal prototype for evaluation by Discourse Graphs, piloted on the `dg-team` graph's sync pages. +It replaces a `roam/render` component of the same behavior that lived entirely in one code block. +That component read the graph synchronously while Roam rendered the page: on `Sync / All Hands` +(26,728 blocks) each widget took 1.6 to 2.0 seconds, and a page mounts several. This version reads +after the page has rendered, shares one set of reads between all widgets on a page, and replaces +the slowest queries with direct lookups. See "Performance" below. + +Check which build you are running with `roamMeetingTasks.debug()` in the browser console. + +## What a widget shows + +A widget is placed with `{{roam/render: ((uid))}}`, where `uid` is the page's shared code block +(see "Installing on a graph"). It is scoped to the page it sits on and has two modes. + +**Page mode** (anywhere on the page that is not inside a meeting): + +1. **Carried over from past next actions**: every TODO and DONE under a past meeting's "next + actions" header, newest meeting first, dated. +2. **Other tasks on this page**: every other TODO and DONE on the page, collapsed by default so it + cannot bury the first section. Tasks outside any meeting are labeled "inbox". + +**Meeting mode** (inside a dated meeting block, such as a "last meeting" template slot): the first +section shows only the previous meeting's next actions, and the collapsed section shows older open +items. + +The mode is detected from the widget's position. Force it with a `page` or `meeting` argument. + +In both modes: + +- Checkboxes write `{{[[TODO]]}}` / `{{[[DONE]]}}` back to the task's own block. A task often shows + in two widgets at once; checking it in one updates the other. +- A DONE item stays visible for 14 days, then ages out. +- Clicking an item opens it. Shift-click opens it in the right sidebar. +- Only meetings from the last 120 days are read. A footer says how many older meetings were left + out. Tasks outside any meeting are always shown. + +### Arguments + +| argument | effect | +| --- | --- | +| a bare number, e.g. `365` | look back that many days instead of 120 | +| `page` / `meeting` | force a mode | +| `debug` | show a diagnostic line, including how long the widget took to load | + +Example: `{{roam/render: ((uid)) 365 debug}}`. + +## Graph conventions it relies on + +- **Meeting**: a block on the page that references a daily-note page, such as + `[[September 22nd, 2026]] #.sticky`. When any meeting on the page is tagged `#.sticky`, only tagged + blocks count, which keeps prose that mentions a date from counting as a meeting. +- **Next-actions header**: a block one or two levels under a meeting that either references the + `ℹ` tooltip block `ANCHOR_UID` in `src/config.ts` (dg-team's `yuAIplpov`), or is worded like a + header: "next actions", "Actions", "Action items/for next time", "next steps", and close variants. + The depth limit keeps prose such as "next steps for X" out of the results. +- **Task**: a block with a `{{[[TODO]]}}` or `{{[[DONE]]}}` marker. An item under a header that only + wraps a block reference, `((uid))`, stands for the block it references, and the checkbox writes + there. Chains of wrappers are followed up to 4 hops. + +`ANCHOR_UID` is specific to dg-team. On another graph the wording rules still apply. + +## Installing on a graph + +Two pieces are needed: the extension, and the shim in the graph's `roam/render` code block. + +**1. Load the extension.** Either use **Load Developer Extensions from URL** with: + +```text +https://discoursegraphs.com/releases/prototypes/roam-meeting-tasks/ +``` + +or put a loader in a `roam/js` block, which loads it for everyone in the graph who has `roam/js` +enabled. To try a pull-request preview, change `url` to the preview URL posted on the pull request. + +```js +(async () => { + const url = "https://discoursegraphs.com/releases/prototypes/roam-meeting-tasks/extension.js"; + const globalKey = "__roamMeetingTasksExtension"; + + const previous = window[globalKey]; + if (previous?.onunload) await previous.onunload(); + + const module = await import(`${url}?v=${Date.now()}`); + const extension = module.default; + if (!extension?.onload) throw new Error("The loaded module is not a Roam extension."); + + await extension.onload({ extensionAPI: undefined, extension: { version: "roam/js" } }); + window[globalKey] = extension; +})().catch((error) => console.error("Could not load Meeting Tasks:", error)); +``` + +The stylesheet is carried in the bundle, so neither loading path needs `extension.css`. + +**2. Put the shim in the code block.** Roam has no API for registering an inline component, so the +`{{roam/render: ((uid))}}` blocks stay how a widget is placed. The code block they point at holds +a short shim instead of the whole component. It renders an empty element and hands it to the +extension. With the extension loaded, run `copy(roamMeetingTasks.shim)` in the browser console and +paste the result over the code block's content. The source is `src/shim.ts`. + +Existing `{{roam/render: ((uid))}}` blocks and templates keep working unchanged, because only the +code block's content changes. + +Things to know before switching a graph over: + +- Anyone who has not loaded the extension sees a one-line note in place of each widget ("waiting + for the roam-meeting-tasks extension"). With a `roam/js` loader, that is anyone who has not + enabled `roam/js` for the graph. +- `roam/render` itself needs **custom components** turned on in each user's settings. That was + already true for the component this replaces. +- The shim's function is named `dgMeetingTasks`, the name the old component used. Roam finds a + `roam/render` component by its function name on `window`, so the name must stay unique in the + graph. + +## Performance + +Measured on `dg-team`'s `Sync / All Hands` (26,728 blocks, 120 meetings, 18 in the 120-day window): + +| | roam/render component | this extension | +| --- | --- | --- | +| when reads run | during Roam's render, synchronously | after the widget has rendered | +| cost per widget | 1.6 to 2.0 s (9 queries) | first widget on the page about 390 ms; the others reuse its reads (24 ms for a third widget) | +| collapsed section | built on every render | read and rendered only when opened | +| block references in item text | one query per reference | one batched read, only for visible rows | + +Most of the old cost came from four Datalog queries that bind a variable from a list, such as +`:in $ [?mu ...]` followed by `[?m :block/uid ?mu]`. DataScript answers those by scanning the whole +attribute rather than looking each value up, so each took 230 to 360 ms however few rows it +returned. They are now `pull_many` calls (about 70 ms together). The page-wide scans for meetings +and for TODO/DONE blocks start from the `.sticky`, `TODO`, and `DONE` pages' reverse references +instead, which are direct index reads. + +Output was checked against the old component on all 20 widgets on the three dg-team sync pages +(`All Hands`, `Roam Product`, `Protocol Product`): the same items in both sections, the same counts, +and the same footers. + +## Changes from the roam/render component + +Behavior is kept on purpose. These differ: + +- The "From last meeting" label no longer shows the date's link brackets (`[[September 22nd, 2026]]` + is now `September 22nd, 2026`). +- Block references inside item text are resolved through chains (a reference to a block that is + itself a reference). The old component resolved one level and showed the inner `((uid))`. +- A wrapper that points to another wrapper is followed to the task. The old component dropped it. +- Forcing `meeting` mode outside a meeting shows page mode. The old component threw. +- A task block with no edit time is included; if it is DONE, it counts as older than 14 days. The + old queries silently skipped such blocks. Only very old imported blocks lack an edit time. +- The collapse arrow turns (`▸` to `▾`) when the section is open. + +## Development + +```text +pnpm test # vitest: model, text rules, the shim, and the built bundle +pnpm build +pnpm typecheck # opt-in strict check +``` + +`src/index.ts` lists the modules. `roam.ts` is the only module that touches the graph; `model.ts` +and `text.ts` are pure and hold every rule above. diff --git a/prototypes/roam-meeting-tasks/package.json b/prototypes/roam-meeting-tasks/package.json new file mode 100644 index 0000000..3f9010b --- /dev/null +++ b/prototypes/roam-meeting-tasks/package.json @@ -0,0 +1,28 @@ +{ + "name": "roam-meeting-tasks", + "version": "0.1.0", + "private": true, + "description": "Carried-over next actions and open tasks for a recurring-meeting page, rendered inline where the page's roam/render widget sits.", + "type": "module", + "scripts": { + "dev": "roam-prototype dev", + "build": "roam-prototype build", + "test": "vitest run --passWithNoTests", + "typecheck": "tsc -p tsconfig.check.json" + }, + "dependencies": { + "roamjs-components": "catalog:", + "use-sync-external-store": "catalog:" + }, + "devDependencies": { + "@discoursegraphs/extension-base": "workspace:*", + "@types/node": "^22.0.0", + "@types/react": "18.2.79", + "@types/react-dom": "18.2.25", + "jsdom": "catalog:", + "react": "18.2.0", + "react-dom": "18.2.0", + "typescript": "^5.9.0", + "vitest": "catalog:" + } +} diff --git a/prototypes/roam-meeting-tasks/src/args.ts b/prototypes/roam-meeting-tasks/src/args.ts new file mode 100644 index 0000000..4488372 --- /dev/null +++ b/prototypes/roam-meeting-tasks/src/args.ts @@ -0,0 +1,40 @@ +/* The render arguments a widget was invoked with: + * + * {{roam/render: ((code-uid))}} defaults + * {{roam/render: ((code-uid)) 365}} look back 365 days + * {{roam/render: ((code-uid)) meeting debug}} force a mode, show diagnostics + */ +import { LOOKBACK_DAYS } from "~/config"; + +export type Mode = "page" | "meeting"; + +export type WidgetArgs = { + hostUid: string | null; + lookbackDays: number; + debug: boolean; + forcedMode: Mode | null; +}; + +/* Roam passes the host block as an object somewhere in the list: first on + * some graphs, not on others. So scan for it rather than trusting an index. */ +export const parseArgs = (argv: readonly unknown[]): WidgetArgs => { + let hostUid: string | null = null; + let lookback: number | null = null; + const flags: string[] = []; + for (const a of argv) { + if (a && typeof a === "object" && typeof (a as Record)["block-uid"] === "string") + hostUid = (a as Record)["block-uid"]; + else if (typeof a === "number") lookback = a; + else if (typeof a === "string") { + // A bare number is the lookback window; anything else is a flag. + if (/^\d+$/.test(a.trim())) lookback = parseInt(a.trim(), 10); + else flags.push(a.toLowerCase()); + } + } + return { + hostUid, + lookbackDays: lookback !== null && lookback > 0 ? lookback : LOOKBACK_DAYS, + debug: flags.includes("debug"), + forcedMode: flags.includes("meeting") ? "meeting" : flags.includes("page") ? "page" : null, + }; +}; diff --git a/prototypes/roam-meeting-tasks/src/config.ts b/prototypes/roam-meeting-tasks/src/config.ts new file mode 100644 index 0000000..566416e --- /dev/null +++ b/prototypes/roam-meeting-tasks/src/config.ts @@ -0,0 +1,47 @@ +/* Every knob in one place. What each one does is documented in README.md. */ + +// Injected by the shared esbuild CLI from package.json; "0.0.0" under vitest. +export const VERSION = process.env.VERSION || "0.0.0"; + +export const LOG = "[meeting-tasks]"; +export const logError = (what: string, error: unknown): void => + console.error(`${LOG} ${what}`, error); + +// The extension's public surface. The roam/render shim (see shim.ts) looks this +// up to hand over its host element, so the name is a contract with every +// graph that has the shim deployed. Never rename it casually. +export const GLOBAL_KEY = "roamMeetingTasks"; +// Where the shim parks host elements that rendered before the extension +// finished loading. The extension drains it on load. +export const QUEUE_KEY = "__roamMeetingTasksQueue"; + +export const STYLE_ID = "roam-meeting-tasks-style"; +export const HOST_CLASS = "roam-meeting-tasks-host"; +export const FALLBACK_CLASS = "roam-meeting-tasks-fallback"; + +// The ℹ tooltip block that every "next actions" header in dg-team references. +// When a header carries it, that beats matching on wording, which drifts +// across meetings and eras. dg-team specific: see README "Graph conventions". +export const ANCHOR_UID = "yuAIplpov"; + +// A DONE item stays visible this long so recent wins are seen, then ages out. +export const CELEBRATE_DAYS = 14; + +// How far back through meeting history to look. Override per invocation with +// a bare number: {{roam/render: ((uid)) 365}} +export const LOOKBACK_DAYS = 120; + +// Every widget on a page asks for the same page-wide data within a few +// milliseconds of each other. Reads started within this window share one +// request. Long enough to cover one page render, short enough that navigating +// away and back shows fresh data. +export const SHARE_MS = 3000; + +// How often to look for widgets whose host element Roam has removed, so their +// React roots are unmounted rather than leaked. +export const SWEEP_MS = 10 * 1000; + +// Refs can point at refs. Follow at most this many hops, in case of a cycle. +export const MAX_REF_HOPS = 4; + +export const DAY_MS = 86400000; diff --git a/prototypes/roam-meeting-tasks/src/index.ts b/prototypes/roam-meeting-tasks/src/index.ts new file mode 100644 index 0000000..fc4d33f --- /dev/null +++ b/prototypes/roam-meeting-tasks/src/index.ts @@ -0,0 +1,66 @@ +/* roam-meeting-tasks: the next actions carried over from past meetings, and + * the other open tasks on a recurring-meeting page, shown inline wherever the + * page's `{{roam/render: ((uid))}}` widget sits. + * + * shim.ts what lives in the graph's roam/render code block + * mount.ts gives each widget its own React root + * view.tsx what a widget displays + * load.ts one widget's load: read, then derive + * roam.ts every graph read and write + * model.ts what to show, from data already read (pure) + * text.ts string rules: markers, headers, refs, display text (pure) + */ +import { render as renderToast } from "roamjs-components/components/Toast"; +import { runExtension } from "roamjs-components/util"; +import { logError } from "~/config"; +import { load, unload } from "~/lifecycle"; + +/* What this extension needs from Roam, checked before anything else so a + * missing capability reports itself by name instead of as a TypeError deep + * in a helper. */ +const missingCapability = (): string => { + const api = window.roamAlphaAPI as unknown as Record | undefined; + if (!api) return "window.roamAlphaAPI is not available"; + const data = api.data as { async?: { pull_many?: unknown } } | undefined; + if (typeof data?.async?.pull_many !== "function") + return "window.roamAlphaAPI.data.async.pull_many is not available in this Roam build"; + const w = window as unknown as { React?: unknown; ReactDOM?: unknown }; + if (!w.React || !w.ReactDOM) return "window.React / window.ReactDOM are not available"; + return ""; +}; + +/* Report a load failure loudly, and never lose the cause. + * + * runExtension's own failure path cannot be relied on. In production it does + * not log the error: it posts the message to SamePage and shows a generic + * toast, and while doing so reads `args.extensionAPI.settings.getAll()`. + * `extensionAPI` is undefined whenever the module is loaded by `import()` + * from a roam/js block, so the reporter throws its own TypeError over ours + * and the original error is gone. So the console line comes first, here. */ +const reportLoadFailure = (error: unknown): void => { + const message = error instanceof Error ? error.message : String(error); + logError("failed to load", error); + try { + renderToast({ + id: "roam-meeting-tasks-load-failure", + content: `Meeting Tasks failed to load: ${message}`, + intent: "danger", + timeout: 0, + }); + } catch (toastError) { + logError("the failure toast also failed", toastError); + } +}; + +export default runExtension(async () => { + try { + const missing = missingCapability(); + if (missing) throw new Error(missing); + load(); + return { unload }; + } catch (error) { + reportLoadFailure(error); + unload(); + return {}; + } +}); diff --git a/prototypes/roam-meeting-tasks/src/lifecycle.ts b/prototypes/roam-meeting-tasks/src/lifecycle.ts new file mode 100644 index 0000000..b8d0f3e --- /dev/null +++ b/prototypes/roam-meeting-tasks/src/lifecycle.ts @@ -0,0 +1,51 @@ +/* Load and unload. Everything the extension adds to the page is created here + * and removed in unload(), so reloading the extension never leaves a + * duplicate behind. */ +import { GLOBAL_KEY, QUEUE_KEY, SWEEP_MS, VERSION } from "~/config"; +import { mount, mountedCount, release, sweep, unmountAll } from "~/mount"; +import { forgetReads } from "~/roam"; +import { SHIM_BLOCK } from "~/shim"; +import { addStyles, removeStyles } from "~/styles"; + +export type PublicApi = { + version: string; + mount: typeof mount; + release: typeof release; + /** The text to paste into the roam/render code block. */ + shim: string; + debug: () => { version: string; mounted: number }; +}; + +type Queued = { el: Element; args: unknown }; + +let sweepTimer: number | null = null; + +const globals = window as unknown as Record; + +export const load = (): void => { + addStyles(); + const api: PublicApi = { + version: VERSION, + mount, + release, + shim: SHIM_BLOCK, + debug: () => ({ version: VERSION, mounted: mountedCount() }), + }; + globals[GLOBAL_KEY] = api; + + // Widgets Roam rendered before this load finished. + const queued = (globals[QUEUE_KEY] as Queued[] | undefined) ?? []; + delete globals[QUEUE_KEY]; + for (const { el, args } of queued) if (el.isConnected) mount(el, args); + + sweepTimer = window.setInterval(sweep, SWEEP_MS); +}; + +export const unload = (): void => { + if (sweepTimer !== null) window.clearInterval(sweepTimer); + sweepTimer = null; + unmountAll(); + forgetReads(); + removeStyles(); + delete globals[GLOBAL_KEY]; +}; diff --git a/prototypes/roam-meeting-tasks/src/load.ts b/prototypes/roam-meeting-tasks/src/load.ts new file mode 100644 index 0000000..95eccbd --- /dev/null +++ b/prototypes/roam-meeting-tasks/src/load.ts @@ -0,0 +1,101 @@ +/* One widget's load: read what it needs (roam.ts), then decide what it shows + * (model.ts). Nothing here renders. */ +import type { WidgetArgs } from "~/args"; +import { ANCHOR_UID, CELEBRATE_DAYS, MAX_REF_HOPS } from "~/config"; +import { + deriveView, + findHeaders, + headerItems, + pendingWrapperTargets, + planWindow, + selectMeetings, + type Block, + type View, +} from "~/model"; +import { readBlocks, readHost, readMeetingRows, readPageTasks, readTrees } from "~/roam"; +import { refUids } from "~/text"; + +export type Loaded = + | { kind: "view"; view: View; texts: Map; ms: number } + | { kind: "empty"; reason: string; ms: number }; + +const pageUidFromLocation = (): string | null => + /\/page\/([\w-]+)/.exec(window.location.hash || "")?.[1] ?? null; + +/* Text of every block referenced from `strings`, following refs inside those + * blocks too, so display text can be resolved without further reads. */ +export const readRefTexts = async ( + strings: readonly string[], + into: Map = new Map(), +): Promise> => { + let frontier = strings; + for (let hop = 0; hop < MAX_REF_HOPS; hop++) { + const want = [...new Set(frontier.flatMap(refUids))].filter((u) => !into.has(u)); + if (!want.length) break; + const got = await readBlocks(want); + for (const [uid, b] of got) into.set(uid, b.string); + frontier = [...got.values()].map((b) => b.string); + } + return into; +}; + +export const loadWidget = async (args: WidgetArgs, now = Date.now()): Promise => { + // Wall time from the first read to the last, shown by the `debug` flag. + const started = performance.now(); + const ms = () => Math.round(performance.now() - started); + const empty = (reason: string): Loaded => ({ kind: "empty", reason, ms: ms() }); + + const host = args.hostUid ? await readHost(args.hostUid) : null; + const pageUid = host?.pageUid ?? pageUidFromLocation(); + if (!pageUid) return empty("no page uid"); + + const [meetingRows, pageTasks] = await Promise.all([ + readMeetingRows(pageUid), + readPageTasks(pageUid), + ]); + const meetings = selectMeetings(meetingRows); + if (!meetings.length) return empty("no meetings found"); + + const plan = planWindow({ + meetings, + hostAncestorUids: host?.ancestorUids ?? [], + forcedMode: args.forcedMode, + lookbackDays: args.lookbackDays, + now, + }); + if (!plan.scan.length) return empty(`no meetings within ${args.lookbackDays} days`); + + const trees = await readTrees(plan.scan.map((m) => m.uid)); + + // Walk ((ref)) wrapper chains one hop per round, one read per round. + const blocks = new Map(); + const lookupBlock = (uid: string) => blocks.get(uid); + const items = headerItems(findHeaders(trees, ANCHOR_UID)).map((i) => i.block); + for (let hop = 0; hop < MAX_REF_HOPS; hop++) { + const want = pendingWrapperTargets(items, lookupBlock, MAX_REF_HOPS); + if (!want.length) break; + const got = await readBlocks(want); + if (!got.size) break; + for (const [uid, b] of got) blocks.set(uid, b); + } + + const view = deriveView({ + pageUid, + meetings, + plan, + trees, + pageTasks, + lookupBlock, + anchorUid: ANCHOR_UID, + celebrateDays: CELEBRATE_DAYS, + maxHops: MAX_REF_HOPS, + now, + }); + if (!view.primary.length && !view.secondary.length) + return empty(`nothing to show. ${view.debug}`); + + // Only the visible section's refs. The collapsed section reads its own + // when it is opened. + const texts = await readRefTexts(view.primary.map((r) => r.task.string)); + return { kind: "view", view, texts, ms: ms() }; +}; diff --git a/prototypes/roam-meeting-tasks/src/model.ts b/prototypes/roam-meeting-tasks/src/model.ts new file mode 100644 index 0000000..f4cf70a --- /dev/null +++ b/prototypes/roam-meeting-tasks/src/model.ts @@ -0,0 +1,288 @@ +/* What a widget shows, derived from data already read from the graph. + * + * Pure functions only: no graph access, no React. roam.ts reads, this file + * decides, view.tsx displays. Each step is a port of the roam/render + * component this prototype replaces, and keeps its behavior unless a comment + * says otherwise. + * + * Vocabulary: + * meeting a block on the page that references a daily-note page and is + * tagged #.sticky, e.g. "[[September 22nd, 2026]] #.sticky" + * header a "next actions" block one or two levels under a meeting + * carried a task anywhere under a header + * other every other TODO/DONE on the page + */ +import type { Mode } from "~/args"; +import { DAY_MS } from "~/config"; +import { dailyNoteTime, hasMarker, headerKind, isDone, leadRef, stripMarkup } from "~/text"; + +export type Block = { uid: string; string: string; time: number }; // time = last edit +export type TreeNode = Block & { children: TreeNode[] }; +export type PageTask = Block & { parentUids: string[] }; +export type MeetingRow = { uid: string; string: string; dailyNoteUid: string; sticky: boolean }; +export type Meeting = { uid: string; string: string; time: number }; // time = meeting date +export type Row = { task: Block; meeting: Meeting | null }; + +/* ── meetings ──────────────────────────────────────────────────────────── */ + +/* One entry per meeting block, newest first. A block can reference several + * dates; the meeting's own date is the earliest. The #.sticky tag separates + * real meeting blocks from prose that happens to mention a date, so when any + * meeting carries it, only those count. */ +export const selectMeetings = (rows: readonly MeetingRow[]): Meeting[] => { + const byUid = new Map(); + for (const r of rows) { + const time = dailyNoteTime(r.dailyNoteUid); + const prev = byUid.get(r.uid); + if (!prev) byUid.set(r.uid, { uid: r.uid, string: r.string, time, sticky: r.sticky }); + else if (time && (!prev.time || time < prev.time)) prev.time = time; + } + const all = [...byUid.values()]; + const sticky = all.filter((m) => m.sticky); + return (sticky.length ? sticky : all) + .filter((m) => m.time > 0) + .sort((a, b) => b.time - a.time) + .map(({ uid, string, time }) => ({ uid, string, time })); +}; + +/* "[[September 22nd, 2026]] #.sticky" → "September 22nd, 2026" */ +export const meetingTitle = (s: string): string => + stripMarkup(s) + .replace(/#\S+/g, "") + .replace(/\[\[|\]\]/g, "") + .trim(); + +/* ── mode and window ───────────────────────────────────────────────────── */ + +export type Plan = { + mode: Mode; + hostMeeting: Meeting | null; + windowStart: number; + scan: Meeting[]; + olderCount: number; + lookbackDays: number; +}; + +/* A widget inside a dated meeting block shows the PREVIOUS meeting's next + * actions (the "last meeting" template slot); anywhere else on the page it + * shows everything. The lookback window is anchored on the host meeting in the + * first case, so "last meeting" still resolves after a long gap. */ +export const planWindow = (input: { + meetings: readonly Meeting[]; + hostAncestorUids: readonly string[]; + forcedMode: Mode | null; + lookbackDays: number; + now: number; +}): Plan => { + const byUid = new Map(input.meetings.map((m) => [m.uid, m])); + // Nested meeting blocks are rare; when they happen the latest-dated wins, + // the same rule tasks use below. (The old component took whichever + // ancestor the query happened to return first.) + let hostMeeting: Meeting | null = null; + for (const uid of input.hostAncestorUids) { + const m = byUid.get(uid); + if (m && (!hostMeeting || m.time > hostMeeting.time)) hostMeeting = m; + } + // Forcing meeting mode outside a meeting used to throw; show page mode. + const mode: Mode = hostMeeting && input.forcedMode !== "page" ? "meeting" : "page"; + if (mode === "page") hostMeeting = null; + const anchor = hostMeeting ? hostMeeting.time : input.now; + const windowStart = anchor - input.lookbackDays * DAY_MS; + const scan = input.meetings.filter((m) => m.time >= windowStart); + return { + mode, + hostMeeting, + windowStart, + scan, + olderCount: input.meetings.length - scan.length, + lookbackDays: input.lookbackDays, + }; +}; + +/* ── headers and the tasks under them ──────────────────────────────────── */ + +export type Header = { uid: string; meetingUid: string; viaAnchor: boolean; node: TreeNode }; + +/* Headers sit one or two levels under a meeting block. Bounding the depth is + * what keeps prose like "next steps for X", which lives deeper under + * "points of discussion", out of the results. */ +export const findHeaders = (trees: readonly TreeNode[], anchorUid: string): Header[] => { + const seen = new Set(); + const out: Header[] = []; + const consider = (meetingUid: string, node: TreeNode) => { + if (seen.has(node.uid)) return; + const kind = headerKind(node.string, anchorUid); + if (!kind) return; + seen.add(node.uid); + out.push({ uid: node.uid, meetingUid, viaAnchor: kind === "anchor", node }); + }; + for (const t of trees) for (const c of t.children) consider(t.uid, c); + for (const t of trees) for (const c of t.children) for (const g of c.children) consider(t.uid, g); + return out; +}; + +const descendants = (node: TreeNode): TreeNode[] => + node.children.flatMap((c) => [c, ...descendants(c)]); + +export type HeaderItem = { meetingUid: string; block: Block }; + +export const headerItems = (headers: readonly Header[]): HeaderItem[] => + headers.flatMap((h) => + descendants(h.node).map((b) => ({ + meetingUid: h.meetingUid, + block: { uid: b.uid, string: b.string, time: b.time }, + })), + ); + +/* Items are often ((block-ref)) wrappers around the real task, and the real + * task may live on another page, or be a wrapper itself. Follow the chain to + * the block that actually holds the marker, so the checkbox writes there. */ +export const resolveTask = ( + block: Block, + lookup: (uid: string) => Block | undefined, + maxHops: number, +): Block | null => { + let cur = block; + for (let hop = 0; hop <= maxHops; hop++) { + if (hasMarker(cur.string)) return cur; + const next = leadRef(cur.string); + const target = next ? lookup(next) : undefined; + if (!target) return null; + cur = target; + } + return null; +}; + +/* The wrapper targets the data layer must fetch before resolveTask can run. + * Call repeatedly with the growing lookup to walk chains one hop at a time. */ +export const pendingWrapperTargets = ( + blocks: readonly Block[], + lookup: (uid: string) => Block | undefined, + maxHops: number, +): string[] => { + const wanted = new Set(); + for (const b of blocks) { + let cur: Block | undefined = b; + for (let hop = 0; cur && hop <= maxHops; hop++) { + if (hasMarker(cur.string)) break; + const next = leadRef(cur.string); + if (!next) break; + const target = lookup(next); + if (!target) { + wanted.add(next); + break; + } + cur = target; + } + } + return [...wanted]; +}; + +/* ── the view ──────────────────────────────────────────────────────────── */ + +export type View = { + mode: Mode; + primary: Row[]; + secondary: Row[]; + primaryLabel: string; + secondaryLabel: string; + olderCount: number; + lookbackDays: number; + debug: string; +}; + +const newestFirst = (a: Row, b: Row): number => { + const d = (b.meeting?.time ?? 0) - (a.meeting?.time ?? 0); + return d !== 0 ? d : b.task.time - a.task.time; +}; + +export const deriveView = (input: { + pageUid: string; + meetings: readonly Meeting[]; + plan: Plan; + trees: readonly TreeNode[]; + pageTasks: readonly PageTask[]; + lookupBlock: (uid: string) => Block | undefined; + anchorUid: string; + celebrateDays: number; + maxHops: number; + now: number; +}): View => { + const { meetings, plan } = input; + const meetingByUid = new Map(meetings.map((m) => [m.uid, m])); + const headers = findHeaders(input.trees, input.anchorUid); + const meetingsWithHeaders = new Set(headers.map((h) => h.meetingUid)); + + // The same task can be carried forward across meetings: keep the newest. + const claimed = new Map(); + for (const item of headerItems(headers)) { + const task = resolveTask(item.block, input.lookupBlock, input.maxHops); + const meeting = meetingByUid.get(item.meetingUid); + if (!task || !meeting) continue; + const prev = claimed.get(task.uid); + if (prev && (prev.meeting?.time ?? 0) >= meeting.time) continue; + claimed.set(task.uid, { task, meeting }); + } + const carried = [...claimed.values()]; + + const others: Row[] = input.pageTasks + .filter((t) => hasMarker(t.string) && !claimed.has(t.uid)) + .map((t) => { + // A task can sit under nested meeting blocks; the latest-dated wins. + let meeting: Meeting | null = null; + for (const p of t.parentUids) { + const m = meetingByUid.get(p); + if (m && (!meeting || m.time > meeting.time)) meeting = m; + } + return { task: { uid: t.uid, string: t.string, time: t.time }, meeting }; + }) + // A task under a meeting outside the window drops out. Page-level inbox + // tasks have no meeting and always stay: they are current, not history, + // and silently hiding open work is worse than a slightly longer list. + .filter((r) => !r.meeting || r.meeting.time >= plan.windowStart); + + const cutoff = input.now - input.celebrateDays * DAY_MS; + const visible = (r: Row) => (isDone(r.task.string) ? r.task.time >= cutoff : true); + const open = (r: Row) => !isDone(r.task.string); + + let primary: Row[]; + let secondary: Row[]; + let primaryLabel: string; + let secondaryLabel: string; + const host = plan.hostMeeting; + if (plan.mode === "meeting" && host) { + const prev = + meetings.find((m) => m.time < host.time && meetingsWithHeaders.has(m.uid)) ?? null; + primary = carried.filter((r) => prev !== null && r.meeting?.uid === prev.uid && visible(r)); + secondary = carried + .filter((r) => (prev === null || r.meeting?.uid !== prev.uid) && open(r)) + .concat(others.filter(open)); + primaryLabel = prev ? `From last meeting · ${meetingTitle(prev.string)}` : "From last meeting"; + secondaryLabel = "older open items on this page"; + } else { + primary = carried.filter(visible); + secondary = others.filter(visible); + primaryLabel = "Carried over from past next actions"; + // Not "open tasks": in page mode this list also carries recent DONEs. + secondaryLabel = "Other tasks on this page"; + } + primary.sort(newestFirst); + secondary.sort(newestFirst); + + const anchored = headers.filter((h) => h.viaAnchor).length; + const debug = + `mode=${plan.mode} lookback=${plan.lookbackDays}d(${plan.scan.length}/${meetings.length} meetings)` + + ` page=${input.pageUid} meetings=${meetings.length} headers=${headers.length} (anchored ${anchored})` + + ` carried=${carried.length} others=${others.length}`; + + return { + mode: plan.mode, + primary, + secondary, + primaryLabel, + secondaryLabel, + olderCount: plan.olderCount, + lookbackDays: plan.lookbackDays, + debug, + }; +}; diff --git a/prototypes/roam-meeting-tasks/src/mount.ts b/prototypes/roam-meeting-tasks/src/mount.ts new file mode 100644 index 0000000..ccabae4 --- /dev/null +++ b/prototypes/roam-meeting-tasks/src/mount.ts @@ -0,0 +1,77 @@ +/* The bridge between Roam's roam/render blocks and this extension. + * + * Roam has no API for registering an inline component (registerComponent is + * for whole main-window views), so `{{roam/render: ((uid))}}` stays the way a + * widget is placed. The code block it points at is a small shim (shim.ts) + * that renders an empty host element and hands it here. Each host gets its + * own React root, so the widget has ordinary state and effects: the shim + * never runs any of the widget's code during Roam's render. + */ +import React from "react"; +import ReactDOM from "react-dom"; +import * as ReactDOMClient from "react-dom/client"; +import { parseArgs } from "~/args"; +import { logError } from "~/config"; +import { MeetingTasks } from "~/view"; + +type Root = { render: (node: React.ReactElement) => void; unmount: () => void }; + +/* Roam ships React 18 (18.2 as of September 2026), so each widget gets a + * createRoot root. The legacy render API is the fallback for older hosts. */ +const createRoot = (el: Element): Root => { + const client = ReactDOMClient as unknown as { createRoot?: (el: Element) => Root }; + if (typeof client.createRoot === "function") return client.createRoot(el); + const legacy = ReactDOM as unknown as { + render: (node: React.ReactElement, el: Element) => void; + unmountComponentAtNode: (el: Element) => boolean; + }; + return { + render: (node) => legacy.render(node, el), + unmount: () => void legacy.unmountComponentAtNode(el), + }; +}; + +const roots = new Map(); + +const unmountHost = (el: Element): void => { + try { + roots.get(el)?.unmount(); + } catch (error) { + logError("could not unmount a widget", error); + } + roots.delete(el); +}; + +/* Render (or re-render in place) the widget into a host element. Roam calls + * the shim again whenever it re-renders the block; rendering into the same + * root updates it, so state such as an opened section and the loaded data + * survive. */ +export const mount = (el: Element, argv: unknown): void => { + const args = parseArgs(Array.isArray(argv) ? argv : []); + let root = roots.get(el); + if (!root) { + root = createRoot(el); + roots.set(el, root); + } + root.render(React.createElement(MeetingTasks, { args })); +}; + +/* The shim lets go of a host. It may be re-attaching the same element on its + * next render, so only unmount once the element has really left the page. + * The delay also keeps the unmount out of React's own commit phase. */ +export const release = (el: Element): void => { + window.setTimeout(() => { + if (!el.isConnected) unmountHost(el); + }, 0); +}; + +/* Roam can remove a block's DOM without the shim hearing about it. */ +export const sweep = (): void => { + for (const el of [...roots.keys()]) if (!el.isConnected) unmountHost(el); +}; + +export const unmountAll = (): void => { + for (const el of [...roots.keys()]) unmountHost(el); +}; + +export const mountedCount = (): number => roots.size; diff --git a/prototypes/roam-meeting-tasks/src/roam.ts b/prototypes/roam-meeting-tasks/src/roam.ts new file mode 100644 index 0000000..d7cf14a --- /dev/null +++ b/prototypes/roam-meeting-tasks/src/roam.ts @@ -0,0 +1,240 @@ +/* Every graph read and write, and nothing else. + * + * Reads use the promise-returning `data.async.*` API and run after the widget + * has rendered, never during render. They are direct lookups (pull, + * pull_many, reverse references) wherever possible. Datalog queries that + * bind a variable from an earlier clause or from a list make DataScript scan + * the whole attribute: on dg-team's All Hands page (26,728 blocks) the old + * component spent 1.6-2.0 s per widget that way, synchronously, and a page + * mounts several widgets. + * + * Page-wide reads are shared: every widget on a page asks for the same data + * at nearly the same moment, and only the first one actually reads. + */ +import { SHARE_MS } from "~/config"; +import type { Block, MeetingRow, PageTask, TreeNode } from "~/model"; + +type Pulled = Record; + +const api = () => window.roamAlphaAPI as unknown as RoamApi; + +type RoamApi = { + graph?: { name?: string }; + data: { + async: { + q: (query: string, ...inputs: unknown[]) => Promise; + pull: (pattern: string, eid: unknown) => Promise; + pull_many: (pattern: string, eids: unknown[]) => Promise<(Pulled | null)[]>; + }; + block: { update: (args: { block: { uid: string; string: string } }) => Promise }; + }; + ui: { + rightSidebar: { + addWindow: (args: { window: { type: "block"; "block-uid": string } }) => Promise; + open: () => Promise; + }; + }; +}; + +const str = (v: unknown): string => (typeof v === "string" ? v : ""); +const num = (v: unknown): number => (typeof v === "number" ? v : 0); +const list = (v: unknown): Pulled[] => (Array.isArray(v) ? (v as Pulled[]) : []); +const byUid = (uid: string) => [":block/uid", uid]; + +/* ── sharing ───────────────────────────────────────────────────────────── */ + +type Entry = { at: number; value: Promise }; +const shared = new Map(); + +const fresh = (key: string): Entry | undefined => { + const hit = shared.get(key); + return hit && Date.now() - hit.at < SHARE_MS ? hit : undefined; +}; + +const share = (key: string, load: () => Promise): Promise => { + const hit = fresh(key); + if (hit) return hit.value as Promise; + const value = load(); + shared.set(key, { at: Date.now(), value }); + value.catch(() => shared.delete(key)); + return value; +}; + +/* Like share(), for per-uid values fetched in one batch: uids already in + * flight are reused, the rest go out in a single pull_many. */ +const shareMany = async ( + prefix: string, + uids: readonly string[], + load: (uids: string[]) => Promise>, +): Promise> => { + const missing = [...new Set(uids)].filter((u) => !fresh(prefix + u)); + if (missing.length) { + const batch = load(missing); + const at = Date.now(); + for (const u of missing) { + const value = batch.then((m) => m.get(u)); + shared.set(prefix + u, { at, value }); + value.catch(() => shared.delete(prefix + u)); + } + } + const out = new Map(); + await Promise.all( + uids.map(async (u) => { + const v = (await shared.get(prefix + u)?.value) as T | undefined; + if (v !== undefined) out.set(u, v); + }), + ); + return out; +}; + +/* After a write, anything read before it may be stale. */ +export const forgetReads = (): void => shared.clear(); + +/* ── reads ─────────────────────────────────────────────────────────────── */ + +export const graphName = (): string => api().graph?.name ?? ""; + +/* The widget's page and every ancestor block, in one direct read. */ +export const readHost = async ( + uid: string, +): Promise<{ pageUid: string; ancestorUids: string[] } | null> => { + const b = await api().data.async.pull( + "[{:block/page [:block/uid]} {:block/parents [:block/uid]}]", + byUid(uid), + ); + const pageUid = str((b?.[":block/page"] as Pulled | undefined)?.[":block/uid"]); + if (!pageUid) return null; + return { pageUid, ancestorUids: list(b?.[":block/parents"]).map((p) => str(p[":block/uid"])) }; +}; + +/* Blocks on the page that reference a daily-note page. + * + * Fast path: start from the `.sticky` tag's reverse references (~2,300 blocks + * graph-wide in dg-team, ~35 ms) instead of scanning every block on the page + * (~170 ms on All Hands). model.selectMeetings only keeps #.sticky meetings + * when any exist, so this returns the same meetings. A page with no #.sticky + * meetings falls back to the page scan. */ +export const readMeetingRows = (pageUid: string): Promise => + share(`meetings:${pageUid}`, async () => { + const tag = await api().data.async.pull( + "[{(:block/_refs :limit nil) [:block/uid :block/string {:block/page [:block/uid]} {:block/refs [:block/uid :log/id]}]}]", + [":node/title", ".sticky"], + ); + const sticky = list(tag?.[":block/_refs"]) + .filter((b) => str((b[":block/page"] as Pulled | undefined)?.[":block/uid"]) === pageUid) + .flatMap((b) => + list(b[":block/refs"]) + .filter((r) => r[":log/id"] !== undefined) + .map((r) => ({ + uid: str(b[":block/uid"]), + string: str(b[":block/string"]), + dailyNoteUid: str(r[":block/uid"]), + sticky: true, + })), + ); + if (sticky.length) return sticky; + + // Tuple order: [meeting uid, meeting string, daily-note uid] + const rows = await api().data.async.q( + `[:find ?mu ?ms ?du :in $ ?pgu :where + [?pg :block/uid ?pgu] [?m :block/page ?pg] + [?m :block/refs ?d] [?d :log/id _] [?d :block/uid ?du] + [?m :block/uid ?mu] [?m :block/string ?ms]]`, + pageUid, + ); + return rows.map(([uid, string, dailyNoteUid]) => ({ + uid: str(uid), + string: str(string), + dailyNoteUid: str(dailyNoteUid), + sticky: str(string).includes(".sticky"), + })); + }); + +const toTree = (b: Pulled): TreeNode => ({ + uid: str(b[":block/uid"]), + string: str(b[":block/string"]), + time: num(b[":edit/time"]), + children: list(b[":block/children"]).map(toTree), +}); + +/* Whole subtrees of the given meeting blocks, in one pull_many. On All Hands + * the 18 meetings in a 120-day window are ~2,200 blocks and ~45 ms. */ +export const readTrees = async (uids: readonly string[]): Promise => { + const got = await shareMany("tree:", uids, async (missing) => { + const pulled = await api().data.async.pull_many( + "[:block/uid :block/string :edit/time {:block/children ...}]", + missing.map(byUid), + ); + return new Map(pulled.filter((b): b is Pulled => !!b).map((b) => [str(b[":block/uid"]), toTree(b)])); + }); + return uids.map((u) => got.get(u)).filter((t): t is TreeNode => !!t); +}; + +/* Every TODO/DONE block on the page, with its ancestors. + * + * Starts from the TODO and DONE pages' reverse references, since every + * checkbox references one of them, and keeps the ones on this page. Then one + * pull_many for their text and ancestors, which replaces a query that joined + * every task against every meeting. */ +export const readPageTasks = (pageUid: string): Promise => + share(`tasks:${pageUid}`, async () => { + const markers = await Promise.all( + ["TODO", "DONE"].map((title) => + api().data.async.pull( + "[{(:block/_refs :limit nil) [:block/uid {:block/page [:block/uid]}]}]", + [":node/title", title], + ), + ), + ); + const uids = [ + ...new Set( + markers.flatMap((m) => + list(m?.[":block/_refs"]) + .filter((b) => str((b[":block/page"] as Pulled | undefined)?.[":block/uid"]) === pageUid) + .map((b) => str(b[":block/uid"])), + ), + ), + ]; + if (!uids.length) return []; + const pulled = await api().data.async.pull_many( + "[:block/uid :block/string :edit/time {:block/parents [:block/uid]}]", + uids.map(byUid), + ); + return pulled + .filter((b): b is Pulled => !!b) + .map((b) => ({ + uid: str(b[":block/uid"]), + string: str(b[":block/string"]), + time: num(b[":edit/time"]), + parentUids: list(b[":block/parents"]).map((p) => str(p[":block/uid"])), + })); + }); + +/* Text of the given blocks, for resolving ((refs)). One pull_many per call. */ +export const readBlocks = (uids: readonly string[]): Promise> => + shareMany("block:", uids, async (missing) => { + const pulled = await api().data.async.pull_many( + "[:block/uid :block/string :edit/time]", + missing.map(byUid), + ); + return new Map( + pulled + .filter((b): b is Pulled => !!b) + .map((b) => [ + str(b[":block/uid"]), + { uid: str(b[":block/uid"]), string: str(b[":block/string"]), time: num(b[":edit/time"]) }, + ]), + ); + }); + +/* ── writes ────────────────────────────────────────────────────────────── */ + +export const writeString = async (uid: string, string: string): Promise => { + await api().data.block.update({ block: { uid, string } }); + forgetReads(); +}; + +export const openInSidebar = async (uid: string): Promise => { + await api().ui.rightSidebar.addWindow({ window: { type: "block", "block-uid": uid } }); + await api().ui.rightSidebar.open(); +}; diff --git a/prototypes/roam-meeting-tasks/src/shim.ts b/prototypes/roam-meeting-tasks/src/shim.ts new file mode 100644 index 0000000..27bc391 --- /dev/null +++ b/prototypes/roam-meeting-tasks/src/shim.ts @@ -0,0 +1,57 @@ +/* The code that lives in the graph's roam/render code block. + * + * Every widget is placed with `{{roam/render: ((uid))}}`, where `uid` is one + * javascript code block. That block used to hold the whole component. Now it + * holds only this shim, which renders an empty host element and hands it to + * the extension. So invocations, templates, and the block uid all stay as + * they are; only the code block's content changes. + * + * The extension may finish loading after Roam has already rendered a widget + * (a roam/js loader imports it asynchronously). In that case the shim parks + * its host element in a queue, which the extension drains when it loads. + * Until then, and for anyone who does not have the extension, a one-line + * note is shown; the extension's stylesheet hides it. + * + * Constraints on this text, all learned the hard way: + * - Roam finds the component by its function name on `window`, so the name + * must be unique across every roam/render block in the graph. It stays + * `dgMeetingTasks`, the name the deployed component already uses. + * - No hooks: Roam calls this function in a way that makes hooks throw. + * - ES5 only, no imports: Roam evaluates the block as a plain script. + */ +import { FALLBACK_CLASS, GLOBAL_KEY, HOST_CLASS, QUEUE_KEY } from "~/config"; + +export const SHIM_FUNCTION = "dgMeetingTasks"; + +export const SHIM_SOURCE = `function ${SHIM_FUNCTION}(props) { + // Drawn by the roam-meeting-tasks extension: + // https://github.com/DiscourseGraphs/roam-prototypes/tree/main/prototypes/roam-meeting-tasks + // This block only gives it a place to draw. Keep the function name. + var React = window.React; + var argv = (props && props.args) || (typeof args !== "undefined" ? args : []) || []; + var host = null; + function attach(el) { + var ext = window.${GLOBAL_KEY}; + if (el) { + host = el; + if (ext) ext.mount(el, argv); + else (window.${QUEUE_KEY} = window.${QUEUE_KEY} || []).push({ el: el, args: argv }); + } else if (host) { + if (ext) ext.release(host); + host = null; + } + } + return React.createElement( + "div", + null, + React.createElement("div", { className: "${HOST_CLASS}", ref: attach }), + React.createElement( + "div", + { className: "${FALLBACK_CLASS}", style: { fontSize: "12px", opacity: 0.5 } }, + "Meeting tasks: waiting for the roam-meeting-tasks extension." + ) + ); +}`; + +/* The exact string to paste into the roam/render code block. */ +export const SHIM_BLOCK = "```javascript\n" + SHIM_SOURCE + "\n```"; diff --git a/prototypes/roam-meeting-tasks/src/styles.ts b/prototypes/roam-meeting-tasks/src/styles.ts new file mode 100644 index 0000000..e650496 --- /dev/null +++ b/prototypes/roam-meeting-tasks/src/styles.ts @@ -0,0 +1,42 @@ +/* Carried in the bundle rather than as a published extension.css: Roam only + * injects that file on the URL-loading path, not when a roam/js block + * `import()`s the bundle. Every class is prefixed `rmt-` so nothing here can + * restyle the graph. The look matches the roam/render component it replaces. */ +import { FALLBACK_CLASS, STYLE_ID } from "~/config"; + +export const CSS = ` + /* The shim's "waiting for the extension" note. Hidden whenever this + stylesheet is present, which is exactly when the extension is loaded. */ + .${FALLBACK_CLASS} { display: none; } + + .rmt { font-size: 13px; line-height: 1.45; } + .rmt-muted { font-size: 12px; opacity: 0.5; } + .rmt-count { font-size: 12px; opacity: 0.7; margin-bottom: 2px; } + .rmt-heading { + font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; + opacity: 0.6; margin: 8px 0 2px; + } + .rmt-toggle { cursor: pointer; user-select: none; } + .rmt-row { display: flex; align-items: flex-start; gap: 6px; padding: 1px 0; } + .rmt-row[data-done="true"] { opacity: 0.55; } + .rmt .rmt-row[data-done="true"] .rmt-text { text-decoration: line-through; } + .rmt-check { margin-top: 3px; cursor: pointer; flex: 0 0 auto; } + .rmt-date { + flex: 0 0 auto; font-size: 11px; opacity: 0.6; min-width: 42px; padding-top: 2px; + } + .rmt-date[data-inbox="true"] { opacity: 0.45; } + /* Two classes deep so Roam's and Blueprint's own link colors lose. */ + .rmt .rmt-text { flex: 1 1 auto; color: inherit; text-decoration: none; } + .rmt-footer { font-size: 11px; opacity: 0.5; margin-top: 6px; } + .rmt-debug { font-size: 11px; opacity: 0.6; margin-top: 6px; } +`; + +export const addStyles = (): void => { + if (document.getElementById(STYLE_ID)) return; + const el = document.createElement("style"); + el.id = STYLE_ID; + el.textContent = CSS; + document.head.appendChild(el); +}; + +export const removeStyles = (): void => document.getElementById(STYLE_ID)?.remove(); diff --git a/prototypes/roam-meeting-tasks/src/text.ts b/prototypes/roam-meeting-tasks/src/text.ts new file mode 100644 index 0000000..dd49252 --- /dev/null +++ b/prototypes/roam-meeting-tasks/src/text.ts @@ -0,0 +1,91 @@ +/* String rules: what counts as a task, a header, or a reference, and how a + * block string is turned into display text. Pure functions, no graph access. */ + +// Anchored to end-of-string on purpose: "next steps for cybrarian position" is +// prose, not a header. Novel wordings are meant to be caught by ANCHOR_UID. +const HEADER_RE = + /^(?:(?:next\s+)?actions?(?:\s+items?)?|next\s+steps?)(?:\s*[::])?(?:\s*[/,-]?\s*(?:by\s+\w+|for\s+next\s+time|this\s+week))?$/i; +const MARKER_RE = /\{\{\[\[(TODO|DONE)\]\]\}\}|\{\{(TODO|DONE)\}\}/; +const DONE_RE = /\{\{\[\[DONE\]\]\}\}|\{\{DONE\}\}/; +// A block that starts with a ((ref)) may be a wrapper around the real task. +const LEAD_REF_RE = /^\s*\(\(([\w-]{6,})\)\)/; +const REF_RE = /\(\(([\w-]{6,})\)\)/g; + +export const hasMarker = (s: string): boolean => MARKER_RE.test(s); +export const isDone = (s: string): boolean => DONE_RE.test(s); +export const leadRef = (s: string): string | null => LEAD_REF_RE.exec(s)?.[1] ?? null; +export const refUids = (s: string): string[] => [...s.matchAll(REF_RE)].map((m) => m[1]); + +/* The string with its checkbox flipped. Handles both marker spellings Roam + * accepts, and leaves a string without the matching marker unchanged. */ +export const withDone = (s: string, done: boolean): string => + done + ? s.replace(/\{\{\[\[TODO\]\]\}\}/g, "{{[[DONE]]}}").replace(/\{\{TODO\}\}/g, "{{DONE}}") + : s.replace(/\{\{\[\[DONE\]\]\}\}/g, "{{[[TODO]]}}").replace(/\{\{DONE\}\}/g, "{{TODO}}"); + +export const stripMarkup = (s: string): string => + s + .replace(/\[ℹ\]\(\(\([\w-]+\)\)\)/g, "") + .replace(/\*\*|__|\^\^|~~/g, "") + .replace(/^#+\s*/, "") + .trim(); + +/* Is this block a "next actions" section header? A block that references the + * anchor tooltip always is. Otherwise it must match the wording, and must not + * carry its own TODO/DONE marker: a marked block is a task, not a header. */ +export const headerKind = (s: string, anchorUid: string): "anchor" | "wording" | null => { + if (anchorUid && s.includes(anchorUid)) return "anchor"; + if (hasMarker(s)) return null; + const bare = stripMarkup(s).replace(/[::]\s*$/, ""); + return HEADER_RE.test(bare) ? "wording" : null; +}; + +/* Daily-note pages have the uid MM-DD-YYYY. Returns local midnight, or 0. */ +export const dailyNoteTime = (uid: string): number => { + const p = uid.split("-"); + if (p.length !== 3) return 0; + const t = new Date(+p[2], +p[0] - 1, +p[1]).getTime(); + return Number.isFinite(t) ? t : 0; +}; + +/* Replace every ((ref)) with its target's text, following refs inside the + * target too. A ref whose target is unknown drops out, as before. Stops after + * `maxHops` rounds, so a cycle cannot loop forever. */ +export const resolveRefs = ( + s: string, + lookup: (uid: string) => string | undefined, + maxHops: number, +): string => { + let out = s; + for (let i = 0; i < maxHops; i++) { + const next = out.replace(REF_RE, (_whole, uid: string) => lookup(uid) ?? ""); + if (next === out) break; + out = next; + } + return out; +}; + +/* Block string → one line of display text. Markup is removed rather than + * rendered: the row is a link, and nested links inside it would fight it. */ +export const displayText = ( + s: string, + lookup: (uid: string) => string | undefined, + maxHops: number, +): string => { + // Embeds go first: they are not text, and a resolved ref inside one could + // contain "}}" and cut the non-greedy match short. + const withoutEmbeds = s.replace(/\{\{\[\[embed\]\]:.*?\}\}/g, ""); + const out = resolveRefs(withoutEmbeds, lookup, maxHops) + .replace(/\{\{\[\[(TODO|DONE)\]\]\}\}|\{\{(TODO|DONE)\}\}/g, "") + .replace(/\{\{\[\[embed\]\]:.*?\}\}/g, "") + .replace(/\{\{\[\[POMO\]\]:\s*\d+\}\}/g, "") + .replace(/!\[\]\(\S+\)/g, "") + .replace(/\[([^\]]*)\]\((?:https?:[^)]*|\[\[[^)]*)\)/g, "$1") + // Remove every bracket rather than matching pairs: nested node titles + // like [[[[QUE]] - ...]] leave strays otherwise. + .replace(/\[\[|\]\]/g, "") + .replace(/\*\*|__|\^\^|~~|`/g, "") + .replace(/\s+/g, " ") + .trim(); + return out || "(untitled block)"; +}; diff --git a/prototypes/roam-meeting-tasks/src/view.tsx b/prototypes/roam-meeting-tasks/src/view.tsx new file mode 100644 index 0000000..fad97b3 --- /dev/null +++ b/prototypes/roam-meeting-tasks/src/view.tsx @@ -0,0 +1,233 @@ +/* The widget's React components. Display only: graph access goes through + * load.ts (reads) and roam.ts (writes). + * + * These are real components with state, mounted in their own React root + * (see mount.ts). The roam/render version could not re-render itself, so it + * painted checkbox changes straight into the DOM; here a toggle is ordinary + * state, and every widget on the page hears about it. */ +import React from "react"; +import type { WidgetArgs } from "~/args"; +import { logError, MAX_REF_HOPS } from "~/config"; +import { loadWidget, readRefTexts, type Loaded } from "~/load"; +import type { Row } from "~/model"; +import { graphName, openInSidebar, writeString } from "~/roam"; +import { displayText, isDone, withDone } from "~/text"; + +/* ── cross-widget sync ─────────────────────────────────────────────────── */ + +// A task often shows in two widgets at once (the page-level one and a +// meeting's "last meeting" slot). A toggle in one is announced to all. +const WRITTEN = "roam-meeting-tasks:written"; +type Written = { uid: string; string: string }; + +const announce = (detail: Written): void => { + window.dispatchEvent(new CustomEvent(WRITTEN, { detail })); +}; + +const useWrittenStrings = (): [Record, (w: Written) => void] => { + const [strings, setStrings] = React.useState>({}); + React.useEffect(() => { + const onWritten = (e: Event) => { + const { uid, string } = (e as CustomEvent).detail; + setStrings((prev) => ({ ...prev, [uid]: string })); + }; + window.addEventListener(WRITTEN, onWritten); + return () => window.removeEventListener(WRITTEN, onWritten); + }, []); + return [strings, announce]; +}; + +/* ── rows ──────────────────────────────────────────────────────────────── */ + +const dateLabel = (time: number): string => + new Date(time).toLocaleDateString(undefined, { month: "short", day: "numeric" }); + +const TaskRow = ({ + row, + string, + text, + onToggle, +}: { + row: Row; + string: string; + text: string; + onToggle: (row: Row, done: boolean) => void; +}) => { + const done = isDone(string); + const uid = row.task.uid; + return ( + + ); +}; + +type RowsProps = { + rows: Row[]; + texts: Map; + strings: Record; + onToggle: (row: Row, done: boolean) => void; +}; + +const Rows = ({ rows, texts, strings, onToggle }: RowsProps) => ( + <> + {rows.map((row) => { + const string = strings[row.task.uid] ?? row.task.string; + return ( + texts.get(u), MAX_REF_HOPS)} + onToggle={onToggle} + /> + ); + })} + +); + +/* The collapsed section. Its rows, and the refs inside them, are only read + * and rendered once someone opens it. */ +const LazyRows = (props: Omit) => { + const [texts, setTexts] = React.useState | null>(null); + React.useEffect(() => { + let live = true; + readRefTexts(props.rows.map((r) => r.task.string)) + .then((t) => live && setTexts(t)) + .catch((error) => { + logError("could not read block references", error); + if (live) setTexts(new Map()); + }); + return () => { + live = false; + }; + }, [props.rows]); + if (!texts) return
…
; + return ; +}; + +/* ── the widget ────────────────────────────────────────────────────────── */ + +type State = { status: "loading" } | { status: "done"; loaded: Loaded } | { status: "failed" }; + +export const MeetingTasks = ({ args }: { args: WidgetArgs }) => { + const [state, setState] = React.useState({ status: "loading" }); + const [open, setOpen] = React.useState(false); + const [strings, announceWrite] = useWrittenStrings(); + + const argsKey = JSON.stringify(args); + React.useEffect(() => { + let live = true; + setState({ status: "loading" }); + loadWidget(args) + .then((loaded) => live && setState({ status: "done", loaded })) + .catch((error) => { + logError("could not load", error); + if (live) setState({ status: "failed" }); + }); + return () => { + live = false; + }; + // argsKey stands in for args, which is a fresh object on every render. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [argsKey]); + + const onToggle = React.useCallback( + (row: Row, done: boolean) => { + const uid = row.task.uid; + const before = strings[uid] ?? row.task.string; + const next = withDone(before, done); + if (next === before) return; + announceWrite({ uid, string: next }); + writeString(uid, next).catch((error) => { + logError("could not save the checkbox", error); + announceWrite({ uid, string: before }); + }); + }, + [strings, announceWrite], + ); + + if (state.status === "loading") return
Loading tasks…
; + if (state.status === "failed") + return args.debug ?
meeting-tasks: failed to load (see console)
: null; + const { loaded } = state; + if (loaded.kind === "empty") + return args.debug ? ( +
+ meeting-tasks: {loaded.reason} load={loaded.ms}ms +
+ ) : null; + + const { view, texts } = loaded; + const current = (r: Row) => strings[r.task.uid] ?? r.task.string; + const openCount = view.primary.concat(view.secondary).filter((r) => !isDone(current(r))).length; + + return ( +
+
+ {openCount} {openCount === 1 ? "open item" : "open items"} +
+ {view.primary.length > 0 && ( + <> +
{view.primaryLabel}
+
+ +
+ + )} + {view.secondary.length > 0 && ( + <> + {/* Collapsed by default: on a long-lived page this section is dozens + of items, which would bury the carried-over next actions. */} +
setOpen((o) => !o)}> + {open ? "▾" : "▸"} {view.secondary.length} {view.secondaryLabel} +
+ {open && ( +
+ +
+ )} + + )} + {view.olderCount > 0 && ( +
+ window {view.lookbackDays}d · {view.olderCount}{" "} + {view.olderCount === 1 ? "older meeting" : "older meetings"} not shown +
+ )} + {args.debug && ( +
+ {view.debug} load={loaded.ms}ms +
+ )} +
+ ); +}; diff --git a/prototypes/roam-meeting-tasks/tailwind.config.cjs b/prototypes/roam-meeting-tasks/tailwind.config.cjs new file mode 100644 index 0000000..394b553 --- /dev/null +++ b/prototypes/roam-meeting-tasks/tailwind.config.cjs @@ -0,0 +1,6 @@ +const base = require("../../packages/extension-base/tailwind.config.cjs"); + +module.exports = { + ...base, + content: ["./src/**/*.{js,jsx,ts,tsx}"], +}; diff --git a/prototypes/roam-meeting-tasks/tests/bundle.spec.ts b/prototypes/roam-meeting-tasks/tests/bundle.spec.ts new file mode 100644 index 0000000..de8651f --- /dev/null +++ b/prototypes/roam-meeting-tasks/tests/bundle.spec.ts @@ -0,0 +1,77 @@ +/* Load the BUILT bundle the way a roam/js block does. + * + * Unit tests test the source; only this tests the artifact. The failure it + * guards against is invisible to vitest: esbuild's ESM output gives a default + * import of a CommonJS module the shape `{ default: fn }`, so code that passes + * every unit test can throw at load. So this imports dist/extension.js into + * jsdom with Roam's host globals supplied, runs onload exactly as the roam/js + * loader block does (extensionAPI undefined), renders a widget through the + * public surface the shim uses, and unloads. + * + * Skips when dist/ has not been built yet: run `pnpm build` first. + */ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import React from "react"; +import ReactDOM from "react-dom"; +import * as ReactDOMClient from "react-dom/client"; +import { act } from "react-dom/test-utils"; +import { afterAll, describe, expect, it, vi } from "vitest"; +import { buildGraph, installFakeRoam, U } from "./fixtures"; + +const BUNDLE = join(process.cwd(), "dist", "extension.js"); +const { version } = JSON.parse(readFileSync(join(process.cwd(), "package.json"), "utf8")); + +/* Permissive stand-in for host libraries the bundle destructures lazily + * (Blueprint, RoamLazy, …): any property access or call yields another + * proxy, so module-scope destructuring never throws. */ +const anyProxy = (): any => + new Proxy(function () {}, { + get: (_t, p) => (p === Symbol.toPrimitive ? () => "" : anyProxy()), + set: () => true, + apply: () => anyProxy(), + construct: () => anyProxy(), + }); + +describe.skipIf(!existsSync(BUNDLE))("built bundle", () => { + const w = window as any; + afterAll(() => { + for (const k of ["React", "ReactDOM", "Blueprint", "RoamLazy", "TSLib", "Nanoid", "roamAlphaAPI"]) + delete w[k]; + }); + + it("loads via import(), renders a widget, and unloads cleanly", async () => { + w.React = React; + // Roam's window.ReactDOM is one object carrying both APIs. + w.ReactDOM = { ...ReactDOM, createRoot: ReactDOMClient.createRoot }; + w.Blueprint = anyProxy(); + w.RoamLazy = anyProxy(); + w.TSLib = anyProxy(); + w.Nanoid = anyProxy(); + installFakeRoam(buildGraph()); + + const module = await import(/* @vite-ignore */ BUNDLE); + const extension = module.default; + expect(typeof extension?.onload).toBe("function"); + + // Exactly what the roam/js loader block passes. runExtension's onload + // returns void, not the load promise, so wait for the public surface. + extension.onload({ extensionAPI: undefined, extension: { version: "roam/js" } }); + await vi.waitFor(() => expect(w.roamMeetingTasks).toBeTruthy()); + expect(w.roamMeetingTasks.version).toBe(version); + expect(w.roamMeetingTasks.shim).toMatch(/^```javascript\nfunction dgMeetingTasks\(props\)/); + expect(document.getElementById("roam-meeting-tasks-style")).toBeTruthy(); + + const host = document.createElement("div"); + document.body.appendChild(host); + w.roamMeetingTasks.mount(host, [{ "block-uid": U("host") }]); + await vi.waitFor(() => expect(host.querySelector(".rmt-count")?.textContent).toMatch(/open items/)); + + await act(async () => { + await extension.onunload(); + }); + expect(w.roamMeetingTasks).toBeUndefined(); + expect(document.getElementById("roam-meeting-tasks-style")).toBeNull(); + expect(host.innerHTML).toBe(""); + }); +}); diff --git a/prototypes/roam-meeting-tasks/tests/fixtures.ts b/prototypes/roam-meeting-tasks/tests/fixtures.ts new file mode 100644 index 0000000..0782ed1 --- /dev/null +++ b/prototypes/roam-meeting-tasks/tests/fixtures.ts @@ -0,0 +1,197 @@ +/* A fake graph and a fake roamAlphaAPI over it. + * + * The graph mirrors every awkward shape observed on dg-team's sync pages + * (ported from the roam/render component's offline harness): + * - meeting blocks identified by a daily-note ref + #.sticky + * - prose that merely mentions a date (must not count as a meeting) + * - header wording drift: "next actions" / "Actions" / "Action items/for next time" + * - a next-actions header nested one extra level (All Hands "Meeting items") + * - prose starting with "next steps" at depth 3 (must not count as a header) + * - items that are ((block-ref)) wrappers around the real task + * - a wrapper whose target lives on another page + * - a marker mid-string ("next: {{[[DONE]]}} ...") + * - a DONE item older than CELEBRATE_DAYS (must age out) + * - the same task carried across two meetings (must appear once, newest) + * - a meeting outside the default 120-day window + * + * The fake API answers only the pull patterns and queries the extension + * uses, and counts calls so tests can assert that reads are shared. + */ +import { vi } from "vitest"; + +export const DAY = 86400000; +export const PAGE = "PAGE1xxxx"; +export const OTHER_PAGE = "PAGE2xxxx"; + +type FakeBlock = { + uid: string; + string: string; + children: string[]; + page: string; + time: number; + dates: string[]; // daily-note uids this block references +}; + +// Real Roam uids are 9 characters; the ref regex requires 6 or more. +export const U = (n: string): string => (n + "xxxxxxxxx").slice(0, 9); + +const dnpUid = (daysAgo: number, now: number): string => { + const d = new Date(now - daysAgo * DAY); + const p2 = (x: number) => String(x).padStart(2, "0"); + return `${p2(d.getMonth() + 1)}-${p2(d.getDate())}-${d.getFullYear()}`; +}; + +export type FakeGraph = ReturnType; + +export const buildGraph = (now = Date.now()) => { + const blocks = new Map(); + const add = ( + raw: string, + string: string, + children: string[] = [], + opts: { page?: string; ageDays?: number; dates?: string[] } = {}, + ) => { + blocks.set(U(raw), { + uid: U(raw), + string, + children: children.map(U), + page: opts.page ?? PAGE, + time: now - (opts.ageDays ?? 0) * DAY, + dates: opts.dates ?? [], + }); + }; + const M1 = dnpUid(7, now); + const M2 = dnpUid(14, now); + const M3 = dnpUid(21, now); + const M4 = dnpUid(200, now); + const PROSE = dnpUid(30, now); + + add("host", "{{roam/render: ((CODE)) debug}}"); + + // meeting 1: newest. All Hands shape (header one level deeper) + add("m1", "[[Meeting one]] #.sticky", ["m1items"], { dates: [M1] }); + add("m1items", "Meeting items [ℹ](((TIP)))", ["m1na", "m1pod"]); + add("m1na", "**next actions**", ["t1", "t2wrap", "t3wrap"]); + add("t1", "{{[[TODO]]}} #[[Trang Doan]] send team the next-step items", [], { ageDays: 1 }); + add("t2wrap", `((${U("t2")}))`, [], { ageDays: 1 }); // wrapper → on-page target + add("t2", "{{[[DONE]]}} #[[Karola Kirsanow]] track OKR 1 items", [], { ageDays: 2 }); + add("t3wrap", `((${U("t3off")}))`, [], { ageDays: 1 }); // wrapper → OFF-page target + add("t3off", "next: {{[[DONE]]}} Sid: create documentation", [], { page: OTHER_PAGE, ageDays: 3 }); + add("m1pod", "**points of discussion**", ["m1prose"]); + add("m1prose", "next steps for cybrarian position", ["m1prose2"]); // depth 3: not a header + add("m1prose2", "{{[[TODO]]}} buried task under discussion, not a next action", [], { ageDays: 4 }); + + // meeting 2: Roam Product shape (header at depth 1), drifted wording + add("m2", " [[Meeting two]] #.sticky", ["m2na", "m2other"], { dates: [M2] }); + add("m2na", "Action items/for next time", ["t4", "t5", "t1again"]); + add("t4", "{{[[TODO]]}} MG to take a look at backlog count over time", [], { ageDays: 5 }); + add("t5", "{{[[DONE]]}} MG to create ticket for text selection bug", [], { ageDays: 40 }); // aged out + add("t1again", `((${U("t1")}))`); // same task as meeting 1: dedupe, keep newest + add("m2other", "Next meeting", ["t6"]); + add("t6", "{{[[TODO]]}} not under a next-actions header", [], { ageDays: 6 }); + + // meeting 3: older, wording "Actions" + add("m3", "[[Meeting three]] #.sticky", ["m3na"], { dates: [M3] }); + add("m3na", "Actions", ["t7"]); + add("t7", "{{[[TODO]]}} old open item from three meetings ago", [], { ageDays: 20 }); + + // meeting 4: outside the default 120-day window + add("m4", "[[Ancient meeting]] #.sticky", ["m4na"], { dates: [M4] }); + add("m4na", "Next actions", ["t8"]); + add("t8", "{{[[TODO]]}} ancient item from outside the window", [], { ageDays: 200 }); + + // decoys + add("prose", "PRs including UX work: aiming for [[a date]]", [], { dates: [PROSE] }); + add("inbox", "{{[[TODO]]}} page-level inbox task, under no meeting", [], { ageDays: 7 }); + + return { blocks, add, now, dates: { M1, M2, M3, M4 } }; +}; + +/* ── the fake API ────────────────────────────────────────────────────────── */ + +const MARKED = /\{\{\[\[(TODO|DONE)\]\]\}\}|\{\{(TODO|DONE)\}\}/; + +export const installFakeRoam = (graph: FakeGraph) => { + const { blocks } = graph; + const parentOf = () => { + const out = new Map(); + for (const b of blocks.values()) for (const c of b.children) out.set(c, b.uid); + return out; + }; + const ancestors = (uid: string): string[] => { + const p = parentOf(); + const out: string[] = []; + for (let cur = p.get(uid); cur; cur = p.get(cur)) out.push(cur); + return out; + }; + const refsOf = (b: FakeBlock) => { + const out: Record[] = b.dates.map((d) => ({ ":block/uid": d, ":log/id": 1 })); + if (b.string.includes("#.sticky")) out.push({ ":block/uid": "stickyxxx" }); + return out; + }; + const markerTitle = (s: string): string | null => { + const m = MARKED.exec(s); + return m ? (m[1] ?? m[2]) : null; + }; + + const view = (b: FakeBlock, pattern: string): Record => { + const out: Record = { ":block/uid": b.uid }; + if (pattern.includes(":block/string")) out[":block/string"] = b.string; + if (pattern.includes(":edit/time")) out[":edit/time"] = b.time; + if (pattern.includes("{:block/page")) out[":block/page"] = { ":block/uid": b.page }; + if (pattern.includes("{:block/parents")) + out[":block/parents"] = ancestors(b.uid).map((u) => ({ ":block/uid": u })); + if (pattern.includes("{:block/refs")) out[":block/refs"] = refsOf(b); + if (pattern.includes("{:block/children ...}") && b.children.length) + out[":block/children"] = b.children.map((c) => view(blocks.get(c)!, pattern)); + return out; + }; + + const reverseRefs = (title: string): FakeBlock[] => { + const all = [...blocks.values()]; + if (title === ".sticky") return all.filter((b) => b.string.includes("#.sticky")); + return all.filter((b) => markerTitle(b.string) === title); + }; + + const pull = vi.fn(async (pattern: string, eid: [string, string]) => { + const [attr, value] = eid; + if (attr === ":node/title") { + if (!pattern.includes("(:block/_refs :limit nil)")) + throw new Error(`fake pull: unexpected title pattern ${pattern}`); + const inner = pattern.slice(pattern.indexOf(")") + 1); + return { ":block/_refs": reverseRefs(value).map((b) => view(b, inner)) }; + } + const b = blocks.get(value); + return b ? view(b, pattern) : null; + }); + const pull_many = vi.fn(async (pattern: string, eids: [string, string][]) => + eids.map(([, uid]) => { + const b = blocks.get(uid); + return b ? view(b, pattern) : null; + }), + ); + // Only the fallback meeting query, for pages with no #.sticky meetings. + const q = vi.fn(async (query: string, pageUid: string) => { + if (!query.includes(":log/id")) throw new Error(`fake q: unexpected query ${query}`); + return [...blocks.values()] + .filter((b) => b.page === pageUid) + .flatMap((b) => b.dates.map((d) => [b.uid, b.string, d])); + }); + const update = vi.fn(async ({ block }: { block: { uid: string; string: string } }) => { + const b = blocks.get(block.uid); + if (!b) throw new Error(`no block ${block.uid}`); + b.string = block.string; + }); + const addWindow = vi.fn(async () => {}); + const open = vi.fn(async () => {}); + + const api = { + graph: { name: "discourse-graphs" }, + data: { async: { pull, pull_many, q }, block: { update } }, + ui: { rightSidebar: { addWindow, open } }, + }; + (window as unknown as { roamAlphaAPI: unknown }).roamAlphaAPI = api; + return api; +}; + +export type FakeApi = ReturnType; diff --git a/prototypes/roam-meeting-tasks/tests/interop.spec.ts b/prototypes/roam-meeting-tasks/tests/interop.spec.ts new file mode 100644 index 0000000..5369f81 --- /dev/null +++ b/prototypes/roam-meeting-tasks/tests/interop.spec.ts @@ -0,0 +1,53 @@ +/* A source-level guard for a bug two sibling prototypes shipped. + * + * roamjs-components is CommonJS. This repository builds with esbuild in ESM + * format, and its __toESM helper runs in Node-interop mode, where a default + * import of a CommonJS module resolves to the whole module object. So + * + * import addStyle from "roamjs-components/dom/addStyle"; + * addStyle(css); + * + * compiles to a call on `{ default: fn }` and throws "is not a function" at + * load. Named imports are unaffected. Vitest cannot catch this: it resolves + * CommonJS with ordinary interop, so the same code passes every unit test and + * only fails in the built bundle. Hence a check on the source text. + */ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const SRC = join(process.cwd(), "src"); + +const sourceFiles = (dir: string): string[] => + readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = join(dir, entry.name); + if (entry.isDirectory()) return sourceFiles(full); + return /\.tsx?$/.test(entry.name) ? [full] : []; + }); + +describe("module interop", () => { + it("never default-imports from roamjs-components", () => { + const offenders = sourceFiles(SRC).flatMap((file) => + readFileSync(file, "utf8") + .split("\n") + .filter((line) => /^import\s+[A-Za-z_$][\w$]*\s*(,|from)/.test(line)) + .filter((line) => line.includes("roamjs-components")) + .map((line) => `${file.replace(SRC, "src")}: ${line.trim()}`), + ); + expect(offenders).toEqual([]); + }); + + it("never uses the legacy top-level roamAlphaAPI aliases", () => { + const offenders = sourceFiles(SRC).flatMap((file) => + readFileSync(file, "utf8") + .split("\n") + .filter((line) => /roamAlphaAPI\.(q|pull|createBlock|updateBlock|createPage)\b/.test(line)) + .map((line) => `${file.replace(SRC, "src")}: ${line.trim()}`), + ); + expect(offenders).toEqual([]); + }); + + it("finds the source files it is supposed to be checking", () => { + expect(sourceFiles(SRC).length).toBeGreaterThan(9); + }); +}); diff --git a/prototypes/roam-meeting-tasks/tests/setup.ts b/prototypes/roam-meeting-tasks/tests/setup.ts new file mode 100644 index 0000000..bf69e98 --- /dev/null +++ b/prototypes/roam-meeting-tasks/tests/setup.ts @@ -0,0 +1,2 @@ +// React 18 only runs act() cleanly when told this is a test environment. +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; diff --git a/prototypes/roam-meeting-tasks/tests/shim.spec.ts b/prototypes/roam-meeting-tasks/tests/shim.spec.ts new file mode 100644 index 0000000..2c9ad40 --- /dev/null +++ b/prototypes/roam-meeting-tasks/tests/shim.spec.ts @@ -0,0 +1,109 @@ +/* The shim evaluated the way Roam evaluates a roam/render code block: as a + * plain script that defines a global function, which is then rendered by a + * React tree the extension does not own. */ +import React from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { act } from "react-dom/test-utils"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { FALLBACK_CLASS, GLOBAL_KEY, HOST_CLASS, QUEUE_KEY } from "~/config"; +import { load, unload } from "~/lifecycle"; +import { forgetReads } from "~/roam"; +import { SHIM_BLOCK, SHIM_FUNCTION, SHIM_SOURCE } from "~/shim"; +import { buildGraph, installFakeRoam, U } from "./fixtures"; + +const w = window as unknown as Record; + +// What Roam does with the block: evaluate it, then look the name up. +type RenderProps = { args?: unknown[] }; +const evaluateShim = (): React.FunctionComponent => + new Function(`${SHIM_SOURCE}; return ${SHIM_FUNCTION};`)(); + +// Roam's own tree, standing in for the block that holds the render. Roam +// mounts the shim function as a component with props { args }, as its React +// fibers on dg-team show. +const roamRoots: Root[] = []; +const renderAsRoamWould = async (props: RenderProps, shim = evaluateShim()) => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + roamRoots.push(root); + await act(async () => { + root.render(React.createElement(shim, props)); + }); + return { container, root, shim }; +}; + +beforeEach(() => { + forgetReads(); + installFakeRoam(buildGraph()); + w.React = React; +}); +afterEach(() => { + for (const r of roamRoots.splice(0)) act(() => r.unmount()); + unload(); + delete w[QUEUE_KEY]; + document.body.innerHTML = ""; +}); + +describe("the roam/render shim", () => { + it("is a plain ES5 script Roam can evaluate", () => { + expect(SHIM_SOURCE).not.toMatch(/=>|\bconst\b|\blet\b|`|\bimport\b|useState|useEffect/); + expect(SHIM_BLOCK.startsWith("```javascript\n")).toBe(true); + expect(typeof evaluateShim()).toBe("function"); + }); + + it("parks its host and shows a note when the extension has not loaded", async () => { + const { container: root } = await renderAsRoamWould({ args: [{ "block-uid": U("host") }] }); + const queue = w[QUEUE_KEY] as { el: Element }[]; + expect(queue).toHaveLength(1); + expect(queue[0].el.className).toBe(HOST_CLASS); + expect(root.querySelector(`.${FALLBACK_CLASS}`)?.textContent).toMatch(/waiting for the roam-meeting-tasks extension/); + }); + + it("is picked up when the extension loads later", async () => { + const { container: root } = await renderAsRoamWould({ args: [{ "block-uid": U("host") }] }); + await act(async () => { + load(); + }); + await expect.poll(() => root.querySelector(".rmt-count")?.textContent).toMatch(/open items/); + expect(w[QUEUE_KEY]).toBeUndefined(); + // The note is still in the DOM but hidden by the extension's stylesheet. + expect(document.getElementById("roam-meeting-tasks-style")?.textContent).toContain( + `.${FALLBACK_CLASS} { display: none; }`, + ); + }); + + it("mounts straight away when the extension is already loaded", async () => { + load(); + const { container: root } = await renderAsRoamWould({ args: [{ "block-uid": U("host") }] }); + await expect.poll(() => root.querySelector(".rmt-count")?.textContent).toMatch(/open items/); + expect(w[QUEUE_KEY]).toBeUndefined(); + }); + + it("keeps the widget's state when Roam re-renders the block", async () => { + load(); + const props = { args: [{ "block-uid": U("host") }] }; + const { container, root, shim } = await renderAsRoamWould(props); + await expect.poll(() => container.querySelector(".rmt-toggle")).toBeTruthy(); + await act(async () => { + (container.querySelector(".rmt-toggle") as HTMLElement).click(); + }); + // Roam re-renders the block: the shim runs again with a new ref callback. + await act(async () => { + root.render(React.createElement(shim, { ...props })); + }); + await new Promise((r) => setTimeout(r, 10)); + expect(container.querySelector('[data-section="secondary"]')).toBeTruthy(); + }); + + it("is unmounted when Roam removes the block", async () => { + load(); + const { root } = await renderAsRoamWould({ args: [{ "block-uid": U("host") }] }); + const mounted = () => (w[GLOBAL_KEY] as { debug: () => { mounted: number } }).debug().mounted; + await expect.poll(mounted).toBe(1); + await act(async () => { + root.unmount(); + }); + await expect.poll(mounted).toBe(0); + }); +}); diff --git a/prototypes/roam-meeting-tasks/tests/text.spec.ts b/prototypes/roam-meeting-tasks/tests/text.spec.ts new file mode 100644 index 0000000..0294c1b --- /dev/null +++ b/prototypes/roam-meeting-tasks/tests/text.spec.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { parseArgs } from "~/args"; +import { dailyNoteTime, displayText, headerKind, withDone } from "~/text"; + +describe("headerKind", () => { + const ANCHOR = "yuAIplpov"; + it.each([ + "next actions", + "**next actions**", + "Next Actions:", + "Actions", + "Action items", + "Action items/for next time", + "next steps", + "Next step:", + "## next actions", + "next actions by Friday", + "next actions this week", + ])("treats %j as a header", (s) => { + expect(headerKind(s, ANCHOR)).toBe("wording"); + }); + + it.each([ + "next steps for cybrarian position", + "actions speak louder", + "{{[[TODO]]}} next actions", + "we discussed next actions at length", + ])("does not treat %j as a header", (s) => { + expect(headerKind(s, ANCHOR)).toBeNull(); + }); + + it("prefers the anchor ref over wording, even on novel wording", () => { + expect(headerKind("To do before we meet again [ℹ](((yuAIplpov)))", ANCHOR)).toBe("anchor"); + }); +}); + +describe("withDone", () => { + it("flips both marker spellings, both ways", () => { + expect(withDone("{{[[TODO]]}} a", true)).toBe("{{[[DONE]]}} a"); + expect(withDone("{{TODO}} a", true)).toBe("{{DONE}} a"); + expect(withDone("{{[[DONE]]}} a", false)).toBe("{{[[TODO]]}} a"); + expect(withDone("next: {{[[DONE]]}} a", false)).toBe("next: {{[[TODO]]}} a"); + }); +}); + +describe("displayText", () => { + const blocks: Record = { + aaaaaaaaa: "{{[[DONE]]}} ((bbbbbbbbb))", + bbbbbbbbb: "the question [[with a link]]", + loopxxxxx: "((loopxxxxx)) again", + }; + const lookup = (u: string) => blocks[u]; + + it("resolves refs inside refs, and strips markup from the result", () => { + expect(displayText("((aaaaaaaaa)) -- ongoing", lookup, 4)).toBe("the question with a link -- ongoing"); + }); + + it("drops a ref whose target is unknown", () => { + expect(displayText("see ((zzzzzzzzz)) here", lookup, 4)).toBe("see here"); + }); + + it("terminates on a reference cycle", () => { + expect(displayText("((loopxxxxx))", lookup, 4)).toContain("again"); + }); + + it("removes embeds, images, and alias targets", () => { + expect( + displayText("{{[[TODO]]}} read [the doc](https://x.y) ![](https://img) {{[[embed]]: ((aaaaaaaaa))}}", lookup, 4), + ).toBe("read the doc"); + }); +}); + +describe("dailyNoteTime", () => { + it("reads MM-DD-YYYY as local midnight", () => { + expect(dailyNoteTime("09-22-2026")).toBe(new Date(2026, 8, 22).getTime()); + expect(dailyNoteTime("not-a-date")).toBe(0); + }); +}); + +describe("parseArgs", () => { + it("finds the host block wherever Roam puts it, and reads flags and the window", () => { + expect(parseArgs(["debug", { "block-uid": "abc" }, "365", "Meeting"])).toEqual({ + hostUid: "abc", + lookbackDays: 365, + debug: true, + forcedMode: "meeting", + }); + expect(parseArgs([]).lookbackDays).toBe(120); + expect(parseArgs([0]).lookbackDays).toBe(120); + }); +}); diff --git a/prototypes/roam-meeting-tasks/tests/widget.spec.tsx b/prototypes/roam-meeting-tasks/tests/widget.spec.tsx new file mode 100644 index 0000000..2e9acf2 --- /dev/null +++ b/prototypes/roam-meeting-tasks/tests/widget.spec.tsx @@ -0,0 +1,286 @@ +/* The widget end to end against the fake graph: mount into a host element + * exactly as the shim does, let it load, and read what it shows. + * + * The first three blocks port the roam/render component's offline harness + * check for check, so the port is held to the behavior it replaces. */ +import { act } from "react-dom/test-utils"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mount, unmountAll } from "~/mount"; +import { forgetReads } from "~/roam"; +import { buildGraph, installFakeRoam, OTHER_PAGE, PAGE, U, type FakeApi, type FakeGraph } from "./fixtures"; + +let graph: FakeGraph; +let api: FakeApi; + +beforeEach(() => { + forgetReads(); + graph = buildGraph(); + api = installFakeRoam(graph); + window.location.hash = `#/app/discourse-graphs/page/${PAGE}`; +}); +afterEach(() => { + unmountAll(); + document.body.innerHTML = ""; +}); + +const render = async (argv: unknown[]): Promise => { + const el = document.createElement("div"); + document.body.appendChild(el); + await act(async () => { + mount(el, argv); + }); + await vi.waitFor(() => expect(el.textContent).not.toContain("Loading tasks")); + return el; +}; + +const texts = (el: Element, section?: "primary" | "secondary"): string[] => + [...el.querySelectorAll(`${section ? `[data-section="${section}"] ` : ""}.rmt-text`)].map( + (a) => a.textContent ?? "", + ); + +const openSecondary = async (el: HTMLElement) => { + const toggle = el.querySelector(".rmt-toggle") as HTMLElement; + await act(async () => { + toggle.click(); + }); + await vi.waitFor(() => expect(el.querySelector('[data-section="secondary"] .rmt-row')).toBeTruthy()); +}; + +const host = (uid = "host") => ({ "block-uid": U(uid) }); + +describe("page mode", () => { + it("finds every carried-over next action, across header wordings and wrappers", async () => { + const el = await render([host(), "debug"]); + const primary = texts(el, "primary").join(" | "); + expect(primary).toMatch(/Trang Doan send team/); // plain TODO under **next actions** + expect(primary).toMatch(/Karola Kirsanow track OKR 1/); // on-page ((ref)) wrapper + expect(primary).toMatch(/Sid: create documentation/); // off-page ((ref)) wrapper + expect(primary).toMatch(/backlog count over time/); // "Action items/for next time" + expect(primary).toMatch(/old open item from three meetings ago/); // "Actions" + }); + + it("ages out a DONE item older than 14 days", async () => { + const el = await render([host()]); + await openSecondary(el); + expect(texts(el).join(" ")).not.toMatch(/text selection bug/); + }); + + it("keeps prose 'next steps for...' at depth 3 out of the headers", async () => { + const el = await render([host()]); + expect(texts(el, "primary").join(" ")).not.toMatch(/buried task/); + await openSecondary(el); + expect(texts(el, "secondary").join(" ")).toMatch(/buried task under discussion/); + }); + + it("shows only real next-action items in the carried-over section", async () => { + const el = await render([host()]); + const primary = texts(el, "primary"); + expect(primary.length).toBe(5); + for (const t of primary) + expect(t).toMatch(/Trang Doan|Karola Kirsanow|Sid: create documentation|backlog count|old open item/); + }); + + it("strips markers and brackets from display text", async () => { + const el = await render([host()]); + await openSecondary(el); + const all = texts(el).join(" "); + expect(all).not.toMatch(/\{\{\[\[/); + expect(all).not.toMatch(/\[\[|\]\]/); + }); + + it("shows a task carried across two meetings once", async () => { + const el = await render([host()]); + await openSecondary(el); + expect(texts(el).filter((t) => /Trang Doan send team/.test(t))).toHaveLength(1); + }); + + it("collects tasks outside next-actions headers, and page-level inbox tasks", async () => { + const el = await render([host()]); + await openSecondary(el); + const secondary = texts(el, "secondary").join(" | "); + expect(secondary).toMatch(/not under a next-actions header/); + expect(secondary).toMatch(/page-level inbox task/); + expect(el.querySelector('[data-section="secondary"] [data-inbox="true"]')?.textContent).toBe("inbox"); + }); + + it("reports the same diagnostics as the component it replaces", async () => { + const el = await render([host(), "debug"]); + const debug = el.querySelector(".rmt-debug")?.textContent ?? ""; + expect(debug).toContain("meetings=4"); // prose that cites a date is not a 5th + expect(debug).toContain("lookback=120d(3/4 meetings)"); + expect(debug).toContain("headers=3"); + expect(debug).toContain("carried=6"); + }); +}); + +describe("lookback window", () => { + it("excludes a meeting older than 120 days, keeps old inbox work, and says so", async () => { + const el = await render([host()]); + await openSecondary(el); + const all = texts(el).join(" "); + expect(all).not.toMatch(/ancient item/); + expect(all).toMatch(/page-level inbox task/); + expect(el.querySelector(".rmt-footer")?.textContent).toBe("window 120d · 1 older meeting not shown"); + }); + + it("widens with a bare number, and drops the footer once nothing is excluded", async () => { + const el = await render([host(), "365"]); + await openSecondary(el); + expect(texts(el).join(" ")).toMatch(/ancient item from outside the window/); + expect(el.querySelector(".rmt-footer")).toBeNull(); + }); +}); + +describe("meeting mode (mounted inside the newest meeting)", () => { + const mountInM1 = async () => { + graph.add("hostInM1", "{{roam/render: ((CODE))}}"); + graph.blocks.get(U("m1"))!.children.unshift(U("hostInM1")); + return render([host("hostInM1")]); + }; + + it("headlines the previous meeting's next actions", async () => { + const el = await mountInM1(); + expect(texts(el, "primary")).toEqual(["MG to take a look at backlog count over time"]); + }); + + it("labels the section with the meeting's date text, without brackets", async () => { + const el = await mountInM1(); + const headings = [...el.querySelectorAll(".rmt-heading")].map((h) => h.textContent); + expect(headings[0]).toBe("From last meeting · Meeting two"); + }); +}); + +describe("write-back", () => { + it("writes {{[[DONE]]}} to the real task block, never to a ((ref)) wrapper", async () => { + const el = await render([host()]); + const row = [...el.querySelectorAll('[data-section="primary"] .rmt-row')].find((r) => + /Trang Doan/.test(r.textContent ?? ""), + )!; + const box = row.querySelector("input") as HTMLInputElement; + expect(box.checked).toBe(false); + await act(async () => { + box.click(); + }); + expect(api.data.block.update).toHaveBeenCalledTimes(1); + const { block } = api.data.block.update.mock.calls[0][0]; + expect(block.uid).toBe(U("t1")); + expect(block.string).toMatch(/^\{\{\[\[DONE\]\]\}\}/); + expect(box.checked).toBe(true); + expect(row.getAttribute("data-done")).toBe("true"); + expect(el.querySelector(".rmt-count")?.textContent).toBe("5 open items"); + }); + + it("writes through a wrapper to an off-page target", async () => { + const el = await render([host()]); + const row = [...el.querySelectorAll('[data-section="primary"] .rmt-row')].find((r) => + /Sid: create documentation/.test(r.textContent ?? ""), + )!; + await act(async () => { + (row.querySelector("input") as HTMLInputElement).click(); + }); + const { block } = api.data.block.update.mock.calls[0][0]; + expect(block.uid).toBe(U("t3off")); + expect(block.string).toBe("next: {{[[TODO]]}} Sid: create documentation"); + expect(graph.blocks.get(U("t3off"))!.page).toBe(OTHER_PAGE); + }); + + it("puts the checkbox back when the write fails", async () => { + api.data.block.update.mockRejectedValueOnce(new Error("offline")); + const errors = vi.spyOn(console, "error").mockImplementation(() => {}); + const el = await render([host()]); + const box = el.querySelector('[data-section="primary"] .rmt-row input') as HTMLInputElement; + const was = box.checked; + await act(async () => { + box.click(); + }); + await vi.waitFor(() => expect(box.checked).toBe(was)); + expect(errors).toHaveBeenCalled(); + }); + + it("updates every widget showing the same task", async () => { + const a = await render([host()]); + const b = await render([host()]); + const rowIn = (el: HTMLElement) => + [...el.querySelectorAll('[data-section="primary"] .rmt-row')].find((r) => + /Trang Doan/.test(r.textContent ?? ""), + )!; + await act(async () => { + (rowIn(a).querySelector("input") as HTMLInputElement).click(); + }); + expect((rowIn(b).querySelector("input") as HTMLInputElement).checked).toBe(true); + }); +}); + +describe("shift-click", () => { + it("leaves a plain click alone, and opens the right sidebar on shift-click", async () => { + const el = await render([host()]); + const link = el.querySelector('[data-section="primary"] .rmt-text') as HTMLAnchorElement; + const uid = link.getAttribute("href")!.split("/").pop(); + + const plain = new MouseEvent("click", { bubbles: true, cancelable: true }); + link.addEventListener("click", (e) => e.preventDefault(), { once: true }); // keep jsdom from navigating + link.dispatchEvent(plain); + expect(api.ui.rightSidebar.addWindow).not.toHaveBeenCalled(); + + const shifted = new MouseEvent("click", { bubbles: true, cancelable: true, shiftKey: true }); + await act(async () => { + link.dispatchEvent(shifted); + }); + expect(shifted.defaultPrevented).toBe(true); + expect(api.ui.rightSidebar.addWindow).toHaveBeenCalledWith({ + window: { type: "block", "block-uid": uid }, + }); + expect(api.ui.rightSidebar.open).toHaveBeenCalled(); + }); +}); + +describe("what changed in the port", () => { + it("renders nothing, not an error, on a page with no meetings", async () => { + window.location.hash = "#/app/discourse-graphs/page/EMPTYxxxx"; + const el = await render([{ "block-uid": "nope" }]); + expect(el.textContent).toBe(""); + }); + + it("shares page-wide reads between widgets on the same page", async () => { + await render([host()]); + await render([host()]); + await render([host(), "365"]); + const titlePulls = api.data.async.pull.mock.calls.filter(([, eid]) => eid[0] === ":node/title"); + // .sticky, TODO and DONE once each, not once per widget + expect(titlePulls.map(([, eid]) => eid[1]).sort()).toEqual([".sticky", "DONE", "TODO"]); + }); + + it("does not read or render the collapsed section until it is opened", async () => { + const el = await render([host()]); + expect(el.querySelector('[data-section="secondary"]')).toBeNull(); + expect(el.querySelector(".rmt-toggle")?.textContent).toBe("▸ 3 Other tasks on this page"); + await openSecondary(el); + expect(texts(el, "secondary")).toHaveLength(3); + }); + + it("follows a chain of ((ref)) wrappers to the task", async () => { + graph.add("wrapA", `((${U("wrapB")}))`); + graph.add("wrapB", `((${U("deep")}))`, [], { page: OTHER_PAGE }); + graph.add("deep", "{{[[TODO]]}} task two refs away", [], { page: OTHER_PAGE }); + graph.blocks.get(U("m1na"))!.children.push(U("wrapA")); + const el = await render([host()]); + expect(texts(el, "primary")).toContain("task two refs away"); + }); + + it("resolves refs inside refs in the display text", async () => { + graph.add("outer", `{{[[TODO]]}} ((${U("mid")})) -- ongoing`); + graph.add("mid", `{{[[DONE]]}} ((${U("inner")}))`, [], { page: OTHER_PAGE }); + graph.add("inner", "Has anyone compared the intensities?", [], { page: OTHER_PAGE }); + graph.blocks.get(U("m1na"))!.children.push(U("outer")); + const el = await render([host()]); + expect(texts(el, "primary")).toContain("Has anyone compared the intensities? -- ongoing"); + }); + + it("falls back to the date-reference query on a page without #.sticky", async () => { + for (const b of graph.blocks.values()) b.string = b.string.replace(" #.sticky", ""); + const el = await render([host(), "debug"]); + expect(api.data.async.q).toHaveBeenCalledTimes(1); + // Without #.sticky the dated prose block counts as a meeting too, as before. + expect(el.querySelector(".rmt-debug")?.textContent).toContain("meetings=5"); + }); +}); diff --git a/prototypes/roam-meeting-tasks/tsconfig.check.json b/prototypes/roam-meeting-tasks/tsconfig.check.json new file mode 100644 index 0000000..fdccc92 --- /dev/null +++ b/prototypes/roam-meeting-tasks/tsconfig.check.json @@ -0,0 +1,19 @@ +{ + // Opt-in strict typecheck (`pnpm typecheck`): the repository has no + // typecheck step and the shared tsconfig predates TypeScript 6's removal + // of node10 moduleResolution, so this carries its own compilerOptions. + "compilerOptions": { + "target": "es2022", + "module": "esnext", + "moduleResolution": "bundler", + "jsx": "react", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["node"], + "paths": { "~/*": ["./src/*"] } + }, + "include": ["src", "tests"] +} diff --git a/prototypes/roam-meeting-tasks/tsconfig.json b/prototypes/roam-meeting-tasks/tsconfig.json new file mode 100644 index 0000000..a3469d4 --- /dev/null +++ b/prototypes/roam-meeting-tasks/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../packages/extension-base/tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "~/*": [ + "./src/*" + ] + } + }, + "include": [ + "src", + "tests", + "vitest.config.ts" + ] +} diff --git a/prototypes/roam-meeting-tasks/vitest.config.ts b/prototypes/roam-meeting-tasks/vitest.config.ts new file mode 100644 index 0000000..f920567 --- /dev/null +++ b/prototypes/roam-meeting-tasks/vitest.config.ts @@ -0,0 +1,20 @@ +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + // The generated tsconfig declares a "~/*" path alias and the esbuild CLI + // honours it, but the generated vitest config does not. Mirrored here. + resolve: { + alias: { + "~": fileURLToPath(new URL("./src", import.meta.url)), + }, + }, + test: { + environment: "jsdom", + restoreMocks: true, + setupFiles: ["tests/setup.ts"], + // Spec files, not test files: the repository root's test runner picks up + // dot-test files itself and cannot resolve vitest or the alias. + include: ["tests/**/*.spec.ts", "tests/**/*.spec.tsx"], + }, +}); From 204c4834e417949dd5004350d184251bd80f86b0 Mon Sep 17 00:00:00 2001 From: mattakamatsu Date: Tue, 29 Sep 2026 00:36:38 -0700 Subject: [PATCH 2/3] Fold in the matsulab widgets' fixes, and simplify (0.2.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behavior, from comparing with the three akamatsulab widgets: - Meeting mode reads only meetings dated before the host meeting, so a task carried from last meeting into this one stays under "From last meeting", and "older open items" no longer lists this meeting's tasks. - "Last meeting" skips meetings whose next-actions header has no tasks. - The "From last meeting" date links to the meeting. - Items within a meeting follow outline order, not edit time. - A wrapper such as `((uid)) --> by Friday` keeps its note on screen. - "Proposed next step" wording, the ℹ️ tooltip variant, a list of anchors, and cleaner item text (components, labelled refs, mailto links). Simplify pass (reuse, simplification, efficiency, altitude reviews): - Reverse-reference reads compare entity ids and pull only the blocks on the page (3-6x cheaper for .sticky, ~30% for DONE, measured). - Meetings are read two levels deep; only header subtrees in full. - Headers are found once; one ref-chain reader serves wrappers and display text; one latest-meeting rule; one marker definition. - A Roam re-render with unchanged args no longer redraws the widget; expired shared reads are dropped; the sweep runs only while mounted. - Rows memoize display text and skip rendering when unchanged. - `meeting` never forced anything, so the flag is now just `page`. Co-Authored-By: Claude Fable 5.1 --- prototypes/roam-meeting-tasks/CHANGELOG.md | 16 ++ prototypes/roam-meeting-tasks/README.md | 42 ++- prototypes/roam-meeting-tasks/package.json | 2 +- prototypes/roam-meeting-tasks/src/args.ts | 14 +- prototypes/roam-meeting-tasks/src/config.ts | 15 +- prototypes/roam-meeting-tasks/src/index.ts | 21 +- .../roam-meeting-tasks/src/lifecycle.ts | 12 +- prototypes/roam-meeting-tasks/src/load.ts | 92 ++++--- prototypes/roam-meeting-tasks/src/model.ts | 242 +++++++----------- prototypes/roam-meeting-tasks/src/mount.ts | 48 ++-- prototypes/roam-meeting-tasks/src/roam.ts | 210 +++++++-------- prototypes/roam-meeting-tasks/src/styles.ts | 3 +- prototypes/roam-meeting-tasks/src/text.ts | 120 +++++---- prototypes/roam-meeting-tasks/src/view.tsx | 230 +++++++++-------- .../roam-meeting-tasks/tests/fixtures.ts | 50 ++-- .../roam-meeting-tasks/tests/text.spec.ts | 35 ++- .../roam-meeting-tasks/tests/widget.spec.tsx | 106 ++++++-- 17 files changed, 682 insertions(+), 576 deletions(-) diff --git a/prototypes/roam-meeting-tasks/CHANGELOG.md b/prototypes/roam-meeting-tasks/CHANGELOG.md index 5d088cd..020d604 100644 --- a/prototypes/roam-meeting-tasks/CHANGELOG.md +++ b/prototypes/roam-meeting-tasks/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 0.2.0 - 2026-09-29 + +Changes found by comparing with the three akamatsulab widgets. + +- Meeting mode looks only at meetings dated before the host meeting, so a task carried from last + meeting into this one stays under "From last meeting", and "older open items" no longer lists + this meeting's own tasks. +- "Last meeting" skips earlier meetings whose next-actions header has no tasks. +- The "From last meeting" date links to the meeting (shift-click opens it in the sidebar). +- Items within a meeting follow outline order instead of edit time. +- A wrapper item such as `((uid)) --> by Friday` keeps its note on screen and still writes to the + wrapped task. +- Headers: "Proposed next step" wording, the `ℹ️` tooltip with the emoji variation selector, and a + list of anchors (dg-team plus akamatsulab's two). +- Item text: tooltips, `{{components}}`, labelled block references, and `mailto:` links are cleaned. + ## 0.1.0 - 2026-09-29 Port of the dg-team `roam/render` meeting-tasks component into an installable extension. diff --git a/prototypes/roam-meeting-tasks/README.md b/prototypes/roam-meeting-tasks/README.md index 688ad89..2f37e03 100644 --- a/prototypes/roam-meeting-tasks/README.md +++ b/prototypes/roam-meeting-tasks/README.md @@ -27,15 +27,17 @@ A widget is placed with `{{roam/render: ((uid))}}`, where `uid` is the page's sh cannot bury the first section. Tasks outside any meeting are labeled "inbox". **Meeting mode** (inside a dated meeting block, such as a "last meeting" template slot): the first -section shows only the previous meeting's next actions, and the collapsed section shows older open -items. +section shows the next actions of the most recent earlier meeting that has any, with a link to that +meeting. The collapsed section shows open items from older meetings. Meetings dated on or after the +host meeting are ignored. -The mode is detected from the widget's position. Force it with a `page` or `meeting` argument. +The mode is detected from the widget's position. A `page` argument shows page mode inside a meeting. In both modes: - Checkboxes write `{{[[TODO]]}}` / `{{[[DONE]]}}` back to the task's own block. A task often shows in two widgets at once; checking it in one updates the other. +- Items are listed newest meeting first, and in outline order within a meeting. - A DONE item stays visible for 14 days, then ages out. - Clicking an item opens it. Shift-click opens it in the right sidebar. - Only meetings from the last 120 days are read. A footer says how many older meetings were left @@ -46,7 +48,7 @@ In both modes: | argument | effect | | --- | --- | | a bare number, e.g. `365` | look back that many days instead of 120 | -| `page` / `meeting` | force a mode | +| `page` | show page mode even inside a meeting | | `debug` | show a diagnostic line, including how long the widget took to load | Example: `{{roam/render: ((uid)) 365 debug}}`. @@ -56,15 +58,17 @@ Example: `{{roam/render: ((uid)) 365 debug}}`. - **Meeting**: a block on the page that references a daily-note page, such as `[[September 22nd, 2026]] #.sticky`. When any meeting on the page is tagged `#.sticky`, only tagged blocks count, which keeps prose that mentions a date from counting as a meeting. -- **Next-actions header**: a block one or two levels under a meeting that either references the - `ℹ` tooltip block `ANCHOR_UID` in `src/config.ts` (dg-team's `yuAIplpov`), or is worded like a - header: "next actions", "Actions", "Action items/for next time", "next steps", and close variants. - The depth limit keeps prose such as "next steps for X" out of the results. -- **Task**: a block with a `{{[[TODO]]}}` or `{{[[DONE]]}}` marker. An item under a header that only - wraps a block reference, `((uid))`, stands for the block it references, and the checkbox writes - there. Chains of wrappers are followed up to 4 hops. - -`ANCHOR_UID` is specific to dg-team. On another graph the wording rules still apply. +- **Next-actions header**: a block one or two levels under a meeting that either references one of + the `ℹ` tooltip blocks in `ANCHOR_UIDS` (`src/config.ts`), or is worded like a header: "next + actions", "Actions", "Action items/for next time", "next steps", "Proposed next step", and close + variants. The depth limit keeps prose such as "next steps for X" out of the results. +- **Task**: a block with a `{{[[TODO]]}}` or `{{[[DONE]]}}` marker. An item under a header that + starts with a block reference, `((uid))`, and has no marker of its own stands for the block it + references: the checkbox writes there, and the row shows the item's own text, including any note + after the reference. Chains of wrappers are followed up to 4 hops. + +`ANCHOR_UIDS` lists dg-team's anchor and akamatsulab's two "Proposed next step" anchors. Block uids +are unique per graph, so one list serves every graph. ## Installing on a graph @@ -145,7 +149,17 @@ and the same footers. Behavior is kept on purpose. These differ: - The "From last meeting" label no longer shows the date's link brackets (`[[September 22nd, 2026]]` - is now `September 22nd, 2026`). + is now `September 22nd, 2026`), and the date links to the meeting. +- Meeting mode looks only at earlier meetings. Before, a task listed under last meeting's next + actions and carried into this meeting's next actions dropped out of "From last meeting", and tasks + from this meeting and newer ones were listed as "older open items". +- "Last meeting" is the most recent earlier meeting with at least one task under its next actions. + Before, a meeting whose next-actions header was left empty was chosen, and the section was hidden. +- Within a meeting, items follow outline order. Before, they were sorted by edit time, so checking + a box moved the item to the top on the next load. +- An item like `((uid)) --> by Friday` shows the note after the reference. Before, the note was lost. +- Item text drops tooltips, buttons, videos, and other `{{components}}`; a labelled block reference + `[label](((uid)))` shows its label; `mailto:` links show their text. - Block references inside item text are resolved through chains (a reference to a block that is itself a reference). The old component resolved one level and showed the inner `((uid))`. - A wrapper that points to another wrapper is followed to the task. The old component dropped it. diff --git a/prototypes/roam-meeting-tasks/package.json b/prototypes/roam-meeting-tasks/package.json index 3f9010b..35bf6c9 100644 --- a/prototypes/roam-meeting-tasks/package.json +++ b/prototypes/roam-meeting-tasks/package.json @@ -1,6 +1,6 @@ { "name": "roam-meeting-tasks", - "version": "0.1.0", + "version": "0.2.0", "private": true, "description": "Carried-over next actions and open tasks for a recurring-meeting page, rendered inline where the page's roam/render widget sits.", "type": "module", diff --git a/prototypes/roam-meeting-tasks/src/args.ts b/prototypes/roam-meeting-tasks/src/args.ts index 4488372..abbeb0d 100644 --- a/prototypes/roam-meeting-tasks/src/args.ts +++ b/prototypes/roam-meeting-tasks/src/args.ts @@ -1,18 +1,18 @@ /* The render arguments a widget was invoked with: * - * {{roam/render: ((code-uid))}} defaults - * {{roam/render: ((code-uid)) 365}} look back 365 days - * {{roam/render: ((code-uid)) meeting debug}} force a mode, show diagnostics + * {{roam/render: ((code-uid))}} defaults + * {{roam/render: ((code-uid)) 365}} look back 365 days + * {{roam/render: ((code-uid)) page debug}} page mode inside a meeting, diagnostics */ import { LOOKBACK_DAYS } from "~/config"; -export type Mode = "page" | "meeting"; - export type WidgetArgs = { hostUid: string | null; lookbackDays: number; debug: boolean; - forcedMode: Mode | null; + /* Show page mode even inside a meeting. (Meeting mode needs a meeting, so + * there is nothing to force the other way; `meeting` overrides `page`.) */ + forcePage: boolean; }; /* Roam passes the host block as an object somewhere in the list: first on @@ -35,6 +35,6 @@ export const parseArgs = (argv: readonly unknown[]): WidgetArgs => { hostUid, lookbackDays: lookback !== null && lookback > 0 ? lookback : LOOKBACK_DAYS, debug: flags.includes("debug"), - forcedMode: flags.includes("meeting") ? "meeting" : flags.includes("page") ? "page" : null, + forcePage: flags.includes("page") && !flags.includes("meeting"), }; }; diff --git a/prototypes/roam-meeting-tasks/src/config.ts b/prototypes/roam-meeting-tasks/src/config.ts index 566416e..bde1b7e 100644 --- a/prototypes/roam-meeting-tasks/src/config.ts +++ b/prototypes/roam-meeting-tasks/src/config.ts @@ -3,7 +3,7 @@ // Injected by the shared esbuild CLI from package.json; "0.0.0" under vitest. export const VERSION = process.env.VERSION || "0.0.0"; -export const LOG = "[meeting-tasks]"; +const LOG = "[meeting-tasks]"; export const logError = (what: string, error: unknown): void => console.error(`${LOG} ${what}`, error); @@ -19,10 +19,15 @@ export const STYLE_ID = "roam-meeting-tasks-style"; export const HOST_CLASS = "roam-meeting-tasks-host"; export const FALLBACK_CLASS = "roam-meeting-tasks-fallback"; -// The ℹ tooltip block that every "next actions" header in dg-team references. -// When a header carries it, that beats matching on wording, which drifts -// across meetings and eras. dg-team specific: see README "Graph conventions". -export const ANCHOR_UID = "yuAIplpov"; +// The ℹ tooltip blocks that "next actions" headers reference. When a header +// carries one, that beats matching on wording, which drifts across meetings +// and eras. Uids are unique per graph, so listing several graphs' anchors is +// safe. See README "Graph conventions". +export const ANCHOR_UIDS: readonly string[] = [ + "yuAIplpov", // dg-team: next actions + "6-tIoP1wk", // akamatsulab: 1:1 "Proposed next step" + "VGiKqwasD", // akamatsulab: group meeting "Proposed next step" +]; // A DONE item stays visible this long so recent wins are seen, then ages out. export const CELEBRATE_DAYS = 14; diff --git a/prototypes/roam-meeting-tasks/src/index.ts b/prototypes/roam-meeting-tasks/src/index.ts index fc4d33f..bcbc695 100644 --- a/prototypes/roam-meeting-tasks/src/index.ts +++ b/prototypes/roam-meeting-tasks/src/index.ts @@ -18,15 +18,15 @@ import { load, unload } from "~/lifecycle"; /* What this extension needs from Roam, checked before anything else so a * missing capability reports itself by name instead of as a TypeError deep * in a helper. */ -const missingCapability = (): string => { - const api = window.roamAlphaAPI as unknown as Record | undefined; - if (!api) return "window.roamAlphaAPI is not available"; - const data = api.data as { async?: { pull_many?: unknown } } | undefined; - if (typeof data?.async?.pull_many !== "function") - return "window.roamAlphaAPI.data.async.pull_many is not available in this Roam build"; - const w = window as unknown as { React?: unknown; ReactDOM?: unknown }; - if (!w.React || !w.ReactDOM) return "window.React / window.ReactDOM are not available"; - return ""; +const requireCapabilities = (): void => { + const w = window as unknown as { + roamAlphaAPI?: { data?: { async?: { pull_many?: unknown } } }; + React?: unknown; + ReactDOM?: unknown; + }; + if (typeof w.roamAlphaAPI?.data?.async?.pull_many !== "function") + throw new Error("window.roamAlphaAPI.data.async.pull_many is not available in this Roam build"); + if (!w.React || !w.ReactDOM) throw new Error("window.React / window.ReactDOM are not available"); }; /* Report a load failure loudly, and never lose the cause. @@ -54,8 +54,7 @@ const reportLoadFailure = (error: unknown): void => { export default runExtension(async () => { try { - const missing = missingCapability(); - if (missing) throw new Error(missing); + requireCapabilities(); load(); return { unload }; } catch (error) { diff --git a/prototypes/roam-meeting-tasks/src/lifecycle.ts b/prototypes/roam-meeting-tasks/src/lifecycle.ts index b8d0f3e..da3b014 100644 --- a/prototypes/roam-meeting-tasks/src/lifecycle.ts +++ b/prototypes/roam-meeting-tasks/src/lifecycle.ts @@ -1,13 +1,13 @@ /* Load and unload. Everything the extension adds to the page is created here * and removed in unload(), so reloading the extension never leaves a * duplicate behind. */ -import { GLOBAL_KEY, QUEUE_KEY, SWEEP_MS, VERSION } from "~/config"; -import { mount, mountedCount, release, sweep, unmountAll } from "~/mount"; +import { GLOBAL_KEY, QUEUE_KEY, VERSION } from "~/config"; +import { mount, mountedCount, release, unmountAll } from "~/mount"; import { forgetReads } from "~/roam"; import { SHIM_BLOCK } from "~/shim"; import { addStyles, removeStyles } from "~/styles"; -export type PublicApi = { +type PublicApi = { version: string; mount: typeof mount; release: typeof release; @@ -18,8 +18,6 @@ export type PublicApi = { type Queued = { el: Element; args: unknown }; -let sweepTimer: number | null = null; - const globals = window as unknown as Record; export const load = (): void => { @@ -37,13 +35,9 @@ export const load = (): void => { const queued = (globals[QUEUE_KEY] as Queued[] | undefined) ?? []; delete globals[QUEUE_KEY]; for (const { el, args } of queued) if (el.isConnected) mount(el, args); - - sweepTimer = window.setInterval(sweep, SWEEP_MS); }; export const unload = (): void => { - if (sweepTimer !== null) window.clearInterval(sweepTimer); - sweepTimer = null; unmountAll(); forgetReads(); removeStyles(); diff --git a/prototypes/roam-meeting-tasks/src/load.ts b/prototypes/roam-meeting-tasks/src/load.ts index 95eccbd..af0bd75 100644 --- a/prototypes/roam-meeting-tasks/src/load.ts +++ b/prototypes/roam-meeting-tasks/src/load.ts @@ -1,42 +1,44 @@ /* One widget's load: read what it needs (roam.ts), then decide what it shows * (model.ts). Nothing here renders. */ import type { WidgetArgs } from "~/args"; -import { ANCHOR_UID, CELEBRATE_DAYS, MAX_REF_HOPS } from "~/config"; -import { - deriveView, - findHeaders, - headerItems, - pendingWrapperTargets, - planWindow, - selectMeetings, - type Block, - type View, -} from "~/model"; +import { ANCHOR_UIDS, MAX_REF_HOPS } from "~/config"; +import { deriveView, findHeaders, headerItems, planWindow, selectMeetings, type Block, type View } from "~/model"; import { readBlocks, readHost, readMeetingRows, readPageTasks, readTrees } from "~/roam"; -import { refUids } from "~/text"; +import { displayRefUids, wrapperRef } from "~/text"; export type Loaded = - | { kind: "view"; view: View; texts: Map; ms: number } + | { kind: "view"; view: View; refs: Map; ms: number } | { kind: "empty"; reason: string; ms: number }; const pageUidFromLocation = (): string | null => /\/page\/([\w-]+)/.exec(window.location.hash || "")?.[1] ?? null; -/* Text of every block referenced from `strings`, following refs inside those - * blocks too, so display text can be resolved without further reads. */ -export const readRefTexts = async ( +/* The blocks `strings` lead to, one read per hop: `refsOf` names the refs to + * follow in a string, and the blocks read in one round supply the next + * round's strings. Stops after MAX_REF_HOPS rounds, so a cycle cannot loop. */ +const readRefChain = async ( strings: readonly string[], - into: Map = new Map(), -): Promise> => { + refsOf: (s: string) => string[], +): Promise> => { + const found = new Map(); let frontier = strings; for (let hop = 0; hop < MAX_REF_HOPS; hop++) { - const want = [...new Set(frontier.flatMap(refUids))].filter((u) => !into.has(u)); + const want = [...new Set(frontier.flatMap(refsOf))].filter((u) => !found.has(u)); if (!want.length) break; const got = await readBlocks(want); - for (const [uid, b] of got) into.set(uid, b.string); + if (!got.size) break; + for (const [uid, b] of got) found.set(uid, b); frontier = [...got.values()].map((b) => b.string); } - return into; + return found; +}; + +/* Every block the display text of `strings` needs, refs inside refs included. */ +export const readDisplayRefs = (strings: readonly string[]) => readRefChain(strings, displayRefUids); + +const wrapperRefs = (s: string): string[] => { + const uid = wrapperRef(s); + return uid ? [uid] : []; }; export const loadWidget = async (args: WidgetArgs, now = Date.now()): Promise => { @@ -49,53 +51,45 @@ export const loadWidget = async (args: WidgetArgs, now = Date.now()): Promise m.uid)); - - // Walk ((ref)) wrapper chains one hop per round, one read per round. - const blocks = new Map(); - const lookupBlock = (uid: string) => blocks.get(uid); - const items = headerItems(findHeaders(trees, ANCHOR_UID)).map((i) => i.block); - for (let hop = 0; hop < MAX_REF_HOPS; hop++) { - const want = pendingWrapperTargets(items, lookupBlock, MAX_REF_HOPS); - if (!want.length) break; - const got = await readBlocks(want); - if (!got.size) break; - for (const [uid, b] of got) blocks.set(uid, b); - } + // Headers live one or two levels under a meeting, so meetings are read two + // levels deep and only the headers in full. + const [tops, pageTasks] = await Promise.all([ + readTrees(plan.scan.map((m) => m.uid), 2), + readPageTasks(pageUid), + ]); + const found = findHeaders(tops, ANCHOR_UIDS); + const full = new Map((await readTrees(found.map((h) => h.node.uid))).map((t) => [t.uid, t])); + const headers = found.map((h) => ({ ...h, node: full.get(h.node.uid) ?? h.node })); + const wrapped = await readRefChain( + headerItems(headers).map((i) => i.block.string), + wrapperRefs, + ); const view = deriveView({ pageUid, meetings, plan, - trees, + headers, pageTasks, - lookupBlock, - anchorUid: ANCHOR_UID, - celebrateDays: CELEBRATE_DAYS, - maxHops: MAX_REF_HOPS, + lookupBlock: (uid) => wrapped.get(uid), now, }); - if (!view.primary.length && !view.secondary.length) - return empty(`nothing to show. ${view.debug}`); + if (!view.primary.length && !view.secondary.length) return empty(`nothing to show. ${view.debug}`); // Only the visible section's refs. The collapsed section reads its own // when it is opened. - const texts = await readRefTexts(view.primary.map((r) => r.task.string)); - return { kind: "view", view, texts, ms: ms() }; + const refs = await readDisplayRefs(view.primary.map((r) => r.shown)); + return { kind: "view", view, refs, ms: ms() }; }; diff --git a/prototypes/roam-meeting-tasks/src/model.ts b/prototypes/roam-meeting-tasks/src/model.ts index f4cf70a..d374af4 100644 --- a/prototypes/roam-meeting-tasks/src/model.ts +++ b/prototypes/roam-meeting-tasks/src/model.ts @@ -12,23 +12,38 @@ * carried a task anywhere under a header * other every other TODO/DONE on the page */ -import type { Mode } from "~/args"; -import { DAY_MS } from "~/config"; -import { dailyNoteTime, hasMarker, headerKind, isDone, leadRef, stripMarkup } from "~/text"; +import { CELEBRATE_DAYS, DAY_MS, MAX_REF_HOPS } from "~/config"; +import { dailyNoteTime, hasMarker, headerKind, isDone, meetingTitle, wrapperRef } from "~/text"; + +type Mode = "page" | "meeting"; export type Block = { uid: string; string: string; time: number }; // time = last edit -export type TreeNode = Block & { children: TreeNode[] }; +export type TreeNode = Block & { children: TreeNode[] }; // children in outline order export type PageTask = Block & { parentUids: string[] }; export type MeetingRow = { uid: string; string: string; dailyNoteUid: string; sticky: boolean }; export type Meeting = { uid: string; string: string; time: number }; // time = meeting date -export type Row = { task: Block; meeting: Meeting | null }; +/* `task` is the block the checkbox reads and writes. `shown` is the text the + * row displays: the header item as written, which for a ((ref)) wrapper keeps + * any note next to the ref. `order` is the item's outline position, for rows + * that come from under a header. */ +export type Row = { task: Block; meeting: Meeting | null; shown: string; order?: number }; /* ── meetings ──────────────────────────────────────────────────────────── */ +/* The latest-dated meeting among these, or null. One rule for the widget's + * own meeting, a task's meeting, and "last meeting": nested meeting blocks + * are rare, and when they happen the latest date wins. */ +const latest = (ms: Iterable): Meeting | null => { + let best: Meeting | null = null; + for (const m of ms) if (m && (!best || m.time > best.time)) best = m; + return best; +}; + /* One entry per meeting block, newest first. A block can reference several * dates; the meeting's own date is the earliest. The #.sticky tag separates * real meeting blocks from prose that happens to mention a date, so when any - * meeting carries it, only those count. */ + * meeting carries it, only those count. (roam.ts relies on this rule to skip + * the page scan when #.sticky meetings exist.) */ export const selectMeetings = (rows: readonly MeetingRow[]): Meeting[] => { const byUid = new Map(); for (const r of rows) { @@ -45,19 +60,12 @@ export const selectMeetings = (rows: readonly MeetingRow[]): Meeting[] => { .map(({ uid, string, time }) => ({ uid, string, time })); }; -/* "[[September 22nd, 2026]] #.sticky" → "September 22nd, 2026" */ -export const meetingTitle = (s: string): string => - stripMarkup(s) - .replace(/#\S+/g, "") - .replace(/\[\[|\]\]/g, "") - .trim(); - /* ── mode and window ───────────────────────────────────────────────────── */ export type Plan = { mode: Mode; - hostMeeting: Meeting | null; - windowStart: number; + /* The meetings whose next actions and tasks count: those inside the + * lookback window and, in meeting mode, dated before the host meeting. */ scan: Meeting[]; olderCount: number; lookbackDays: number; @@ -70,51 +78,41 @@ export type Plan = { export const planWindow = (input: { meetings: readonly Meeting[]; hostAncestorUids: readonly string[]; - forcedMode: Mode | null; + forcePage: boolean; lookbackDays: number; now: number; }): Plan => { const byUid = new Map(input.meetings.map((m) => [m.uid, m])); - // Nested meeting blocks are rare; when they happen the latest-dated wins, - // the same rule tasks use below. (The old component took whichever - // ancestor the query happened to return first.) - let hostMeeting: Meeting | null = null; - for (const uid of input.hostAncestorUids) { - const m = byUid.get(uid); - if (m && (!hostMeeting || m.time > hostMeeting.time)) hostMeeting = m; - } - // Forcing meeting mode outside a meeting used to throw; show page mode. - const mode: Mode = hostMeeting && input.forcedMode !== "page" ? "meeting" : "page"; - if (mode === "page") hostMeeting = null; - const anchor = hostMeeting ? hostMeeting.time : input.now; - const windowStart = anchor - input.lookbackDays * DAY_MS; - const scan = input.meetings.filter((m) => m.time >= windowStart); + const host = input.forcePage ? null : latest(input.hostAncestorUids.map((u) => byUid.get(u))); + const windowStart = (host ? host.time : input.now) - input.lookbackDays * DAY_MS; + // Meeting mode looks only backwards. The old component also read the host + // meeting and newer ones, so a task carried into this meeting's next + // actions left "From last meeting", and newer items leaked into "older". + const scan = input.meetings.filter((m) => m.time >= windowStart && (!host || m.time < host.time)); return { - mode, - hostMeeting, - windowStart, + mode: host ? "meeting" : "page", scan, - olderCount: input.meetings.length - scan.length, + olderCount: input.meetings.filter((m) => m.time < windowStart).length, lookbackDays: input.lookbackDays, }; }; /* ── headers and the tasks under them ──────────────────────────────────── */ -export type Header = { uid: string; meetingUid: string; viaAnchor: boolean; node: TreeNode }; +export type Header = { meetingUid: string; viaAnchor: boolean; node: TreeNode }; /* Headers sit one or two levels under a meeting block. Bounding the depth is * what keeps prose like "next steps for X", which lives deeper under * "points of discussion", out of the results. */ -export const findHeaders = (trees: readonly TreeNode[], anchorUid: string): Header[] => { +export const findHeaders = (trees: readonly TreeNode[], anchorUids: readonly string[]): Header[] => { const seen = new Set(); const out: Header[] = []; const consider = (meetingUid: string, node: TreeNode) => { if (seen.has(node.uid)) return; - const kind = headerKind(node.string, anchorUid); + const kind = headerKind(node.string, anchorUids); if (!kind) return; seen.add(node.uid); - out.push({ uid: node.uid, meetingUid, viaAnchor: kind === "anchor", node }); + out.push({ meetingUid, viaAnchor: kind === "anchor", node }); }; for (const t of trees) for (const c of t.children) consider(t.uid, c); for (const t of trees) for (const c of t.children) for (const g of c.children) consider(t.uid, g); @@ -124,165 +122,113 @@ export const findHeaders = (trees: readonly TreeNode[], anchorUid: string): Head const descendants = (node: TreeNode): TreeNode[] => node.children.flatMap((c) => [c, ...descendants(c)]); -export type HeaderItem = { meetingUid: string; block: Block }; - -export const headerItems = (headers: readonly Header[]): HeaderItem[] => - headers.flatMap((h) => - descendants(h.node).map((b) => ({ - meetingUid: h.meetingUid, - block: { uid: b.uid, string: b.string, time: b.time }, - })), - ); +/* Every block under every header, in outline order. */ +export const headerItems = (headers: readonly Header[]): { meetingUid: string; block: Block }[] => + headers.flatMap((h) => descendants(h.node).map((block) => ({ meetingUid: h.meetingUid, block }))); /* Items are often ((block-ref)) wrappers around the real task, and the real * task may live on another page, or be a wrapper itself. Follow the chain to - * the block that actually holds the marker, so the checkbox writes there. */ -export const resolveTask = ( - block: Block, - lookup: (uid: string) => Block | undefined, - maxHops: number, -): Block | null => { - let cur = block; - for (let hop = 0; hop <= maxHops; hop++) { + * the block that actually holds the marker, so the checkbox writes there. + * load.ts reads the chain with the same wrapperRef rule and hop limit. */ +const resolveTask = (block: Block, lookup: (uid: string) => Block | undefined): Block | null => { + let cur: Block | undefined = block; + for (let hop = 0; cur && hop <= MAX_REF_HOPS; hop++) { if (hasMarker(cur.string)) return cur; - const next = leadRef(cur.string); - const target = next ? lookup(next) : undefined; - if (!target) return null; - cur = target; + const next = wrapperRef(cur.string); + cur = next ? lookup(next) : undefined; } return null; }; -/* The wrapper targets the data layer must fetch before resolveTask can run. - * Call repeatedly with the growing lookup to walk chains one hop at a time. */ -export const pendingWrapperTargets = ( - blocks: readonly Block[], - lookup: (uid: string) => Block | undefined, - maxHops: number, -): string[] => { - const wanted = new Set(); - for (const b of blocks) { - let cur: Block | undefined = b; - for (let hop = 0; cur && hop <= maxHops; hop++) { - if (hasMarker(cur.string)) break; - const next = leadRef(cur.string); - if (!next) break; - const target = lookup(next); - if (!target) { - wanted.add(next); - break; - } - cur = target; - } - } - return [...wanted]; -}; - /* ── the view ──────────────────────────────────────────────────────────── */ export type View = { - mode: Mode; primary: Row[]; secondary: Row[]; primaryLabel: string; + /* Meeting mode: the meeting "From last meeting" refers to, shown as a link. */ + lastMeeting: { uid: string; title: string } | null; secondaryLabel: string; olderCount: number; - lookbackDays: number; debug: string; }; -const newestFirst = (a: Row, b: Row): number => { - const d = (b.meeting?.time ?? 0) - (a.meeting?.time ?? 0); - return d !== 0 ? d : b.task.time - a.task.time; -}; +/* Newest meeting first; within a meeting, outline order, then latest edit. */ +const byMeetingThenOutline = (a: Row, b: Row): number => + (b.meeting?.time ?? 0) - (a.meeting?.time ?? 0) || + (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER) || + b.task.time - a.task.time; export const deriveView = (input: { pageUid: string; meetings: readonly Meeting[]; plan: Plan; - trees: readonly TreeNode[]; + headers: readonly Header[]; pageTasks: readonly PageTask[]; lookupBlock: (uid: string) => Block | undefined; - anchorUid: string; - celebrateDays: number; - maxHops: number; now: number; }): View => { - const { meetings, plan } = input; + const { meetings, plan, headers } = input; const meetingByUid = new Map(meetings.map((m) => [m.uid, m])); - const headers = findHeaders(input.trees, input.anchorUid); - const meetingsWithHeaders = new Set(headers.map((h) => h.meetingUid)); + const inScan = new Set(plan.scan.map((m) => m.uid)); // The same task can be carried forward across meetings: keep the newest. const claimed = new Map(); - for (const item of headerItems(headers)) { - const task = resolveTask(item.block, input.lookupBlock, input.maxHops); + headerItems(headers).forEach((item, order) => { + const task = resolveTask(item.block, input.lookupBlock); const meeting = meetingByUid.get(item.meetingUid); - if (!task || !meeting) continue; + if (!task || !meeting) return; const prev = claimed.get(task.uid); - if (prev && (prev.meeting?.time ?? 0) >= meeting.time) continue; - claimed.set(task.uid, { task, meeting }); - } + if (prev && (prev.meeting?.time ?? 0) >= meeting.time) return; + claimed.set(task.uid, { task, meeting, shown: item.block.string, order }); + }); const carried = [...claimed.values()]; const others: Row[] = input.pageTasks .filter((t) => hasMarker(t.string) && !claimed.has(t.uid)) - .map((t) => { - // A task can sit under nested meeting blocks; the latest-dated wins. - let meeting: Meeting | null = null; - for (const p of t.parentUids) { - const m = meetingByUid.get(p); - if (m && (!meeting || m.time > meeting.time)) meeting = m; - } - return { task: { uid: t.uid, string: t.string, time: t.time }, meeting }; - }) - // A task under a meeting outside the window drops out. Page-level inbox + .map((t) => ({ task: t, meeting: latest(t.parentUids.map((p) => meetingByUid.get(p))), shown: t.string })) + // A task under a meeting outside the scan drops out. Page-level inbox // tasks have no meeting and always stay: they are current, not history, // and silently hiding open work is worse than a slightly longer list. - .filter((r) => !r.meeting || r.meeting.time >= plan.windowStart); + .filter((r) => !r.meeting || inScan.has(r.meeting.uid)); - const cutoff = input.now - input.celebrateDays * DAY_MS; + const cutoff = input.now - CELEBRATE_DAYS * DAY_MS; const visible = (r: Row) => (isDone(r.task.string) ? r.task.time >= cutoff : true); const open = (r: Row) => !isDone(r.task.string); - let primary: Row[]; - let secondary: Row[]; - let primaryLabel: string; - let secondaryLabel: string; - const host = plan.hostMeeting; - if (plan.mode === "meeting" && host) { - const prev = - meetings.find((m) => m.time < host.time && meetingsWithHeaders.has(m.uid)) ?? null; - primary = carried.filter((r) => prev !== null && r.meeting?.uid === prev.uid && visible(r)); - secondary = carried - .filter((r) => (prev === null || r.meeting?.uid !== prev.uid) && open(r)) - .concat(others.filter(open)); - primaryLabel = prev ? `From last meeting · ${meetingTitle(prev.string)}` : "From last meeting"; - secondaryLabel = "older open items on this page"; + let view: Pick; + // Meeting mode: the last meeting with anything under its next actions. A + // meeting whose header was left empty (templates add one to every meeting) + // is skipped. + const lastMeeting = plan.mode === "meeting" ? latest(carried.map((r) => r.meeting)) : null; + if (plan.mode === "meeting") { + const fromLast = (r: Row) => !!lastMeeting && r.meeting?.uid === lastMeeting.uid; + view = { + primary: carried.filter((r) => fromLast(r) && visible(r)), + secondary: carried.filter((r) => !fromLast(r) && open(r)).concat(others.filter(open)), + primaryLabel: "From last meeting", + secondaryLabel: "older open items on this page", + }; } else { - primary = carried.filter(visible); - secondary = others.filter(visible); - primaryLabel = "Carried over from past next actions"; - // Not "open tasks": in page mode this list also carries recent DONEs. - secondaryLabel = "Other tasks on this page"; + view = { + primary: carried.filter(visible), + secondary: others.filter(visible), + primaryLabel: "Carried over from past next actions", + // Not "open tasks": in page mode this list also carries recent DONEs. + secondaryLabel: "Other tasks on this page", + }; } - primary.sort(newestFirst); - secondary.sort(newestFirst); + view.primary.sort(byMeetingThenOutline); + view.secondary.sort(byMeetingThenOutline); const anchored = headers.filter((h) => h.viaAnchor).length; - const debug = - `mode=${plan.mode} lookback=${plan.lookbackDays}d(${plan.scan.length}/${meetings.length} meetings)` + - ` page=${input.pageUid} meetings=${meetings.length} headers=${headers.length} (anchored ${anchored})` + - ` carried=${carried.length} others=${others.length}`; - return { - mode: plan.mode, - primary, - secondary, - primaryLabel, - secondaryLabel, + ...view, + lastMeeting: lastMeeting && { uid: lastMeeting.uid, title: meetingTitle(lastMeeting.string) }, olderCount: plan.olderCount, - lookbackDays: plan.lookbackDays, - debug, + debug: + `mode=${plan.mode} lookback=${plan.lookbackDays}d(${plan.scan.length}/${meetings.length} meetings)` + + ` page=${input.pageUid} meetings=${meetings.length} headers=${headers.length} (anchored ${anchored})` + + ` carried=${carried.length} others=${others.length}`, }; }; diff --git a/prototypes/roam-meeting-tasks/src/mount.ts b/prototypes/roam-meeting-tasks/src/mount.ts index ccabae4..0523f1c 100644 --- a/prototypes/roam-meeting-tasks/src/mount.ts +++ b/prototypes/roam-meeting-tasks/src/mount.ts @@ -11,7 +11,7 @@ import React from "react"; import ReactDOM from "react-dom"; import * as ReactDOMClient from "react-dom/client"; import { parseArgs } from "~/args"; -import { logError } from "~/config"; +import { logError, SWEEP_MS } from "~/config"; import { MeetingTasks } from "~/view"; type Root = { render: (node: React.ReactElement) => void; unmount: () => void }; @@ -31,29 +31,42 @@ const createRoot = (el: Element): Root => { }; }; -const roots = new Map(); +const mounted = new Map(); + +// Roam can remove a block's DOM without the shim hearing about it, so a +// sweep unmounts roots whose host has left the page. It runs only while +// something is mounted. +let sweepTimer: number | null = null; const unmountHost = (el: Element): void => { try { - roots.get(el)?.unmount(); + mounted.get(el)?.root.unmount(); } catch (error) { logError("could not unmount a widget", error); } - roots.delete(el); + mounted.delete(el); + if (!mounted.size && sweepTimer !== null) { + window.clearInterval(sweepTimer); + sweepTimer = null; + } +}; + +const sweep = (): void => { + for (const el of [...mounted.keys()]) if (!el.isConnected) unmountHost(el); }; -/* Render (or re-render in place) the widget into a host element. Roam calls - * the shim again whenever it re-renders the block; rendering into the same - * root updates it, so state such as an opened section and the loaded data - * survive. */ +/* Render the widget into a host element. Roam runs the shim again whenever it + * re-renders the block; with the same arguments that is a no-op, so the + * widget keeps its state and loaded data and does not redraw. */ export const mount = (el: Element, argv: unknown): void => { const args = parseArgs(Array.isArray(argv) ? argv : []); - let root = roots.get(el); - if (!root) { - root = createRoot(el); - roots.set(el, root); - } + const key = JSON.stringify(args); + const existing = mounted.get(el); + if (existing?.key === key) return; + const root = existing?.root ?? createRoot(el); + mounted.set(el, { root, key }); root.render(React.createElement(MeetingTasks, { args })); + sweepTimer ??= window.setInterval(sweep, SWEEP_MS); }; /* The shim lets go of a host. It may be re-attaching the same element on its @@ -65,13 +78,8 @@ export const release = (el: Element): void => { }, 0); }; -/* Roam can remove a block's DOM without the shim hearing about it. */ -export const sweep = (): void => { - for (const el of [...roots.keys()]) if (!el.isConnected) unmountHost(el); -}; - export const unmountAll = (): void => { - for (const el of [...roots.keys()]) unmountHost(el); + for (const el of [...mounted.keys()]) unmountHost(el); }; -export const mountedCount = (): number => roots.size; +export const mountedCount = (): number => mounted.size; diff --git a/prototypes/roam-meeting-tasks/src/roam.ts b/prototypes/roam-meeting-tasks/src/roam.ts index d7cf14a..98d0100 100644 --- a/prototypes/roam-meeting-tasks/src/roam.ts +++ b/prototypes/roam-meeting-tasks/src/roam.ts @@ -13,11 +13,12 @@ */ import { SHARE_MS } from "~/config"; import type { Block, MeetingRow, PageTask, TreeNode } from "~/model"; +import { isSticky } from "~/text"; type Pulled = Record; -const api = () => window.roamAlphaAPI as unknown as RoamApi; - +// roamjs-components declares these too, but types pull_many ids as uid pairs +// only, and entity ids are what makes refsOnPage cheap. type RoamApi = { graph?: { name?: string }; data: { @@ -36,11 +37,25 @@ type RoamApi = { }; }; +const api = () => window.roamAlphaAPI as unknown as RoamApi; + const str = (v: unknown): string => (typeof v === "string" ? v : ""); const num = (v: unknown): number => (typeof v === "number" ? v : 0); const list = (v: unknown): Pulled[] => (Array.isArray(v) ? (v as Pulled[]) : []); +const uidOf = (v: unknown): string => str((v as Pulled | undefined)?.[":block/uid"]); const byUid = (uid: string) => [":block/uid", uid]; +const toBlock = (b: Pulled): Block => ({ + uid: uidOf(b), + string: str(b[":block/string"]), + time: num(b[":edit/time"]), +}); + +const pullMany = async (pattern: string, eids: readonly unknown[]): Promise => + eids.length + ? (await api().data.async.pull_many(pattern, [...eids])).filter((b): b is Pulled => !!b) + : []; + /* ── sharing ───────────────────────────────────────────────────────────── */ type Entry = { at: number; value: Promise }; @@ -51,12 +66,21 @@ const fresh = (key: string): Entry | undefined => { return hit && Date.now() - hit.at < SHARE_MS ? hit : undefined; }; +/* Entries leave the map when they expire or fail, so a session of browsing + * does not keep every page's trees alive. */ +const put = (key: string, value: Promise): void => { + const entry = { at: Date.now(), value }; + shared.set(key, entry); + const drop = () => shared.get(key) === entry && shared.delete(key); + value.catch(drop); + window.setTimeout(drop, SHARE_MS); +}; + const share = (key: string, load: () => Promise): Promise => { const hit = fresh(key); if (hit) return hit.value as Promise; const value = load(); - shared.set(key, { at: Date.now(), value }); - value.catch(() => shared.delete(key)); + put(key, value); return value; }; @@ -67,23 +91,21 @@ const shareMany = async ( uids: readonly string[], load: (uids: string[]) => Promise>, ): Promise> => { - const missing = [...new Set(uids)].filter((u) => !fresh(prefix + u)); + const entries = new Map(uids.map((u) => [u, fresh(prefix + u)?.value])); + const missing = [...entries].filter(([, v]) => !v).map(([u]) => u); if (missing.length) { const batch = load(missing); - const at = Date.now(); for (const u of missing) { const value = batch.then((m) => m.get(u)); - shared.set(prefix + u, { at, value }); - value.catch(() => shared.delete(prefix + u)); + put(prefix + u, value); + entries.set(u, value); } } const out = new Map(); - await Promise.all( - uids.map(async (u) => { - const v = (await shared.get(prefix + u)?.value) as T | undefined; - if (v !== undefined) out.set(u, v); - }), - ); + for (const [u, value] of entries) { + const v = (await value) as T | undefined; + if (v !== undefined) out.set(u, v); + } return out; }; @@ -102,129 +124,107 @@ export const readHost = async ( "[{:block/page [:block/uid]} {:block/parents [:block/uid]}]", byUid(uid), ); - const pageUid = str((b?.[":block/page"] as Pulled | undefined)?.[":block/uid"]); + const pageUid = uidOf(b?.[":block/page"]); if (!pageUid) return null; - return { pageUid, ancestorUids: list(b?.[":block/parents"]).map((p) => str(p[":block/uid"])) }; + return { pageUid, ancestorUids: list(b?.[":block/parents"]).map(uidOf) }; +}; + +/* Blocks on one page that reference the page titled `title`, pulled with + * `pattern`. Starts from the title's reverse references, an index read, and + * compares entity ids, so only the blocks on this page are pulled in full. + * On dg-team this is 3-6x cheaper than pulling every reference by uid. */ +const refsOnPage = async (title: string, pageUid: string, pattern: string): Promise => { + const [page, target] = await Promise.all([ + share(`page:${pageUid}`, () => api().data.async.pull("[:db/id]", byUid(pageUid))), + api().data.async.pull("[{(:block/_refs :limit nil) [:db/id :block/page]}]", [":node/title", title]), + ]); + const pageEid = page?.[":db/id"]; + if (pageEid === undefined) return []; + const eids = list(target?.[":block/_refs"]) + .filter((b) => (b[":block/page"] as Pulled | undefined)?.[":db/id"] === pageEid) + .map((b) => b[":db/id"]); + return pullMany(pattern, eids); }; /* Blocks on the page that reference a daily-note page. * - * Fast path: start from the `.sticky` tag's reverse references (~2,300 blocks - * graph-wide in dg-team, ~35 ms) instead of scanning every block on the page - * (~170 ms on All Hands). model.selectMeetings only keeps #.sticky meetings - * when any exist, so this returns the same meetings. A page with no #.sticky - * meetings falls back to the page scan. */ + * Fast path: start from the `.sticky` tag's reverse references instead of + * scanning every block on the page (~170 ms on All Hands). selectMeetings + * keeps only #.sticky meetings when any exist, so when this finds some it has + * found every meeting that counts. A page with none falls back to the scan. */ export const readMeetingRows = (pageUid: string): Promise => share(`meetings:${pageUid}`, async () => { - const tag = await api().data.async.pull( - "[{(:block/_refs :limit nil) [:block/uid :block/string {:block/page [:block/uid]} {:block/refs [:block/uid :log/id]}]}]", - [":node/title", ".sticky"], - ); - const sticky = list(tag?.[":block/_refs"]) - .filter((b) => str((b[":block/page"] as Pulled | undefined)?.[":block/uid"]) === pageUid) - .flatMap((b) => - list(b[":block/refs"]) - .filter((r) => r[":log/id"] !== undefined) - .map((r) => ({ - uid: str(b[":block/uid"]), - string: str(b[":block/string"]), - dailyNoteUid: str(r[":block/uid"]), - sticky: true, - })), - ); - if (sticky.length) return sticky; - - // Tuple order: [meeting uid, meeting string, daily-note uid] - const rows = await api().data.async.q( - `[:find ?mu ?ms ?du :in $ ?pgu :where - [?pg :block/uid ?pgu] [?m :block/page ?pg] - [?m :block/refs ?d] [?d :log/id _] [?d :block/uid ?du] - [?m :block/uid ?mu] [?m :block/string ?ms]]`, + const tagged = await refsOnPage( + ".sticky", pageUid, + "[:block/uid :block/string {:block/refs [:block/uid :log/id]}]", ); - return rows.map(([uid, string, dailyNoteUid]) => ({ + const rows = tagged.flatMap((b) => + list(b[":block/refs"]) + .filter((r) => r[":log/id"] !== undefined) + .map((r) => [uidOf(b), str(b[":block/string"]), uidOf(r)]), + ); + const found = rows.length + ? rows + : // Tuple order: [meeting uid, meeting string, daily-note uid] + await api().data.async.q( + `[:find ?mu ?ms ?du :in $ ?pgu :where + [?pg :block/uid ?pgu] [?m :block/page ?pg] + [?m :block/refs ?d] [?d :log/id _] [?d :block/uid ?du] + [?m :block/uid ?mu] [?m :block/string ?ms]]`, + pageUid, + ); + return found.map(([uid, string, dailyNoteUid]) => ({ uid: str(uid), string: str(string), dailyNoteUid: str(dailyNoteUid), - sticky: str(string).includes(".sticky"), + sticky: isSticky(str(string)), })); }); +// Pulled children come back in no particular order; :block/order is the +// outline position. const toTree = (b: Pulled): TreeNode => ({ - uid: str(b[":block/uid"]), - string: str(b[":block/string"]), - time: num(b[":edit/time"]), - children: list(b[":block/children"]).map(toTree), + ...toBlock(b), + children: list(b[":block/children"]) + .sort((x, y) => num(x[":block/order"]) - num(y[":block/order"])) + .map(toTree), }); -/* Whole subtrees of the given meeting blocks, in one pull_many. On All Hands - * the 18 meetings in a 120-day window are ~2,200 blocks and ~45 ms. */ -export const readTrees = async (uids: readonly string[]): Promise => { - const got = await shareMany("tree:", uids, async (missing) => { - const pulled = await api().data.async.pull_many( - "[:block/uid :block/string :edit/time {:block/children ...}]", +/* Blocks with their descendants, `depth` levels deep (all of them when + * omitted), in one pull_many. Meetings are read two levels deep, which is + * where headers live; only the headers are then read in full. */ +export const readTrees = async (uids: readonly string[], depth?: number): Promise => { + const levels = depth ?? "..."; + const got = await shareMany(`tree${levels}:`, uids, async (missing) => { + const pulled = await pullMany( + `[:block/uid :block/string :block/order :edit/time {:block/children ${levels}}]`, missing.map(byUid), ); - return new Map(pulled.filter((b): b is Pulled => !!b).map((b) => [str(b[":block/uid"]), toTree(b)])); + return new Map(pulled.map((b) => [uidOf(b), toTree(b)])); }); return uids.map((u) => got.get(u)).filter((t): t is TreeNode => !!t); }; -/* Every TODO/DONE block on the page, with its ancestors. - * - * Starts from the TODO and DONE pages' reverse references, since every - * checkbox references one of them, and keeps the ones on this page. Then one - * pull_many for their text and ancestors, which replaces a query that joined - * every task against every meeting. */ +/* Every TODO/DONE block on the page, with its ancestors. Every checkbox + * references the TODO or DONE page, so this is two refsOnPage reads. */ export const readPageTasks = (pageUid: string): Promise => share(`tasks:${pageUid}`, async () => { - const markers = await Promise.all( - ["TODO", "DONE"].map((title) => - api().data.async.pull( - "[{(:block/_refs :limit nil) [:block/uid {:block/page [:block/uid]}]}]", - [":node/title", title], - ), - ), - ); - const uids = [ - ...new Set( - markers.flatMap((m) => - list(m?.[":block/_refs"]) - .filter((b) => str((b[":block/page"] as Pulled | undefined)?.[":block/uid"]) === pageUid) - .map((b) => str(b[":block/uid"])), - ), - ), - ]; - if (!uids.length) return []; - const pulled = await api().data.async.pull_many( - "[:block/uid :block/string :edit/time {:block/parents [:block/uid]}]", - uids.map(byUid), + const pattern = "[:block/uid :block/string :edit/time {:block/parents [:block/uid]}]"; + const [todo, done] = await Promise.all( + ["TODO", "DONE"].map((title) => refsOnPage(title, pageUid, pattern)), ); - return pulled - .filter((b): b is Pulled => !!b) - .map((b) => ({ - uid: str(b[":block/uid"]), - string: str(b[":block/string"]), - time: num(b[":edit/time"]), - parentUids: list(b[":block/parents"]).map((p) => str(p[":block/uid"])), - })); + return todo.concat(done).map((b) => ({ + ...toBlock(b), + parentUids: list(b[":block/parents"]).map(uidOf), + })); }); /* Text of the given blocks, for resolving ((refs)). One pull_many per call. */ export const readBlocks = (uids: readonly string[]): Promise> => shareMany("block:", uids, async (missing) => { - const pulled = await api().data.async.pull_many( - "[:block/uid :block/string :edit/time]", - missing.map(byUid), - ); - return new Map( - pulled - .filter((b): b is Pulled => !!b) - .map((b) => [ - str(b[":block/uid"]), - { uid: str(b[":block/uid"]), string: str(b[":block/string"]), time: num(b[":edit/time"]) }, - ]), - ); + const pulled = await pullMany("[:block/uid :block/string :edit/time]", missing.map(byUid)); + return new Map(pulled.map((b) => [uidOf(b), toBlock(b)])); }); /* ── writes ────────────────────────────────────────────────────────────── */ diff --git a/prototypes/roam-meeting-tasks/src/styles.ts b/prototypes/roam-meeting-tasks/src/styles.ts index e650496..5d50024 100644 --- a/prototypes/roam-meeting-tasks/src/styles.ts +++ b/prototypes/roam-meeting-tasks/src/styles.ts @@ -4,7 +4,7 @@ * restyle the graph. The look matches the roam/render component it replaces. */ import { FALLBACK_CLASS, STYLE_ID } from "~/config"; -export const CSS = ` +const CSS = ` /* The shim's "waiting for the extension" note. Hidden whenever this stylesheet is present, which is exactly when the extension is loaded. */ .${FALLBACK_CLASS} { display: none; } @@ -27,6 +27,7 @@ export const CSS = ` .rmt-date[data-inbox="true"] { opacity: 0.45; } /* Two classes deep so Roam's and Blueprint's own link colors lose. */ .rmt .rmt-text { flex: 1 1 auto; color: inherit; text-decoration: none; } + .rmt .rmt-meeting { color: inherit; text-decoration: underline dotted; } .rmt-footer { font-size: 11px; opacity: 0.5; margin-top: 6px; } .rmt-debug { font-size: 11px; opacity: 0.6; margin-top: 6px; } `; diff --git a/prototypes/roam-meeting-tasks/src/text.ts b/prototypes/roam-meeting-tasks/src/text.ts index dd49252..10a9851 100644 --- a/prototypes/roam-meeting-tasks/src/text.ts +++ b/prototypes/roam-meeting-tasks/src/text.ts @@ -2,41 +2,56 @@ * block string is turned into display text. Pure functions, no graph access. */ // Anchored to end-of-string on purpose: "next steps for cybrarian position" is -// prose, not a header. Novel wordings are meant to be caught by ANCHOR_UID. +// prose, not a header. Novel wordings are meant to be caught by the anchors. const HEADER_RE = - /^(?:(?:next\s+)?actions?(?:\s+items?)?|next\s+steps?)(?:\s*[::])?(?:\s*[/,-]?\s*(?:by\s+\w+|for\s+next\s+time|this\s+week))?$/i; -const MARKER_RE = /\{\{\[\[(TODO|DONE)\]\]\}\}|\{\{(TODO|DONE)\}\}/; -const DONE_RE = /\{\{\[\[DONE\]\]\}\}|\{\{DONE\}\}/; + /^(?:(?:next\s+)?actions?(?:\s+items?)?|(?:proposed\s+)?next\s+steps?)(?:\s*[::])?(?:\s*[/,-]?\s*(?:by\s+\w+|for\s+next\s+time|this\s+week))?(?:\s*[::])?$/i; +const UID = "[\\w-]{6,}"; // A block that starts with a ((ref)) may be a wrapper around the real task. -const LEAD_REF_RE = /^\s*\(\(([\w-]{6,})\)\)/; -const REF_RE = /\(\(([\w-]{6,})\)\)/g; +const LEAD_REF_RE = new RegExp(`^\\s*\\(\\((${UID})\\)\\)`); +const REF_RE = new RegExp(`\\(\\((${UID})\\)\\)`, "g"); +// An ℹ tooltip, with or without the emoji variation selector, pointing at a +// block ([ℹ](((uid)))) or a page ([ℹ]([[Page]])). +const TOOLTIP_RE = new RegExp(`\\[ℹ\\uFE0F?\\]\\((?:\\(\\(${UID}\\)\\)|\\[\\[[^\\]]*\\]\\])\\)`, "g"); +// A block reference shown under a label: [label](((uid))). +const ALIAS_REF_RE = new RegExp(`\\[([^\\]]*)\\]\\(\\(\\((${UID})\\)\\)\\)`, "g"); +// Any {{component}}: TODO/DONE markers, embeds, buttons, SmartBlocks, POMOs. +const COMPONENT_RE = /\{\{[^{}]*\}\}/g; +const TAG_RE = /#\[\[[^\]]*\]\]|#[^\s[]+/g; +const EMPHASIS_RE = /\*\*|__|\^\^|~~/g; + +/* The checkbox marker, in both spellings Roam accepts: {{[[TODO]]}} and + * {{TODO}}. Every marker rule below is built from this one. */ +const marker = (state: string, flags = "") => + new RegExp(`\\{\\{(?:\\[\\[)?(?:${state})(?:\\]\\])?\\}\\}`, flags); +const MARKER_RE = marker("TODO|DONE"); +const DONE_RE = marker("DONE"); export const hasMarker = (s: string): boolean => MARKER_RE.test(s); export const isDone = (s: string): boolean => DONE_RE.test(s); -export const leadRef = (s: string): string | null => LEAD_REF_RE.exec(s)?.[1] ?? null; -export const refUids = (s: string): string[] => [...s.matchAll(REF_RE)].map((m) => m[1]); +export const isSticky = (s: string): boolean => s.includes(".sticky"); -/* The string with its checkbox flipped. Handles both marker spellings Roam - * accepts, and leaves a string without the matching marker unchanged. */ -export const withDone = (s: string, done: boolean): string => - done - ? s.replace(/\{\{\[\[TODO\]\]\}\}/g, "{{[[DONE]]}}").replace(/\{\{TODO\}\}/g, "{{DONE}}") - : s.replace(/\{\{\[\[DONE\]\]\}\}/g, "{{[[TODO]]}}").replace(/\{\{DONE\}\}/g, "{{TODO}}"); +/* The block a wrapper item stands for: its leading ((ref)), unless the item + * carries its own marker, in which case it is the task itself. */ +export const wrapperRef = (s: string): string | null => + hasMarker(s) ? null : (LEAD_REF_RE.exec(s)?.[1] ?? null); -export const stripMarkup = (s: string): string => - s - .replace(/\[ℹ\]\(\(\([\w-]+\)\)\)/g, "") - .replace(/\*\*|__|\^\^|~~/g, "") - .replace(/^#+\s*/, "") - .trim(); +/* The string with its checkbox flipped, keeping the marker's spelling. A + * string without the matching marker is returned unchanged. */ +export const withDone = (s: string, done: boolean): string => { + const [from, to] = done ? ["TODO", "DONE"] : ["DONE", "TODO"]; + return s.replace(marker(from, "g"), (m) => m.replace(from, to)); +}; -/* Is this block a "next actions" section header? A block that references the +/* Is this block a "next actions" section header? A block that references an * anchor tooltip always is. Otherwise it must match the wording, and must not * carry its own TODO/DONE marker: a marked block is a task, not a header. */ -export const headerKind = (s: string, anchorUid: string): "anchor" | "wording" | null => { - if (anchorUid && s.includes(anchorUid)) return "anchor"; +export const headerKind = ( + s: string, + anchorUids: readonly string[], +): "anchor" | "wording" | null => { + if (anchorUids.some((uid) => s.includes(uid))) return "anchor"; if (hasMarker(s)) return null; - const bare = stripMarkup(s).replace(/[::]\s*$/, ""); + const bare = s.replace(TOOLTIP_RE, "").replace(EMPHASIS_RE, "").replace(/^#+\s*/, "").trim(); return HEADER_RE.test(bare) ? "wording" : null; }; @@ -48,44 +63,49 @@ export const dailyNoteTime = (uid: string): number => { return Number.isFinite(t) ? t : 0; }; -/* Replace every ((ref)) with its target's text, following refs inside the - * target too. A ref whose target is unknown drops out, as before. Stops after - * `maxHops` rounds, so a cycle cannot loop forever. */ -export const resolveRefs = ( +/* Everything in a block string that is not text: tooltips, components + * (markers, embeds, buttons), and the targets of labelled block references. + * Removed before refs are resolved, so refs inside them are never read. */ +const dropNonText = (s: string): string => + s + .replace(TOOLTIP_RE, "") + .replace(ALIAS_REF_RE, (_m, label: string, uid: string) => (label.trim() ? label : `((${uid}))`)) + .replace(COMPONENT_RE, ""); + +/* The refs whose text the display of `s` needs. */ +export const displayRefUids = (s: string): string[] => + [...dropNonText(s).matchAll(REF_RE)].map((m) => m[1]); + +/* Block string → one line of display text. Markup is removed rather than + * rendered: the row is a link, and nested links inside it would fight it. + * Refs are resolved through chains (a ref to a block that is itself a ref), + * at most `maxHops` deep so a cycle cannot loop forever. A ref whose target + * is unknown drops out. */ +export const displayText = ( s: string, lookup: (uid: string) => string | undefined, maxHops: number, ): string => { - let out = s; + let out = dropNonText(s); for (let i = 0; i < maxHops; i++) { - const next = out.replace(REF_RE, (_whole, uid: string) => lookup(uid) ?? ""); + const next = out.replace(REF_RE, (_whole, uid: string) => dropNonText(lookup(uid) ?? "")); if (next === out) break; out = next; } - return out; -}; - -/* Block string → one line of display text. Markup is removed rather than - * rendered: the row is a link, and nested links inside it would fight it. */ -export const displayText = ( - s: string, - lookup: (uid: string) => string | undefined, - maxHops: number, -): string => { - // Embeds go first: they are not text, and a resolved ref inside one could - // contain "}}" and cut the non-greedy match short. - const withoutEmbeds = s.replace(/\{\{\[\[embed\]\]:.*?\}\}/g, ""); - const out = resolveRefs(withoutEmbeds, lookup, maxHops) - .replace(/\{\{\[\[(TODO|DONE)\]\]\}\}|\{\{(TODO|DONE)\}\}/g, "") - .replace(/\{\{\[\[embed\]\]:.*?\}\}/g, "") - .replace(/\{\{\[\[POMO\]\]:\s*\d+\}\}/g, "") - .replace(/!\[\]\(\S+\)/g, "") - .replace(/\[([^\]]*)\]\((?:https?:[^)]*|\[\[[^)]*)\)/g, "$1") + out = out + .replace(/!\[[^\]]*\]\([^)]*\)/g, "") + .replace(/\[([^\]]*)\]\((?:https?:|mailto:|\[\[)[^)]*\)/g, "$1") // Remove every bracket rather than matching pairs: nested node titles // like [[[[QUE]] - ...]] leave strays otherwise. .replace(/\[\[|\]\]/g, "") - .replace(/\*\*|__|\^\^|~~|`/g, "") + .replace(EMPHASIS_RE, "") + .replace(/`/g, "") .replace(/\s+/g, " ") .trim(); return out || "(untitled block)"; }; + +/* A meeting block as a label: "[[September 22nd, 2026]] #.sticky {{button}}" + * becomes "September 22nd, 2026". */ +export const meetingTitle = (s: string): string => + displayText(s.replace(TAG_RE, ""), () => undefined, 1); diff --git a/prototypes/roam-meeting-tasks/src/view.tsx b/prototypes/roam-meeting-tasks/src/view.tsx index fad97b3..e41b53d 100644 --- a/prototypes/roam-meeting-tasks/src/view.tsx +++ b/prototypes/roam-meeting-tasks/src/view.tsx @@ -8,24 +8,53 @@ import React from "react"; import type { WidgetArgs } from "~/args"; import { logError, MAX_REF_HOPS } from "~/config"; -import { loadWidget, readRefTexts, type Loaded } from "~/load"; -import type { Row } from "~/model"; +import { loadWidget, readDisplayRefs } from "~/load"; +import type { Block, Row } from "~/model"; import { graphName, openInSidebar, writeString } from "~/roam"; import { displayText, isDone, withDone } from "~/text"; -/* ── cross-widget sync ─────────────────────────────────────────────────── */ +/* ── state ─────────────────────────────────────────────────────────────── */ + +type Async = { kind: "loading" } | { kind: "failed" } | { kind: "done"; value: T }; + +/* Run `load` after render, and again when `deps` change. A result that + * arrives after the component unmounted or reloaded is dropped. */ +const useAsync = (load: () => Promise, what: string, deps: React.DependencyList): Async => { + const [state, setState] = React.useState>({ kind: "loading" }); + React.useEffect(() => { + let live = true; + setState({ kind: "loading" }); + load().then( + (value) => live && setState({ kind: "done", value }), + (error) => { + logError(`could not ${what}`, error); + if (live) setState({ kind: "failed" }); + }, + ); + return () => { + live = false; + }; + // `load` is a new closure every render; `deps` says when it changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); + return state; +}; // A task often shows in two widgets at once (the page-level one and a // meeting's "last meeting" slot). A toggle in one is announced to all. const WRITTEN = "roam-meeting-tasks:written"; type Written = { uid: string; string: string }; - const announce = (detail: Written): void => { window.dispatchEvent(new CustomEvent(WRITTEN, { detail })); }; -const useWrittenStrings = (): [Record, (w: Written) => void] => { +/* The current text of each task, and the one way to toggle a checkbox: + * update every widget at once, write, and put the text back if the write + * fails. `toggle` never changes identity, so unchanged rows skip rendering. */ +const useTaskStrings = () => { const [strings, setStrings] = React.useState>({}); + const latest = React.useRef(strings); + latest.current = strings; React.useEffect(() => { const onWritten = (e: Event) => { const { uid, string } = (e as CustomEvent).detail; @@ -34,7 +63,18 @@ const useWrittenStrings = (): [Record, (w: Written) => void] => window.addEventListener(WRITTEN, onWritten); return () => window.removeEventListener(WRITTEN, onWritten); }, []); - return [strings, announce]; + const current = React.useCallback((task: Block) => strings[task.uid] ?? task.string, [strings]); + const toggle = React.useCallback((task: Block, done: boolean) => { + const before = latest.current[task.uid] ?? task.string; + const next = withDone(before, done); + if (next === before) return; + announce({ uid: task.uid, string: next }); + writeString(task.uid, next).catch((error) => { + logError("could not save the checkbox", error); + announce({ uid: task.uid, string: before }); + }); + }, []); + return { current, toggle }; }; /* ── rows ──────────────────────────────────────────────────────────────── */ @@ -42,26 +82,37 @@ const useWrittenStrings = (): [Record, (w: Written) => void] => const dateLabel = (time: number): string => new Date(time).toLocaleDateString(undefined, { month: "short", day: "numeric" }); -const TaskRow = ({ - row, - string, - text, - onToggle, -}: { - row: Row; - string: string; - text: string; - onToggle: (row: Row, done: boolean) => void; -}) => { +/* A link to a block. A plain click follows it like any Roam link; shift-click + * opens the block in the right sidebar, as it does everywhere else in Roam. */ +const BlockLink = ({ uid, className, children }: { uid: string; className: string; children: React.ReactNode }) => ( + { + // On a plain link the browser would turn shift-click into a new window, + // so the default is cancelled first. + if (!e.shiftKey) return; + e.preventDefault(); + e.stopPropagation(); + openInSidebar(uid).catch((error) => logError("could not open the sidebar", error)); + }} + > + {children} + +); + +type RowProps = { row: Row; string: string; text: string; toggle: (task: Block, done: boolean) => void }; + +const TaskRow = React.memo(({ row, string, text, toggle }: RowProps) => { const done = isDone(string); - const uid = row.task.uid; return ( ); -}; +}); type RowsProps = { rows: Row[]; - texts: Map; - strings: Record; - onToggle: (row: Row, done: boolean) => void; + refs: Map; + tasks: ReturnType; }; -const Rows = ({ rows, texts, strings, onToggle }: RowsProps) => ( - <> - {rows.map((row) => { - const string = strings[row.task.uid] ?? row.task.string; - return ( +const Rows = ({ rows, refs, tasks }: RowsProps) => { + // Display text depends only on the loaded data, not on checkbox state. + const texts = React.useMemo( + () => rows.map((r) => displayText(r.shown, (u) => refs.get(u)?.string, MAX_REF_HOPS)), + [rows, refs], + ); + return ( + <> + {rows.map((row, i) => ( texts.get(u), MAX_REF_HOPS)} - onToggle={onToggle} + string={tasks.current(row.task)} + text={texts[i]} + toggle={tasks.toggle} /> - ); - })} - -); + ))} + + ); +}; /* The collapsed section. Its rows, and the refs inside them, are only read * and rendered once someone opens it. */ -const LazyRows = (props: Omit) => { - const [texts, setTexts] = React.useState | null>(null); - React.useEffect(() => { - let live = true; - readRefTexts(props.rows.map((r) => r.task.string)) - .then((t) => live && setTexts(t)) - .catch((error) => { - logError("could not read block references", error); - if (live) setTexts(new Map()); - }); - return () => { - live = false; - }; - }, [props.rows]); - if (!texts) return
…
; - return ; +const LazyRows = ({ rows, tasks }: Omit) => { + const refs = useAsync(() => readDisplayRefs(rows.map((r) => r.shown)), "read block references", [rows]); + if (refs.kind === "loading") return
…
; + return ; }; /* ── the widget ────────────────────────────────────────────────────────── */ -type State = { status: "loading" } | { status: "done"; loaded: Loaded } | { status: "failed" }; - export const MeetingTasks = ({ args }: { args: WidgetArgs }) => { - const [state, setState] = React.useState({ status: "loading" }); + const state = useAsync(() => loadWidget(args), "load", [args]); const [open, setOpen] = React.useState(false); - const [strings, announceWrite] = useWrittenStrings(); + const tasks = useTaskStrings(); - const argsKey = JSON.stringify(args); - React.useEffect(() => { - let live = true; - setState({ status: "loading" }); - loadWidget(args) - .then((loaded) => live && setState({ status: "done", loaded })) - .catch((error) => { - logError("could not load", error); - if (live) setState({ status: "failed" }); - }); - return () => { - live = false; - }; - // argsKey stands in for args, which is a fresh object on every render. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [argsKey]); - - const onToggle = React.useCallback( - (row: Row, done: boolean) => { - const uid = row.task.uid; - const before = strings[uid] ?? row.task.string; - const next = withDone(before, done); - if (next === before) return; - announceWrite({ uid, string: next }); - writeString(uid, next).catch((error) => { - logError("could not save the checkbox", error); - announceWrite({ uid, string: before }); - }); - }, - [strings, announceWrite], - ); - - if (state.status === "loading") return
Loading tasks…
; - if (state.status === "failed") + if (state.kind === "loading") return
Loading tasks…
; + if (state.kind === "failed") return args.debug ?
meeting-tasks: failed to load (see console)
: null; - const { loaded } = state; + const loaded = state.value; if (loaded.kind === "empty") return args.debug ? (
@@ -186,9 +181,10 @@ export const MeetingTasks = ({ args }: { args: WidgetArgs }) => {
) : null; - const { view, texts } = loaded; - const current = (r: Row) => strings[r.task.uid] ?? r.task.string; - const openCount = view.primary.concat(view.secondary).filter((r) => !isDone(current(r))).length; + const { view, refs } = loaded; + const openCount = view.primary + .concat(view.secondary) + .filter((r) => !isDone(tasks.current(r.task))).length; return (
@@ -197,9 +193,19 @@ export const MeetingTasks = ({ args }: { args: WidgetArgs }) => {
{view.primary.length > 0 && ( <> -
{view.primaryLabel}
+
+ {view.primaryLabel} + {view.lastMeeting && ( + <> + {" · "} + + {view.lastMeeting.title} + + + )} +
- +
)} @@ -212,14 +218,14 @@ export const MeetingTasks = ({ args }: { args: WidgetArgs }) => { {open && (
- +
)} )} {view.olderCount > 0 && (
- window {view.lookbackDays}d · {view.olderCount}{" "} + window {args.lookbackDays}d · {view.olderCount}{" "} {view.olderCount === 1 ? "older meeting" : "older meetings"} not shown
)} diff --git a/prototypes/roam-meeting-tasks/tests/fixtures.ts b/prototypes/roam-meeting-tasks/tests/fixtures.ts index 0782ed1..417b94a 100644 --- a/prototypes/roam-meeting-tasks/tests/fixtures.ts +++ b/prototypes/roam-meeting-tasks/tests/fixtures.ts @@ -18,8 +18,9 @@ * uses, and counts calls so tests can assert that reads are shared. */ import { vi } from "vitest"; +import { DAY_MS } from "~/config"; +import { hasMarker, isDone } from "~/text"; -export const DAY = 86400000; export const PAGE = "PAGE1xxxx"; export const OTHER_PAGE = "PAGE2xxxx"; @@ -35,8 +36,8 @@ type FakeBlock = { // Real Roam uids are 9 characters; the ref regex requires 6 or more. export const U = (n: string): string => (n + "xxxxxxxxx").slice(0, 9); -const dnpUid = (daysAgo: number, now: number): string => { - const d = new Date(now - daysAgo * DAY); +export const dnpUid = (daysAgo: number, now: number): string => { + const d = new Date(now - daysAgo * DAY_MS); const p2 = (x: number) => String(x).padStart(2, "0"); return `${p2(d.getMonth() + 1)}-${p2(d.getDate())}-${d.getFullYear()}`; }; @@ -56,7 +57,7 @@ export const buildGraph = (now = Date.now()) => { string, children: children.map(U), page: opts.page ?? PAGE, - time: now - (opts.ageDays ?? 0) * DAY, + time: now - (opts.ageDays ?? 0) * DAY_MS, dates: opts.dates ?? [], }); }; @@ -104,13 +105,11 @@ export const buildGraph = (now = Date.now()) => { add("prose", "PRs including UX work: aiming for [[a date]]", [], { dates: [PROSE] }); add("inbox", "{{[[TODO]]}} page-level inbox task, under no meeting", [], { ageDays: 7 }); - return { blocks, add, now, dates: { M1, M2, M3, M4 } }; + return { blocks, add, now }; }; /* ── the fake API ────────────────────────────────────────────────────────── */ -const MARKED = /\{\{\[\[(TODO|DONE)\]\]\}\}|\{\{(TODO|DONE)\}\}/; - export const installFakeRoam = (graph: FakeGraph) => { const { blocks } = graph; const parentOf = () => { @@ -129,21 +128,38 @@ export const installFakeRoam = (graph: FakeGraph) => { if (b.string.includes("#.sticky")) out.push({ ":block/uid": "stickyxxx" }); return out; }; - const markerTitle = (s: string): string | null => { - const m = MARKED.exec(s); - return m ? (m[1] ?? m[2]) : null; + const markerTitle = (s: string): string | null => (isDone(s) ? "DONE" : hasMarker(s) ? "TODO" : null); + // Entity ids, handed out on first sight. Pages get them too. + const eids = new Map(); + const eidOf = (uid: string): number => { + if (!eids.has(uid)) eids.set(uid, eids.size + 1); + return eids.get(uid)!; }; + const uidOfEid = (eid: number) => [...eids].find(([, e]) => e === eid)?.[0]; - const view = (b: FakeBlock, pattern: string): Record => { + const view = (b: FakeBlock, pattern: string, depth?: number): Record => { const out: Record = { ":block/uid": b.uid }; + if (pattern.includes(":db/id")) out[":db/id"] = eidOf(b.uid); + if (/:block\/page[\s\]]/.test(pattern)) out[":block/page"] = { ":db/id": eidOf(b.page) }; if (pattern.includes(":block/string")) out[":block/string"] = b.string; if (pattern.includes(":edit/time")) out[":edit/time"] = b.time; if (pattern.includes("{:block/page")) out[":block/page"] = { ":block/uid": b.page }; if (pattern.includes("{:block/parents")) out[":block/parents"] = ancestors(b.uid).map((u) => ({ ":block/uid": u })); if (pattern.includes("{:block/refs")) out[":block/refs"] = refsOf(b); - if (pattern.includes("{:block/children ...}") && b.children.length) - out[":block/children"] = b.children.map((c) => view(blocks.get(c)!, pattern)); + if (pattern.includes(":block/order")) { + const parent = parentOf().get(b.uid); + out[":block/order"] = parent ? blocks.get(parent)!.children.indexOf(b.uid) : 0; + } + // Reversed on purpose: Roam returns children in no particular order, so + // the code must sort them by :block/order itself. + // Recursion: `...` for all levels, or a number of levels. + const levels = depth ?? /\{:block\/children (\.\.\.|\d+)\}/.exec(pattern)?.[1]; + const left = levels === "..." || levels === undefined ? levels : Number(levels); + if (left !== undefined && left !== 0 && b.children.length) + out[":block/children"] = [...b.children] + .reverse() + .map((c) => view(blocks.get(c)!, pattern, left === "..." ? undefined : (left as number) - 1)); return out; }; @@ -155,6 +171,7 @@ export const installFakeRoam = (graph: FakeGraph) => { const pull = vi.fn(async (pattern: string, eid: [string, string]) => { const [attr, value] = eid; + if (pattern === "[:db/id]") return { ":db/id": eidOf(value) }; if (attr === ":node/title") { if (!pattern.includes("(:block/_refs :limit nil)")) throw new Error(`fake pull: unexpected title pattern ${pattern}`); @@ -164,9 +181,10 @@ export const installFakeRoam = (graph: FakeGraph) => { const b = blocks.get(value); return b ? view(b, pattern) : null; }); - const pull_many = vi.fn(async (pattern: string, eids: [string, string][]) => - eids.map(([, uid]) => { - const b = blocks.get(uid); + // Takes lookup refs ([":block/uid", uid]) or entity ids. + const pull_many = vi.fn(async (pattern: string, ids: ([string, string] | number)[]) => + ids.map((id) => { + const b = blocks.get(typeof id === "number" ? (uidOfEid(id) ?? "") : id[1]); return b ? view(b, pattern) : null; }), ); diff --git a/prototypes/roam-meeting-tasks/tests/text.spec.ts b/prototypes/roam-meeting-tasks/tests/text.spec.ts index 0294c1b..c28fa38 100644 --- a/prototypes/roam-meeting-tasks/tests/text.spec.ts +++ b/prototypes/roam-meeting-tasks/tests/text.spec.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vitest"; import { parseArgs } from "~/args"; -import { dailyNoteTime, displayText, headerKind, withDone } from "~/text"; +import { dailyNoteTime, displayText, headerKind, meetingTitle, withDone } from "~/text"; describe("headerKind", () => { - const ANCHOR = "yuAIplpov"; + const ANCHOR = ["yuAIplpov"]; it.each([ "next actions", "**next actions**", @@ -16,6 +16,9 @@ describe("headerKind", () => { "## next actions", "next actions by Friday", "next actions this week", + "Proposed next step", + "**Proposed next steps** [ℹ️](((6-tIoP1wk)))", + "next actions [ℹ](((abcdefghi)))", ])("treats %j as a header", (s) => { expect(headerKind(s, ANCHOR)).toBe("wording"); }); @@ -34,6 +37,17 @@ describe("headerKind", () => { }); }); +describe("meetingTitle", () => { + it("keeps only the date text", () => { + expect(meetingTitle("[[September 22nd, 2026]] #.sticky")).toBe("September 22nd, 2026"); + expect( + meetingTitle( + "### [[September 24th, 2026]] {{Add my entry:SmartBlock:Add meeting entry:RemoveButton=false}} [ℹ]([[Tutorial/Group meetings]])#.sticky ", + ), + ).toBe("September 24th, 2026"); + }); +}); + describe("withDone", () => { it("flips both marker spellings, both ways", () => { expect(withDone("{{[[TODO]]}} a", true)).toBe("{{[[DONE]]}} a"); @@ -63,6 +77,18 @@ describe("displayText", () => { expect(displayText("((loopxxxxx))", lookup, 4)).toContain("again"); }); + it("shows a labelled block reference as its label, and drops tooltips", () => { + expect(displayText("{{[[TODO]]}} see [the plan](((bbbbbbbbb))) [ℹ️](((aaaaaaaaa)))", lookup, 4)).toBe( + "see the plan", + ); + }); + + it("drops components such as buttons and videos, and unwraps mailto links", () => { + expect( + displayText("ask {{Reply:SmartBlock:Reply}} [Sid](mailto:sid@example.org) {{[[video]]: https://x.y}}", lookup, 4), + ).toBe("ask Sid"); + }); + it("removes embeds, images, and alias targets", () => { expect( displayText("{{[[TODO]]}} read [the doc](https://x.y) ![](https://img) {{[[embed]]: ((aaaaaaaaa))}}", lookup, 4), @@ -79,12 +105,13 @@ describe("dailyNoteTime", () => { describe("parseArgs", () => { it("finds the host block wherever Roam puts it, and reads flags and the window", () => { - expect(parseArgs(["debug", { "block-uid": "abc" }, "365", "Meeting"])).toEqual({ + expect(parseArgs(["debug", { "block-uid": "abc" }, "365", "Page"])).toEqual({ hostUid: "abc", lookbackDays: 365, debug: true, - forcedMode: "meeting", + forcePage: true, }); + expect(parseArgs(["page", "meeting"]).forcePage).toBe(false); expect(parseArgs([]).lookbackDays).toBe(120); expect(parseArgs([0]).lookbackDays).toBe(120); }); diff --git a/prototypes/roam-meeting-tasks/tests/widget.spec.tsx b/prototypes/roam-meeting-tasks/tests/widget.spec.tsx index 2e9acf2..fab44f7 100644 --- a/prototypes/roam-meeting-tasks/tests/widget.spec.tsx +++ b/prototypes/roam-meeting-tasks/tests/widget.spec.tsx @@ -7,7 +7,16 @@ import { act } from "react-dom/test-utils"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { mount, unmountAll } from "~/mount"; import { forgetReads } from "~/roam"; -import { buildGraph, installFakeRoam, OTHER_PAGE, PAGE, U, type FakeApi, type FakeGraph } from "./fixtures"; +import { + buildGraph, + dnpUid, + installFakeRoam, + OTHER_PAGE, + PAGE, + U, + type FakeApi, + type FakeGraph, +} from "./fixtures"; let graph: FakeGraph; let api: FakeApi; @@ -48,6 +57,14 @@ const openSecondary = async (el: HTMLElement) => { const host = (uid = "host") => ({ "block-uid": U(uid) }); +const primaryRow = (el: Element, text: RegExp): Element => + [...el.querySelectorAll('[data-section="primary"] .rmt-row')].find((r) => text.test(r.textContent ?? ""))!; + +const clickBox = (row: Element) => + act(async () => { + (row.querySelector("input") as HTMLInputElement).click(); + }); + describe("page mode", () => { it("finds every carried-over next action, across header wordings and wrappers", async () => { const el = await render([host(), "debug"]); @@ -138,29 +155,55 @@ describe("meeting mode (mounted inside the newest meeting)", () => { return render([host("hostInM1")]); }; - it("headlines the previous meeting's next actions", async () => { + it("headlines the previous meeting's next actions, in outline order", async () => { const el = await mountInM1(); - expect(texts(el, "primary")).toEqual(["MG to take a look at backlog count over time"]); + // The Trang Doan task is also carried into this meeting's next actions. + // It still belongs under "From last meeting": the old component let the + // host meeting claim it, and it dropped out. + expect(texts(el, "primary")).toEqual([ + "MG to take a look at backlog count over time", + "#Trang Doan send team the next-step items", + ]); }); - it("labels the section with the meeting's date text, without brackets", async () => { + it("labels the section with a link to that meeting, without brackets", async () => { const el = await mountInM1(); const headings = [...el.querySelectorAll(".rmt-heading")].map((h) => h.textContent); expect(headings[0]).toBe("From last meeting · Meeting two"); + expect(el.querySelector(".rmt-meeting")?.getAttribute("href")).toMatch(new RegExp(`/page/${U("m2")}$`)); + }); + + it("shows page mode instead when given the page flag", async () => { + graph.add("hostInM1", "{{roam/render: ((CODE)) page}}"); + graph.blocks.get(U("m1"))!.children.unshift(U("hostInM1")); + const el = await render([host("hostInM1"), "page"]); + expect(el.querySelector(".rmt-heading")?.textContent).toBe("Carried over from past next actions"); + }); + + it("skips a later meeting whose next actions are empty", async () => { + graph.add("m15", "[[Meeting one and a half]] #.sticky", ["m15na"], { dates: [dnpUid(10, graph.now)] }); + graph.add("m15na", "next actions", ["m15note"]); + graph.add("m15note", "just a note, not a task"); + const el = await mountInM1(); + expect(el.querySelector(".rmt-heading")?.textContent).toBe("From last meeting · Meeting two"); + }); + + it("does not list this meeting's own tasks as older open items", async () => { + const el = await mountInM1(); + await openSecondary(el); + const secondary = texts(el, "secondary").join(" | "); + expect(secondary).not.toMatch(/buried task under discussion/); // under m1, the host + expect(secondary).toMatch(/not under a next-actions header/); // under m2 }); }); describe("write-back", () => { it("writes {{[[DONE]]}} to the real task block, never to a ((ref)) wrapper", async () => { const el = await render([host()]); - const row = [...el.querySelectorAll('[data-section="primary"] .rmt-row')].find((r) => - /Trang Doan/.test(r.textContent ?? ""), - )!; + const row = primaryRow(el, /Trang Doan/); const box = row.querySelector("input") as HTMLInputElement; expect(box.checked).toBe(false); - await act(async () => { - box.click(); - }); + await clickBox(row); expect(api.data.block.update).toHaveBeenCalledTimes(1); const { block } = api.data.block.update.mock.calls[0][0]; expect(block.uid).toBe(U("t1")); @@ -172,12 +215,8 @@ describe("write-back", () => { it("writes through a wrapper to an off-page target", async () => { const el = await render([host()]); - const row = [...el.querySelectorAll('[data-section="primary"] .rmt-row')].find((r) => - /Sid: create documentation/.test(r.textContent ?? ""), - )!; - await act(async () => { - (row.querySelector("input") as HTMLInputElement).click(); - }); + const row = primaryRow(el, /Sid: create documentation/); + await clickBox(row); const { block } = api.data.block.update.mock.calls[0][0]; expect(block.uid).toBe(U("t3off")); expect(block.string).toBe("next: {{[[TODO]]}} Sid: create documentation"); @@ -200,14 +239,8 @@ describe("write-back", () => { it("updates every widget showing the same task", async () => { const a = await render([host()]); const b = await render([host()]); - const rowIn = (el: HTMLElement) => - [...el.querySelectorAll('[data-section="primary"] .rmt-row')].find((r) => - /Trang Doan/.test(r.textContent ?? ""), - )!; - await act(async () => { - (rowIn(a).querySelector("input") as HTMLInputElement).click(); - }); - expect((rowIn(b).querySelector("input") as HTMLInputElement).checked).toBe(true); + await clickBox(primaryRow(a, /Trang Doan/)); + expect((primaryRow(b, /Trang Doan/).querySelector("input") as HTMLInputElement).checked).toBe(true); }); }); @@ -234,6 +267,31 @@ describe("shift-click", () => { }); }); +describe("ordering and wrappers", () => { + it("lists a meeting's next actions in outline order, not by edit time", async () => { + const el = await render([host()]); + expect(texts(el, "primary")).toEqual([ + "#Trang Doan send team the next-step items", + "#Karola Kirsanow track OKR 1 items", + "next: Sid: create documentation", + "MG to take a look at backlog count over time", + "old open item from three meetings ago", + ]); + }); + + it("shows a wrapper's own note, and still writes to the task it wraps", async () => { + graph.add("annot", `((${U("t4")})) --> by Friday`); + graph.blocks.get(U("m1na"))!.children.push(U("annot")); + const el = await render([host()]); + const row = primaryRow(el, /by Friday/); + expect(row.querySelector(".rmt-text")?.textContent).toBe( + "MG to take a look at backlog count over time --> by Friday", + ); + await clickBox(row); + expect(api.data.block.update.mock.calls[0][0].block.uid).toBe(U("t4")); + }); +}); + describe("what changed in the port", () => { it("renders nothing, not an error, on a page with no meetings", async () => { window.location.hash = "#/app/discourse-graphs/page/EMPTYxxxx"; From d36469848a2eacf9c4c68f01a82bac211273d61b Mon Sep 17 00:00:00 2001 From: mattakamatsu Date: Tue, 29 Sep 2026 00:45:05 -0700 Subject: [PATCH 3/3] Update the README's performance and comparison notes for 0.2.0 Co-Authored-By: Claude Fable 5.1 --- prototypes/roam-meeting-tasks/README.md | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/prototypes/roam-meeting-tasks/README.md b/prototypes/roam-meeting-tasks/README.md index 2f37e03..fdcad04 100644 --- a/prototypes/roam-meeting-tasks/README.md +++ b/prototypes/roam-meeting-tasks/README.md @@ -129,20 +129,23 @@ Measured on `dg-team`'s `Sync / All Hands` (26,728 blocks, 120 meetings, 18 in t | | roam/render component | this extension | | --- | --- | --- | | when reads run | during Roam's render, synchronously | after the widget has rendered | -| cost per widget | 1.6 to 2.0 s (9 queries) | first widget on the page about 390 ms; the others reuse its reads (24 ms for a third widget) | +| cost per widget | 1.6 to 2.0 s (9 queries) | 90 to 320 ms for the first widget on a page; widgets that reuse its reads, 2 to 3 ms | | collapsed section | built on every render | read and rendered only when opened | | block references in item text | one query per reference | one batched read, only for visible rows | Most of the old cost came from four Datalog queries that bind a variable from a list, such as `:in $ [?mu ...]` followed by `[?m :block/uid ?mu]`. DataScript answers those by scanning the whole attribute rather than looking each value up, so each took 230 to 360 ms however few rows it -returned. They are now `pull_many` calls (about 70 ms together). The page-wide scans for meetings -and for TODO/DONE blocks start from the `.sticky`, `TODO`, and `DONE` pages' reverse references -instead, which are direct index reads. - -Output was checked against the old component on all 20 widgets on the three dg-team sync pages -(`All Hands`, `Roam Product`, `Protocol Product`): the same items in both sections, the same counts, -and the same footers. +returned. They are now `pull_many` calls. The page-wide scans for meetings and for TODO/DONE blocks +start from the `.sticky`, `TODO`, and `DONE` pages' reverse references instead, which are index +reads. Those references are compared by entity id first, so only the blocks on this page are pulled +in full. Meetings are read two levels deep, and only the next-actions headers in full. + +Output was compared with the old component on all 20 widgets on the three dg-team sync pages +(`All Hands`, `Roam Product`, `Protocol Product`). Version 0.1.0 matched on every widget. In 0.2.0, +4 widgets match and the rest differ only by the two meeting-mode fixes listed below: 15 no longer +list the host meeting's or newer meetings' tasks as "older open items", and 1 now shows "From last +meeting" where the old component showed nothing. ## Changes from the roam/render component