From 936011c186235e3049c2a5ce57befa0e703a5e4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=BCl=C3=B6p=20Kov=C3=A1cs?= <43729152+fulopkovacs@users.noreply.github.com> Date: Sat, 3 Jan 2026 12:47:34 +0100 Subject: [PATCH 01/17] Update the tanstack db packages --- package.json | 6 ++-- pnpm-lock.yaml | 87 +++++++++++++++++++++++++++----------------------- 2 files changed, 50 insertions(+), 43 deletions(-) diff --git a/package.json b/package.json index 273be92..2dd16c5 100644 --- a/package.json +++ b/package.json @@ -41,9 +41,9 @@ "@radix-ui/react-tooltip": "^1.2.8", "@shikijs/rehype": "^3.20.0", "@tailwindcss/vite": "^4.0.6", - "@tanstack/db": "^0.5.0", - "@tanstack/query-db-collection": "^1.0.0", - "@tanstack/react-db": "^0.1.44", + "@tanstack/db": "^0.5.16", + "@tanstack/query-db-collection": "^1.0.12", + "@tanstack/react-db": "^0.1.60", "@tanstack/react-devtools": "^0.9.0", "@tanstack/react-form": "^1.25.0", "@tanstack/react-query": "^5.90.14", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3fa819d..cea2722 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -78,14 +78,14 @@ importers: specifier: ^4.0.6 version: 4.1.17(vite@7.2.2(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)) '@tanstack/db': - specifier: ^0.5.0 - version: 0.5.0(typescript@5.9.3) + specifier: ^0.5.16 + version: 0.5.16(typescript@5.9.3) '@tanstack/query-db-collection': - specifier: ^1.0.0 - version: 1.0.0(@tanstack/db@0.5.0(typescript@5.9.3))(@tanstack/query-core@5.90.14)(typescript@5.9.3) + specifier: ^1.0.12 + version: 1.0.12(@tanstack/query-core@5.90.14)(typescript@5.9.3) '@tanstack/react-db': - specifier: ^0.1.44 - version: 0.1.44(react@19.2.0)(typescript@5.9.3) + specifier: ^0.1.60 + version: 0.1.60(react@19.2.0)(typescript@5.9.3) '@tanstack/react-devtools': specifier: ^0.9.0 version: 0.9.0(@types/react-dom@19.2.3(@types/react@19.2.4))(@types/react@19.2.4)(csstype@3.1.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(solid-js@1.9.10) @@ -1924,8 +1924,8 @@ packages: '@speed-highlight/core@1.2.12': resolution: {integrity: sha512-uilwrK0Ygyri5dToHYdZSjcvpS2ZwX0w5aSt3GCEN9hrjxWCoeV4Z2DTXuxjwbntaLQIEEAlCeNQss5SoHvAEA==} - '@standard-schema/spec@1.0.0': - resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} '@tailwindcss/node@4.1.17': resolution: {integrity: sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==} @@ -2022,13 +2022,13 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 - '@tanstack/db-ivm@0.1.13': - resolution: {integrity: sha512-sBOWGY4tqMEym2ewjdWrDb5c5c8akvgnEbGVPAtkfFS3QVV0zfVb5RJAkAc8GSxb3ByVfYjyaShVr0kMJhMuow==} + '@tanstack/db-ivm@0.1.14': + resolution: {integrity: sha512-GluhFsd/Z1E/MZTf60l9dZpKNpmdxtV4izPRnj1RK6TYrhC9LMndN+ywk1VDFBrjtBq/CTShz4CilECLwVlTGg==} peerDependencies: typescript: '>=4.7' - '@tanstack/db@0.5.0': - resolution: {integrity: sha512-3AA8xiNhezH18TZ0Dq8FrakAVsRnidTVIRus2vGjFiiVLOmJFiogIVRB16xChAcF4hws12juRl5om8YKK042Hg==} + '@tanstack/db@0.5.16': + resolution: {integrity: sha512-V4oCsGlighwgKGWldw1umaXaVYiDR0sK/1fCWictrx2lqxqmBMUEfmNtWnt0CFJXEbyRA/LF8TyFn1ooXtyZ3w==} peerDependencies: typescript: '>=4.7' @@ -2073,8 +2073,8 @@ packages: resolution: {integrity: sha512-LS54XNyxyTs5m/pl1lkwlg7uZM3lvsv2FIIV1rsJgnfwVCnI+n4ZGZ2CcjNT13BPu/3hPP+iHmliBSscJxW5FQ==} engines: {node: '>=12'} - '@tanstack/pacer@0.1.0': - resolution: {integrity: sha512-QVzkGO5clvGj/qdX8H2wUj0QCXCLZ/pwPMnfSqhoYfpzDRkRHDj+3D+VzdcehBIVnE+GCd1D/P1tGMzfjmfrzQ==} + '@tanstack/pacer-lite@0.1.1': + resolution: {integrity: sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w==} engines: {node: '>=18'} '@tanstack/pacer@0.15.4': @@ -2084,18 +2084,17 @@ packages: '@tanstack/query-core@5.90.14': resolution: {integrity: sha512-/6di2yNI+YxpVrH9Ig74Q+puKnkCE+D0LGyagJEGndJHJc6ahkcc/UqirHKy8zCYE/N9KLggxcQvzYCsUBWgdw==} - '@tanstack/query-db-collection@1.0.0': - resolution: {integrity: sha512-BO5m9C73kFwuymB1XblVInyE1rNNaNlIDe7W26xSdecSCSm4AH1OyTxmgoUn0IbfMCUg6i3m7ww45cniy33ukg==} + '@tanstack/query-db-collection@1.0.12': + resolution: {integrity: sha512-kwE9lfGhbnRbRHut1k4yrshoTSDNqP/uY7Brr0l1HbOJZuW7V8Q1zQ1GN4TMAtsLohKaSsL/XDxZULCi6MGngg==} peerDependencies: - '@tanstack/db': '*' '@tanstack/query-core': ^5.0.0 typescript: '>=4.7' '@tanstack/query-devtools@5.92.0': resolution: {integrity: sha512-N8D27KH1vEpVacvZgJL27xC6yPFUy0Zkezn5gnB3L3gRCxlDeSuiya7fKge8Y91uMTnC8aSxBQhcK6ocY7alpQ==} - '@tanstack/react-db@0.1.44': - resolution: {integrity: sha512-O1jYNhCWhrGvJYP2QBmG4HMPcUEbJoX78WYHAwjNl1slGqm7KuGKQtkOZovFNuioLiLaJmkU+77X5k+MBo2wfw==} + '@tanstack/react-db@0.1.60': + resolution: {integrity: sha512-Pz3pwH4vgRxlS/L3+BszINjNlIUXahJ6EqSEsYBMBT19G36GzTzyd5Qq98JaOXuu8V0dkiFWEoyEoTv3BiHcrg==} peerDependencies: react: '>=16.8.0' @@ -2322,6 +2321,9 @@ packages: '@types/node@22.19.1': resolution: {integrity: sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==} + '@types/node@22.19.3': + resolution: {integrity: sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -3385,8 +3387,8 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - node-abi@3.83.0: - resolution: {integrity: sha512-o2PH88PgFlfoSDjU5oq/b/p9m+DJaPfslRI5FzNqcK1ea1i2/8xo/FL850kdgw0EAQJ/cSyyi2W2fBjHBdg5rA==} + node-abi@3.85.0: + resolution: {integrity: sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==} engines: {node: '>=10'} node-releases@2.0.27: @@ -3594,8 +3596,8 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - rou3@0.7.10: - resolution: {integrity: sha512-aoFj6f7MJZ5muJ+Of79nrhs9N3oLGqi2VEMe94Zbkjb6Wupha46EuoYgpWSOZlXww3bbd8ojgXTAA2mzimX5Ww==} + rou3@0.7.12: + resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -5602,7 +5604,7 @@ snapshots: '@speed-highlight/core@1.2.12': {} - '@standard-schema/spec@1.0.0': {} + '@standard-schema/spec@1.1.0': {} '@tailwindcss/node@4.1.17': dependencies: @@ -5677,17 +5679,17 @@ snapshots: tailwindcss: 4.1.17 vite: 7.2.2(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6) - '@tanstack/db-ivm@0.1.13(typescript@5.9.3)': + '@tanstack/db-ivm@0.1.14(typescript@5.9.3)': dependencies: fractional-indexing: 3.2.0 sorted-btree: 1.8.1 typescript: 5.9.3 - '@tanstack/db@0.5.0(typescript@5.9.3)': + '@tanstack/db@0.5.16(typescript@5.9.3)': dependencies: - '@standard-schema/spec': 1.0.0 - '@tanstack/db-ivm': 0.1.13(typescript@5.9.3) - '@tanstack/pacer': 0.1.0 + '@standard-schema/spec': 1.1.0 + '@tanstack/db-ivm': 0.1.14(typescript@5.9.3) + '@tanstack/pacer-lite': 0.1.1 typescript: 5.9.3 '@tanstack/devtools-client@0.0.5': @@ -5755,7 +5757,7 @@ snapshots: '@tanstack/history@1.141.0': {} - '@tanstack/pacer@0.1.0': {} + '@tanstack/pacer-lite@0.1.1': {} '@tanstack/pacer@0.15.4': dependencies: @@ -5764,18 +5766,18 @@ snapshots: '@tanstack/query-core@5.90.14': {} - '@tanstack/query-db-collection@1.0.0(@tanstack/db@0.5.0(typescript@5.9.3))(@tanstack/query-core@5.90.14)(typescript@5.9.3)': + '@tanstack/query-db-collection@1.0.12(@tanstack/query-core@5.90.14)(typescript@5.9.3)': dependencies: - '@standard-schema/spec': 1.0.0 - '@tanstack/db': 0.5.0(typescript@5.9.3) + '@standard-schema/spec': 1.1.0 + '@tanstack/db': 0.5.16(typescript@5.9.3) '@tanstack/query-core': 5.90.14 typescript: 5.9.3 '@tanstack/query-devtools@5.92.0': {} - '@tanstack/react-db@0.1.44(react@19.2.0)(typescript@5.9.3)': + '@tanstack/react-db@0.1.60(react@19.2.0)(typescript@5.9.3)': dependencies: - '@tanstack/db': 0.5.0(typescript@5.9.3) + '@tanstack/db': 0.5.16(typescript@5.9.3) react: 19.2.0 use-sync-external-store: 1.6.0(react@19.2.0) transitivePeerDependencies: @@ -6069,7 +6071,7 @@ snapshots: '@types/better-sqlite3@7.6.13': dependencies: - '@types/node': 22.19.1 + '@types/node': 22.19.3 optional: true '@types/chai@5.2.3': @@ -6105,6 +6107,11 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@22.19.3': + dependencies: + undici-types: 6.21.0 + optional: true + '@types/react-dom@19.2.3(@types/react@19.2.4)': dependencies: '@types/react': 19.2.4 @@ -6735,7 +6742,7 @@ snapshots: h3@2.0.1-rc.6: dependencies: - rou3: 0.7.10 + rou3: 0.7.12 srvx: 0.9.8 hast-util-to-estree@3.1.3: @@ -7384,7 +7391,7 @@ snapshots: react: 19.2.0 react-dom: 19.2.0(react@19.2.0) - node-abi@3.83.0: + node-abi@3.85.0: dependencies: semver: 7.7.3 optional: true @@ -7490,7 +7497,7 @@ snapshots: minimist: 1.2.8 mkdirp-classic: 0.5.3 napi-build-utils: 2.0.0 - node-abi: 3.83.0 + node-abi: 3.85.0 pump: 3.0.3 rc: 1.2.8 simple-get: 4.0.1 @@ -7685,7 +7692,7 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.53.2 fsevents: 2.3.3 - rou3@0.7.10: {} + rou3@0.7.12: {} run-parallel@1.2.0: dependencies: From e95cb867a19e033e91bae9297a20e07c1bbfaf76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=BCl=C3=B6p=20Kov=C3=A1cs?= <43729152+fulopkovacs@users.noreply.github.com> Date: Sat, 3 Jan 2026 13:52:29 +0100 Subject: [PATCH 02/17] Pass down the whole href to the api handlers --- public/sw.js | 2 +- src/client/pgliteHelpers.ts | 9 +++++---- src/local-api/helpers.ts | 4 ++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/public/sw.js b/public/sw.js index 868df6f..d0aecb6 100644 --- a/public/sw.js +++ b/public/sw.js @@ -79,7 +79,7 @@ async function handleFetch(event) { body: { requestBody, method: req.method, - pathname: url.pathname, + href: url.href, }, }, [msgChannel.port2], diff --git a/src/client/pgliteHelpers.ts b/src/client/pgliteHelpers.ts index 3823a62..caeb1a4 100644 --- a/src/client/pgliteHelpers.ts +++ b/src/client/pgliteHelpers.ts @@ -71,9 +71,10 @@ export const setupServiceWorkerHttpsProxy = createIsomorphicFn().client( async function processRequestInMainThread(body: any) { const requestData = requestSchema.parse(body); - const handler = (API as APIType)[requestData.pathname][ - requestData.method - ]; + const url = new URL(requestData.href); + + const handler = (API as APIType)[url.pathname][requestData.method]; + if (handler) { // Generate unique request ID for tracking const requestId = `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; @@ -84,7 +85,7 @@ export const setupServiceWorkerHttpsProxy = createIsomorphicFn().client( id: requestId, timestamp: Date.now(), method: requestData.method, - pathname: requestData.pathname, + pathname: url.pathname, requestBody: requestData.requestBody, status: "pending", duration: null, diff --git a/src/local-api/helpers.ts b/src/local-api/helpers.ts index 1d69c6f..4a173c0 100644 --- a/src/local-api/helpers.ts +++ b/src/local-api/helpers.ts @@ -19,7 +19,7 @@ export type APIRouteHandler = Partial< export type APIType = Record; export const requestSchema = z.object({ - pathname: z.string().min(1), + href: z.url(), method: methodShema, requestBody: z.any().optional(), }); @@ -51,7 +51,7 @@ export function constructRequestForHandler(requestData: RequestData): { request: Request; } { return { - request: new Request(requestData.pathname, { + request: new Request(requestData.href, { method: requestData.method, headers: { "Content-Type": "application/json", From 7497b2c4b5e7336e554f8f218800b7958c30ae78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=BCl=C3=B6p=20Kov=C3=A1cs?= <43729152+fulopkovacs@users.noreply.github.com> Date: Sat, 3 Jan 2026 14:01:55 +0100 Subject: [PATCH 03/17] Show the search string in the APIRequestsPanel --- src/client/pgliteHelpers.ts | 1 + src/collections/apiRequests.ts | 1 + src/components/ApiRequestsPanel.tsx | 28 ++++++++++++++-------------- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/src/client/pgliteHelpers.ts b/src/client/pgliteHelpers.ts index caeb1a4..b365173 100644 --- a/src/client/pgliteHelpers.ts +++ b/src/client/pgliteHelpers.ts @@ -86,6 +86,7 @@ export const setupServiceWorkerHttpsProxy = createIsomorphicFn().client( timestamp: Date.now(), method: requestData.method, pathname: url.pathname, + search: url.search, requestBody: requestData.requestBody, status: "pending", duration: null, diff --git a/src/collections/apiRequests.ts b/src/collections/apiRequests.ts index 99ba09b..f5b2698 100644 --- a/src/collections/apiRequests.ts +++ b/src/collections/apiRequests.ts @@ -10,6 +10,7 @@ export type ApiRequest = { pathname: string; requestBody?: unknown; responseBody?: unknown; + search?: string; status: number | "pending"; duration: number | null; // null when pending }; diff --git a/src/components/ApiRequestsPanel.tsx b/src/components/ApiRequestsPanel.tsx index 6825a7d..71d4bf8 100644 --- a/src/components/ApiRequestsPanel.tsx +++ b/src/components/ApiRequestsPanel.tsx @@ -21,7 +21,6 @@ import { CollapsibleContent, CollapsibleTrigger, } from "./ui/collapsible"; -import { ScrollArea } from "./ui/scroll-area"; const API_PANEL_WIDTH = "24rem"; const PANEL_ANIMATION_DURATION = 0.15; @@ -74,17 +73,17 @@ function RequestItem({ request }: { request: ApiRequest }) { const isPending = request.status === "pending"; return ( -
+
@@ -184,7 +184,7 @@ export function ApiRequestsPanel() { style={{ width: API_PANEL_WIDTH }} > {/* Request list */} - +
{requests.length === 0 ? (

@@ -233,7 +233,7 @@ export function ApiRequestsPanel() { ))}

)} - +
From c283e5f1ad36b049cf9434c98179b29148b36e66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=BCl=C3=B6p=20Kov=C3=A1cs?= <43729152+fulopkovacs@users.noreply.github.com> Date: Sat, 3 Jan 2026 14:09:48 +0100 Subject: [PATCH 04/17] Show the search string in the API Requests panel --- src/components/ApiRequestsPanel.tsx | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/components/ApiRequestsPanel.tsx b/src/components/ApiRequestsPanel.tsx index 71d4bf8..56e196e 100644 --- a/src/components/ApiRequestsPanel.tsx +++ b/src/components/ApiRequestsPanel.tsx @@ -42,8 +42,16 @@ function formatDuration(ms: number | null): string { return `${(ms / 1000).toFixed(2)}s`; } -function JsonViewer({ data, label }: { data: unknown; label: string }) { - const [isOpen, setIsOpen] = useState(false); +function JsonViewer({ + data, + label, + defaultOpen = false, +}: { + data: unknown; + label: string; + defaultOpen?: boolean; +}) { + const [isOpen, setIsOpen] = useState(defaultOpen); if (data === undefined || data === null) { return null; @@ -61,7 +69,7 @@ function JsonViewer({ data, label }: { data: unknown; label: string }) {
-          {JSON.stringify(data, null, 2)}
+          {typeof data === "string" ? data : JSON.stringify(data, null, 2)}
         
@@ -135,6 +143,11 @@ function RequestItem({ request }: { request: ApiRequest }) { {new Date(request.timestamp).toLocaleTimeString()}
+ {!isPending && ( )} From f23816aabf660defff0ee396d0156a655e8d76f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=BCl=C3=B6p=20Kov=C3=A1cs?= <43729152+fulopkovacs@users.noreply.github.com> Date: Sat, 3 Jan 2026 14:29:11 +0100 Subject: [PATCH 05/17] Initial commit - load the todo items on demand --- src/collections/todoItems.ts | 66 +++++++++++++++++++++++- src/components/CreateOrEditTodoItems.tsx | 2 +- src/components/TodoBoards.tsx | 28 ++++++---- src/local-api/api.todo-items.ts | 23 +++++++-- 4 files changed, 102 insertions(+), 17 deletions(-) diff --git a/src/collections/todoItems.ts b/src/collections/todoItems.ts index a734c93..550d26f 100644 --- a/src/collections/todoItems.ts +++ b/src/collections/todoItems.ts @@ -1,5 +1,10 @@ -import { createCollection } from "@tanstack/db"; +import { createCollection, parseLoadSubsetOptions } from "@tanstack/db"; import { queryCollectionOptions } from "@tanstack/query-db-collection"; +import type { + BasicExpression, + Offset, + OrderBy, +} from "node_modules/@tanstack/db/dist/esm/query/ir"; import { toast } from "sonner"; import type { TodoItemRecord } from "@/db/schema"; import * as TanstackQuery from "@/integrations/tanstack-query/root-provider"; @@ -58,8 +63,65 @@ const todoItemsQueryKey = ["todo-items"]; export const todoItemsCollection = createCollection( queryCollectionOptions({ queryKey: todoItemsQueryKey, - queryFn: getTodoItems, + queryFn: async ({ meta }) => { + const params = new URLSearchParams(); + + if (meta) { + const { limit, offset, where, orderBy } = meta.loadSubsetOptions as { + where?: BasicExpression; + orderBy?: OrderBy; + offset?: Offset; + limit?: number; + }; + + // Parse the expressions into simple format + const parsed = parseLoadSubsetOptions({ where, orderBy, limit }); + + console.info({ + parsed, + }); + + // Build query parameters from parsed filters + + // Add filters + parsed.filters.forEach(({ field, operator, value }) => { + const fieldName = field.join("."); + if (operator === "eq") { + params.set(fieldName, String(value)); + } else if (operator === "lt") { + params.set(`${fieldName}_lt`, String(value)); + } else if (operator === "gt") { + params.set(`${fieldName}_gt`, String(value)); + } + }); + + // Add sorting + if (parsed.sorts.length > 0) { + const sortParam = parsed.sorts + .map((s) => `${s.field.join(".")}:${s.direction}`) + .join(","); + params.set("sort", sortParam); + } + + // Add limit + if (parsed.limit) { + params.set("limit", String(parsed.limit)); + } + + // Add offset for pagination + if (offset) { + params.set("offset", String(offset)); + } + } + + const res = await fetch(`/api/todo-items?${params}`, { method: "GET" }); + + const todoItems: TodoItemRecord[] = await res.json(); + + return todoItems; + }, queryClient: TanstackQuery.getContext().queryClient, + syncMode: "on-demand", onInsert: async ({ transaction }) => { const { modified: newTodoItem } = transaction.mutations[0]; diff --git a/src/components/CreateOrEditTodoItems.tsx b/src/components/CreateOrEditTodoItems.tsx index d0d278a..eb4eb81 100644 --- a/src/components/CreateOrEditTodoItems.tsx +++ b/src/components/CreateOrEditTodoItems.tsx @@ -41,7 +41,7 @@ export function CreateOrEditTodoItems({ if (isNewItem) { // Find the first position in the board to prepend the new item - const itemsInBoard = (await todoItemsCollection.toArrayWhenReady()) + const itemsInBoard = todoItemsCollection.toArray .filter((item) => item.boardId === todoItem.boardId) .sort((a, b) => (a.position < b.position ? -1 : 1)); diff --git a/src/components/TodoBoards.tsx b/src/components/TodoBoards.tsx index ab9d8e5..1d9bdfe 100644 --- a/src/components/TodoBoards.tsx +++ b/src/components/TodoBoards.tsx @@ -329,16 +329,19 @@ export function TodoBoards({ projectId }: { projectId: string }) { [projectId], ); - const { - data: [activeTodoItem], - } = useLiveQuery( - (q) => - q + const { data: activeTodoItemData } = useLiveQuery( + (q) => { + if (!activeId) return undefined; + + return q .from({ todoItem: todoItemsCollection }) - .where(({ todoItem }) => eq(todoItem.id, activeId)), - [activeId, projectId], + .where(({ todoItem }) => eq(todoItem.id, activeId)); + }, + [activeId], ); + const activeTodoItem = activeTodoItemData?.[0]; + const { data: orderedTodoItems } = useLiveQuery( (q) => q @@ -407,7 +410,12 @@ export function TodoBoards({ projectId }: { projectId: string }) { (item) => item.id === over.id, ); - if (overTodoItem?.boardId === activeTodoItem.boardId) { + if (!overTodoItem) { + console.error("overTodoId not found"); + return; + } + + if (overTodoItem.boardId === activeTodoItem?.boardId) { // Reorder within the same column const prev = findPrevItem({ @@ -428,7 +436,7 @@ export function TodoBoards({ projectId }: { projectId: string }) { itemId: active.id as string, newPosition, }); - } else if (overTodoItem) { + } else { // Move to another column and insert at the correct position const newBoardId = overTodoItem.boardId; @@ -451,8 +459,6 @@ export function TodoBoards({ projectId }: { projectId: string }) { boardId: newBoardId as string, newPosition, }); - } else { - console.error("overTodoId not found"); } } }; diff --git a/src/local-api/api.todo-items.ts b/src/local-api/api.todo-items.ts index 3771c3c..7b8e8ad 100644 --- a/src/local-api/api.todo-items.ts +++ b/src/local-api/api.todo-items.ts @@ -25,10 +25,27 @@ const todoItemUpdateData = z.object({ }); export default { - GET: async () => { - const results = await db.select().from(todoItemsTable); + GET: async ({ request }) => { + const url = new URL(request.url); + + const boardId = url.searchParams.get("boardId"); + const id = url.searchParams.get("id"); + + if (boardId) { + const results = await db + .select() + .from(todoItemsTable) + .where(eq(todoItemsTable.boardId, boardId)); + return json(results); + } else if (id) { + const results = await db + .select() + .from(todoItemsTable) + .where(eq(todoItemsTable.id, id)); + return json(results); + } - return json(results); + return json([]); }, POST: async ({ request }) => { // Create new todo item From 3a74711f625d4566f791407b3f2fadf4e9cf09d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=BCl=C3=B6p=20Kov=C3=A1cs?= <43729152+fulopkovacs@users.noreply.github.com> Date: Sat, 3 Jan 2026 16:39:47 +0100 Subject: [PATCH 06/17] Remove any unnecessary data fetches for the todoItems --- src/collections/todoItems.ts | 15 ++++-- src/components/TodoBoards.tsx | 81 ++++++++++----------------------- src/local-api/api.todo-items.ts | 12 ++++- 3 files changed, 45 insertions(+), 63 deletions(-) diff --git a/src/collections/todoItems.ts b/src/collections/todoItems.ts index 550d26f..c3dc2b8 100644 --- a/src/collections/todoItems.ts +++ b/src/collections/todoItems.ts @@ -92,6 +92,9 @@ export const todoItemsCollection = createCollection( params.set(`${fieldName}_lt`, String(value)); } else if (operator === "gt") { params.set(`${fieldName}_gt`, String(value)); + } else if (operator === "in" && Array.isArray(value)) { + // Handle inArray - join values with comma + params.set(`${fieldName}_in`, value.join(",")); } }); @@ -172,11 +175,13 @@ export const todoItemsCollection = createCollection( } catch (_) { toast.error(`Failed to update todo item "${original.title}"`); - // Do not sync if the collection is already refetching - if (todoItemsCollection.utils.isRefetching === false) { - // Sync back the server's data - todoItemsCollection.utils.refetch(); - } + // TODO: handle this one later properly + // with queryClient.invalidateQueries(todoItemsQueryKey); + // // Do not sync if the collection is already refetching + // if (todoItemsCollection.utils.isRefetching === false) { + // // Sync back the server's data + // todoItemsCollection.utils.refetch(); + // } } // Do not sync back the server's data by default diff --git a/src/components/TodoBoards.tsx b/src/components/TodoBoards.tsx index 1d9bdfe..b1c659c 100644 --- a/src/components/TodoBoards.tsx +++ b/src/components/TodoBoards.tsx @@ -24,7 +24,6 @@ import { import { forwardRef, useEffect, useMemo, useRef, useState } from "react"; import { Virtualizer } from "virtua"; import { boardCollection } from "@/collections/boards"; -import { projectsCollection } from "@/collections/projects"; import { todoItemsCollection } from "@/collections/todoItems"; import type { BoardRecord, TodoItemRecord } from "@/db/schema"; import { useScrollShadow } from "@/hooks/use-scroll-shadow"; @@ -55,10 +54,7 @@ function DropIndicator() { return
; } -function findPrevItem< - T extends { boardId: string; todoItemId: string }, - U extends T, ->({ +function findPrevItem({ todoItems, target, }: { @@ -68,9 +64,7 @@ function findPrevItem< todoItems: U[]; target: T; }) { - const targetIndex = todoItems.findIndex( - (t) => t.todoItemId === target.todoItemId, - ); + const targetIndex = todoItems.findIndex((t) => t.id === target.id); const prev = todoItems[targetIndex - 1]; @@ -329,53 +323,26 @@ export function TodoBoards({ projectId }: { projectId: string }) { [projectId], ); - const { data: activeTodoItemData } = useLiveQuery( - (q) => { - if (!activeId) return undefined; - - return q - .from({ todoItem: todoItemsCollection }) - .where(({ todoItem }) => eq(todoItem.id, activeId)); - }, - [activeId], - ); - - const activeTodoItem = activeTodoItemData?.[0]; - - const { data: orderedTodoItems } = useLiveQuery( - (q) => - q - .from({ - todoItem: todoItemsCollection, - }) - .innerJoin({ board: boardCollection }, ({ todoItem, board }) => - eq(todoItem.boardId, board.id), - ) - .innerJoin({ project: projectsCollection }, ({ board, project }) => - eq(board.projectId, project.id), - ) - .where(({ project }) => eq(project.id, projectId)) - .select(({ todoItem, board }) => ({ - boardId: todoItem.boardId, - boardName: board.name, - todoTitle: todoItem.title, - projectId: projectId, - position: todoItem.position, - todoItemId: todoItem.id, - })) - .orderBy(({ todoItem }) => [todoItem.boardId, todoItem.position], { - direction: "asc", - /* - We use fractional indexes, so we need lexical sorting to get the correct order. - - Ascending order of ["Zz", "a0"] is: - - lexical string sort: ["Zz", "a0"] - - default result (uses "locale"): ["a0", "Zz"] - */ - stringSort: "lexical", - }), - [projectId], - ); + // Get active todo item from already-loaded collection data + // instead of making a separate query + const activeTodoItem = activeId + ? todoItemsCollection.toArray.find((item) => item.id === activeId) + : undefined; + + // Derive ordered todo items from already-loaded collection data + // instead of making a separate query + const orderedTodoItems = useMemo(() => { + const boardIdSet = new Set(boards.map((b) => b.id)); + return todoItemsCollection.toArray + .filter((item) => boardIdSet.has(item.boardId)) + .sort((a, b) => { + // Sort by boardId first, then by position (lexical for fractional indexing) + if (a.boardId !== b.boardId) { + return a.boardId.localeCompare(b.boardId); + } + return a.position < b.position ? -1 : a.position > b.position ? 1 : 0; + }); + }, [boards]); const handleDragStart = (event: DragStartEvent) => { setActiveId(event.active.id); @@ -422,7 +389,7 @@ export function TodoBoards({ projectId }: { projectId: string }) { todoItems: orderedTodoItems, target: { boardId: overTodoItem.boardId, - todoItemId: overTodoItem.id as string, + id: overTodoItem.id as string, }, }); @@ -444,7 +411,7 @@ export function TodoBoards({ projectId }: { projectId: string }) { todoItems: orderedTodoItems, target: { boardId: overTodoItem.boardId, - todoItemId: overTodoItem.id as string, + id: overTodoItem.id as string, }, }); diff --git a/src/local-api/api.todo-items.ts b/src/local-api/api.todo-items.ts index 7b8e8ad..4f1f4b9 100644 --- a/src/local-api/api.todo-items.ts +++ b/src/local-api/api.todo-items.ts @@ -1,4 +1,4 @@ -import { eq } from "drizzle-orm"; +import { eq, inArray } from "drizzle-orm"; import z from "zod"; import { db } from "@/db"; import { type TodoItemRecord, todoItemsTable } from "@/db/schema"; @@ -28,7 +28,10 @@ export default { GET: async ({ request }) => { const url = new URL(request.url); + // NOTE: these query params should not be + // mutually exclusive const boardId = url.searchParams.get("boardId"); + const boardIdIn = url.searchParams.get("boardId_in"); const id = url.searchParams.get("id"); if (boardId) { @@ -37,6 +40,13 @@ export default { .from(todoItemsTable) .where(eq(todoItemsTable.boardId, boardId)); return json(results); + } else if (boardIdIn) { + const boardIds = boardIdIn.split(","); + const results = await db + .select() + .from(todoItemsTable) + .where(inArray(todoItemsTable.boardId, boardIds)); + return json(results); } else if (id) { const results = await db .select() From c8c87f535f79905a7d48e1904fbb0450324d897b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=BCl=C3=B6p=20Kov=C3=A1cs?= <43729152+fulopkovacs@users.noreply.github.com> Date: Sat, 3 Jan 2026 17:32:00 +0100 Subject: [PATCH 07/17] Fix the issue of changes disappearing when the user switch between projects --- .../migrations/0001_previous_chronomancer.sql | 3 + drizzle/migrations/meta/0001_snapshot.json | 323 ++++++++++++++++++ drizzle/migrations/meta/_journal.json | 7 + src/collections/todoItems.ts | 35 +- src/components/CreateOrEditTodoItems.tsx | 4 +- src/components/TodoBoards.tsx | 51 +-- src/db/migrations.json | 10 + src/db/schema.ts | 4 + src/db/seed.ts | 3 +- .../tanstack-query/root-provider.tsx | 9 +- src/local-api/api.todo-items.ts | 14 +- 11 files changed, 427 insertions(+), 36 deletions(-) create mode 100644 drizzle/migrations/0001_previous_chronomancer.sql create mode 100644 drizzle/migrations/meta/0001_snapshot.json diff --git a/drizzle/migrations/0001_previous_chronomancer.sql b/drizzle/migrations/0001_previous_chronomancer.sql new file mode 100644 index 0000000..debc117 --- /dev/null +++ b/drizzle/migrations/0001_previous_chronomancer.sql @@ -0,0 +1,3 @@ +ALTER TABLE "todo_items" ADD COLUMN "project_id" text NOT NULL;--> statement-breakpoint +ALTER TABLE "todo_items" ADD CONSTRAINT "todo_items_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "todo_items_project_id_idx" ON "todo_items" USING btree ("project_id"); \ No newline at end of file diff --git a/drizzle/migrations/meta/0001_snapshot.json b/drizzle/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..5ef5cd0 --- /dev/null +++ b/drizzle/migrations/meta/0001_snapshot.json @@ -0,0 +1,323 @@ +{ + "id": "bec13a12-e3c0-460a-8fd3-76c386221c9d", + "prevId": "58ddc186-dd38-49a3-81e4-b0e9c8528908", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.boards": { + "name": "boards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "boards_project_id_projects_id_fk": { + "name": "boards_project_id_projects_id_fk", + "tableFrom": "boards", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "date", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "projects_name_unique": { + "name": "projects_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.seed_script_runs": { + "name": "seed_script_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.todo_items": { + "name": "todo_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "board_id": { + "name": "board_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "todo_items_board_id_position_idx": { + "name": "todo_items_board_id_position_idx", + "columns": [ + { + "expression": "board_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "todo_items_project_id_idx": { + "name": "todo_items_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "todo_items_board_id_boards_id_fk": { + "name": "todo_items_board_id_boards_id_fk", + "tableFrom": "todo_items", + "tableTo": "boards", + "columnsFrom": [ + "board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "todo_items_project_id_projects_id_fk": { + "name": "todo_items_project_id_projects_id_fk", + "tableFrom": "todo_items", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "age": { + "name": "age", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique_tempdbid": { + "name": "users_email_unique_tempdbid", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/migrations/meta/_journal.json b/drizzle/migrations/meta/_journal.json index db2e249..c7d868c 100644 --- a/drizzle/migrations/meta/_journal.json +++ b/drizzle/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1766184598553, "tag": "0000_outstanding_silvermane", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1767455245894, + "tag": "0001_previous_chronomancer", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/collections/todoItems.ts b/src/collections/todoItems.ts index c3dc2b8..883365a 100644 --- a/src/collections/todoItems.ts +++ b/src/collections/todoItems.ts @@ -10,14 +10,6 @@ import type { TodoItemRecord } from "@/db/schema"; import * as TanstackQuery from "@/integrations/tanstack-query/root-provider"; import type { TodoItemCreateDataType } from "@/local-api/api.todo-items"; -async function getTodoItems() { - const res = await fetch("/api/todo-items", { method: "GET" }); - - const todoItems: TodoItemRecord[] = await res.json(); - - return todoItems; -} - async function updateTodoItem({ data, }: { @@ -172,6 +164,33 @@ export const todoItemsCollection = createCollection( id: original.id, ...changes, }); + + // Update the TanStack Query cache so switching projects shows correct data + const queryClient = TanstackQuery.getContext().queryClient; + queryClient.setQueriesData( + { queryKey: todoItemsQueryKey }, + (oldData) => { + if (!oldData) return oldData; + return oldData.map((item) => + item.id === original.id ? { ...item, ...changes } : item, + ); + }, + ); + + queryClient.setQueriesData( + { queryKey: todoItemsQueryKey }, + (oldData) => { + console.log( + "Updating query cache, oldData:", + oldData?.length, + "items", + ); + if (!oldData) return oldData; + return oldData.map((item) => + item.id === original.id ? { ...item, ...changes } : item, + ); + }, + ); } catch (_) { toast.error(`Failed to update todo item "${original.title}"`); diff --git a/src/components/CreateOrEditTodoItems.tsx b/src/components/CreateOrEditTodoItems.tsx index eb4eb81..0261e7e 100644 --- a/src/components/CreateOrEditTodoItems.tsx +++ b/src/components/CreateOrEditTodoItems.tsx @@ -20,7 +20,8 @@ export function CreateOrEditTodoItems({ todoItem, children, }: { - todoItem: Partial & Pick; + todoItem: Partial & + Pick; children: React.ReactNode; }) { const [isOpen, setIsOpen] = useState(false); @@ -51,6 +52,7 @@ export function CreateOrEditTodoItems({ todoItemsCollection.insert({ id: itemId, boardId: todoItem.boardId, + projectId: todoItem.projectId, title: value.title, description: value.description, position: newPosition, diff --git a/src/components/TodoBoards.tsx b/src/components/TodoBoards.tsx index b1c659c..27ad1c9 100644 --- a/src/components/TodoBoards.tsx +++ b/src/components/TodoBoards.tsx @@ -167,24 +167,15 @@ function DraggableTask({ task }: { task: TodoItemRecord }) { ); } -function Board({ board }: { board: BoardRecord }) { - const { data: todoItems } = useLiveQuery((q) => - q - .from({ todoItem: todoItemsCollection }) - .where(({ todoItem }) => eq(todoItem.boardId, board.id)) - .orderBy(({ todoItem }) => todoItem.position, { - direction: "asc", - /* - We use fractional indexes, so we need lexical sorting to get the correct order. - - Ascending order of ["Zz", "a0"] is: - - lexical string sort: ["Zz", "a0"] - - default result (uses "locale"): ["a0", "Zz"] - */ - stringSort: "lexical", - }), - ); - +function Board({ + board, + todoItems, + projectId, +}: { + board: BoardRecord; + todoItems: TodoItemRecord[]; + projectId: string; +}) { const { active, over } = useDndContext(); const { scrollRef, canScrollUp, canScrollDown } = useScrollShadow(); @@ -241,7 +232,7 @@ function Board({ board }: { board: BoardRecord }) {

{board.description}

- + @@ -323,6 +314,19 @@ export function TodoBoards({ projectId }: { projectId: string }) { [projectId], ); + // Fetch all todo items for the project in a single query + const { data: allTodoItems } = useLiveQuery( + (q) => + q + .from({ todoItem: todoItemsCollection }) + .where(({ todoItem }) => eq(todoItem.projectId, projectId)) + .orderBy(({ todoItem }) => todoItem.position, { + direction: "asc", + stringSort: "lexical", + }), + [projectId], + ); + // Get active todo item from already-loaded collection data // instead of making a separate query const activeTodoItem = activeId @@ -446,7 +450,14 @@ export function TodoBoards({ projectId }: { projectId: string }) { ) : (
{sortedBoards.map((board) => ( - + item.boardId === board.id, + )} + projectId={projectId} + /> ))}
)} diff --git a/src/db/migrations.json b/src/db/migrations.json index 91da8ce..f0040a1 100644 --- a/src/db/migrations.json +++ b/src/db/migrations.json @@ -13,5 +13,15 @@ "bps": true, "folderMillis": 1766184598553, "hash": "34b95068a8520477abc224439403ce87853814c878b1e4457492528b98cbe70b" + }, + { + "sql": [ + "ALTER TABLE \"todo_items\" ADD COLUMN \"project_id\" text NOT NULL;", + "\nALTER TABLE \"todo_items\" ADD CONSTRAINT \"todo_items_project_id_projects_id_fk\" FOREIGN KEY (\"project_id\") REFERENCES \"public\".\"projects\"(\"id\") ON DELETE cascade ON UPDATE no action;", + "\nCREATE INDEX \"todo_items_project_id_idx\" ON \"todo_items\" USING btree (\"project_id\");" + ], + "bps": true, + "folderMillis": 1767455245894, + "hash": "6f5679430929920907927eacc8252c4256cd9bf75c1b568cd164b906d6e9403d" } ] diff --git a/src/db/schema.ts b/src/db/schema.ts index 8d550de..42c45c8 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -61,11 +61,15 @@ export const todoItemsTable = pgTable( boardId: text("board_id") .references(() => boardsTable.id, { onDelete: "cascade" }) .notNull(), + projectId: text("project_id") + .references(() => projectsTable.id, { onDelete: "cascade" }) + .notNull(), priority: integer().default(0), position: text().notNull(), }, (table) => [ index("todo_items_board_id_position_idx").on(table.boardId, table.position), + index("todo_items_project_id_idx").on(table.projectId), ], ); diff --git a/src/db/seed.ts b/src/db/seed.ts index 1c2d2a6..9f90e6e 100644 --- a/src/db/seed.ts +++ b/src/db/seed.ts @@ -18,7 +18,7 @@ import { type BoardName = "Todo" | "In Progress" | "Done"; type TodoItemBase = Omit< TodoItemRecord, - "id" | "boardId" | "tempDbId" | "createdAt" + "id" | "boardId" | "projectId" | "tempDbId" | "createdAt" > & { boardName: BoardName }; type ProjectBase = ProjectRecord & { @@ -215,6 +215,7 @@ function getMockBoardsAndTodoItemsForProject({ ...item, id: nanoid(), boardId: boardIds[boardName], + projectId, createdAt: now, }), ); diff --git a/src/integrations/tanstack-query/root-provider.tsx b/src/integrations/tanstack-query/root-provider.tsx index f2a8e5b..841698f 100644 --- a/src/integrations/tanstack-query/root-provider.tsx +++ b/src/integrations/tanstack-query/root-provider.tsx @@ -1,9 +1,14 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +// Singleton queryClient to ensure the same instance is used everywhere +let queryClientSingleton: QueryClient | null = null; + export function getContext() { - const queryClient = new QueryClient(); + if (!queryClientSingleton) { + queryClientSingleton = new QueryClient(); + } return { - queryClient, + queryClient: queryClientSingleton, }; } diff --git a/src/local-api/api.todo-items.ts b/src/local-api/api.todo-items.ts index 4f1f4b9..5d31fd5 100644 --- a/src/local-api/api.todo-items.ts +++ b/src/local-api/api.todo-items.ts @@ -7,6 +7,7 @@ import { type APIRouteHandler, json } from "./helpers"; const todoItemCreateData = z.object({ id: z.string().min(1), boardId: z.string(), + projectId: z.string(), priority: z.number().min(0).max(3).int().optional().nullable(), title: z.string(), description: z.string().optional().nullable(), @@ -28,13 +29,18 @@ export default { GET: async ({ request }) => { const url = new URL(request.url); - // NOTE: these query params should not be - // mutually exclusive + const projectId = url.searchParams.get("projectId"); const boardId = url.searchParams.get("boardId"); const boardIdIn = url.searchParams.get("boardId_in"); const id = url.searchParams.get("id"); - if (boardId) { + if (projectId) { + const results = await db + .select() + .from(todoItemsTable) + .where(eq(todoItemsTable.projectId, projectId)); + return json(results); + } else if (boardId) { const results = await db .select() .from(todoItemsTable) @@ -59,7 +65,7 @@ export default { }, POST: async ({ request }) => { // Create new todo item - let newTodoItemData: Omit, "projectId">; + let newTodoItemData: z.infer; // biome-ignore lint/suspicious/noExplicitAny: it can be any here let bodyObj: any; From 56e1792786b538b334fcc100bbac65876a3f1d97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=BCl=C3=B6p=20Kov=C3=A1cs?= <43729152+fulopkovacs@users.noreply.github.com> Date: Sat, 3 Jan 2026 17:32:35 +0100 Subject: [PATCH 08/17] This is not necessary anymore --- src/collections/todoItems.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/collections/todoItems.ts b/src/collections/todoItems.ts index 883365a..9635d93 100644 --- a/src/collections/todoItems.ts +++ b/src/collections/todoItems.ts @@ -159,12 +159,6 @@ export const todoItemsCollection = createCollection( }, }); - // If successful, we can keep the optimistic update - todoItemsCollection.utils.writeUpdate({ - id: original.id, - ...changes, - }); - // Update the TanStack Query cache so switching projects shows correct data const queryClient = TanstackQuery.getContext().queryClient; queryClient.setQueriesData( From 4c47340ea9737f24170487432600d87a5f66a80d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=BCl=C3=B6p=20Kov=C3=A1cs?= <43729152+fulopkovacs@users.noreply.github.com> Date: Sat, 3 Jan 2026 19:14:53 +0100 Subject: [PATCH 09/17] Show a loading skeleton for the tasks when they are being loaded after the user switched to another project --- src/components/TodoBoards.tsx | 138 ++++++++++++++++++--------- src/components/TodoBoardsLoading.tsx | 28 ++++-- 2 files changed, 112 insertions(+), 54 deletions(-) diff --git a/src/components/TodoBoards.tsx b/src/components/TodoBoards.tsx index 27ad1c9..ec157ca 100644 --- a/src/components/TodoBoards.tsx +++ b/src/components/TodoBoards.tsx @@ -30,7 +30,10 @@ import { useScrollShadow } from "@/hooks/use-scroll-shadow"; import { cn } from "@/lib/utils"; import { CreateOrEditTodoItems } from "./CreateOrEditTodoItems"; import { PriorityRatingPopup } from "./PriorityRating"; -import { TodoBoardsLoading } from "./TodoBoardsLoading"; +import { + LoadingTasksOnBoardSkeleton, + TodoBoardsLoading, +} from "./TodoBoardsLoading"; import { Badge } from "./ui/badge"; import { Button } from "./ui/button"; import { @@ -171,10 +174,14 @@ function Board({ board, todoItems, projectId, + showTodoItemsLoading, + boardIndex, }: { board: BoardRecord; todoItems: TodoItemRecord[]; projectId: string; + showTodoItemsLoading?: boolean; + boardIndex: number; }) { const { active, over } = useDndContext(); const { scrollRef, canScrollUp, canScrollDown } = useScrollShadow(); @@ -233,55 +240,63 @@ function Board({ {board.description}

-
-
- - -
{ - setNodeRef(node); - scrollRef.current = node; - }} - className="h-full overflow-auto" - > - task.id)} + {showTodoItemsLoading ? ( + + ) : ( +
+ + +
{ + setNodeRef(node); + scrollRef.current = node; + }} + className="h-full overflow-auto" > - { - /* - Empty columns do not have any todo items in them, so we can't - render the drop indicator when we iterate through the todo items. - - In that case, we need to render the drop indicator here. - */ - active && dropIndex === 0 && todoItems.length === 0 && ( - - ) - } - - {(todoItem, index) => { - const showDropIndicator = active && dropIndex === index; - return ( -
- {showDropIndicator && } - - {active && - dropIndex === todoItems.length && - index === todoItems.length - 1 && } -
- ); - }} -
- -
+ task.id)} + > + { + /* + Empty columns do not have any todo items in them, so we can't + render the drop indicator when we iterate through the todo items. + + In that case, we need to render the drop indicator here. + */ + active && dropIndex === 0 && todoItems.length === 0 && ( + + ) + } + + {(todoItem, index) => { + const showDropIndicator = active && dropIndex === index; + return ( +
+ {showDropIndicator && } + + {active && + dropIndex === todoItems.length && + index === todoItems.length - 1 && } +
+ ); + }} +
+
+
- -
+ +
+ )} ); } @@ -327,6 +342,37 @@ export function TodoBoards({ projectId }: { projectId: string }) { [projectId], ); + // TODO: not sure if this is necessary + const [projectIds, setProjectIds] = useState([]); + + const [showTodoItemsLoading, setShowTodoItemsLoading] = + useState(false); + + useEffect(() => { + let unsubscribe: (() => void) | undefined; + + if ( + allTodoItems.length === 0 && + todoItemsCollection.isLoadingSubset && + !projectIds.includes(projectId) + ) { + setShowTodoItemsLoading(true); + setProjectIds((prev) => [...prev, projectId]); + + unsubscribe = todoItemsCollection.on("loadingSubset:change", (event) => { + if (event.loadingSubsetTransition === "end") { + setShowTodoItemsLoading(false); + } + }); + } else { + setShowTodoItemsLoading(false); + } + + return () => { + unsubscribe?.(); + }; + }, [projectId, projectIds, allTodoItems.length]); + // Get active todo item from already-loaded collection data // instead of making a separate query const activeTodoItem = activeId @@ -449,7 +495,7 @@ export function TodoBoards({ projectId }: { projectId: string }) { ) : (
- {sortedBoards.map((board) => ( + {sortedBoards.map((board, i) => ( item.boardId === board.id, )} projectId={projectId} + showTodoItemsLoading={showTodoItemsLoading} + boardIndex={i} /> ))}
diff --git a/src/components/TodoBoardsLoading.tsx b/src/components/TodoBoardsLoading.tsx index 46e453c..1fe1c6b 100644 --- a/src/components/TodoBoardsLoading.tsx +++ b/src/components/TodoBoardsLoading.tsx @@ -4,7 +4,21 @@ function TaskSkeleton() { return ; } -function BoardSkeleton({ taskCount }: { taskCount: number }) { +export function LoadingTasksOnBoardSkeleton({ + boardIndex, +}: { + boardIndex: number; +}) { + return ( +
+ {Array.from({ length: boardIndex === 1 ? 3 : 2 }).map((_, index) => ( + + ))} +
+ ); +} + +function BoardSkeleton({ boardIndex }: { boardIndex: number }) { return (
@@ -16,11 +30,7 @@ function BoardSkeleton({ taskCount }: { taskCount: number }) {
-
- {Array.from({ length: taskCount }).map((_, index) => ( - - ))} -
+
); } @@ -29,9 +39,9 @@ export function TodoBoardsLoading() { return (
- - - + + +
); From 5db4211fa1128e57f9354fda09b9cc1557b25be6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=BCl=C3=B6p=20Kov=C3=A1cs?= <43729152+fulopkovacs@users.noreply.github.com> Date: Sat, 3 Jan 2026 19:39:56 +0100 Subject: [PATCH 10/17] Import types from the main @tanstack/db entry point --- src/collections/todoItems.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/collections/todoItems.ts b/src/collections/todoItems.ts index 9635d93..42903c3 100644 --- a/src/collections/todoItems.ts +++ b/src/collections/todoItems.ts @@ -1,10 +1,9 @@ -import { createCollection, parseLoadSubsetOptions } from "@tanstack/db"; +import { + createCollection, + type IR, + parseLoadSubsetOptions, +} from "@tanstack/db"; import { queryCollectionOptions } from "@tanstack/query-db-collection"; -import type { - BasicExpression, - Offset, - OrderBy, -} from "node_modules/@tanstack/db/dist/esm/query/ir"; import { toast } from "sonner"; import type { TodoItemRecord } from "@/db/schema"; import * as TanstackQuery from "@/integrations/tanstack-query/root-provider"; @@ -60,9 +59,9 @@ export const todoItemsCollection = createCollection( if (meta) { const { limit, offset, where, orderBy } = meta.loadSubsetOptions as { - where?: BasicExpression; - orderBy?: OrderBy; - offset?: Offset; + where?: IR.BasicExpression; + orderBy?: IR.OrderBy; + offset?: IR.Offset; limit?: number; }; From e4c2b18231c95b933742f076f551f7a344959588 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=BCl=C3=B6p=20Kov=C3=A1cs?= <43729152+fulopkovacs@users.noreply.github.com> Date: Sat, 3 Jan 2026 19:41:46 +0100 Subject: [PATCH 11/17] Remove a console log --- src/collections/todoItems.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/collections/todoItems.ts b/src/collections/todoItems.ts index 42903c3..db7e369 100644 --- a/src/collections/todoItems.ts +++ b/src/collections/todoItems.ts @@ -68,10 +68,6 @@ export const todoItemsCollection = createCollection( // Parse the expressions into simple format const parsed = parseLoadSubsetOptions({ where, orderBy, limit }); - console.info({ - parsed, - }); - // Build query parameters from parsed filters // Add filters From 43501c16ec2ca347ee3dfc25348e6a56f10d7e5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=BCl=C3=B6p=20Kov=C3=A1cs?= <43729152+fulopkovacs@users.noreply.github.com> Date: Sat, 3 Jan 2026 19:42:54 +0100 Subject: [PATCH 12/17] Remove some duplicated logic --- src/collections/todoItems.ts | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/src/collections/todoItems.ts b/src/collections/todoItems.ts index db7e369..13bb58d 100644 --- a/src/collections/todoItems.ts +++ b/src/collections/todoItems.ts @@ -165,21 +165,6 @@ export const todoItemsCollection = createCollection( ); }, ); - - queryClient.setQueriesData( - { queryKey: todoItemsQueryKey }, - (oldData) => { - console.log( - "Updating query cache, oldData:", - oldData?.length, - "items", - ); - if (!oldData) return oldData; - return oldData.map((item) => - item.id === original.id ? { ...item, ...changes } : item, - ); - }, - ); } catch (_) { toast.error(`Failed to update todo item "${original.title}"`); From 67f239032dcf93648b30d54095df01b67efd27e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=BCl=C3=B6p=20Kov=C3=A1cs?= <43729152+fulopkovacs@users.noreply.github.com> Date: Sat, 3 Jan 2026 19:52:47 +0100 Subject: [PATCH 13/17] Handle some edge cases --- src/components/TodoBoards.tsx | 52 +++++++++++++++++++++++------------ 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/src/components/TodoBoards.tsx b/src/components/TodoBoards.tsx index ec157ca..fcb9221 100644 --- a/src/components/TodoBoards.tsx +++ b/src/components/TodoBoards.tsx @@ -342,36 +342,54 @@ export function TodoBoards({ projectId }: { projectId: string }) { [projectId], ); - // TODO: not sure if this is necessary - const [projectIds, setProjectIds] = useState([]); + // Track projects that have completed loading (not just started) + const loadedProjectsRef = useRef>(new Set()); + const unsubscribeRef = useRef<(() => void) | undefined>(undefined); const [showTodoItemsLoading, setShowTodoItemsLoading] = useState(false); useEffect(() => { - let unsubscribe: (() => void) | undefined; + // Clean up previous subscription synchronously before creating a new one + if (unsubscribeRef.current) { + unsubscribeRef.current(); + unsubscribeRef.current = undefined; + } + + // If project already loaded, no need to show loading or subscribe + if (loadedProjectsRef.current.has(projectId)) { + setShowTodoItemsLoading(false); + return; + } + + // If we have items or not loading, mark as loaded + if (allTodoItems.length > 0 || !todoItemsCollection.isLoadingSubset) { + loadedProjectsRef.current.add(projectId); + setShowTodoItemsLoading(false); + return; + } - if ( - allTodoItems.length === 0 && - todoItemsCollection.isLoadingSubset && - !projectIds.includes(projectId) - ) { - setShowTodoItemsLoading(true); - setProjectIds((prev) => [...prev, projectId]); + // Items are empty and still loading - show skeleton and subscribe + setShowTodoItemsLoading(true); - unsubscribe = todoItemsCollection.on("loadingSubset:change", (event) => { + const currentProjectId = projectId; + unsubscribeRef.current = todoItemsCollection.on( + "loadingSubset:change", + (event) => { if (event.loadingSubsetTransition === "end") { + loadedProjectsRef.current.add(currentProjectId); setShowTodoItemsLoading(false); } - }); - } else { - setShowTodoItemsLoading(false); - } + }, + ); return () => { - unsubscribe?.(); + if (unsubscribeRef.current) { + unsubscribeRef.current(); + unsubscribeRef.current = undefined; + } }; - }, [projectId, projectIds, allTodoItems.length]); + }, [projectId, allTodoItems.length]); // Get active todo item from already-loaded collection data // instead of making a separate query From a2880941edbf2bfa1800cfa142a914e6721d0c0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=BCl=C3=B6p=20Kov=C3=A1cs?= <43729152+fulopkovacs@users.noreply.github.com> Date: Sat, 3 Jan 2026 20:01:34 +0100 Subject: [PATCH 14/17] Apparently, using singletons on the server leads to data leaks --- .../tanstack-query/root-provider.tsx | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/integrations/tanstack-query/root-provider.tsx b/src/integrations/tanstack-query/root-provider.tsx index 841698f..eac6f46 100644 --- a/src/integrations/tanstack-query/root-provider.tsx +++ b/src/integrations/tanstack-query/root-provider.tsx @@ -1,14 +1,26 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -// Singleton queryClient to ensure the same instance is used everywhere -let queryClientSingleton: QueryClient | null = null; +// Client-side singleton to ensure the same instance is used across the app +let clientQueryClient: QueryClient | null = null; + +function makeQueryClient() { + return new QueryClient(); +} export function getContext() { - if (!queryClientSingleton) { - queryClientSingleton = new QueryClient(); + // Server: always create a new QueryClient per request to avoid data leaks + if (typeof window === "undefined") { + return { + queryClient: makeQueryClient(), + }; + } + + // Client: use singleton to preserve cache across navigations + if (!clientQueryClient) { + clientQueryClient = makeQueryClient(); } return { - queryClient: queryClientSingleton, + queryClient: clientQueryClient, }; } From a3f32ec2e4bb79ed14cbd399a546c7c1741afaf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=BCl=C3=B6p=20Kov=C3=A1cs?= <43729152+fulopkovacs@users.noreply.github.com> Date: Sat, 3 Jan 2026 20:03:38 +0100 Subject: [PATCH 15/17] Remove an unnecessary type casting --- src/collections/todoItems.ts | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/src/collections/todoItems.ts b/src/collections/todoItems.ts index 13bb58d..44171e6 100644 --- a/src/collections/todoItems.ts +++ b/src/collections/todoItems.ts @@ -1,8 +1,4 @@ -import { - createCollection, - type IR, - parseLoadSubsetOptions, -} from "@tanstack/db"; +import { createCollection, parseLoadSubsetOptions } from "@tanstack/db"; import { queryCollectionOptions } from "@tanstack/query-db-collection"; import { toast } from "sonner"; import type { TodoItemRecord } from "@/db/schema"; @@ -58,12 +54,7 @@ export const todoItemsCollection = createCollection( const params = new URLSearchParams(); if (meta) { - const { limit, offset, where, orderBy } = meta.loadSubsetOptions as { - where?: IR.BasicExpression; - orderBy?: IR.OrderBy; - offset?: IR.Offset; - limit?: number; - }; + const { limit, offset, where, orderBy } = meta.loadSubsetOptions; // Parse the expressions into simple format const parsed = parseLoadSubsetOptions({ where, orderBy, limit }); From 21e953dc1bc0fc66bd85bb23098deede90bd4be7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=BCl=C3=B6p=20Kov=C3=A1cs?= <43729152+fulopkovacs@users.noreply.github.com> Date: Sat, 3 Jan 2026 20:10:53 +0100 Subject: [PATCH 16/17] Remove code that was not used --- src/collections/todoItems.ts | 31 ++++--------------------------- src/local-api/api.todo-items.ts | 26 +++----------------------- 2 files changed, 7 insertions(+), 50 deletions(-) diff --git a/src/collections/todoItems.ts b/src/collections/todoItems.ts index 44171e6..f94caeb 100644 --- a/src/collections/todoItems.ts +++ b/src/collections/todoItems.ts @@ -54,45 +54,22 @@ export const todoItemsCollection = createCollection( const params = new URLSearchParams(); if (meta) { - const { limit, offset, where, orderBy } = meta.loadSubsetOptions; + const { where } = meta.loadSubsetOptions; // Parse the expressions into simple format - const parsed = parseLoadSubsetOptions({ where, orderBy, limit }); + const parsed = parseLoadSubsetOptions({ where }); // Build query parameters from parsed filters // Add filters parsed.filters.forEach(({ field, operator, value }) => { const fieldName = field.join("."); + + // Currently only "eq" operator is supported in the API if (operator === "eq") { params.set(fieldName, String(value)); - } else if (operator === "lt") { - params.set(`${fieldName}_lt`, String(value)); - } else if (operator === "gt") { - params.set(`${fieldName}_gt`, String(value)); - } else if (operator === "in" && Array.isArray(value)) { - // Handle inArray - join values with comma - params.set(`${fieldName}_in`, value.join(",")); } }); - - // Add sorting - if (parsed.sorts.length > 0) { - const sortParam = parsed.sorts - .map((s) => `${s.field.join(".")}:${s.direction}`) - .join(","); - params.set("sort", sortParam); - } - - // Add limit - if (parsed.limit) { - params.set("limit", String(parsed.limit)); - } - - // Add offset for pagination - if (offset) { - params.set("offset", String(offset)); - } } const res = await fetch(`/api/todo-items?${params}`, { method: "GET" }); diff --git a/src/local-api/api.todo-items.ts b/src/local-api/api.todo-items.ts index 5d31fd5..e0bf39d 100644 --- a/src/local-api/api.todo-items.ts +++ b/src/local-api/api.todo-items.ts @@ -29,10 +29,8 @@ export default { GET: async ({ request }) => { const url = new URL(request.url); + // Currently we only support filtering by projectId const projectId = url.searchParams.get("projectId"); - const boardId = url.searchParams.get("boardId"); - const boardIdIn = url.searchParams.get("boardId_in"); - const id = url.searchParams.get("id"); if (projectId) { const results = await db @@ -40,28 +38,10 @@ export default { .from(todoItemsTable) .where(eq(todoItemsTable.projectId, projectId)); return json(results); - } else if (boardId) { - const results = await db - .select() - .from(todoItemsTable) - .where(eq(todoItemsTable.boardId, boardId)); - return json(results); - } else if (boardIdIn) { - const boardIds = boardIdIn.split(","); - const results = await db - .select() - .from(todoItemsTable) - .where(inArray(todoItemsTable.boardId, boardIds)); - return json(results); - } else if (id) { - const results = await db - .select() - .from(todoItemsTable) - .where(eq(todoItemsTable.id, id)); + } else { + const results = await db.select().from(todoItemsTable); return json(results); } - - return json([]); }, POST: async ({ request }) => { // Create new todo item From 85cb0d010980517160fdd4488f163a3e1e0d3ef0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=BCl=C3=B6p=20Kov=C3=A1cs?= <43729152+fulopkovacs@users.noreply.github.com> Date: Sat, 3 Jan 2026 20:14:08 +0100 Subject: [PATCH 17/17] Fix a type error --- src/local-api/api.todo-items.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/local-api/api.todo-items.ts b/src/local-api/api.todo-items.ts index e0bf39d..e5746d3 100644 --- a/src/local-api/api.todo-items.ts +++ b/src/local-api/api.todo-items.ts @@ -1,4 +1,4 @@ -import { eq, inArray } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import z from "zod"; import { db } from "@/db"; import { type TodoItemRecord, todoItemsTable } from "@/db/schema";