diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..96cc8b6 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "eamodio.gitlens" + ] +} + + diff --git a/README.md b/README.md index 3f27d18..87a4e82 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,12 @@
- Focal Logo + FinanceMate Logo - # Focal Finance Tracker + # FinanceMate A modern, privacy-focused expense tracking Progressive Web App (PWA) with AI-powered receipt scanning. ![Dashboard](images/dashboard.png) -[![Live Demo](https://img.shields.io/badge/demo-live-success)](https://focal.creative-geek.tech) [![License](https://img.shields.io/badge/license-MIT-blue)]()
@@ -34,8 +33,8 @@ A modern, privacy-focused expense tracking Progressive Web App (PWA) with AI-pow ```bash # Clone repository -git clone https://github.com/Creative-Geek/Focal.git -cd Focal +git clone https://github.com/yourusername/FinanceMate.git +cd FinanceMate # Install dependencies pnpm install @@ -101,8 +100,8 @@ Built with [React](https://react.dev), [Cloudflare](https://cloudflare.com), [sh
-**[Live Demo](https://focal.creative-geek.tech)** • **[Documentation](docs/DEVELOPMENT.md)** • **[Report Bug](https://github.com/Creative-Geek/Focal/issues)** +**[Documentation](docs/DEVELOPMENT.md)** • **[Report Bug](https://github.com/yourusername/FinanceMate/issues)** -Made with ❤️ by Creative Geek +Track smarter, spend better 💙
diff --git a/e2e/user-flow.spec.ts b/e2e/user-flow.spec.ts new file mode 100644 index 0000000..6f58d9f --- /dev/null +++ b/e2e/user-flow.spec.ts @@ -0,0 +1,142 @@ +import { test, expect } from '@playwright/test'; + +test.describe('User Flow', () => { + test('should allow user to login and see dashboard', async ({ page }) => { + // Mock login API + await page.route('**/api/auth/login', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ token: 'fake-jwt-token', user: { id: 1, email: 'test@example.com' } }), + }); + }); + + // Mock expenses API (empty list initially) + await page.route('**/api/expenses', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([]), + }); + }); + + // Mock User API if needed (often checked on load) + await page.route('**/api/auth/me', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 1, email: 'test@example.com' }), + }); + }); + + await page.goto('/login'); + + // Fill login form + await page.getByLabel('Email').fill('test@example.com'); + await page.getByLabel('Password').fill('password123'); + await page.getByRole('button', { name: 'Sign in' }).click(); + + // Verify redirect to dashboard + await expect(page).toHaveURL('/'); + await expect(page.getByText('Current Balance')).toBeVisible(); + }); + + test('should allow adding an expense', async ({ page }) => { + // Mock APIs + await page.route('**/api/auth/me', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 1, email: 'test@example.com' }), + }); + }); + + await page.route('**/api/expenses', async route => { + if (route.request().method() === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([]), + }); + } else if (route.request().method() === 'POST') { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ id: 101, amount: 50, description: 'Lunch', date: new Date().toISOString() }), + }); + } + }); + + // Bypass login by setting token (if app checks localstorage on load) + // Or just re-login. Re-login is safer with mocks. + await page.route('**/api/auth/login', async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ token: 'fake-jwt-token', user: { id: 1, email: 'test@example.com' } }), + }); + }); + + await page.goto('/login'); + await page.getByLabel('Email').fill('test@example.com'); + await page.getByLabel('Password').fill('password123'); + await page.getByRole('button', { name: 'Sign in' }).click(); + await expect(page).toHaveURL('/'); + + // Add Expense interaction + // Assuming there is a button to add expense. + // I need to know the UI. Usually a "+" button or "Add Expense". + // I will check the dashboard code or guess. + // Based on README images, there's likely an "Add Expense" button. + + // For now, I'll pause there or look for the button. + // Let's assume there is an "Add Expense" button text or label. + // I'll wait for selector or just generic text. + + // Looking at AddExpenseMenu.tsx might help knowing the trigger. + // But I'll write the test up to login for now and verify, then refine. + // Actually, I should write the full test if possible. + // I'll check AddExpenseMenu.tsx content quickly in next step if needed, but I'll write a basic check first. + + const addBtn = page.getByRole('button', { name: /add expense/i }); + await expect(addBtn).toBeVisible(); + await addBtn.click(); + + // Choose Manual Entry + await page.getByRole('button', { name: /manual entry/i }).click(); + + // Fill form + await page.locator('#merchant').fill('Coffee Shop'); + await page.locator('#total').fill('5.50'); + // Date defaults to today usually, but let's leave it or fill it if key + + // Select Category (shadcn select) + await page.getByRole('combobox').click(); + await page.getByRole('option', { name: 'Food & Drink' }).click(); + + // Mock Save API + await page.route('**/api/expenses', async route => { + // Handle both GET (refresh) and POST (save) + if (route.request().method() === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([{ id: 101, amount: 5.5, total: 5.5, merchant: 'Coffee Shop', category: 'Food & Drink', date: new Date().toISOString(), currency: 'USD' }]), + }); + } else if (route.request().method() === 'POST') { + await route.fulfill({ + status: 201, + contentType: 'application/json', + body: JSON.stringify({ success: true, data: { id: 101 } }), + }); + } + }); + + // Click Save + await page.getByRole('button', { name: /save expense/i }).click(); + + // Verify expense appears (via GET mock) + await expect(page.getByText('Coffee Shop')).toBeVisible(); + await expect(page.getByText('$5.50')).toBeVisible(); + }); +}); diff --git a/index.html b/index.html index 029d135..51f42ad 100644 --- a/index.html +++ b/index.html @@ -1,93 +1,57 @@ - - - - - - - - Focal: AI-Powered Expense Tracker & Receipt Scanner - - - - + + + + + + - - - - - - - - + + FinanceMate: AI Expense Tracker + + + - - - - - - - + + + + + + + + - - - - - - + + + + + + + - - - - - - - -
- - - + + + + + + + + + + + + + + + +
+ + + + \ No newline at end of file diff --git a/migrations/007_budgets.sql b/migrations/007_budgets.sql new file mode 100644 index 0000000..755de12 --- /dev/null +++ b/migrations/007_budgets.sql @@ -0,0 +1,16 @@ +-- Migration: 007_budgets.sql +-- Created table for tracking user budgets per category + +CREATE TABLE budgets ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + category TEXT NOT NULL, + limit_amount REAL NOT NULL, + currency TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(user_id, category) +); + +CREATE INDEX idx_budgets_user_id ON budgets(user_id); diff --git a/migrations/008_admin_and_logs.sql b/migrations/008_admin_and_logs.sql new file mode 100644 index 0000000..8033012 --- /dev/null +++ b/migrations/008_admin_and_logs.sql @@ -0,0 +1,19 @@ +-- Migration: 008_admin_and_logs.sql +-- Add role and is_active to users, and create system_logs table + +-- Add role and is_active columns to users table +ALTER TABLE users ADD COLUMN role TEXT DEFAULT 'user'; +ALTER TABLE users ADD COLUMN is_active INTEGER DEFAULT 1; + +-- Create system_logs table +CREATE TABLE system_logs ( + id TEXT PRIMARY KEY, + level TEXT NOT NULL, -- 'info', 'warn', 'error' + message TEXT NOT NULL, + details TEXT, -- JSON string + timestamp INTEGER NOT NULL +); + +-- Index for logs +CREATE INDEX idx_system_logs_timestamp ON system_logs(timestamp); +CREATE INDEX idx_system_logs_level ON system_logs(level); diff --git a/migrations/009_recurring_expenses.sql b/migrations/009_recurring_expenses.sql new file mode 100644 index 0000000..d943e4d --- /dev/null +++ b/migrations/009_recurring_expenses.sql @@ -0,0 +1,18 @@ +-- Create recurring expenses table +CREATE TABLE IF NOT EXISTS recurring_expenses ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + amount REAL NOT NULL, + currency TEXT NOT NULL, + category TEXT NOT NULL, + merchant TEXT NOT NULL, + description TEXT, + frequency TEXT NOT NULL CHECK(frequency IN ('daily', 'weekly', 'monthly', 'yearly')), + next_due_date INTEGER NOT NULL, + is_active INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) +); + +-- Index for querying active recurring expenses +CREATE INDEX IF NOT EXISTS idx_recurring_expenses_user_active ON recurring_expenses(user_id, is_active); diff --git a/migrations/010_api_keys.sql b/migrations/010_api_keys.sql new file mode 100644 index 0000000..34d8a09 --- /dev/null +++ b/migrations/010_api_keys.sql @@ -0,0 +1,4 @@ +-- Migration number: 010 2024-03-22T00:00:00.000Z +-- This migration is skipped because the api_keys table already exists (as user_settings) +-- and we are creating api_auth_keys in migration 011 instead. +SELECT 1; diff --git a/migrations/011_api_auth_keys.sql b/migrations/011_api_auth_keys.sql new file mode 100644 index 0000000..0bc25bb --- /dev/null +++ b/migrations/011_api_auth_keys.sql @@ -0,0 +1,17 @@ +-- Create api_auth_keys table +CREATE TABLE IF NOT EXISTS api_auth_keys ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + key_hash TEXT NOT NULL, + name TEXT NOT NULL, + prefix TEXT NOT NULL, + created_at INTEGER NOT NULL, + last_used_at INTEGER, + expires_at INTEGER +); + +-- Index for faster lookups by hash +CREATE INDEX IF NOT EXISTS idx_api_auth_keys_hash ON api_auth_keys(key_hash); + +-- Index for listing keys by user +CREATE INDEX IF NOT EXISTS idx_api_auth_keys_user ON api_auth_keys(user_id); diff --git a/migrations/012_ban_reason.sql b/migrations/012_ban_reason.sql new file mode 100644 index 0000000..b3c74ce --- /dev/null +++ b/migrations/012_ban_reason.sql @@ -0,0 +1,2 @@ +-- Add ban_reason column to users table +ALTER TABLE users ADD COLUMN ban_reason TEXT; diff --git a/migrations/013_ai_processing_logs.sql b/migrations/013_ai_processing_logs.sql new file mode 100644 index 0000000..b18de52 --- /dev/null +++ b/migrations/013_ai_processing_logs.sql @@ -0,0 +1,16 @@ +-- Create table for logging AI processing events +CREATE TABLE IF NOT EXISTS ai_processing_logs ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + provider TEXT NOT NULL, -- 'gemini', 'openai', etc. + model TEXT, -- 'gemini-1.5-flash', etc. + duration_ms INTEGER NOT NULL, -- Processing time in ms + success INTEGER NOT NULL, -- 1 for success, 0 for failure + error TEXT, -- Error message if failed + timestamp INTEGER NOT NULL, -- Date.now() + FOREIGN KEY (user_id) REFERENCES users(id) +); + +-- Index for faster analytics queries +CREATE INDEX IF NOT EXISTS idx_ai_logs_timestamp ON ai_processing_logs(timestamp); +CREATE INDEX IF NOT EXISTS idx_ai_logs_provider ON ai_processing_logs(provider); diff --git a/migrations/014_add_budget_periods.sql b/migrations/014_add_budget_periods.sql new file mode 100644 index 0000000..0bc7d19 --- /dev/null +++ b/migrations/014_add_budget_periods.sql @@ -0,0 +1,6 @@ +-- Migration: 014_add_budget_periods.sql +-- Add period, year, and month columns to budgets table + +ALTER TABLE budgets ADD COLUMN period TEXT DEFAULT 'monthly'; +ALTER TABLE budgets ADD COLUMN year INTEGER DEFAULT 0; +ALTER TABLE budgets ADD COLUMN month INTEGER DEFAULT 0; diff --git a/migrations/015_custom_categories.sql b/migrations/015_custom_categories.sql new file mode 100644 index 0000000..d47af29 --- /dev/null +++ b/migrations/015_custom_categories.sql @@ -0,0 +1,13 @@ +-- Migration: 015_custom_categories.sql +-- Create table for user-defined expense categories + +CREATE TABLE categories ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + created_at INTEGER NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(user_id, name) +); + +CREATE INDEX idx_categories_user_id ON categories(user_id); diff --git a/migrations/016_category_icons_colors.sql b/migrations/016_category_icons_colors.sql new file mode 100644 index 0000000..02287d3 --- /dev/null +++ b/migrations/016_category_icons_colors.sql @@ -0,0 +1,5 @@ +-- Migration: 016_category_icons_colors.sql +-- Add icon and color support to categories + +ALTER TABLE categories ADD COLUMN icon TEXT; +ALTER TABLE categories ADD COLUMN color TEXT; diff --git a/migrations/017_saved_searches.sql b/migrations/017_saved_searches.sql new file mode 100644 index 0000000..c590db3 --- /dev/null +++ b/migrations/017_saved_searches.sql @@ -0,0 +1,13 @@ +-- Migration: 017_saved_searches.sql +-- Create table for saved search filters + +CREATE TABLE saved_searches ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + filters TEXT NOT NULL, + created_at INTEGER NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX idx_saved_searches_user_id ON saved_searches(user_id); diff --git a/migrations/018_add_last_active.sql b/migrations/018_add_last_active.sql new file mode 100644 index 0000000..f48ec00 --- /dev/null +++ b/migrations/018_add_last_active.sql @@ -0,0 +1,4 @@ +-- Migration: 018_add_last_active.sql +-- Add last_active_at column to users table for real-time activity tracking + +ALTER TABLE users ADD COLUMN last_active_at INTEGER; diff --git a/migrations/019_expenses_fts.sql b/migrations/019_expenses_fts.sql new file mode 100644 index 0000000..bc687a7 --- /dev/null +++ b/migrations/019_expenses_fts.sql @@ -0,0 +1,35 @@ +-- Migration: 019_expenses_fts.sql +-- Enable Full-Text Search for expenses using SQLite FTS5 + +-- Create separate FTS table since main table has TEXT PK +CREATE VIRTUAL TABLE IF NOT EXISTS expenses_fts USING fts5( + id UNINDEXED, + merchant, + category +); + +-- Triggers to keep FTS index in sync with expenses table + +-- On Insert +CREATE TRIGGER IF NOT EXISTS trg_expenses_fts_insert AFTER INSERT ON expenses BEGIN + INSERT INTO expenses_fts(id, merchant, category) + VALUES (new.id, new.merchant, new.category); +END; + +-- On Delete +CREATE TRIGGER IF NOT EXISTS trg_expenses_fts_delete AFTER DELETE ON expenses BEGIN + DELETE FROM expenses_fts WHERE id = old.id; +END; + +-- On Update +CREATE TRIGGER IF NOT EXISTS trg_expenses_fts_update AFTER UPDATE ON expenses BEGIN + DELETE FROM expenses_fts WHERE id = old.id; + INSERT INTO expenses_fts(id, merchant, category) + VALUES (new.id, new.merchant, new.category); +END; + +-- Populate existing data (safe to run multiple times as it only inserts active records, +-- but might duplicate if we don't clear. For migration, we assume fresh or one-off runs) +-- To be safe, we clear and re-populate +DELETE FROM expenses_fts; +INSERT INTO expenses_fts(id, merchant, category) SELECT id, merchant, category FROM expenses; diff --git a/migrations/020_tags.sql b/migrations/020_tags.sql new file mode 100644 index 0000000..01e1989 --- /dev/null +++ b/migrations/020_tags.sql @@ -0,0 +1,28 @@ +-- Migration: 020_tags.sql +-- Create tables for Tagging System + +-- Tags definition +CREATE TABLE IF NOT EXISTS tags ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + color TEXT, + created_at INTEGER NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(user_id, name) +); + +-- Many-to-Many link between Expenses and Tags +CREATE TABLE IF NOT EXISTS expense_tags ( + expense_id TEXT NOT NULL, + tag_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (expense_id, tag_id), + FOREIGN KEY (expense_id) REFERENCES expenses(id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_tags_user_id ON tags(user_id); +CREATE INDEX IF NOT EXISTS idx_expense_tags_tag_id ON expense_tags(tag_id); +CREATE INDEX IF NOT EXISTS idx_expense_tags_expense_id ON expense_tags(expense_id); diff --git a/migrations/021_notifications.sql b/migrations/021_notifications.sql new file mode 100644 index 0000000..23725af --- /dev/null +++ b/migrations/021_notifications.sql @@ -0,0 +1,16 @@ +-- Migration: 021_notifications.sql +-- Create Notifications table + +CREATE TABLE IF NOT EXISTS notifications ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + type TEXT NOT NULL, -- 'system', 'budget_alert', 'achievement', 'info' + title TEXT NOT NULL, + message TEXT NOT NULL, + is_read INTEGER DEFAULT 0, -- boolean + data TEXT, -- JSON payload for extra link/metadata + created_at INTEGER NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_notifications_user_read ON notifications(user_id, is_read); diff --git a/migrations/022_category_normalization.sql b/migrations/022_category_normalization.sql new file mode 100644 index 0000000..310c172 --- /dev/null +++ b/migrations/022_category_normalization.sql @@ -0,0 +1,38 @@ +-- Migration: 022_category_normalization.sql +-- Link expenses to categories table for normalization + +-- 1. Add category_id column to expenses +ALTER TABLE expenses ADD COLUMN category_id TEXT REFERENCES categories(id); + +-- 2. Populate categories table with any missing categories from expenses +-- (We use a temporary ID generation trick: user_id || '_' || category_name or random hex) +-- Since SQLite standard doesn't have uuid(), we might need application level migration or use hex(randomblob(16)) +-- We'll try to insert distinct categories that don't satisfy the unique constraint yet. +-- Assumes categories table has UNIQUE(user_id, name) from 015_custom_categories.sql + +INSERT OR IGNORE INTO categories (id, user_id, name, created_at) +SELECT + hex(randomblob(16)), + user_id, + category, + strftime('%s','now') * 1000 +FROM expenses +GROUP BY user_id, category; + +-- 3. Update expenses.category_id by matching name +-- We use a correlated subquery since SQLite support for UPDATE JOIN varies by version (but usually supported in recent) +UPDATE expenses +SET category_id = ( + SELECT id + FROM categories c + WHERE c.name = expenses.category + AND c.user_id = expenses.user_id +); + +-- 4. Create index on category_id +CREATE INDEX IF NOT EXISTS idx_expenses_category_id ON expenses(category_id); + +-- Note: We generally do NOT drop the 'category' text column immediately to prevent downtime/code breakage. +-- The application should switch to reading category name from the JOIN or continue using the text column +-- but write to both (or rely on category_id). +-- Eventually, we can drop the text column in a future migration. diff --git a/migrations/023_budget_alert_threshold.sql b/migrations/023_budget_alert_threshold.sql new file mode 100644 index 0000000..0515379 --- /dev/null +++ b/migrations/023_budget_alert_threshold.sql @@ -0,0 +1,2 @@ +-- Migration: 023_budget_alert_threshold.sql +ALTER TABLE budgets ADD COLUMN alert_threshold REAL DEFAULT 80; diff --git a/package.json b/package.json index b34a9ad..e179917 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "focal-finance-tracker", + "name": "financemate", "private": true, "version": "0.0.0", "type": "module", @@ -10,16 +10,25 @@ }, "scripts": { "dev": "vite --host 0.0.0.0 --port 3000", - "dev:worker": "wrangler dev --local --port 8787", + "dev:worker": "wrangler dev --local --ip 0.0.0.0 --port 8787", "dev:full": "concurrently \"pnpm dev\" \"pnpm dev:worker\"", "build": "vite build", "lint": "eslint --cache -f json --quiet .", + "test": "vitest", "preview": "bun run build && vite preview --host 0.0.0.0 --port ${PORT:-4173}", "deploy": "pnpm run build && wrangler deploy", "db:migrate": "wrangler d1 execute focal_expensi_db --local --file=./migrations/001_initial_schema.sql", "db:migrate:prod": "wrangler d1 execute focal_expensi_db --remote --file=./migrations/001_initial_schema.sql", "db:migrate:002": "wrangler d1 execute focal_expensi_db --local --file=./migrations/002_quantity_real.sql", "db:migrate:002:prod": "wrangler d1 execute focal_expensi_db --remote --file=./migrations/002_quantity_real.sql", + "db:migrate:003": "wrangler d1 execute focal_expensi_db --local --file=./migrations/003_email_verification.sql", + "db:migrate:003:prod": "wrangler d1 execute focal_expensi_db --remote --file=./migrations/003_email_verification.sql", + "db:migrate:004": "wrangler d1 execute focal_expensi_db --local --file=./migrations/004_reset_password.sql", + "db:migrate:004:prod": "wrangler d1 execute focal_expensi_db --remote --file=./migrations/004_reset_password.sql", + "db:migrate:005": "wrangler d1 execute focal_expensi_db --local --file=./migrations/005_rate_limiting.sql", + "db:migrate:005:prod": "wrangler d1 execute focal_expensi_db --remote --file=./migrations/005_rate_limiting.sql", + "db:migrate:006": "wrangler d1 execute focal_expensi_db --local --file=./migrations/006_user_ai_provider.sql", + "db:migrate:006:prod": "wrangler d1 execute focal_expensi_db --remote --file=./migrations/006_user_ai_provider.sql", "setup:prod": "bash scripts/setup-production.sh" }, "dependencies": { @@ -94,6 +103,9 @@ "devDependencies": { "@cloudflare/workers-types": "^4.20251113.0", "@eslint/js": "^9.39.1", + "@playwright/test": "^1.58.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/bcryptjs": "^3.0.0", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^22.19.1", @@ -112,12 +124,14 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", + "jsdom": "^27.4.0", "postcss": "^8.5.6", "tailwindcss": "^3.4.18", "typescript": "~5.8.3", "typescript-eslint": "^8.46.4", "vite": "^6.4.1", "vite-plugin-pwa": "^1.1.0", + "vitest": "^4.0.18", "wrangler": "^4.48.0" } } \ No newline at end of file diff --git a/playwright-report/index.html b/playwright-report/index.html new file mode 100644 index 0000000..0e7af0d --- /dev/null +++ b/playwright-report/index.html @@ -0,0 +1,85 @@ + + + + + + + + + Playwright Test Report + + + + +
+ + + \ No newline at end of file diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..bcec444 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,25 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + use: { + baseURL: 'http://localhost:3000', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + command: 'pnpm dev', + port: 3000, + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d54fda..3813417 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -154,7 +154,7 @@ importers: version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) openai: specifier: ^6.9.0 - version: 6.9.0(ws@8.18.0)(zod@4.1.12) + version: 6.9.0(ws@8.19.0)(zod@4.1.12) react: specifier: ^18.3.1 version: 18.3.1 @@ -219,6 +219,15 @@ importers: '@eslint/js': specifier: ^9.39.1 version: 9.39.1 + '@playwright/test': + specifier: ^1.58.0 + version: 1.58.0 + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/bcryptjs': specifier: ^3.0.0 version: 3.0.0 @@ -273,6 +282,9 @@ importers: globals: specifier: ^16.5.0 version: 16.5.0 + jsdom: + specifier: ^27.4.0 + version: 27.4.0 postcss: specifier: ^8.5.6 version: 8.5.6 @@ -291,12 +303,21 @@ importers: vite-plugin-pwa: specifier: ^1.1.0 version: 1.1.0(@vite-pwa/assets-generator@1.0.2)(vite@6.4.1(@types/node@22.19.1)(jiti@1.21.7)(terser@5.44.1))(workbox-build@7.3.0(@types/babel__core@7.20.5))(workbox-window@7.3.0) + vitest: + specifier: ^4.0.18 + version: 4.0.18(@types/node@22.19.1)(jiti@1.21.7)(jsdom@27.4.0)(terser@5.44.1) wrangler: specifier: ^4.48.0 version: 4.48.0(@cloudflare/workers-types@4.20251113.0) packages: + '@acemir/cssom@0.9.31': + resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==} + + '@adobe/css-tools@4.4.4': + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -307,6 +328,15 @@ packages: peerDependencies: ajv: '>=8' + '@asamuzakjp/css-color@4.1.1': + resolution: {integrity: sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==} + + '@asamuzakjp/dom-selector@6.7.6': + resolution: {integrity: sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -867,6 +897,37 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-syntax-patches-for-csstree@1.0.26': + resolution: {integrity: sha512-6boXK0KkzT5u5xOgF6TKB+CLq9SOpEGmkZw0g5n9/7yg85wab3UzSxB8TxhLJ31L4SGJ6BCFRw/iftTha1CJXA==} + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + '@date-fns/tz@1.4.1': resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} @@ -1272,6 +1333,15 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@exodus/bytes@1.10.0': + resolution: {integrity: sha512-tf8YdcbirXdPnJ+Nd4UN1EXnz+IP2DI45YVEr3vvzcVTOyrApkmIB4zvOQVd3XPr7RXnfBtAx+PXImXOIU0Ajg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@floating-ui/core@1.7.3': resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} @@ -1493,6 +1563,11 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@playwright/test@1.58.0': + resolution: {integrity: sha512-fWza+Lpbj6SkQKCrU6si4iu+fD2dD3gxNHFhUPxsfXBPhnv3rRSQVd0NtBUT9Z/RhF/boCBcuUaMUSTRTopjZg==} + engines: {node: '>=18'} + hasBin: true + '@poppinss/colors@4.1.5': resolution: {integrity: sha512-FvdDqtcRCtz6hThExcFOgW0cWX+xwSMWcRuQe5ZEb2m7cVQOAVZOIMt+/v9RxGiD9/OY16qJBXK4CVKWAPalBw==} @@ -2374,6 +2449,9 @@ packages: '@speed-highlight/core@1.2.12': resolution: {integrity: sha512-uilwrK0Ygyri5dToHYdZSjcvpS2ZwX0w5aSt3GCEN9hrjxWCoeV4Z2DTXuxjwbntaLQIEEAlCeNQss5SoHvAEA==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} @@ -2400,9 +2478,35 @@ packages: '@tanstack/virtual-core@3.13.12': resolution: {integrity: sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -2419,6 +2523,9 @@ packages: resolution: {integrity: sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==} deprecated: This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed. + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-array@3.2.2': resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} @@ -2446,6 +2553,9 @@ packages: '@types/d3-timer@3.0.2': resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@0.0.39': resolution: {integrity: sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==} @@ -2667,6 +2777,35 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/expect@4.0.18': + resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + + '@vitest/mocker@4.0.18': + resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.0.18': + resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + + '@vitest/runner@4.0.18': + resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + + '@vitest/snapshot@4.0.18': + resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} + + '@vitest/spy@4.0.18': + resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + + '@vitest/utils@4.0.18': + resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + '@xobotyi/scrollbar-width@1.9.5': resolution: {integrity: sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==} @@ -2689,6 +2828,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} @@ -2703,6 +2846,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -2720,6 +2867,13 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + array-buffer-byte-length@1.0.2: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} @@ -2744,6 +2898,10 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} @@ -2806,6 +2964,9 @@ packages: resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==} hasBin: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -2872,6 +3033,10 @@ packages: caniuse-lite@1.0.30001759: resolution: {integrity: sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -2976,11 +3141,22 @@ packages: resolution: {integrity: sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==} engines: {node: '>=8.0.0'} + css-tree@3.1.0: + resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} hasBin: true + cssstyle@5.3.7: + resolution: {integrity: sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==} + engines: {node: '>=20'} + csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} @@ -3028,6 +3204,10 @@ packages: resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} engines: {node: '>=12'} + data-urls@6.0.1: + resolution: {integrity: sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==} + engines: {node: '>=20'} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -3066,6 +3246,9 @@ packages: decimal.js-light@2.5.1: resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-bmp@0.2.1: resolution: {integrity: sha512-NiOaGe+GN0KJqi2STf24hfMkFitDUaIoUU3eKvP/wAbLe8o6FuW5n/x7MHPR0HKvBokp6MQY/j7w8lewEeVCIA==} engines: {node: '>=8.6.0'} @@ -3096,6 +3279,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -3117,6 +3304,12 @@ packages: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} @@ -3154,6 +3347,10 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -3175,6 +3372,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -3334,6 +3534,9 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -3345,6 +3548,10 @@ packages: resolution: {integrity: sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==} engines: {node: '>=6'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -3454,6 +3661,11 @@ packages: resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} engines: {node: '>=10'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3578,6 +3790,18 @@ packages: resolution: {integrity: sha512-h/MXuTkoAK8NG1EfDp0jI1YLf6yGdDnfkebRO2pwEh5+hE3RAJFXkCsnD0vamSiARK4ZrB6MY+o3E/hCnOyHrQ==} engines: {node: '>=16.9.0'} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + hyphenate-style-name@1.1.0: resolution: {integrity: sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==} @@ -3606,6 +3830,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + inline-style-prefixer@7.0.1: resolution: {integrity: sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw==} @@ -3715,6 +3943,9 @@ packages: resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} engines: {node: '>=8'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -3788,6 +4019,15 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsdom@27.4.0: + resolution: {integrity: sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -3912,9 +4152,16 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.25.9: resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -3922,6 +4169,9 @@ packages: mdn-data@2.0.14: resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==} + mdn-data@2.12.2: + resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} + memoize-one@6.0.0: resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} @@ -3946,6 +4196,10 @@ packages: engines: {node: '>=10.0.0'} hasBin: true + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + miniflare@4.20251109.1: resolution: {integrity: sha512-btcTw1pH40PGVMwn1pZDcrodQkgY8ijKJA/r7LKgJQGqVZ1k9gqfHHtbelZp8O9bJ995eQqdURyvXMflZwCo+g==} engines: {node: '>=18.0.0'} @@ -4067,6 +4321,9 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + openai@6.9.0: resolution: {integrity: sha512-n2sJRYmM+xfJ0l3OfH8eNnIyv3nQY7L08gZQu3dw6wSdfPtKAk92L83M2NIP5SS8Cl/bsBBG3yKzEOjkx0O+7A==} hasBin: true @@ -4103,6 +4360,9 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -4147,6 +4407,16 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + playwright-core@1.58.0: + resolution: {integrity: sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.58.0: + resolution: {integrity: sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==} + engines: {node: '>=18'} + hasBin: true + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -4210,6 +4480,10 @@ packages: resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==} engines: {node: ^14.13.1 || >=16.0.0} + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -4255,6 +4529,9 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} @@ -4368,6 +4645,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 + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -4463,6 +4744,10 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} @@ -4533,6 +4818,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + simple-swizzle@0.2.4: resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} @@ -4580,6 +4868,9 @@ packages: stack-generator@2.0.10: resolution: {integrity: sha512-mwnua/hkqM6pF4k8SnmZ2zfETsRUpWXREfA/goT8SLCV4iOFa4bzOX2nDipWAZFPTjLvQB82f5yaodMVhK0yJQ==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + stackframe@1.3.4: resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} @@ -4589,6 +4880,9 @@ packages: stacktrace-js@2.0.2: resolution: {integrity: sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg==} + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -4633,6 +4927,10 @@ packages: resolution: {integrity: sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==} engines: {node: '>=10'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -4664,6 +4962,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tabbable@6.3.0: resolution: {integrity: sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==} @@ -4710,10 +5011,28 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyrainbow@3.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + engines: {node: '>=14.0.0'} + + tldts-core@7.0.19: + resolution: {integrity: sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==} + + tldts@7.0.19: + resolution: {integrity: sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==} + hasBin: true + to-data-view@1.1.0: resolution: {integrity: sha512-1eAdufMg6mwgmlojAx3QeMnzB/BTVp7Tbndi3U7ftcT2zCZadjxkkmLmd97zmaxWi+sgGcgWrokmpEoy0Dn0vQ==} @@ -4724,9 +5043,17 @@ packages: toggle-selection@1.0.6: resolution: {integrity: sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==} + tough-cookie@6.0.0: + resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} + engines: {node: '>=16'} + tr46@1.0.1: resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -4957,9 +5284,63 @@ packages: yaml: optional: true + vitest@4.0.18: + resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.18 + '@vitest/browser-preview': 4.0.18 + '@vitest/browser-webdriverio': 4.0.18 + '@vitest/ui': 4.0.18 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + webidl-conversions@4.0.2: resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@15.1.0: + resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==} + engines: {node: '>=20'} + whatwg-url@7.1.0: resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} @@ -4984,6 +5365,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -5068,6 +5454,25 @@ packages: utf-8-validate: optional: true + ws@8.19.0: + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -5123,6 +5528,10 @@ packages: snapshots: + '@acemir/cssom@0.9.31': {} + + '@adobe/css-tools@4.4.4': {} + '@alloc/quick-lru@5.2.0': {} '@apideck/better-ajv-errors@0.3.6(ajv@8.17.1)': @@ -5132,6 +5541,24 @@ snapshots: jsonpointer: 5.0.1 leven: 3.1.0 + '@asamuzakjp/css-color@4.1.1': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 11.2.4 + + '@asamuzakjp/dom-selector@6.7.6': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.1.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.4 + + '@asamuzakjp/nwsapi@2.3.9': {} + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -5829,6 +6256,28 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-syntax-patches-for-csstree@1.0.26': {} + + '@csstools/css-tokenizer@3.0.4': {} + '@date-fns/tz@1.4.1': {} '@emnapi/core@1.7.0': @@ -6131,6 +6580,8 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@exodus/bytes@1.10.0': {} + '@floating-ui/core@1.7.3': dependencies: '@floating-ui/utils': 0.2.10 @@ -6336,6 +6787,10 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.1 + '@playwright/test@1.58.0': + dependencies: + playwright: 1.58.0 + '@poppinss/colors@4.1.5': dependencies: kleur: 4.1.5 @@ -7222,6 +7677,8 @@ snapshots: '@speed-highlight/core@1.2.12': {} + '@standard-schema/spec@1.1.0': {} + '@standard-schema/utils@0.3.0': {} '@surma/rollup-plugin-off-main-thread@2.2.3': @@ -7250,11 +7707,43 @@ snapshots: '@tanstack/virtual-core@3.13.12': {} + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/runtime': 7.28.4 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.4.4 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.26))(@types/react@18.3.26)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.4 + '@testing-library/dom': 10.4.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.26 + '@types/react-dom': 18.3.7(@types/react@18.3.26) + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 optional: true + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.5 @@ -7280,6 +7769,11 @@ snapshots: dependencies: bcryptjs: 3.0.3 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/d3-array@3.2.2': {} '@types/d3-color@3.1.3': {} @@ -7304,6 +7798,8 @@ snapshots: '@types/d3-timer@3.0.2': {} + '@types/deep-eql@4.0.2': {} + '@types/estree@0.0.39': {} '@types/estree@1.0.8': {} @@ -7528,6 +8024,45 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@4.0.18': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + chai: 6.2.2 + tinyrainbow: 3.0.3 + + '@vitest/mocker@4.0.18(vite@6.4.1(@types/node@22.19.1)(jiti@1.21.7)(terser@5.44.1))': + dependencies: + '@vitest/spy': 4.0.18 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.4.1(@types/node@22.19.1)(jiti@1.21.7)(terser@5.44.1) + + '@vitest/pretty-format@4.0.18': + dependencies: + tinyrainbow: 3.0.3 + + '@vitest/runner@4.0.18': + dependencies: + '@vitest/utils': 4.0.18 + pathe: 2.0.3 + + '@vitest/snapshot@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.0.18': {} + + '@vitest/utils@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + tinyrainbow: 3.0.3 + '@xobotyi/scrollbar-width@1.9.5': {} acorn-jsx@5.3.2(acorn@8.15.0): @@ -7540,6 +8075,8 @@ snapshots: acorn@8.15.0: {} + agent-base@7.1.4: {} + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -7560,6 +8097,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + any-promise@1.3.0: {} anymatch@3.1.3: @@ -7575,6 +8114,12 @@ snapshots: dependencies: tslib: 2.8.1 + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + array-buffer-byte-length@1.0.2: dependencies: call-bound: 1.0.4 @@ -7625,6 +8170,8 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + assertion-error@2.0.1: {} + async-function@1.0.0: {} async@3.2.6: {} @@ -7693,6 +8240,10 @@ snapshots: bcryptjs@3.0.3: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + binary-extensions@2.3.0: {} blake3-wasm@2.1.5: {} @@ -7759,6 +8310,8 @@ snapshots: caniuse-lite@1.0.30001759: {} + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -7880,8 +8433,22 @@ snapshots: mdn-data: 2.0.14 source-map: 0.6.1 + css-tree@3.1.0: + dependencies: + mdn-data: 2.12.2 + source-map-js: 1.2.1 + + css.escape@1.5.1: {} + cssesc@3.0.0: {} + cssstyle@5.3.7: + dependencies: + '@asamuzakjp/css-color': 4.1.1 + '@csstools/css-syntax-patches-for-csstree': 1.0.26 + css-tree: 3.1.0 + lru-cache: 11.2.4 + csstype@3.1.3: {} d3-array@3.2.4: @@ -7922,6 +8489,11 @@ snapshots: d3-timer@3.0.1: {} + data-urls@6.0.1: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 15.1.0 + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -7954,6 +8526,8 @@ snapshots: decimal.js-light@2.5.1: {} + decimal.js@10.6.0: {} + decode-bmp@0.2.1: dependencies: '@canvas/image-data': 1.1.0 @@ -7985,6 +8559,8 @@ snapshots: delayed-stream@1.0.0: {} + dequal@2.0.3: {} + detect-libc@2.1.2: {} detect-node-es@1.1.0: {} @@ -8001,6 +8577,10 @@ snapshots: dependencies: esutils: 2.0.3 + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + dom-helpers@5.2.1: dependencies: '@babel/runtime': 7.28.4 @@ -8038,6 +8618,8 @@ snapshots: emoji-regex@8.0.0: {} + entities@6.0.1: {} + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -8109,6 +8691,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -8393,12 +8977,18 @@ snapshots: estree-walker@2.0.2: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} eventemitter3@4.0.7: {} exit-hook@2.2.1: {} + expect-type@1.3.0: {} + fast-deep-equal@3.1.3: {} fast-equals@5.3.3: {} @@ -8497,6 +9087,9 @@ snapshots: jsonfile: 6.2.0 universalify: 2.0.1 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -8614,6 +9207,26 @@ snapshots: hono@4.10.5: {} + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.10.0 + transitivePeerDependencies: + - '@noble/hashes' + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + hyphenate-style-name@1.1.0: {} ico-endec@0.1.6: {} @@ -8633,6 +9246,8 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@4.0.0: {} + inline-style-prefixer@7.0.1: dependencies: css-in-js-utils: 3.1.0 @@ -8739,6 +9354,8 @@ snapshots: is-path-inside@3.0.3: {} + is-potential-custom-element-name@1.0.1: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -8804,6 +9421,34 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@27.4.0: + dependencies: + '@acemir/cssom': 0.9.31 + '@asamuzakjp/dom-selector': 6.7.6 + '@exodus/bytes': 1.10.0 + cssstyle: 5.3.7 + data-urls: 6.0.1 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + parse5: 8.0.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 15.1.0 + ws: 8.19.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + - bufferutil + - supports-color + - utf-8-validate + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -8913,14 +9558,22 @@ snapshots: dependencies: react: 18.3.1 + lz-string@1.5.0: {} + magic-string@0.25.9: dependencies: sourcemap-codec: 1.4.8 + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + math-intrinsics@1.1.0: {} mdn-data@2.0.14: {} + mdn-data@2.12.2: {} + memoize-one@6.0.0: {} merge2@1.4.1: {} @@ -8938,6 +9591,8 @@ snapshots: mime@3.0.0: {} + min-indent@1.0.1: {} + miniflare@4.20251109.1: dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -9065,9 +9720,11 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 - openai@6.9.0(ws@8.18.0)(zod@4.1.12): + obug@2.1.1: {} + + openai@6.9.0(ws@8.19.0)(zod@4.1.12): optionalDependencies: - ws: 8.18.0 + ws: 8.19.0 zod: 4.1.12 optionator@0.9.4: @@ -9104,6 +9761,10 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse5@8.0.0: + dependencies: + entities: 6.0.1 + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -9131,6 +9792,14 @@ snapshots: pirates@4.0.7: {} + playwright-core@1.58.0: {} + + playwright@1.58.0: + dependencies: + playwright-core: 1.58.0 + optionalDependencies: + fsevents: 2.3.2 + possible-typed-array-names@1.1.0: {} postcss-import@15.1.0(postcss@8.5.6): @@ -9176,6 +9845,12 @@ snapshots: pretty-bytes@6.1.1: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -9218,6 +9893,8 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react-is@18.3.1: {} react-refresh@0.17.0: {} @@ -9358,6 +10035,11 @@ snapshots: tiny-invariant: 1.3.3 victory-vendor: 36.9.2 + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -9492,6 +10174,10 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.23.2: dependencies: loose-envify: 1.4.0 @@ -9598,6 +10284,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + simple-swizzle@0.2.4: dependencies: is-arrayish: 0.3.4 @@ -9634,6 +10322,8 @@ snapshots: dependencies: stackframe: 1.3.4 + stackback@0.0.2: {} + stackframe@1.3.4: {} stacktrace-gps@3.1.2: @@ -9647,6 +10337,8 @@ snapshots: stack-generator: 2.0.10 stacktrace-gps: 3.1.2 + std-env@3.10.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -9713,6 +10405,10 @@ snapshots: strip-comments@2.0.1: {} + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-json-comments@3.1.1: {} stylis@4.2.0: {} @@ -9741,6 +10437,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + symbol-tree@3.2.4: {} + tabbable@6.3.0: {} tailwind-merge@3.4.0: {} @@ -9807,11 +10505,23 @@ snapshots: tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} + + tinyexec@1.0.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyrainbow@3.0.3: {} + + tldts-core@7.0.19: {} + + tldts@7.0.19: + dependencies: + tldts-core: 7.0.19 + to-data-view@1.1.0: {} to-regex-range@5.0.1: @@ -9820,10 +10530,18 @@ snapshots: toggle-selection@1.0.6: {} + tough-cookie@6.0.0: + dependencies: + tldts: 7.0.19 + tr46@1.0.1: dependencies: punycode: 2.3.1 + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + tree-kill@1.2.2: {} ts-api-utils@2.1.0(typescript@5.8.3): @@ -10066,8 +10784,61 @@ snapshots: jiti: 1.21.7 terser: 5.44.1 + vitest@4.0.18(@types/node@22.19.1)(jiti@1.21.7)(jsdom@27.4.0)(terser@5.44.1): + dependencies: + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(vite@6.4.1(@types/node@22.19.1)(jiti@1.21.7)(terser@5.44.1)) + '@vitest/pretty-format': 4.0.18 + '@vitest/runner': 4.0.18 + '@vitest/snapshot': 4.0.18 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + es-module-lexer: 1.7.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 6.4.1(@types/node@22.19.1)(jiti@1.21.7)(terser@5.44.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.1 + jsdom: 27.4.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + webidl-conversions@4.0.2: {} + webidl-conversions@8.0.1: {} + + whatwg-mimetype@4.0.0: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@15.1.0: + dependencies: + tr46: 6.0.0 + webidl-conversions: 8.0.1 + whatwg-url@7.1.0: dependencies: lodash.sortby: 4.7.0 @@ -10119,6 +10890,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} workbox-background-sync@7.3.0: @@ -10267,6 +11043,12 @@ snapshots: ws@8.18.0: {} + ws@8.19.0: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + y18n@5.0.8: {} yallist@3.1.1: {} diff --git a/public/financemate-icon.png b/public/financemate-icon.png new file mode 100644 index 0000000..4ccfa31 Binary files /dev/null and b/public/financemate-icon.png differ diff --git a/public/focal-icon.svg b/public/focal-icon.svg index 72ba5e5..d411e3d 100644 --- a/public/focal-icon.svg +++ b/public/focal-icon.svg @@ -1 +1,39 @@ - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/maskable-icon-512x512.png b/public/maskable-icon-512x512.png new file mode 100644 index 0000000..759a96c Binary files /dev/null and b/public/maskable-icon-512x512.png differ diff --git a/public/pwa-192x192.png b/public/pwa-192x192.png new file mode 100644 index 0000000..759a96c Binary files /dev/null and b/public/pwa-192x192.png differ diff --git a/public/pwa-512x512.png b/public/pwa-512x512.png new file mode 100644 index 0000000..759a96c Binary files /dev/null and b/public/pwa-512x512.png differ diff --git a/scripts/verify-custom-categories.ts b/scripts/verify-custom-categories.ts new file mode 100644 index 0000000..7a8dca1 --- /dev/null +++ b/scripts/verify-custom-categories.ts @@ -0,0 +1,132 @@ + +import { randomUUID } from 'crypto'; + +const BASE_PORTS = [8787, 8788, 3000]; // Try Worker ports then potentially proxy +const EMAIL = `test_${Date.now()}@example.com`; +const PASSWORD = 'Password123!'; + +async function getBaseUrl() { + for (const port of BASE_PORTS) { + try { + const url = `http://127.0.0.1:${port}`; + // Try to hit a known endpoint to check connectivity + const res = await fetch(`${url}/api/auth/login`, { + method: 'POST', + body: JSON.stringify({}) + }).catch(e => e); + + if (res && !(res instanceof Error) && (res.status !== undefined)) { + console.log(`✅ Detected active API at port ${port}`); + return `${url}/api`; + } + } catch (e) { + // Ignore + } + } + console.error('❌ Could not find active API server on ports', BASE_PORTS); + process.exit(1); +} + +async function main() { + console.log('🚀 Starting Custom Categories Verification'); + + const API_BASE = await getBaseUrl(); + console.log(`Targeting API: ${API_BASE}`); + + // 1. Register/Login to get Token + console.log(`\n1. Registering user: ${EMAIL}`); + const authRes = await fetch(`${API_BASE}/auth/signup`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: EMAIL, password: PASSWORD }) + }); + + const authData = await authRes.json(); + if (!authRes.ok) { + console.error('❌ Registration failed:', authData); + process.exit(1); + } + + const token = authData.token || authData.data?.token; + + if (!token) { + console.error('❌ No token found in response:', authData); + process.exit(1); + } + + console.log('✅ User registered, token received.'); + + const headers = { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }; + + // 2. Add Custom Category + const categoryName = `Test_Cat_${randomUUID().substring(0, 8)}`; + console.log(`\n2. Creating category: ${categoryName}`); + + const createRes = await fetch(`${API_BASE}/categories`, { + method: 'POST', + headers, + body: JSON.stringify({ name: categoryName }) + }); + + const createData = await createRes.json(); + if (!createRes.ok) { + console.error('❌ Failed to create category:', createData); + process.exit(1); + } + console.log('✅ Category created:', createData); + const categoryId = createData.data.id; + + // 3. Fetch Categories + console.log('\n3. Fetching all categories to verify...'); + const fetchRes = await fetch(`${API_BASE}/categories`, { headers }); + const fetchData = await fetchRes.json(); + + if (!fetchRes.ok) { + console.error('❌ Failed to fetch categories:', fetchData); + process.exit(1); + } + + const responseData = fetchData.data; + const allCategories = [...responseData.defaults, ...responseData.custom.map((c: any) => c.name)]; + console.log(`Received ${allCategories.length} categories (${responseData.defaults.length} defaults + ${responseData.custom.length} custom).`); + + if (allCategories.includes(categoryName)) { + console.log('✅ Custom category found in list!'); + } else { + console.error(`❌ Custom category '${categoryName}' NOT found in list:`, allCategories); + process.exit(1); + } + + // 4. Delete Category + console.log(`\n4. Deleting category ID: ${categoryId}`); + const deleteRes = await fetch(`${API_BASE}/categories/${categoryId}`, { + method: 'DELETE', + headers + }); + + if (!deleteRes.ok) { + console.error('❌ Failed to delete category:', await deleteRes.json()); + process.exit(1); + } + console.log('✅ Category deleted.'); + + // 5. Verify Deletion + const refetchRes = await fetch(`${API_BASE}/categories`, { headers }); + const refetchData = await refetchRes.json(); + const refetchResponseData = refetchData.data; + const newCategories = [...refetchResponseData.defaults, ...refetchResponseData.custom.map((c: any) => c.name)]; + + if (!newCategories.includes(categoryName)) { + console.log('✅ Custom category successfully removed from list.'); + } else { + console.error('❌ Category still exists after deletion!'); + process.exit(1); + } + + console.log('\n🎉 Verification SUCCESS!'); +} + +main().catch(console.error); diff --git a/setup_ban_test.js b/setup_ban_test.js new file mode 100644 index 0000000..383d2f1 --- /dev/null +++ b/setup_ban_test.js @@ -0,0 +1,75 @@ + +const BASE_URL = 'http://127.0.0.1:8787/api'; + +async function test() { + console.log('Starting Ban Enforcement Verification...'); + + // Helper to parse JSON + const parse = async (res) => { + const text = await res.text(); + try { + return { ok: res.ok, status: res.status, data: JSON.parse(text) }; + } catch (e) { + return { ok: res.ok, status: res.status, error: text }; + } + }; + + // 1. Signup/Login + const email = `ban_test_${Date.now()}@example.com`; + console.log(`\n1. Creating user: ${email}`); + + let res = await fetch(`${BASE_URL}/auth/signup`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password: 'password123' }) + }); + + let { ok, data } = await parse(res); + let token = data?.data?.token || data?.token; + let userId = data?.data?.user?.id || data?.user?.id; + + if (!token) { + // Try login + res = await fetch(`${BASE_URL}/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password: 'password123' }) + }); + const loginRes = await parse(res); + token = loginRes.data?.data?.token; + userId = loginRes.data?.data?.user?.id; + } + + if (!token) { + console.error('❌ Failed to get token.'); + process.exit(1); + } + console.log('✅ Logged in.'); + + // 2. Verify Access (Pre-Ban) + console.log('\n2. Verifying Access (Pre-Ban)...'); + res = await fetch(`${BASE_URL}/auth/me`, { + headers: { 'Authorization': `Bearer ${token}` } + }); + if (res.status === 200) { + console.log('✅ Access granted.'); + } else { + console.error(`❌ Access denied unexpectedly: ${res.status}`); + process.exit(1); + } + + // 3. Save Context + const fs = await import('fs'); + fs.writeFileSync('temp_context.json', JSON.stringify({ token, userId })); + console.log(`\n3. Context saved to temp_context.json`); + console.log(`USER_ID_TO_BAN:${userId}`); + + // 4. Verify Access (Post-Ban) - this part will be run in a second pass or I'll just keep the process alive? + // Let's make this script simple: It does steps 1 & 2. + // Then I will manually run the SQL. + // Then I will run a script "verify_access.js" with the token. + + return token; +} + +test().catch(err => console.error(err)); diff --git a/src/App.tsx b/src/App.tsx index 162b5de..55f586d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import { HomePage } from "@/pages/HomePage"; import { ExpensesPage } from "@/pages/ExpensesPage"; import { SettingsPage } from "@/pages/SettingsPage"; import { AdminPage } from "@/pages/AdminPage"; +import { ReportsPage } from "@/pages/ReportsPage"; import { LoginPage } from "@/pages/LoginPage"; import { LandingPage } from "@/pages/LandingPage"; import VerifyEmailPage from "@/pages/VerifyEmailPage"; @@ -60,6 +61,10 @@ const router = createBrowserRouter([ path: "/settings", element: , }, + { + path: "/reports", + element: , + }, { path: "/admin", element: , diff --git a/src/components/ApiKeysManager.tsx b/src/components/ApiKeysManager.tsx new file mode 100644 index 0000000..26edac5 --- /dev/null +++ b/src/components/ApiKeysManager.tsx @@ -0,0 +1,224 @@ +import React, { useState } from "react"; +import { Copy, Plus, Trash2, Key, Check } from "lucide-react"; +import { format } from "date-fns"; +import { useApiKeys } from "@/hooks/useApiKeys"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Alert, + AlertDescription, + AlertTitle, +} from "@/components/ui/alert"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { toast } from "sonner"; + +export const ApiKeysManager: React.FC = () => { + const { keys, isLoading, isCreating, createKey, revokeKey, createdKey, clearCreatedKey } = useApiKeys(); + const [newKeyName, setNewKeyName] = useState(""); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [showKeyDialog, setShowKeyDialog] = useState(false); + + const handleCreateKey = async () => { + if (!newKeyName.trim()) return; + + const result = await createKey(newKeyName); + if (result.success) { + setIsDialogOpen(false); + setNewKeyName(""); + setShowKeyDialog(true); + toast.success("API Key Created", { + description: "Make sure to copy your key now. You won't be able to see it again.", + }); + } else { + toast.error("Failed to Create API Key", { + description: result.error, + }); + } + }; + + const handleRevokeKey = async (id: string, name: string) => { + if (confirm(`Are you sure you want to revoke the key "${name}"? This action cannot be undone.`)) { + const success = await revokeKey(id); + if (success) { + toast.success("API Key Revoked", { + description: `The key "${name}" has been revoked successfully.`, + }); + } else { + toast.error("Failed to Revoke API Key"); + } + } + }; + + const copyToClipboard = async (text: string) => { + try { + await navigator.clipboard.writeText(text); + toast.success("Copied to clipboard"); + } catch (err) { + toast.error("Failed to copy"); + } + }; + + return ( +
+
+
+

