From fd44aceb3113b31132ab59facc49fc601a882261 Mon Sep 17 00:00:00 2001 From: Philipp Nowinski Date: Wed, 10 Jun 2026 15:55:51 +0200 Subject: [PATCH 1/5] feat: performance optimizations --- package.json | 3 + pnpm-lock.yaml | 199 +++++++++++++++++ src/components/OpenTask.tsx | 61 ++++-- src/components/TaskCard.tsx | 98 ++++----- src/components/views/BoardView.tsx | 39 ++-- src/components/views/ListView.tsx | 19 +- src/components/views/TaskViewsContainer.tsx | 61 +++++- src/components/views/VirtualizedTaskCards.tsx | 49 +++++ .../views/VirtualizedTaskListRows.tsx | 44 ++++ src/components/views/task-view-types.ts | 3 + src/db/mutations/attachments.ts | 9 +- src/db/mutations/comments.ts | 9 +- src/db/mutations/labels.ts | 18 +- src/db/mutations/projects.ts | 13 +- src/db/mutations/sprints.ts | 13 +- src/db/mutations/statuses.ts | 18 +- src/db/mutations/sub-tasks.ts | 21 +- src/db/mutations/sync.ts | 61 ++++-- src/db/mutations/tasks.ts | 141 ++++++------ src/db/queries/attachments.ts | 17 +- src/db/queries/bundles.ts | 24 ++- src/db/queries/comments.ts | 15 +- src/db/queries/dashboard.ts | 13 +- src/db/queries/hydrate-query-cache.ts | 12 ++ src/db/queries/labels.ts | 33 +-- src/db/queries/notifications.ts | 13 +- src/db/queries/projects.ts | 29 +-- src/db/queries/sprints.ts | 29 +-- src/db/queries/statuses.ts | 33 +-- src/db/queries/tasks.ts | 57 +---- src/hooks/live-sync-provider.tsx | 200 ++++++++++++++++++ src/hooks/use-event-source.ts | 35 --- src/hooks/use-git-task-live-sync.ts | 25 +-- src/lib/query-cache-fresh.ts | 15 ++ src/router.tsx | 3 + src/routes/_signed-in.tsx | 38 +++- src/routes/_signed-in/dashboard.tsx | 10 +- .../_signed-in/projects.$projectId.tasks.tsx | 19 +- .../_signed-in/sprints.$sprintId.tasks.tsx | 19 +- src/routes/_signed-in/tasks.tsx | 8 +- vite.config.ts | 8 + 41 files changed, 964 insertions(+), 570 deletions(-) create mode 100644 src/components/views/VirtualizedTaskCards.tsx create mode 100644 src/components/views/VirtualizedTaskListRows.tsx create mode 100644 src/hooks/live-sync-provider.tsx delete mode 100644 src/hooks/use-event-source.ts create mode 100644 src/lib/query-cache-fresh.ts diff --git a/package.json b/package.json index 726b93d..8f9ddc5 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "scripts": { "dev": "vite dev --port 3001", "build": "node scripts/compute-app-version.mjs && vite build && tsc --noEmit", + "build:analyze": "ANALYZE=true node scripts/compute-app-version.mjs && vite build", "lint": "eslint src --max-warnings 0", "test": "vitest run", "preview": "vite preview", @@ -52,6 +53,7 @@ "@tanstack/react-router-ssr-query": "^1.167.0", "@tanstack/react-start": "^1.167.0", "@tanstack/react-store": "^0.9.3", + "@tanstack/react-virtual": "^3.14.2", "@tiptap/core": "^2.25.0", "@tiptap/extension-text-style": "^2.25.0", "@tiptap/react": "^2.25.0", @@ -103,6 +105,7 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", + "rollup-plugin-visualizer": "^7.0.1", "semantic-release": "^24.2.7", "semver": "^7.7.2", "tailwind-scrollbar": "^4.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4fbd6af..9e16a5b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -110,6 +110,9 @@ importers: '@tanstack/react-store': specifier: ^0.9.3 version: 0.9.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@tanstack/react-virtual': + specifier: ^3.14.2 + version: 3.14.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@tiptap/core': specifier: ^2.25.0 version: 2.27.2(@tiptap/pm@2.27.2) @@ -258,6 +261,9 @@ importers: globals: specifier: ^17.6.0 version: 17.6.0 + rollup-plugin-visualizer: + specifier: ^7.0.1 + version: 7.0.1(rolldown@1.0.1)(rollup@4.61.0) semantic-release: specifier: ^24.2.7 version: 24.2.9(typescript@5.9.3) @@ -3215,6 +3221,12 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/react-virtual@3.14.2': + resolution: {integrity: sha512-IpWnmCLvuymRfeeLNVXIzNEYBFLpd3drVIS91sqV78VTZFyldlChkOocZRCPp1B+Wnk09bcLNme8WaMU/9/9bQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/router-core@1.171.2': resolution: {integrity: sha512-sUd+BhGYkBF64LVhmOHnYsc1AutPNch/huohEXiXL4IUgmk17Gy+RkUazvjQhptVdYW5QT+qtATrUr2cQZNHFA==} engines: {node: '>=20.19'} @@ -3296,6 +3308,9 @@ packages: '@tanstack/store@0.9.3': resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} + '@tanstack/virtual-core@3.17.0': + resolution: {integrity: sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==} + '@tanstack/virtual-file-routes@1.162.0': resolution: {integrity: sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA==} engines: {node: '>=20.19'} @@ -4046,6 +4061,10 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -4170,6 +4189,10 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} + cliui@9.0.1: + resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} + engines: {node: '>=20'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -4435,10 +4458,22 @@ packages: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + define-data-property@1.1.4: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + define-properties@1.2.1: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} @@ -4675,6 +4710,9 @@ packages: electron-to-chromium@1.5.358: resolution: {integrity: sha512-EO7tKm3QxRqTs1lSuPXzl6yRAwznehp0AH9OoMOIC+4mQzTFday8FJCO5KU6J/TFSQXEOahNq4vTKpz1jmCVOA==} + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -5091,6 +5129,10 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -5433,6 +5475,11 @@ packages: is-decimal@2.0.1: resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -5456,6 +5503,15 @@ packages: is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-in-ssh@1.0.0: + resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} + engines: {node: '>=20'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + is-map@2.0.3: resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} engines: {node: '>= 0.4'} @@ -5548,6 +5604,10 @@ packages: resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} engines: {node: '>= 0.4'} + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} @@ -6366,6 +6426,10 @@ packages: oniguruma-to-es@4.3.6: resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + open@11.0.0: + resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} + engines: {node: '>=20'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -6591,6 +6655,10 @@ packages: resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==} engines: {node: '>=12'} + powershell-utils@0.1.0: + resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} + engines: {node: '>=20'} + preact-render-to-string@6.6.5: resolution: {integrity: sha512-O6MHzYNIKYaiSX3bOw0gGZfEbOmlIDtDfWwN1JJdc/T3ihzRT6tGGSEWE088dWrEDGa1u7101q+6fzQnO9XCPA==} peerDependencies: @@ -6904,6 +6972,19 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rollup-plugin-visualizer@7.0.1: + resolution: {integrity: sha512-UJUT4+1Ho4OcWmPYU3sYXgUqI8B8Ayfe06MX7y0qCJ1K8aGoKtR/NDd/2nZqM7ADkrzny+I99Ul7GgyoiVNAgg==} + engines: {node: '>=22'} + hasBin: true + peerDependencies: + rolldown: 1.x || ^1.0.0-beta || ^1.0.0-rc + rollup: 2.x || 3.x || 4.x + peerDependenciesMeta: + rolldown: + optional: true + rollup: + optional: true + rollup@4.61.0: resolution: {integrity: sha512-T9mWdbWfQtp0B5lv/HX+wrhYsmXRlcWnXXmJbXqKJhlRaoS6KMhq0gpyzW4UJfclcxrEdLnTgjT2NjruLONu0g==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -6918,6 +6999,10 @@ packages: rou3@0.8.1: resolution: {integrity: sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA==} + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -7140,6 +7225,10 @@ packages: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} engines: {node: '>=12'} + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + string.prototype.trim@1.2.10: resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} engines: {node: '>= 0.4'} @@ -7864,6 +7953,10 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -7879,6 +7972,10 @@ packages: utf-8-validate: optional: true + wsl-utils@0.3.1: + resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} + engines: {node: '>=20'} + xmlbuilder2@4.0.3: resolution: {integrity: sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA==} engines: {node: '>=20.0'} @@ -7916,6 +8013,10 @@ packages: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yargs@16.2.0: resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} engines: {node: '>=10'} @@ -7924,6 +8025,10 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} + yargs@18.0.0: + resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yauzl@2.10.0: resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} @@ -10680,6 +10785,12 @@ snapshots: react-dom: 19.2.6(react@19.2.6) use-sync-external-store: 1.6.0(react@19.2.6) + '@tanstack/react-virtual@3.14.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@tanstack/virtual-core': 3.17.0 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + '@tanstack/router-core@1.171.2': dependencies: '@tanstack/history': 1.162.0 @@ -10809,6 +10920,8 @@ snapshots: '@tanstack/store@0.9.3': {} + '@tanstack/virtual-core@3.17.0': {} + '@tanstack/virtual-file-routes@1.162.0': {} '@tiptap/core@2.27.2(@tiptap/pm@2.27.2)': @@ -11632,6 +11745,10 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + cac@6.7.14: {} call-bind-apply-helpers@1.0.2: @@ -11782,6 +11899,12 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + cliui@9.0.1: + dependencies: + string-width: 7.2.0 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 + clsx@2.1.1: {} cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): @@ -12023,12 +12146,21 @@ snapshots: deepmerge@4.3.1: {} + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + define-data-property@1.1.4: dependencies: es-define-property: 1.0.1 es-errors: 1.3.0 gopd: 1.2.0 + define-lazy-prop@3.0.0: {} + define-properties@1.2.1: dependencies: define-data-property: 1.1.4 @@ -12190,6 +12322,8 @@ snapshots: electron-to-chromium@1.5.358: {} + emoji-regex@10.6.0: {} + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} @@ -12767,6 +12901,8 @@ snapshots: get-caller-file@2.0.5: {} + get-east-asian-width@1.6.0: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -13216,6 +13352,8 @@ snapshots: is-decimal@2.0.1: {} + is-docker@3.0.0: {} + is-extglob@2.1.1: {} is-finalizationregistry@1.1.1: @@ -13238,6 +13376,12 @@ snapshots: is-hexadecimal@2.0.1: {} + is-in-ssh@1.0.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + is-map@2.0.3: {} is-negative-zero@2.0.3: {} @@ -13308,6 +13452,10 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + isarray@1.0.0: {} isarray@2.0.5: {} @@ -14236,6 +14384,15 @@ snapshots: regex: 6.1.0 regex-recursion: 6.0.2 + open@11.0.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-in-ssh: 1.0.0 + is-inside-container: 1.0.0 + powershell-utils: 0.1.0 + wsl-utils: 0.3.1 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -14468,6 +14625,8 @@ snapshots: postgres@3.4.9: {} + powershell-utils@0.1.0: {} + preact-render-to-string@6.6.5(preact@11.0.0-beta.0): dependencies: preact: 11.0.0-beta.0 @@ -14895,6 +15054,16 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.1 '@rolldown/binding-win32-x64-msvc': 1.0.1 + rollup-plugin-visualizer@7.0.1(rolldown@1.0.1)(rollup@4.61.0): + dependencies: + open: 11.0.0 + picomatch: 4.0.4 + source-map: 0.7.6 + yargs: 18.0.0 + optionalDependencies: + rolldown: 1.0.1 + rollup: 4.61.0 + rollup@4.61.0: dependencies: '@types/estree': 1.0.9 @@ -14932,6 +15101,8 @@ snapshots: rou3@0.8.1: {} + run-applescript@7.1.0: {} + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -15225,6 +15396,12 @@ snapshots: emoji-regex: 9.2.2 strip-ansi: 7.2.0 + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + string.prototype.trim@1.2.10: dependencies: call-bind: 1.0.9 @@ -15911,10 +16088,21 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.2.0 + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + wrappy@1.0.2: {} ws@8.20.1: {} + wsl-utils@0.3.1: + dependencies: + is-wsl: 3.1.1 + powershell-utils: 0.1.0 + xmlbuilder2@4.0.3: dependencies: '@oozcitak/dom': 2.0.2 @@ -15941,6 +16129,8 @@ snapshots: yargs-parser@21.1.1: {} + yargs-parser@22.0.0: {} + yargs@16.2.0: dependencies: cliui: 7.0.4 @@ -15961,6 +16151,15 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 + yargs@18.0.0: + dependencies: + cliui: 9.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + string-width: 7.2.0 + y18n: 5.0.8 + yargs-parser: 22.0.0 + yauzl@2.10.0: dependencies: buffer-crc32: 0.2.13 diff --git a/src/components/OpenTask.tsx b/src/components/OpenTask.tsx index 19e7c39..af561d7 100644 --- a/src/components/OpenTask.tsx +++ b/src/components/OpenTask.tsx @@ -11,8 +11,7 @@ import { TaskWithRelations, UpdateTask, } from "~/db/schema"; -import { RichtextEditor } from "~/components/RichtextEditor/Editor"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAssignTaskMutation, useDeleteTaskMutation, @@ -71,7 +70,6 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs"; import { SubTasks } from "./SubTasks"; import { Badge } from "./ui/badge"; import { SprintSelect } from "./SprintSelect"; -import { TaskDevelopmentSection } from "~/components/git/TaskDevelopmentSection"; import { TaskPullRequestReviewFeed } from "~/components/git/TaskPullRequestReviewFeed"; import { GitProviderIcon } from "~/components/git/GitProviderIcon"; import { useGitConnectionQuery, useTaskGitContextQuery } from "~/db/queries/git"; @@ -83,6 +81,27 @@ import { type TaskTab, type TaskTabSearch, } from "~/lib/task-tab-search"; +import { EndlessLoadingSpinner } from "./EndlessLoadingSpinner"; + +const RichtextEditor = lazy(() => + import("~/components/RichtextEditor/Editor").then((module) => ({ + default: module.RichtextEditor, + })) +); + +const TaskDevelopmentSection = lazy(() => + import("~/components/git/TaskDevelopmentSection").then((module) => ({ + default: module.TaskDevelopmentSection, + })) +); + +function TabLoadingFallback() { + return ( +
+ +
+ ); +} export function OpenTask({ task, @@ -518,16 +537,18 @@ export function OpenTask({ - { - handleUpdateTask({ - id: task.id, - description: data.text, - projectId: task.projectId, - }); - }} - /> + }> + { + handleUpdateTask({ + id: task.id, + description: data.text, + projectId: task.projectId, + }); + }} + /> + - + }> + + diff --git a/src/components/TaskCard.tsx b/src/components/TaskCard.tsx index acecc73..f4132a5 100644 --- a/src/components/TaskCard.tsx +++ b/src/components/TaskCard.tsx @@ -1,3 +1,5 @@ +import { memo, type CSSProperties, type ReactNode } from "react"; +import type { AppUser } from "~/db/queries/users"; import { TaskWithRelations } from "~/db/schema"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { ClientOnly, Link } from "@tanstack/react-router"; @@ -29,13 +31,6 @@ import { } from "~/components/ui/tooltip"; import { DateDisplay } from "./ui/date-display"; import { Avatar, AvatarFallback, AvatarImage, AvatarList } from "./ui/avatar"; -import { useUsersQuery } from "~/db/queries/users"; -import { useEffect, useState, type CSSProperties, type ReactNode } from "react"; -import { authClient } from "~/lib/auth-client"; -import { - useAssignTaskMutation, - useUnassignTaskMutation, -} from "~/db/mutations/tasks"; import { EndlessLoadingSpinner } from "./EndlessLoadingSpinner"; import { Progress } from "./ui/progress"; import { Badge } from "./ui/badge"; @@ -55,22 +50,30 @@ import { type TaskCardProps = { task: TaskWithRelations; columnTaskIds: string[]; + users: AppUser[]; + assigningTaskId?: string | null; showSprint?: boolean; showProject?: boolean; taskLinkTo?: "project" | "sprint" | "all"; gitSummary?: TaskGitSummary; closingStatusId?: string; manualSortEnabled?: boolean; + isSelected?: boolean; + isHovered?: boolean; }; -function TaskCardShell({ +const TaskCardShell = memo(function TaskCardShell({ task, columnTaskIds, + users, + assigningTaskId, showSprint, showProject, taskLinkTo, gitSummary, closingStatusId, + isSelected = false, + isHovered = false, listeners, setNodeRef, isDragging, @@ -81,14 +84,6 @@ function TaskCardShell({ isDragging: boolean; style: CSSProperties | undefined; }) { - const isSelected = useStore(TaskViewStore, (state) => - state.selectedTaskIds.includes(task.id) - ); - const isHovered = useStore( - TaskViewStore, - (state) => state.hoveredTaskId === task.id - ); - return (
); -} +}); function DraggableTaskCard(props: TaskCardProps) { const { attributes: _attributes, ...drag } = useDraggableTaskItem(props.task.id); @@ -151,16 +148,30 @@ function SortableTaskCard(props: TaskCardProps) { return ; } -export default function TaskCard({ +const TaskCard = memo(function TaskCard({ manualSortEnabled = false, ...props }: TaskCardProps) { + const isSelected = useStore(TaskViewStore, (state) => + state.selectedTaskIds.includes(props.task.id) + ); + const isHovered = useStore( + TaskViewStore, + (state) => state.hoveredTaskId === props.task.id + ); + const cardProps = { + ...props, + isSelected, + isHovered, + }; + return ( {manualSortEnabled ? ( - + ) : ( - + )} ); -} +}); + +export default TaskCard; const TaskCardLinkWrapper = ({ to, @@ -260,9 +273,11 @@ function TaskCardIndicatorTooltip({ ); } -export const TaskCardComponent = ({ +export const TaskCardComponent = memo(function TaskCardComponent({ task, columnTaskIds = [], + users, + assigningTaskId = null, showSprint = true, showProject = true, taskLinkTo, @@ -273,6 +288,8 @@ export const TaskCardComponent = ({ }: { task: TaskWithRelations; columnTaskIds?: string[]; + users: AppUser[]; + assigningTaskId?: string | null; showSprint?: boolean; showProject?: boolean; taskLinkTo?: "project" | "sprint" | "all"; @@ -280,40 +297,13 @@ export const TaskCardComponent = ({ isHovered?: boolean; gitSummary?: TaskGitSummary; closingStatusId?: string; -}) => { +}) { const isOverdue = isTaskOverdue(task, { closingStatusId }); const taskKeyLabel = task.project.taskKeyPrefix != null ? formatTaskKey(task.project.taskKeyPrefix, task.number) : null; - const usersQuery = useUsersQuery(); - const { data: session } = authClient.useSession(); - const currentUserId = session?.user.id; - const assignTask = useAssignTaskMutation(); - const unassignTask = useUnassignTaskMutation(); - const [isAssigning, setIsAssigning] = useState(false); - - const [isCardHovered, setIsCardHovered] = useState(false); - useEffect(() => { - const handleKeyPress = async (event: KeyboardEvent) => { - if (currentUserId && isCardHovered && event.key === "m") { - setIsAssigning(true); - if ( - task.assignees.find((assignee) => assignee.userId === currentUserId) - ) { - await unassignTask(task, [currentUserId]); - } else { - await assignTask(task, [currentUserId]); - } - setIsAssigning(false); - } - }; - - window.addEventListener("keydown", handleKeyPress); - return () => { - window.removeEventListener("keydown", handleKeyPress); - }; - }, [isCardHovered, task, currentUserId, assignTask, unassignTask]); + const isAssigning = assigningTaskId === task.id; const percentageComplete = Math.round( (task.subTasks.filter((t) => t.done).length / task.subTasks.length) * 100 @@ -342,8 +332,6 @@ export const TaskCardComponent = ({ isSelected && "border-l-[3px] border-l-primary bg-primary/5", isHovered && !isSelected && "bg-accent/30" )} - onMouseEnter={() => setIsCardHovered(true)} - onMouseLeave={() => setIsCardHovered(false)} > {showProject || task.labels.length > 0 ? ( @@ -443,7 +431,7 @@ export const TaskCardComponent = ({ {!isAssigning ? ( {task.assignees.map((taskAssignee) => { - const assignee = usersQuery.data.find( + const assignee = users.find( (u) => u.id === taskAssignee.userId ); if (!assignee) return null; @@ -483,4 +471,4 @@ export const TaskCardComponent = ({ ); -}; +}); diff --git a/src/components/views/BoardView.tsx b/src/components/views/BoardView.tsx index 8cdbf14..cefe0cf 100644 --- a/src/components/views/BoardView.tsx +++ b/src/components/views/BoardView.tsx @@ -21,12 +21,15 @@ import { import { TaskViewStore } from "./task-view-store"; import type { TaskViewProps } from "./task-view-types"; import { getClosingStatusId } from "~/lib/statuses"; +import { VirtualizedTaskCards } from "./VirtualizedTaskCards"; export const BoardView = ({ tasks, projectId, sprintId, statuses, + users, + assigningTaskId, location, updateTask, reorderTasks, @@ -46,8 +49,9 @@ export const BoardView = ({ const sensors = useTaskViewSensors(manualSortEnabled); const taskIds = useMemo(() => tasks.map((t) => t.id), [tasks]); + const taskIdsKey = useMemo(() => taskIds.join(","), [taskIds]); const { data: gitSummaries } = useQuery({ - queryKey: ["git", "summaries", taskIds], + queryKey: ["git", "summaries", taskIdsKey], queryFn: () => fetchTaskGitSummaries({ data: { taskIds } }), enabled: taskIds.length > 0, }); @@ -85,19 +89,27 @@ export const BoardView = ({ const renderColumnCards = (columnTasks: TaskViewProps["tasks"]) => { const sorted = sortColumnTasks(columnTasks); const columnTaskIds = sorted.map((t) => t.id); - const cards = sorted.map((task) => ( - ( + + )} /> - )); + ); if (!manualSortEnabled) return cards; @@ -164,6 +176,7 @@ export const BoardView = ({ {activeTask ? ( 0 ? (
- {manualSortEnabled ? ( + {sortedTasks.length > 25 ? ( + ( + + )} + /> + ) : manualSortEnabled ? ( `task:${task.id}`)} strategy={verticalListSortingStrategy} diff --git a/src/components/views/TaskViewsContainer.tsx b/src/components/views/TaskViewsContainer.tsx index ee6f4ca..deed08e 100644 --- a/src/components/views/TaskViewsContainer.tsx +++ b/src/components/views/TaskViewsContainer.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useStore } from "@tanstack/react-store"; import { BatchTaskToolbar } from "~/components/BatchTaskToolbar"; import { cn } from "~/lib/utils"; @@ -14,8 +14,19 @@ import { TaskViewStore } from "./task-view-store"; import { useSyncTaskViewModeFromPreferences } from "./use-sync-task-view-mode"; import { useSyncTaskSortFromPreferences } from "./use-sync-task-sort"; import { registerTaskReorderKeyboard } from "./task-reorder-keyboard"; +import { useUsersQuery } from "~/db/queries/users"; +import { authClient } from "~/lib/auth-client"; +import { + useAssignTaskMutation, + useUnassignTaskMutation, +} from "~/db/mutations/tasks"; + +export type TaskViewsContainerProps = Omit< + TaskViewProps, + "users" | "assigningTaskId" +>; -export function TaskViewsContainer(props: TaskViewProps) { +export function TaskViewsContainer(props: TaskViewsContainerProps) { useSyncTaskViewModeFromPreferences(); useSyncTaskSortFromPreferences( props.location, @@ -23,6 +34,14 @@ export function TaskViewsContainer(props: TaskViewProps) { props.sprintId ); + const usersQuery = useUsersQuery(); + const { data: session } = authClient.useSession(); + const currentUserId = session?.user.id; + const assignTask = useAssignTaskMutation(); + const unassignTask = useUnassignTaskMutation(); + const [assigningTaskId, setAssigningTaskId] = useState(null); + const hoveredTaskId = useStore(TaskViewStore, (state) => state.hoveredTaskId); + const viewMode = useStore(TaskViewStore, (state) => state.viewMode); const sortBy = useStore(TaskViewStore, (state) => state.sortBy); const sortDirection = useStore( @@ -65,14 +84,34 @@ export function TaskViewsContainer(props: TaskViewProps) { getStatuses: () => props.statuses, reorderTasks: props.reorderTasks, }), - [ - flatTaskIds, - props.tasks, - props.statuses, - props.reorderTasks, - ] + [flatTaskIds, props.tasks, props.statuses, props.reorderTasks] ); + useEffect(() => { + const handleKeyPress = async (event: KeyboardEvent) => { + if (!currentUserId || !hoveredTaskId || event.key !== "m") return; + + const task = props.tasks.find((entry) => entry.id === hoveredTaskId); + if (!task) return; + + setAssigningTaskId(task.id); + try { + if (task.assignees.some((assignee) => assignee.userId === currentUserId)) { + await unassignTask(task, [currentUserId]); + } else { + await assignTask(task, [currentUserId]); + } + } finally { + setAssigningTaskId(null); + } + }; + + window.addEventListener("keydown", handleKeyPress); + return () => { + window.removeEventListener("keydown", handleKeyPress); + }; + }, [assignTask, currentUserId, hoveredTaskId, props.tasks, unassignTask]); + const ViewComponent = TASK_VIEW_REGISTRY[viewMode]; return ( @@ -91,7 +130,11 @@ export function TaskViewsContainer(props: TaskViewProps) { selectedTasks.length > 0 ? "pb-16" : "pb-3" )} > - +
); diff --git a/src/components/views/VirtualizedTaskCards.tsx b/src/components/views/VirtualizedTaskCards.tsx new file mode 100644 index 0000000..5a7ab90 --- /dev/null +++ b/src/components/views/VirtualizedTaskCards.tsx @@ -0,0 +1,49 @@ +import { useVirtualizer } from "@tanstack/react-virtual"; +import { useEffect, useRef, type ReactNode } from "react"; + +type VirtualizedTaskCardsProps = { + items: T[]; + estimateSize?: number; + renderItem: (item: T, index: number) => ReactNode; +}; + +export function VirtualizedTaskCards({ + items, + estimateSize = 132, + renderItem, +}: VirtualizedTaskCardsProps) { + const listRef = useRef(null); + const scrollElementRef = useRef(null); + + useEffect(() => { + scrollElementRef.current = listRef.current?.parentElement ?? null; + }, [items.length]); + + const virtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => scrollElementRef.current, + estimateSize: () => estimateSize, + overscan: 6, + }); + + return ( +
+
+ {virtualizer.getVirtualItems().map((virtualItem) => ( +
+ {renderItem(items[virtualItem.index]!, virtualItem.index)} +
+ ))} +
+
+ ); +} diff --git a/src/components/views/VirtualizedTaskListRows.tsx b/src/components/views/VirtualizedTaskListRows.tsx new file mode 100644 index 0000000..6805c01 --- /dev/null +++ b/src/components/views/VirtualizedTaskListRows.tsx @@ -0,0 +1,44 @@ +import { useVirtualizer } from "@tanstack/react-virtual"; +import { useRef, type ReactNode } from "react"; + +type VirtualizedTaskListRowsProps = { + items: T[]; + estimateSize?: number; + renderItem: (item: T, index: number) => ReactNode; +}; + +export function VirtualizedTaskListRows({ + items, + estimateSize = 44, + renderItem, +}: VirtualizedTaskListRowsProps) { + const parentRef = useRef(null); + + const virtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => parentRef.current, + estimateSize: () => estimateSize, + overscan: 8, + }); + + return ( +
+
+ {virtualizer.getVirtualItems().map((virtualItem) => ( +
+ {renderItem(items[virtualItem.index]!, virtualItem.index)} +
+ ))} +
+
+ ); +} diff --git a/src/components/views/task-view-types.ts b/src/components/views/task-view-types.ts index 75ef5c1..9f9e526 100644 --- a/src/components/views/task-view-types.ts +++ b/src/components/views/task-view-types.ts @@ -1,4 +1,5 @@ import type { ComponentType } from "react"; +import type { AppUser } from "~/db/queries/users"; import type { Label, Status, @@ -15,6 +16,8 @@ export type TaskViewProps = { sprintId?: string; statuses: Status[]; labels: Label[]; + users: AppUser[]; + assigningTaskId: string | null; location: TaskViewLocation; updateTask: (task: UpdateTask) => Promise; reorderTasks: (input: { diff --git a/src/db/mutations/attachments.ts b/src/db/mutations/attachments.ts index 4cbaa37..dfdf469 100644 --- a/src/db/mutations/attachments.ts +++ b/src/db/mutations/attachments.ts @@ -8,7 +8,6 @@ import { requireSessionFromRequest } from "~/lib/session"; import { v7 as uuid } from "uuid"; import { getOwningIdentity } from "~/lib/utils"; import { useCallback } from "react"; -import { useRouter } from "@tanstack/react-router"; import { useQueryClient } from "@tanstack/react-query"; import z from "zod"; @@ -32,7 +31,6 @@ const createAttachment = createServerFn({ method: "POST" }) }); export function useCreateAttachmentMutation() { - const router = useRouter(); const queryClient = useQueryClient(); const _createAttachment = useServerFn(createAttachment); @@ -40,7 +38,6 @@ export function useCreateAttachmentMutation() { async (attachmentData: CreateAttachment) => { const result = await _createAttachment({ data: attachmentData }); - router.invalidate(); queryClient.invalidateQueries({ queryKey: ["attachments", attachmentData.taskId], }); @@ -50,7 +47,7 @@ export function useCreateAttachmentMutation() { return result; }, - [router, queryClient, _createAttachment] + [queryClient, _createAttachment] ); } @@ -63,7 +60,6 @@ export const deleteAttachment = createServerFn({ method: "POST" }) }); export function useDeleteAttachmentMutation() { - const router = useRouter(); const queryClient = useQueryClient(); const _deleteAttachment = useServerFn(deleteAttachment); @@ -71,7 +67,6 @@ export function useDeleteAttachmentMutation() { async (id: string) => { const result = await _deleteAttachment({ data: { id } }); - router.invalidate(); queryClient.invalidateQueries({ queryKey: ["attachments"], }); @@ -86,6 +81,6 @@ export function useDeleteAttachmentMutation() { return result; }, - [router, queryClient, _deleteAttachment] + [queryClient, _deleteAttachment] ); } diff --git a/src/db/mutations/comments.ts b/src/db/mutations/comments.ts index c01e1ab..a34df89 100644 --- a/src/db/mutations/comments.ts +++ b/src/db/mutations/comments.ts @@ -20,7 +20,6 @@ import { import { getActorNameFromSession } from "~/lib/notifications/actor-name"; import { stripHtml } from "~/lib/notifications/strip-html"; import { useCallback } from "react"; -import { useRouter } from "@tanstack/react-router"; import { useQueryClient } from "@tanstack/react-query"; import { sync } from "./sync"; @@ -120,7 +119,6 @@ const updateComment = createServerFn({ method: "POST" }) }); export function useCreateCommentMutation() { - const router = useRouter(); const queryClient = useQueryClient(); const _createComment = useServerFn(createComment); @@ -128,19 +126,17 @@ export function useCreateCommentMutation() { async (commentData: CreateComment) => { const result = await _createComment({ data: commentData }); - router.invalidate(); queryClient.invalidateQueries({ queryKey: ["comments", commentData.taskId], }); return result; }, - [router, queryClient, _createComment] + [queryClient, _createComment] ); } export function useUpdateCommentMutation() { - const router = useRouter(); const queryClient = useQueryClient(); const _updateComment = useServerFn(updateComment); @@ -149,13 +145,12 @@ export function useUpdateCommentMutation() { const { taskId, ...updateData } = commentData; const result = await _updateComment({ data: updateData }); - router.invalidate(); queryClient.invalidateQueries({ queryKey: ["comments", taskId], }); return result; }, - [router, queryClient, _updateComment] + [queryClient, _updateComment] ); } diff --git a/src/db/mutations/labels.ts b/src/db/mutations/labels.ts index a69e609..d122def 100644 --- a/src/db/mutations/labels.ts +++ b/src/db/mutations/labels.ts @@ -11,7 +11,6 @@ import { } from "~/db/schema"; import { v7 as uuid } from "uuid"; import z from "zod"; -import { useRouter } from "@tanstack/react-router"; import { useQueryClient } from "@tanstack/react-query"; import { useCallback } from "react"; import { and, eq } from "drizzle-orm"; @@ -34,7 +33,6 @@ const createLabel = createServerFn({ method: "POST" }) }); export function useCreateLabelMutation() { - const router = useRouter(); const queryClient = useQueryClient(); const _createLabel = useServerFn(createLabel); @@ -42,7 +40,6 @@ export function useCreateLabelMutation() { async (label: CreateLabel) => { const result = await _createLabel({ data: label }); - router.invalidate(); queryClient.invalidateQueries({ queryKey: ["labels"], }); @@ -52,7 +49,7 @@ export function useCreateLabelMutation() { return result; }, - [router, queryClient, _createLabel] + [queryClient, _createLabel] ); } @@ -77,7 +74,6 @@ const updateLabel = createServerFn({ method: "POST" }) }); export function useUpdateLabelMutation() { - const router = useRouter(); const queryClient = useQueryClient(); const _updateLabel = useServerFn(updateLabel); @@ -85,7 +81,6 @@ export function useUpdateLabelMutation() { async (label: UpdateLabel) => { const result = await _updateLabel({ data: label }); - router.invalidate(); queryClient.invalidateQueries({ queryKey: ["labels"], }); @@ -95,7 +90,7 @@ export function useUpdateLabelMutation() { return result; }, - [router, queryClient, _updateLabel] + [queryClient, _updateLabel] ); } @@ -128,7 +123,6 @@ const updateMultipleLabels = createServerFn({ method: "POST" }) }); export function useUpdateMultipleLabelsMutation() { - const router = useRouter(); const queryClient = useQueryClient(); const _updateMultipleLabels = useServerFn(updateMultipleLabels); @@ -155,11 +149,9 @@ export function useUpdateMultipleLabelsMutation() { queryClient.invalidateQueries({ queryKey: ["labels"] }); queryClient.invalidateQueries({ queryKey: ["labels-with-counts"] }); throw error; - } finally { - router.invalidate(); } }, - [router, queryClient, _updateMultipleLabels] + [queryClient, _updateMultipleLabels] ); } @@ -178,7 +170,6 @@ const deleteLabel = createServerFn({ method: "POST" }) }); export function useDeleteLabelMutation() { - const router = useRouter(); const queryClient = useQueryClient(); const _deleteLabel = useServerFn(deleteLabel); @@ -186,7 +177,6 @@ export function useDeleteLabelMutation() { async (id: string) => { const result = await _deleteLabel({ data: { id } }); - router.invalidate(); queryClient.invalidateQueries({ queryKey: ["labels"], }); @@ -196,6 +186,6 @@ export function useDeleteLabelMutation() { return result; }, - [router, queryClient, _deleteLabel] + [queryClient, _deleteLabel] ); } diff --git a/src/db/mutations/projects.ts b/src/db/mutations/projects.ts index 2c9e353..ce37209 100644 --- a/src/db/mutations/projects.ts +++ b/src/db/mutations/projects.ts @@ -12,7 +12,6 @@ import { db } from ".."; import { v7 as uuid } from "uuid"; import { getOwningIdentity } from "~/lib/utils"; import { useCallback } from "react"; -import { useRouter } from "@tanstack/react-router"; import { useQueryClient } from "@tanstack/react-query"; import { sync } from "./sync"; import { and, eq } from "drizzle-orm"; @@ -40,7 +39,6 @@ const createProject = createServerFn({ method: "POST" }) }); export function useCreateProjectMutation() { - const router = useRouter(); const queryClient = useQueryClient(); const _createProject = useServerFn(createProject); @@ -48,14 +46,13 @@ export function useCreateProjectMutation() { async (projectData: CreateProject) => { const result = await _createProject({ data: projectData }); - router.invalidate(); queryClient.invalidateQueries({ queryKey: ["projects"], }); return result; }, - [router, queryClient, _createProject] + [queryClient, _createProject] ); } @@ -83,7 +80,6 @@ const updateProject = createServerFn({ method: "POST" }) }); export function useUpdateProjectMutation() { - const router = useRouter(); const queryClient = useQueryClient(); const _updateProject = useServerFn(updateProject); @@ -91,14 +87,13 @@ export function useUpdateProjectMutation() { async (projectData: UpdateProject) => { const result = await _updateProject({ data: projectData }); - router.invalidate(); queryClient.invalidateQueries({ queryKey: ["projects", projectData.id], }); return result; }, - [router, queryClient, _updateProject] + [queryClient, _updateProject] ); } @@ -127,7 +122,6 @@ const deleteProject = createServerFn({ method: "POST" }) }); export function useDeleteProjectMutation() { - const router = useRouter(); const queryClient = useQueryClient(); const _deleteProject = useServerFn(deleteProject); @@ -135,7 +129,6 @@ export function useDeleteProjectMutation() { async (id: string) => { const result = await _deleteProject({ data: { id } }); - router.invalidate(); queryClient.invalidateQueries({ queryKey: ["projects"], }); @@ -145,6 +138,6 @@ export function useDeleteProjectMutation() { return result; }, - [router, queryClient, _deleteProject] + [queryClient, _deleteProject] ); } diff --git a/src/db/mutations/sprints.ts b/src/db/mutations/sprints.ts index 9bf3c84..d741170 100644 --- a/src/db/mutations/sprints.ts +++ b/src/db/mutations/sprints.ts @@ -13,7 +13,6 @@ import { getOwningIdentity } from "~/lib/utils"; import { v7 as uuid } from "uuid"; import { sync, syncDashboard } from "./sync"; import { useCallback } from "react"; -import { useRouter } from "@tanstack/react-router"; import { useQueryClient } from "@tanstack/react-query"; import { and, eq } from "drizzle-orm"; import { z } from "zod"; @@ -36,7 +35,6 @@ const createSprint = createServerFn({ method: "POST" }) }); export function useCreateSprintMutation() { - const router = useRouter(); const queryClient = useQueryClient(); const _createSprint = useServerFn(createSprint); @@ -44,14 +42,13 @@ export function useCreateSprintMutation() { async (sprint: CreateSprint) => { const result = await _createSprint({ data: sprint }); - router.invalidate(); queryClient.invalidateQueries({ queryKey: ["sprints"], }); return result; }, - [router, queryClient, _createSprint] + [queryClient, _createSprint] ); } @@ -73,7 +70,6 @@ const updateSprint = createServerFn({ method: "POST" }) }); export function useUpdateSprintMutation() { - const router = useRouter(); const queryClient = useQueryClient(); const _updateSprint = useServerFn(updateSprint); @@ -81,7 +77,6 @@ export function useUpdateSprintMutation() { async (sprint: UpdateSprint) => { const result = await _updateSprint({ data: sprint }); - router.invalidate(); queryClient.invalidateQueries({ queryKey: ["sprints"], }); @@ -91,7 +86,7 @@ export function useUpdateSprintMutation() { return result; }, - [router, queryClient, _updateSprint] + [queryClient, _updateSprint] ); } @@ -115,7 +110,6 @@ export const deleteSprint = createServerFn({ method: "POST" }) }); export function useDeleteSprintMutation() { - const router = useRouter(); const queryClient = useQueryClient(); const _deleteSprint = useServerFn(deleteSprint); @@ -123,7 +117,6 @@ export function useDeleteSprintMutation() { async (sprintId: string) => { const result = await _deleteSprint({ data: { sprintId } }); - router.invalidate(); queryClient.invalidateQueries({ queryKey: ["sprints"], }); @@ -133,6 +126,6 @@ export function useDeleteSprintMutation() { return result; }, - [router, queryClient, _deleteSprint] + [queryClient, _deleteSprint] ); } diff --git a/src/db/mutations/statuses.ts b/src/db/mutations/statuses.ts index 264136f..1262431 100644 --- a/src/db/mutations/statuses.ts +++ b/src/db/mutations/statuses.ts @@ -12,7 +12,6 @@ import { } from "~/db/schema"; import { v7 as uuid } from "uuid"; import z from "zod"; -import { useRouter } from "@tanstack/react-router"; import { useQueryClient } from "@tanstack/react-query"; import { useCallback } from "react"; import { and, eq, ne } from "drizzle-orm"; @@ -61,7 +60,6 @@ const createStatus = createServerFn({ method: "POST" }) }); export function useCreateStatusMutation() { - const router = useRouter(); const queryClient = useQueryClient(); const _createStatus = useServerFn(createStatus); @@ -69,7 +67,6 @@ export function useCreateStatusMutation() { async (status: CreateStatus) => { const result = await _createStatus({ data: status }); - router.invalidate(); queryClient.invalidateQueries({ queryKey: ["statuses"], }); @@ -79,7 +76,7 @@ export function useCreateStatusMutation() { return result; }, - [router, queryClient, _createStatus] + [queryClient, _createStatus] ); } @@ -110,7 +107,6 @@ const updateStatus = createServerFn({ method: "POST" }) }); export function useUpdateStatusMutation() { - const router = useRouter(); const queryClient = useQueryClient(); const _updateStatus = useServerFn(updateStatus); @@ -118,7 +114,6 @@ export function useUpdateStatusMutation() { async (status: UpdateStatus) => { const result = await _updateStatus({ data: status }); - router.invalidate(); queryClient.invalidateQueries({ queryKey: ["statuses"], }); @@ -128,7 +123,7 @@ export function useUpdateStatusMutation() { return result; }, - [router, queryClient, _updateStatus] + [queryClient, _updateStatus] ); } @@ -161,7 +156,6 @@ const updateMultipleStatuses = createServerFn({ method: "POST" }) }); export function useUpdateMultipleStatusesMutation() { - const router = useRouter(); const queryClient = useQueryClient(); const _updateMultipleStatuses = useServerFn(updateMultipleStatuses); @@ -188,11 +182,9 @@ export function useUpdateMultipleStatusesMutation() { queryClient.invalidateQueries({ queryKey: ["statuses"] }); queryClient.invalidateQueries({ queryKey: ["statuses-with-counts"] }); throw error; - } finally { - router.invalidate(); } }, - [router, queryClient, _updateMultipleStatuses] + [queryClient, _updateMultipleStatuses] ); } @@ -222,7 +214,6 @@ const deleteStatus = createServerFn({ method: "POST" }) }); export function useDeleteStatusMutation() { - const router = useRouter(); const queryClient = useQueryClient(); const _deleteStatus = useServerFn(deleteStatus); @@ -230,7 +221,6 @@ export function useDeleteStatusMutation() { async (id: string) => { const result = await _deleteStatus({ data: { id } }); - router.invalidate(); queryClient.invalidateQueries({ queryKey: ["statuses"], }); @@ -240,6 +230,6 @@ export function useDeleteStatusMutation() { return result; }, - [router, queryClient, _deleteStatus] + [queryClient, _deleteStatus] ); } diff --git a/src/db/mutations/sub-tasks.ts b/src/db/mutations/sub-tasks.ts index e6f9b51..55a15d0 100644 --- a/src/db/mutations/sub-tasks.ts +++ b/src/db/mutations/sub-tasks.ts @@ -15,7 +15,6 @@ import { v7 as uuid } from "uuid"; import { getOwningIdentity } from "~/lib/utils"; import { getActorNameFromSession } from "~/lib/notifications/actor-name"; import { sync } from "./sync"; -import { useRouter } from "@tanstack/react-router"; import { useQueryClient } from "@tanstack/react-query"; import { useCallback } from "react"; import { and, eq, inArray } from "drizzle-orm"; @@ -37,7 +36,6 @@ const createSubTask = createServerFn({ method: "POST" }) }); export function useCreateSubTaskMutation() { - const router = useRouter(); const queryClient = useQueryClient(); const _createSubTask = useServerFn(createSubTask); @@ -45,14 +43,13 @@ export function useCreateSubTaskMutation() { async (subTask: CreateSubTask) => { const result = await _createSubTask({ data: subTask }); - router.invalidate(); queryClient.invalidateQueries({ queryKey: ["tasks", subTask.taskId], }); return result; }, - [router, queryClient, _createSubTask] + [queryClient, _createSubTask] ); } @@ -77,7 +74,6 @@ const updateSubTask = createServerFn({ method: "POST" }) }); export function useUpdateSubTaskMutation() { - const router = useRouter(); const queryClient = useQueryClient(); const _updateSubTask = useServerFn(updateSubTask); @@ -104,7 +100,6 @@ export function useUpdateSubTaskMutation() { const result = await _updateSubTask({ data: subTask }); // On success, invalidate queries to ensure consistency - router.invalidate(); queryClient.invalidateQueries({ queryKey: ["tasks", subTask.taskId], }); @@ -119,7 +114,7 @@ export function useUpdateSubTaskMutation() { throw error; } }, - [router, queryClient, _updateSubTask] + [queryClient, _updateSubTask] ); } @@ -152,7 +147,6 @@ const deleteSubTask = createServerFn({ method: "POST" }) }); export function useDeleteSubTaskMutation() { - const router = useRouter(); const queryClient = useQueryClient(); const _deleteSubTask = useServerFn(deleteSubTask); @@ -161,7 +155,6 @@ export function useDeleteSubTaskMutation() { const result = await _deleteSubTask({ data: { id: subTaskId } }); if (!result) return; - router.invalidate(); queryClient.invalidateQueries({ queryKey: ["tasks", result.projectId], }); @@ -171,7 +164,7 @@ export function useDeleteSubTaskMutation() { return result; }, - [router, queryClient, _deleteSubTask] + [queryClient, _deleteSubTask] ); } @@ -205,7 +198,6 @@ const unassignSubTask = createServerFn({ method: "POST" }) }); export function useUnassignSubTaskMutation() { - const router = useRouter(); const queryClient = useQueryClient(); const _unassignSubTask = useServerFn(unassignSubTask); @@ -213,7 +205,6 @@ export function useUnassignSubTaskMutation() { async (subTask: SubTask, userIds: string[]) => { await _unassignSubTask({ data: { subTaskId: subTask.id, userIds } }); - router.invalidate(); queryClient.invalidateQueries({ queryKey: ["tasks", subTask.taskId], }); @@ -221,7 +212,7 @@ export function useUnassignSubTaskMutation() { queryKey: ["tasks", subTask.projectId], }); }, - [router, queryClient, _unassignSubTask] + [queryClient, _unassignSubTask] ); } @@ -288,7 +279,6 @@ const assignSubTask = createServerFn({ method: "POST" }) }); export function useAssignSubTaskMutation() { - const router = useRouter(); const queryClient = useQueryClient(); const _assignSubTask = useServerFn(assignSubTask); @@ -296,7 +286,6 @@ export function useAssignSubTaskMutation() { async (subTask: SubTask, userIds: string[]) => { await _assignSubTask({ data: { subTaskId: subTask.id, userIds } }); - router.invalidate(); queryClient.invalidateQueries({ queryKey: ["tasks", subTask.taskId], }); @@ -304,6 +293,6 @@ export function useAssignSubTaskMutation() { queryKey: ["tasks", subTask.projectId], }); }, - [router, queryClient, _assignSubTask] + [queryClient, _assignSubTask] ); } diff --git a/src/db/mutations/sync.ts b/src/db/mutations/sync.ts index 8fba4c2..bb2c3f9 100644 --- a/src/db/mutations/sync.ts +++ b/src/db/mutations/sync.ts @@ -25,30 +25,59 @@ export async function syncDashboard(owner: string, payload: unknown = {}) { await sync(ownerDashboardTopic(owner), payload); } +const COARSE_TOPIC_PREFIXES: Record = { + "task-update-": "task-update", + "project-update-": "project-update", + "sprint-update-": "sprint-update", + "status-update-": "status-update", + "label-update-": "label-update", + "comment-update-": "comment-update", + "attachment-update-": "attachment-update", +}; + +async function publishSyncTopic( + baseUrl: string, + topic: string, + payload: unknown +) { + const response = await fetch( + `${baseUrl}/stream/${env.SYNC_APP_ID}?key=${env.SYNC_PUBLISH_KEY}&topic=${topic}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ topic, payload }), + } + ); + + if (!response.ok) { + const body = await response.text(); + console.error( + `Sync failed for topic "${topic}" (app ${env.SYNC_APP_ID}, ${baseUrl}): ${response.status} ${body}` + ); + } +} + export async function sync(topic: string, payload: unknown) { const baseUrl = resolveSyncEngineUrl(); try { - const response = await fetch( - `${baseUrl}/stream/${env.SYNC_APP_ID}?key=${env.SYNC_PUBLISH_KEY}&topic=${topic}`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ payload }), - }, - ); + await publishSyncTopic(baseUrl, topic, payload); - if (!response.ok) { - const body = await response.text(); - console.error( - `Sync failed for topic "${topic}" (app ${env.SYNC_APP_ID}, ${baseUrl}): ${response.status} ${body}`, - ); + for (const [prefix, coarseTopic] of Object.entries(COARSE_TOPIC_PREFIXES)) { + if (!topic.startsWith(prefix)) continue; + const entityId = topic.slice(prefix.length); + await publishSyncTopic(baseUrl, coarseTopic, { + topic: coarseTopic, + entityId, + payload, + }); + break; } } catch (error) { console.error( `Sync error for topic "${topic}" (app ${env.SYNC_APP_ID}, ${baseUrl}):`, - error, + error ); } } diff --git a/src/db/mutations/tasks.ts b/src/db/mutations/tasks.ts index dc54ebe..97286ff 100644 --- a/src/db/mutations/tasks.ts +++ b/src/db/mutations/tasks.ts @@ -18,7 +18,6 @@ import { updateTaskValidator, } from "~/db/schema"; import { v7 as uuid } from "uuid"; -import { useRouter } from "@tanstack/react-router"; import { useQueryClient } from "@tanstack/react-query"; import { useCallback } from "react"; import { and, eq, inArray, isNull, max } from "drizzle-orm"; @@ -277,7 +276,6 @@ const createTask = createServerFn({ method: "POST" }) }); export function useCreateTaskMutation() { - const router = useRouter(); const queryClient = useQueryClient(); const _createTask = useServerFn(createTask); @@ -287,7 +285,6 @@ export function useCreateTaskMutation() { data: { ...task, updatedAt: new Date() }, }); - router.invalidate(); queryClient.invalidateQueries({ queryKey: ["tasks", task.projectId], }); @@ -300,7 +297,7 @@ export function useCreateTaskMutation() { return result; }, - [router, queryClient, _createTask] + [queryClient, _createTask] ); } @@ -395,7 +392,6 @@ const unassignTask = createServerFn({ method: "POST" }) // Update the hook interfaces export function useAssignTaskMutation() { - const router = useRouter(); const queryClient = useQueryClient(); const _assignTask = useServerFn(assignTask); @@ -403,7 +399,6 @@ export function useAssignTaskMutation() { async (task: Task, userIds: string[]) => { await _assignTask({ data: { taskId: task.id, userIds } }); - router.invalidate(); queryClient.invalidateQueries({ queryKey: ["tasks", task.projectId], }); @@ -416,12 +411,11 @@ export function useAssignTaskMutation() { }); } }, - [router, queryClient, _assignTask] + [queryClient, _assignTask] ); } export function useUnassignTaskMutation() { - const router = useRouter(); const queryClient = useQueryClient(); const _unassignTask = useServerFn(unassignTask); @@ -429,7 +423,6 @@ export function useUnassignTaskMutation() { async (task: Task, userIds: string[]) => { await _unassignTask({ data: { taskId: task.id, userIds } }); - router.invalidate(); queryClient.invalidateQueries({ queryKey: ["tasks", task.projectId], }); @@ -442,7 +435,7 @@ export function useUnassignTaskMutation() { }); } }, - [router, queryClient, _unassignTask] + [queryClient, _unassignTask] ); } @@ -474,7 +467,6 @@ const updateTask = createServerFn({ method: "POST" }) }); export function useUpdateTaskMutation() { - const router = useRouter(); const queryClient = useQueryClient(); const _updateTask = useServerFn(updateTask); @@ -511,17 +503,9 @@ export function useUpdateTaskMutation() { ); try { - const result = await _updateTask({ + return await _updateTask({ data: task, }); - - for (const queryKey of listQueryKeys) { - queryClient.invalidateQueries({ queryKey }); - } - queryClient.invalidateQueries({ queryKey: detailQueryKey }); - router.invalidate(); - - return result; } catch (error) { for (const { queryKey, data } of snapshots) { queryClient.setQueryData(queryKey, data); @@ -535,7 +519,7 @@ export function useUpdateTaskMutation() { throw error; } }, - [router, queryClient, _updateTask] + [queryClient, _updateTask] ); } @@ -576,7 +560,6 @@ const deleteTask = createServerFn({ method: "POST" }) }); export function useDeleteTaskMutation() { - const router = useRouter(); const queryClient = useQueryClient(); const _deleteTask = useServerFn(deleteTask); @@ -584,7 +567,6 @@ export function useDeleteTaskMutation() { async (id: string) => { const result = await _deleteTask({ data: { id } }); - router.invalidate(); queryClient.invalidateQueries({ queryKey: ["tasks"], }); @@ -594,7 +576,7 @@ export function useDeleteTaskMutation() { return result; }, - [router, queryClient, _deleteTask] + [queryClient, _deleteTask] ); } @@ -640,7 +622,6 @@ const setLabelsForTask = createServerFn({ method: "POST" }) }); export function useSetLabelsForTaskMutation() { - const router = useRouter(); const queryClient = useQueryClient(); const _setLabelsForTask = useServerFn(setLabelsForTask); @@ -661,12 +642,25 @@ export function useSetLabelsForTaskMutation() { await _setLabelsForTask({ data: { taskId: task.id, labelIds } }); - router.invalidate(); - queryClient.invalidateQueries({ queryKey: ["tasks"] }); - queryClient.invalidateQueries({ queryKey: ["tasks", task.projectId] }); - queryClient.invalidateQueries({ queryKey: ["tasks", task.id] }); + const listQueryKeys = getTaskListQueryKeys(queryClient, task); + const patchList = (oldData: TaskWithRelations[] | undefined) => { + if (!oldData) return oldData; + return oldData.map((entry) => + entry.id === task.id + ? { + ...entry, + labels: labelIds.map( + (id) => labels.find((label) => label.id === id)! + ), + } + : entry + ); + }; + for (const queryKey of listQueryKeys) { + queryClient.setQueryData(queryKey, patchList); + } }, - [router, queryClient, _setLabelsForTask] + [queryClient, _setLabelsForTask] ); } @@ -875,7 +869,6 @@ function snapshotTaskQueries( } export function useBatchUpdateTasksMutation() { - const router = useRouter(); const queryClient = useQueryClient(); const _batchUpdateTasks = useServerFn(batchUpdateTasks); @@ -910,13 +903,6 @@ export function useBatchUpdateTasksMutation() { try { await _batchUpdateTasks({ data: { taskIds, ...patch } }); - for (const queryKey of listQueryKeys) { - queryClient.invalidateQueries({ queryKey }); - } - for (const id of taskIds) { - queryClient.invalidateQueries({ queryKey: ["tasks", id] }); - } - router.invalidate(); } catch (error) { for (const { queryKey, data } of snapshots) { queryClient.setQueryData(queryKey, data); @@ -925,12 +911,11 @@ export function useBatchUpdateTasksMutation() { throw error; } }, - [router, queryClient, _batchUpdateTasks] + [queryClient, _batchUpdateTasks] ); } export function useBatchDeleteTasksMutation() { - const router = useRouter(); const queryClient = useQueryClient(); const _batchDeleteTasks = useServerFn(batchDeleteTasks); @@ -954,8 +939,6 @@ export function useBatchDeleteTasksMutation() { try { await _batchDeleteTasks({ data: { taskIds } }); - queryClient.invalidateQueries({ queryKey: ["tasks"] }); - router.invalidate(); } catch (error) { for (const { queryKey, data } of snapshots) { queryClient.setQueryData(queryKey, data); @@ -964,12 +947,11 @@ export function useBatchDeleteTasksMutation() { throw error; } }, - [router, queryClient, _batchDeleteTasks] + [queryClient, _batchDeleteTasks] ); } export function useBatchAssignTasksMutation() { - const router = useRouter(); const queryClient = useQueryClient(); const _batchAssignTasks = useServerFn(batchAssignTasks); @@ -1035,13 +1017,6 @@ export function useBatchAssignTasksMutation() { try { await _batchAssignTasks({ data: { taskIds, userIds } }); - for (const queryKey of listQueryKeys) { - queryClient.invalidateQueries({ queryKey }); - } - for (const id of taskIds) { - queryClient.invalidateQueries({ queryKey: ["tasks", id] }); - } - router.invalidate(); } catch (error) { for (const { queryKey, data } of snapshots) { queryClient.setQueryData(queryKey, data); @@ -1050,12 +1025,11 @@ export function useBatchAssignTasksMutation() { throw error; } }, - [router, queryClient, _batchAssignTasks] + [queryClient, _batchAssignTasks] ); } export function useBatchSetLabelsForTasksMutation() { - const router = useRouter(); const queryClient = useQueryClient(); const _batchSetLabelsForTasks = useServerFn(batchSetLabelsForTasks); @@ -1097,13 +1071,6 @@ export function useBatchSetLabelsForTasksMutation() { try { await _batchSetLabelsForTasks({ data: { taskIds, labelIds } }); - for (const queryKey of listQueryKeys) { - queryClient.invalidateQueries({ queryKey }); - } - for (const id of taskIds) { - queryClient.invalidateQueries({ queryKey: ["tasks", id] }); - } - router.invalidate(); } catch (error) { for (const { queryKey, data } of snapshots) { queryClient.setQueryData(queryKey, data); @@ -1112,7 +1079,7 @@ export function useBatchSetLabelsForTasksMutation() { throw error; } }, - [router, queryClient, _batchSetLabelsForTasks] + [queryClient, _batchSetLabelsForTasks] ); } @@ -1294,7 +1261,6 @@ const moveTaskInBucket = createServerFn({ method: "POST" }) }); export function useReorderTasksMutation() { - const router = useRouter(); const queryClient = useQueryClient(); const _reorderTasks = useServerFn(reorderTasks); @@ -1325,11 +1291,6 @@ export function useReorderTasksMutation() { try { await _reorderTasks({ data: input }); - for (const queryKey of listQueryKeys) { - queryClient.invalidateQueries({ queryKey }); - } - queryClient.invalidateQueries({ queryKey: allTasksListQueryKey }); - router.invalidate(); } catch (error) { for (const { queryKey, data } of snapshots) { queryClient.setQueryData(queryKey, data); @@ -1338,12 +1299,11 @@ export function useReorderTasksMutation() { throw error; } }, - [router, queryClient, _reorderTasks] + [queryClient, _reorderTasks] ); } export function useMoveTaskInBucketMutation() { - const router = useRouter(); const queryClient = useQueryClient(); const _moveTaskInBucket = useServerFn(moveTaskInBucket); @@ -1360,14 +1320,43 @@ export function useMoveTaskInBucketMutation() { listQueryKeys ); + const patchMovedTask = (oldData: TaskWithRelations[] | undefined) => { + if (!oldData) return oldData; + const taskIndex = oldData.findIndex((task) => task.id === input.taskId); + if (taskIndex === -1) return oldData; + + const movedTask = { + ...oldData[taskIndex]!, + statusId: input.statusId, + updatedAt: new Date(), + }; + const withoutTask = oldData.filter((task) => task.id !== input.taskId); + const next = [...withoutTask]; + next.splice( + Math.min(input.targetIndex, next.length), + 0, + movedTask + ); + return next; + }; + + for (const queryKey of listQueryKeys) { + queryClient.setQueryData(queryKey, patchMovedTask); + } + queryClient.setQueryData( + ["tasks", input.taskId], + (oldData: TaskWithRelations | undefined) => { + if (!oldData) return oldData; + return { + ...oldData, + statusId: input.statusId, + updatedAt: new Date(), + }; + } + ); + try { await _moveTaskInBucket({ data: input }); - for (const queryKey of listQueryKeys) { - queryClient.invalidateQueries({ queryKey }); - } - queryClient.invalidateQueries({ queryKey: ["tasks", input.taskId] }); - queryClient.invalidateQueries({ queryKey: allTasksListQueryKey }); - router.invalidate(); } catch (error) { for (const { queryKey, data } of snapshots) { queryClient.setQueryData(queryKey, data); @@ -1376,6 +1365,6 @@ export function useMoveTaskInBucketMutation() { throw error; } }, - [router, queryClient, _moveTaskInBucket] + [queryClient, _moveTaskInBucket] ); } diff --git a/src/db/queries/attachments.ts b/src/db/queries/attachments.ts index 89a4adf..1119fd2 100644 --- a/src/db/queries/attachments.ts +++ b/src/db/queries/attachments.ts @@ -3,7 +3,6 @@ import { createServerFn } from "@tanstack/react-start"; import { db } from ".."; import { getOwningIdentity } from "~/lib/utils"; import { queryOptions, useSuspenseQuery } from "@tanstack/react-query"; -import { useEventSource } from "~/hooks/use-event-source"; const fetchAttachmentsForTask = createServerFn({ method: "GET" }) .inputValidator((data?: string) => data) @@ -11,7 +10,6 @@ const fetchAttachmentsForTask = createServerFn({ method: "GET" }) if (!data) { return []; } - console.info(`Fetching attachments for... ${data}`); const session = await requireSessionFromRequest(); return await db.query.attachments.findMany({ where: (model, { eq, and }) => @@ -27,18 +25,5 @@ export const attachmentsQueryOptions = (taskId?: string) => }); export const useAttachmentsQuery = (taskId: string) => { - const queryData = useSuspenseQuery(attachmentsQueryOptions(taskId)); - - useEventSource({ - topics: [ - "attachment-create", - "attachment-delete", - ...queryData.data.map((t) => `attachment-update-${t.id}`), - ], - callback: () => { - queryData.refetch(); - }, - }); - - return { ...queryData }; + return useSuspenseQuery(attachmentsQueryOptions(taskId)); }; diff --git a/src/db/queries/bundles.ts b/src/db/queries/bundles.ts index 27e1266..3806ffb 100644 --- a/src/db/queries/bundles.ts +++ b/src/db/queries/bundles.ts @@ -16,6 +16,7 @@ export type ProjectBoardBundle = { project?: Project; tasks: TaskWithRelations[]; statuses: Status[]; + labels: Label[]; users: AppUser[]; }; @@ -23,12 +24,14 @@ export type SprintBoardBundle = { sprint: Sprint | null | undefined; tasks: TaskWithRelations[]; statuses: Status[]; + labels: Label[]; users: AppUser[]; }; export type AllTasksBoardBundle = { tasks: TaskWithRelations[]; statuses: Status[]; + labels: Label[]; users: AppUser[]; }; @@ -61,7 +64,7 @@ export const fetchProjectBoardBundle = createServerFn({ method: "GET" }) const owner = getOwningIdentity(session); const { getUsersForSession } = await import("~/db/queries/users.server"); - const [project, rawTasks, statuses, users] = await Promise.all([ + const [project, rawTasks, statuses, labels, users] = await Promise.all([ db.query.projects.findFirst({ where: (model, { eq, and }) => and(eq(model.id, projectId), eq(model.owner, owner)), @@ -75,6 +78,10 @@ export const fetchProjectBoardBundle = createServerFn({ method: "GET" }) where: (model, { eq }) => eq(model.owner, owner), orderBy: (fields, { asc: ascFn }) => [ascFn(fields.order)], }), + db.query.labels.findMany({ + where: (model, { eq }) => eq(model.owner, owner), + orderBy: (fields, { asc: ascFn }) => [ascFn(fields.order)], + }), getUsersForSession(session), ]); @@ -84,6 +91,7 @@ export const fetchProjectBoardBundle = createServerFn({ method: "GET" }) includeProject: false, }), statuses: statuses as Status[], + labels: labels as Label[], users, }; } @@ -99,7 +107,7 @@ export const fetchSprintBoardBundle = createServerFn({ method: "GET" }) const owner = getOwningIdentity(session); const { getUsersForSession } = await import("~/db/queries/users.server"); - const [sprint, rawTasks, statuses, users] = await Promise.all([ + const [sprint, rawTasks, statuses, labels, users] = await Promise.all([ db.query.sprints.findFirst({ where: (model, { eq, and }) => and(eq(model.id, sprintId), eq(model.owner, owner)), @@ -113,6 +121,10 @@ export const fetchSprintBoardBundle = createServerFn({ method: "GET" }) where: (model, { eq }) => eq(model.owner, owner), orderBy: (fields, { asc: ascFn }) => [ascFn(fields.order)], }), + db.query.labels.findMany({ + where: (model, { eq }) => eq(model.owner, owner), + orderBy: (fields, { asc: ascFn }) => [ascFn(fields.order)], + }), getUsersForSession(session), ]); @@ -120,6 +132,7 @@ export const fetchSprintBoardBundle = createServerFn({ method: "GET" }) sprint, tasks: mapBoardTasks(rawTasks, { includeProject: true }), statuses: statuses as Status[], + labels: labels as Label[], users, }; } @@ -131,7 +144,7 @@ export const fetchAllTasksBoardBundle = createServerFn({ method: "GET" }).handle const owner = getOwningIdentity(session); const { getUsersForSession } = await import("~/db/queries/users.server"); - const [rawTasks, statuses, users] = await Promise.all([ + const [rawTasks, statuses, labels, users] = await Promise.all([ db.query.tasks.findMany({ with: boardTaskRelationsForSprint, where: (model, { eq }) => eq(model.owner, owner), @@ -140,12 +153,17 @@ export const fetchAllTasksBoardBundle = createServerFn({ method: "GET" }).handle where: (model, { eq }) => eq(model.owner, owner), orderBy: (fields, { asc: ascFn }) => [ascFn(fields.order)], }), + db.query.labels.findMany({ + where: (model, { eq }) => eq(model.owner, owner), + orderBy: (fields, { asc: ascFn }) => [ascFn(fields.order)], + }), getUsersForSession(session), ]); return { tasks: mapBoardTasks(rawTasks, { includeProject: true }), statuses: statuses as Status[], + labels: labels as Label[], users, }; } diff --git a/src/db/queries/comments.ts b/src/db/queries/comments.ts index c738b56..0a93ab9 100644 --- a/src/db/queries/comments.ts +++ b/src/db/queries/comments.ts @@ -2,7 +2,6 @@ import { requireSessionFromRequest } from "~/lib/session"; import { createServerFn } from "@tanstack/react-start"; import { getOwningIdentity } from "~/lib/utils"; import { queryOptions, useSuspenseQuery } from "@tanstack/react-query"; -import { useEventSource } from "~/hooks/use-event-source"; export type { CommentWithAuthor } from "~/db/queries/comments.server"; @@ -25,17 +24,5 @@ export const commentsQueryOptions = (taskId?: string) => }); export const useCommentsQuery = (taskId: string) => { - const queryData = useSuspenseQuery(commentsQueryOptions(taskId)); - - useEventSource({ - topics: [ - "comment-create", - ...queryData.data.map((t) => `comment-update-${t.id}`), - ], - callback: () => { - queryData.refetch(); - }, - }); - - return { ...queryData }; + return useSuspenseQuery(commentsQueryOptions(taskId)); }; diff --git a/src/db/queries/dashboard.ts b/src/db/queries/dashboard.ts index 88f4c30..2db36ac 100644 --- a/src/db/queries/dashboard.ts +++ b/src/db/queries/dashboard.ts @@ -13,8 +13,6 @@ import { } from "drizzle-orm"; import { db } from "~/db"; import { Sprint, statuses, tasks } from "~/db/schema"; -import { ownerDashboardTopic } from "~/lib/owner-dashboard-topic"; -import { useEventSource } from "~/hooks/use-event-source"; import { requireSessionFromRequest } from "~/lib/session"; import { getOwningIdentity } from "~/lib/utils"; @@ -98,14 +96,5 @@ export const dashboardStatsQueryOptions = () => }); export const useDashboardStatsQuery = () => { - const queryData = useSuspenseQuery(dashboardStatsQueryOptions()); - - useEventSource({ - topics: [ownerDashboardTopic(queryData.data.owner)], - callback: () => { - queryData.refetch(); - }, - }); - - return { ...queryData }; + return useSuspenseQuery(dashboardStatsQueryOptions()); }; diff --git a/src/db/queries/hydrate-query-cache.ts b/src/db/queries/hydrate-query-cache.ts index c2cbcf0..bcc1e4b 100644 --- a/src/db/queries/hydrate-query-cache.ts +++ b/src/db/queries/hydrate-query-cache.ts @@ -54,6 +54,10 @@ export function hydrateProjectBoardCache( statusesQueryOptions().queryKey, data.statuses as StatusesCache ); + queryClient.setQueryData( + labelsQueryOptions().queryKey, + data.labels as LabelsCache + ); queryClient.setQueryData(usersQueryOptions().queryKey, data.users); } @@ -66,6 +70,10 @@ export function hydrateAllTasksBoardCache( statusesQueryOptions().queryKey, data.statuses as StatusesCache ); + queryClient.setQueryData( + labelsQueryOptions().queryKey, + data.labels as LabelsCache + ); queryClient.setQueryData(usersQueryOptions().queryKey, data.users); } @@ -85,6 +93,10 @@ export function hydrateSprintBoardCache( statusesQueryOptions().queryKey, data.statuses as StatusesCache ); + queryClient.setQueryData( + labelsQueryOptions().queryKey, + data.labels as LabelsCache + ); queryClient.setQueryData(usersQueryOptions().queryKey, data.users); } diff --git a/src/db/queries/labels.ts b/src/db/queries/labels.ts index 0194168..cbacea7 100644 --- a/src/db/queries/labels.ts +++ b/src/db/queries/labels.ts @@ -5,11 +5,9 @@ import { labels, labelsToTasks } from "~/db/schema"; import { asc, eq, sql } from "drizzle-orm"; import { requireSessionFromRequest } from "~/lib/session"; import { getOwningIdentity } from "~/lib/utils"; -import { useEventSource } from "~/hooks/use-event-source"; export const fetchLabels = createServerFn({ method: "GET" }).handler( async () => { - console.info("Fetching labels..."); const session = await requireSessionFromRequest(); return await db.query.labels.findMany({ where: (model, { eq }) => eq(model.owner, getOwningIdentity(session)), @@ -26,26 +24,12 @@ export const labelsQueryOptions = () => }); export const useLabelsQuery = () => { - const queryData = useSuspenseQuery(labelsQueryOptions()); - - useEventSource({ - topics: [ - "label-create", - "label-delete", - ...queryData.data.map((t) => `label-update-${t.id}`), - ], - callback: () => { - queryData.refetch(); - }, - }); - - return { ...queryData }; + return useSuspenseQuery(labelsQueryOptions()); }; export const fetchLabelsWithTaskCounts = createServerFn({ method: "GET", }).handler(async () => { - console.info("Fetching labels with task counts..."); const session = await requireSessionFromRequest(); const labelsWithCounts = await db .select({ @@ -73,18 +57,5 @@ export const labelsWithCountsQueryOptions = () => }); export const useLabelsWithCountsQuery = () => { - const queryData = useSuspenseQuery(labelsWithCountsQueryOptions()); - - useEventSource({ - topics: [ - "label-create", - "label-delete", - ...queryData.data.map((t) => `label-update-${t.id}`), - ], - callback: () => { - queryData.refetch(); - }, - }); - - return { ...queryData }; + return useSuspenseQuery(labelsWithCountsQueryOptions()); }; diff --git a/src/db/queries/notifications.ts b/src/db/queries/notifications.ts index 1c57f11..8d4088d 100644 --- a/src/db/queries/notifications.ts +++ b/src/db/queries/notifications.ts @@ -3,9 +3,7 @@ import { createServerFn } from "@tanstack/react-start"; import { and, count, desc, eq, isNull } from "drizzle-orm"; import { db } from "~/db"; import { inAppNotifications } from "~/db/schema"; -import { useEventSource } from "~/hooks/use-event-source"; import { authClient } from "~/lib/auth-client"; -import { userNotificationsTopic } from "~/lib/user-notifications-topic"; import { requireSessionFromRequest } from "~/lib/session"; export type InAppNotificationFeed = { @@ -69,17 +67,8 @@ export const inAppNotificationFeedQueryOptions = () => export function useInAppNotificationFeedQuery() { const { data: session } = authClient.useSession(); - const query = useQuery({ + return useQuery({ ...inAppNotificationFeedQueryOptions(), enabled: Boolean(session), }); - - useEventSource({ - topics: session ? [userNotificationsTopic(session.user.id)] : [], - callback: () => { - void query.refetch(); - }, - }); - - return query; } diff --git a/src/db/queries/projects.ts b/src/db/queries/projects.ts index 8dd41c2..aa30f0f 100644 --- a/src/db/queries/projects.ts +++ b/src/db/queries/projects.ts @@ -8,10 +8,8 @@ import { } from "~/lib/project-order"; import { loadUserPreferences } from "~/lib/user-preferences.server"; import { getOwningIdentity } from "~/lib/utils"; -import { useEventSource } from "~/hooks/use-event-source"; const fetchProjects = createServerFn({ method: "GET" }).handler(async () => { - console.info("Fetching projects..."); const session = await requireSessionFromRequest(); const owner = getOwningIdentity(session); const [projects, preferences] = await Promise.all([ @@ -34,26 +32,12 @@ export const projectsQueryOptions = () => }); export const useProjectsQuery = () => { - const queryData = useSuspenseQuery(projectsQueryOptions()); - - useEventSource({ - topics: [ - "project-create", - "project-delete", - ...queryData.data.map((t) => `project-update-${t.id}`), - ], - callback: () => { - queryData.refetch(); - }, - }); - - return { ...queryData }; + return useSuspenseQuery(projectsQueryOptions()); }; const fetchProject = createServerFn({ method: "GET" }) .inputValidator((d: { projectId: string }) => d) .handler(async ({ data: { projectId } }) => { - console.info("Fetching project..."); const session = await requireSessionFromRequest(); return await db.query.projects.findFirst({ where: (model, { eq, and }) => @@ -68,14 +52,5 @@ export const projectQueryOptions = (projectId: string) => }); export const useProjectQuery = (projectId: string) => { - const queryData = useSuspenseQuery(projectQueryOptions(projectId)); - - useEventSource({ - topics: [`project-update-${projectId}`], - callback: () => { - queryData.refetch(); - }, - }); - - return { ...queryData }; + return useSuspenseQuery(projectQueryOptions(projectId)); }; diff --git a/src/db/queries/sprints.ts b/src/db/queries/sprints.ts index e057a73..f714dc6 100644 --- a/src/db/queries/sprints.ts +++ b/src/db/queries/sprints.ts @@ -3,10 +3,8 @@ import { createServerFn } from "@tanstack/react-start"; import { db } from "~/db"; import { requireSessionFromRequest } from "~/lib/session"; import { getOwningIdentity } from "~/lib/utils"; -import { useEventSource } from "~/hooks/use-event-source"; const fetchSprints = createServerFn({ method: "GET" }).handler(async () => { - console.info("Fetching sprints..."); const session = await requireSessionFromRequest(); return await db.query.sprints.findMany({ @@ -22,26 +20,12 @@ export const sprintsQueryOptions = () => }); export const useSprintsQuery = () => { - const queryData = useSuspenseQuery(sprintsQueryOptions()); - - useEventSource({ - topics: [ - "sprint-create", - "sprint-delete", - ...queryData.data.map((t) => `sprint-update-${t.id}`), - ], - callback: () => { - queryData.refetch(); - }, - }); - - return { ...queryData }; + return useSuspenseQuery(sprintsQueryOptions()); }; const fetchSprint = createServerFn({ method: "GET" }) .inputValidator((d: { sprintId: string }) => d) .handler(async ({ data: { sprintId } }) => { - console.info("Fetching sprint..."); const session = await requireSessionFromRequest(); return await db.query.sprints.findFirst({ @@ -58,14 +42,5 @@ export const sprintQueryOptions = (sprintId: string) => }); export const useSprintQuery = (sprintId: string) => { - const queryData = useSuspenseQuery(sprintQueryOptions(sprintId)); - - useEventSource({ - topics: [`sprint-update-${sprintId}`], - callback: () => { - queryData.refetch(); - }, - }); - - return { ...queryData }; + return useSuspenseQuery(sprintQueryOptions(sprintId)); }; diff --git a/src/db/queries/statuses.ts b/src/db/queries/statuses.ts index bd56084..5fadc15 100644 --- a/src/db/queries/statuses.ts +++ b/src/db/queries/statuses.ts @@ -5,11 +5,9 @@ import { statuses, tasks } from "~/db/schema"; import { asc, eq, sql } from "drizzle-orm"; import { requireSessionFromRequest } from "~/lib/session"; import { getOwningIdentity } from "~/lib/utils"; -import { useEventSource } from "~/hooks/use-event-source"; export const fetchStatuses = createServerFn({ method: "GET" }).handler( async () => { - console.info("Fetching statuses..."); const session = await requireSessionFromRequest(); return await db.query.statuses.findMany({ @@ -27,26 +25,12 @@ export const statusesQueryOptions = () => }); export const useStatusesQuery = () => { - const queryData = useSuspenseQuery(statusesQueryOptions()); - - useEventSource({ - topics: [ - "status-create", - "status-delete", - ...queryData.data.map((t) => `status-update-${t.id}`), - ], - callback: () => { - queryData.refetch(); - }, - }); - - return { ...queryData }; + return useSuspenseQuery(statusesQueryOptions()); }; export const fetchStatusesWithTaskCounts = createServerFn({ method: "GET", }).handler(async () => { - console.info("Fetching statuses with task counts..."); const session = await requireSessionFromRequest(); const statusesWithCounts = await db @@ -76,18 +60,5 @@ export const statusesWithCountsQueryOptions = () => }); export const useStatusesWithCountsQuery = () => { - const queryData = useSuspenseQuery(statusesWithCountsQueryOptions()); - - useEventSource({ - topics: [ - "status-create", - "status-delete", - ...queryData.data.map((t) => `status-update-${t.id}`), - ], - callback: () => { - queryData.refetch(); - }, - }); - - return { ...queryData }; + return useSuspenseQuery(statusesWithCountsQueryOptions()); }; diff --git a/src/db/queries/tasks.ts b/src/db/queries/tasks.ts index c809daa..b67d8b6 100644 --- a/src/db/queries/tasks.ts +++ b/src/db/queries/tasks.ts @@ -4,7 +4,6 @@ import { db } from "~/db"; import { Project, TaskWithRelations } from "../schema"; import { requireSessionFromRequest } from "~/lib/session"; import { getOwningIdentity } from "~/lib/utils"; -import { useEventSource } from "~/hooks/use-event-source"; /** Board cards only need lightweight relations (not full attachments / subtask bodies). */ export const boardTaskRelationsForProject = { @@ -135,20 +134,7 @@ export const tasksQueryOptions = (projectId: string) => }); export const useTasksQuery = (projectId: string) => { - const queryData = useSuspenseQuery(tasksQueryOptions(projectId)); - - useEventSource({ - topics: [ - "task-create", - "task-delete", - ...queryData.data.map((t) => `task-update-${t.id}`), - ], - callback: () => { - queryData.refetch(); - }, - }); - - return { ...queryData }; + return useSuspenseQuery(tasksQueryOptions(projectId)); }; const fetchTasksForSprint = createServerFn({ method: "GET" }) @@ -179,20 +165,7 @@ export const tasksForSprintQueryOptions = (sprintId: string) => }); export const useTasksForSprintQuery = (sprintId: string) => { - const queryData = useSuspenseQuery(tasksForSprintQueryOptions(sprintId)); - - useEventSource({ - topics: [ - "task-create", - "task-delete", - ...queryData.data.map((t) => `task-update-${t.id}`), - ], - callback: () => { - queryData.refetch(); - }, - }); - - return { ...queryData }; + return useSuspenseQuery(tasksForSprintQueryOptions(sprintId)); }; export const ALL_TASKS_SCOPE = "all" as const; @@ -222,20 +195,7 @@ export const allTasksQueryOptions = () => }); export const useAllTasksQuery = () => { - const queryData = useSuspenseQuery(allTasksQueryOptions()); - - useEventSource({ - topics: [ - "task-create", - "task-delete", - ...queryData.data.map((t) => `task-update-${t.id}`), - ], - callback: () => { - queryData.refetch(); - }, - }); - - return { ...queryData }; + return useSuspenseQuery(allTasksQueryOptions()); }; export const fetchTask = createServerFn({ method: "GET" }) @@ -280,14 +240,5 @@ export const taskQueryOptions = (id: string) => }); export const useTaskQuery = (taskId: string) => { - const queryData = useSuspenseQuery(taskQueryOptions(taskId)); - - useEventSource({ - topics: [`task-update-${taskId}`], - callback: () => { - queryData.refetch(); - }, - }); - - return { ...queryData }; + return useSuspenseQuery(taskQueryOptions(taskId)); }; diff --git a/src/hooks/live-sync-provider.tsx b/src/hooks/live-sync-provider.tsx new file mode 100644 index 0000000..d999d0c --- /dev/null +++ b/src/hooks/live-sync-provider.tsx @@ -0,0 +1,200 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { useQueryClient } from "@tanstack/react-query"; +import { useEffect, useEffectEvent, type ReactNode } from "react"; +import { authClient } from "~/lib/auth-client"; +import { invalidateGitTaskQueries } from "~/hooks/invalidate-git-task-queries"; +import { parseTaskGitSyncPayload } from "~/lib/git/sync-task-update"; +import { ownerDashboardTopic } from "~/lib/owner-dashboard-topic"; +import { userNotificationsTopic } from "~/lib/user-notifications-topic"; +import { getOwningIdentity } from "~/lib/utils"; + +const SYNC_APP_ID = "676bb0d1-942d-465a-a706-4ee451177507"; +const SYNC_STREAM_BASE = "https://sync-connect.pno.dev"; + +const COARSE_TOPICS = [ + "task-create", + "task-delete", + "task-update", + "project-create", + "project-delete", + "project-update", + "sprint-create", + "sprint-delete", + "sprint-update", + "status-create", + "status-delete", + "status-update", + "label-create", + "label-delete", + "label-update", + "comment-create", + "comment-update", + "attachment-create", + "attachment-delete", + "attachment-update", +] as const; + +function parseEventPayload(data: unknown): { + topic?: string; + entityId?: string; +} { + if (typeof data !== "string" || !data) return {}; + try { + const parsed = JSON.parse(data) as { topic?: string; entityId?: string }; + return { + topic: typeof parsed.topic === "string" ? parsed.topic : undefined, + entityId: + typeof parsed.entityId === "string" ? parsed.entityId : undefined, + }; + } catch { + return {}; + } +} + +function invalidateTaskQueries(queryClient: QueryClient, taskId?: string) { + if (taskId) { + void queryClient.invalidateQueries({ queryKey: ["tasks", taskId] }); + } + void queryClient.invalidateQueries({ queryKey: ["tasks"] }); +} + +function handleCoarseSyncEvent( + queryClient: QueryClient, + topic: string, + data: unknown, + entityId: string | undefined, + ownerTopic: string | null, + notificationsTopic: string | null +) { + if (notificationsTopic && topic === notificationsTopic) { + void queryClient.invalidateQueries({ queryKey: ["in-app-notifications"] }); + return; + } + + if (ownerTopic && topic === ownerTopic) { + void queryClient.invalidateQueries({ queryKey: ["dashboard-stats"] }); + return; + } + + switch (topic) { + case "task-create": + case "task-delete": + invalidateTaskQueries(queryClient); + if (ownerTopic) { + void queryClient.invalidateQueries({ queryKey: ["dashboard-stats"] }); + } + return; + case "task-update": { + const gitPayload = parseTaskGitSyncPayload(data); + const taskId = gitPayload?.id ?? entityId; + if (taskId) { + void invalidateGitTaskQueries( + queryClient, + taskId, + gitPayload?.gitInvalidate, + gitPayload?.pullRequestId + ); + } + invalidateTaskQueries(queryClient, taskId); + if (ownerTopic) { + void queryClient.invalidateQueries({ queryKey: ["dashboard-stats"] }); + } + return; + } + case "project-create": + case "project-delete": + case "project-update": + void queryClient.invalidateQueries({ queryKey: ["projects"] }); + return; + case "sprint-create": + case "sprint-delete": + case "sprint-update": + void queryClient.invalidateQueries({ queryKey: ["sprints"] }); + return; + case "status-create": + case "status-delete": + case "status-update": + void queryClient.invalidateQueries({ queryKey: ["statuses"] }); + void queryClient.invalidateQueries({ queryKey: ["statuses-with-counts"] }); + return; + case "label-create": + case "label-delete": + case "label-update": + void queryClient.invalidateQueries({ queryKey: ["labels"] }); + void queryClient.invalidateQueries({ queryKey: ["labels-with-counts"] }); + return; + case "comment-create": + case "comment-update": + void queryClient.invalidateQueries({ queryKey: ["comments"] }); + return; + case "attachment-create": + case "attachment-delete": + case "attachment-update": + void queryClient.invalidateQueries({ queryKey: ["attachments"] }); + return; + default: + return; + } +} + +export function LiveSyncProvider({ children }: { children: ReactNode }) { + const queryClient = useQueryClient(); + const { data: session } = authClient.useSession(); + const ownerTopic = session + ? ownerDashboardTopic(getOwningIdentity(session)) + : null; + const notificationsTopic = session?.user.id + ? userNotificationsTopic(session.user.id) + : null; + + const onUpdate = useEffectEvent((data: unknown) => { + const { topic, entityId } = parseEventPayload(data); + if (!topic) return; + + if (notificationsTopic && topic === notificationsTopic) { + void queryClient.invalidateQueries({ queryKey: ["in-app-notifications"] }); + return; + } + + if (ownerTopic && topic === ownerTopic) { + void queryClient.invalidateQueries({ queryKey: ["dashboard-stats"] }); + return; + } + + handleCoarseSyncEvent( + queryClient, + topic, + data, + entityId, + ownerTopic, + notificationsTopic + ); + }); + + useEffect(() => { + const topics = [ + ...COARSE_TOPICS, + ...(ownerTopic ? [ownerTopic] : []), + ...(notificationsTopic ? [notificationsTopic] : []), + ]; + let topicsString = "?"; + for (const topic of topics) { + if (topicsString !== "?") topicsString += "&"; + topicsString += `topic[]=${topic}`; + } + + const sse = new EventSource( + `${SYNC_STREAM_BASE}/stream/${SYNC_APP_ID}${topicsString}` + ); + + sse.addEventListener("update", (event) => { + onUpdate(event.data); + }); + + return () => { + sse.close(); + }; + }, [ownerTopic, notificationsTopic]); + + return children; +} diff --git a/src/hooks/use-event-source.ts b/src/hooks/use-event-source.ts deleted file mode 100644 index f24f583..0000000 --- a/src/hooks/use-event-source.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { useEffect, useEffectEvent } from "react"; - -export function useEventSource({ - topics, - callback, -}: { - topics: string[]; - callback: (data: unknown) => void; -}) { - const topicsKey = [...topics].sort().join("\0"); - - const onUpdate = useEffectEvent((data: unknown) => { - callback(data); - }); - - useEffect(() => { - const sortedTopics = topicsKey ? topicsKey.split("\0") : []; - const appId = "676bb0d1-942d-465a-a706-4ee451177507"; - let topicsString = "?"; - sortedTopics.forEach((topic) => { - if (topicsString !== "?") topicsString += "&"; - topicsString += `topic[]=${topic}`; - }); - const sse = new EventSource( - `https://sync-connect.pno.dev/stream/${appId}${topicsString}`, - ); - sse.addEventListener("update", (event) => { - onUpdate(event.data); - }); - - return () => { - sse.close(); - }; - }, [topicsKey]); -} diff --git a/src/hooks/use-git-task-live-sync.ts b/src/hooks/use-git-task-live-sync.ts index 9f74374..57c6996 100644 --- a/src/hooks/use-git-task-live-sync.ts +++ b/src/hooks/use-git-task-live-sync.ts @@ -1,23 +1,2 @@ -import { useQueryClient } from "@tanstack/react-query"; -import { useEventSource } from "~/hooks/use-event-source"; -import { invalidateGitTaskQueries } from "~/hooks/invalidate-git-task-queries"; -import { parseTaskGitSyncPayload } from "~/lib/git/sync-task-update"; - -/** Refetch git queries when webhooks or mutations sync this task. */ -export function useGitTaskLiveSync(taskId: string | undefined) { - const queryClient = useQueryClient(); - - useEventSource({ - topics: taskId ? [`task-update-${taskId}`] : [], - callback: (data) => { - if (!taskId) return; - const payload = parseTaskGitSyncPayload(data); - void invalidateGitTaskQueries( - queryClient, - taskId, - payload?.gitInvalidate, - payload?.pullRequestId - ); - }, - }); -} +/** Git task live sync is handled by LiveSyncProvider via coarse task-update events. */ +export function useGitTaskLiveSync(_taskId: string | undefined) {} diff --git a/src/lib/query-cache-fresh.ts b/src/lib/query-cache-fresh.ts new file mode 100644 index 0000000..1dd6fe5 --- /dev/null +++ b/src/lib/query-cache-fresh.ts @@ -0,0 +1,15 @@ +import type { QueryClient, QueryKey } from "@tanstack/react-query"; + +const DEFAULT_STALE_TIME_MS = 60_000; + +export function isQueryCacheFresh( + queryClient: QueryClient, + queryKey: QueryKey, + staleTimeMs = DEFAULT_STALE_TIME_MS +): boolean { + const state = queryClient.getQueryState(queryKey); + if (state?.data === undefined || state.dataUpdatedAt === undefined) { + return false; + } + return Date.now() - state.dataUpdatedAt < staleTimeMs; +} diff --git a/src/router.tsx b/src/router.tsx index deab56c..fc333df 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -20,6 +20,9 @@ export function getRouter() { routeTree, context: { queryClient }, defaultPreload: "intent", + defaultStaleTime: 60_000, + defaultGcTime: 5 * 60_000, + defaultStaleReloadMode: "background", defaultErrorComponent: DefaultCatchBoundary, defaultNotFoundComponent: () => , defaultViewTransition: true, diff --git a/src/routes/_signed-in.tsx b/src/routes/_signed-in.tsx index 72ccf80..149e44e 100644 --- a/src/routes/_signed-in.tsx +++ b/src/routes/_signed-in.tsx @@ -13,7 +13,12 @@ import { } from "~/db/queries/hydrate-query-cache"; import { useRouterState } from "@tanstack/react-router"; import { TopLoadingState } from "~/components/TopLoadingState"; +import { LiveSyncProvider } from "~/hooks/live-sync-provider"; import { ensureSignedInBeforeLoad } from "~/lib/signed-in-before-load"; +import { isQueryCacheFresh } from "~/lib/query-cache-fresh"; +import { userPreferencesQueryOptions } from "~/db/queries/user-preferences"; +import { projectsQueryOptions } from "~/db/queries/projects"; +import { sprintsQueryOptions } from "~/db/queries/sprints"; export const Route = createFileRoute("/_signed-in")({ validateSearch: signedInSearchSchema, @@ -21,12 +26,25 @@ export const Route = createFileRoute("/_signed-in")({ await ensureSignedInBeforeLoad({ data: location.href }); }, loader: async ({ context }) => { + const needsSidebar = + !isQueryCacheFresh(context.queryClient, projectsQueryOptions().queryKey) || + !isQueryCacheFresh(context.queryClient, sprintsQueryOptions().queryKey); + const needsPreferences = !isQueryCacheFresh( + context.queryClient, + userPreferencesQueryOptions().queryKey + ); + const [bundle, preferences] = await Promise.all([ - fetchSidebarBundle(), - fetchUserPreferences(), + needsSidebar ? fetchSidebarBundle() : Promise.resolve(null), + needsPreferences ? fetchUserPreferences() : Promise.resolve(null), ]); - hydrateSidebarCache(context.queryClient, bundle); - hydrateUserPreferencesCache(context.queryClient, preferences); + + if (bundle) { + hydrateSidebarCache(context.queryClient, bundle); + } + if (preferences) { + hydrateUserPreferencesCache(context.queryClient, preferences); + } }, component: PathlessLayoutComponent, }); @@ -36,9 +54,10 @@ function PathlessLayoutComponent() { const routerState = useRouterState(); return ( -
- - + +
+ +
@@ -49,7 +68,8 @@ function PathlessLayoutComponent() {
-
-
+
+
+ ); } diff --git a/src/routes/_signed-in/dashboard.tsx b/src/routes/_signed-in/dashboard.tsx index db7472a..84721b9 100644 --- a/src/routes/_signed-in/dashboard.tsx +++ b/src/routes/_signed-in/dashboard.tsx @@ -15,6 +15,8 @@ import { useDashboardStatsQuery, } from "~/db/queries/dashboard"; import { hydrateDashboardCache } from "~/db/queries/hydrate-query-cache"; +import { isQueryCacheFresh } from "~/lib/query-cache-fresh"; +import { dashboardStatsQueryOptions } from "~/db/queries/dashboard"; import { useProjectsQuery } from "~/db/queries/projects"; import { useSprintsQuery } from "~/db/queries/sprints"; import { authClient } from "~/lib/auth-client"; @@ -24,8 +26,12 @@ import { pageMeta } from "~/utils/seo"; export const Route = createFileRoute("/_signed-in/dashboard")({ loader: async ({ context }) => { - const stats = await fetchDashboardStats(); - hydrateDashboardCache(context.queryClient, stats); + if ( + !isQueryCacheFresh(context.queryClient, dashboardStatsQueryOptions().queryKey) + ) { + const stats = await fetchDashboardStats(); + hydrateDashboardCache(context.queryClient, stats); + } }, head: () => ({ meta: [...pageMeta("Dashboard")], diff --git a/src/routes/_signed-in/projects.$projectId.tasks.tsx b/src/routes/_signed-in/projects.$projectId.tasks.tsx index bd923c4..e4dc972 100644 --- a/src/routes/_signed-in/projects.$projectId.tasks.tsx +++ b/src/routes/_signed-in/projects.$projectId.tasks.tsx @@ -21,6 +21,9 @@ import { EndlessLoadingSpinner } from "~/components/EndlessLoadingSpinner"; import { useTasksQuery } from "~/db/queries/tasks"; import { fetchProjectBoardBundle } from "~/db/queries/bundles"; import { hydrateProjectBoardCache } from "~/db/queries/hydrate-query-cache"; +import { isQueryCacheFresh } from "~/lib/query-cache-fresh"; +import { tasksQueryOptions } from "~/db/queries/tasks"; +import { projectQueryOptions } from "~/db/queries/projects"; import { Button } from "~/components/ui/button"; import { TaskViewSortControls } from "~/components/views/TaskViewSortControls"; import { TaskViewSwitcher } from "~/components/views/TaskViewSwitcher"; @@ -31,9 +34,19 @@ import { pageMeta } from "~/utils/seo"; export const Route = createFileRoute("/_signed-in/projects/$projectId/tasks")({ loader: async ({ context, params }) => { const { projectId } = params; - const bundle = await fetchProjectBoardBundle({ data: { projectId } }); - hydrateProjectBoardCache(context.queryClient, projectId, bundle); - const project = bundle.project; + const needsBundle = !isQueryCacheFresh( + context.queryClient, + tasksQueryOptions(projectId).queryKey + ); + + if (needsBundle) { + const bundle = await fetchProjectBoardBundle({ data: { projectId } }); + hydrateProjectBoardCache(context.queryClient, projectId, bundle); + } + + const project = context.queryClient.getQueryData( + projectQueryOptions(projectId).queryKey + ); return { pageTitle: project ? `${project.name} - Tasks` : "Tasks", }; diff --git a/src/routes/_signed-in/sprints.$sprintId.tasks.tsx b/src/routes/_signed-in/sprints.$sprintId.tasks.tsx index 1cdb5e9..631f19e 100644 --- a/src/routes/_signed-in/sprints.$sprintId.tasks.tsx +++ b/src/routes/_signed-in/sprints.$sprintId.tasks.tsx @@ -22,6 +22,9 @@ import { useLabelsQuery } from "~/db/queries/labels"; import { useTasksForSprintQuery } from "~/db/queries/tasks"; import { fetchSprintBoardBundle } from "~/db/queries/bundles"; import { hydrateSprintBoardCache } from "~/db/queries/hydrate-query-cache"; +import { isQueryCacheFresh } from "~/lib/query-cache-fresh"; +import { tasksForSprintQueryOptions } from "~/db/queries/tasks"; +import { sprintQueryOptions } from "~/db/queries/sprints"; import { UpdateTask } from "~/db/schema"; import { sprintEditSheetSearch } from "~/lib/form-sheet-search"; import { pageMeta } from "~/utils/seo"; @@ -29,9 +32,19 @@ import { pageMeta } from "~/utils/seo"; export const Route = createFileRoute("/_signed-in/sprints/$sprintId/tasks")({ loader: async ({ context, params }) => { const { sprintId } = params; - const bundle = await fetchSprintBoardBundle({ data: { sprintId } }); - hydrateSprintBoardCache(context.queryClient, sprintId, bundle); - const sprint = bundle.sprint; + const needsBundle = !isQueryCacheFresh( + context.queryClient, + tasksForSprintQueryOptions(sprintId).queryKey + ); + + if (needsBundle) { + const bundle = await fetchSprintBoardBundle({ data: { sprintId } }); + hydrateSprintBoardCache(context.queryClient, sprintId, bundle); + } + + const sprint = context.queryClient.getQueryData( + sprintQueryOptions(sprintId).queryKey + ); return { pageTitle: sprint ? `${sprint.name} - Tasks` : "Tasks", }; diff --git a/src/routes/_signed-in/tasks.tsx b/src/routes/_signed-in/tasks.tsx index 8bd2ee4..854d127 100644 --- a/src/routes/_signed-in/tasks.tsx +++ b/src/routes/_signed-in/tasks.tsx @@ -14,6 +14,8 @@ import { EndlessLoadingSpinner } from "~/components/EndlessLoadingSpinner"; import { useAllTasksQuery } from "~/db/queries/tasks"; import { fetchAllTasksBoardBundle } from "~/db/queries/bundles"; import { hydrateAllTasksBoardCache } from "~/db/queries/hydrate-query-cache"; +import { isQueryCacheFresh } from "~/lib/query-cache-fresh"; +import { allTasksQueryOptions } from "~/db/queries/tasks"; import { TaskViewSortControls } from "~/components/views/TaskViewSortControls"; import { TaskViewSwitcher } from "~/components/views/TaskViewSwitcher"; import { TaskViewsContainer } from "~/components/views/TaskViewsContainer"; @@ -21,8 +23,10 @@ import { pageMeta } from "~/utils/seo"; export const Route = createFileRoute("/_signed-in/tasks")({ loader: async ({ context }) => { - const bundle = await fetchAllTasksBoardBundle(); - hydrateAllTasksBoardCache(context.queryClient, bundle); + if (!isQueryCacheFresh(context.queryClient, allTasksQueryOptions().queryKey)) { + const bundle = await fetchAllTasksBoardBundle(); + hydrateAllTasksBoardCache(context.queryClient, bundle); + } }, head: () => ({ meta: [...pageMeta("All Projects - Tasks")], diff --git a/vite.config.ts b/vite.config.ts index e01196b..f594ecf 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,6 +7,7 @@ import netlify from "@netlify/vite-plugin-tanstack-start"; import { tanstackStart } from "@tanstack/react-start/plugin/vite"; import viteReact from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; +import { visualizer } from "rollup-plugin-visualizer"; const packageJson = JSON.parse( readFileSync(new URL("./package.json", import.meta.url), "utf8"), @@ -45,6 +46,13 @@ export default defineConfig(({ mode }) => { tanstackStart(), netlify(), viteReact(), + process.env.ANALYZE === "true" + ? visualizer({ + filename: "dist/bundle-stats.html", + gzipSize: true, + open: false, + }) + : undefined, ], define: { "import.meta.env.VITE_APP_VERSION": JSON.stringify(appVersion), From 320d3dc6c5ee8a798b3d9e4e350b430180eae6cc Mon Sep 17 00:00:00 2001 From: Philipp Nowinski Date: Thu, 11 Jun 2026 08:41:33 +0200 Subject: [PATCH 2/5] fix: address coderabbit findings --- src/components/views/BoardView.tsx | 1 + src/components/views/ListView.tsx | 2 +- src/components/views/TaskViewsContainer.tsx | 32 +++++- src/components/views/VirtualizedTaskCards.tsx | 10 +- src/db/mutations/sync.ts | 24 ++--- src/db/mutations/tasks.ts | 99 +++++++++++++++---- src/hooks/live-sync-provider.tsx | 61 ++++-------- src/lib/query-cache-fresh.ts | 3 +- src/router.tsx | 15 ++- src/routes/_signed-in.tsx | 7 +- 10 files changed, 160 insertions(+), 94 deletions(-) diff --git a/src/components/views/BoardView.tsx b/src/components/views/BoardView.tsx index cefe0cf..9301691 100644 --- a/src/components/views/BoardView.tsx +++ b/src/components/views/BoardView.tsx @@ -177,6 +177,7 @@ export const BoardView = ({ 0 ? (
- {sortedTasks.length > 25 ? ( + {!manualSortEnabled && sortedTasks.length > 25 ? ( ( diff --git a/src/components/views/TaskViewsContainer.tsx b/src/components/views/TaskViewsContainer.tsx index deed08e..94f1aba 100644 --- a/src/components/views/TaskViewsContainer.tsx +++ b/src/components/views/TaskViewsContainer.tsx @@ -89,7 +89,27 @@ export function TaskViewsContainer(props: TaskViewsContainerProps) { useEffect(() => { const handleKeyPress = async (event: KeyboardEvent) => { - if (!currentUserId || !hoveredTaskId || event.key !== "m") return; + if ( + !currentUserId || + !hoveredTaskId || + event.key !== "m" || + event.repeat || + assigningTaskId != null + ) { + return; + } + + const target = event.target; + if (target instanceof HTMLElement) { + if ( + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target.isContentEditable || + target.closest('[contenteditable="true"]') + ) { + return; + } + } const task = props.tasks.find((entry) => entry.id === hoveredTaskId); if (!task) return; @@ -110,7 +130,15 @@ export function TaskViewsContainer(props: TaskViewsContainerProps) { return () => { window.removeEventListener("keydown", handleKeyPress); }; - }, [assignTask, currentUserId, hoveredTaskId, props.tasks, unassignTask]); + }, [ + assignTask, + assigningTaskId, + currentUserId, + hoveredTaskId, + props.tasks, + setAssigningTaskId, + unassignTask, + ]); const ViewComponent = TASK_VIEW_REGISTRY[viewMode]; diff --git a/src/components/views/VirtualizedTaskCards.tsx b/src/components/views/VirtualizedTaskCards.tsx index 5a7ab90..e17f7c3 100644 --- a/src/components/views/VirtualizedTaskCards.tsx +++ b/src/components/views/VirtualizedTaskCards.tsx @@ -1,5 +1,5 @@ import { useVirtualizer } from "@tanstack/react-virtual"; -import { useEffect, useRef, type ReactNode } from "react"; +import { useCallback, useRef, type ReactNode } from "react"; type VirtualizedTaskCardsProps = { items: T[]; @@ -12,12 +12,10 @@ export function VirtualizedTaskCards({ estimateSize = 132, renderItem, }: VirtualizedTaskCardsProps) { - const listRef = useRef(null); const scrollElementRef = useRef(null); - - useEffect(() => { - scrollElementRef.current = listRef.current?.parentElement ?? null; - }, [items.length]); + const listRef = useCallback((node: HTMLDivElement | null) => { + scrollElementRef.current = node?.parentElement ?? null; + }, []); const virtualizer = useVirtualizer({ count: items.length, diff --git a/src/db/mutations/sync.ts b/src/db/mutations/sync.ts index bb2c3f9..1539805 100644 --- a/src/db/mutations/sync.ts +++ b/src/db/mutations/sync.ts @@ -2,6 +2,7 @@ import "@tanstack/react-start/server-only"; import { env } from "~/env"; import { ownerDashboardTopic } from "~/lib/owner-dashboard-topic"; +import { createSyncEnvelope, type SyncEnvelope } from "~/lib/sync-envelope"; const DEFAULT_SYNC_ENGINE_URL = "https://sync-connect.pno.dev"; @@ -35,26 +36,22 @@ const COARSE_TOPIC_PREFIXES: Record = { "attachment-update-": "attachment-update", }; -async function publishSyncTopic( - baseUrl: string, - topic: string, - payload: unknown -) { +async function publishSyncTopic(baseUrl: string, envelope: SyncEnvelope) { const response = await fetch( - `${baseUrl}/stream/${env.SYNC_APP_ID}?key=${env.SYNC_PUBLISH_KEY}&topic=${topic}`, + `${baseUrl}/stream/${env.SYNC_APP_ID}?key=${env.SYNC_PUBLISH_KEY}&topic=${envelope.topic}`, { method: "POST", headers: { "Content-Type": "application/json", }, - body: JSON.stringify({ topic, payload }), + body: JSON.stringify(envelope), } ); if (!response.ok) { const body = await response.text(); console.error( - `Sync failed for topic "${topic}" (app ${env.SYNC_APP_ID}, ${baseUrl}): ${response.status} ${body}` + `Sync failed for topic "${envelope.topic}" (app ${env.SYNC_APP_ID}, ${baseUrl}): ${response.status} ${body}` ); } } @@ -62,16 +59,15 @@ async function publishSyncTopic( export async function sync(topic: string, payload: unknown) { const baseUrl = resolveSyncEngineUrl(); try { - await publishSyncTopic(baseUrl, topic, payload); + await publishSyncTopic(baseUrl, createSyncEnvelope(topic, payload)); for (const [prefix, coarseTopic] of Object.entries(COARSE_TOPIC_PREFIXES)) { if (!topic.startsWith(prefix)) continue; const entityId = topic.slice(prefix.length); - await publishSyncTopic(baseUrl, coarseTopic, { - topic: coarseTopic, - entityId, - payload, - }); + await publishSyncTopic( + baseUrl, + createSyncEnvelope(coarseTopic, payload, entityId) + ); break; } } catch (error) { diff --git a/src/db/mutations/tasks.ts b/src/db/mutations/tasks.ts index 97286ff..cde9b53 100644 --- a/src/db/mutations/tasks.ts +++ b/src/db/mutations/tasks.ts @@ -36,6 +36,27 @@ import { const allTasksListQueryKey: QueryKey = ["tasks", ALL_TASKS_SCOPE]; +function resolveLabels(labelIds: string[], labels: Label[]): Label[] { + return labelIds + .map((id) => labels.find((label) => label.id === id)) + .filter((label): label is Label => label != null); +} + +function computeBucketSortOrder( + tasks: TaskWithRelations[], + taskId: string, + statusId: string | null, + targetIndex: number +): number | undefined { + const bucketTaskIds = tasks + .filter( + (task) => (task.statusId ?? null) === statusId && task.id !== taskId + ) + .map((task) => task.id); + bucketTaskIds.splice(Math.min(targetIndex, bucketTaskIds.length), 0, taskId); + return sortOrdersFromOrderedIds(bucketTaskIds).get(taskId); +} + function maybeIncludeAllTasksListKey( queryClient: QueryClient, keys: QueryKey[] @@ -628,37 +649,53 @@ export function useSetLabelsForTaskMutation() { return useCallback( async (task: Task, labelIds: string[]) => { const labels: Label[] = queryClient.getQueryData(["labels"]) || []; - - queryClient.setQueryData( - ["tasks", task.id], - (oldData: TaskWithRelations | undefined) => { - if (!oldData) return oldData; - return { - ...oldData, - labels: [...labelIds.map((id) => labels.find((l) => l.id === id))], - }; - } - ); - - await _setLabelsForTask({ data: { taskId: task.id, labelIds } }); - + const resolvedLabels = resolveLabels(labelIds, labels); const listQueryKeys = getTaskListQueryKeys(queryClient, task); + const detailQueryKey: QueryKey = ["tasks", task.id]; + const snapshots = [ + ...listQueryKeys.map((queryKey) => ({ + queryKey, + data: queryClient.getQueryData(queryKey), + })), + { + queryKey: detailQueryKey, + data: queryClient.getQueryData(detailQueryKey), + }, + ]; + const patchList = (oldData: TaskWithRelations[] | undefined) => { if (!oldData) return oldData; return oldData.map((entry) => entry.id === task.id - ? { - ...entry, - labels: labelIds.map( - (id) => labels.find((label) => label.id === id)! - ), - } + ? { ...entry, labels: resolvedLabels, updatedAt: new Date() } : entry ); }; + for (const queryKey of listQueryKeys) { queryClient.setQueryData(queryKey, patchList); } + queryClient.setQueryData( + detailQueryKey, + (oldData: TaskWithRelations | undefined) => { + if (!oldData) return oldData; + return { + ...oldData, + labels: resolvedLabels, + updatedAt: new Date(), + }; + } + ); + + try { + await _setLabelsForTask({ data: { taskId: task.id, labelIds } }); + } catch (error) { + for (const { queryKey, data } of snapshots) { + queryClient.setQueryData(queryKey, data); + } + toast.error("Failed to update labels"); + throw error; + } }, [queryClient, _setLabelsForTask] ); @@ -1325,9 +1362,16 @@ export function useMoveTaskInBucketMutation() { const taskIndex = oldData.findIndex((task) => task.id === input.taskId); if (taskIndex === -1) return oldData; + const sortOrder = computeBucketSortOrder( + oldData, + input.taskId, + input.statusId, + input.targetIndex + ); const movedTask = { ...oldData[taskIndex]!, statusId: input.statusId, + ...(sortOrder != null ? { sortOrder } : {}), updatedAt: new Date(), }; const withoutTask = oldData.filter((task) => task.id !== input.taskId); @@ -1340,6 +1384,20 @@ export function useMoveTaskInBucketMutation() { return next; }; + const listData = listQueryKeys + .map((queryKey) => + queryClient.getQueryData(queryKey) + ) + .find((data) => data != null); + const detailSortOrder = listData + ? computeBucketSortOrder( + listData, + input.taskId, + input.statusId, + input.targetIndex + ) + : undefined; + for (const queryKey of listQueryKeys) { queryClient.setQueryData(queryKey, patchMovedTask); } @@ -1350,6 +1408,7 @@ export function useMoveTaskInBucketMutation() { return { ...oldData, statusId: input.statusId, + ...(detailSortOrder != null ? { sortOrder: detailSortOrder } : {}), updatedAt: new Date(), }; } diff --git a/src/hooks/live-sync-provider.tsx b/src/hooks/live-sync-provider.tsx index d999d0c..fd36f77 100644 --- a/src/hooks/live-sync-provider.tsx +++ b/src/hooks/live-sync-provider.tsx @@ -5,6 +5,7 @@ import { authClient } from "~/lib/auth-client"; import { invalidateGitTaskQueries } from "~/hooks/invalidate-git-task-queries"; import { parseTaskGitSyncPayload } from "~/lib/git/sync-task-update"; import { ownerDashboardTopic } from "~/lib/owner-dashboard-topic"; +import { parseSyncEnvelope } from "~/lib/sync-envelope"; import { userNotificationsTopic } from "~/lib/user-notifications-topic"; import { getOwningIdentity } from "~/lib/utils"; @@ -34,23 +35,6 @@ const COARSE_TOPICS = [ "attachment-update", ] as const; -function parseEventPayload(data: unknown): { - topic?: string; - entityId?: string; -} { - if (typeof data !== "string" || !data) return {}; - try { - const parsed = JSON.parse(data) as { topic?: string; entityId?: string }; - return { - topic: typeof parsed.topic === "string" ? parsed.topic : undefined, - entityId: - typeof parsed.entityId === "string" ? parsed.entityId : undefined, - }; - } catch { - return {}; - } -} - function invalidateTaskQueries(queryClient: QueryClient, taskId?: string) { if (taskId) { void queryClient.invalidateQueries({ queryKey: ["tasks", taskId] }); @@ -63,19 +47,8 @@ function handleCoarseSyncEvent( topic: string, data: unknown, entityId: string | undefined, - ownerTopic: string | null, - notificationsTopic: string | null + ownerTopic: string | null ) { - if (notificationsTopic && topic === notificationsTopic) { - void queryClient.invalidateQueries({ queryKey: ["in-app-notifications"] }); - return; - } - - if (ownerTopic && topic === ownerTopic) { - void queryClient.invalidateQueries({ queryKey: ["dashboard-stats"] }); - return; - } - switch (topic) { case "task-create": case "task-delete": @@ -148,26 +121,25 @@ export function LiveSyncProvider({ children }: { children: ReactNode }) { : null; const onUpdate = useEffectEvent((data: unknown) => { - const { topic, entityId } = parseEventPayload(data); - if (!topic) return; + const envelope = parseSyncEnvelope(data); + if (!envelope) return; - if (notificationsTopic && topic === notificationsTopic) { + if (notificationsTopic && envelope.topic === notificationsTopic) { void queryClient.invalidateQueries({ queryKey: ["in-app-notifications"] }); return; } - if (ownerTopic && topic === ownerTopic) { + if (ownerTopic && envelope.topic === ownerTopic) { void queryClient.invalidateQueries({ queryKey: ["dashboard-stats"] }); return; } handleCoarseSyncEvent( queryClient, - topic, + envelope.topic, data, - entityId, - ownerTopic, - notificationsTopic + envelope.entityId, + ownerTopic ); }); @@ -177,16 +149,23 @@ export function LiveSyncProvider({ children }: { children: ReactNode }) { ...(ownerTopic ? [ownerTopic] : []), ...(notificationsTopic ? [notificationsTopic] : []), ]; - let topicsString = "?"; + const params = new URLSearchParams(); for (const topic of topics) { - if (topicsString !== "?") topicsString += "&"; - topicsString += `topic[]=${topic}`; + params.append("topic[]", topic); } const sse = new EventSource( - `${SYNC_STREAM_BASE}/stream/${SYNC_APP_ID}${topicsString}` + `${SYNC_STREAM_BASE}/stream/${SYNC_APP_ID}?${params.toString()}` ); + sse.addEventListener("open", () => { + console.info("[live-sync] EventSource connected"); + }); + + sse.addEventListener("error", (event) => { + console.error("[live-sync] EventSource error", event); + }); + sse.addEventListener("update", (event) => { onUpdate(event.data); }); diff --git a/src/lib/query-cache-fresh.ts b/src/lib/query-cache-fresh.ts index 1dd6fe5..cc24d50 100644 --- a/src/lib/query-cache-fresh.ts +++ b/src/lib/query-cache-fresh.ts @@ -1,6 +1,5 @@ import type { QueryClient, QueryKey } from "@tanstack/react-query"; - -const DEFAULT_STALE_TIME_MS = 60_000; +import { DEFAULT_STALE_TIME_MS } from "~/lib/query-cache-defaults"; export function isQueryCacheFresh( queryClient: QueryClient, diff --git a/src/router.tsx b/src/router.tsx index fc333df..e67430c 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -4,13 +4,18 @@ import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query import { routeTree } from "./routeTree.gen"; import { DefaultCatchBoundary } from "./components/DefaultCatchBoundary"; import { NotFound } from "./components/NotFound"; +import { + DEFAULT_GC_TIME_MS, + DEFAULT_STALE_RELOAD_MODE, + DEFAULT_STALE_TIME_MS, +} from "~/lib/query-cache-defaults"; export function getRouter() { const queryClient = new QueryClient({ defaultOptions: { queries: { - staleTime: 60_000, - gcTime: 5 * 60_000, + staleTime: DEFAULT_STALE_TIME_MS, + gcTime: DEFAULT_GC_TIME_MS, refetchOnWindowFocus: false, }, }, @@ -20,9 +25,9 @@ export function getRouter() { routeTree, context: { queryClient }, defaultPreload: "intent", - defaultStaleTime: 60_000, - defaultGcTime: 5 * 60_000, - defaultStaleReloadMode: "background", + defaultStaleTime: DEFAULT_STALE_TIME_MS, + defaultGcTime: DEFAULT_GC_TIME_MS, + defaultStaleReloadMode: DEFAULT_STALE_RELOAD_MODE, defaultErrorComponent: DefaultCatchBoundary, defaultNotFoundComponent: () => , defaultViewTransition: true, diff --git a/src/routes/_signed-in.tsx b/src/routes/_signed-in.tsx index 149e44e..30bc3dc 100644 --- a/src/routes/_signed-in.tsx +++ b/src/routes/_signed-in.tsx @@ -26,13 +26,14 @@ export const Route = createFileRoute("/_signed-in")({ await ensureSignedInBeforeLoad({ data: location.href }); }, loader: async ({ context }) => { - const needsSidebar = - !isQueryCacheFresh(context.queryClient, projectsQueryOptions().queryKey) || - !isQueryCacheFresh(context.queryClient, sprintsQueryOptions().queryKey); const needsPreferences = !isQueryCacheFresh( context.queryClient, userPreferencesQueryOptions().queryKey ); + const needsSidebar = + needsPreferences || + !isQueryCacheFresh(context.queryClient, projectsQueryOptions().queryKey) || + !isQueryCacheFresh(context.queryClient, sprintsQueryOptions().queryKey); const [bundle, preferences] = await Promise.all([ needsSidebar ? fetchSidebarBundle() : Promise.resolve(null), From 23d6e983ed40df79cc316d61cbed4f1d41721b12 Mon Sep 17 00:00:00 2001 From: Philipp Nowinski Date: Thu, 11 Jun 2026 08:41:51 +0200 Subject: [PATCH 3/5] fix: add missing files --- src/lib/query-cache-defaults.ts | 3 +++ src/lib/sync-envelope.test.ts | 22 ++++++++++++++++++++++ src/lib/sync-envelope.ts | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+) create mode 100644 src/lib/query-cache-defaults.ts create mode 100644 src/lib/sync-envelope.test.ts create mode 100644 src/lib/sync-envelope.ts diff --git a/src/lib/query-cache-defaults.ts b/src/lib/query-cache-defaults.ts new file mode 100644 index 0000000..3989de3 --- /dev/null +++ b/src/lib/query-cache-defaults.ts @@ -0,0 +1,3 @@ +export const DEFAULT_STALE_TIME_MS = 60_000; +export const DEFAULT_GC_TIME_MS = 5 * 60_000; +export const DEFAULT_STALE_RELOAD_MODE = "background" as const; diff --git a/src/lib/sync-envelope.test.ts b/src/lib/sync-envelope.test.ts new file mode 100644 index 0000000..f9a8cc9 --- /dev/null +++ b/src/lib/sync-envelope.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { createSyncEnvelope, parseSyncEnvelope } from "./sync-envelope"; + +describe("sync envelope", () => { + it("round-trips fine-grained topics", () => { + const envelope = createSyncEnvelope("task-create", { data: { id: "1" } }); + const serialized = JSON.stringify(envelope); + + expect(parseSyncEnvelope(serialized)).toEqual(envelope); + }); + + it("round-trips coarse topics with entityId", () => { + const envelope = createSyncEnvelope( + "task-update", + { id: "task-1", statusId: "status-1" }, + "task-1" + ); + const serialized = JSON.stringify(envelope); + + expect(parseSyncEnvelope(serialized)).toEqual(envelope); + }); +}); diff --git a/src/lib/sync-envelope.ts b/src/lib/sync-envelope.ts new file mode 100644 index 0000000..6fc78af --- /dev/null +++ b/src/lib/sync-envelope.ts @@ -0,0 +1,33 @@ +export type SyncEnvelope = { + topic: string; + entityId?: string; + payload: unknown; +}; + +export function createSyncEnvelope( + topic: string, + payload: unknown, + entityId?: string +): SyncEnvelope { + return entityId ? { topic, entityId, payload } : { topic, payload }; +} + +export function parseSyncEnvelope(data: unknown): SyncEnvelope | null { + if (typeof data !== "string" || !data) return null; + try { + const parsed = JSON.parse(data) as { + topic?: string; + entityId?: string; + payload?: unknown; + }; + if (typeof parsed.topic !== "string") return null; + return { + topic: parsed.topic, + entityId: + typeof parsed.entityId === "string" ? parsed.entityId : undefined, + payload: parsed.payload, + }; + } catch { + return null; + } +} From c9c8942a9195f57a13734f1437256d300501767d Mon Sep 17 00:00:00 2001 From: Philipp Nowinski Date: Thu, 11 Jun 2026 09:18:12 +0200 Subject: [PATCH 4/5] fic: fix hydration error --- src/routes/_signed-in/dashboard.tsx | 34 +++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/src/routes/_signed-in/dashboard.tsx b/src/routes/_signed-in/dashboard.tsx index 84721b9..f3dc030 100644 --- a/src/routes/_signed-in/dashboard.tsx +++ b/src/routes/_signed-in/dashboard.tsx @@ -19,11 +19,24 @@ import { isQueryCacheFresh } from "~/lib/query-cache-fresh"; import { dashboardStatsQueryOptions } from "~/db/queries/dashboard"; import { useProjectsQuery } from "~/db/queries/projects"; import { useSprintsQuery } from "~/db/queries/sprints"; -import { authClient } from "~/lib/auth-client"; import { FORM_SHEET_CREATE_LINKS } from "~/lib/form-sheet-search"; import { cn, formatUserName } from "~/lib/utils"; import { pageMeta } from "~/utils/seo"; +function sessionDisplayName( + user: + | { + name?: string | null; + firstname?: string; + lastname?: string; + } + | undefined +) { + return ( + formatUserName(user?.firstname, user?.lastname) || user?.name || "there" + ); +} + export const Route = createFileRoute("/_signed-in/dashboard")({ loader: async ({ context }) => { if ( @@ -32,6 +45,16 @@ export const Route = createFileRoute("/_signed-in/dashboard")({ const stats = await fetchDashboardStats(); hydrateDashboardCache(context.queryClient, stats); } + + const { getSessionFromRequest } = await import("~/lib/session"); + const session = await getSessionFromRequest(); + return { + displayName: sessionDisplayName( + session?.user as + | { name?: string | null; firstname?: string; lastname?: string } + | undefined + ), + }; }, head: () => ({ meta: [...pageMeta("Dashboard")], @@ -40,18 +63,11 @@ export const Route = createFileRoute("/_signed-in/dashboard")({ }); function RouteComponent() { - const { data: session } = authClient.useSession(); + const { displayName } = Route.useLoaderData(); const statsQuery = useDashboardStatsQuery(); const projectsQuery = useProjectsQuery(); const sprintsQuery = useSprintsQuery(); - const user = session?.user; - const authUser = user as { firstname?: string; lastname?: string } | undefined; - const displayName = - formatUserName(authUser?.firstname, authUser?.lastname) || - user?.name || - "there"; - const stats = statsQuery.data; const now = new Date(); const isSprintActive = (start: Date, end: Date) => start < now && end > now; From 846512deb6970fc964ef6249befc35309a129512 Mon Sep 17 00:00:00 2001 From: Philipp Nowinski Date: Thu, 11 Jun 2026 09:24:56 +0200 Subject: [PATCH 5/5] fix: rix build error --- src/routes/_signed-in/dashboard.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/routes/_signed-in/dashboard.tsx b/src/routes/_signed-in/dashboard.tsx index f3dc030..7e8f4ed 100644 --- a/src/routes/_signed-in/dashboard.tsx +++ b/src/routes/_signed-in/dashboard.tsx @@ -19,6 +19,7 @@ import { isQueryCacheFresh } from "~/lib/query-cache-fresh"; import { dashboardStatsQueryOptions } from "~/db/queries/dashboard"; import { useProjectsQuery } from "~/db/queries/projects"; import { useSprintsQuery } from "~/db/queries/sprints"; +import { getSession } from "~/lib/auth-functions"; import { FORM_SHEET_CREATE_LINKS } from "~/lib/form-sheet-search"; import { cn, formatUserName } from "~/lib/utils"; import { pageMeta } from "~/utils/seo"; @@ -46,8 +47,7 @@ export const Route = createFileRoute("/_signed-in/dashboard")({ hydrateDashboardCache(context.queryClient, stats); } - const { getSessionFromRequest } = await import("~/lib/session"); - const session = await getSessionFromRequest(); + const session = await getSession(); return { displayName: sessionDisplayName( session?.user as