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..020d604 --- /dev/null +++ b/prototypes/roam-meeting-tasks/CHANGELOG.md @@ -0,0 +1,35 @@ +# 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. + +- 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..fdcad04 --- /dev/null +++ b/prototypes/roam-meeting-tasks/README.md @@ -0,0 +1,183 @@ +# 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 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. 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 + 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` | 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}}`. + +## 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 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 + +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) | 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. 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 + +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`), 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. +- 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..35bf6c9 --- /dev/null +++ b/prototypes/roam-meeting-tasks/package.json @@ -0,0 +1,28 @@ +{ + "name": "roam-meeting-tasks", + "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", + "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..abbeb0d --- /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)) page debug}} page mode inside a meeting, diagnostics + */ +import { LOOKBACK_DAYS } from "~/config"; + +export type WidgetArgs = { + hostUid: string | null; + lookbackDays: number; + debug: boolean; + /* 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 + * 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"), + 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 new file mode 100644 index 0000000..bde1b7e --- /dev/null +++ b/prototypes/roam-meeting-tasks/src/config.ts @@ -0,0 +1,52 @@ +/* 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"; + +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 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; + +// 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..bcbc695 --- /dev/null +++ b/prototypes/roam-meeting-tasks/src/index.ts @@ -0,0 +1,65 @@ +/* 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 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. + * + * 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 { + requireCapabilities(); + 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..da3b014 --- /dev/null +++ b/prototypes/roam-meeting-tasks/src/lifecycle.ts @@ -0,0 +1,45 @@ +/* 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, VERSION } from "~/config"; +import { mount, mountedCount, release, unmountAll } from "~/mount"; +import { forgetReads } from "~/roam"; +import { SHIM_BLOCK } from "~/shim"; +import { addStyles, removeStyles } from "~/styles"; + +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 }; + +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); +}; + +export const unload = (): void => { + 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..af0bd75 --- /dev/null +++ b/prototypes/roam-meeting-tasks/src/load.ts @@ -0,0 +1,95 @@ +/* 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_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 { displayRefUids, wrapperRef } from "~/text"; + +export type Loaded = + | { 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; + +/* 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[], + 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(refsOf))].filter((u) => !found.has(u)); + if (!want.length) break; + const got = await readBlocks(want); + if (!got.size) break; + for (const [uid, b] of got) found.set(uid, b); + frontier = [...got.values()].map((b) => b.string); + } + 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 => { + // 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 meetings = selectMeetings(await readMeetingRows(pageUid)); + if (!meetings.length) return empty("no meetings found"); + + const plan = planWindow({ + meetings, + hostAncestorUids: host?.ancestorUids ?? [], + forcePage: args.forcePage, + lookbackDays: args.lookbackDays, + now, + }); + if (!plan.scan.length) return empty(`no meetings within ${args.lookbackDays} days`); + + // 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, + headers, + pageTasks, + lookupBlock: (uid) => wrapped.get(uid), + 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 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 new file mode 100644 index 0000000..d374af4 --- /dev/null +++ b/prototypes/roam-meeting-tasks/src/model.ts @@ -0,0 +1,234 @@ +/* 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 { 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[] }; // 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 +/* `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. (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) { + 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 })); +}; + +/* ── mode and window ───────────────────────────────────────────────────── */ + +export type Plan = { + mode: Mode; + /* 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; +}; + +/* 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[]; + forcePage: boolean; + lookbackDays: number; + now: number; +}): Plan => { + const byUid = new Map(input.meetings.map((m) => [m.uid, m])); + 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: host ? "meeting" : "page", + scan, + olderCount: input.meetings.filter((m) => m.time < windowStart).length, + lookbackDays: input.lookbackDays, + }; +}; + +/* ── headers and the tasks under them ──────────────────────────────────── */ + +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[], 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, anchorUids); + if (!kind) return; + seen.add(node.uid); + 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); + return out; +}; + +const descendants = (node: TreeNode): TreeNode[] => + node.children.flatMap((c) => [c, ...descendants(c)]); + +/* 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. + * 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 = wrapperRef(cur.string); + cur = next ? lookup(next) : undefined; + } + return null; +}; + +/* ── the view ──────────────────────────────────────────────────────────── */ + +export type View = { + 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; + debug: string; +}; + +/* 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; + headers: readonly Header[]; + pageTasks: readonly PageTask[]; + lookupBlock: (uid: string) => Block | undefined; + now: number; +}): View => { + const { meetings, plan, headers } = input; + const meetingByUid = new Map(meetings.map((m) => [m.uid, m])); + 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(); + headerItems(headers).forEach((item, order) => { + const task = resolveTask(item.block, input.lookupBlock); + const meeting = meetingByUid.get(item.meetingUid); + if (!task || !meeting) return; + const prev = claimed.get(task.uid); + 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) => ({ 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 || inScan.has(r.meeting.uid)); + + 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 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 { + 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", + }; + } + view.primary.sort(byMeetingThenOutline); + view.secondary.sort(byMeetingThenOutline); + + const anchored = headers.filter((h) => h.viaAnchor).length; + return { + ...view, + lastMeeting: lastMeeting && { uid: lastMeeting.uid, title: meetingTitle(lastMeeting.string) }, + olderCount: plan.olderCount, + 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 new file mode 100644 index 0000000..0523f1c --- /dev/null +++ b/prototypes/roam-meeting-tasks/src/mount.ts @@ -0,0 +1,85 @@ +/* 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, SWEEP_MS } 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 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 { + mounted.get(el)?.root.unmount(); + } catch (error) { + logError("could not unmount a widget", error); + } + 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 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 : []); + 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 + * 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); +}; + +export const unmountAll = (): void => { + for (const el of [...mounted.keys()]) unmountHost(el); +}; + +export const mountedCount = (): number => mounted.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..98d0100 --- /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"; +import { isSticky } from "~/text"; + +type Pulled = Record; + +// 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: { + 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 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 }; +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; +}; + +/* 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(); + put(key, value); + 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 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); + for (const u of missing) { + const value = batch.then((m) => m.get(u)); + put(prefix + u, value); + entries.set(u, value); + } + } + const out = new Map(); + for (const [u, value] of entries) { + const v = (await 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 = uidOf(b?.[":block/page"]); + if (!pageUid) return null; + 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 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 tagged = await refsOnPage( + ".sticky", + pageUid, + "[:block/uid :block/string {:block/refs [:block/uid :log/id]}]", + ); + 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: isSticky(str(string)), + })); + }); + +// Pulled children come back in no particular order; :block/order is the +// outline position. +const toTree = (b: Pulled): TreeNode => ({ + ...toBlock(b), + children: list(b[":block/children"]) + .sort((x, y) => num(x[":block/order"]) - num(y[":block/order"])) + .map(toTree), +}); + +/* 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.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. 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 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 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 pullMany("[:block/uid :block/string :edit/time]", missing.map(byUid)); + return new Map(pulled.map((b) => [uidOf(b), toBlock(b)])); + }); + +/* ── 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..5d50024 --- /dev/null +++ b/prototypes/roam-meeting-tasks/src/styles.ts @@ -0,0 +1,43 @@ +/* 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"; + +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 .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; } +`; + +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..10a9851 --- /dev/null +++ b/prototypes/roam-meeting-tasks/src/text.ts @@ -0,0 +1,111 @@ +/* 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 the anchors. +const HEADER_RE = + /^(?:(?: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 = 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 isSticky = (s: string): boolean => s.includes(".sticky"); + +/* 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); + +/* 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 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, + anchorUids: readonly string[], +): "anchor" | "wording" | null => { + if (anchorUids.some((uid) => s.includes(uid))) return "anchor"; + if (hasMarker(s)) return null; + const bare = s.replace(TOOLTIP_RE, "").replace(EMPHASIS_RE, "").replace(/^#+\s*/, "").trim(); + 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; +}; + +/* 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 = dropNonText(s); + for (let i = 0; i < maxHops; i++) { + const next = out.replace(REF_RE, (_whole, uid: string) => dropNonText(lookup(uid) ?? "")); + if (next === out) break; + out = next; + } + 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(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 new file mode 100644 index 0000000..e41b53d --- /dev/null +++ b/prototypes/roam-meeting-tasks/src/view.tsx @@ -0,0 +1,239 @@ +/* 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, readDisplayRefs } from "~/load"; +import type { Block, Row } from "~/model"; +import { graphName, openInSidebar, writeString } from "~/roam"; +import { displayText, isDone, withDone } from "~/text"; + +/* ── 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 })); +}; + +/* 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; + setStrings((prev) => ({ ...prev, [uid]: string })); + }; + window.addEventListener(WRITTEN, onWritten); + return () => window.removeEventListener(WRITTEN, onWritten); + }, []); + 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 ──────────────────────────────────────────────────────────────── */ + +const dateLabel = (time: number): string => + new Date(time).toLocaleDateString(undefined, { month: "short", day: "numeric" }); + +/* 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); + return ( +
+ toggle(row.task, e.target.checked)} + /> + {row.meeting ? ( + {dateLabel(row.meeting.time)} + ) : ( + + inbox + + )} + + {text} + +
+ ); +}); + +type RowsProps = { + rows: Row[]; + refs: Map; + tasks: ReturnType; +}; + +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) => ( + + ))} + + ); +}; + +/* The collapsed section. Its rows, and the refs inside them, are only read + * and rendered once someone opens it. */ +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 ────────────────────────────────────────────────────────── */ + +export const MeetingTasks = ({ args }: { args: WidgetArgs }) => { + const state = useAsync(() => loadWidget(args), "load", [args]); + const [open, setOpen] = React.useState(false); + const tasks = useTaskStrings(); + + 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.value; + if (loaded.kind === "empty") + return args.debug ? ( +
+ meeting-tasks: {loaded.reason} load={loaded.ms}ms +
+ ) : null; + + const { view, refs } = loaded; + const openCount = view.primary + .concat(view.secondary) + .filter((r) => !isDone(tasks.current(r.task))).length; + + return ( +
+
+ {openCount} {openCount === 1 ? "open item" : "open items"} +
+ {view.primary.length > 0 && ( + <> +
+ {view.primaryLabel} + {view.lastMeeting && ( + <> + {" · "} + + {view.lastMeeting.title} + + + )} +
+
+ +
+ + )} + {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 {args.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..417b94a --- /dev/null +++ b/prototypes/roam-meeting-tasks/tests/fixtures.ts @@ -0,0 +1,215 @@ +/* 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"; +import { DAY_MS } from "~/config"; +import { hasMarker, isDone } from "~/text"; + +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); + +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()}`; +}; + +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_MS, + 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 }; +}; + +/* ── the fake API ────────────────────────────────────────────────────────── */ + +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 => (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, 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/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; + }; + + 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 (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}`); + 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; + }); + // 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; + }), + ); + // 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..c28fa38 --- /dev/null +++ b/prototypes/roam-meeting-tasks/tests/text.spec.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; +import { parseArgs } from "~/args"; +import { dailyNoteTime, displayText, headerKind, meetingTitle, 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", + "Proposed next step", + "**Proposed next steps** [ℹ️](((6-tIoP1wk)))", + "next actions [ℹ](((abcdefghi)))", + ])("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("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"); + 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("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), + ).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", "Page"])).toEqual({ + hostUid: "abc", + lookbackDays: 365, + debug: true, + 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 new file mode 100644 index 0000000..fab44f7 --- /dev/null +++ b/prototypes/roam-meeting-tasks/tests/widget.spec.tsx @@ -0,0 +1,344 @@ +/* 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, + dnpUid, + 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) }); + +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"]); + 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, in outline order", async () => { + const el = await mountInM1(); + // 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 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 = primaryRow(el, /Trang Doan/); + const box = row.querySelector("input") as HTMLInputElement; + expect(box.checked).toBe(false); + 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")); + 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 = 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"); + 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()]); + await clickBox(primaryRow(a, /Trang Doan/)); + expect((primaryRow(b, /Trang Doan/).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("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"; + 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"], + }, +});