API Keys

+

+ Manage API keys for external access (e.g., Moltbot). +

+
+ + + + + + + Generate API Key + + Enter a name for this API key to identify it later (e.g., "Moltbot Integration"). + + +
+
+ + setNewKeyName(e.target.value)} + className="col-span-3" + placeholder="My API Key" + /> +
+
+ + + + +
+
+
+ + {isLoading ? ( +
Loading keys...
+ ) : keys.length === 0 ? ( +
+ +

No API Keys

+

+ Generate an API key to allow external applications to access your FinanceMate account. +

+
+ ) : ( +
+ + + + Name + Prefix + Created + Last Used + Actions + + + + {keys.map((key) => ( + + {key.name} + {key.prefix}... + {format(new Date(key.created_at), "MMM d, yyyy")} + + {key.last_used_at ? format(new Date(key.last_used_at), "MMM d, yyyy HH:mm") : "Never"} + + + + + + ))} + +
+
+ )} + + {/* Success Dialog showing the key */} + { + if (!open) { + setShowKeyDialog(false); + clearCreatedKey(); + } + }}> + + + + + API Key Generated Successfully + + + Please copy your API key now. For security reasons, you will not be able to see it again. + + +
+
+ + +
+ +
+ + + +
+
+
+ ); +}; diff --git a/src/components/AuthForm.test.tsx b/src/components/AuthForm.test.tsx new file mode 100644 index 0000000..47de03a --- /dev/null +++ b/src/components/AuthForm.test.tsx @@ -0,0 +1,73 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { AuthForm } from './AuthForm'; +import { BrowserRouter } from 'react-router-dom'; + +const renderWithRouter = (component: React.ReactNode) => { + return render({component}); +}; + +describe('AuthForm Component', () => { + it('should render login form by default', () => { + renderWithRouter( + + ); + expect(screen.getByRole('heading', { name: /welcome back/i })).toBeInTheDocument(); + expect(screen.getByText('Sign in')).toBeInTheDocument(); + }); + + it('should render signup form when mode is signup', () => { + renderWithRouter( + + ); + expect(screen.getByRole('heading', { name: /create an account/i })).toBeInTheDocument(); + expect(screen.getByText('Sign up')).toBeInTheDocument(); + }); + + it('should show validation errors for invalid input', async () => { + renderWithRouter( + + ); + + const submitButton = screen.getByRole('button', { name: 'Sign in' }); + fireEvent.click(submitButton); + + // Expect validation errors + await waitFor(() => { + expect(screen.getByText(/invalid email address/i)).toBeInTheDocument(); + expect(screen.getByText(/password must be at least 8 characters/i)).toBeInTheDocument(); + }); + }); + + it('should call onSubmit with correct data when form is valid', async () => { + const mockSubmit = vi.fn().mockResolvedValue({ success: true }); + renderWithRouter( + + ); + + fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'test@example.com' } }); + fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'password123' } }); + + fireEvent.click(screen.getByRole('button', { name: 'Sign in' })); + + await waitFor(() => { + expect(mockSubmit).toHaveBeenCalledWith('test@example.com', 'password123'); + }); + }); + + it('should display error message when submission fails', async () => { + const mockSubmit = vi.fn().mockResolvedValue({ success: false, error: 'Invalid credentials' }); + renderWithRouter( + + ); + + fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'test@example.com' } }); + fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'password123' } }); + + fireEvent.click(screen.getByRole('button', { name: 'Sign in' })); + + await waitFor(() => { + expect(screen.getByText('Invalid credentials')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/BudgetOverview.tsx b/src/components/BudgetOverview.tsx new file mode 100644 index 0000000..c7fc8f6 --- /dev/null +++ b/src/components/BudgetOverview.tsx @@ -0,0 +1,93 @@ +import React, { useEffect, useState } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Progress } from '@/components/ui/progress'; +import { budgetService, Budget } from '@/lib/budget-service'; +import { expenseService, Expense } from '@/lib/expense-service'; +import { Loader } from 'lucide-react'; +import { toast } from 'sonner'; +import { useUserSettings } from '@/hooks/useUserSettings'; + +export const BudgetOverview: React.FC = () => { + const { defaultCurrency } = useUserSettings(); + const [budgets, setBudgets] = useState([]); + const [expenses, setExpenses] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + loadData(); + }, []); + + const loadData = async () => { + setLoading(true); + try { + const [budgetRes, expenseRes] = await Promise.all([ + budgetService.getBudgets(), + expenseService.getExpenses() // Fetches all, filter locally + ]); + + if (budgetRes.success && budgetRes.data) { + setBudgets(budgetRes.data); + } + if (expenseRes.success && expenseRes.data) { + setExpenses(expenseRes.data); + } + } catch (e) { + console.error(e); + toast.error('Failed to load budget data'); + } finally { + setLoading(false); + } + }; + + if (loading) { + return
; + } + + if (budgets.length === 0) { + return null; // Don't show if no budgets set + } + + // Filter expenses for current month + const now = new Date(); + const currentMonth = now.getMonth(); + const currentYear = now.getFullYear(); + + const monthlyExpenses = expenses.filter(e => { + const d = new Date(e.date); + return d.getMonth() === currentMonth && d.getFullYear() === currentYear; + }); + + // Aggregate by category + const spentByCategory: Record = {}; + monthlyExpenses.forEach(e => { + const amount = e.currency === defaultCurrency ? e.total : e.total; // Simplified: assuming same currency or ignoring conversion for MVP + spentByCategory[e.category] = (spentByCategory[e.category] || 0) + amount; + }); + + return ( + + + Monthly Budgets + + + {budgets.map(budget => { + const spent = spentByCategory[budget.category] || 0; + const percentage = Math.min((spent / budget.limit_amount) * 100, 100); + const isOver = spent > budget.limit_amount; + + return ( +
+
+ {budget.category} + + {spent.toFixed(2)} / {budget.limit_amount.toFixed(2)} {budget.currency} + +
+ +
+ ); + })} +
+
+ ); +}; diff --git a/src/components/BudgetSettings.tsx b/src/components/BudgetSettings.tsx new file mode 100644 index 0000000..7f61e46 --- /dev/null +++ b/src/components/BudgetSettings.tsx @@ -0,0 +1,136 @@ +import React, { useEffect, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { budgetService, Budget } from '@/lib/budget-service'; +import { toast } from 'sonner'; +import { Loader, Save } from 'lucide-react'; +import { useUserSettings } from '@/hooks/useUserSettings'; + +import { useCategories } from '@/hooks/useCategories'; + +export const BudgetSettings: React.FC = () => { + const { defaultCurrency } = useUserSettings(); + const { categories } = useCategories(); + const [budgets, setBudgets] = useState>({}); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(null); // Category being saved + + // Local state for inputs to allow editing before saving + const [inputs, setInputs] = useState>({}); + const [thresholdInputs, setThresholdInputs] = useState>({}); + + useEffect(() => { + loadBudgets(); + }, []); + + const loadBudgets = async () => { + setLoading(true); + const response = await budgetService.getBudgets(); + if (response.success && response.data) { + const budgetMap: Record = {}; + const inputMap: Record = {}; + const thresholdMap: Record = {}; + response.data.forEach(b => { + budgetMap[b.category] = b; + inputMap[b.category] = b.limit_amount === 0 ? '' : b.limit_amount.toString(); + thresholdMap[b.category] = b.alert_threshold !== undefined ? b.alert_threshold.toString() : '80'; + }); + setBudgets(budgetMap); + setInputs(inputMap); + setThresholdInputs(thresholdMap); + } else { + toast.error('Failed to load budgets'); + } + setLoading(false); + }; + + const handleInputChange = (category: string, value: string) => { + setInputs(prev => ({ ...prev, [category]: value })); + }; + + const handleThresholdChange = (category: string, value: string) => { + setThresholdInputs(prev => ({ ...prev, [category]: value })); + }; + + const handleSave = async (category: string) => { + const amountStr = inputs[category]; + const thresholdStr = thresholdInputs[category] || '80'; + const amount = parseFloat(amountStr); + const threshold = parseFloat(thresholdStr); + + if (isNaN(amount) || amount < 0) { + toast.error('Invalid amount'); + return; + } + if (isNaN(threshold) || threshold < 0 || threshold > 100) { + toast.error('Invalid threshold (0-100)'); + return; + } + + setSaving(category); + const response = await budgetService.setBudget(category, amount, defaultCurrency, threshold); + + if (response.success && response.data) { + const newBudget = response.data; + setBudgets(prev => ({ ...prev, [category]: newBudget })); + toast.success(`Budget for ${category} updated`); + } else { + toast.error('Failed to save budget'); + } + setSaving(null); + }; + + if (loading) { + return
; + } + + return ( + + + Budget Limits + Set monthly spending limits and alert thresholds for each category. + + + {categories.map(category => ( +
+ +
+
+
+ handleInputChange(category, e.target.value)} + placeholder="Limit" + /> + {defaultCurrency} +
+
+ handleThresholdChange(category, e.target.value)} + placeholder="Alert %" + min="0" + max="100" + /> + % Alert +
+
+ +
+
+ ))} +
+
+ ); +}; diff --git a/src/components/CameraErrorDialog.tsx b/src/components/CameraErrorDialog.tsx new file mode 100644 index 0000000..0418538 --- /dev/null +++ b/src/components/CameraErrorDialog.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { Camera, ExternalLink } from 'lucide-react'; +import { getCameraInstructions, isPWA } from '@/lib/camera-utils'; + +interface CameraErrorDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + error: string; +} + +export const CameraErrorDialog: React.FC = ({ + open, + onOpenChange, + error, +}) => { + const instructions = getCameraInstructions(); + const isInPWA = isPWA(); + + return ( + + + +
+ + Camera Access Issue +
+ +

{error}

+ +
+

How to fix:

+
    +
  • {instructions}
  • + {!isInPWA && ( +
  • Make sure you're using HTTPS (secure connection)
  • + )} +
  • Check that no other app is using the camera
  • +
  • Try refreshing the page and allowing access again
  • +
+
+ + {!isInPWA && ( +
+

💡 Tip:

+

+ Install this app to your home screen for a better camera experience! +

+
+ )} +
+
+ + onOpenChange(false)}> + Got it + + +
+
+ ); +}; diff --git a/src/components/CategoryManager.tsx b/src/components/CategoryManager.tsx new file mode 100644 index 0000000..b8f4fee --- /dev/null +++ b/src/components/CategoryManager.tsx @@ -0,0 +1,365 @@ +import React, { useState, useEffect } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { useCategories } from '@/hooks/useCategories'; +import { categoryService, type CategoryStatistic } from '@/lib/category-service'; +import { toast } from 'sonner'; +import { Loader, Plus, Trash2, Edit2, Check, X, BarChart3 } from 'lucide-react'; +import { EXPENSE_CATEGORIES } from '@/constants'; +import { AVAILABLE_ICONS, getIconByName } from '@/lib/category-icons'; +import { CATEGORY_COLORS, getColorByValue } from '@/lib/category-colors'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Checkbox } from '@/components/ui/checkbox'; + +export const CategoryManager: React.FC = () => { + const { categories, customCategoriesWithIds, loading, refreshCategories, deleteCategory } = useCategories(); + const [newCategoryName, setNewCategoryName] = useState(''); + const [selectedIcon, setSelectedIcon] = useState('Sparkles'); + const [selectedColor, setSelectedColor] = useState(CATEGORY_COLORS[10].value); + const [adding, setAdding] = useState(false); + const [deletingId, setDeletingId] = useState(null); + const [editingId, setEditingId] = useState(null); + const [editName, setEditName] = useState(''); + const [editIcon, setEditIcon] = useState(''); + const [editColor, setEditColor] = useState(''); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [statistics, setStatistics] = useState([]); + const [showStatistics, setShowStatistics] = useState(false); + + useEffect(() => { + if (showStatistics) { + loadStatistics(); + } + }, [showStatistics]); + + const loadStatistics = async () => { + const { success, data } = await categoryService.getCategoryStatistics(); + if (success && data) { + setStatistics(data); + } + }; + + const handleAdd = async () => { + if (!newCategoryName.trim()) { + toast.error('Please enter a category name'); + return; + } + + setAdding(true); + const { success, error } = await categoryService.createCategory( + newCategoryName.trim(), + selectedIcon, + selectedColor + ); + + if (success) { + toast.success('Category added'); + setNewCategoryName(''); + setSelectedIcon('Sparkles'); + setSelectedColor(CATEGORY_COLORS[10].value); + await refreshCategories(); + } else { + toast.error(error || 'Failed to add category'); + } + setAdding(false); + }; + + const handleDelete = async (categoryId: string) => { + setDeletingId(categoryId); + await deleteCategory(categoryId); + setDeletingId(null); + }; + + const startEdit = (category: any) => { + setEditingId(category.id); + setEditName(category.name); + setEditIcon(category.icon || 'Sparkles'); + setEditColor(category.color || CATEGORY_COLORS[10].value); + }; + + const cancelEdit = () => { + setEditingId(null); + setEditName(''); + setEditIcon(''); + setEditColor(''); + }; + + const saveEdit = async (categoryId: string) => { + const { success, error } = await categoryService.updateCategory(categoryId, { + name: editName, + icon: editIcon, + color: editColor, + }); + + if (success) { + toast.success('Category updated'); + await refreshCategories(); + cancelEdit(); + } else { + toast.error(error || 'Failed to update category'); + } + }; + + const toggleSelection = (id: string) => { + const newSelected = new Set(selectedIds); + if (newSelected.has(id)) { + newSelected.delete(id); + } else { + newSelected.add(id); + } + setSelectedIds(newSelected); + }; + + const handleBulkDelete = async () => { + if (selectedIds.size === 0) return; + + const { success, error } = await categoryService.bulkDeleteCategories(Array.from(selectedIds)); + if (success) { + toast.success(`Deleted ${selectedIds.size} categories`); + setSelectedIds(new Set()); + await refreshCategories(); + } else { + toast.error(error || 'Failed to delete categories'); + } + }; + + if (loading) { + return ( + + + + + + ); + } + + const IconComponent = getIconByName(selectedIcon); + const colorObj = getColorByValue(selectedColor); + + return ( + + +
+
+ Custom Categories + + Add your own expense categories with icons and colors. + +
+ +
+
+ + {/* Statistics */} + {showStatistics && statistics.length > 0 && ( +
+

Category Usage

+
+ {statistics.map((stat) => ( +
+ + {stat.category} + {stat.isCustom && ( + + Custom + + )} + + {stat.count} expenses +
+ ))} +
+
+ )} + + {/* Add New Category */} +
+

Add New Category

+
+ setNewCategoryName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleAdd()} + disabled={adding} + className="flex-1" + /> + + + +
+
+ Preview: +
+
+ + {newCategoryName || 'Category Name'} +
+
+
+ + {/* Bulk Actions */} + {selectedIds.size > 0 && ( +
+ {selectedIds.size} selected + +
+ )} + + {/* Custom Categories List */} + {customCategoriesWithIds.length > 0 ? ( +
+

+ Your Custom Categories ({customCategoriesWithIds.length}) +

+
+ {customCategoriesWithIds.map((category) => { + const Icon = getIconByName(category.icon); + const color = getColorByValue(category.color); + const isEditing = editingId === category.id; + + return ( +
+ toggleSelection(category.id)} + /> + {isEditing ? ( + <> + setEditName(e.target.value)} + className="h-8 flex-1" + /> + + + + ) : ( + <> +
+ + {category.name} + + + + )} +
+ ); + })} +
+
+ ) : ( +

+ No custom categories yet. Add one above! +

+ )} + + {/* Default Categories Info */} +
+

