@taskmark/ui 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/app/globals.css +25 -0
- package/app/layout.tsx +15 -5
- package/app/setup/page.tsx +1 -1
- package/bin/lib/resolve-board.mjs +1 -4
- package/components/board/app-bar.tsx +3 -0
- package/components/board/board-screen.tsx +61 -117
- package/components/board/changelog-panel.tsx +26 -0
- package/components/board/epic-list.tsx +2 -0
- package/components/board/list-view-switcher.tsx +7 -3
- package/components/board/markdown-content.tsx +21 -1
- package/components/board/mermaid-diagram.tsx +88 -0
- package/components/board/metric-stat-card.tsx +4 -4
- package/components/board/overall-tree-list.tsx +467 -0
- package/components/board/overall-work-items-list.tsx +5 -1
- package/components/board/points-heatmap.tsx +183 -0
- package/components/board/project-status-metrics-strip.tsx +5 -22
- package/components/board/static-board-app.tsx +29 -94
- package/components/board/status-badge.tsx +8 -0
- package/components/board/story-list.tsx +2 -0
- package/components/board/task-list.tsx +5 -1
- package/components/board/work-item-detail-body.tsx +123 -8
- package/components/board/work-items-list.tsx +2 -0
- package/components/theme-provider.tsx +39 -0
- package/components/theme-toggle.tsx +27 -0
- package/components/ui/sonner.tsx +2 -2
- package/dist/prod-next/BUILD_ID +1 -1
- package/dist/prod-next/build-manifest.json +6 -6
- package/dist/prod-next/react-loadable-manifest.json +391 -1
- package/dist/prod-next/server/app/_global-error/page.js +2 -2
- package/dist/prod-next/server/app/_global-error/page.js.nft.json +1 -1
- package/dist/prod-next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/dist/prod-next/server/app/_global-error.html +1 -1
- package/dist/prod-next/server/app/_global-error.rsc +1 -1
- package/dist/prod-next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/dist/prod-next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/dist/prod-next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/dist/prod-next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/dist/prod-next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/dist/prod-next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/dist/prod-next/server/app/_not-found/page.js +2 -2
- package/dist/prod-next/server/app/_not-found/page.js.nft.json +1 -1
- package/dist/prod-next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/dist/prod-next/server/app/_not-found.html +1 -1
- package/dist/prod-next/server/app/_not-found.rsc +19 -17
- package/dist/prod-next/server/app/_not-found.segments/_full.segment.rsc +19 -17
- package/dist/prod-next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/dist/prod-next/server/app/_not-found.segments/_index.segment.rsc +8 -6
- package/dist/prod-next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/dist/prod-next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/dist/prod-next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/dist/prod-next/server/app/api/taskmark-reload/route.js +1 -1
- package/dist/prod-next/server/app/api/taskmark-reload/route.js.nft.json +1 -1
- package/dist/prod-next/server/app/board/page.js +2 -2
- package/dist/prod-next/server/app/board/page.js.nft.json +1 -1
- package/dist/prod-next/server/app/board/page_client-reference-manifest.js +1 -1
- package/dist/prod-next/server/app/favicon.ico/route.js +1 -1
- package/dist/prod-next/server/app/favicon.ico/route.js.nft.json +1 -1
- package/dist/prod-next/server/app/page.js +2 -2
- package/dist/prod-next/server/app/page.js.nft.json +1 -1
- package/dist/prod-next/server/app/page_client-reference-manifest.js +1 -1
- package/dist/prod-next/server/app/setup/page.js +2 -2
- package/dist/prod-next/server/app/setup/page.js.nft.json +1 -1
- package/dist/prod-next/server/app/setup/page_client-reference-manifest.js +1 -1
- package/dist/prod-next/server/chunks/102.js +90 -0
- package/dist/prod-next/server/chunks/11.js +1 -0
- package/dist/prod-next/server/chunks/1286.js +1 -0
- package/dist/prod-next/server/chunks/1412.js +38 -0
- package/dist/prod-next/server/chunks/1605.js +1 -0
- package/dist/prod-next/server/chunks/169.js +1 -0
- package/dist/prod-next/server/chunks/18.js +86 -0
- package/dist/prod-next/server/chunks/2.js +1 -0
- package/dist/prod-next/server/chunks/2022.js +1 -0
- package/dist/prod-next/server/chunks/2156.js +1 -0
- package/dist/prod-next/server/chunks/2157.js +1 -0
- package/dist/prod-next/server/chunks/2325.js +82 -0
- package/dist/prod-next/server/chunks/2838.js +1 -0
- package/dist/prod-next/server/chunks/2897.js +79 -0
- package/dist/prod-next/server/chunks/3227.js +1 -0
- package/dist/prod-next/server/chunks/3266.js +1 -0
- package/dist/prod-next/server/chunks/{192.js → 3307.js} +5 -5
- package/dist/prod-next/server/chunks/3387.js +19 -0
- package/dist/prod-next/server/chunks/3428.js +32 -0
- package/dist/prod-next/server/chunks/3479.js +1 -0
- package/dist/prod-next/server/chunks/367.js +115 -0
- package/dist/prod-next/server/chunks/3698.js +93 -0
- package/dist/prod-next/server/chunks/3709.js +148 -0
- package/dist/prod-next/server/chunks/41.js +1 -0
- package/dist/prod-next/server/chunks/4107.js +62 -0
- package/dist/prod-next/server/chunks/4569.js +34 -0
- package/dist/prod-next/server/chunks/{741.js → 4741.js} +1 -1
- package/dist/prod-next/server/chunks/4755.js +153 -0
- package/dist/prod-next/server/chunks/5002.js +1 -0
- package/dist/prod-next/server/chunks/5112.js +1 -0
- package/dist/prod-next/server/chunks/5289.js +8 -0
- package/dist/prod-next/server/chunks/5318.js +1 -0
- package/dist/prod-next/server/chunks/5452.js +23 -0
- package/dist/prod-next/server/chunks/5453.js +1 -0
- package/dist/prod-next/server/chunks/5749.js +27 -0
- package/dist/prod-next/server/chunks/5782.js +36 -0
- package/dist/prod-next/server/chunks/6163.js +104 -0
- package/dist/prod-next/server/chunks/6169.js +64 -0
- package/dist/prod-next/server/chunks/6208.js +172 -0
- package/dist/prod-next/server/chunks/6399.js +24 -0
- package/dist/prod-next/server/chunks/6496.js +1 -0
- package/dist/prod-next/server/chunks/6823.js +215 -0
- package/dist/prod-next/server/chunks/6888.js +1 -0
- package/dist/prod-next/server/chunks/6980.js +1 -0
- package/dist/prod-next/server/chunks/7245.js +77 -0
- package/dist/prod-next/server/chunks/7307.js +1 -0
- package/dist/prod-next/server/chunks/7372.js +287 -0
- package/dist/prod-next/server/chunks/7384.js +226 -0
- package/dist/prod-next/server/chunks/7516.js +1 -0
- package/dist/prod-next/server/chunks/7531.js +24 -0
- package/dist/prod-next/server/chunks/7591.js +1 -0
- package/dist/prod-next/server/chunks/7755.js +138 -0
- package/dist/prod-next/server/chunks/7916.js +1 -0
- package/dist/prod-next/server/chunks/8078.js +94 -0
- package/dist/prod-next/server/chunks/8253.js +41 -0
- package/dist/prod-next/server/chunks/8635.js +1 -0
- package/dist/prod-next/server/chunks/8688.js +1 -0
- package/dist/prod-next/server/chunks/8872.js +1 -0
- package/dist/prod-next/server/chunks/9006.js +1 -0
- package/dist/prod-next/server/chunks/9018.js +1 -0
- package/dist/prod-next/server/chunks/9047.js +1 -0
- package/dist/prod-next/server/chunks/9108.js +1 -0
- package/dist/prod-next/server/chunks/9280.js +256 -0
- package/dist/prod-next/server/chunks/9419.js +1 -0
- package/dist/prod-next/server/chunks/9430.js +1 -0
- package/dist/prod-next/server/chunks/9586.js +1 -0
- package/dist/prod-next/server/chunks/9775.js +1 -0
- package/dist/prod-next/server/chunks/992.js +1 -0
- package/dist/prod-next/server/chunks/9926.js +1 -0
- package/dist/prod-next/server/chunks/9984.js +8 -0
- package/dist/prod-next/server/middleware-build-manifest.js +1 -1
- package/dist/prod-next/server/middleware-react-loadable-manifest.js +1 -1
- package/dist/prod-next/server/pages/404.html +1 -1
- package/dist/prod-next/server/pages/500.html +1 -1
- package/dist/prod-next/server/server-reference-manifest.js +1 -1
- package/dist/prod-next/server/server-reference-manifest.json +1 -1
- package/dist/prod-next/server/webpack-runtime.js +1 -1
- package/dist/prod-next/static/chunks/1213.e5c6e70a79b5f7e6.js +24 -0
- package/dist/prod-next/static/chunks/1519.2f596e5f5af9ef57.js +62 -0
- package/dist/prod-next/static/chunks/1759-f5b8fb456f8fb420.js +1 -0
- package/dist/prod-next/static/chunks/1974.885c9a133b86d8a4.js +32 -0
- package/dist/prod-next/static/chunks/2280.6a7f540787704698.js +1 -0
- package/dist/prod-next/static/chunks/2521.7f745f50d30076e7.js +77 -0
- package/dist/prod-next/static/chunks/2585.4d6aa847c780c666.js +1 -0
- package/dist/prod-next/static/chunks/2589.1aad68c09021b1ac.js +1 -0
- package/dist/prod-next/static/chunks/2600.45ba17a0f756fc31.js +1 -0
- package/dist/prod-next/static/chunks/2619.9aac8983f30c7c8a.js +1 -0
- package/dist/prod-next/static/chunks/26d302b2.9f6383e42abdfe16.js +122 -0
- package/dist/prod-next/static/chunks/2768.305914f7900745b6.js +1 -0
- package/dist/prod-next/static/chunks/2931.c2acdbf72808b646.js +41 -0
- package/dist/prod-next/static/chunks/3012-838f69b72a0dc1b3.js +1 -0
- package/dist/prod-next/static/chunks/3016.406e568aa95ff867.js +1 -0
- package/dist/prod-next/static/chunks/3022.972047558a20553a.js +172 -0
- package/dist/prod-next/static/chunks/3064.3a2a1e7cd4fdca5f.js +1 -0
- package/dist/prod-next/static/chunks/3174.03fbed7b7d7a8256.js +1 -0
- package/dist/prod-next/static/chunks/3189.b048866768087750.js +1 -0
- package/dist/prod-next/static/chunks/3217.1a053843c58b70b5.js +24 -0
- package/dist/prod-next/static/chunks/3308.30b95d524f9c1daf.js +74 -0
- package/dist/prod-next/static/chunks/331.fc5d78e1e91daf43.js +1 -0
- package/dist/prod-next/static/chunks/3510.5d4d1c79533e080c.js +1 -0
- package/dist/prod-next/static/chunks/364.ce5400a9dd70b9dc.js +1 -0
- package/dist/prod-next/static/chunks/3661-1f08d73aaffb011d.js +1 -0
- package/dist/prod-next/static/chunks/370.57cfa27da8567918.js +1 -0
- package/dist/prod-next/static/chunks/3789.c418b69121761983.js +1 -0
- package/dist/prod-next/static/chunks/3794-c66b3d8325973459.js +32 -0
- package/dist/prod-next/static/chunks/3831.25ae62d10dccc27a.js +1 -0
- package/dist/prod-next/static/chunks/395.b41e9d76613d37b5.js +90 -0
- package/dist/prod-next/static/chunks/3a7aa434.93dbd20997cfd24f.js +82 -0
- package/dist/prod-next/static/chunks/4020.3cc19c463c49f969.js +23 -0
- package/dist/prod-next/static/chunks/4035.0acc428926d5c8dd.js +1 -0
- package/dist/prod-next/static/chunks/405.0902e4f6eb7bd9a2.js +215 -0
- package/dist/prod-next/static/chunks/4159.49ae537ff687bdc6.js +138 -0
- package/dist/prod-next/static/chunks/4208.384b381ca2757643.js +287 -0
- package/dist/prod-next/static/chunks/4263.ffa8d9bd8a0faaae.js +1 -0
- package/dist/prod-next/static/chunks/4561.d1a3aaca8bae3224.js +34 -0
- package/dist/prod-next/static/chunks/4592.d0f37bc216b9d988.js +93 -0
- package/dist/prod-next/static/chunks/4799.0dc7aa21bff2fd2a.js +1 -0
- package/dist/prod-next/static/chunks/4bd1b696-e356ca5ba0218e27.js +1 -0
- package/dist/prod-next/static/chunks/514.b804c51b26f5e126.js +1 -0
- package/dist/prod-next/static/chunks/5270.28592864d38e881c.js +1 -0
- package/dist/prod-next/static/chunks/52733464.3870f99cc446f9ff.js +1 -0
- package/dist/prod-next/static/chunks/5354.b559c717a607dc5c.js +1 -0
- package/dist/prod-next/static/chunks/5448.d3972d9fb464b54f.js +38 -0
- package/dist/prod-next/static/chunks/587.d52737d7288c11b4.js +1 -0
- package/dist/prod-next/static/chunks/5909.9e37f7b495b11995.js +115 -0
- package/dist/prod-next/static/chunks/5949.5504b3545fcc3d34.js +104 -0
- package/dist/prod-next/static/chunks/6322-4e97920c006f7933.js +1 -0
- package/dist/prod-next/static/chunks/6361.893d04ac34fa1b49.js +148 -0
- package/dist/prod-next/static/chunks/6774.8cd53427dbf32cac.js +86 -0
- package/dist/prod-next/static/chunks/6968.b80277c724dbc96e.js +1 -0
- package/dist/prod-next/static/chunks/7113.4b9e7ea870aa300a.js +8 -0
- package/dist/prod-next/static/chunks/7236.55a9728d35f5b8a6.js +1 -0
- package/dist/prod-next/static/chunks/7390.dde9b5bc2f6e24b8.js +1 -0
- package/dist/prod-next/static/chunks/7806.a903780fcfa3a30e.js +1 -0
- package/dist/prod-next/static/chunks/7997.be57e92e583fe635.js +1 -0
- package/dist/prod-next/static/chunks/8216.64a72d4d35ea7be0.js +1 -0
- package/dist/prod-next/static/chunks/830.5d5274d7ad1cb58e.js +1 -0
- package/dist/prod-next/static/chunks/8378.fa91de3406f554c9.js +226 -0
- package/dist/prod-next/static/chunks/8501.cd06aa1b0c7e1c6a.js +79 -0
- package/dist/prod-next/static/chunks/8663.6ec5792fb4952e45.js +27 -0
- package/dist/prod-next/static/chunks/872.96937e758ddce860.js +1 -0
- package/dist/prod-next/static/chunks/8803.75bde095fe3613f5.js +1 -0
- package/dist/prod-next/static/chunks/8863.72f5dbb820a0e4c3.js +1 -0
- package/dist/prod-next/static/chunks/90542734.f7a7b2972ef5a749.js +1 -0
- package/dist/prod-next/static/chunks/9379.5bc9c9343ec5a16c.js +64 -0
- package/dist/prod-next/static/chunks/9389.906fe7aa9cd6a170.js +1 -0
- package/dist/prod-next/static/chunks/9472.8818ee52cfa931a4.js +94 -0
- package/dist/prod-next/static/chunks/966.d6babbf8119162cc.js +1 -0
- package/dist/prod-next/static/chunks/9884.d4ac1a961b3366f1.js +36 -0
- package/dist/prod-next/static/chunks/9909.67cf9940b125243b.js +1 -0
- package/dist/prod-next/static/chunks/app/_global-error/page-8be5492b3b872a4c.js +1 -0
- package/dist/prod-next/static/chunks/app/_not-found/page-799e445a538b0024.js +1 -0
- package/dist/prod-next/static/chunks/app/api/taskmark-reload/route-8be5492b3b872a4c.js +1 -0
- package/dist/prod-next/static/chunks/app/board/page-5c181f45738daf1c.js +1 -0
- package/dist/prod-next/static/chunks/app/layout-21782e6d13a6f275.js +1 -0
- package/dist/prod-next/static/chunks/app/page-51bfe4356fe233e3.js +1 -0
- package/dist/prod-next/static/chunks/app/setup/page-3ccfba00c10f9b17.js +1 -0
- package/dist/prod-next/static/chunks/d3ac728e.7964f816a1ca64e5.js +1 -0
- package/dist/prod-next/static/chunks/e413bedd.3a12a8cb02e28fdf.js +153 -0
- package/dist/prod-next/static/chunks/ea233666.f00b64a7d99b4950.js +62 -0
- package/dist/prod-next/static/chunks/framework-711ef29bc66f648c.js +1 -0
- package/dist/prod-next/static/chunks/main-app-dd26d4f9d120646e.js +1 -0
- package/dist/prod-next/static/chunks/{main-bb33c46a9c25dee8.js → main-df4bcaed74f274eb.js} +2 -2
- package/dist/prod-next/static/chunks/next/dist/client/components/builtin/app-error-8be5492b3b872a4c.js +1 -0
- package/dist/prod-next/static/chunks/next/dist/client/components/builtin/forbidden-8be5492b3b872a4c.js +1 -0
- package/dist/prod-next/static/chunks/next/dist/client/components/builtin/global-error-45a0cf77e03361ed.js +1 -0
- package/dist/prod-next/static/chunks/next/dist/client/components/builtin/not-found-8be5492b3b872a4c.js +1 -0
- package/dist/prod-next/static/chunks/next/dist/client/components/builtin/unauthorized-8be5492b3b872a4c.js +1 -0
- package/dist/prod-next/static/chunks/webpack-713747aaa9716c4c.js +1 -0
- package/dist/prod-next/static/css/cb78d4b0062d9a3f.css +1 -0
- package/dist/public/taskmark-snapshot.json +1 -1
- package/lib/taskmark/board-index.ts +101 -0
- package/lib/taskmark/build-snapshot.ts +28 -7
- package/lib/taskmark/derive-parents.test.ts +23 -0
- package/lib/taskmark/derive-parents.ts +323 -0
- package/lib/taskmark/detail-load.ts +55 -7
- package/lib/taskmark/detail-types.ts +10 -2
- package/lib/taskmark/dev-reload-token.ts +1 -1
- package/lib/taskmark/discover.ts +4 -7
- package/lib/taskmark/epic-types.ts +2 -1
- package/lib/taskmark/flat-work-item-types.ts +1 -4
- package/lib/taskmark/identity.ts +3 -3
- package/lib/taskmark/item-types.ts +2 -1
- package/lib/taskmark/list-filters.test.ts +23 -0
- package/lib/taskmark/list-filters.ts +7 -2
- package/lib/taskmark/list-view-mode.test.ts +45 -0
- package/lib/taskmark/list-view-mode.ts +19 -8
- package/lib/taskmark/load-changelog.test.ts +32 -0
- package/lib/taskmark/load-changelog.ts +18 -0
- package/lib/taskmark/overall-tree.test.ts +196 -0
- package/lib/taskmark/overall-tree.ts +194 -0
- package/lib/taskmark/parse-detail.ts +5 -5
- package/lib/taskmark/parse-epics.ts +19 -16
- package/lib/taskmark/parse-flat-lists.ts +32 -28
- package/lib/taskmark/parse-items.ts +47 -9
- package/lib/taskmark/parse-stories.ts +18 -15
- package/lib/taskmark/points-calendar.test.ts +72 -0
- package/lib/taskmark/points-calendar.ts +86 -0
- package/lib/taskmark/project-metrics.ts +11 -7
- package/lib/taskmark/snapshot-types.ts +2 -0
- package/lib/taskmark/story-types.ts +2 -1
- package/lib/taskmark/table-sort.ts +4 -3
- package/lib/taskmark/timing.ts +29 -18
- package/lib/taskmark/validate.ts +1 -1
- package/lib/theme-cookie.test.ts +27 -0
- package/lib/theme-cookie.ts +43 -0
- package/package.json +3 -1
- package/public/taskmark-snapshot.json +1 -1
- package/dist/prod-next/server/chunks/463.js +0 -1
- package/dist/prod-next/server/chunks/54.js +0 -19
- package/dist/prod-next/server/chunks/57.js +0 -8
- package/dist/prod-next/server/chunks/724.js +0 -1
- package/dist/prod-next/static/chunks/4bd1b696-deb4a0a1da1923b0.js +0 -1
- package/dist/prod-next/static/chunks/613-6f794154d28bb75a.js +0 -1
- package/dist/prod-next/static/chunks/636-626c380f021b95c8.js +0 -1
- package/dist/prod-next/static/chunks/669-8103441c67b185da.js +0 -1
- package/dist/prod-next/static/chunks/701-df3ee5f1cf837b1a.js +0 -1
- package/dist/prod-next/static/chunks/794-25668765560cb197.js +0 -32
- package/dist/prod-next/static/chunks/909.932f2af3be7f1e89.js +0 -1
- package/dist/prod-next/static/chunks/app/_global-error/page-a1e3320be3da9be1.js +0 -1
- package/dist/prod-next/static/chunks/app/_not-found/page-0b3785af737e7968.js +0 -1
- package/dist/prod-next/static/chunks/app/api/taskmark-reload/route-a1e3320be3da9be1.js +0 -1
- package/dist/prod-next/static/chunks/app/board/page-636f60191db62f52.js +0 -1
- package/dist/prod-next/static/chunks/app/layout-856e8a282e69e7d0.js +0 -1
- package/dist/prod-next/static/chunks/app/page-562a33b50da7665b.js +0 -1
- package/dist/prod-next/static/chunks/app/setup/page-4cf527b758b1d51e.js +0 -1
- package/dist/prod-next/static/chunks/framework-b29f9083d3cda3b2.js +0 -1
- package/dist/prod-next/static/chunks/main-app-bfb36455d24b3e5a.js +0 -1
- package/dist/prod-next/static/chunks/next/dist/client/components/builtin/app-error-a1e3320be3da9be1.js +0 -1
- package/dist/prod-next/static/chunks/next/dist/client/components/builtin/forbidden-a1e3320be3da9be1.js +0 -1
- package/dist/prod-next/static/chunks/next/dist/client/components/builtin/global-error-dc53ab29e0491fe4.js +0 -1
- package/dist/prod-next/static/chunks/next/dist/client/components/builtin/not-found-a1e3320be3da9be1.js +0 -1
- package/dist/prod-next/static/chunks/next/dist/client/components/builtin/unauthorized-a1e3320be3da9be1.js +0 -1
- package/dist/prod-next/static/chunks/webpack-d33b8a6298c94a64.js +0 -1
- package/dist/prod-next/static/css/2e6ec4c80894e3c0.css +0 -1
- /package/dist/prod-next/static/{yyRnYAFQrJjF5_b5ZIeqh → EI1zQaa_kQ7DqUSE4o3Qo}/_buildManifest.js +0 -0
- /package/dist/prod-next/static/{yyRnYAFQrJjF5_b5ZIeqh → EI1zQaa_kQ7DqUSE4o3Qo}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { ChevronRightIcon } from "lucide-react"
|
|
5
|
+
|
|
6
|
+
import { AttributionAvatarGroup } from "@/components/board/attribution-avatars"
|
|
7
|
+
import { ChildProgressBar } from "@/components/board/child-progress-bar"
|
|
8
|
+
import {
|
|
9
|
+
IdCreatedTooltip,
|
|
10
|
+
SizeWithPointsTooltip,
|
|
11
|
+
StatusWithSolvedTooltip,
|
|
12
|
+
} from "@/components/board/date-tooltip"
|
|
13
|
+
import { ListFiltersBar } from "@/components/board/list-filters-bar"
|
|
14
|
+
import { ListPagination } from "@/components/board/list-pagination"
|
|
15
|
+
import {
|
|
16
|
+
statusRowClass,
|
|
17
|
+
TypeBadge,
|
|
18
|
+
} from "@/components/board/status-badge"
|
|
19
|
+
import { TimeframeFilter } from "@/components/board/timeframe-filter"
|
|
20
|
+
import { ViewWorkItemButton } from "@/components/board/work-item-sheet"
|
|
21
|
+
import {
|
|
22
|
+
Card,
|
|
23
|
+
CardAction,
|
|
24
|
+
CardContent,
|
|
25
|
+
CardHeader,
|
|
26
|
+
CardTitle,
|
|
27
|
+
} from "@/components/ui/card"
|
|
28
|
+
import { Button } from "@/components/ui/button"
|
|
29
|
+
import { usePaginatedRows } from "@/hooks/use-paginated-rows"
|
|
30
|
+
import { usePersistedHideCompleted } from "@/hooks/use-persisted-hide-completed"
|
|
31
|
+
import { displayFileName } from "@/lib/display-path"
|
|
32
|
+
import type { ProjectEpicList } from "@/lib/taskmark/epic-types"
|
|
33
|
+
import type { EpicWorkItemsList } from "@/lib/taskmark/flat-work-item-types"
|
|
34
|
+
import type { StoryItemList } from "@/lib/taskmark/item-types"
|
|
35
|
+
import {
|
|
36
|
+
DEFAULT_TIMEFRAME_FILTER,
|
|
37
|
+
type TimeframeFilterState,
|
|
38
|
+
} from "@/lib/taskmark/list-filters"
|
|
39
|
+
import {
|
|
40
|
+
buildOverallTree,
|
|
41
|
+
filterOverallTree,
|
|
42
|
+
searchRevealIds,
|
|
43
|
+
type OverallTreeNode,
|
|
44
|
+
} from "@/lib/taskmark/overall-tree"
|
|
45
|
+
import type { SolvedCompletionSample } from "@/lib/taskmark/timeframe-filters"
|
|
46
|
+
import { cn } from "@/lib/utils"
|
|
47
|
+
|
|
48
|
+
type OverallTreeListProps = {
|
|
49
|
+
list: ProjectEpicList
|
|
50
|
+
workItemsByEpic: Record<string, EpicWorkItemsList>
|
|
51
|
+
itemsByStory: Record<string, StoryItemList>
|
|
52
|
+
selectedEpicId?: string | null
|
|
53
|
+
selectedStoryId?: string | null
|
|
54
|
+
countableCompletions?: readonly SolvedCompletionSample[]
|
|
55
|
+
initialHideCompleted?: boolean
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function allNodes(nodes: readonly OverallTreeNode[]): OverallTreeNode[] {
|
|
59
|
+
return nodes.flatMap((node) => [node, ...allNodes(node.children)])
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function expansionStorageKey(projectId: string): string {
|
|
63
|
+
return `taskmark:overall-expanded:${projectId}`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function readExpanded(projectId: string): string[] {
|
|
67
|
+
if (typeof window === "undefined") return []
|
|
68
|
+
try {
|
|
69
|
+
const parsed = JSON.parse(
|
|
70
|
+
window.sessionStorage.getItem(expansionStorageKey(projectId)) ?? "[]"
|
|
71
|
+
)
|
|
72
|
+
return Array.isArray(parsed)
|
|
73
|
+
? parsed.filter((value): value is string => typeof value === "string")
|
|
74
|
+
: []
|
|
75
|
+
} catch {
|
|
76
|
+
return []
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function TreeNodeRow({
|
|
81
|
+
node,
|
|
82
|
+
level,
|
|
83
|
+
expanded,
|
|
84
|
+
selectedEpicId,
|
|
85
|
+
selectedStoryId,
|
|
86
|
+
onToggle,
|
|
87
|
+
}: {
|
|
88
|
+
node: OverallTreeNode
|
|
89
|
+
level: number
|
|
90
|
+
expanded: ReadonlySet<string>
|
|
91
|
+
selectedEpicId: string | null
|
|
92
|
+
selectedStoryId: string | null
|
|
93
|
+
onToggle: (id: string) => void
|
|
94
|
+
}) {
|
|
95
|
+
const expandable = node.kind === "epic" || node.kind === "story"
|
|
96
|
+
const isExpanded = expandable && expanded.has(node.id)
|
|
97
|
+
const selected =
|
|
98
|
+
(node.kind === "epic" && node.id === selectedEpicId) ||
|
|
99
|
+
(node.kind === "story" && node.id === selectedStoryId)
|
|
100
|
+
const emptyMessage =
|
|
101
|
+
node.kind === "epic"
|
|
102
|
+
? "No stories, tasks, or bugs under this epic yet."
|
|
103
|
+
: "No tasks or bugs under this story yet."
|
|
104
|
+
|
|
105
|
+
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
106
|
+
if (!expandable || (event.key !== "Enter" && event.key !== " ")) return
|
|
107
|
+
if ((event.target as HTMLElement).closest("button")) return
|
|
108
|
+
event.preventDefault()
|
|
109
|
+
onToggle(node.id)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div role="none">
|
|
114
|
+
<div
|
|
115
|
+
role="treeitem"
|
|
116
|
+
aria-level={level}
|
|
117
|
+
aria-expanded={expandable ? isExpanded : undefined}
|
|
118
|
+
aria-selected={selected}
|
|
119
|
+
tabIndex={0}
|
|
120
|
+
data-state={selected ? "selected" : undefined}
|
|
121
|
+
onKeyDown={onKeyDown}
|
|
122
|
+
className={cn(
|
|
123
|
+
"grid min-h-12 grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-2 border-b px-2 py-1.5 outline-none transition-colors hover:bg-muted/40 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring sm:grid-cols-[minmax(0,1fr)_5rem_5rem_4rem_5.5rem_3rem]",
|
|
124
|
+
statusRowClass(node.status),
|
|
125
|
+
selected && "bg-muted/60"
|
|
126
|
+
)}
|
|
127
|
+
>
|
|
128
|
+
<div
|
|
129
|
+
className="flex min-w-0 items-center gap-1.5"
|
|
130
|
+
style={{ paddingInlineStart: `${Math.max(0, level - 1) * 12}px` }}
|
|
131
|
+
>
|
|
132
|
+
{expandable ? (
|
|
133
|
+
<Button
|
|
134
|
+
type="button"
|
|
135
|
+
variant="ghost"
|
|
136
|
+
size="icon"
|
|
137
|
+
className="size-11 shrink-0 sm:size-9"
|
|
138
|
+
aria-label={`${isExpanded ? "Collapse" : "Expand"} ${node.id}`}
|
|
139
|
+
aria-expanded={isExpanded}
|
|
140
|
+
onClick={() => onToggle(node.id)}
|
|
141
|
+
>
|
|
142
|
+
<ChevronRightIcon
|
|
143
|
+
className={cn(
|
|
144
|
+
"size-4 transition-transform",
|
|
145
|
+
isExpanded && "rotate-90"
|
|
146
|
+
)}
|
|
147
|
+
/>
|
|
148
|
+
</Button>
|
|
149
|
+
) : (
|
|
150
|
+
<span className="w-11 shrink-0 sm:w-9" aria-hidden />
|
|
151
|
+
)}
|
|
152
|
+
<TypeBadge type={node.kind} />
|
|
153
|
+
<div className="min-w-0">
|
|
154
|
+
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2">
|
|
155
|
+
<IdCreatedTooltip id={node.id} created={node.created} />
|
|
156
|
+
<span className="min-w-0 break-words font-medium leading-snug">
|
|
157
|
+
{node.title}
|
|
158
|
+
</span>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
<div className="sm:hidden">
|
|
163
|
+
{node.children.length > 0 ? (
|
|
164
|
+
<ChildProgressBar
|
|
165
|
+
done={node.doneWorkItemCount}
|
|
166
|
+
total={node.workItemCount}
|
|
167
|
+
/>
|
|
168
|
+
) : (
|
|
169
|
+
<StatusWithSolvedTooltip
|
|
170
|
+
status={node.status}
|
|
171
|
+
solvedAt={node.completedAt}
|
|
172
|
+
/>
|
|
173
|
+
)}
|
|
174
|
+
</div>
|
|
175
|
+
<div className="hidden sm:block">
|
|
176
|
+
<SizeWithPointsTooltip size={node.size} points={node.points} />
|
|
177
|
+
</div>
|
|
178
|
+
<div className="hidden justify-center sm:flex">
|
|
179
|
+
<AttributionAvatarGroup
|
|
180
|
+
reporters={node.reporters}
|
|
181
|
+
resolvers={node.resolvers}
|
|
182
|
+
/>
|
|
183
|
+
</div>
|
|
184
|
+
<div className="hidden tabular-nums sm:block">
|
|
185
|
+
{node.children.length > 0 ? node.workItemCount : "—"}
|
|
186
|
+
</div>
|
|
187
|
+
<div className="hidden justify-center sm:flex">
|
|
188
|
+
{node.children.length > 0 ? (
|
|
189
|
+
<ChildProgressBar
|
|
190
|
+
done={node.doneWorkItemCount}
|
|
191
|
+
total={node.workItemCount}
|
|
192
|
+
/>
|
|
193
|
+
) : (
|
|
194
|
+
<StatusWithSolvedTooltip
|
|
195
|
+
status={node.status}
|
|
196
|
+
solvedAt={node.completedAt}
|
|
197
|
+
/>
|
|
198
|
+
)}
|
|
199
|
+
</div>
|
|
200
|
+
<div className="hidden justify-end sm:flex pl-2">
|
|
201
|
+
<ViewWorkItemButton
|
|
202
|
+
label={`View details for ${node.id}`}
|
|
203
|
+
itemRef={{
|
|
204
|
+
kind:
|
|
205
|
+
node.kind === "epic"
|
|
206
|
+
? "epic"
|
|
207
|
+
: node.kind === "story"
|
|
208
|
+
? "story"
|
|
209
|
+
: "item",
|
|
210
|
+
id: node.id,
|
|
211
|
+
title: node.title,
|
|
212
|
+
filePath: node.filePath,
|
|
213
|
+
itemType:
|
|
214
|
+
node.kind === "task" || node.kind === "bug"
|
|
215
|
+
? node.kind
|
|
216
|
+
: undefined,
|
|
217
|
+
}}
|
|
218
|
+
/>
|
|
219
|
+
</div>
|
|
220
|
+
<div className="col-span-2 flex justify-end sm:hidden pt-0.5">
|
|
221
|
+
<ViewWorkItemButton
|
|
222
|
+
label={`View details for ${node.id}`}
|
|
223
|
+
itemRef={{
|
|
224
|
+
kind:
|
|
225
|
+
node.kind === "epic"
|
|
226
|
+
? "epic"
|
|
227
|
+
: node.kind === "story"
|
|
228
|
+
? "story"
|
|
229
|
+
: "item",
|
|
230
|
+
id: node.id,
|
|
231
|
+
title: node.title,
|
|
232
|
+
filePath: node.filePath,
|
|
233
|
+
itemType:
|
|
234
|
+
node.kind === "task" || node.kind === "bug"
|
|
235
|
+
? node.kind
|
|
236
|
+
: undefined,
|
|
237
|
+
}}
|
|
238
|
+
/>
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
{isExpanded ? (
|
|
242
|
+
<div role="group">
|
|
243
|
+
{node.children.length > 0 ? (
|
|
244
|
+
node.children.map((child) => (
|
|
245
|
+
<TreeNodeRow
|
|
246
|
+
key={`${child.kind}:${child.id}:${child.filePath}`}
|
|
247
|
+
node={child}
|
|
248
|
+
level={level + 1}
|
|
249
|
+
expanded={expanded}
|
|
250
|
+
selectedEpicId={selectedEpicId}
|
|
251
|
+
selectedStoryId={selectedStoryId}
|
|
252
|
+
onToggle={onToggle}
|
|
253
|
+
/>
|
|
254
|
+
))
|
|
255
|
+
) : (
|
|
256
|
+
<p
|
|
257
|
+
className="border-b py-3 pr-3 text-sm text-muted-foreground"
|
|
258
|
+
style={{ paddingInlineStart: `${level * 12 + 44}px` }}
|
|
259
|
+
>
|
|
260
|
+
{emptyMessage}
|
|
261
|
+
</p>
|
|
262
|
+
)}
|
|
263
|
+
</div>
|
|
264
|
+
) : null}
|
|
265
|
+
</div>
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function OverallTreeList({
|
|
270
|
+
list,
|
|
271
|
+
workItemsByEpic,
|
|
272
|
+
itemsByStory,
|
|
273
|
+
selectedEpicId = null,
|
|
274
|
+
selectedStoryId = null,
|
|
275
|
+
countableCompletions = [],
|
|
276
|
+
initialHideCompleted = false,
|
|
277
|
+
}: OverallTreeListProps) {
|
|
278
|
+
const { project, errors } = list
|
|
279
|
+
const tree = React.useMemo(
|
|
280
|
+
() => buildOverallTree(list, workItemsByEpic, itemsByStory),
|
|
281
|
+
[list, workItemsByEpic, itemsByStory]
|
|
282
|
+
)
|
|
283
|
+
const [query, setQuery] = React.useState("")
|
|
284
|
+
const [hideCompleted, setHideCompleted] = usePersistedHideCompleted(
|
|
285
|
+
"epics",
|
|
286
|
+
initialHideCompleted
|
|
287
|
+
)
|
|
288
|
+
const [timeframe, setTimeframe] = React.useState<TimeframeFilterState>(
|
|
289
|
+
DEFAULT_TIMEFRAME_FILTER
|
|
290
|
+
)
|
|
291
|
+
const [expandedIds, setExpandedIds] = React.useState<string[]>(() => [
|
|
292
|
+
...(selectedEpicId ? [selectedEpicId] : []),
|
|
293
|
+
...(selectedStoryId ? [selectedStoryId] : []),
|
|
294
|
+
])
|
|
295
|
+
|
|
296
|
+
React.useEffect(() => {
|
|
297
|
+
let active = true
|
|
298
|
+
queueMicrotask(() => {
|
|
299
|
+
if (!active) return
|
|
300
|
+
setExpandedIds((current) => [
|
|
301
|
+
...new Set([
|
|
302
|
+
...current,
|
|
303
|
+
...readExpanded(project.id),
|
|
304
|
+
...(selectedEpicId ? [selectedEpicId] : []),
|
|
305
|
+
...(selectedStoryId ? [selectedStoryId] : []),
|
|
306
|
+
]),
|
|
307
|
+
])
|
|
308
|
+
})
|
|
309
|
+
return () => {
|
|
310
|
+
active = false
|
|
311
|
+
}
|
|
312
|
+
}, [project.id, selectedEpicId, selectedStoryId])
|
|
313
|
+
|
|
314
|
+
const filtered = React.useMemo(
|
|
315
|
+
() => filterOverallTree(tree, { query, hideCompleted, timeframe }),
|
|
316
|
+
[tree, query, hideCompleted, timeframe]
|
|
317
|
+
)
|
|
318
|
+
const effectiveExpanded = React.useMemo(
|
|
319
|
+
() => new Set([...expandedIds, ...searchRevealIds(filtered, query)]),
|
|
320
|
+
[expandedIds, filtered, query]
|
|
321
|
+
)
|
|
322
|
+
const completedAts = React.useMemo(
|
|
323
|
+
() => allNodes(tree).map((node) => node.completedAt),
|
|
324
|
+
[tree]
|
|
325
|
+
)
|
|
326
|
+
const resetKey = `${project.id}|${query}|${hideCompleted}|${JSON.stringify(timeframe)}`
|
|
327
|
+
const {
|
|
328
|
+
pageRows,
|
|
329
|
+
page,
|
|
330
|
+
pageSize,
|
|
331
|
+
totalCount,
|
|
332
|
+
totalPages,
|
|
333
|
+
setPage,
|
|
334
|
+
setPageSize,
|
|
335
|
+
} = usePaginatedRows(filtered, resetKey)
|
|
336
|
+
|
|
337
|
+
React.useEffect(() => {
|
|
338
|
+
if (!selectedEpicId) return
|
|
339
|
+
const index = filtered.findIndex((node) => node.id === selectedEpicId)
|
|
340
|
+
if (index >= 0) setPage(Math.floor(index / pageSize) + 1)
|
|
341
|
+
}, [selectedEpicId, filtered, pageSize, setPage])
|
|
342
|
+
|
|
343
|
+
const toggle = React.useCallback(
|
|
344
|
+
(id: string) => {
|
|
345
|
+
setExpandedIds((current) => {
|
|
346
|
+
const next = new Set(current)
|
|
347
|
+
if (next.has(id)) next.delete(id)
|
|
348
|
+
else next.add(id)
|
|
349
|
+
const values = [...next]
|
|
350
|
+
try {
|
|
351
|
+
window.sessionStorage.setItem(
|
|
352
|
+
expansionStorageKey(project.id),
|
|
353
|
+
JSON.stringify(values)
|
|
354
|
+
)
|
|
355
|
+
} catch {
|
|
356
|
+
// Session storage may be unavailable in privacy-restricted contexts.
|
|
357
|
+
}
|
|
358
|
+
return values
|
|
359
|
+
})
|
|
360
|
+
},
|
|
361
|
+
[project.id]
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
const nestedErrors = [
|
|
365
|
+
...Object.values(workItemsByEpic).flatMap((value) => value.errors),
|
|
366
|
+
...Object.values(itemsByStory).flatMap((value) => value.errors),
|
|
367
|
+
]
|
|
368
|
+
const allErrors = [
|
|
369
|
+
...errors.map((error) => ({
|
|
370
|
+
filePath: error.filePath,
|
|
371
|
+
message: error.message,
|
|
372
|
+
})),
|
|
373
|
+
...nestedErrors,
|
|
374
|
+
]
|
|
375
|
+
|
|
376
|
+
return (
|
|
377
|
+
<Card>
|
|
378
|
+
<CardHeader>
|
|
379
|
+
<CardTitle className="font-head text-xl">{project.name}</CardTitle>
|
|
380
|
+
{tree.length > 0 ? (
|
|
381
|
+
<CardAction>
|
|
382
|
+
<TimeframeFilter
|
|
383
|
+
id={`overall-tree-timeframe-${project.id}`}
|
|
384
|
+
value={timeframe}
|
|
385
|
+
onChange={setTimeframe}
|
|
386
|
+
completedAts={completedAts}
|
|
387
|
+
countableCompletions={countableCompletions}
|
|
388
|
+
/>
|
|
389
|
+
</CardAction>
|
|
390
|
+
) : null}
|
|
391
|
+
</CardHeader>
|
|
392
|
+
<CardContent className="flex flex-col gap-4">
|
|
393
|
+
{allErrors.length > 0 ? (
|
|
394
|
+
<div
|
|
395
|
+
role="status"
|
|
396
|
+
className="rounded border-2 border-destructive/40 bg-destructive/10 px-3 py-2 text-sm"
|
|
397
|
+
>
|
|
398
|
+
<p className="font-medium text-destructive">
|
|
399
|
+
{allErrors.length} file{allErrors.length === 1 ? "" : "s"} could
|
|
400
|
+
not be parsed
|
|
401
|
+
</p>
|
|
402
|
+
<ul className="mt-2 flex flex-col gap-1 font-mono text-xs text-muted-foreground">
|
|
403
|
+
{allErrors.map((error, index) => (
|
|
404
|
+
<li key={`${error.filePath}:${error.message}:${index}`}>
|
|
405
|
+
{displayFileName(error.filePath)}: {error.message}
|
|
406
|
+
</li>
|
|
407
|
+
))}
|
|
408
|
+
</ul>
|
|
409
|
+
</div>
|
|
410
|
+
) : null}
|
|
411
|
+
|
|
412
|
+
{tree.length === 0 ? (
|
|
413
|
+
<p className="text-sm text-muted-foreground">
|
|
414
|
+
No epics in this board yet.
|
|
415
|
+
</p>
|
|
416
|
+
) : (
|
|
417
|
+
<>
|
|
418
|
+
<ListFiltersBar
|
|
419
|
+
searchId={`overall-tree-search-${project.id}`}
|
|
420
|
+
query={query}
|
|
421
|
+
onQueryChange={setQuery}
|
|
422
|
+
hideCompleted={hideCompleted}
|
|
423
|
+
onHideCompletedChange={setHideCompleted}
|
|
424
|
+
/>
|
|
425
|
+
{filtered.length === 0 ? (
|
|
426
|
+
<p className="text-sm text-muted-foreground">
|
|
427
|
+
No work matches the current search or filters.
|
|
428
|
+
</p>
|
|
429
|
+
) : (
|
|
430
|
+
<>
|
|
431
|
+
<div className="overflow-hidden rounded-md border" role="tree">
|
|
432
|
+
<div className="hidden grid-cols-[minmax(0,1fr)_5rem_5rem_4rem_5.5rem_3rem] gap-x-3 border-b bg-muted/30 px-2 py-2 text-xs font-medium text-muted-foreground sm:grid">
|
|
433
|
+
<span>Work</span>
|
|
434
|
+
<span>Size</span>
|
|
435
|
+
<span className="text-center">People</span>
|
|
436
|
+
<span>Items</span>
|
|
437
|
+
<span className="text-center">Status</span>
|
|
438
|
+
<span className="sr-only pl-2">Actions</span>
|
|
439
|
+
</div>
|
|
440
|
+
{pageRows.map((node) => (
|
|
441
|
+
<TreeNodeRow
|
|
442
|
+
key={`${node.id}:${node.filePath}`}
|
|
443
|
+
node={node}
|
|
444
|
+
level={1}
|
|
445
|
+
expanded={effectiveExpanded}
|
|
446
|
+
selectedEpicId={selectedEpicId}
|
|
447
|
+
selectedStoryId={selectedStoryId}
|
|
448
|
+
onToggle={toggle}
|
|
449
|
+
/>
|
|
450
|
+
))}
|
|
451
|
+
</div>
|
|
452
|
+
<ListPagination
|
|
453
|
+
page={page}
|
|
454
|
+
totalPages={totalPages}
|
|
455
|
+
totalCount={totalCount}
|
|
456
|
+
pageSize={pageSize}
|
|
457
|
+
onPageChange={setPage}
|
|
458
|
+
onPageSizeChange={setPageSize}
|
|
459
|
+
/>
|
|
460
|
+
</>
|
|
461
|
+
)}
|
|
462
|
+
</>
|
|
463
|
+
)}
|
|
464
|
+
</CardContent>
|
|
465
|
+
</Card>
|
|
466
|
+
)
|
|
467
|
+
}
|
|
@@ -28,7 +28,10 @@ import {
|
|
|
28
28
|
import { ListFiltersBar } from "@/components/board/list-filters-bar"
|
|
29
29
|
import { ListPagination } from "@/components/board/list-pagination"
|
|
30
30
|
import { SortableTableHead } from "@/components/board/sortable-table-head"
|
|
31
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
statusRowClass,
|
|
33
|
+
TypeBadge,
|
|
34
|
+
} from "@/components/board/status-badge"
|
|
32
35
|
import { TimeframeFilter } from "@/components/board/timeframe-filter"
|
|
33
36
|
import { ViewWorkItemButton } from "@/components/board/work-item-sheet"
|
|
34
37
|
import { AttributionAvatarGroup } from "@/components/board/attribution-avatars"
|
|
@@ -251,6 +254,7 @@ export function OverallWorkItemsList({
|
|
|
251
254
|
key={`${project.id}:${row.kind}:${row.id}:${row.filePath}`}
|
|
252
255
|
data-state={selected ? "selected" : undefined}
|
|
253
256
|
className={cn(
|
|
257
|
+
statusRowClass(row.status),
|
|
254
258
|
selected && "bg-muted/60",
|
|
255
259
|
"hover:bg-muted/40"
|
|
256
260
|
)}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { format } from "date-fns"
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
buildPointsCalendar,
|
|
7
|
+
pointsHeatLevel,
|
|
8
|
+
type PointsCalendarDay,
|
|
9
|
+
type PointsCalendarWeek,
|
|
10
|
+
} from "@/lib/taskmark/points-calendar"
|
|
11
|
+
import type { SolvedCompletionSample } from "@/lib/taskmark/timeframe-filters"
|
|
12
|
+
import { cn } from "@/lib/utils"
|
|
13
|
+
|
|
14
|
+
/** White through #C4A1FF: darker means more story points completed. */
|
|
15
|
+
const LEVEL_CLASS: Record<0 | 1 | 2 | 3 | 4, string> = {
|
|
16
|
+
0: "bg-white dark:bg-neutral-900",
|
|
17
|
+
1: "bg-[#f0e8ff] dark:bg-[#2f2542]",
|
|
18
|
+
2: "bg-[#e2d0ff] dark:bg-[#4c3a73]",
|
|
19
|
+
3: "bg-[#d3b9ff] dark:bg-[#8368c0]",
|
|
20
|
+
4: "bg-[#c4a1ff]",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const WEEKDAY_LABELS = ["Mon", "", "Wed", "", "Fri", "", ""]
|
|
24
|
+
const CELL_SIZE = "0.75rem"
|
|
25
|
+
const CELL_GAP = "3px"
|
|
26
|
+
|
|
27
|
+
function pointsLabel(points: number): string {
|
|
28
|
+
return points === 1 ? "1 story point" : `${points} story points`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function dayLabel(day: PointsCalendarDay): string {
|
|
32
|
+
return `${format(day.date, "MMM d, yyyy")} · ${pointsLabel(day.points)}`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function monthLabels(weeks: readonly PointsCalendarWeek[]): string[] {
|
|
36
|
+
let previous: number | null = null
|
|
37
|
+
return weeks.map((week, index) => {
|
|
38
|
+
const first = week.days[0]!.date
|
|
39
|
+
const month = first.getMonth()
|
|
40
|
+
const changed = previous !== null && month !== previous
|
|
41
|
+
previous = month
|
|
42
|
+
// Skip the last column so a label never runs past the grid.
|
|
43
|
+
if (!changed || index >= weeks.length - 1) return ""
|
|
44
|
+
return format(first, "MMM")
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function HeatmapCell({ day, maxPoints }: { day: PointsCalendarDay; maxPoints: number }) {
|
|
49
|
+
if (day.isFuture) return <span aria-hidden className="size-3" />
|
|
50
|
+
|
|
51
|
+
const level = pointsHeatLevel(day.points, maxPoints)
|
|
52
|
+
const className = cn(
|
|
53
|
+
"size-3 rounded-[2px] border border-black/20 dark:border-white/15",
|
|
54
|
+
LEVEL_CLASS[level]
|
|
55
|
+
)
|
|
56
|
+
const label = dayLabel(day)
|
|
57
|
+
|
|
58
|
+
if (day.points <= 0) {
|
|
59
|
+
return <span aria-hidden title={label} className={className} />
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<button
|
|
64
|
+
type="button"
|
|
65
|
+
title={label}
|
|
66
|
+
aria-label={label}
|
|
67
|
+
className={cn(
|
|
68
|
+
className,
|
|
69
|
+
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
|
|
70
|
+
)}
|
|
71
|
+
/>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
type PointsHeatmapProps = {
|
|
76
|
+
samples: readonly SolvedCompletionSample[]
|
|
77
|
+
now?: Date
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function PointsHeatmap({ samples, now }: PointsHeatmapProps) {
|
|
81
|
+
const calendar = buildPointsCalendar(samples, now)
|
|
82
|
+
const { weeks, maxPoints, totalPoints, start, end } = calendar
|
|
83
|
+
const labels = monthLabels(weeks)
|
|
84
|
+
const summary = `Story points completed per day between ${format(start, "MMM d, yyyy")} and ${format(end, "MMM d, yyyy")}: ${pointsLabel(totalPoints)} in total.`
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<section
|
|
88
|
+
className="border-2 border-black bg-card p-4 shadow-[4px_4px_0_0_var(--shadow-color)]"
|
|
89
|
+
aria-label="Completed story points calendar"
|
|
90
|
+
>
|
|
91
|
+
<div className="mb-3 flex flex-col gap-1 sm:flex-row sm:items-end sm:justify-between">
|
|
92
|
+
<div>
|
|
93
|
+
<h2 className="font-head text-base tracking-tight">Story points</h2>
|
|
94
|
+
<p className="text-xs text-muted-foreground">
|
|
95
|
+
Done tasks and bugs over the last year
|
|
96
|
+
</p>
|
|
97
|
+
</div>
|
|
98
|
+
<p className="text-xs text-muted-foreground">
|
|
99
|
+
{maxPoints > 0
|
|
100
|
+
? `${pointsLabel(totalPoints)} completed`
|
|
101
|
+
: "No completed story points in this window yet."}
|
|
102
|
+
</p>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<p className="sr-only">{summary}</p>
|
|
106
|
+
|
|
107
|
+
<div className="overflow-x-auto pb-1">
|
|
108
|
+
<div className="mx-auto flex w-max gap-1.5">
|
|
109
|
+
<div
|
|
110
|
+
className="grid shrink-0"
|
|
111
|
+
style={{
|
|
112
|
+
gridTemplateRows: `repeat(7, ${CELL_SIZE})`,
|
|
113
|
+
rowGap: CELL_GAP,
|
|
114
|
+
// Clear the month-label row so weekday labels line up with their cells.
|
|
115
|
+
paddingTop: `calc(${CELL_SIZE} + ${CELL_GAP})`,
|
|
116
|
+
}}
|
|
117
|
+
aria-hidden
|
|
118
|
+
>
|
|
119
|
+
{WEEKDAY_LABELS.map((label, index) => (
|
|
120
|
+
<span
|
|
121
|
+
key={`weekday-${index}`}
|
|
122
|
+
className="flex items-center pr-1 text-[9px] leading-none text-muted-foreground"
|
|
123
|
+
>
|
|
124
|
+
{label}
|
|
125
|
+
</span>
|
|
126
|
+
))}
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
<div className="flex flex-col" style={{ rowGap: CELL_GAP }}>
|
|
130
|
+
<div
|
|
131
|
+
className="grid"
|
|
132
|
+
style={{
|
|
133
|
+
gridTemplateColumns: `repeat(${weeks.length}, ${CELL_SIZE})`,
|
|
134
|
+
columnGap: CELL_GAP,
|
|
135
|
+
}}
|
|
136
|
+
aria-hidden
|
|
137
|
+
>
|
|
138
|
+
{labels.map((label, index) => (
|
|
139
|
+
<span
|
|
140
|
+
key={`month-${weeks[index]?.key ?? index}`}
|
|
141
|
+
className="h-3 whitespace-nowrap text-[9px] leading-3 text-muted-foreground"
|
|
142
|
+
>
|
|
143
|
+
{label}
|
|
144
|
+
</span>
|
|
145
|
+
))}
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
<div
|
|
149
|
+
className="grid grid-flow-col"
|
|
150
|
+
style={{
|
|
151
|
+
gridTemplateRows: `repeat(7, ${CELL_SIZE})`,
|
|
152
|
+
gridAutoColumns: CELL_SIZE,
|
|
153
|
+
gap: CELL_GAP,
|
|
154
|
+
}}
|
|
155
|
+
>
|
|
156
|
+
{weeks.flatMap((week) =>
|
|
157
|
+
week.days.map((day) => (
|
|
158
|
+
<HeatmapCell key={day.key} day={day} maxPoints={maxPoints} />
|
|
159
|
+
))
|
|
160
|
+
)}
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
<div className="mt-3 flex items-center justify-center gap-2 text-[10px] text-muted-foreground">
|
|
167
|
+
<span>Less</span>
|
|
168
|
+
<div className="flex items-center gap-1" aria-hidden>
|
|
169
|
+
{([0, 1, 2, 3, 4] as const).map((level) => (
|
|
170
|
+
<span
|
|
171
|
+
key={level}
|
|
172
|
+
className={cn(
|
|
173
|
+
"size-3 rounded-[2px] border border-black/20 dark:border-white/15",
|
|
174
|
+
LEVEL_CLASS[level]
|
|
175
|
+
)}
|
|
176
|
+
/>
|
|
177
|
+
))}
|
|
178
|
+
</div>
|
|
179
|
+
<span>More</span>
|
|
180
|
+
</div>
|
|
181
|
+
</section>
|
|
182
|
+
)
|
|
183
|
+
}
|