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/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: 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..b365173 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,8 @@ export const setupServiceWorkerHttpsProxy = createIsomorphicFn().client( id: requestId, timestamp: Date.now(), method: requestData.method, - pathname: requestData.pathname, + 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/collections/todoItems.ts b/src/collections/todoItems.ts index a734c93..f94caeb 100644 --- a/src/collections/todoItems.ts +++ b/src/collections/todoItems.ts @@ -1,18 +1,10 @@ -import { createCollection } 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"; 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, }: { @@ -58,8 +50,36 @@ const todoItemsQueryKey = ["todo-items"]; export const todoItemsCollection = createCollection( queryCollectionOptions({ queryKey: todoItemsQueryKey, - queryFn: getTodoItems, + queryFn: async ({ meta }) => { + const params = new URLSearchParams(); + + if (meta) { + const { where } = meta.loadSubsetOptions; + + // Parse the expressions into simple format + 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)); + } + }); + } + + 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]; @@ -102,19 +122,27 @@ 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( + { queryKey: todoItemsQueryKey }, + (oldData) => { + 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}"`); - // 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/ApiRequestsPanel.tsx b/src/components/ApiRequestsPanel.tsx index 6825a7d..56e196e 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; @@ -43,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; @@ -62,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)}
         
@@ -74,17 +81,17 @@ function RequestItem({ request }: { request: ApiRequest }) { const isPending = request.status === "pending"; return ( -
+
@@ -135,6 +143,11 @@ function RequestItem({ request }: { request: ApiRequest }) { {new Date(request.timestamp).toLocaleTimeString()}
+ {!isPending && ( )} @@ -184,7 +197,7 @@ export function ApiRequestsPanel() { style={{ width: API_PANEL_WIDTH }} > {/* Request list */} - +
{requests.length === 0 ? (

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

)} - +
diff --git a/src/components/CreateOrEditTodoItems.tsx b/src/components/CreateOrEditTodoItems.tsx index d0d278a..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); @@ -41,7 +42,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)); @@ -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 ab9d8e5..fcb9221 100644 --- a/src/components/TodoBoards.tsx +++ b/src/components/TodoBoards.tsx @@ -24,14 +24,16 @@ 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"; 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 { @@ -55,10 +57,7 @@ function DropIndicator() { return
; } -function findPrevItem< - T extends { boardId: string; todoItemId: string }, - U extends T, ->({ +function findPrevItem({ todoItems, target, }: { @@ -68,9 +67,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]; @@ -173,24 +170,19 @@ 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, + showTodoItemsLoading, + boardIndex, +}: { + board: BoardRecord; + todoItems: TodoItemRecord[]; + projectId: string; + showTodoItemsLoading?: boolean; + boardIndex: number; +}) { const { active, over } = useDndContext(); const { scrollRef, canScrollUp, canScrollDown } = useScrollShadow(); @@ -247,56 +239,64 @@ function Board({ board }: { board: BoardRecord }) {

{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 && } +
+ ); + }} +
+
+
- -
+ +
+ )} ); } @@ -329,51 +329,89 @@ export function TodoBoards({ projectId }: { projectId: string }) { [projectId], ); - const { - data: [activeTodoItem], - } = useLiveQuery( + // Fetch all todo items for the project in a single query + const { data: allTodoItems } = useLiveQuery( (q) => q .from({ todoItem: todoItemsCollection }) - .where(({ todoItem }) => eq(todoItem.id, activeId)), - [activeId, projectId], - ); - - 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], { + .where(({ todoItem }) => eq(todoItem.projectId, projectId)) + .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", }), [projectId], ); + // 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(() => { + // 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; + } + + // Items are empty and still loading - show skeleton and subscribe + setShowTodoItemsLoading(true); + + const currentProjectId = projectId; + unsubscribeRef.current = todoItemsCollection.on( + "loadingSubset:change", + (event) => { + if (event.loadingSubsetTransition === "end") { + loadedProjectsRef.current.add(currentProjectId); + setShowTodoItemsLoading(false); + } + }, + ); + + return () => { + if (unsubscribeRef.current) { + unsubscribeRef.current(); + unsubscribeRef.current = undefined; + } + }; + }, [projectId, allTodoItems.length]); + + // 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); }; @@ -407,14 +445,19 @@ 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({ todoItems: orderedTodoItems, target: { boardId: overTodoItem.boardId, - todoItemId: overTodoItem.id as string, + id: overTodoItem.id as string, }, }); @@ -428,7 +471,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; @@ -436,7 +479,7 @@ export function TodoBoards({ projectId }: { projectId: string }) { todoItems: orderedTodoItems, target: { boardId: overTodoItem.boardId, - todoItemId: overTodoItem.id as string, + id: overTodoItem.id as string, }, }); @@ -451,8 +494,6 @@ export function TodoBoards({ projectId }: { projectId: string }) { boardId: newBoardId as string, newPosition, }); - } else { - console.error("overTodoId not found"); } } }; @@ -472,8 +513,17 @@ 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 (
- - - + + +
); 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..eac6f46 100644 --- a/src/integrations/tanstack-query/root-provider.tsx +++ b/src/integrations/tanstack-query/root-provider.tsx @@ -1,9 +1,26 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +// 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() { - const queryClient = 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, + queryClient: clientQueryClient, }; } diff --git a/src/local-api/api.todo-items.ts b/src/local-api/api.todo-items.ts index 3771c3c..e5746d3 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(), @@ -25,14 +26,26 @@ const todoItemUpdateData = z.object({ }); export default { - GET: async () => { - const results = await db.select().from(todoItemsTable); - - return json(results); + GET: async ({ request }) => { + const url = new URL(request.url); + + // Currently we only support filtering by projectId + const projectId = url.searchParams.get("projectId"); + + if (projectId) { + const results = await db + .select() + .from(todoItemsTable) + .where(eq(todoItemsTable.projectId, projectId)); + return json(results); + } else { + const results = await db.select().from(todoItemsTable); + return json(results); + } }, 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; 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",