+ Default Categories ({EXPENSE_CATEGORIES.length}) +

+
+ {EXPENSE_CATEGORIES.map((cat) => ( + + {cat} + + ))} +
+
+ + + ); +}; diff --git a/src/components/ExpenseForm.tsx b/src/components/ExpenseForm.tsx index 4e04653..0d5b834 100644 --- a/src/components/ExpenseForm.tsx +++ b/src/components/ExpenseForm.tsx @@ -11,6 +11,7 @@ import { } from "@/components/ui/select"; import { Trash2, Plus, ScanLine } from "lucide-react"; import type { ExpenseData } from "@/lib/expense-service"; +import { useCategories } from "@/hooks/useCategories"; interface ExpenseFormProps { value: ExpenseData; onChange: (data: ExpenseData) => void; @@ -28,6 +29,9 @@ export const ExpenseForm: React.FC = ({ onChangeRef.current = onChange; }, [value, onChange]); + // Use custom categories hook + const { categories } = useCategories(); + const handleFieldChange = useCallback( (field: keyof ExpenseData, fieldValue: any) => { onChangeRef.current({ ...valueRef.current, [field]: fieldValue }); @@ -43,7 +47,17 @@ export const ExpenseForm: React.FC = ({ ) => { const newLineItems = [...valueRef.current.lineItems]; newLineItems[index] = { ...newLineItems[index], [field]: fieldValue }; - onChangeRef.current({ ...valueRef.current, lineItems: newLineItems }); + + // Auto-calculate total from line items + const calculatedTotal = newLineItems.reduce((sum, item) => { + return sum + (item.quantity * item.price); + }, 0); + + onChangeRef.current({ + ...valueRef.current, + lineItems: newLineItems, + total: calculatedTotal + }); }, [] ); @@ -60,7 +74,17 @@ export const ExpenseForm: React.FC = ({ const newLineItems = valueRef.current.lineItems.filter( (_, i) => i !== index ); - onChangeRef.current({ ...valueRef.current, lineItems: newLineItems }); + + // Recalculate total after removing item + const calculatedTotal = newLineItems.reduce((sum, item) => { + return sum + (item.quantity * item.price); + }, 0); + + onChangeRef.current({ + ...valueRef.current, + lineItems: newLineItems, + total: calculatedTotal + }); }, []); return (
@@ -85,29 +109,44 @@ export const ExpenseForm: React.FC = ({ type="date" value={value.date} onChange={(e) => handleFieldChange("date", e.target.value)} - className="text-sm sm:text-base" + className="text-sm sm:text-base [&::-webkit-calendar-picker-indicator]:ml-auto" />
- handleFieldChange("total", parseFloat(e.target.value) || 0) - } - className="text-sm sm:text-base" + value={value.total === 0 ? "" : value.total.toFixed(2)} + onDoubleClick={(e) => { + // Remove readonly on double-click to allow manual editing + e.currentTarget.readOnly = false; + e.currentTarget.classList.remove('bg-muted/50', 'cursor-not-allowed'); + e.currentTarget.classList.add('bg-background'); + e.currentTarget.select(); + }} + onChange={(e) => { + // Allow manual editing when field is not readonly + if (!e.currentTarget.readOnly) { + handleFieldChange("total", parseFloat(e.target.value) || 0); + } + }} + onBlur={(e) => { + // Re-enable readonly and styling when focus is lost + e.currentTarget.readOnly = true; + e.currentTarget.classList.add('bg-muted/50', 'cursor-not-allowed'); + e.currentTarget.classList.remove('bg-background'); + }} + readOnly + className="text-sm sm:text-base bg-muted/50 cursor-not-allowed" />
- +
@@ -142,6 +180,47 @@ export const ExpenseForm: React.FC = ({ />
+ +
+
+
+ + handleFieldChange("isRecurring", e.target.checked) + } + className="h-4 w-4 rounded border-gray-300 text-focal-blue-600 focus:ring-focal-blue-500" + /> + +
+ + {value.isRecurring && ( +
+ +
+ )} +
+
+

Line Items

@@ -164,7 +243,7 @@ export const ExpenseForm: React.FC = ({ type="number" step="any" placeholder="Qty" - value={item.quantity} + value={item.quantity === 0 ? "" : item.quantity} onChange={(e) => handleLineItemChange( index, @@ -177,7 +256,7 @@ export const ExpenseForm: React.FC = ({ className="col-span-4 sm:col-span-3 text-xs sm:text-sm" type="number" placeholder="Price" - value={item.price} + value={item.price === 0 ? "" : item.price} onChange={(e) => handleLineItemChange( index, @@ -189,10 +268,10 @@ export const ExpenseForm: React.FC = ({ )) diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 5feaffe..e837e7f 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,136 +1,246 @@ import React, { useState } from "react"; -import { NavLink, Outlet, useLocation } from "react-router-dom"; -import { Settings, Home, Wallet, Github } from "lucide-react"; +import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom"; +import { + Settings, + Home, + Wallet, + LayoutDashboard, + TrendingUp, + Camera, + Menu, + X, + Plus +} from "lucide-react"; +import { useAuth } from "@/contexts/AuthContext"; import { cn } from "@/lib/utils"; import { ThemeToggle } from "./ThemeToggle"; import { Button } from "./ui/button"; import { UserMenu } from "./UserMenu"; -const Header: React.FC = () => { - const activeLinkClass = "text-focal-blue-500"; - const inactiveLinkClass = - "text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"; +import { NotificationBell } from "./NotificationBell"; +import { useIsMobile } from "@/hooks/use-mobile"; +import { AnimatePresence, motion } from "framer-motion"; + +// --- Components --- + +const FabInteraction: React.FC = () => { + const navigate = useNavigate(); return ( -
-
-
- navigate('/home')} // Assuming home opens camera by default or we scroll to it + className="fixed bottom-20 right-4 sm:bottom-8 sm:right-8 z-50 bg-primary text-primary-foreground p-4 rounded-2xl shadow-lg hover:shadow-xl transition-shadow flex items-center gap-2" + > + + Scan Receipt + + ); +}; + +const NavbarItem = ({ + to, + icon: Icon, + label, + hideLabel = false, +}: { + to: string; + icon: React.ElementType; + label: string; + hideLabel?: boolean; +}) => ( + + cn( + "flex flex-col items-center gap-1 p-2 rounded-xl transition-colors hover:bg-secondary/50 w-full group", + isActive && "bg-primary/10 text-primary" + ) + } + > + {({ isActive }) => ( + <> +
+ +
+ {!hideLabel && ( + + {label} + + )} + + )} +
+); + +const DesktopNavRail: React.FC = () => { + const { user } = useAuth(); + + return ( +
+ ); }; -const Footer: React.FC = () => { + +const MobileTopBar: React.FC = () => { return ( -
-
-
-

Built with ❤️ by The Creative Geek.

- -
-

- Configure your preferences and default currency in settings. -

+
+
+ Logo + FinanceMate
-
+
+ + +
+ ); }; -const BottomNav: React.FC = () => { + +const MobileBottomNav: React.FC = () => { const location = useLocation(); - const activeLinkClass = "text-focal-blue-500"; - const inactiveLinkClass = "text-gray-500 dark:text-gray-400"; - const getLinkClass = (path: string) => - cn( - "flex flex-col items-center gap-1 transition-colors duration-200 w-1/3", - location.pathname === path ? activeLinkClass : inactiveLinkClass - ); + const activeClass = "text-primary bg-primary/10"; + const inactiveClass = "text-muted-foreground"; + return ( -