From 8db20aced2a764ed5daa3eda060ec89c188b818c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 19:23:44 +0000 Subject: [PATCH 1/2] feat: add self-hosted SQL backend as an opt-in third storage mode Adds a small Express + Knex REST API (server/) so local deployments can use their own PostgreSQL or MySQL database instead of Supabase or localStorage. Opt in via VITE_DATA_BACKEND=sql; Supabase and localStorage behavior is unchanged when unset. Includes idempotent schema/seed scripts (pnpm run db:migrate / db:seed), a frontend DataService implementation (sqlApiService.ts), and docs/SQL_BACKEND.md. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01BKo7ta6PPHvHKb9L1Jt6UZ --- .env.example | 25 +- AGENTS.md | 4 +- CHANGELOG.md | 3 + README-EXT.md | 37 +- docs/SQL_BACKEND.md | 92 ++++ package.json | 15 +- pnpm-lock.yaml | 687 ++++++++++++++++++++++++++++ server/app.ts | 165 +++++++ server/db.ts | 31 ++ server/index.ts | 19 + server/migrate.ts | 15 + server/repositories/archivedDays.ts | 128 ++++++ server/repositories/categories.ts | 48 ++ server/repositories/clients.ts | 78 ++++ server/repositories/currentDay.ts | 67 +++ server/repositories/plannedTasks.ts | 81 ++++ server/repositories/projects.ts | 59 +++ server/repositories/taskMapper.ts | 53 +++ server/repositories/todos.ts | 50 ++ server/schema.ts | 118 +++++ server/seed.ts | 82 ++++ server/tsconfig.json | 20 + server/types.ts | 21 + src/services/dataService.ts | 6 + src/services/sqlApiService.ts | 240 ++++++++++ 25 files changed, 2137 insertions(+), 7 deletions(-) create mode 100644 docs/SQL_BACKEND.md create mode 100644 server/app.ts create mode 100644 server/db.ts create mode 100644 server/index.ts create mode 100644 server/migrate.ts create mode 100644 server/repositories/archivedDays.ts create mode 100644 server/repositories/categories.ts create mode 100644 server/repositories/clients.ts create mode 100644 server/repositories/currentDay.ts create mode 100644 server/repositories/plannedTasks.ts create mode 100644 server/repositories/projects.ts create mode 100644 server/repositories/taskMapper.ts create mode 100644 server/repositories/todos.ts create mode 100644 server/schema.ts create mode 100644 server/seed.ts create mode 100644 server/tsconfig.json create mode 100644 server/types.ts create mode 100644 src/services/sqlApiService.ts diff --git a/.env.example b/.env.example index 53fb7a7..f6bc3b1 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,10 @@ # 🔒 SECURITY NOTICE: Never commit .env to version control! # Copy this file to .env and add your actual Supabase credentials # -# The app works in two modes: -# - WITH credentials: Full cloud sync and authentication +# The app works in three modes: +# - WITH Supabase credentials: Full cloud sync and authentication # - WITHOUT credentials: Local storage only (no account needed) +# - WITH VITE_DATA_BACKEND=sql: Self-hosted SQL backend (see below) # Your Supabase Project Settings VITE_SUPABASE_URL=https://your-project-id.supabase.co @@ -31,3 +32,23 @@ VITE_GEMINI_API_KEY=your_gemini_api_key_here # Enable verbose database and auth call logging (development only) # Set to 'true' to see detailed console logs for every database operation # VITE_ENABLE_DB_LOGS=true + +# 🗄️ Self-Hosted SQL Backend (optional, opt-in) +# Lets you run Timetraked against your own Postgres or MySQL database instead +# of Supabase or localStorage. Requires running the backend in server/ +# (`pnpm run db:migrate`, `pnpm run db:seed`, `pnpm run server:start`). +# Leave VITE_DATA_BACKEND unset to keep using Supabase/localStorage as before. + +# Frontend (safe to expose — read by the browser bundle): +# VITE_DATA_BACKEND=sql +# VITE_SQL_API_URL=http://localhost:4001/api + +# Backend only (used by server/, never VITE_-prefixed, never sent to the browser): +# DB_CLIENT=pg # "pg" (PostgreSQL) or "mysql2" (MySQL) +# DB_HOST=localhost +# DB_PORT=5432 # 3306 for MySQL +# DB_USER=timetraked +# DB_PASSWORD=your_db_password_here +# DB_NAME=timetraked +# DB_SSL=false # set to true if your database requires SSL +# SQL_SERVER_PORT=4001 # port the server/ API listens on diff --git a/AGENTS.md b/AGENTS.md index 63f585d..048ae24 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,9 +122,11 @@ export const MyComponent = () => { | File | Purpose | | --------------------------------------------- | ---------------------------------------------------------------------------------- | | `src/contexts/TimeTrackingContext.tsx` | Main application state and logic (1200+ lines) | -| `src/services/dataService.ts` | Data persistence factory — returns `LocalStorageService` or `SupabaseService` | +| `src/services/dataService.ts` | Data persistence factory — returns `LocalStorageService`, `SupabaseService`, or `SqlApiService` | | `src/services/supabaseService.ts` | Supabase data persistence implementation (1100+ lines) | | `src/services/localStorageService/` | localStorage data persistence implementation (split into per-entity modules) | +| `src/services/sqlApiService.ts` | `DataService` implementation that talks to the self-hosted `server/` REST API over `fetch` | +| `server/` | Optional self-hosted SQL backend (Express + Knex, Postgres/MySQL) — see `docs/SQL_BACKEND.md` | | `src/contexts/AuthContext.tsx` | Authentication state management | | `src/lib/supabase.ts` | Supabase client configuration and caching | | `src/config/categories.ts` | Default category definitions | diff --git a/CHANGELOG.md b/CHANGELOG.md index c1f0bac..bcfdd78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Self-hosted SQL backend (opt-in, additive) — lets local deployments use their own PostgreSQL or MySQL database instead of Supabase or `localStorage`. Browsers can't open a raw SQL connection, so this adds a small Express + Knex REST API (`server/`) that the frontend talks to over HTTP when `VITE_DATA_BACKEND=sql` is set. Supabase and `localStorage` modes are unchanged when this var is unset. + — `server/` (new: `db.ts`, `schema.ts`, `app.ts`, `index.ts`, `migrate.ts`, `seed.ts`, `repositories/*.ts`, `types.ts`), `src/services/sqlApiService.ts` (new), `src/services/dataService.ts` (factory branch), `package.json` (`db:migrate`, `db:seed`, `server:dev`, `server:start` scripts; `express`, `knex`, `pg`, `mysql2`, `cors`, `dotenv`, `tsx` deps), `.env.example`, `docs/SQL_BACKEND.md` (new) + - `AppSidebar` — new collapsible sidebar navigation component replacing the top `SiteNavigationMenu`. Uses shadcn/ui `Sidebar` primitives; groups nav items into Planning/Manage/Reports sections; shows live session timer in the footer alongside sync status, auth, and export actions. — `src/components/AppSidebar.tsx` (new), `src/App.tsx` (wrapped in `SidebarProvider`, renders `AppSidebar`) diff --git a/README-EXT.md b/README-EXT.md index df9e7b2..1aae836 100644 --- a/README-EXT.md +++ b/README-EXT.md @@ -185,6 +185,7 @@ Task descriptions support **GitHub Flavored Markdown (GFM)**: | ---------------------------- | ------------------------------------------------------------------------------------------------------------- | | **Guest (default)** | No account required. All data in `localStorage`. Full functionality, single-device only. | | **Authenticated (optional)** | Sign in via Supabase. Data synced to PostgreSQL. Multi-device access with automatic `localStorage` migration. | +| **Self-hosted SQL (optional)** | Opt-in via `VITE_DATA_BACKEND=sql`. Talks to the small REST API in `server/` backed by your own Postgres or MySQL database. See [docs/SQL_BACKEND.md](docs/SQL_BACKEND.md). | ### How Data Storage Works @@ -218,6 +219,34 @@ cp .env.example .env > ⚠️ Never commit your `.env` file to version control. +### Setting Up a Self-Hosted SQL Backend + +For local deployments that don't want Supabase, Timetraked can run against your own PostgreSQL or MySQL database via a small bundled REST API (`server/`). This mode is fully opt-in — leaving `VITE_DATA_BACKEND` unset keeps the existing Supabase/`localStorage` behavior unchanged. + +**1. Configure the backend** in `.env` (see `.env.example` for the full list): `DB_CLIENT` (`pg` or `mysql2`), `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`. + +**2. Apply the schema and seed default data:** + +```bash +pnpm run db:migrate +pnpm run db:seed +``` + +**3. Start the backend API:** + +```bash +pnpm run server:dev +``` + +**4. Point the frontend at it** in `.env`: + +```bash +VITE_DATA_BACKEND=sql +VITE_SQL_API_URL=http://localhost:4001/api +``` + +See [docs/SQL_BACKEND.md](docs/SQL_BACKEND.md) for full setup details. + ### Authentication Flow **Sign In:** Click "Sign In" → enter credentials → data migrates from `localStorage`. @@ -265,7 +294,7 @@ App.tsx #### 2. Service Layer Pattern -Data persistence is abstracted through a factory that returns either `LocalStorageService` or `SupabaseService` depending on auth state: +Data persistence is abstracted through a factory that returns `LocalStorageService`, `SupabaseService`, or `SqlApiService` depending on auth state and the `VITE_DATA_BACKEND` env var: ```typescript interface DataService { @@ -390,8 +419,9 @@ src/ │ └── TaskList.tsx # Active task list and NewTaskForm ├── services/ │ ├── localStorageService/ # localStorage implementation (per-entity modules) -│ ├── dataService.ts # Factory — returns LocalStorage or Supabase impl -│ └── supabaseService.ts # Supabase implementation (1100+ lines) +│ ├── dataService.ts # Factory — returns LocalStorage, Supabase, or SqlApi impl +│ ├── supabaseService.ts # Supabase implementation (1100+ lines) +│ └── sqlApiService.ts # REST client for the self-hosted server/ backend ├── utils/ │ ├── calculationUtils.ts # Revenue and hours calculations │ ├── checklistUtils.ts # GFM checklist extraction @@ -566,6 +596,7 @@ const MyPage = lazy(() => import("./pages/MyPage")); | [docs/AUTH_DATA_PERSISTENCE_FIX.md](docs/AUTH_DATA_PERSISTENCE_FIX.md) | Persistence implementation details | | [docs/SCHEMA_COMPATIBILITY.md](docs/SCHEMA_COMPATIBILITY.md) | Database schema history | | [docs/MIGRATION.md](docs/MIGRATION.md) | Supabase data migration guide | +| [docs/SQL_BACKEND.md](docs/SQL_BACKEND.md) | Self-hosted SQL (Postgres/MySQL) backend setup | | [docs/SECURITY.md](docs/SECURITY.md) | Security configuration and practices | | [docs/CSV_TEMPLATES_README.md](docs/CSV_TEMPLATES_README.md) | CSV import/export format | | [docs/FEATURES.md](docs/FEATURES.md) | Feature requests and improvement notes | diff --git a/docs/SQL_BACKEND.md b/docs/SQL_BACKEND.md new file mode 100644 index 0000000..09accd8 --- /dev/null +++ b/docs/SQL_BACKEND.md @@ -0,0 +1,92 @@ +# Self-Hosted SQL Backend + +Timetraked can run against your own PostgreSQL or MySQL database instead of Supabase or `localStorage`. This is an **opt-in third storage mode** — Supabase and `localStorage` continue to work exactly as before if you don't configure this. + +Browsers cannot open a raw MySQL/Postgres connection directly, so this mode adds a small self-hosted REST API (`server/`) that the frontend talks to over HTTP. The API is a thin wrapper around [Knex](https://knexjs.org), so the same code works against either database engine. + +## When to use this + +- You want to self-host Timetraked with your own database instead of relying on Supabase. +- You're fine running one extra Node process (the `server/` API) alongside the frontend. + +If you just want a quick start with no account, the default `localStorage` mode requires no setup at all. If you want multi-device cloud sync, Supabase is the simplest path. This mode is for self-hosters who specifically want their own SQL database. + +## Architecture + +```text +Browser (SqlApiService) --HTTP--> server/app.ts (Express) --Knex--> Postgres or MySQL +``` + +- `server/db.ts` — Knex connection, configured by `DB_*` env vars. +- `server/schema.ts` — idempotent schema creation (`ensureSchema`), cross-dialect (Postgres + MySQL). +- `server/repositories/*.ts` — one module per entity (current day, archived days, projects, clients, categories, todos, planned tasks), mirroring `SupabaseService`'s persistence semantics. +- `server/app.ts` / `server/index.ts` — Express app and entrypoint, served under `/api/*`. +- `server/migrate.ts` / `server/seed.ts` — standalone scripts for schema setup and default data. +- `src/services/sqlApiService.ts` — frontend `DataService` implementation that calls the API via `fetch`. + +This backend is **single-tenant**: there's no `user_id` or row-level security, since it's meant for one self-hosted deployment with one shared dataset — the same data model as guest/`localStorage` mode, just persisted centrally. + +## Setup + +### 1. Provision a database + +Create an empty PostgreSQL or MySQL database and a user with access to it. + +### 2. Configure environment variables + +Copy `.env.example` to `.env` and fill in the backend-only variables (none of these are `VITE_`-prefixed, so they're never bundled into the browser build): + +```bash +DB_CLIENT=pg # "pg" for PostgreSQL, "mysql2" for MySQL +DB_HOST=localhost +DB_PORT=5432 # 3306 for MySQL +DB_USER=timetraked +DB_PASSWORD=your_db_password_here +DB_NAME=timetraked +DB_SSL=false # set true if your database requires SSL +SQL_SERVER_PORT=4001 # port the server/ API listens on +``` + +### 3. Apply the schema + +```bash +pnpm run db:migrate +``` + +Safe to re-run — it only creates tables that don't already exist. + +### 4. Seed default data (optional) + +```bash +pnpm run db:seed +``` + +Populates the default categories and projects (and their derived clients) the same way a fresh `localStorage` install would. Safe to re-run — it skips seeding if rows already exist. + +### 5. Start the backend API + +```bash +pnpm run server:dev # watch mode, for local development +pnpm run server:start # plain start, for production +``` + +### 6. Point the frontend at it + +Add to `.env`: + +```bash +VITE_DATA_BACKEND=sql +VITE_SQL_API_URL=http://localhost:4001/api +``` + +Restart the Vite dev server (or rebuild) so the new env vars are picked up. + +## Migrating existing data + +`SqlApiService` implements the same `migrateFromLocalStorage()` / `migrateToLocalStorage()` methods as `SupabaseService`: switching `VITE_DATA_BACKEND` to `sql` on a browser that already has guest `localStorage` data will pull that data into the SQL backend (only filling in entities the backend doesn't already have). + +## Troubleshooting + +- **Frontend can't reach the backend**: confirm `VITE_SQL_API_URL` matches where `server/index.ts` is actually listening, and that CORS isn't being blocked (the server enables CORS for all origins by default). +- **`db:migrate` fails to connect**: double-check `DB_HOST`/`DB_PORT`/`DB_USER`/`DB_PASSWORD`/`DB_NAME` and that the database accepts connections from where the script runs. +- **MySQL errors about key length**: this shouldn't happen with the bundled schema (all indexed columns are `VARCHAR`, not `TEXT`), but if you've hand-edited `server/schema.ts`, remember MySQL requires an explicit length on indexed `TEXT` columns. diff --git a/package.json b/package.json index e0c06ff..55a1f71 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,11 @@ "electron:build:main": "vite build --config vite.electron.config.ts", "electron:dev": "pnpm run electron:build:main && concurrently \"pnpm run dev\" \"wait-on http://localhost:8080 && ELECTRON_DEV=true electron dist-electron/main.cjs\"", "electron:preview": "pnpm run build && pnpm run electron:build:main && electron dist-electron/main.cjs", - "electron:build": "pnpm run build && pnpm run electron:build:main && electron-builder" + "electron:build": "pnpm run build && pnpm run electron:build:main && electron-builder", + "db:migrate": "tsx server/migrate.ts", + "db:seed": "tsx server/seed.ts", + "server:dev": "tsx watch server/index.ts", + "server:start": "tsx server/index.ts" }, "dependencies": { "@hookform/resolvers": "^3.10.0", @@ -60,9 +64,15 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "cors": "^2.8.6", + "dotenv": "^17.4.2", "embla-carousel-react": "^8.6.0", + "express": "^5.2.1", + "knex": "^3.2.10", "lucide-react": "^1.17.0", + "mysql2": "^3.22.5", "next-themes": "^0.3.0", + "pg": "^8.21.0", "react": "^18.3.1", "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", @@ -91,6 +101,8 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^14.3.1", "@testing-library/user-event": "^14.6.1", + "@types/cors": "^2.8.19", + "@types/express": "^5.0.6", "@types/node": "^22.19.17", "@types/react": "^18.3.28", "@types/react-dom": "^18.3.7", @@ -108,6 +120,7 @@ "shadcn": "^4.9.0", "tailwindcss": "^4.3.0", "ts-node": "^10.9.2", + "tsx": "^4.22.4", "typescript": "^5.9.3", "typescript-eslint": "^8.38.0", "vite": "^5.4.21", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d08ba4b..bb8b98d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -110,15 +110,33 @@ importers: cmdk: specifier: ^1.1.1 version: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + cors: + specifier: ^2.8.6 + version: 2.8.6 + dotenv: + specifier: ^17.4.2 + version: 17.4.2 embla-carousel-react: specifier: ^8.6.0 version: 8.6.0(react@18.3.1) + express: + specifier: ^5.2.1 + version: 5.2.1 + knex: + specifier: ^3.2.10 + version: 3.2.10(mysql2@3.22.5(@types/node@22.19.20))(pg@8.21.0) lucide-react: specifier: ^1.17.0 version: 1.17.0(react@18.3.1) + mysql2: + specifier: ^3.22.5 + version: 3.22.5(@types/node@22.19.20) next-themes: specifier: ^0.3.0 version: 0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + pg: + specifier: ^8.21.0 + version: 8.21.0 react: specifier: ^18.3.1 version: 18.3.1 @@ -186,6 +204,12 @@ importers: '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@9.3.4) + '@types/cors': + specifier: ^2.8.19 + version: 2.8.19 + '@types/express': + specifier: ^5.0.6 + version: 5.0.6 '@types/node': specifier: ^22.19.17 version: 22.19.20 @@ -237,6 +261,9 @@ importers: ts-node: specifier: ^10.9.2 version: 10.9.2(@swc/core@1.15.40)(@types/node@22.19.20)(typescript@5.9.3) + tsx: + specifier: ^4.22.4 + version: 4.22.4 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -890,138 +917,294 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.21.5': resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} engines: {node: '>=12'} cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.21.5': resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} engines: {node: '>=12'} cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.21.5': resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} engines: {node: '>=12'} cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.21.5': resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} engines: {node: '>=12'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.21.5': resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} engines: {node: '>=12'} cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.21.5': resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} engines: {node: '>=12'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} engines: {node: '>=12'} cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.21.5': resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} engines: {node: '>=12'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.21.5': resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} engines: {node: '>=12'} cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.21.5': resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} engines: {node: '>=12'} cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.21.5': resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} engines: {node: '>=12'} cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.21.5': resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} engines: {node: '>=12'} cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.21.5': resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} engines: {node: '>=12'} cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.21.5': resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} engines: {node: '>=12'} cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.21.5': resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} engines: {node: '>=12'} cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.21.5': resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} engines: {node: '>=12'} cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} engines: {node: '>=12'} cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} engines: {node: '>=12'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.21.5': resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} engines: {node: '>=12'} cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.21.5': resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} engines: {node: '>=12'} cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.21.5': resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} engines: {node: '>=12'} cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.21.5': resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} engines: {node: '>=12'} cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -2404,9 +2587,18 @@ packages: '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/cacheable-request@6.0.3': resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/cors@2.8.19': + resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + '@types/d3-array@3.2.2': resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} @@ -2443,6 +2635,12 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/express-serve-static-core@5.1.1': + resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==} + + '@types/express@5.0.6': + resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + '@types/fs-extra@9.0.13': resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==} @@ -2452,6 +2650,9 @@ packages: '@types/http-cache-semantics@4.2.0': resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -2473,6 +2674,12 @@ packages: '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + '@types/qs@6.15.1': + resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/react-dom@18.3.7': resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} peerDependencies: @@ -2487,6 +2694,12 @@ packages: '@types/responselike@1.0.3': resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@2.2.0': + resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + '@types/set-cookie-parser@2.4.10': resolution: {integrity: sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==} @@ -2727,6 +2940,10 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + aws-ssl-profiles@1.1.2: + resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} + engines: {node: '>= 6.0.0'} + aws4@1.13.2: resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} @@ -2962,6 +3179,9 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + colorette@2.0.19: + resolution: {integrity: sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -2969,6 +3189,10 @@ packages: comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + commander@11.1.0: resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} engines: {node: '>=16'} @@ -3149,6 +3373,15 @@ packages: date-fns@3.6.0: resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==} + debug@4.3.4: + resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -3225,6 +3458,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -3423,6 +3660,11 @@ packages: engines: {node: '>=12'} hasBin: true + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -3475,6 +3717,10 @@ packages: jiti: optional: true + esm@3.2.25: + resolution: {integrity: sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==} + engines: {node: '>=6'} + espree@10.4.0: resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3722,6 +3968,9 @@ packages: fuzzysort@3.1.0: resolution: {integrity: sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==} + generate-function@2.3.1: + resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} + generator-function@2.0.1: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} @@ -3756,6 +4005,10 @@ packages: get-own-enumerable-property-symbols@3.0.2: resolution: {integrity: sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==} + get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} @@ -3780,6 +4033,9 @@ packages: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} + getopts@2.3.0: + resolution: {integrity: sha512-5eDf9fuSXwxBL6q5HX+dhDj+dslFGWzU5thZ9kNKUkcPtaPdatmUFKwHFrLb/uf/WpA4BHET+AX3Scl56cAjpA==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -3976,6 +4232,10 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} + interpret@2.2.0: + resolution: {integrity: sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==} + engines: {node: '>= 0.10'} + ip-address@10.2.0: resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} engines: {node: '>= 12'} @@ -4117,6 +4377,9 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-property@1.0.2: + resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -4304,6 +4567,37 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} + knex@3.2.10: + resolution: {integrity: sha512-oypTHfrc9i72iyxaUQBKHOxhcr0xM65MPf6FpN02nimsftXwzXprIkLjfXdubvhbu4PMWLp023q8o8CYvHSuZw==} + engines: {node: '>=16'} + hasBin: true + peerDependencies: + better-sqlite3: '*' + mysql: '*' + mysql2: '*' + pg: '*' + pg-native: '*' + pg-query-stream: ^4.14.0 + sqlite3: '*' + tedious: '*' + peerDependenciesMeta: + better-sqlite3: + optional: true + mysql: + optional: true + mysql2: + optional: true + pg: + optional: true + pg-native: + optional: true + pg-query-stream: + optional: true + sqlite3: + optional: true + tedious: + optional: true + lazy-val@1.0.5: resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} @@ -4420,6 +4714,9 @@ packages: resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} engines: {node: '>=18'} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -4448,6 +4745,10 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} + lru.min@1.1.4: + resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} + engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + lucide-react@1.17.0: resolution: {integrity: sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==} peerDependencies: @@ -4731,6 +5032,9 @@ packages: mlly@1.8.2: resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + ms@2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -4748,6 +5052,16 @@ packages: resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} engines: {node: ^20.17.0 || >=22.9.0} + mysql2@3.22.5: + resolution: {integrity: sha512-95uZ2TrPWAZdwpB3vvvDbmEMcNG8yIeNCyu6GUcr/QnWEE/wXm7+mhOCsdQfWQDTV7qYT/PDUZ4U4UPP4AsXqQ==} + engines: {node: '>= 8.0'} + peerDependencies: + '@types/node': '>= 8' + + named-placeholders@1.1.6: + resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} + engines: {node: '>=8.0.0'} + nanoid@3.3.12: resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -4981,6 +5295,43 @@ packages: pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + pg-cloudflare@1.4.0: + resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} + + pg-connection-string@2.13.0: + resolution: {integrity: sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==} + + pg-connection-string@2.6.2: + resolution: {integrity: sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.14.0: + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.14.0: + resolution: {integrity: sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.21.0: + resolution: {integrity: sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -5033,6 +5384,22 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + postject@1.0.0-alpha.6: resolution: {integrity: sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==} engines: {node: '>=14.0.0'} @@ -5258,6 +5625,10 @@ packages: react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + rechoir@0.8.0: + resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==} + engines: {node: '>= 10.13.0'} + redent@3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} @@ -5322,6 +5693,10 @@ packages: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + resolve@1.22.12: resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} engines: {node: '>= 0.4'} @@ -5560,9 +5935,17 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + sprintf-js@1.1.3: resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + sql-escaper@1.3.3: + resolution: {integrity: sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==} + engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'} + ssri@12.0.0: resolution: {integrity: sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==} engines: {node: ^18.17.0 || >=20.5.0} @@ -5727,6 +6110,10 @@ packages: resolution: {integrity: sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==} engines: {node: '>=18'} + tarn@3.0.2: + resolution: {integrity: sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==} + engines: {node: '>=8.0.0'} + temp-dir@2.0.0: resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==} engines: {node: '>=8'} @@ -5747,6 +6134,10 @@ packages: engines: {node: '>=10'} hasBin: true + tildify@2.0.0: + resolution: {integrity: sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw==} + engines: {node: '>=8'} + tiny-async-pool@1.3.0: resolution: {integrity: sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==} @@ -5855,6 +6246,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.22.4: + resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==} + engines: {node: '>=18.0.0'} + hasBin: true + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -6303,6 +6699,10 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -7226,72 +7626,150 @@ snapshots: '@esbuild/aix-ppc64@0.21.5': optional: true + '@esbuild/aix-ppc64@0.28.1': + optional: true + '@esbuild/android-arm64@0.21.5': optional: true + '@esbuild/android-arm64@0.28.1': + optional: true + '@esbuild/android-arm@0.21.5': optional: true + '@esbuild/android-arm@0.28.1': + optional: true + '@esbuild/android-x64@0.21.5': optional: true + '@esbuild/android-x64@0.28.1': + optional: true + '@esbuild/darwin-arm64@0.21.5': optional: true + '@esbuild/darwin-arm64@0.28.1': + optional: true + '@esbuild/darwin-x64@0.21.5': optional: true + '@esbuild/darwin-x64@0.28.1': + optional: true + '@esbuild/freebsd-arm64@0.21.5': optional: true + '@esbuild/freebsd-arm64@0.28.1': + optional: true + '@esbuild/freebsd-x64@0.21.5': optional: true + '@esbuild/freebsd-x64@0.28.1': + optional: true + '@esbuild/linux-arm64@0.21.5': optional: true + '@esbuild/linux-arm64@0.28.1': + optional: true + '@esbuild/linux-arm@0.21.5': optional: true + '@esbuild/linux-arm@0.28.1': + optional: true + '@esbuild/linux-ia32@0.21.5': optional: true + '@esbuild/linux-ia32@0.28.1': + optional: true + '@esbuild/linux-loong64@0.21.5': optional: true + '@esbuild/linux-loong64@0.28.1': + optional: true + '@esbuild/linux-mips64el@0.21.5': optional: true + '@esbuild/linux-mips64el@0.28.1': + optional: true + '@esbuild/linux-ppc64@0.21.5': optional: true + '@esbuild/linux-ppc64@0.28.1': + optional: true + '@esbuild/linux-riscv64@0.21.5': optional: true + '@esbuild/linux-riscv64@0.28.1': + optional: true + '@esbuild/linux-s390x@0.21.5': optional: true + '@esbuild/linux-s390x@0.28.1': + optional: true + '@esbuild/linux-x64@0.21.5': optional: true + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + '@esbuild/netbsd-x64@0.21.5': optional: true + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + '@esbuild/openbsd-x64@0.21.5': optional: true + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + '@esbuild/sunos-x64@0.21.5': optional: true + '@esbuild/sunos-x64@0.28.1': + optional: true + '@esbuild/win32-arm64@0.21.5': optional: true + '@esbuild/win32-arm64@0.28.1': + optional: true + '@esbuild/win32-ia32@0.21.5': optional: true + '@esbuild/win32-ia32@0.28.1': + optional: true + '@esbuild/win32-x64@0.21.5': optional: true + '@esbuild/win32-x64@0.28.1': + optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.7.0))': dependencies: eslint: 9.39.4(jiti@2.7.0) @@ -8628,6 +9106,11 @@ snapshots: '@types/aria-query@5.0.4': {} + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 22.19.20 + '@types/cacheable-request@6.0.3': dependencies: '@types/http-cache-semantics': 4.2.0 @@ -8635,6 +9118,14 @@ snapshots: '@types/node': 22.19.20 '@types/responselike': 1.0.3 + '@types/connect@3.4.38': + dependencies: + '@types/node': 22.19.20 + + '@types/cors@2.8.19': + dependencies: + '@types/node': 22.19.20 + '@types/d3-array@3.2.2': {} '@types/d3-color@3.1.3': {} @@ -8669,6 +9160,19 @@ snapshots: '@types/estree@1.0.9': {} + '@types/express-serve-static-core@5.1.1': + dependencies: + '@types/node': 22.19.20 + '@types/qs': 6.15.1 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@5.0.6': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 5.1.1 + '@types/serve-static': 2.2.0 + '@types/fs-extra@9.0.13': dependencies: '@types/node': 22.19.20 @@ -8679,6 +9183,8 @@ snapshots: '@types/http-cache-semantics@4.2.0': {} + '@types/http-errors@2.0.5': {} + '@types/json-schema@7.0.15': {} '@types/keyv@3.1.4': @@ -8701,6 +9207,10 @@ snapshots: '@types/prop-types@15.7.15': {} + '@types/qs@6.15.1': {} + + '@types/range-parser@1.2.7': {} + '@types/react-dom@18.3.7(@types/react@18.3.31)': dependencies: '@types/react': 18.3.31 @@ -8716,6 +9226,15 @@ snapshots: dependencies: '@types/node': 22.19.20 + '@types/send@1.2.1': + dependencies: + '@types/node': 22.19.20 + + '@types/serve-static@2.2.0': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 22.19.20 + '@types/set-cookie-parser@2.4.10': dependencies: '@types/node': 22.19.20 @@ -9027,6 +9546,8 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + aws-ssl-profiles@1.1.2: {} + aws4@1.13.2: {} axios@1.17.0: @@ -9311,12 +9832,16 @@ snapshots: color-name@1.1.4: {} + colorette@2.0.19: {} + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 comma-separated-tokens@2.0.3: {} + commander@10.0.1: {} + commander@11.1.0: {} commander@14.0.3: {} @@ -9468,6 +9993,10 @@ snapshots: date-fns@3.6.0: {} + debug@4.3.4: + dependencies: + ms: 2.1.2 + debug@4.4.3: dependencies: ms: 2.1.3 @@ -9544,6 +10073,8 @@ snapshots: delayed-stream@1.0.0: {} + denque@2.1.0: {} + depd@2.0.0: {} dequal@2.0.3: {} @@ -9853,6 +10384,35 @@ snapshots: '@esbuild/win32-ia32': 0.21.5 '@esbuild/win32-x64': 0.21.5 + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + escalade@3.2.0: {} escape-html@1.0.3: {} @@ -9921,6 +10481,8 @@ snapshots: transitivePeerDependencies: - supports-color + esm@3.2.25: {} + espree@10.4.0: dependencies: acorn: 8.16.0 @@ -10221,6 +10783,10 @@ snapshots: fuzzysort@3.1.0: {} + generate-function@2.3.1: + dependencies: + is-property: 1.0.2 + generator-function@2.0.1: {} gensync@1.0.0-beta.2: {} @@ -10250,6 +10816,8 @@ snapshots: get-own-enumerable-property-symbols@3.0.2: {} + get-package-type@0.1.0: {} + get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 @@ -10274,6 +10842,8 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 + getopts@2.3.0: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -10497,6 +11067,8 @@ snapshots: internmap@2.0.3: {} + interpret@2.2.0: {} + ip-address@10.2.0: {} ipaddr.js@1.9.1: {} @@ -10616,6 +11188,8 @@ snapshots: is-promise@4.0.0: {} + is-property@1.0.2: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -10791,6 +11365,28 @@ snapshots: kleur@4.1.5: {} + knex@3.2.10(mysql2@3.22.5(@types/node@22.19.20))(pg@8.21.0): + dependencies: + colorette: 2.0.19 + commander: 10.0.1 + debug: 4.3.4 + escalade: 3.2.0 + esm: 3.2.25 + get-package-type: 0.1.0 + getopts: 2.3.0 + interpret: 2.2.0 + lodash: 4.18.1 + pg-connection-string: 2.6.2 + rechoir: 0.8.0 + resolve-from: 5.0.0 + tarn: 3.0.2 + tildify: 2.0.0 + optionalDependencies: + mysql2: 3.22.5(@types/node@22.19.20) + pg: 8.21.0 + transitivePeerDependencies: + - supports-color + lazy-val@1.0.5: {} leven@3.1.0: {} @@ -10878,6 +11474,8 @@ snapshots: chalk: 5.6.2 is-unicode-supported: 1.3.0 + long@5.3.2: {} + longest-streak@3.1.0: {} loose-envify@1.4.0: @@ -10902,6 +11500,8 @@ snapshots: dependencies: yallist: 4.0.0 + lru.min@1.1.4: {} + lucide-react@1.17.0(react@18.3.1): dependencies: react: 18.3.1 @@ -11390,6 +11990,8 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.4 + ms@2.1.2: {} + ms@2.1.3: {} msw@2.14.6(@types/node@22.19.20)(typescript@5.9.3): @@ -11419,6 +12021,22 @@ snapshots: mute-stream@3.0.0: {} + mysql2@3.22.5(@types/node@22.19.20): + dependencies: + '@types/node': 22.19.20 + aws-ssl-profiles: 1.1.2 + denque: 2.1.0 + generate-function: 2.3.1 + iconv-lite: 0.7.2 + long: 5.3.2 + lru.min: 1.1.4 + named-placeholders: 1.1.6 + sql-escaper: 1.3.3 + + named-placeholders@1.1.6: + dependencies: + lru.min: 1.1.4 + nanoid@3.3.12: {} natural-compare@1.4.0: {} @@ -11661,6 +12279,43 @@ snapshots: pend@1.2.0: {} + pg-cloudflare@1.4.0: + optional: true + + pg-connection-string@2.13.0: {} + + pg-connection-string@2.6.2: {} + + pg-int8@1.0.1: {} + + pg-pool@3.14.0(pg@8.21.0): + dependencies: + pg: 8.21.0 + + pg-protocol@1.14.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.21.0: + dependencies: + pg-connection-string: 2.13.0 + pg-pool: 3.14.0(pg@8.21.0) + pg-protocol: 1.14.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -11716,6 +12371,16 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + postject@1.0.0-alpha.6: dependencies: commander: 9.5.0 @@ -11963,6 +12628,10 @@ snapshots: tiny-invariant: 1.3.3 victory-vendor: 36.9.2 + rechoir@0.8.0: + dependencies: + resolve: 1.22.12 + redent@3.0.0: dependencies: indent-string: 4.0.0 @@ -12061,6 +12730,8 @@ snapshots: resolve-from@4.0.0: {} + resolve-from@5.0.0: {} + resolve@1.22.12: dependencies: es-errors: 1.3.0 @@ -12391,9 +13062,13 @@ snapshots: space-separated-tokens@2.0.2: {} + split2@4.2.0: {} + sprintf-js@1.1.3: optional: true + sql-escaper@1.3.3: {} + ssri@12.0.0: dependencies: minipass: 7.1.3 @@ -12572,6 +13247,8 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 + tarn@3.0.2: {} + temp-dir@2.0.0: {} temp-file@3.4.0: @@ -12598,6 +13275,8 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 + tildify@2.0.0: {} + tiny-async-pool@1.3.0: dependencies: semver: 5.7.2 @@ -12702,6 +13381,12 @@ snapshots: tslib@2.8.1: {} + tsx@4.22.4: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -13263,6 +13948,8 @@ snapshots: xmlchars@2.2.0: {} + xtend@4.0.2: {} + y18n@5.0.8: {} yallist@3.1.1: {} diff --git a/server/app.ts b/server/app.ts new file mode 100644 index 0000000..292660d --- /dev/null +++ b/server/app.ts @@ -0,0 +1,165 @@ +import cors from "cors"; +import express, { type NextFunction, type Request, type Response } from "express"; +import * as archivedDays from "./repositories/archivedDays"; +import * as categories from "./repositories/categories"; +import * as clients from "./repositories/clients"; +import * as currentDay from "./repositories/currentDay"; +import * as plannedTasks from "./repositories/plannedTasks"; +import * as projects from "./repositories/projects"; +import * as todos from "./repositories/todos"; + +type Handler = (req: Request, res: Response) => Promise; + +// Wraps async route handlers so rejected promises reach Express's error handler +// instead of crashing the process. +const wrap = (handler: Handler) => (req: Request, res: Response, next: NextFunction) => { + handler(req, res).catch(next); +}; + +export function createApp() { + const app = express(); + app.use(cors()); + app.use(express.json({ limit: "5mb" })); + + app.get("/api/health", (_req, res) => res.json({ ok: true })); + + app.get( + "/api/current-day", + wrap(async (_req, res) => { + res.json(await currentDay.getCurrentDay()); + }) + ); + app.put( + "/api/current-day", + wrap(async (req, res) => { + await currentDay.saveCurrentDay(req.body); + res.status(204).end(); + }) + ); + + app.get( + "/api/archived-days", + wrap(async (_req, res) => { + res.json(await archivedDays.getArchivedDays()); + }) + ); + app.put( + "/api/archived-days", + wrap(async (req, res) => { + await archivedDays.saveArchivedDays(req.body); + res.status(204).end(); + }) + ); + app.patch( + "/api/archived-days/:id", + wrap(async (req, res) => { + await archivedDays.updateArchivedDay(String(req.params.id), req.body); + res.status(204).end(); + }) + ); + app.delete( + "/api/archived-days/:id", + wrap(async (req, res) => { + await archivedDays.deleteArchivedDay(String(req.params.id)); + res.status(204).end(); + }) + ); + + app.get( + "/api/projects", + wrap(async (_req, res) => { + res.json(await projects.getProjects()); + }) + ); + app.put( + "/api/projects", + wrap(async (req, res) => { + await projects.saveProjects(req.body); + res.status(204).end(); + }) + ); + + app.get( + "/api/clients", + wrap(async (_req, res) => { + res.json(await clients.getClients()); + }) + ); + app.put( + "/api/clients", + wrap(async (req, res) => { + await clients.saveClients(req.body); + res.status(204).end(); + }) + ); + app.put( + "/api/clients/:id", + wrap(async (req, res) => { + await clients.upsertClient(req.body); + res.status(204).end(); + }) + ); + + app.get( + "/api/categories", + wrap(async (_req, res) => { + res.json(await categories.getCategories()); + }) + ); + app.put( + "/api/categories", + wrap(async (req, res) => { + await categories.saveCategories(req.body); + res.status(204).end(); + }) + ); + + app.get( + "/api/todos", + wrap(async (_req, res) => { + res.json(await todos.getTodos()); + }) + ); + app.put( + "/api/todos", + wrap(async (req, res) => { + await todos.saveTodos(req.body); + res.status(204).end(); + }) + ); + + app.get( + "/api/planned-tasks", + wrap(async (_req, res) => { + res.json(await plannedTasks.getPlannedTasks()); + }) + ); + app.put( + "/api/planned-tasks", + wrap(async (req, res) => { + await plannedTasks.savePlannedTasks(req.body); + res.status(204).end(); + }) + ); + app.put( + "/api/planned-tasks/:id", + wrap(async (req, res) => { + await plannedTasks.upsertPlannedTask(req.body); + res.status(204).end(); + }) + ); + app.delete( + "/api/planned-tasks/:id", + wrap(async (req, res) => { + await plannedTasks.deletePlannedTask(String(req.params.id)); + res.status(204).end(); + }) + ); + + app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => { + console.error("❌ Request failed:", err); + res.status(500).json({ error: err instanceof Error ? err.message : "Internal error" }); + }); + + return app; +} diff --git a/server/db.ts b/server/db.ts new file mode 100644 index 0000000..ab4ff51 --- /dev/null +++ b/server/db.ts @@ -0,0 +1,31 @@ +import "dotenv/config"; +import knex, { Knex } from "knex"; + +// DB_CLIENT selects the self-hosted SQL engine. Accepts friendly aliases +// ("postgres"/"postgresql"/"mysql") as well as the underlying knex client names. +const rawClient = (process.env.DB_CLIENT ?? "postgres").toLowerCase(); +const isMysql = rawClient === "mysql" || rawClient === "mysql2"; +export const dbClient: "pg" | "mysql2" = isMysql ? "mysql2" : "pg"; + +const connection: Knex.Config["connection"] = isMysql + ? { + host: process.env.DB_HOST ?? "127.0.0.1", + port: Number(process.env.DB_PORT ?? 3306), + user: process.env.DB_USER ?? "root", + password: process.env.DB_PASSWORD ?? "", + database: process.env.DB_NAME ?? "timetraked" + } + : { + host: process.env.DB_HOST ?? "127.0.0.1", + port: Number(process.env.DB_PORT ?? 5432), + user: process.env.DB_USER ?? "postgres", + password: process.env.DB_PASSWORD ?? "", + database: process.env.DB_NAME ?? "timetraked", + ssl: process.env.DB_SSL === "true" ? { rejectUnauthorized: false } : undefined + }; + +export const db: Knex = knex({ + client: dbClient, + connection, + pool: { min: 0, max: 10 } +}); diff --git a/server/index.ts b/server/index.ts new file mode 100644 index 0000000..a0a32ac --- /dev/null +++ b/server/index.ts @@ -0,0 +1,19 @@ +import { createApp } from "./app"; +import { db, dbClient } from "./db"; +import { ensureSchema } from "./schema"; + +const PORT = Number(process.env.SQL_SERVER_PORT ?? 4001); + +async function main() { + await ensureSchema(db); + + const app = createApp(); + app.listen(PORT, () => { + console.log(`Timetraked SQL backend (${dbClient}) listening on http://localhost:${PORT}`); + }); +} + +main().catch((error) => { + console.error("❌ Failed to start SQL backend:", error); + process.exit(1); +}); diff --git a/server/migrate.ts b/server/migrate.ts new file mode 100644 index 0000000..6cca3f0 --- /dev/null +++ b/server/migrate.ts @@ -0,0 +1,15 @@ +import { db, dbClient } from "./db"; +import { ensureSchema } from "./schema"; + +async function main() { + console.log(`Applying schema to ${dbClient} database...`); + await ensureSchema(db); + console.log("✅ Schema is up to date."); +} + +main() + .catch((error) => { + console.error("❌ Migration failed:", error); + process.exitCode = 1; + }) + .finally(() => db.destroy()); diff --git a/server/repositories/archivedDays.ts b/server/repositories/archivedDays.ts new file mode 100644 index 0000000..6504220 --- /dev/null +++ b/server/repositories/archivedDays.ts @@ -0,0 +1,128 @@ +import { db } from "../db"; +import type { DayRecord, Task } from "../types"; +import { mapTaskRow, taskToRow, type TaskRow } from "./taskMapper"; + +interface ArchivedDayRow { + id: string; + date: string; + total_duration: number | string | null; + start_time: Date | string; + end_time: Date | string; + notes: string | null; + inserted_at: Date | string | null; + updated_at: Date | string | null; +} + +export async function getArchivedDays(): Promise { + const dayRows: ArchivedDayRow[] = await db("archived_days").orderBy("start_time", "desc"); + if (dayRows.length === 0) { + return []; + } + + const taskRows: TaskRow[] = await db("tasks") + .where({ is_current: false }) + .orderBy("start_time", "asc"); + + const tasksByDay = new Map(); + for (const row of taskRows) { + if (!row.day_record_id) continue; + const list = tasksByDay.get(row.day_record_id) ?? []; + list.push(mapTaskRow(row)); + tasksByDay.set(row.day_record_id, list); + } + + return dayRows.map((day) => ({ + id: day.id, + date: day.date, + tasks: tasksByDay.get(day.id) ?? [], + totalDuration: day.total_duration != null ? Number(day.total_duration) : 0, + startTime: new Date(day.start_time), + endTime: new Date(day.end_time), + notes: day.notes ?? undefined, + insertedAt: day.inserted_at ? new Date(day.inserted_at) : undefined, + updatedAt: day.updated_at ? new Date(day.updated_at) : undefined + })); +} + +export async function saveArchivedDays(days: DayRecord[]): Promise { + if (days.length === 0) { + await db("tasks").where({ is_current: false }).delete(); + await db("archived_days").delete(); + return; + } + + const existingDays = await db("archived_days").select("id"); + const existingTasks = await db("tasks").where({ is_current: false }).select("id"); + + const newDayIds = new Set(days.map((d) => d.id)); + const daysToDelete = existingDays.map((d) => d.id).filter((id) => !newDayIds.has(id)); + if (daysToDelete.length > 0) { + await db("archived_days").whereIn("id", daysToDelete).delete(); + } + + const allTasks = days.flatMap((day) => day.tasks.map((task) => ({ task, dayId: day.id }))); + const newTaskIds = new Set(allTasks.map((t) => t.task.id)); + const tasksToDelete = existingTasks.map((t) => t.id).filter((id) => !newTaskIds.has(id)); + if (tasksToDelete.length > 0) { + await db("tasks").where({ is_current: false }).whereIn("id", tasksToDelete).delete(); + } + + const dayRows = days.map((day) => ({ + id: day.id, + date: day.date, + total_duration: day.totalDuration, + start_time: day.startTime, + end_time: day.endTime, + notes: day.notes ?? null, + updated_at: new Date() + })); + await db("archived_days").insert(dayRows).onConflict("id").merge(); + + if (allTasks.length > 0) { + const taskRows = allTasks.map(({ task, dayId }) => + taskToRow(task, { isCurrent: false, dayRecordId: dayId }) + ); + await db("tasks").insert(taskRows).onConflict("id").merge(); + } +} + +export async function updateArchivedDay( + dayId: string, + updates: Partial +): Promise { + const updateData: Record = { updated_at: new Date() }; + if (updates.date) updateData.date = updates.date; + if (updates.totalDuration !== undefined) updateData.total_duration = updates.totalDuration; + if (updates.startTime) updateData.start_time = updates.startTime; + if (updates.endTime) updateData.end_time = updates.endTime; + if (updates.notes !== undefined) updateData.notes = updates.notes; + + await db("archived_days").where({ id: dayId }).update(updateData); + + if (updates.tasks) { + const existing = await db("tasks") + .where({ day_record_id: dayId, is_current: false }) + .select("id"); + const existingIds = new Set(existing.map((r) => r.id)); + const newIds = new Set(updates.tasks.map((t) => t.id)); + const toDelete = [...existingIds].filter((id) => !newIds.has(id)); + if (toDelete.length > 0) { + await db("tasks") + .where({ day_record_id: dayId, is_current: false }) + .whereIn("id", toDelete) + .delete(); + } + + if (updates.tasks.length > 0) { + const rows = updates.tasks.map((task) => + taskToRow(task, { isCurrent: false, dayRecordId: dayId }) + ); + await db("tasks").insert(rows).onConflict("id").merge(); + } + } +} + +export async function deleteArchivedDay(dayId: string): Promise { + await db("tasks").where({ day_record_id: dayId }).delete(); + await db("archived_days").where({ id: dayId }).delete(); +} diff --git a/server/repositories/categories.ts b/server/repositories/categories.ts new file mode 100644 index 0000000..d45fb9c --- /dev/null +++ b/server/repositories/categories.ts @@ -0,0 +1,48 @@ +import { db } from "../db"; +import type { TaskCategory } from "../types"; + +interface CategoryRow { + id: string; + name: string; + color: string | null; + is_billable: boolean | number; +} + +function mapRow(row: CategoryRow): TaskCategory { + return { + id: row.id, + name: row.name, + color: row.color || "#8B5CF6", + isBillable: !!row.is_billable + }; +} + +export async function getCategories(): Promise { + const rows: CategoryRow[] = await db("categories").orderBy("name", "asc"); + return rows.map(mapRow); +} + +export async function saveCategories(categories: TaskCategory[]): Promise { + if (categories.length === 0) { + await db("categories").delete(); + return; + } + + const existing = await db("categories").select("id"); + const existingIds = new Set(existing.map((r) => r.id)); + const newIds = new Set(categories.map((c) => c.id)); + const toDelete = [...existingIds].filter((id) => !newIds.has(id)); + if (toDelete.length > 0) { + await db("categories").whereIn("id", toDelete).delete(); + } + + const rows = categories.map((category) => ({ + id: category.id, + name: category.name, + color: category.color || null, + is_billable: category.isBillable !== false, + updated_at: new Date() + })); + + await db("categories").insert(rows).onConflict("id").merge(); +} diff --git a/server/repositories/clients.ts b/server/repositories/clients.ts new file mode 100644 index 0000000..4e18cb6 --- /dev/null +++ b/server/repositories/clients.ts @@ -0,0 +1,78 @@ +import { db } from "../db"; +import type { Client } from "../types"; + +interface ClientRow { + id: string; + name: string; + archived: boolean | number; + created_at: Date | string; + address_street: string | null; + address_city: string | null; + address_state: string | null; + address_zip: string | null; + address_country: string | null; + contact_name: string | null; + contact_email: string | null; + contact_website: string | null; +} + +function mapRow(row: ClientRow): Client { + return { + id: row.id, + name: row.name, + archived: !!row.archived, + createdAt: new Date(row.created_at).toISOString(), + addressStreet: row.address_street ?? undefined, + addressCity: row.address_city ?? undefined, + addressState: row.address_state ?? undefined, + addressZip: row.address_zip ?? undefined, + addressCountry: row.address_country ?? undefined, + contactName: row.contact_name ?? undefined, + contactEmail: row.contact_email ?? undefined, + contactWebsite: row.contact_website ?? undefined + }; +} + +function clientToRow(client: Client) { + return { + id: client.id, + name: client.name, + archived: client.archived === true, + created_at: client.createdAt, + address_street: client.addressStreet ?? null, + address_city: client.addressCity ?? null, + address_state: client.addressState ?? null, + address_zip: client.addressZip ?? null, + address_country: client.addressCountry ?? null, + contact_name: client.contactName ?? null, + contact_email: client.contactEmail ?? null, + contact_website: client.contactWebsite ?? null + }; +} + +export async function getClients(): Promise { + const rows: ClientRow[] = await db("clients").orderBy("name", "asc"); + return rows.map(mapRow); +} + +export async function saveClients(clients: Client[]): Promise { + if (clients.length === 0) { + await db("clients").delete(); + return; + } + + const existing = await db("clients").select("id"); + const existingIds = new Set(existing.map((r) => r.id)); + const newIds = new Set(clients.map((c) => c.id)); + const toDelete = [...existingIds].filter((id) => !newIds.has(id)); + if (toDelete.length > 0) { + await db("clients").whereIn("id", toDelete).delete(); + } + + const rows = clients.map(clientToRow); + await db("clients").insert(rows).onConflict("id").merge(); +} + +export async function upsertClient(client: Client): Promise { + await db("clients").insert(clientToRow(client)).onConflict("id").merge(); +} diff --git a/server/repositories/currentDay.ts b/server/repositories/currentDay.ts new file mode 100644 index 0000000..6040142 --- /dev/null +++ b/server/repositories/currentDay.ts @@ -0,0 +1,67 @@ +import { db } from "../db"; +import type { CurrentDayData } from "../types"; +import { mapTaskRow, taskToRow, type TaskRow } from "./taskMapper"; + +const SINGLETON_ID = "singleton"; + +interface CurrentDayRow { + id: string; + is_day_started: boolean | number; + day_start_time: Date | string | null; + current_task_id: string | null; +} + +export async function getCurrentDay(): Promise { + const row: CurrentDayRow | undefined = await db("current_day") + .where({ id: SINGLETON_ID }) + .first(); + const taskRows: TaskRow[] = await db("tasks") + .where({ is_current: true }) + .orderBy("start_time", "asc"); + + if (!row && taskRows.length === 0) { + return null; + } + + const tasks = taskRows.map(mapTaskRow); + const currentTask = row?.current_task_id + ? tasks.find((task) => task.id === row.current_task_id) ?? null + : null; + + return { + isDayStarted: !!row?.is_day_started, + dayStartTime: row?.day_start_time ? new Date(row.day_start_time) : null, + tasks, + currentTask + }; +} + +export async function saveCurrentDay(data: CurrentDayData): Promise { + await db("current_day") + .insert({ + id: SINGLETON_ID, + is_day_started: data.isDayStarted, + day_start_time: data.dayStartTime, + current_task_id: data.currentTask?.id ?? null, + updated_at: new Date() + }) + .onConflict("id") + .merge(); + + if (data.tasks.length === 0) { + await db("tasks").where({ is_current: true }).delete(); + return; + } + + const existing = await db("tasks").where({ is_current: true }).select("id"); + const existingIds = new Set(existing.map((r) => r.id)); + const newIds = new Set(data.tasks.map((t) => t.id)); + + const toDelete = [...existingIds].filter((id) => !newIds.has(id)); + if (toDelete.length > 0) { + await db("tasks").where({ is_current: true }).whereIn("id", toDelete).delete(); + } + + const rows = data.tasks.map((task) => taskToRow(task, { isCurrent: true })); + await db("tasks").insert(rows).onConflict("id").merge(); +} diff --git a/server/repositories/plannedTasks.ts b/server/repositories/plannedTasks.ts new file mode 100644 index 0000000..ccd67c9 --- /dev/null +++ b/server/repositories/plannedTasks.ts @@ -0,0 +1,81 @@ +import { db } from "../db"; +import type { PlannedTask, PlannedTaskStatus } from "../types"; + +interface PlannedTaskRow { + id: string; + title: string; + description: string | null; + status: string; + project_name: string | null; + client: string | null; + category_id: string | null; + priority: number; + linked_task_id: string | null; + created_at: Date | string; + updated_at: Date | string; +} + +function mapRow(row: PlannedTaskRow): PlannedTask { + return { + id: row.id, + title: row.title, + description: row.description ?? undefined, + status: row.status as PlannedTaskStatus, + project: row.project_name ?? undefined, + client: row.client ?? undefined, + category: row.category_id ?? undefined, + priority: row.priority, + linkedTaskId: row.linked_task_id ?? undefined, + createdAt: new Date(row.created_at).toISOString(), + updatedAt: new Date(row.updated_at).toISOString() + }; +} + +function taskToRow(task: PlannedTask) { + return { + id: task.id, + title: task.title, + description: task.description ?? null, + status: task.status, + project_name: task.project ?? null, + client: task.client ?? null, + category_id: task.category ?? null, + priority: task.priority, + linked_task_id: task.linkedTaskId ?? null, + created_at: task.createdAt, + updated_at: new Date() + }; +} + +export async function getPlannedTasks(): Promise { + const rows: PlannedTaskRow[] = await db("planned_tasks") + .orderBy("priority", "asc") + .orderBy("created_at", "asc"); + return rows.map(mapRow); +} + +export async function savePlannedTasks(tasks: PlannedTask[]): Promise { + if (tasks.length === 0) { + await db("planned_tasks").delete(); + return; + } + + const existing = await db("planned_tasks").select("id"); + const existingIds = new Set(existing.map((r) => r.id)); + const newIds = new Set(tasks.map((t) => t.id)); + const toDelete = [...existingIds].filter((id) => !newIds.has(id)); + if (toDelete.length > 0) { + await db("planned_tasks").whereIn("id", toDelete).delete(); + } + + const rows = tasks.map(taskToRow); + await db("planned_tasks").insert(rows).onConflict("id").merge(); +} + +export async function upsertPlannedTask(task: PlannedTask): Promise { + await db("planned_tasks").insert(taskToRow(task)).onConflict("id").merge(); +} + +export async function deletePlannedTask(id: string): Promise { + await db("planned_tasks").where({ id }).delete(); +} diff --git a/server/repositories/projects.ts b/server/repositories/projects.ts new file mode 100644 index 0000000..665a84c --- /dev/null +++ b/server/repositories/projects.ts @@ -0,0 +1,59 @@ +import { db } from "../db"; +import type { Project } from "../types"; + +interface ProjectRow { + id: string; + name: string; + client: string; + hourly_rate: string | number | null; + color: string | null; + is_billable: boolean | number; + archived: boolean | number; +} + +function mapRow(row: ProjectRow): Project { + return { + id: row.id, + name: row.name, + client: row.client, + hourlyRate: row.hourly_rate != null ? Number(row.hourly_rate) : undefined, + color: row.color ?? undefined, + isBillable: !!row.is_billable, + archived: !!row.archived + }; +} + +export async function getProjects(): Promise { + const rows: ProjectRow[] = await db("projects").orderBy("name", "asc"); + return rows.map(mapRow); +} + +export async function saveProjects(projects: Project[]): Promise { + if (projects.length === 0) { + await db("projects").delete(); + return; + } + + const existing = await db("projects").select("id"); + const existingIds = new Set(existing.map((r) => r.id)); + const newIds = new Set(projects.map((p) => p.id)); + const toDelete = [...existingIds].filter((id) => !newIds.has(id)); + if (toDelete.length > 0) { + await db("projects").whereIn("id", toDelete).delete(); + } + + // Dedup by id: a single insert batch cannot upsert the same conflict key twice. + const dedupedById = Array.from(new Map(projects.map((p) => [p.id, p])).values()); + const rows = dedupedById.map((project) => ({ + id: project.id, + name: project.name, + client: project.client, + hourly_rate: project.hourlyRate ?? null, + color: project.color ?? null, + is_billable: project.isBillable !== false, + archived: project.archived === true, + updated_at: new Date() + })); + + await db("projects").insert(rows).onConflict("id").merge(); +} diff --git a/server/repositories/taskMapper.ts b/server/repositories/taskMapper.ts new file mode 100644 index 0000000..40dbc77 --- /dev/null +++ b/server/repositories/taskMapper.ts @@ -0,0 +1,53 @@ +import type { Task } from "../types"; + +export interface TaskRow { + id: string; + title: string; + description: string | null; + start_time: Date | string; + end_time: Date | string | null; + duration: number | string | null; + project_name: string | null; + client: string | null; + category_id: string | null; + day_record_id: string | null; + is_current: boolean | number; + inserted_at: Date | string | null; + updated_at: Date | string | null; +} + +export function mapTaskRow(row: TaskRow): Task { + return { + id: row.id, + title: row.title, + description: row.description ?? undefined, + startTime: new Date(row.start_time), + endTime: row.end_time ? new Date(row.end_time) : undefined, + duration: row.duration != null ? Number(row.duration) : undefined, + project: row.project_name ?? undefined, + client: row.client ?? undefined, + category: row.category_id ?? undefined, + insertedAt: row.inserted_at ? new Date(row.inserted_at) : undefined, + updatedAt: row.updated_at ? new Date(row.updated_at) : undefined + }; +} + +export function taskToRow( + task: Task, + opts: { isCurrent: boolean; dayRecordId?: string | null } +) { + return { + id: task.id, + title: task.title, + description: task.description ?? null, + start_time: task.startTime, + end_time: task.endTime ?? null, + duration: task.duration ?? null, + project_name: task.project ?? null, + client: task.client ?? null, + category_id: task.category ?? null, + day_record_id: opts.dayRecordId ?? null, + is_current: opts.isCurrent, + updated_at: new Date() + }; +} diff --git a/server/repositories/todos.ts b/server/repositories/todos.ts new file mode 100644 index 0000000..52c3c0a --- /dev/null +++ b/server/repositories/todos.ts @@ -0,0 +1,50 @@ +import { db } from "../db"; +import type { TodoItem } from "../types"; + +interface TodoRow { + id: string; + text: string; + completed: boolean | number; + created_at: Date | string; + completed_at: Date | string | null; +} + +function mapRow(row: TodoRow): TodoItem { + return { + id: row.id, + text: row.text, + completed: !!row.completed, + createdAt: new Date(row.created_at).toISOString(), + completedAt: row.completed_at ? new Date(row.completed_at).toISOString() : undefined + }; +} + +export async function getTodos(): Promise { + const rows: TodoRow[] = await db("todo_items").orderBy("created_at", "asc"); + return rows.map(mapRow); +} + +export async function saveTodos(todos: TodoItem[]): Promise { + if (todos.length === 0) { + await db("todo_items").delete(); + return; + } + + const existing = await db("todo_items").select("id"); + const existingIds = new Set(existing.map((r) => r.id)); + const newIds = new Set(todos.map((t) => t.id)); + const toDelete = [...existingIds].filter((id) => !newIds.has(id)); + if (toDelete.length > 0) { + await db("todo_items").whereIn("id", toDelete).delete(); + } + + const rows = todos.map((item) => ({ + id: item.id, + text: item.text, + completed: item.completed, + created_at: item.createdAt, + completed_at: item.completedAt ?? null + })); + + await db("todo_items").insert(rows).onConflict("id").merge(); +} diff --git a/server/schema.ts b/server/schema.ts new file mode 100644 index 0000000..c2561f8 --- /dev/null +++ b/server/schema.ts @@ -0,0 +1,118 @@ +import type { Knex } from "knex"; + +// Single-tenant schema: no user_id/RLS, mirroring how guest/localStorage mode +// holds one shared dataset. Mirrors supabase/schema.sql minus auth concerns. +export async function ensureSchema(db: Knex): Promise { + if (!(await db.schema.hasTable("projects"))) { + await db.schema.createTable("projects", (t) => { + t.string("id").primary(); + t.string("name").notNullable(); + t.string("client").notNullable(); + t.decimal("hourly_rate", 10, 2); + t.string("color"); + t.boolean("is_billable").defaultTo(true); + t.boolean("archived").defaultTo(false); + t.timestamp("inserted_at").defaultTo(db.fn.now()); + t.timestamp("updated_at").defaultTo(db.fn.now()); + }); + } + + if (!(await db.schema.hasTable("categories"))) { + await db.schema.createTable("categories", (t) => { + t.string("id").primary(); + t.string("name").notNullable(); + t.string("color"); + t.boolean("is_billable").defaultTo(true); + t.timestamp("inserted_at").defaultTo(db.fn.now()); + t.timestamp("updated_at").defaultTo(db.fn.now()); + }); + } + + if (!(await db.schema.hasTable("clients"))) { + await db.schema.createTable("clients", (t) => { + t.string("id").primary(); + t.string("name").notNullable(); + t.boolean("archived").defaultTo(false); + t.timestamp("created_at").defaultTo(db.fn.now()); + t.string("address_street"); + t.string("address_city"); + t.string("address_state"); + t.string("address_zip"); + t.string("address_country"); + t.string("contact_name"); + t.string("contact_email"); + t.string("contact_website"); + }); + } + + if (!(await db.schema.hasTable("tasks"))) { + await db.schema.createTable("tasks", (t) => { + t.string("id").primary(); + t.string("title").notNullable(); + t.text("description"); + t.timestamp("start_time").notNullable(); + t.timestamp("end_time"); + t.bigInteger("duration"); + t.string("project_id"); + t.string("project_name"); + t.string("client"); + t.string("category_id"); + t.string("category_name"); + t.string("day_record_id"); + t.boolean("is_current").defaultTo(false); + t.timestamp("inserted_at").defaultTo(db.fn.now()); + t.timestamp("updated_at").defaultTo(db.fn.now()); + t.index("is_current"); + t.index("day_record_id"); + }); + } + + if (!(await db.schema.hasTable("archived_days"))) { + await db.schema.createTable("archived_days", (t) => { + t.string("id").primary(); + t.string("date").notNullable(); + t.bigInteger("total_duration"); + t.timestamp("start_time").notNullable(); + t.timestamp("end_time").notNullable(); + t.text("notes"); + t.timestamp("inserted_at").defaultTo(db.fn.now()); + t.timestamp("updated_at").defaultTo(db.fn.now()); + }); + } + + if (!(await db.schema.hasTable("current_day"))) { + await db.schema.createTable("current_day", (t) => { + t.string("id").primary(); + t.boolean("is_day_started").defaultTo(false); + t.timestamp("day_start_time"); + t.string("current_task_id"); + t.timestamp("updated_at").defaultTo(db.fn.now()); + }); + } + + if (!(await db.schema.hasTable("todo_items"))) { + await db.schema.createTable("todo_items", (t) => { + t.string("id").primary(); + t.text("text").notNullable(); + t.boolean("completed").notNullable().defaultTo(false); + t.timestamp("created_at").notNullable(); + t.timestamp("completed_at"); + }); + } + + if (!(await db.schema.hasTable("planned_tasks"))) { + await db.schema.createTable("planned_tasks", (t) => { + t.string("id").primary(); + t.string("title").notNullable(); + t.text("description"); + t.string("status").notNullable().defaultTo("todo"); + t.string("project_name"); + t.string("client"); + t.string("category_id"); + t.integer("priority").notNullable().defaultTo(0); + t.string("linked_task_id"); + t.timestamp("created_at").notNullable(); + t.timestamp("updated_at").notNullable().defaultTo(db.fn.now()); + }); + } +} diff --git a/server/seed.ts b/server/seed.ts new file mode 100644 index 0000000..58e62d8 --- /dev/null +++ b/server/seed.ts @@ -0,0 +1,82 @@ +import { randomUUID } from "node:crypto"; +import { DEFAULT_CATEGORIES } from "../src/config/categories"; +import { DEFAULT_PROJECTS } from "../src/config/projects"; +import { db, dbClient } from "./db"; +import { ensureSchema } from "./schema"; + +// Mirrors convertDefaultProjects() in TimeTrackingContext.tsx so seeded +// project ids match what the app would derive on a fresh, empty database. +function defaultProjectId(name: string, index: number): string { + return `default-${index}-${name.toLowerCase().replace(/\s+/g, "-")}`; +} + +async function seedCategories() { + const count = await db("categories").count<{ count: string }[]>({ count: "*" }); + if (Number(count[0]?.count ?? 0) > 0) { + console.log("Categories already seeded, skipping."); + return; + } + + const rows = DEFAULT_CATEGORIES.map((category) => ({ + id: category.id, + name: category.name, + color: category.color, + is_billable: category.isBillable !== false + })); + await db("categories").insert(rows); + console.log(`Seeded ${rows.length} default categories.`); +} + +async function seedProjectsAndClients() { + const count = await db("projects").count<{ count: string }[]>({ count: "*" }); + if (Number(count[0]?.count ?? 0) > 0) { + console.log("Projects already seeded, skipping."); + return; + } + + const projectRows = DEFAULT_PROJECTS.map((project, index) => ({ + id: defaultProjectId(project.name, index), + name: project.name, + client: project.client, + hourly_rate: project.hourlyRate ?? null, + color: project.color, + is_billable: project.isBillable !== false, + archived: false + })); + await db("projects").insert(projectRows); + console.log(`Seeded ${projectRows.length} default projects.`); + + // Mirrors the client-reconcile-on-load logic: every distinct project + // client name becomes an active client. + const existingClients = await db("clients").select("name"); + const existingNames = new Set(existingClients.map((c) => c.name)); + const clientNames = [...new Set(DEFAULT_PROJECTS.map((p) => p.client.trim()).filter(Boolean))]; + const newClients = clientNames + .filter((name) => !existingNames.has(name)) + .map((name) => ({ + id: randomUUID(), + name, + archived: false, + created_at: new Date() + })); + + if (newClients.length > 0) { + await db("clients").insert(newClients); + console.log(`Seeded ${newClients.length} default clients.`); + } +} + +async function main() { + console.log(`Seeding ${dbClient} database with default data...`); + await ensureSchema(db); + await seedCategories(); + await seedProjectsAndClients(); + console.log("✅ Seed complete."); +} + +main() + .catch((error) => { + console.error("❌ Seed failed:", error); + process.exitCode = 1; + }) + .finally(() => db.destroy()); diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 0000000..621f29d --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": false, + "isolatedModules": true, + "moduleDetection": "force", + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noEmit": true, + "types": ["node"] + }, + "include": ["./**/*.ts", "../src/contexts/TimeTrackingContext.tsx", "../src/config/categories.ts", "../src/config/projects.ts"] +} diff --git a/server/types.ts b/server/types.ts new file mode 100644 index 0000000..a19a7fd --- /dev/null +++ b/server/types.ts @@ -0,0 +1,21 @@ +// Type-only imports — erased at build time, so importing them here never +// pulls the React/Supabase-heavy runtime code in src/ into the Node server. +export type { + Task, + DayRecord, + Project, + Client, + TodoItem, + PlannedTask, + PlannedTaskStatus +} from "../src/contexts/TimeTrackingContext"; +export type { TaskCategory } from "../src/config/categories"; + +import type { Task } from "../src/contexts/TimeTrackingContext"; + +export interface CurrentDayData { + isDayStarted: boolean; + dayStartTime: Date | null; + currentTask: Task | null; + tasks: Task[]; +} diff --git a/src/services/dataService.ts b/src/services/dataService.ts index c07ecbd..8809f78 100644 --- a/src/services/dataService.ts +++ b/src/services/dataService.ts @@ -3,6 +3,7 @@ import { Task } from "@/contexts/TimeTrackingContext"; import { TaskCategory } from "@/config/categories"; import { LocalStorageService } from "@/services/localStorageService"; import { SupabaseService } from "@/services/supabaseService"; +import { SqlApiService } from "@/services/sqlApiService"; export { STORAGE_KEYS } from "@/services/localStorageService"; @@ -56,5 +57,10 @@ export interface DataService { // Factory function to get the appropriate service export const createDataService = (isAuthenticated: boolean): DataService => { + // Self-hosted SQL backend opt-in — takes precedence over Supabase/localStorage + // when explicitly configured, but never affects existing deployments. + if (import.meta.env.VITE_DATA_BACKEND === "sql") { + return new SqlApiService(); + } return isAuthenticated ? new SupabaseService() : new LocalStorageService(); }; diff --git a/src/services/sqlApiService.ts b/src/services/sqlApiService.ts new file mode 100644 index 0000000..6acee08 --- /dev/null +++ b/src/services/sqlApiService.ts @@ -0,0 +1,240 @@ +import { Task, DayRecord, Project, Client, TodoItem, PlannedTask } from "@/contexts/TimeTrackingContext"; +import { TaskCategory } from "@/config/categories"; +import { DataService, CurrentDayData } from "@/services/dataService"; +import { LocalStorageService } from "@/services/localStorageService"; + +const BASE_URL = (import.meta.env.VITE_SQL_API_URL ?? "http://localhost:4001/api").replace(/\/$/, ""); + +interface TaskWire extends Omit { + startTime: string; + endTime?: string; + insertedAt?: string; + updatedAt?: string; +} + +interface CurrentDayWire { + isDayStarted: boolean; + dayStartTime: string | null; + currentTask: TaskWire | null; + tasks: TaskWire[]; +} + +interface DayRecordWire extends Omit { + tasks: TaskWire[]; + startTime: string; + endTime: string; + insertedAt?: string; + updatedAt?: string; +} + +function reviveTask(task: TaskWire): Task { + return { + ...task, + startTime: new Date(task.startTime), + endTime: task.endTime ? new Date(task.endTime) : undefined, + insertedAt: task.insertedAt ? new Date(task.insertedAt) : undefined, + updatedAt: task.updatedAt ? new Date(task.updatedAt) : undefined + }; +} + +function reviveDay(day: DayRecordWire): DayRecord { + return { + ...day, + tasks: day.tasks.map(reviveTask), + startTime: new Date(day.startTime), + endTime: new Date(day.endTime), + insertedAt: day.insertedAt ? new Date(day.insertedAt) : undefined, + updatedAt: day.updatedAt ? new Date(day.updatedAt) : undefined + }; +} + +async function request(path: string, options?: RequestInit): Promise { + const response = await fetch(`${BASE_URL}${path}`, { + ...options, + headers: { "Content-Type": "application/json", ...options?.headers } + }); + + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new Error(`SQL backend request failed (${response.status}): ${body || response.statusText}`); + } + + if (response.status === 204) { + return undefined as T; + } + + return response.json() as Promise; +} + +// Talks to the self-hosted backend in server/ over HTTP — browsers cannot +// open a raw MySQL/Postgres connection directly, so this mirrors the +// Supabase/localStorage services against a small REST API instead. +export class SqlApiService implements DataService { + async saveCurrentDay(data: CurrentDayData): Promise { + await request("/current-day", { method: "PUT", body: JSON.stringify(data) }); + } + + async getCurrentDay(): Promise { + const data = await request("/current-day"); + if (!data) return null; + return { + isDayStarted: data.isDayStarted, + dayStartTime: data.dayStartTime ? new Date(data.dayStartTime) : null, + tasks: data.tasks.map(reviveTask), + currentTask: data.currentTask ? reviveTask(data.currentTask) : null + }; + } + + async saveArchivedDays(days: DayRecord[]): Promise { + await request("/archived-days", { method: "PUT", body: JSON.stringify(days) }); + } + + async getArchivedDays(): Promise { + const days = await request("/archived-days"); + return days.map(reviveDay); + } + + async updateArchivedDay(id: string, updates: Partial): Promise { + await request(`/archived-days/${encodeURIComponent(id)}`, { + method: "PATCH", + body: JSON.stringify(updates) + }); + } + + async deleteArchivedDay(id: string): Promise { + await request(`/archived-days/${encodeURIComponent(id)}`, { method: "DELETE" }); + } + + async saveProjects(projects: Project[]): Promise { + await request("/projects", { method: "PUT", body: JSON.stringify(projects) }); + } + + async getProjects(): Promise { + return request("/projects"); + } + + async saveClients(clients: Client[]): Promise { + await request("/clients", { method: "PUT", body: JSON.stringify(clients) }); + } + + async getClients(): Promise { + return request("/clients"); + } + + async upsertClient(client: Client): Promise { + await request(`/clients/${encodeURIComponent(client.id)}`, { + method: "PUT", + body: JSON.stringify(client) + }); + } + + async saveCategories(categories: TaskCategory[]): Promise { + await request("/categories", { method: "PUT", body: JSON.stringify(categories) }); + } + + async getCategories(): Promise { + return request("/categories"); + } + + async saveTodos(todos: TodoItem[]): Promise { + await request("/todos", { method: "PUT", body: JSON.stringify(todos) }); + } + + async getTodos(): Promise { + return request("/todos"); + } + + async savePlannedTasks(tasks: PlannedTask[]): Promise { + await request("/planned-tasks", { method: "PUT", body: JSON.stringify(tasks) }); + } + + async getPlannedTasks(): Promise { + return request("/planned-tasks"); + } + + async upsertPlannedTask(task: PlannedTask): Promise { + await request(`/planned-tasks/${encodeURIComponent(task.id)}`, { + method: "PUT", + body: JSON.stringify(task) + }); + } + + async deletePlannedTask(id: string): Promise { + await request(`/planned-tasks/${encodeURIComponent(id)}`, { method: "DELETE" }); + } + + // Reuses the same one-time-merge strategy as SupabaseService: only pull + // guest localStorage data in if the SQL backend doesn't already have it. + async migrateFromLocalStorage(): Promise { + try { + const localService = new LocalStorageService(); + + const projects = await localService.getProjects(); + const categories = await localService.getCategories(); + const currentDay = await localService.getCurrentDay(); + const archivedDaysData = await localService.getArchivedDays(); + const todos = await localService.getTodos(); + const plannedTasksData = await localService.getPlannedTasks(); + const clients = await localService.getClients(); + + const hasProjects = projects.length > 0; + const hasCategories = categories.length > 0; + const hasCurrentDay = currentDay && (currentDay.tasks.length > 0 || currentDay.isDayStarted); + const hasArchivedDays = archivedDaysData.length > 0; + const hasTodos = todos.length > 0; + const hasPlannedTasks = plannedTasksData.length > 0; + const hasClients = clients.length > 0; + + if (!hasProjects && !hasCategories && !hasCurrentDay && !hasArchivedDays && !hasTodos && !hasPlannedTasks && !hasClients) { + return; + } + + const existingProjects = await this.getProjects(); + const existingCategories = await this.getCategories(); + const existingArchivedDays = await this.getArchivedDays(); + const existingPlannedTasks = await this.getPlannedTasks(); + const existingClients = await this.getClients(); + + if (hasProjects && existingProjects.length === 0) await this.saveProjects(projects); + if (hasCategories && existingCategories.length === 0) await this.saveCategories(categories); + if (hasCurrentDay && currentDay) await this.saveCurrentDay(currentDay); + if (hasArchivedDays && existingArchivedDays.length === 0) await this.saveArchivedDays(archivedDaysData); + if (hasTodos) await this.saveTodos(todos); + if (hasPlannedTasks && existingPlannedTasks.length === 0) await this.savePlannedTasks(plannedTasksData); + + if (hasClients) { + const existingNames = new Set(existingClients.map((client) => client.name)); + const newClients = clients.filter((client) => !existingNames.has(client.name)); + for (const client of newClients) { + await this.upsertClient(client); + } + } + } catch (error) { + console.error("❌ Error migrating data from localStorage to SQL backend:", error); + } + } + + async migrateToLocalStorage(): Promise { + try { + const localService = new LocalStorageService(); + + const currentDay = await this.getCurrentDay(); + const archivedDaysData = await this.getArchivedDays(); + const projects = await this.getProjects(); + const categories = await this.getCategories(); + const todos = await this.getTodos(); + const plannedTasksData = await this.getPlannedTasks(); + const clients = await this.getClients(); + + if (currentDay) await localService.saveCurrentDay(currentDay); + if (archivedDaysData.length > 0) await localService.saveArchivedDays(archivedDaysData); + if (projects.length > 0) await localService.saveProjects(projects); + if (categories.length > 0) await localService.saveCategories(categories); + if (todos.length > 0) await localService.saveTodos(todos); + if (plannedTasksData.length > 0) await localService.savePlannedTasks(plannedTasksData); + if (clients.length > 0) await localService.saveClients(clients); + } catch (error) { + console.error("❌ Error migrating data from SQL backend to localStorage:", error); + } + } +} From 4f636435ec077832b3303199eac811265d1ecedd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 17 Jun 2026 20:10:41 +0000 Subject: [PATCH 2/2] docs: sync documentation with recent changes --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 81b0c88..b6a66f1 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ A Progressive Web App (PWA) for time tracking built with React, TypeScript, and - **CSV Import** — bring in existing time data from other tools - **Weekly Report** — AI-generated work summaries (standup, client, or retrospective tone) - **No Account Required** — full functionality with local storage; optional cloud sync via Supabase +- **Self-Hosted SQL Backend** — optionally run against your own PostgreSQL or MySQL database instead of Supabase or local storage (see [docs/SQL_BACKEND.md](docs/SQL_BACKEND.md)) - **PWA** — installable on desktop/mobile --- @@ -68,6 +69,12 @@ pnpm screenshots # Capture screenshots (headless) pnpm test-csv-import pnpm test-full-import pnpm test-error-handling + +# Self-Hosted SQL Backend (optional) +pnpm db:migrate # apply schema to your Postgres/MySQL database +pnpm db:seed # seed default categories/projects +pnpm server:dev # run the backend API in watch mode +pnpm server:start # run the backend API ``` --- @@ -126,6 +133,7 @@ See [CHANGELOG.md](CHANGELOG.md) for the full history of changes. **Recent highlights:** +- **Self-hosted SQL backend** — opt-in PostgreSQL/MySQL support via a small REST API (`server/`), alongside the existing Supabase and local storage modes - **Backdated entry creation** — "Add Past Entry" button on Archive page opens a multi-step dialog to log tasks for any past date - **Kanban planning board** — drag-and-drop task planning view (`KanbanBoard`, `KanbanColumn`, `PlannedTaskCard`) - Persistent report summaries saved to localStorage; markdown preview/export in the report output panel