From 464afb307493f1a7adbf41c07057c8bd9f832551 Mon Sep 17 00:00:00 2001 From: joeprogrammer88 Date: Fri, 27 Feb 2026 21:03:10 -0800 Subject: [PATCH] feat: Migrate app to TypeScript. Add Unit and UI tests - Add StorageManager class for managing tasks, projects, habits, finances, rewards, and user stats. - Define interfaces for Task, Project, Habit, FinanceItem, Reward, Purchase, and UserStats. - Implement methods for adding, updating, deleting, and retrieving tasks, projects, habits, expenses, revenue, charges, and rewards. - Include functionality for points management, daily streak tracking, and category management. - Add data export and import capabilities. - Create initial data structure in localStorage if none exists. - Implement unit tests for all functionalities using Vitest. - Configure TypeScript and Vitest for the project. --- .github/workflows/ci.yml | 43 + .github/workflows/deploy.yml | 71 + .gitignore | 14 + README.md | 59 +- e2e/app.spec.ts | 312 ++++ index.html | 6 +- js/app.d.ts.map | 1 + js/app.js | 583 ++----- js/storage.d.ts.map | 1 + js/storage.js | 240 ++- package-lock.json | 3187 ++++++++++++++++++++++++++++++++++ package.json | 35 + playwright.config.ts | 25 + service-worker.js | 4 +- src/app.ts | 1868 ++++++++++++++++++++ src/storage.ts | 763 ++++++++ tests/storage.test.ts | 494 ++++++ tsconfig.json | 19 + vitest.config.ts | 13 + 19 files changed, 7185 insertions(+), 553 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/deploy.yml create mode 100644 .gitignore create mode 100644 e2e/app.spec.ts create mode 100644 js/app.d.ts.map create mode 100644 js/storage.d.ts.map create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 playwright.config.ts create mode 100644 src/app.ts create mode 100644 src/storage.ts create mode 100644 tests/storage.test.ts create mode 100644 tsconfig.json create mode 100644 vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..89a6bb8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,43 @@ +name: CI + +on: + pull_request: + branches: + - main + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build TypeScript + run: npm run build + + - name: Run unit tests + run: npm test + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Run E2E tests + run: npm run test:e2e + + - name: Upload Playwright report on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..439850f --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,71 @@ +name: Deploy to GitHub Pages + +on: + workflow_run: + workflows: + - "Update Service Worker Cache Version" + types: + - completed + branches: + - main + +# Required for GitHub Pages deployment +permissions: + contents: read + pages: write + id-token: write + +# Only one Pages deployment at a time; do not cancel in-progress so we don't +# leave the site in a half-deployed state. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + deploy: + # Only deploy when the upstream workflow succeeded + if: ${{ github.event.workflow_run.conclusion == 'success' }} + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + # Use main explicitly so we pick up the bot-committed service worker + # cache version update that the upstream workflow may have just pushed. + ref: main + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build TypeScript + run: npm run build + + - name: Stage PWA files for deployment + run: | + mkdir -p dist + cp index.html manifest.json service-worker.js dist/ + cp icon-192.png icon-512.png dist/ + cp -r css dist/ + cp -r js dist/ + + - name: Configure GitHub Pages + uses: actions/configure-pages@v5 + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: dist/ + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..95c7104 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Dependencies +node_modules/ + +# TypeScript build output +js/*.js +js/*.js.map +js/*.d.ts + +# Playwright +test-results/ +playwright-report/ + +# Misc +.DS_Store diff --git a/README.md b/README.md index 07664be..5c3ac1f 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,64 @@ The app features a gamification system with points, levels, and daily streaks to - The app will appear in your applications menu - You can also launch it from the browser's apps page: `chrome://apps` -## 🚀 Getting Started +## �️ Development (TypeScript) + +This project is written in **TypeScript**. The source files live in `src/` and are compiled to `js/` for the browser. + +### Project Structure + +``` +src/ # TypeScript source files + storage.ts # Data layer – StorageManager class & interfaces + app.ts # UI layer – TaskManager class +js/ # Compiled JavaScript (generated – do not edit) +tests/ # Vitest unit tests +e2e/ # Playwright end-to-end tests +``` + +### Prerequisites + +- [Node.js](https://nodejs.org/) (v18 or later recommended) + +### Setup + +```bash +npm install # Install dependencies +npx playwright install # Install Playwright browsers (first time only) +``` + +### Build + +```bash +npm run build # Compile TypeScript → js/ +npm run build:watch # Compile in watch mode (auto-rebuild on save) +``` + +### Run Locally + +```bash +npm run serve # Start a local dev server at http://localhost:3000 +``` + +### Testing + +```bash +npm test # Run unit tests (Vitest) +npm run test:watch # Run unit tests in watch mode +npm run test:e2e # Run end-to-end tests (Playwright) +npm run test:e2e:ui # Run E2E tests with the Playwright UI +``` + +### Workflow + +1. Edit TypeScript files in `src/`. +2. Run `npm run build` (or `build:watch`) to compile. +3. Open the app via `npm run serve` or the `index.html` file. +4. Run `npm test` to verify unit tests and `npm run test:e2e` for browser tests. + +> **Note:** Never edit files in `js/` directly — they are overwritten on every build. + +## �🚀 Getting Started 1. **First Launch** - The app will load with empty data diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts new file mode 100644 index 0000000..49c382c --- /dev/null +++ b/e2e/app.spec.ts @@ -0,0 +1,312 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Task Manager App', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + // Clear localStorage to start fresh + await page.evaluate(() => localStorage.clear()); + await page.reload(); + await page.waitForSelector('.header'); + }); + + // ======================== + // Page Load & Navigation + // ======================== + test.describe('page load and navigation', () => { + test('should load the app and display header', async ({ page }) => { + await expect(page.locator('h1')).toContainText('Task Manager'); + }); + + test('should show dashboard tab by default', async ({ page }) => { + await expect(page.locator('#dashboard-tab')).toBeVisible(); + }); + + test('should display header stats', async ({ page }) => { + await expect(page.locator('#totalPoints')).toHaveText('0'); + await expect(page.locator('#userLevel')).toHaveText('1'); + await expect(page.locator('#dailyStreak')).toHaveText('0'); + }); + + test('should switch to tasks tab', async ({ page }) => { + await page.click('[data-tab="tasks"]'); + await expect(page.locator('#tasks-tab')).toBeVisible(); + await expect(page.locator('#tasks-tab h2')).toHaveText('Tasks'); + }); + + test('should switch to projects tab', async ({ page }) => { + await page.click('[data-tab="projects"]'); + await expect(page.locator('#projects-tab')).toBeVisible(); + }); + + test('should switch to habits tab', async ({ page }) => { + await page.click('[data-tab="habits"]'); + await expect(page.locator('#habits-tab')).toBeVisible(); + }); + + test('should switch to finances tab', async ({ page }) => { + await page.click('[data-tab="finances"]'); + await expect(page.locator('#finances-tab')).toBeVisible(); + }); + + test('should switch to settings tab', async ({ page }) => { + await page.click('[data-tab="settings"]'); + await expect(page.locator('#settings-tab')).toBeVisible(); + }); + }); + + // ======================== + // Task CRUD + // ======================== + test.describe('task management', () => { + test.beforeEach(async ({ page }) => { + await page.click('[data-tab="tasks"]'); + }); + + test('should show empty state initially', async ({ page }) => { + await expect(page.locator('#taskList .empty-state')).toBeVisible(); + }); + + test('should open and close task modal', async ({ page }) => { + await page.click('#addTaskBtn'); + await expect(page.locator('#taskModal')).toHaveClass(/active/); + await page.click('#cancelTaskBtn'); + await expect(page.locator('#taskModal')).not.toHaveClass(/active/); + }); + + test('should create a new task', async ({ page }) => { + await page.click('#addTaskBtn'); + await page.fill('#taskTitle', 'Buy groceries'); + await page.fill('#taskDescription', 'Milk, eggs, bread'); + await page.selectOption('#taskPriority', 'high'); + await page.click('#taskForm button[type="submit"]'); + + await expect(page.locator('#taskModal')).not.toHaveClass(/active/); + await expect(page.locator('.task-item')).toBeVisible(); + await expect(page.locator('.task-title')).toContainText('Buy groceries'); + }); + + test('should complete a task via checkbox', async ({ page }) => { + // Create task first + await page.click('#addTaskBtn'); + await page.fill('#taskTitle', 'Test completion'); + await page.click('#taskForm button[type="submit"]'); + + // Complete it + await page.click('.task-checkbox'); + await expect(page.locator('.task-item')).toHaveClass(/completed/); + }); + + test('should delete a task', async ({ page }) => { + // Create task + await page.click('#addTaskBtn'); + await page.fill('#taskTitle', 'To be deleted'); + await page.click('#taskForm button[type="submit"]'); + + // Open task for editing + await page.click('.task-item .task-content'); + + // Confirm deletion + page.on('dialog', dialog => dialog.accept()); + await page.click('#deleteTaskBtn'); + + await expect(page.locator('#taskList .empty-state')).toBeVisible(); + }); + + test('should filter tasks by search', async ({ page }) => { + // Create two tasks + await page.click('#addTaskBtn'); + await page.fill('#taskTitle', 'Apples'); + await page.click('#taskForm button[type="submit"]'); + + await page.click('#addTaskBtn'); + await page.fill('#taskTitle', 'Oranges'); + await page.click('#taskForm button[type="submit"]'); + + // Search + await page.fill('#searchTasks', 'Apples'); + await expect(page.locator('.task-title')).toHaveCount(1); + await expect(page.locator('.task-title')).toContainText('Apples'); + }); + }); + + // ======================== + // Project CRUD + // ======================== + test.describe('project management', () => { + test.beforeEach(async ({ page }) => { + await page.click('[data-tab="projects"]'); + }); + + test('should show empty state initially', async ({ page }) => { + await expect(page.locator('#projectsList .empty-state')).toBeVisible(); + }); + + test('should create a new project', async ({ page }) => { + await page.click('#addProjectBtn'); + await page.fill('#projectName', 'Website Redesign'); + await page.fill('#projectDescription', 'Redo the company website'); + await page.selectOption('#projectColor', 'green'); + await page.click('#projectForm button[type="submit"]'); + + await expect(page.locator('#projectModal')).not.toHaveClass(/active/); + await expect(page.locator('.project-card')).toBeVisible(); + await expect(page.locator('.project-title')).toContainText('Website Redesign'); + }); + }); + + // ======================== + // Habit CRUD + // ======================== + test.describe('habit management', () => { + test.beforeEach(async ({ page }) => { + await page.click('[data-tab="habits"]'); + }); + + test('should show empty state initially', async ({ page }) => { + await expect(page.locator('#habitsList .empty-state')).toBeVisible(); + }); + + test('should create a new habit', async ({ page }) => { + await page.click('#addHabitBtn'); + await page.fill('#habitName', 'Exercise'); + await page.fill('#habitDescription', '30 min workout'); + await page.click('#habitForm button[type="submit"]'); + + await expect(page.locator('#habitModal')).not.toHaveClass(/active/); + await expect(page.locator('.habit-card')).toBeVisible(); + await expect(page.locator('.habit-name')).toContainText('Exercise'); + }); + + test('should complete a habit', async ({ page }) => { + // Create habit + await page.click('#addHabitBtn'); + await page.fill('#habitName', 'Meditate'); + await page.click('#habitForm button[type="submit"]'); + + // Complete it + await page.click('.habit-checkbox'); + await expect(page.locator('.habit-checkbox')).toContainText('Done for Today'); + }); + }); + + // ======================== + // Finance CRUD + // ======================== + test.describe('finance management', () => { + test.beforeEach(async ({ page }) => { + await page.click('[data-tab="finances"]'); + }); + + test('should show financial summary', async ({ page }) => { + await expect(page.locator('#totalIncome')).toHaveText('$0.00'); + await expect(page.locator('#totalExpenses')).toHaveText('$0.00'); + await expect(page.locator('#netBalance')).toHaveText('$0.00'); + }); + + test('should add an expense', async ({ page }) => { + await page.click('#addExpenseBtn'); + await page.fill('#financeDescription', 'Coffee'); + await page.fill('#financeAmount', '4.50'); + await page.click('#financeForm button[type="submit"]'); + + await expect(page.locator('#financeModal')).not.toHaveClass(/active/); + await expect(page.locator('.finance-item')).toBeVisible(); + }); + + test('should add revenue', async ({ page }) => { + await page.click('#addRevenueBtn'); + await page.fill('#financeDescription', 'Salary'); + await page.fill('#financeAmount', '5000'); + await page.click('#financeForm button[type="submit"]'); + + // Switch to revenue tab to see it + await page.click('[data-finance-tab="revenue"]'); + await expect(page.locator('.finance-item')).toBeVisible(); + }); + + test('should switch between finance tabs', async ({ page }) => { + await page.click('[data-finance-tab="revenue"]'); + await expect(page.locator('#revenue-content')).toHaveClass(/active/); + + await page.click('[data-finance-tab="charges"]'); + await expect(page.locator('#charges-content')).toHaveClass(/active/); + + await page.click('[data-finance-tab="expenses"]'); + await expect(page.locator('#expenses-content')).toHaveClass(/active/); + }); + }); + + // ======================== + // Rewards Shop + // ======================== + test.describe('rewards shop', () => { + test.beforeEach(async ({ page }) => { + await page.click('[data-tab="shop"]'); + }); + + test('should show points display', async ({ page }) => { + await expect(page.locator('#shopPointsDisplay')).toHaveText('0'); + }); + + test('should create a reward', async ({ page }) => { + await page.click('#addRewardBtn'); + await page.fill('#rewardName', 'Movie Night'); + await page.fill('#rewardDescription', 'Watch a movie'); + await page.fill('#rewardCost', '100'); + await page.click('#rewardForm button[type="submit"]'); + + await expect(page.locator('#rewardModal')).not.toHaveClass(/active/); + await expect(page.locator('.project-card')).toBeVisible(); + await expect(page.locator('.project-title')).toContainText('Movie Night'); + }); + }); + + // ======================== + // Date Navigation + // ======================== + test.describe('date navigation', () => { + test('should show today by default', async ({ page }) => { + await expect(page.locator('#selectedDateDisplay')).toContainText('Today'); + }); + + test('should navigate to previous day', async ({ page }) => { + await page.click('#prevDayBtn'); + await expect(page.locator('#selectedDateDisplay')).not.toContainText('Today'); + await expect(page.locator('#goTodayBtn')).toBeVisible(); + }); + + test('should return to today via button', async ({ page }) => { + await page.click('#prevDayBtn'); + await page.click('#goTodayBtn'); + await expect(page.locator('#selectedDateDisplay')).toContainText('Today'); + }); + }); + + // ======================== + // Settings + // ======================== + test.describe('settings', () => { + test.beforeEach(async ({ page }) => { + await page.click('[data-tab="settings"]'); + }); + + test('should display settings page', async ({ page }) => { + await expect(page.locator('#settings-tab h2')).toHaveText('Settings'); + }); + + test('should show data version', async ({ page }) => { + await expect(page.locator('#dataVersion')).toHaveText('1.0.0'); + }); + + test('should update tasks per level', async ({ page }) => { + page.on('dialog', dialog => dialog.accept()); + await page.fill('#tasksPerLevel', '50'); + await page.click('#saveTasksPerLevel'); + // Verify it persisted + await page.click('[data-tab="dashboard"]'); + await page.click('[data-tab="settings"]'); + const value = await page.inputValue('#tasksPerLevel'); + expect(value).toBe('50'); + }); + }); +}); diff --git a/index.html b/index.html index 9db39ee..77fb9a2 100644 --- a/index.html +++ b/index.html @@ -16,8 +16,8 @@ - - + +
@@ -675,7 +675,7 @@

Data Version

About

-

Task Manager Pro - Your complete task and habit management system.

+

Task Manager - Your complete task and habit management system.

All data is stored locally in your browser.

diff --git a/js/app.d.ts.map b/js/app.d.ts.map new file mode 100644 index 0000000..75efb2f --- /dev/null +++ b/js/app.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"AAIA,OAAO,EAA4C,IAAI,EAAE,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AASlG,UAAU,mBAAoB,SAAQ,WAAW;IAC7C,aAAa,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,cAAM,WAAW;IACb,oBAAoB,EAAE,MAAM,GAAG,IAAI,CAAQ;IAC3C,uBAAuB,EAAE,MAAM,GAAG,IAAI,CAAQ;IAC9C,qBAAqB,EAAE,MAAM,GAAG,IAAI,CAAQ;IAC5C,uBAAuB,EAAE,MAAM,GAAG,IAAI,CAAQ;IAC9C,yBAAyB,EAAE,MAAM,GAAG,IAAI,CAAQ;IAChD,sBAAsB,EAAE,MAAM,GAAG,IAAI,CAAQ;IAC7C,YAAY,EAAE,IAAI,CAAc;IAChC,aAAa,EAAE,OAAO,CAAS;IAC/B,MAAM,EAAE,MAAM,EAAE,CAqBd;;IAMF,IAAI,IAAI,IAAI;IAWZ,mBAAmB,IAAI,IAAI;IAwI3B,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;IAiChC,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;IAgBvC,eAAe,IAAI,IAAI;IAyEvB,oBAAoB,IAAI,IAAI;IA6C5B,qBAAqB,IAAI,IAAI;IA8B7B,cAAc,IAAI,IAAI;IAOtB,WAAW,IAAI,IAAI;IAKnB,WAAW,IAAI,IAAI;IAyFnB,cAAc,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM;IA4BlC,oBAAoB,IAAI,IAAI;IAU5B,aAAa,CAAC,MAAM,GAAE,MAAM,GAAG,IAAW,GAAG,IAAI;IA6DjD,cAAc,IAAI,IAAI;IAUtB,kBAAkB,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI;IA4B5C,mBAAmB,IAAI,IAAI;IAO3B,QAAQ,CAAC,CAAC,EAAE,KAAK,GAAG,IAAI;IA4CxB,UAAU,IAAI,IAAI;IAWlB,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI;IAwBhC,oBAAoB,CAAC,QAAQ,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,IAAI;IAW1D,uBAAuB,CAAC,aAAa,EAAE,IAAI,GAAG,IAAI;IA6DlD,cAAc,IAAI,IAAI;IAkBtB,iBAAiB,CAAC,OAAO,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,MAAM;IA8BtG,gBAAgB,CAAC,SAAS,GAAE,MAAM,GAAG,IAAW,GAAG,IAAI;IAsBvD,iBAAiB,IAAI,IAAI;IAKzB,WAAW,CAAC,CAAC,EAAE,KAAK,GAAG,IAAI;IAoB3B,aAAa,IAAI,IAAI;IAUrB,sBAAsB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;IAuB/C,uBAAuB,IAAI,IAAI;IAK/B,qBAAqB,IAAI,IAAI;IAM7B,wBAAwB,CAAC,KAAK,EAAE,IAAI,EAAE,GAAG,IAAI;IA2C7C,YAAY,IAAI,IAAI;IA4BpB,eAAe,CAAC,KAAK,EAAE,KAAK,GAAG,MAAM;IAmDrC,cAAc,CAAC,OAAO,GAAE,MAAM,GAAG,IAAW,GAAG,IAAI;IAkDnD,eAAe,IAAI,IAAI;IAKvB,SAAS,CAAC,CAAC,EAAE,KAAK,GAAG,IAAI;IA0BzB,WAAW,IAAI,IAAI;IAUnB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;IAgBpC,eAAe,IAAI,IAAI;IAkBvB,gBAAgB,IAAI,IAAI;IAIxB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI;IAShC,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IA4BxC,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI;IAavD,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAmBrC,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IASrC,wBAAwB,IAAI,IAAI;IAchC,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM;IAI/B,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,WAAW,GAAG,IAAI;IAmBvD,gBAAgB,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,gBAAgB,EAAE,IAAI,EAAE,WAAW,GAAG,IAAI;IAiBnF,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAMtC,uBAAuB,IAAI,IAAI;IAkB/B,2BAA2B,IAAI,IAAI;IASnC,kBAAkB,IAAI,IAAI;IAK1B,mBAAmB,IAAI;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE;IAM7D,wBAAwB,CAAC,KAAK,EAAE,WAAW,EAAE,GAAG,mBAAmB,EAAE;IAyBrE,cAAc,IAAI,IAAI;IAOtB,oBAAoB,IAAI,IAAI;IAe5B,cAAc,IAAI,IAAI;IAKtB,aAAa,IAAI,IAAI;IAKrB,aAAa,IAAI,IAAI;IAKrB,iBAAiB,CAAC,KAAK,EAAE,mBAAmB,EAAE,EAAE,WAAW,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,GAAE,OAAe,GAAG,IAAI;IAmCnH,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,GAAE,MAAM,GAAG,IAAW,GAAG,IAAI;IAwCrE,iBAAiB,IAAI,IAAI;IAMzB,WAAW,CAAC,CAAC,EAAE,KAAK,GAAG,IAAI;IAsC3B,aAAa,IAAI,IAAI;IAmBrB,UAAU,IAAI,IAAI;IA+DlB,eAAe,CAAC,QAAQ,GAAE,MAAM,GAAG,IAAW,GAAG,IAAI;IAyBrD,gBAAgB,IAAI,IAAI;IAKxB,UAAU,CAAC,CAAC,EAAE,KAAK,GAAG,IAAI;IAoB1B,YAAY,IAAI,IAAI;IAUpB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI;IAoBtC,cAAc,IAAI,IAAI;IAMtB,YAAY,IAAI,IAAI;IAQpB,oBAAoB,IAAI,IAAI;IAiB5B,iBAAiB,IAAI,IAAI;IAczB,UAAU,IAAI,IAAI;IAalB,UAAU,CAAC,CAAC,EAAE,KAAK,GAAG,IAAI;IAuB1B,qBAAqB,IAAI,IAAI;IAQ7B,MAAM,IAAI,IAAI;IAYd,kBAAkB,IAAI,MAAM;IAI5B,mBAAmB,IAAI,OAAO;IAI9B,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI;IAsBjC,iBAAiB,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM;IAMrC,mBAAmB,IAAI,IAAI;CAqB9B;AAOD,OAAO,EAAE,WAAW,EAAE,CAAC"} \ No newline at end of file diff --git a/js/app.js b/js/app.js index f481a00..928150a 100644 --- a/js/app.js +++ b/js/app.js @@ -1,7 +1,7 @@ // ======================== // Main Application Logic // ======================== - +import { storage, STORAGE_VERSION } from './storage.js'; class TaskManager { constructor() { this.currentEditingTaskId = null; @@ -10,7 +10,6 @@ class TaskManager { this.currentEditingFinanceId = null; this.currentEditingFinanceType = null; this.currentEditingRewardId = null; - this.currentEditingFinanceType = null; this.selectedDate = new Date(); this.tasksExpanded = false; this.emojis = [ @@ -37,7 +36,6 @@ class TaskManager { ]; this.init(); } - init() { this.setupEventListeners(); this.initializeFinanceDateFilter(); @@ -45,7 +43,6 @@ class TaskManager { this.render(); this.processRecurringTasks(); } - // ======================== // Event Listeners Setup // ======================== @@ -54,7 +51,6 @@ class TaskManager { document.querySelectorAll('.nav-tab').forEach(tab => { tab.addEventListener('click', (e) => this.switchTab(e.target.dataset.tab)); }); - // Tasks section document.getElementById('addTaskBtn').addEventListener('click', () => this.openTaskModal()); document.getElementById('toggleTaskViewBtn').addEventListener('click', () => this.toggleTaskView()); @@ -68,7 +64,6 @@ class TaskManager { document.getElementById('categoryFilter').addEventListener('change', () => this.filterTasks()); document.getElementById('statusFilter').addEventListener('change', () => this.filterTasks()); document.getElementById('searchTasks').addEventListener('input', () => this.filterTasks()); - // Projects section document.getElementById('addProjectBtn').addEventListener('click', () => this.openProjectModal()); document.getElementById('projectForm').addEventListener('submit', (e) => this.saveProject(e)); @@ -76,7 +71,6 @@ class TaskManager { document.getElementById('deleteProjectBtn').addEventListener('click', () => this.deleteProject()); document.getElementById('closeProjectDetailBtn').addEventListener('click', () => this.closeProjectDetailModal()); document.getElementById('editProjectFromDetailBtn').addEventListener('click', () => this.editProjectFromDetail()); - // Habits section document.getElementById('addHabitBtn').addEventListener('click', () => this.openHabitModal()); document.getElementById('habitForm').addEventListener('submit', (e) => this.saveHabit(e)); @@ -87,7 +81,6 @@ class TaskManager { document.getElementById('habitCategory').addEventListener('change', (e) => this.handleCategoryChange('habit', e.target.value)); document.getElementById('habitCategorySave').addEventListener('click', () => this.handleAddCategory('habit')); document.getElementById('habitCategoryCancel').addEventListener('click', () => this.cancelAddCategory('habit')); - // Finances section document.getElementById('addExpenseBtn').addEventListener('click', () => this.openFinanceModal('expense')); document.getElementById('addRevenueBtn').addEventListener('click', () => this.openFinanceModal('revenue')); @@ -98,28 +91,23 @@ class TaskManager { document.getElementById('financeCategory').addEventListener('change', (e) => this.handleCategoryChange('finance', e.target.value)); document.getElementById('financeCategorySave').addEventListener('click', () => this.handleAddCategory('finance')); document.getElementById('financeCategoryCancel').addEventListener('click', () => this.cancelAddCategory('finance')); - document.querySelectorAll('.finance-tab').forEach(tab => { tab.addEventListener('click', (e) => this.switchFinanceTab(e.target.dataset.financeTab)); }); - // Finance filters document.getElementById('filterFinancesBtn').addEventListener('click', () => this.renderFinances()); document.getElementById('resetFinanceFilterBtn').addEventListener('click', () => this.resetFinanceFilter()); - // Shop section document.getElementById('addRewardBtn').addEventListener('click', () => this.openRewardModal()); document.getElementById('rewardForm').addEventListener('submit', (e) => this.saveReward(e)); document.getElementById('cancelRewardBtn').addEventListener('click', () => this.closeRewardModal()); document.getElementById('deleteRewardBtn').addEventListener('click', () => this.deleteReward()); - // Modal close buttons document.querySelectorAll('.close-btn').forEach(btn => { btn.addEventListener('click', (e) => { e.target.closest('.modal').classList.remove('active'); }); }); - // Settings document.getElementById('saveTasksPerLevel').addEventListener('click', () => this.saveTasksPerLevel()); document.getElementById('exportBtn').addEventListener('click', () => this.exportData()); @@ -130,7 +118,6 @@ class TaskManager { location.reload(); } }); - // Modal backdrop click document.querySelectorAll('.modal').forEach(modal => { modal.addEventListener('click', (e) => { @@ -139,7 +126,6 @@ class TaskManager { } }); }); - // Hamburger menu toggle document.getElementById('hamburgerMenu').addEventListener('click', () => { const navTabs = document.getElementById('navTabs'); @@ -147,7 +133,6 @@ class TaskManager { navTabs.classList.toggle('show'); hamburger.classList.toggle('active'); }); - // Close mobile menu when a tab is clicked document.querySelectorAll('.nav-tab').forEach(tab => { tab.addEventListener('click', () => { @@ -159,7 +144,6 @@ class TaskManager { } }); }); - // Close mobile menu when clicking outside document.addEventListener('click', (e) => { if (window.innerWidth <= 768) { @@ -171,7 +155,6 @@ class TaskManager { } } }); - // Date navigator document.getElementById('prevDayBtn').addEventListener('click', () => this.navigateDate(-1)); document.getElementById('nextDayBtn').addEventListener('click', () => this.navigateDate(1)); @@ -181,7 +164,6 @@ class TaskManager { this.render(); }); } - // ======================== // Tab Navigation // ======================== @@ -190,95 +172,86 @@ class TaskManager { document.querySelectorAll('.tab-content').forEach(tab => { tab.classList.remove('active'); }); - // Remove active from all nav tabs document.querySelectorAll('.nav-tab').forEach(tab => { tab.classList.remove('active'); }); - // Show selected tab document.getElementById(`${tabName}-tab`).classList.add('active'); document.querySelector(`[data-tab="${tabName}"]`).classList.add('active'); - // Re-render based on tab if (tabName === 'dashboard') { this.renderDashboard(); - } else if (tabName === 'tasks') { + } + else if (tabName === 'tasks') { this.renderTasks(); - } else if (tabName === 'projects') { + } + else if (tabName === 'projects') { this.renderProjects(); - } else if (tabName === 'habits') { + } + else if (tabName === 'habits') { this.renderHabits(); - } else if (tabName === 'finances') { + } + else if (tabName === 'finances') { this.renderFinances(); - } else if (tabName === 'shop') { + } + else if (tabName === 'shop') { this.renderShop(); - } else if (tabName === 'settings') { + } + else if (tabName === 'settings') { this.renderSettings(); } } - switchFinanceTab(tabName) { document.querySelectorAll('.finance-content').forEach(tab => { tab.classList.remove('active'); }); - document.querySelectorAll('.finance-tab').forEach(tab => { tab.classList.remove('active'); }); - document.getElementById(`${tabName}-content`).classList.add('active'); document.querySelector(`[data-finance-tab="${tabName}"]`).classList.add('active'); } - // ======================== // Dashboard Rendering // ======================== renderDashboard() { const today = this.getSelectedDateStr(); const tasks = storage.getTasks(); - const projects = storage.getProjects(); const habits = storage.getHabits(); - const expenses = storage.getExpenses(); - const revenue = storage.getRevenue(); const userStats = storage.getUserStats(); - const logs = storage.getData().dailyHabitLogs || []; - // Update header stats - document.getElementById('totalPoints').textContent = userStats.totalPoints; - document.getElementById('userLevel').textContent = userStats.level; - document.getElementById('dailyStreak').textContent = userStats.dailyStreak; - + document.getElementById('totalPoints').textContent = String(userStats.totalPoints); + document.getElementById('userLevel').textContent = String(userStats.level); + document.getElementById('dailyStreak').textContent = String(userStats.dailyStreak); // Update level progress const settings = storage.getSettings(); const completedTasksCount = tasks.filter(t => t.completed).length; const tasksInCurrentLevel = completedTasksCount % settings.tasksPerLevel; const tasksNeeded = settings.tasksPerLevel; document.getElementById('levelProgress').textContent = `${tasksInCurrentLevel}/${tasksNeeded} tasks`; - // Selected day's overview const todayTasks = tasks.filter(t => t.dueDate === today && !t.completed); const completedToday = tasks.filter(t => t.completedDate === today); const todayDay = this.selectedDate.getDay(); - // Find incomplete habits for selected day const incompleteHabits = habits.filter(habit => { const isValidDay = !habit.daysOfWeek || habit.daysOfWeek.includes(todayDay); - if (!isValidDay) return false; + if (!isValidDay) + return false; const todaysCompletions = storage.countHabitCompletionsForDate(habit.id, today); const targetGoal = habit.targetGoal || 1; return todaysCompletions < targetGoal; }); - - document.getElementById('todayTasksCount').textContent = todayTasks.length; - document.getElementById('completedTodayCount').textContent = completedToday.length; - document.getElementById('incompleteHabitsCount').textContent = incompleteHabits.length; - + document.getElementById('todayTasksCount').textContent = String(todayTasks.length); + document.getElementById('completedTodayCount').textContent = String(completedToday.length); + document.getElementById('incompleteHabitsCount').textContent = String(incompleteHabits.length); // Render incomplete habits list const incompleteHabitsList = document.getElementById('incompleteHabitsList'); if (incompleteHabits.length === 0) { incompleteHabitsList.innerHTML = '

All habits completed for today! 🎉

'; - } else { + } + else { incompleteHabitsList.innerHTML = incompleteHabits.map(habit => { const todaysCompletions = storage.countHabitCompletionsForDate(habit.id, today); const targetGoal = habit.targetGoal || 1; @@ -295,7 +268,6 @@ class TaskManager { `; }).join(''); - // Add click handlers to navigate to habits document.querySelectorAll('.habit-item').forEach(item => { item.addEventListener('click', () => { @@ -303,18 +275,14 @@ class TaskManager { }); }); } - // Recent activity this.renderRecentActivity(); - // Active projects this.renderProjectsSummary(); } - renderRecentActivity() { const data = storage.getData(); const activities = []; - // Collect activities from tasks data.tasks.filter(t => t.completedDate).forEach(t => { activities.push({ @@ -324,7 +292,6 @@ class TaskManager { icon: '✓' }); }); - // Collect activities from habits if (data.dailyHabitLogs) { data.dailyHabitLogs.slice(-10).forEach(log => { @@ -339,14 +306,13 @@ class TaskManager { } }); } - // Sort by date - activities.sort((a, b) => new Date(b.date) - new Date(a.date)); - + activities.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); const activityList = document.getElementById('recentActivity'); if (activities.length === 0) { activityList.innerHTML = '

No activity yet. Start completing tasks!

'; - } else { + } + else { activityList.innerHTML = activities.slice(0, 5).map(activity => `
${activity.icon} ${activity.message}
@@ -355,22 +321,18 @@ class TaskManager { `).join(''); } } - renderProjectsSummary() { const projects = storage.getProjects(); const tasks = storage.getTasks(); const container = document.getElementById('projectsSummary'); - if (projects.length === 0) { container.innerHTML = '

No active projects. Create one to organize your tasks!

'; return; } - container.innerHTML = projects.map(project => { const projectTasks = tasks.filter(t => t.projectId === project.id); const completedTasks = projectTasks.filter(t => t.completed); const percentage = projectTasks.length === 0 ? 0 : Math.round((completedTasks.length / projectTasks.length) * 100); - return `
${project.name}
@@ -382,7 +344,6 @@ class TaskManager { `; }).join(''); } - // ======================== // Tasks Management // ======================== @@ -392,12 +353,10 @@ class TaskManager { btn.textContent = this.tasksExpanded ? '⊟ Collapse Details' : '⊞ Expand Details'; document.getElementById('taskList').classList.toggle('expanded', this.tasksExpanded); } - renderTasks() { this.updateCategoryFilter(); this.filterTasks(); } - filterTasks() { const tasks = storage.getTasks(); const categoryFilter = document.getElementById('categoryFilter').value; @@ -405,77 +364,61 @@ class TaskManager { const searchTerm = document.getElementById('searchTasks').value.toLowerCase(); const today = this.getSelectedDateStr(); const filtersActive = categoryFilter || statusFilter || searchTerm; - let filtered = tasks.filter(task => { // Category filter - if (categoryFilter && task.category !== categoryFilter) return false; - + if (categoryFilter && task.category !== categoryFilter) + return false; // Status filter if (statusFilter) { - if (statusFilter === 'completed' && !task.completed) return false; - if (statusFilter === 'pending' && task.completed) return false; - if (statusFilter === 'overdue' && (!task.dueDate || task.completed || task.dueDate >= today)) return false; + if (statusFilter === 'completed' && !task.completed) + return false; + if (statusFilter === 'pending' && task.completed) + return false; + if (statusFilter === 'overdue' && (!task.dueDate || task.completed || task.dueDate >= today)) + return false; } - // Search filter - if (searchTerm && !task.title.toLowerCase().includes(searchTerm) && !task.description?.toLowerCase().includes(searchTerm)) return false; - + if (searchTerm && !task.title.toLowerCase().includes(searchTerm) && !task.description?.toLowerCase().includes(searchTerm)) + return false; return true; }); - const taskList = document.getElementById('taskList'); if (filtered.length === 0) { taskList.innerHTML = '

No tasks found.

'; return; } - let html = ''; - if (filtersActive) { - // When filters are active, render a flat list (existing behaviour) html = filtered.map(task => this.renderTaskItem(task)).join(''); - } else { - // Split into "Tasks Due Today" and "Upcoming Tasks" + } + else { const priorityOrder = { high: 0, medium: 1, low: 2 }; - - // Include overdue, today's tasks, and undated tasks (no future date = actionable now). - // Completed tasks are shown here so users can see today's history in context. - const dueToday = filtered.filter(task => - !task.dueDate || task.dueDate <= today - ); - - // Upcoming: future-dated, not yet completed, non-daily (daily tasks regenerate daily - // so a future-dated daily is just the next occurrence — not useful to show as "upcoming"). + const dueToday = filtered.filter(task => !task.dueDate || task.dueDate <= today); const upcoming = filtered - .filter(task => - !task.completed && - task.dueDate && - task.dueDate > today && - task.repeatType !== 'daily' - ) + .filter(task => !task.completed && + task.dueDate && + task.dueDate > today && + task.repeatType !== 'daily') .sort((a, b) => { - if (a.dueDate < b.dueDate) return -1; - if (a.dueDate > b.dueDate) return 1; - return (priorityOrder[a.priority] ?? 1) - (priorityOrder[b.priority] ?? 1); - }); - + if (a.dueDate < b.dueDate) + return -1; + if (a.dueDate > b.dueDate) + return 1; + return (priorityOrder[a.priority] ?? 1) - (priorityOrder[b.priority] ?? 1); + }); if (dueToday.length > 0) { html += `

Tasks Due Today

`; html += dueToday.map(task => this.renderTaskItem(task)).join(''); } - if (upcoming.length > 0) { html += `

Upcoming Tasks

`; html += upcoming.map(task => this.renderTaskItem(task)).join(''); } - if (!html) { html = '

No tasks found.

'; } } - taskList.innerHTML = html; - // Add event listeners to task items document.querySelectorAll('.task-checkbox').forEach(checkbox => { checkbox.addEventListener('change', (e) => { @@ -483,7 +426,6 @@ class TaskManager { this.toggleTask(taskId); }); }); - document.querySelectorAll('.task-item').forEach(item => { item.addEventListener('click', (e) => { if (!e.target.classList.contains('task-checkbox')) { @@ -492,16 +434,15 @@ class TaskManager { }); }); } - renderTaskItem(task) { const today = storage.formatDate(new Date()); let status = 'pending'; if (task.completed) { status = 'completed'; - } else if (task.dueDate && task.dueDate < today) { + } + else if (task.dueDate && task.dueDate < today) { status = 'overdue'; } - return `
@@ -520,34 +461,28 @@ class TaskManager {
`; } - updateCategoryFilter() { const tasks = storage.getTasks(); - const categories = [...new Set(tasks.map(t => t.category).filter(c => c))]; + const categories = [...new Set(tasks.map(t => t.category).filter((c) => !!c))]; const select = document.getElementById('categoryFilter'); const currentValue = select.value; - select.innerHTML = '' + + select.innerHTML = '' + categories.map(cat => ``).join(''); select.value = currentValue; } - openTaskModal(taskId = null) { this.currentEditingTaskId = taskId; const modal = document.getElementById('taskModal'); const form = document.getElementById('taskForm'); const deleteBtn = document.getElementById('deleteTaskBtn'); - form.reset(); deleteBtn.style.display = 'none'; - // Update project dropdown this.updateProjectSelect(); - // Clear all task day checkboxes document.querySelectorAll('input[name="taskDay"]').forEach(checkbox => { checkbox.checked = false; }); - if (taskId) { const task = storage.getTasks().find(t => t.id === taskId); if (task) { @@ -556,18 +491,16 @@ class TaskManager { document.getElementById('taskDueDate').value = task.dueDate || ''; document.getElementById('taskCategory').value = task.category || ''; document.getElementById('taskPriority').value = task.priority || 'medium'; - document.getElementById('taskPoints').value = task.points || 10; + document.getElementById('taskPoints').value = String(task.points || 10); document.getElementById('taskRepeatType').value = task.repeatType || 'none'; document.getElementById('taskProject').value = task.projectId || ''; - document.getElementById('taskRepeatUnit').value = task.repeatUnit || 1; - + document.getElementById('taskRepeatUnit').value = String(task.repeatUnit || 1); if (task.repeatType === 'custom') { - document.getElementById('customRepeatDays').value = task.customRepeatDays || ''; + document.getElementById('customRepeatDays').value = String(task.customRepeatDays || ''); } if (task.repeatType === 'movable') { - document.getElementById('movableRepeatDays').value = task.movableRepeatDays || ''; + document.getElementById('movableRepeatDays').value = String(task.movableRepeatDays || ''); } - // Load daysOfWeek if available if (task.daysOfWeek && Array.isArray(task.daysOfWeek)) { task.daysOfWeek.forEach(day => { @@ -577,45 +510,36 @@ class TaskManager { } }); } - deleteBtn.style.display = 'block'; - this.updateRepeatTypeUI(task.repeatType); } - } else { - document.getElementById('taskRepeatUnit').value = 1; } - + else { + document.getElementById('taskRepeatUnit').value = '1'; + } // Load categories this.loadCategoryDropdown('task'); - modal.classList.add('active'); } - closeTaskModal() { document.getElementById('taskModal').classList.remove('active'); this.currentEditingTaskId = null; - // If project detail modal is open, refresh it if (document.getElementById('projectDetailModal').classList.contains('active')) { this.openProjectDetailModal(this.currentEditingProjectId); } } - updateRepeatTypeUI(repeatType) { const customGroup = document.getElementById('customRepeatGroup'); const movableGroup = document.getElementById('movableRepeatGroup'); const repeatUnitGroup = document.getElementById('repeatUnitGroup'); const taskDaysGroup = document.getElementById('taskDaysGroup'); const repeatUnitLabel = document.getElementById('repeatUnitLabel'); - customGroup.style.display = repeatType === 'custom' ? 'block' : 'none'; movableGroup.style.display = repeatType === 'movable' ? 'block' : 'none'; - // Show repeat unit for daily, weekly, monthly, yearly if (['daily', 'weekly', 'monthly', 'yearly'].includes(repeatType)) { repeatUnitGroup.style.display = 'block'; - // Update label based on repeat type const labels = { daily: 'day(s)', weekly: 'week(s)', @@ -623,28 +547,24 @@ class TaskManager { yearly: 'year(s)' }; repeatUnitLabel.textContent = labels[repeatType] || 'unit(s)'; - } else { + } + else { repeatUnitGroup.style.display = 'none'; } - // Show days selector for weekly and daily taskDaysGroup.style.display = (['weekly', 'daily'].includes(repeatType)) ? 'block' : 'none'; } - updateProjectSelect() { const projects = storage.getProjects(); const select = document.getElementById('taskProject'); - select.innerHTML = '' + + select.innerHTML = '' + projects.map(p => ``).join(''); } - saveTask(e) { e.preventDefault(); - // Get selected days for weekly/daily tasks const selectedDays = Array.from(document.querySelectorAll('input[name="taskDay"]:checked')) .map(checkbox => parseInt(checkbox.value)); - const task = { title: document.getElementById('taskTitle').value, description: document.getElementById('taskDescription').value, @@ -655,7 +575,6 @@ class TaskManager { repeatType: document.getElementById('taskRepeatType').value, projectId: document.getElementById('taskProject').value || null }; - // Add repeatUnit for daily, weekly, monthly, yearly tasks if (['daily', 'weekly', 'monthly', 'yearly'].includes(task.repeatType)) { task.repeatUnit = parseInt(document.getElementById('taskRepeatUnit').value) || 1; @@ -663,25 +582,22 @@ class TaskManager { task.daysOfWeek = selectedDays; } } - if (task.repeatType === 'custom') { task.customRepeatDays = parseInt(document.getElementById('customRepeatDays').value); } if (task.repeatType === 'movable') { task.movableRepeatDays = parseInt(document.getElementById('movableRepeatDays').value); } - if (this.currentEditingTaskId) { storage.updateTask(this.currentEditingTaskId, task); - } else { + } + else { storage.addTask(task); } - this.closeTaskModal(); this.renderTasks(); this.renderProjects(); } - deleteTask() { if (this.currentEditingTaskId) { if (confirm('Are you sure you want to delete this task?')) { @@ -692,7 +608,6 @@ class TaskManager { } } } - toggleTask(taskId) { const task = storage.getTasks().find(t => t.id === taskId); if (task) { @@ -705,19 +620,18 @@ class TaskManager { if (task.repeatType !== 'none') { this.createNextRecurringTask(task); } - } else { + } + else { // If uncompleting, also recalculate level task.completedDate = null; } storage.updateTask(taskId, task); - storage.updateLevel(); // Recalculate level based on completed tasks + storage.updateLevel(); this.renderTasks(); this.renderDashboard(); this.renderProjects(); } } - - // Returns the next date strictly after `fromDate` whose day-of-week is in `days` (0=Sun..6=Sat). nextOccurrenceOfDays(fromDate, days) { const next = new Date(fromDate); for (let i = 1; i <= 7; i++) { @@ -728,30 +642,28 @@ class TaskManager { } return next; } - createNextRecurringTask(completedTask) { const [year, month, day] = completedTask.completedDate.split('-').map(Number); const completionDate = new Date(year, month - 1, day); const nextDueDate = new Date(completionDate); const hasDaysOfWeek = completedTask.daysOfWeek && completedTask.daysOfWeek.length > 0; - switch (completedTask.repeatType) { case 'daily': if (hasDaysOfWeek) { - // Advance to the next allowed day of week after completion const next = this.nextOccurrenceOfDays(completionDate, completedTask.daysOfWeek); nextDueDate.setTime(next.getTime()); - } else { + } + else { nextDueDate.setDate(nextDueDate.getDate() + (completedTask.repeatUnit || 1)); } break; case 'weekly': if (hasDaysOfWeek) { - // Find the next occurrence of any specified day, then skip (repeatUnit-1) more weeks const next = this.nextOccurrenceOfDays(completionDate, completedTask.daysOfWeek); next.setDate(next.getDate() + 7 * ((completedTask.repeatUnit || 1) - 1)); nextDueDate.setTime(next.getTime()); - } else { + } + else { nextDueDate.setDate(nextDueDate.getDate() + 7 * (completedTask.repeatUnit || 1)); } break; @@ -770,7 +682,6 @@ class TaskManager { default: return; } - const newTask = { title: completedTask.title, description: completedTask.description, @@ -785,36 +696,29 @@ class TaskManager { daysOfWeek: completedTask.daysOfWeek, dueDate: storage.formatDate(nextDueDate) }; - storage.addTask(newTask); } - // ======================== // Projects Management // ======================== renderProjects() { const projects = storage.getProjects(); const container = document.getElementById('projectsList'); - if (projects.length === 0) { container.innerHTML = '

No projects yet. Create one to organize your tasks!

'; return; } - container.innerHTML = projects.map(project => this.renderProjectCard(project)).join(''); - document.querySelectorAll('.project-card').forEach(card => { - card.addEventListener('click', (e) => { + card.addEventListener('click', () => { this.openProjectDetailModal(card.dataset.projectId); }); }); } - renderProjectCard(project) { const tasks = storage.getTasks().filter(t => t.projectId === project.id); const completed = tasks.filter(t => t.completed).length; const percentage = tasks.length === 0 ? 0 : Math.round((completed / tasks.length) * 100); - return `
${project.name}
@@ -839,16 +743,13 @@ class TaskManager {
`; } - openProjectModal(projectId = null) { this.currentEditingProjectId = projectId; const modal = document.getElementById('projectModal'); const form = document.getElementById('projectForm'); const deleteBtn = document.getElementById('deleteProjectBtn'); - form.reset(); deleteBtn.style.display = 'none'; - if (projectId) { const project = storage.getProjects().find(p => p.id === projectId); if (project) { @@ -858,35 +759,29 @@ class TaskManager { deleteBtn.style.display = 'block'; } } - modal.classList.add('active'); } - closeProjectModal() { document.getElementById('projectModal').classList.remove('active'); this.currentEditingProjectId = null; } - saveProject(e) { e.preventDefault(); - const project = { name: document.getElementById('projectName').value, description: document.getElementById('projectDescription').value, color: document.getElementById('projectColor').value }; - if (this.currentEditingProjectId) { storage.updateProject(this.currentEditingProjectId, project); - } else { + } + else { storage.addProject(project); } - this.closeProjectModal(); this.renderProjects(); this.updateProjectSelect(); } - deleteProject() { if (this.currentEditingProjectId) { if (confirm('Are you sure you want to delete this project? This will not delete the tasks in it.')) { @@ -896,53 +791,38 @@ class TaskManager { } } } - openProjectDetailModal(projectId) { const project = storage.getProjects().find(p => p.id === projectId); - if (!project) return; - + if (!project) + return; this.currentEditingProjectId = projectId; const modal = document.getElementById('projectDetailModal'); - - // Set project info document.getElementById('projectDetailTitle').textContent = project.name; document.getElementById('projectDetailDescription').textContent = project.description || 'No description'; - - // Get related tasks const tasks = storage.getTasks().filter(t => t.projectId === projectId); const completed = tasks.filter(t => t.completed).length; const percentage = tasks.length === 0 ? 0 : Math.round((completed / tasks.length) * 100); - - // Update stats - document.getElementById('projectDetailTotalTasks').textContent = tasks.length; - document.getElementById('projectDetailCompletedTasks').textContent = completed; + document.getElementById('projectDetailTotalTasks').textContent = String(tasks.length); + document.getElementById('projectDetailCompletedTasks').textContent = String(completed); document.getElementById('projectDetailProgress').textContent = percentage + '%'; - - // Render task list this.renderProjectDetailTasks(tasks); - modal.classList.add('active'); } - closeProjectDetailModal() { document.getElementById('projectDetailModal').classList.remove('active'); this.currentEditingProjectId = null; } - editProjectFromDetail() { const projectId = this.currentEditingProjectId; this.closeProjectDetailModal(); this.openProjectModal(projectId); } - renderProjectDetailTasks(tasks) { const container = document.getElementById('projectDetailTaskList'); - if (tasks.length === 0) { container.innerHTML = '

No tasks in this project yet.

'; return; } - container.innerHTML = tasks.map(task => `
@@ -956,17 +836,13 @@ class TaskManager {
`).join(''); - - // Add click handlers for tasks document.querySelectorAll('#projectDetailTaskList .task-item').forEach(item => { const checkbox = item.querySelector('.task-checkbox'); checkbox.addEventListener('click', (e) => { e.stopPropagation(); this.toggleTask(item.dataset.taskId); - // Refresh the detail view this.openProjectDetailModal(this.currentEditingProjectId); }); - item.addEventListener('click', (e) => { if (!e.target.classList.contains('task-checkbox')) { this.closeProjectDetailModal(); @@ -976,21 +852,17 @@ class TaskManager { }); }); } - // ======================== // Habits Management // ======================== renderHabits() { const habits = storage.getHabits(); const container = document.getElementById('habitsList'); - if (habits.length === 0) { container.innerHTML = '

No habits yet. Create daily habits to build streaks!

'; return; } - container.innerHTML = habits.map(habit => this.renderHabitCard(habit)).join(''); - document.querySelectorAll('.habit-card').forEach(card => { card.addEventListener('click', (e) => { if (!e.target.classList.contains('habit-checkbox')) { @@ -998,7 +870,6 @@ class TaskManager { } }); }); - document.querySelectorAll('.habit-checkbox').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); @@ -1007,25 +878,20 @@ class TaskManager { }); }); } - renderHabitCard(habit) { const selectedDayOfWeek = this.selectedDate.getDay(); const isValidDay = !habit.daysOfWeek || habit.daysOfWeek.includes(selectedDayOfWeek); const selectedDateStr = this.getSelectedDateStr(); const isPastDay = !this.isSelectedDateToday(); - - // Count completions for the selected date const todaysCompletions = storage.countHabitCompletionsForDate(habit.id, selectedDateStr); const targetGoal = habit.targetGoal || 1; const percentage = Math.min(100, Math.round((todaysCompletions / targetGoal) * 100)); const isComplete = todaysCompletions >= targetGoal; - const btnLabel = !isValidDay ? '✗ Not Scheduled' : isComplete ? (isPastDay ? '✓ Logged' : '✓ Done for Today') : (isPastDay ? '+ Log Past Day' : '+ Complete'); - return `
${habit.icon}
@@ -1059,23 +925,18 @@ class TaskManager {
`; } - openHabitModal(habitId = null) { this.currentEditingHabitId = habitId; const modal = document.getElementById('habitModal'); const form = document.getElementById('habitForm'); const deleteBtn = document.getElementById('deleteHabitBtn'); - form.reset(); deleteBtn.style.display = 'none'; document.getElementById('habitIcon').value = '⭐'; document.getElementById('habitIconDisplay').textContent = '⭐'; - - // Clear all day checkboxes document.querySelectorAll('input[name="habitDay"]').forEach(checkbox => { checkbox.checked = false; }); - if (habitId) { const habit = storage.getHabits().find(h => h.id === habitId); if (habit) { @@ -1084,11 +945,9 @@ class TaskManager { document.getElementById('habitIcon').value = habit.icon || '⭐'; document.getElementById('habitIconDisplay').textContent = habit.icon || '⭐'; document.getElementById('habitCategory').value = habit.category || ''; - document.getElementById('habitPoints').value = habit.points || 5; - document.getElementById('habitTargetGoal').value = habit.targetGoal || 1; + document.getElementById('habitPoints').value = String(habit.points || 5); + document.getElementById('habitTargetGoal').value = String(habit.targetGoal || 1); deleteBtn.style.display = 'block'; - - // Load daysOfWeek if available if (habit.daysOfWeek && Array.isArray(habit.daysOfWeek)) { habit.daysOfWeek.forEach(day => { const checkbox = document.querySelector(`input[name="habitDay"][value="${day}"]`); @@ -1096,38 +955,30 @@ class TaskManager { checkbox.checked = true; } }); - } else { - // Default to all days if not set + } + else { document.querySelectorAll('input[name="habitDay"]').forEach(checkbox => { checkbox.checked = true; }); } } - } else { - // Default to all days for new habits + } + else { document.querySelectorAll('input[name="habitDay"]').forEach(checkbox => { checkbox.checked = true; }); } - - // Load categories this.loadCategoryDropdown('habit'); - modal.classList.add('active'); } - closeHabitModal() { document.getElementById('habitModal').classList.remove('active'); this.currentEditingHabitId = null; } - saveHabit(e) { e.preventDefault(); - - // Get selected days const selectedDays = Array.from(document.querySelectorAll('input[name="habitDay"]:checked')) .map(checkbox => parseInt(checkbox.value)); - const habit = { name: document.getElementById('habitName').value, description: document.getElementById('habitDescription').value, @@ -1137,17 +988,15 @@ class TaskManager { targetGoal: parseInt(document.getElementById('habitTargetGoal').value) || 1, daysOfWeek: selectedDays.length > 0 ? selectedDays : [0, 1, 2, 3, 4, 5, 6] }; - if (this.currentEditingHabitId) { storage.updateHabit(this.currentEditingHabitId, habit); - } else { + } + else { storage.addHabit(habit); } - this.closeHabitModal(); this.renderHabits(); } - deleteHabit() { if (this.currentEditingHabitId) { if (confirm('Are you sure you want to delete this habit?')) { @@ -1157,13 +1006,11 @@ class TaskManager { } } } - completeHabit(habitId) { const habit = storage.getHabits().find(h => h.id === habitId); if (habit) { const selectedDayOfWeek = this.selectedDate.getDay(); const isValidDay = !habit.daysOfWeek || habit.daysOfWeek.includes(selectedDayOfWeek); - if (isValidDay) { storage.logHabitCompletion(habitId, this.selectedDate); storage.addPoints(habit.points, 'habits'); @@ -1173,92 +1020,65 @@ class TaskManager { } } } - openEmojiPicker() { const modal = document.getElementById('emojiModal'); const emojiGrid = document.getElementById('emojiGrid'); - - // Clear and populate emoji grid - emojiGrid.innerHTML = this.emojis.map(emoji => - `` - ).join(''); - - // Add event listeners to emoji buttons + emojiGrid.innerHTML = this.emojis.map(emoji => ``).join(''); document.querySelectorAll('.emoji-btn').forEach(btn => { btn.addEventListener('click', (e) => { e.preventDefault(); this.selectEmoji(e.target.dataset.emoji); }); }); - modal.classList.add('active'); } - closeEmojiPicker() { document.getElementById('emojiModal').classList.remove('active'); } - selectEmoji(emoji) { document.getElementById('habitIcon').value = emoji; document.getElementById('habitIconDisplay').textContent = emoji; this.closeEmojiPicker(); } - // ======================== // Category Management // ======================== loadCategoryDropdown(type) { const select = document.getElementById(`${type}Category`); const categories = storage.getCategories(); - - // Preserve current selection const currentValue = select.value; - - // Store the special options (empty and add new) const emptyOption = document.createElement('option'); emptyOption.value = ''; emptyOption.textContent = 'Select category...'; - const addNewOption = document.createElement('option'); addNewOption.value = '__add_new__'; addNewOption.textContent = '+ Add New Category'; - - // Clear and rebuild select.innerHTML = ''; select.appendChild(emptyOption); - - // Add categories categories.forEach(cat => { const option = document.createElement('option'); option.value = cat; option.textContent = cat; select.appendChild(option); }); - - // Add the add new option at the end select.appendChild(addNewOption); - - // Restore selection select.value = currentValue; } - handleCategoryChange(type, value) { const inputDiv = document.getElementById(`${type}CategoryInput`); const textInput = document.getElementById(`${type}CategoryText`); - if (value === '__add_new__') { inputDiv.style.display = 'block'; textInput.value = ''; textInput.focus(); - } else { + } + else { inputDiv.style.display = 'none'; } } - handleAddCategory(type) { const textInput = document.getElementById(`${type}CategoryText`); const categoryName = textInput.value.trim(); - if (categoryName) { if (storage.addCategory(categoryName)) { this.loadCategoryDropdown(type); @@ -1266,27 +1086,27 @@ class TaskManager { select.value = categoryName; document.getElementById(`${type}CategoryInput`).style.display = 'none'; textInput.value = ''; - } else { + } + else { alert('This category already exists!'); } - } else { + } + else { alert('Please enter a category name'); } } - cancelAddCategory(type) { document.getElementById(`${type}CategoryInput`).style.display = 'none'; document.getElementById(`${type}CategoryText`).value = ''; - const select = document.getElementById(`${type}Category`); - select.value = ''; + document.getElementById(`${type}Category`).value = ''; } - // ======================== // Settings Category Management // ======================== renderCategoryManagement() { const list = document.getElementById('categoryList'); - if (!list) return; + if (!list) + return; const categories = storage.getCategories(); list.innerHTML = categories.length === 0 ? '
  • No categories yet.
  • ' @@ -1297,11 +1117,9 @@ class TaskManager { `).join(''); } - escapeHtml(str) { return String(str).replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); } - startEditCategory(name, btn) { const li = btn.closest('li'); const nameSpan = li.querySelector('.category-name'); @@ -1320,8 +1138,7 @@ class TaskManager { btn.after(cancelBtn); input.focus(); } - - saveEditCategory(oldName, input, btn) { + saveEditCategory(oldName, input, _btn) { const newName = input.value.trim(); if (!newName) { alert('Please enter a category name.'); @@ -1337,13 +1154,12 @@ class TaskManager { } this.renderCategoryManagement(); } - deleteCategoryItem(name) { - if (!confirm(`Delete category "${name}"? All related items will have their category cleared.`)) return; + if (!confirm(`Delete category "${name}"? All related items will have their category cleared.`)) + return; storage.deleteCategory(name); this.renderCategoryManagement(); } - addCategoryFromSettings() { const input = document.getElementById('newCategoryText'); const name = input.value.trim(); @@ -1358,101 +1174,84 @@ class TaskManager { input.value = ''; this.renderCategoryManagement(); } - - + // ======================== + // Finance Management // ======================== initializeFinanceDateFilter() { const today = new Date(); const firstDay = new Date(today.getFullYear(), today.getMonth(), 1); const lastDay = new Date(today.getFullYear(), today.getMonth() + 1, 0); - document.getElementById('financeStartDate').valueAsDate = firstDay; document.getElementById('financeEndDate').valueAsDate = lastDay; } - resetFinanceFilter() { this.initializeFinanceDateFilter(); this.renderFinances(); } - getFinanceDateRange() { const startDate = document.getElementById('financeStartDate').value; const endDate = document.getElementById('financeEndDate').value; return { startDate, endDate }; } - filterFinanceItemsByDate(items) { const { startDate, endDate } = this.getFinanceDateRange(); - if (!startDate && !endDate) { return items.map(item => ({ ...item, monthlyAmount: item.amount })); } - return items.filter(item => { - if (!item.date) return false; - - // Recurring items apply to every period on or after their start date + if (!item.date) + return false; if (item.recurring === 'yearly' || item.recurring === 'monthly') { - if (endDate && item.date > endDate) return false; + if (endDate && item.date > endDate) + return false; return true; } - - // One-time items: only include if date falls within the range - if (startDate && item.date < startDate) return false; - if (endDate && item.date > endDate) return false; - + if (startDate && item.date < startDate) + return false; + if (endDate && item.date > endDate) + return false; return true; }).map(item => ({ ...item, monthlyAmount: item.recurring === 'yearly' ? item.amount / 12 : item.amount })); } - renderFinances() { this.updateFinanceSummary(); this.renderExpenses(); this.renderRevenue(); this.renderCharges(); } - updateFinanceSummary() { const expenses = this.filterFinanceItemsByDate(storage.getExpenses()); const revenue = this.filterFinanceItemsByDate(storage.getRevenue()); const charges = this.filterFinanceItemsByDate(storage.getCharges()); - const totalExpenses = expenses.reduce((sum, e) => sum + (e.monthlyAmount || 0), 0); const totalCharges = charges.reduce((sum, c) => sum + (c.monthlyAmount || 0), 0); const totalRevenue = revenue.reduce((sum, r) => sum + (r.monthlyAmount || 0), 0); const net = totalRevenue - totalExpenses - totalCharges; - document.getElementById('totalIncome').textContent = '$' + totalRevenue.toFixed(2); document.getElementById('totalExpenses').textContent = '$' + (totalExpenses + totalCharges).toFixed(2); document.getElementById('netBalance').textContent = '$' + net.toFixed(2); } - renderExpenses() { const expenses = this.filterFinanceItemsByDate(storage.getExpenses()); this.renderFinanceList(expenses, 'expensesList', 'expense'); } - renderRevenue() { const revenue = this.filterFinanceItemsByDate(storage.getRevenue()); this.renderFinanceList(revenue, 'revenueList', 'revenue', true); } - renderCharges() { const charges = this.filterFinanceItemsByDate(storage.getCharges()); this.renderFinanceList(charges, 'chargesList', 'charge'); } - renderFinanceList(items, containerId, type, isIncome = false) { const container = document.getElementById(containerId); - if (items.length === 0) { container.innerHTML = '

    No items. Add one to get started!

    '; return; } - container.innerHTML = items.map(item => { const displayAmount = (item.monthlyAmount !== undefined ? item.monthlyAmount : item.amount).toFixed(2); const monthlyLabel = item.recurring === 'yearly' ? ' /mo' : ''; @@ -1470,15 +1269,14 @@ class TaskManager { ${isIncome ? '+' : '-'}$${displayAmount}${monthlyLabel}
    - `}).join(''); - + `; + }).join(''); document.querySelectorAll('.finance-item').forEach(item => { - item.addEventListener('click', (e) => { + item.addEventListener('click', () => { this.openFinanceModal(item.dataset.financeType, item.dataset.financeId); }); }); } - openFinanceModal(type, financeId = null) { this.currentEditingFinanceType = type; this.currentEditingFinanceId = financeId; @@ -1486,30 +1284,24 @@ class TaskManager { const form = document.getElementById('financeForm'); const deleteBtn = document.getElementById('deleteFinanceBtn'); const recurringGroup = document.getElementById('financeRecurringGroup'); - form.reset(); deleteBtn.style.display = 'none'; document.getElementById('financeDate').valueAsDate = new Date(); - - // Load categories first this.loadCategoryDropdown('finance'); - - // Show recurring option only for expenses and revenues (not for charges) recurringGroup.style.display = ['expense', 'revenue'].includes(type) ? 'block' : 'none'; - - // Set modal title const titles = { expense: 'Add Expense', revenue: 'Add Revenue', charge: 'Add Other Charge' }; document.getElementById('financeModalTitle').textContent = financeId ? `Edit ${type}` : titles[type]; - if (financeId) { - let item = null; - if (type === 'expense') item = storage.getExpenses().find(e => e.id === financeId); - else if (type === 'revenue') item = storage.getRevenue().find(r => r.id === financeId); - else if (type === 'charge') item = storage.getCharges().find(c => c.id === financeId); - + let item; + if (type === 'expense') + item = storage.getExpenses().find(e => e.id === financeId); + else if (type === 'revenue') + item = storage.getRevenue().find(r => r.id === financeId); + else if (type === 'charge') + item = storage.getCharges().find(c => c.id === financeId); if (item) { document.getElementById('financeDescription').value = item.description; - document.getElementById('financeAmount').value = item.amount; + document.getElementById('financeAmount').value = String(item.amount); document.getElementById('financeDate').value = item.date || ''; document.getElementById('financeCategory').value = item.category || ''; if (item.recurring) { @@ -1518,63 +1310,61 @@ class TaskManager { deleteBtn.style.display = 'block'; } } - modal.classList.add('active'); } - closeFinanceModal() { document.getElementById('financeModal').classList.remove('active'); this.currentEditingFinanceId = null; this.currentEditingFinanceType = null; } - saveFinance(e) { e.preventDefault(); - const financeItem = { description: document.getElementById('financeDescription').value, amount: parseFloat(document.getElementById('financeAmount').value), date: document.getElementById('financeDate').value, category: document.getElementById('financeCategory').value }; - - // Add recurring for expenses and revenues (but not for charges) if (['expense', 'revenue'].includes(this.currentEditingFinanceType)) { financeItem.recurring = document.getElementById('financeRecurring').value; } - if (this.currentEditingFinanceType === 'expense') { if (this.currentEditingFinanceId) { storage.updateExpense(this.currentEditingFinanceId, financeItem); - } else { + } + else { storage.addExpense(financeItem); } - } else if (this.currentEditingFinanceType === 'revenue') { + } + else if (this.currentEditingFinanceType === 'revenue') { if (this.currentEditingFinanceId) { storage.updateRevenue(this.currentEditingFinanceId, financeItem); - } else { + } + else { storage.addRevenue(financeItem); } - } else if (this.currentEditingFinanceType === 'charge') { + } + else if (this.currentEditingFinanceType === 'charge') { if (this.currentEditingFinanceId) { storage.updateCharge(this.currentEditingFinanceId, financeItem); - } else { + } + else { storage.addCharge(financeItem); } } - this.closeFinanceModal(); this.renderFinances(); } - deleteFinance() { if (this.currentEditingFinanceId) { if (confirm('Are you sure you want to delete this item?')) { if (this.currentEditingFinanceType === 'expense') { storage.deleteExpense(this.currentEditingFinanceId); - } else if (this.currentEditingFinanceType === 'revenue') { + } + else if (this.currentEditingFinanceType === 'revenue') { storage.deleteRevenue(this.currentEditingFinanceId); - } else if (this.currentEditingFinanceType === 'charge') { + } + else if (this.currentEditingFinanceType === 'charge') { storage.deleteCharge(this.currentEditingFinanceId); } this.closeFinanceModal(); @@ -1582,7 +1372,6 @@ class TaskManager { } } } - // ======================== // Shop/Rewards Management // ======================== @@ -1590,17 +1379,12 @@ class TaskManager { const rewards = storage.getRewards(); const userStats = storage.getUserStats(); const container = document.getElementById('rewardsList'); - - // Update points display - document.getElementById('shopPointsDisplay').textContent = userStats.totalPoints; - + document.getElementById('shopPointsDisplay').textContent = String(userStats.totalPoints); if (rewards.length === 0) { container.innerHTML = '

    No rewards yet. Add rewards to spend your points on!

    '; return; } - container.innerHTML = rewards.map(reward => { - // Check if one-time and already purchased let alreadyPurchased = false; if (reward.repeatable === false) { const purchaseHistory = storage.getData().purchaseHistory || []; @@ -1608,9 +1392,10 @@ class TaskManager { } const disabled = userStats.totalPoints < reward.cost || alreadyPurchased; let purchaseLabel = 'Purchase'; - if (userStats.totalPoints < reward.cost) purchaseLabel = 'Not Enough Points'; - if (alreadyPurchased) purchaseLabel = 'Purchased'; - + if (userStats.totalPoints < reward.cost) + purchaseLabel = 'Not Enough Points'; + if (alreadyPurchased) + purchaseLabel = 'Purchased'; return `
    ${reward.name}
    @@ -1633,17 +1418,14 @@ class TaskManager {
    `; }).join(''); - - // Add event listeners - document.querySelectorAll('.purchase-btn').forEach((btn, index) => { + document.querySelectorAll('.purchase-btn').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const card = e.target.closest('[data-reward-id]'); this.purchaseReward(card.dataset.rewardId); }); }); - - document.querySelectorAll('.edit-reward-btn').forEach((btn, index) => { + document.querySelectorAll('.edit-reward-btn').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const card = e.target.closest('[data-reward-id]'); @@ -1651,57 +1433,47 @@ class TaskManager { }); }); } - openRewardModal(rewardId = null) { this.currentEditingRewardId = rewardId; const modal = document.getElementById('rewardModal'); const form = document.getElementById('rewardForm'); const deleteBtn = document.getElementById('deleteRewardBtn'); - form.reset(); deleteBtn.style.display = 'none'; - document.getElementById('rewardModalTitle').textContent = rewardId ? 'Edit Reward' : 'Add Reward'; - if (rewardId) { const reward = storage.getRewards().find(r => r.id === rewardId); if (reward) { document.getElementById('rewardName').value = reward.name; document.getElementById('rewardDescription').value = reward.description || ''; - document.getElementById('rewardCost').value = reward.cost; + document.getElementById('rewardCost').value = String(reward.cost); document.getElementById('rewardRepeatable').value = String(reward.repeatable === undefined ? true : reward.repeatable); deleteBtn.style.display = 'block'; } } - modal.classList.add('active'); } - closeRewardModal() { document.getElementById('rewardModal').classList.remove('active'); this.currentEditingRewardId = null; } - saveReward(e) { e.preventDefault(); - const reward = { name: document.getElementById('rewardName').value, description: document.getElementById('rewardDescription').value, cost: parseInt(document.getElementById('rewardCost').value), repeatable: document.getElementById('rewardRepeatable').value === 'true' }; - if (this.currentEditingRewardId) { storage.updateReward(this.currentEditingRewardId, reward); - } else { + } + else { storage.addReward(reward); } - this.closeRewardModal(); this.renderShop(); } - deleteReward() { if (this.currentEditingRewardId) { if (confirm('Are you sure you want to delete this reward?')) { @@ -1711,24 +1483,22 @@ class TaskManager { } } } - purchaseReward(rewardId) { const reward = storage.getRewards().find(r => r.id === rewardId); - if (!reward) return; - + if (!reward) + return; if (confirm(`Purchase "${reward.name}" for ${reward.cost} points?`)) { const result = storage.purchaseReward(rewardId); - if (result.success) { alert(`Congratulations! You've purchased: ${reward.name}!\n\nEnjoy your reward! 🎉`); this.renderShop(); this.renderDashboard(); - } else { + } + else { alert(result.message); } } } - // ======================== // Settings // ======================== @@ -1737,15 +1507,13 @@ class TaskManager { this.updateSettingsStatus(); this.renderCategoryManagement(); } - loadSettings() { const settings = storage.getSettings(); const tasksPerLevelInput = document.getElementById('tasksPerLevel'); if (tasksPerLevelInput) { - tasksPerLevelInput.value = settings.tasksPerLevel || 30; + tasksPerLevelInput.value = String(settings.tasksPerLevel || 30); } } - updateSettingsStatus() { const settings = storage.getSettings(); const tasks = storage.getTasks(); @@ -1753,30 +1521,27 @@ class TaskManager { const userStats = storage.getUserStats(); const tasksInCurrentLevel = completedTasksCount % settings.tasksPerLevel; const tasksToNext = settings.tasksPerLevel - tasksInCurrentLevel; - const currentLevelEl = document.getElementById('settingsCurrentLevel'); const totalCompletedEl = document.getElementById('settingsTotalCompleted'); const tasksToNextEl = document.getElementById('settingsTasksToNext'); - - if (currentLevelEl) currentLevelEl.textContent = userStats.level; - if (totalCompletedEl) totalCompletedEl.textContent = completedTasksCount; - if (tasksToNextEl) tasksToNextEl.textContent = tasksToNext; + if (currentLevelEl) + currentLevelEl.textContent = String(userStats.level); + if (totalCompletedEl) + totalCompletedEl.textContent = String(completedTasksCount); + if (tasksToNextEl) + tasksToNextEl.textContent = String(tasksToNext); } - saveTasksPerLevel() { const tasksPerLevel = parseInt(document.getElementById('tasksPerLevel').value); - if (isNaN(tasksPerLevel) || tasksPerLevel < 1) { alert('Please enter a valid number (minimum 1)'); return; } - storage.updateSettings({ tasksPerLevel }); alert('Settings saved! Level has been recalculated.'); this.updateSettingsStatus(); - this.renderDashboard(); // Update the display with new level + this.renderDashboard(); } - exportData() { const data = storage.exportData(); const blob = new Blob([data], { type: 'application/json' }); @@ -1789,27 +1554,27 @@ class TaskManager { document.body.removeChild(a); URL.revokeObjectURL(url); } - importData(e) { - const file = e.target.files[0]; - if (!file) return; - + const file = e.target.files?.[0]; + if (!file) + return; const reader = new FileReader(); reader.onload = (event) => { try { if (storage.importData(event.target.result)) { alert('Data imported successfully! Refreshing...'); location.reload(); - } else { + } + else { alert('Invalid file format. Please upload a valid Task Manager backup.'); } - } catch (error) { + } + catch (error) { alert('Error importing file: ' + error.message); } }; reader.readAsText(file); } - // ======================== // Recurring Tasks Processing // ======================== @@ -1817,7 +1582,6 @@ class TaskManager { // New recurring tasks are created immediately when a repeatable task is completed // via createNextRecurringTask(). The original completed task stays in history. } - // ======================== // General Rendering // ======================== @@ -1825,75 +1589,64 @@ class TaskManager { document.getElementById('dataVersion').textContent = STORAGE_VERSION; const lastUpdated = storage.getData().lastUpdated; document.getElementById('lastUpdated').textContent = lastUpdated ? new Date(lastUpdated).toLocaleString() : 'Never'; - this.updateDateNavigator(); this.renderDashboard(); } - // ======================== // Date Navigation // ======================== getSelectedDateStr() { return storage.formatDate(this.selectedDate); } - isSelectedDateToday() { return this.getSelectedDateStr() === storage.formatDate(new Date()); } - navigateDate(delta) { const today = new Date(); today.setHours(0, 0, 0, 0); const minDate = new Date(today); minDate.setDate(minDate.getDate() - 6); - const newDate = new Date(this.selectedDate); newDate.setDate(newDate.getDate() + delta); newDate.setHours(0, 0, 0, 0); - if (newDate >= minDate && newDate <= today) { this.selectedDate = newDate; this.updateDateNavigator(); - // Re-render the active tab const activeTab = document.querySelector('.nav-tab.active'); if (activeTab) { this.switchTab(activeTab.dataset.tab); - } else { + } + else { this.renderDashboard(); } } } - formatDisplayDate(date) { const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; return `${days[date.getDay()]}, ${months[date.getMonth()]} ${date.getDate()}`; } - updateDateNavigator() { const today = new Date(); today.setHours(0, 0, 0, 0); const minDate = new Date(today); minDate.setDate(minDate.getDate() - 6); - const sel = new Date(this.selectedDate); sel.setHours(0, 0, 0, 0); - const isToday = sel.getTime() === today.getTime(); const isPastLimit = sel.getTime() <= minDate.getTime(); - const displayStr = isToday ? `Today — ${this.formatDisplayDate(this.selectedDate)}` : this.formatDisplayDate(this.selectedDate); - document.getElementById('selectedDateDisplay').textContent = displayStr; document.getElementById('prevDayBtn').disabled = isPastLimit; document.getElementById('nextDayBtn').disabled = isToday; document.getElementById('goTodayBtn').style.display = isToday ? 'none' : 'inline-block'; } } - // Initialize the app when DOM is ready document.addEventListener('DOMContentLoaded', () => { window.app = new TaskManager(); }); +export { TaskManager }; +//# sourceMappingURL=app.js.map \ No newline at end of file diff --git a/js/storage.d.ts.map b/js/storage.d.ts.map new file mode 100644 index 0000000..031aba3 --- /dev/null +++ b/js/storage.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"storage.d.ts","sourceRoot":"","sources":["../src/storage.ts"],"names":[],"mappings":"AAIA,QAAA,MAAM,eAAe,UAAU,CAAC;AAChC,QAAA,MAAM,WAAW,oBAAoB,CAAC;AAOtC,MAAM,WAAW,IAAI;IACjB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,QAAQ,EAAE,KAAK,GAAG,QAAQ,GAAG,MAAM,CAAC;IACpC,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,GAAG,OAAO,GAAG,QAAQ,GAAG,SAAS,GAAG,QAAQ,GAAG,QAAQ,GAAG,SAAS,CAAC;IACtF,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,SAAS,EAAE,OAAO,CAAC;IACnB,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,WAAW,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,OAAO;IACpB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,KAAK;IAClB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,iBAAiB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,WAAW,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,QAAQ;IACrB,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,WAAW;IACxB,EAAE,EAAE,MAAM,CAAC;IACX,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,GAAG,QAAQ,CAAC;IAC1C,WAAW,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,MAAM;IACnB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,OAAO,CAAC;IACpB,SAAS,EAAE,OAAO,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,QAAQ;IACrB,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,eAAe;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,SAAS;IACtB,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,eAAe,EAAE,eAAe,CAAC;CACpC;AAED,MAAM,WAAW,QAAQ;IACrB,aAAa,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,OAAO;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,aAAa,EAAE,MAAM,CAAC;IACtB,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,IAAI,EAAE,CAAC;IACd,QAAQ,EAAE,OAAO,EAAE,CAAC;IACpB,MAAM,EAAE,KAAK,EAAE,CAAC;IAChB,cAAc,EAAE,QAAQ,EAAE,CAAC;IAC3B,QAAQ,EAAE,WAAW,EAAE,CAAC;IACxB,OAAO,EAAE,WAAW,EAAE,CAAC;IACvB,OAAO,EAAE,WAAW,EAAE,CAAC;IACvB,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,eAAe,EAAE,QAAQ,EAAE,CAAC;IAC5B,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,SAAS,EAAE,SAAS,CAAC;IACrB,QAAQ,EAAE,QAAQ,CAAC;CACtB;AAED,MAAM,WAAW,cAAc;IAC3B,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,QAAQ,CAAC;CACvB;AAED,qBAAa,cAAc;;IAKvB,iBAAiB,IAAI,IAAI;IAOzB,iBAAiB,IAAI,IAAI;IAmCzB,OAAO,IAAI,OAAO;IAKlB,OAAO,CAAC,cAAc;IAKtB,QAAQ,CAAC,IAAI,EAAE,OAAO,GAAG,IAAI;IAM7B,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI;IAiBlC,UAAU,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,GAAG,SAAS;IAUpE,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI;IAMhC,QAAQ,IAAI,IAAI,EAAE;IAMlB,UAAU,CAAC,OAAO,EAAE,OAAO,CAAC,OAAO,CAAC,GAAG,OAAO;IAa9C,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,CAAC,OAAO,CAAC,GAAG,OAAO,GAAG,SAAS;IAUhF,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;IAatC,WAAW,IAAI,OAAO,EAAE;IAMxB,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC,KAAK,CAAC,GAAG,KAAK;IAkBtC,WAAW,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,CAAC,KAAK,CAAC,GAAG,KAAK,GAAG,SAAS;IAWxE,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;IAMlC,SAAS,IAAI,KAAK,EAAE;IAKpB,kBAAkB,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,GAAE,IAAiB,GAAG,IAAI;IAqBlE,qBAAqB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO;IAM/C,0BAA0B,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM;IAMnD,4BAA4B,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM;IAMtE,UAAU,CAAC,OAAO,EAAE,OAAO,CAAC,WAAW,CAAC,GAAG,WAAW;IActD,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,CAAC,WAAW,CAAC,GAAG,WAAW,GAAG,SAAS;IAUxF,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;IAMtC,WAAW,IAAI,WAAW,EAAE;IAK5B,UAAU,CAAC,OAAO,EAAE,OAAO,CAAC,WAAW,CAAC,GAAG,WAAW;IActD,aAAa,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,CAAC,WAAW,CAAC,GAAG,WAAW,GAAG,SAAS;IAUxF,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;IAMtC,UAAU,IAAI,WAAW,EAAE;IAM3B,SAAS,CAAC,MAAM,EAAE,OAAO,CAAC,WAAW,CAAC,GAAG,WAAW;IAiBpD,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,CAAC,WAAW,CAAC,GAAG,WAAW,GAAG,SAAS;IAatF,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI;IASpC,UAAU,IAAI,WAAW,EAAE;IAM3B,SAAS,CAAC,MAAM,EAAE,OAAO,CAAC,MAAM,CAAC,GAAG,MAAM;IAmB1C,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,CAAC,MAAM,CAAC,GAAG,MAAM,GAAG,SAAS;IAc5E,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI;IASpC,UAAU,IAAI,MAAM,EAAE;IAKtB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,cAAc;IA4ChD,kBAAkB,IAAI,QAAQ,EAAE;IAMhC,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI;IAW/C,WAAW,IAAI,IAAI;IAQnB,iBAAiB,CAAC,SAAS,GAAE,OAAc,GAAG,IAAI;IAoBlD,YAAY,IAAI,SAAS;IAMzB,UAAU,IAAI,MAAM;IAIpB,UAAU,CAAC,IAAI,EAAE,IAAI,GAAG,MAAM;IAO9B,UAAU,IAAI,MAAM;IAKpB,UAAU,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO;IAevC,YAAY,IAAI,OAAO;IAYvB,aAAa,IAAI,MAAM,EAAE;IAgBzB,WAAW,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO;IAa1C,cAAc,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO;IAyBzD,cAAc,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO;IAyB7C,WAAW,IAAI,QAAQ;IAYvB,cAAc,CAAC,QAAQ,EAAE,OAAO,CAAC,QAAQ,CAAC,GAAG,IAAI;CAUpD;AAGD,QAAA,MAAM,OAAO,gBAAuB,CAAC;AACrC,OAAO,EAAE,OAAO,EAAE,eAAe,EAAE,WAAW,EAAE,CAAC"} \ No newline at end of file diff --git a/js/storage.js b/js/storage.js index aff8ba0..7a63718 100644 --- a/js/storage.js +++ b/js/storage.js @@ -1,23 +1,19 @@ // ======================== // Storage Management with Versioning // ======================== - const STORAGE_VERSION = '1.0.0'; const STORAGE_KEY = 'taskManagerData'; const DATA_SCHEMA_VERSION = 1; - -class StorageManager { +export class StorageManager { constructor() { this.initializeStorage(); } - initializeStorage() { const existingData = localStorage.getItem(STORAGE_KEY); if (!existingData) { this.createInitialData(); } } - createInitialData() { const initialData = { version: STORAGE_VERSION, @@ -49,31 +45,37 @@ class StorageManager { tasksPerLevel: 30 } }; - localStorage.setItem(STORAGE_KEY, JSON.stringify(initialData)); } - getData() { const data = localStorage.getItem(STORAGE_KEY); - return data ? JSON.parse(data) : null; + return data ? JSON.parse(data) : this.getDefaultData(); + } + getDefaultData() { + this.createInitialData(); + return JSON.parse(localStorage.getItem(STORAGE_KEY)); } - saveData(data) { data.lastUpdated = new Date().toISOString(); localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } - // Task Management addTask(task) { const data = this.getData(); - task.id = this.generateId(); - task.createdDate = new Date().toISOString(); - task.completed = false; - data.tasks.push(task); + const newTask = { + ...task, + id: this.generateId(), + createdDate: new Date().toISOString(), + completed: false, + title: task.title || '', + priority: task.priority || 'medium', + points: task.points || 10, + repeatType: task.repeatType || 'none', + }; + data.tasks.push(newTask); this.saveData(data); - return task; + return newTask; } - updateTask(taskId, updates) { const data = this.getData(); const task = data.tasks.find(t => t.id === taskId); @@ -83,28 +85,28 @@ class StorageManager { } return task; } - deleteTask(taskId) { const data = this.getData(); data.tasks = data.tasks.filter(t => t.id !== taskId); this.saveData(data); } - getTasks() { const data = this.getData(); return data.tasks || []; } - // Project Management addProject(project) { const data = this.getData(); - project.id = this.generateId(); - project.createdDate = new Date().toISOString(); - data.projects.push(project); + const newProject = { + ...project, + id: this.generateId(), + createdDate: new Date().toISOString(), + name: project.name || '', + }; + data.projects.push(newProject); this.saveData(data); - return project; + return newProject; } - updateProject(projectId, updates) { const data = this.getData(); const project = data.projects.find(p => p.id === projectId); @@ -114,7 +116,6 @@ class StorageManager { } return project; } - deleteProject(projectId) { const data = this.getData(); data.projects = data.projects.filter(p => p.id !== projectId); @@ -127,95 +128,93 @@ class StorageManager { }); this.saveData(data); } - getProjects() { const data = this.getData(); return data.projects || []; } - // Habit Management addHabit(habit) { const data = this.getData(); - habit.id = this.generateId(); - habit.createdDate = new Date().toISOString(); - habit.streak = 0; - habit.lastCompletedDate = null; - habit.targetGoal = habit.targetGoal || 1; - data.habits.push(habit); + const newHabit = { + ...habit, + id: this.generateId(), + createdDate: new Date().toISOString(), + streak: 0, + lastCompletedDate: null, + targetGoal: habit.targetGoal || 1, + name: habit.name || '', + icon: habit.icon || '⭐', + points: habit.points || 10, + }; + data.habits.push(newHabit); this.saveData(data); - return habit; + return newHabit; } - updateHabit(habitId, updates) { const data = this.getData(); const habit = data.habits.find(h => h.id === habitId); if (habit) { Object.assign(habit, updates); - if (!habit.targetGoal) habit.targetGoal = 1; + if (!habit.targetGoal) + habit.targetGoal = 1; this.saveData(data); } return habit; } - deleteHabit(habitId) { const data = this.getData(); data.habits = data.habits.filter(h => h.id !== habitId); this.saveData(data); } - getHabits() { const data = this.getData(); return data.habits || []; } - logHabitCompletion(habitId, date = new Date()) { const data = this.getData(); const dateStr = this.formatDate(date); - data.dailyHabitLogs.push({ id: this.generateId(), habitId, date: dateStr, timestamp: new Date().toISOString() }); - // Update habit streak const habit = data.habits.find(h => h.id === habitId); if (habit) { habit.lastCompletedDate = dateStr; habit.streak = (habit.streak || 0) + 1; } - this.saveData(data); } - isHabitCompletedToday(habitId) { const data = this.getData(); const todayStr = this.formatDate(new Date()); return data.dailyHabitLogs.some(log => log.habitId === habitId && log.date === todayStr); } - countHabitCompletionsToday(habitId) { const data = this.getData(); const todayStr = this.formatDate(new Date()); return data.dailyHabitLogs.filter(log => log.habitId === habitId && log.date === todayStr).length; } - countHabitCompletionsForDate(habitId, dateStr) { const data = this.getData(); return data.dailyHabitLogs.filter(log => log.habitId === habitId && log.date === dateStr).length; } - // Finance Management addExpense(expense) { const data = this.getData(); - expense.id = this.generateId(); - expense.createdDate = new Date().toISOString(); - data.expenses.push(expense); + const newExpense = { + ...expense, + id: this.generateId(), + createdDate: new Date().toISOString(), + description: expense.description || '', + amount: expense.amount || 0, + }; + data.expenses.push(newExpense); this.saveData(data); - return expense; + return newExpense; } - updateExpense(expenseId, updates) { const data = this.getData(); const expense = data.expenses.find(e => e.id === expenseId); @@ -225,27 +224,28 @@ class StorageManager { } return expense; } - deleteExpense(expenseId) { const data = this.getData(); data.expenses = data.expenses.filter(e => e.id !== expenseId); this.saveData(data); } - getExpenses() { const data = this.getData(); return data.expenses || []; } - addRevenue(revenue) { const data = this.getData(); - revenue.id = this.generateId(); - revenue.createdDate = new Date().toISOString(); - data.revenue.push(revenue); + const newRevenue = { + ...revenue, + id: this.generateId(), + createdDate: new Date().toISOString(), + description: revenue.description || '', + amount: revenue.amount || 0, + }; + data.revenue.push(newRevenue); this.saveData(data); - return revenue; + return newRevenue; } - updateRevenue(revenueId, updates) { const data = this.getData(); const item = data.revenue.find(r => r.id === revenueId); @@ -255,31 +255,32 @@ class StorageManager { } return item; } - deleteRevenue(revenueId) { const data = this.getData(); data.revenue = data.revenue.filter(r => r.id !== revenueId); this.saveData(data); } - getRevenue() { const data = this.getData(); return data.revenue || []; } - // Other Charges Management addCharge(charge) { const data = this.getData(); if (!data.charges) { data.charges = []; } - charge.id = this.generateId(); - charge.createdDate = new Date().toISOString(); - data.charges.push(charge); + const newCharge = { + ...charge, + id: this.generateId(), + createdDate: new Date().toISOString(), + description: charge.description || '', + amount: charge.amount || 0, + }; + data.charges.push(newCharge); this.saveData(data); - return charge; + return newCharge; } - updateCharge(chargeId, updates) { const data = this.getData(); if (!data.charges) { @@ -292,7 +293,6 @@ class StorageManager { } return charge; } - deleteCharge(chargeId) { const data = this.getData(); if (!data.charges) { @@ -301,27 +301,29 @@ class StorageManager { data.charges = data.charges.filter(c => c.id !== chargeId); this.saveData(data); } - getCharges() { const data = this.getData(); return data.charges || []; } - // Rewards Shop Management addReward(reward) { const data = this.getData(); if (!data.rewards) { data.rewards = []; } - reward.id = this.generateId(); - reward.createdDate = new Date().toISOString(); - reward.purchased = false; - reward.repeatable = typeof reward.repeatable === 'undefined' ? true : reward.repeatable; - data.rewards.push(reward); + const newReward = { + ...reward, + id: this.generateId(), + createdDate: new Date().toISOString(), + purchased: false, + repeatable: typeof reward.repeatable === 'undefined' ? true : reward.repeatable, + name: reward.name || '', + cost: reward.cost || 0, + }; + data.rewards.push(newReward); this.saveData(data); - return reward; + return newReward; } - updateReward(rewardId, updates) { const data = this.getData(); if (!data.rewards) { @@ -330,12 +332,12 @@ class StorageManager { const reward = data.rewards.find(r => r.id === rewardId); if (reward) { Object.assign(reward, updates); - if (typeof reward.repeatable === 'undefined') reward.repeatable = true; + if (typeof reward.repeatable === 'undefined') + reward.repeatable = true; this.saveData(data); } return reward; } - deleteReward(rewardId) { const data = this.getData(); if (!data.rewards) { @@ -344,12 +346,10 @@ class StorageManager { data.rewards = data.rewards.filter(r => r.id !== rewardId); this.saveData(data); } - getRewards() { const data = this.getData(); return data.rewards || []; } - purchaseReward(rewardId) { const data = this.getData(); if (!data.rewards) { @@ -358,16 +358,13 @@ class StorageManager { if (!data.purchaseHistory) { data.purchaseHistory = []; } - const reward = data.rewards.find(r => r.id === rewardId); if (!reward) { return { success: false, message: 'Reward not found' }; } - if (data.userStats.totalPoints < reward.cost) { return { success: false, message: 'Not enough points' }; } - // If one-time and already purchased, block if (reward.repeatable === false) { const alreadyPurchased = data.purchaseHistory.some(ph => ph.rewardId === reward.id); @@ -375,10 +372,8 @@ class StorageManager { return { success: false, message: 'This reward can only be purchased once.' }; } } - // Deduct points data.userStats.totalPoints -= reward.cost; - // Add to purchase history const purchase = { id: this.generateId(), @@ -389,79 +384,67 @@ class StorageManager { purchaseDate: new Date().toISOString() }; data.purchaseHistory.push(purchase); - this.saveData(data); return { success: true, purchase }; } - getPurchaseHistory() { const data = this.getData(); return data.purchaseHistory || []; } - - // - // Points Management addPoints(amount, source) { const data = this.getData(); data.userStats.totalPoints += amount; - if (data.userStats.pointsBreakdown[source]) { + if (data.userStats.pointsBreakdown[source] !== undefined) { data.userStats.pointsBreakdown[source] += amount; } // Level is now calculated based on completed tasks, not points this.updateLevel(); this.saveData(data); } - updateLevel() { const data = this.getData(); const settings = this.getSettings(); const completedTasksCount = data.tasks.filter(t => t.completed).length; data.userStats.level = Math.floor(completedTasksCount / settings.tasksPerLevel) + 1; + this.saveData(data); } - updateDailyStreak(increment = true) { const data = this.getData(); const today = this.formatDate(new Date()); - if (increment) { if (data.userStats.lastActivityDate !== today) { const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1); if (data.userStats.lastActivityDate !== this.formatDate(yesterday)) { data.userStats.dailyStreak = 1; - } else { + } + else { data.userStats.dailyStreak += 1; } } } - data.userStats.lastActivityDate = today; this.saveData(data); } - getUserStats() { const data = this.getData(); return data.userStats; } - // Utility Methods generateId() { return Date.now().toString(36) + Math.random().toString(36).substr(2); } - formatDate(date) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; } - exportData() { const data = this.getData(); return JSON.stringify(data, null, 2); } - importData(jsonString) { try { const data = JSON.parse(jsonString); @@ -471,12 +454,12 @@ class StorageManager { return true; } return false; - } catch (e) { + } + catch (e) { console.error('Import error:', e); return false; } } - clearAllData() { if (confirm('Are you sure you want to clear all data? This cannot be undone.')) { localStorage.removeItem(STORAGE_KEY); @@ -485,7 +468,6 @@ class StorageManager { } return false; } - // ======================== // Category Management // ======================== @@ -493,21 +475,21 @@ class StorageManager { const data = this.getData(); // Migrate old per-type structure to a single shared array if (data.categories && !Array.isArray(data.categories)) { + const catObj = data.categories; const merged = [...new Set([ - ...(data.categories.tasks || []), - ...(data.categories.habits || []), - ...(data.categories.finance || []) - ])]; + ...(catObj['tasks'] || []), + ...(catObj['habits'] || []), + ...(catObj['finance'] || []) + ])]; data.categories = merged; this.saveData(data); } return Array.isArray(data.categories) ? data.categories : []; } - addCategory(categoryName) { const data = this.getData(); - if (!Array.isArray(data.categories)) data.categories = []; - + if (!Array.isArray(data.categories)) + data.categories = []; const trimmedName = categoryName.trim(); if (trimmedName && !data.categories.includes(trimmedName)) { data.categories.push(trimmedName); @@ -516,19 +498,17 @@ class StorageManager { } return false; } - updateCategory(oldName, newName) { const data = this.getData(); - if (!Array.isArray(data.categories)) return false; - + if (!Array.isArray(data.categories)) + return false; const trimmedNew = newName.trim(); - if (!trimmedNew || data.categories.includes(trimmedNew)) return false; - + if (!trimmedNew || data.categories.includes(trimmedNew)) + return false; const idx = data.categories.indexOf(oldName); - if (idx === -1) return false; - + if (idx === -1) + return false; data.categories[idx] = trimmedNew; - // Propagate rename to all item types data.tasks = data.tasks.map(t => t.category === oldName ? { ...t, category: trimmedNew } : t); data.habits = data.habits.map(h => h.category === oldName ? { ...h, category: trimmedNew } : h); @@ -537,20 +517,17 @@ class StorageManager { if (data.charges) { data.charges = data.charges.map(c => c.category === oldName ? { ...c, category: trimmedNew } : c); } - this.saveData(data); return true; } - deleteCategory(categoryName) { const data = this.getData(); - if (!Array.isArray(data.categories)) return false; - + if (!Array.isArray(data.categories)) + return false; const idx = data.categories.indexOf(categoryName); - if (idx === -1) return false; - + if (idx === -1) + return false; data.categories.splice(idx, 1); - // Clear category from all item types data.tasks = data.tasks.map(t => t.category === categoryName ? { ...t, category: null } : t); data.habits = data.habits.map(h => h.category === categoryName ? { ...h, category: null } : h); @@ -559,11 +536,9 @@ class StorageManager { if (data.charges) { data.charges = data.charges.map(c => c.category === categoryName ? { ...c, category: null } : c); } - this.saveData(data); return true; } - // ======================== // Settings Management // ======================== @@ -578,11 +553,10 @@ class StorageManager { } return data.settings; } - updateSettings(settings) { const data = this.getData(); if (!data.settings) { - data.settings = {}; + data.settings = { tasksPerLevel: 30 }; } Object.assign(data.settings, settings); // Recalculate level with new settings @@ -591,4 +565,6 @@ class StorageManager { } } // Initialize global storage manager -const storage = new StorageManager(); \ No newline at end of file +const storage = new StorageManager(); +export { storage, STORAGE_VERSION, STORAGE_KEY }; +//# sourceMappingURL=storage.js.map \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..86c134a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3187 @@ +{ + "name": "taskmanagerweb", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "taskmanagerweb", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "@playwright/test": "^1.58.2", + "@types/node": "^25.3.2", + "jsdom": "^28.1.0", + "serve": "^14.2.5", + "typescript": "^5.9.3", + "vitest": "^4.0.18" + } + }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "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.6" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.28", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.28.tgz", + "integrity": "sha512-1NRf1CUBjnr3K7hu8BLxjQrKCxEe8FP/xmPTenAxCRZWVLbmGotkFvG9mfNpjA6k7Bw1bw4BilZq9cu19RA5pg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0" + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.14.1.tgz", + "integrity": "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ==", + "dev": true, + "license": "MIT", + "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 + } + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.3.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.2.tgz", + "integrity": "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@zeit/schemas": { + "version": "2.36.0", + "resolved": "https://registry.npmjs.org/@zeit/schemas/-/schemas-2.36.0.tgz", + "integrity": "sha512-7kjMwcChYEzMKjeex9ZFXkt1AyNov9R5HZtjBKVsmVpw7pa7ZtlCGvCBC2vnnXctaYN+aRI61HjIqeetZW5ROg==", + "dev": true, + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-align": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.1.0" + } + }, + "node_modules/ansi-align/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-align/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-align/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-align/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/arch": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz", + "integrity": "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/boxen": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-7.0.0.tgz", + "integrity": "sha512-j//dBVuyacJbvW+tvZ9HuH03fZ46QcaKvvhZickZqtB271DxJ7SNRSNxrV/dZX0085m7hISRZWbzWlJvx/rHSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-align": "^3.0.1", + "camelcase": "^7.0.0", + "chalk": "^5.0.1", + "cli-boxes": "^3.0.0", + "string-width": "^5.1.2", + "type-fest": "^2.13.0", + "widest-line": "^4.0.1", + "wrap-ansi": "^8.0.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/camelcase": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-7.0.1.tgz", + "integrity": "sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.0.1.tgz", + "integrity": "sha512-Fo07WOYGqMfCWHOzSXOt2CxDbC6skS/jO9ynEcmpANMoPrD+W1r1K6Vx7iNm+AQmETU1Xr2t+n8nzkV9t6xh3w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk-template": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-0.4.0.tgz", + "integrity": "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/chalk-template?sponsor=1" + } + }, + "node_modules/chalk-template/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/chalk-template/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clipboardy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-3.0.0.tgz", + "integrity": "sha512-Su+uU5sr1jkUy1sGRpLKjKrvEOVXgSgiSInwa/qeID6aJ07yh+5NWc3h2QfjHjBnfX4LhtFcuAWKUsJ3r+fjbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "arch": "^2.2.0", + "execa": "^5.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz", + "integrity": "sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cssstyle": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.1.0.tgz", + "integrity": "sha512-Ml4fP2UT2K3CUBQnVlbdV/8aFDdlY69E+YnwJM+3VUWl08S3J8c8aRuJqCkD9Py8DHZ7zNNvsfKl8psocHZEFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.0", + "@csstools/css-syntax-patches-for-csstree": "^1.0.28", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-port-reachable": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-port-reachable/-/is-port-reachable-4.0.0.tgz", + "integrity": "sha512-9UoipoxYmSk6Xy7QFgRv2HDyaysmgSG75TFQs6S+3pDM7ZhKTF/bskZV+0UlABHzKjNVhPjYCLfeZUEg1wXxig==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jsdom": { + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", + "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.8.1", + "@bramus/specificity": "^2.4.2", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^6.0.1", + "data-urls": "^7.0.0", + "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", + "undici": "^7.21.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz", + "integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "~1.33.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz", + "integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-is-inside": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", + "integrity": "sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz", + "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/range-parser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz", + "integrity": "sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/registry-auth-token": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-3.3.2.tgz", + "integrity": "sha512-JL39c60XlzCVgNrO+qq68FoNb56w/m7JYvGR2jT5iR1xBrUA3Mfx5Twk5rqTThPmQKMWydGmq8oFtDlxfrmxnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "rc": "^1.1.6", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/registry-url": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-3.1.0.tgz", + "integrity": "sha512-ZbgR5aZEdf4UKZVBPYIgaglBmSF2Hi94s2PcIHhRGFjKYu+chjJdYfHn4rt3hB6eCKLJ8giVIIfgMa1ehDfZKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "rc": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/serve": { + "version": "14.2.5", + "resolved": "https://registry.npmjs.org/serve/-/serve-14.2.5.tgz", + "integrity": "sha512-Qn/qMkzCcMFVPb60E/hQy+iRLpiU8PamOfOSYoAHmmF+fFFmpPpqa6Oci2iWYpTdOUM3VF+TINud7CfbQnsZbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zeit/schemas": "2.36.0", + "ajv": "8.12.0", + "arg": "5.0.2", + "boxen": "7.0.0", + "chalk": "5.0.1", + "chalk-template": "0.4.0", + "clipboardy": "3.0.0", + "compression": "1.8.1", + "is-port-reachable": "4.0.0", + "serve-handler": "6.1.6", + "update-check": "1.5.4" + }, + "bin": { + "serve": "build/main.js" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/serve-handler": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.6.tgz", + "integrity": "sha512-x5RL9Y2p5+Sh3D38Fh9i/iQ5ZK+e4xuXRd/pGbM4D13tgo/MGwbttUk8emytcr1YYzBYs+apnUngBDFYfpjPuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.0.0", + "content-disposition": "0.5.2", + "mime-types": "2.1.18", + "minimatch": "3.1.2", + "path-is-inside": "1.0.2", + "path-to-regexp": "3.3.0", + "range-parser": "1.2.0" + } + }, + "node_modules/serve-handler/node_modules/bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz", + "integrity": "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.23" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz", + "integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", + "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-check": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/update-check/-/update-check-1.5.4.tgz", + "integrity": "sha512-5YHsflzHP4t1G+8WGPlvKbJEbAJGCgw+Em+dGR1KmBUbr1J36SJBqlHLjR7oob7sco5hWHGQVcr9B2poIVDDTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "registry-auth-token": "3.3.2", + "registry-url": "3.1.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@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.2.2", + "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.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "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 + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/widest-line": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz", + "integrity": "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^5.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7a3a250 --- /dev/null +++ b/package.json @@ -0,0 +1,35 @@ +{ + "name": "taskmanagerweb", + "version": "1.0.0", + "description": "A comprehensive Progressive Web App (PWA) for managing tasks, projects, habits, and finances with gamification features.", + "main": "service-worker.js", + "scripts": { + "build": "tsc", + "build:watch": "tsc --watch", + "test": "vitest run", + "test:watch": "vitest", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "serve": "npx serve -l 3000 -s ." + }, + "repository": { + "type": "git", + "url": "git+https://github.com/SpeakingInBits/TaskManagerWeb.git" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "bugs": { + "url": "https://github.com/SpeakingInBits/TaskManagerWeb/issues" + }, + "homepage": "https://github.com/SpeakingInBits/TaskManagerWeb#readme", + "devDependencies": { + "@playwright/test": "^1.58.2", + "@types/node": "^25.3.2", + "jsdom": "^28.1.0", + "serve": "^14.2.5", + "typescript": "^5.9.3", + "vitest": "^4.0.18" + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..efc7f2c --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } 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: { browserName: 'chromium' }, + }, + ], + webServer: { + command: 'npx serve -l 3000 -s .', + port: 3000, + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/service-worker.js b/service-worker.js index d1faa7f..1d1fbb9 100644 --- a/service-worker.js +++ b/service-worker.js @@ -3,8 +3,8 @@ const urlsToCache = [ '/', '/index.html', '/css/styles.css', - '/js/app.js', - '/js/storage.js' + '/js/storage.js', + '/js/app.js' ]; // Install event - cache files diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..5d5bda1 --- /dev/null +++ b/src/app.ts @@ -0,0 +1,1868 @@ +// ======================== +// Main Application Logic +// ======================== + +import { StorageManager, storage, STORAGE_VERSION, Task, Habit, FinanceItem } from './storage.js'; + +interface Activity { + type: string; + message: string; + date: string; + icon: string; +} + +interface FilteredFinanceItem extends FinanceItem { + monthlyAmount?: number; +} + +class TaskManager { + currentEditingTaskId: string | null = null; + currentEditingProjectId: string | null = null; + currentEditingHabitId: string | null = null; + currentEditingFinanceId: string | null = null; + currentEditingFinanceType: string | null = null; + currentEditingRewardId: string | null = null; + selectedDate: Date = new Date(); + tasksExpanded: boolean = false; + emojis: string[] = [ + // Activity + '💪', '🏃', '🚴', '🏊', '🧘', '💃', '🕺', '⛹️', + // Food & Health + '🥗', '🍎', '🥕', '💊', '🏥', '🧄', '🥤', '☕', + // Work & Productivity + '📚', '✍️', '💼', '🎯', '📊', '💻', '📱', '⌨️', + // Learning & Mind + '🧠', '📖', '🎓', '💡', '🔬', '🎨', '🎵', '🎭', + // Nature & Outdoors + '🌿', '🌳', '🌞', '🌙', '🌊', '⛰️', '🏞️', '🦋', + // Sleep & Rest + '😴', '🛏️', '😌', '🕯️', '🌙', '💤', '🧖', '🛀', + // Social & Fun + '👨‍👩‍👧‍👦', '🤝', '🎉', '😊', '❤️', '🤗', '😂', '👏', + // Sports & Games + '⚽', '🏀', '🎾', '🏐', '🎯', '♟️', '🎲', '🃏', + // Habits & Goals + '⭐', '🎯', '🏆', '🥇', '🔥', '💎', '✨', '🌟', + // More Emojis + '🚀', '🌈', '🎁', '📅', '⏰', '💰', '🎪', '🎢' + ]; + + constructor() { + this.init(); + } + + init(): void { + this.setupEventListeners(); + this.initializeFinanceDateFilter(); + this.updateDateNavigator(); + this.render(); + this.processRecurringTasks(); + } + + // ======================== + // Event Listeners Setup + // ======================== + setupEventListeners(): void { + // Navigation tabs + document.querySelectorAll('.nav-tab').forEach(tab => { + tab.addEventListener('click', (e) => this.switchTab((e.target as HTMLElement).dataset.tab!)); + }); + + // Tasks section + document.getElementById('addTaskBtn')!.addEventListener('click', () => this.openTaskModal()); + document.getElementById('toggleTaskViewBtn')!.addEventListener('click', () => this.toggleTaskView()); + document.getElementById('taskForm')!.addEventListener('submit', (e) => this.saveTask(e)); + document.getElementById('cancelTaskBtn')!.addEventListener('click', () => this.closeTaskModal()); + document.getElementById('deleteTaskBtn')!.addEventListener('click', () => this.deleteTask()); + document.getElementById('taskRepeatType')!.addEventListener('change', (e) => this.updateRepeatTypeUI((e.target as HTMLSelectElement).value)); + document.getElementById('taskCategory')!.addEventListener('change', (e) => this.handleCategoryChange('task', (e.target as HTMLSelectElement).value)); + document.getElementById('taskCategorySave')!.addEventListener('click', () => this.handleAddCategory('task')); + document.getElementById('taskCategoryCancel')!.addEventListener('click', () => this.cancelAddCategory('task')); + document.getElementById('categoryFilter')!.addEventListener('change', () => this.filterTasks()); + document.getElementById('statusFilter')!.addEventListener('change', () => this.filterTasks()); + document.getElementById('searchTasks')!.addEventListener('input', () => this.filterTasks()); + + // Projects section + document.getElementById('addProjectBtn')!.addEventListener('click', () => this.openProjectModal()); + document.getElementById('projectForm')!.addEventListener('submit', (e) => this.saveProject(e)); + document.getElementById('cancelProjectBtn')!.addEventListener('click', () => this.closeProjectModal()); + document.getElementById('deleteProjectBtn')!.addEventListener('click', () => this.deleteProject()); + document.getElementById('closeProjectDetailBtn')!.addEventListener('click', () => this.closeProjectDetailModal()); + document.getElementById('editProjectFromDetailBtn')!.addEventListener('click', () => this.editProjectFromDetail()); + + // Habits section + document.getElementById('addHabitBtn')!.addEventListener('click', () => this.openHabitModal()); + document.getElementById('habitForm')!.addEventListener('submit', (e) => this.saveHabit(e)); + document.getElementById('cancelHabitBtn')!.addEventListener('click', () => this.closeHabitModal()); + document.getElementById('deleteHabitBtn')!.addEventListener('click', () => this.deleteHabit()); + document.getElementById('habitEmojiBtn')!.addEventListener('click', () => this.openEmojiPicker()); + document.getElementById('closeEmojiBtn')!.addEventListener('click', () => this.closeEmojiPicker()); + document.getElementById('habitCategory')!.addEventListener('change', (e) => this.handleCategoryChange('habit', (e.target as HTMLSelectElement).value)); + document.getElementById('habitCategorySave')!.addEventListener('click', () => this.handleAddCategory('habit')); + document.getElementById('habitCategoryCancel')!.addEventListener('click', () => this.cancelAddCategory('habit')); + + // Finances section + document.getElementById('addExpenseBtn')!.addEventListener('click', () => this.openFinanceModal('expense')); + document.getElementById('addRevenueBtn')!.addEventListener('click', () => this.openFinanceModal('revenue')); + document.getElementById('addChargeBtn')!.addEventListener('click', () => this.openFinanceModal('charge')); + document.getElementById('financeForm')!.addEventListener('submit', (e) => this.saveFinance(e)); + document.getElementById('cancelFinanceBtn')!.addEventListener('click', () => this.closeFinanceModal()); + document.getElementById('deleteFinanceBtn')!.addEventListener('click', () => this.deleteFinance()); + document.getElementById('financeCategory')!.addEventListener('change', (e) => this.handleCategoryChange('finance', (e.target as HTMLSelectElement).value)); + document.getElementById('financeCategorySave')!.addEventListener('click', () => this.handleAddCategory('finance')); + document.getElementById('financeCategoryCancel')!.addEventListener('click', () => this.cancelAddCategory('finance')); + + document.querySelectorAll('.finance-tab').forEach(tab => { + tab.addEventListener('click', (e) => this.switchFinanceTab((e.target as HTMLElement).dataset.financeTab!)); + }); + + // Finance filters + document.getElementById('filterFinancesBtn')!.addEventListener('click', () => this.renderFinances()); + document.getElementById('resetFinanceFilterBtn')!.addEventListener('click', () => this.resetFinanceFilter()); + + // Shop section + document.getElementById('addRewardBtn')!.addEventListener('click', () => this.openRewardModal()); + document.getElementById('rewardForm')!.addEventListener('submit', (e) => this.saveReward(e)); + document.getElementById('cancelRewardBtn')!.addEventListener('click', () => this.closeRewardModal()); + document.getElementById('deleteRewardBtn')!.addEventListener('click', () => this.deleteReward()); + + // Modal close buttons + document.querySelectorAll('.close-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + (e.target as HTMLElement).closest('.modal')!.classList.remove('active'); + }); + }); + + // Settings + document.getElementById('saveTasksPerLevel')!.addEventListener('click', () => this.saveTasksPerLevel()); + document.getElementById('exportBtn')!.addEventListener('click', () => this.exportData()); + document.getElementById('importBtn')!.addEventListener('click', () => document.getElementById('importFile')!.click()); + document.getElementById('importFile')!.addEventListener('change', (e) => this.importData(e)); + document.getElementById('clearDataBtn')!.addEventListener('click', () => { + if (storage.clearAllData()) { + location.reload(); + } + }); + + // Modal backdrop click + document.querySelectorAll('.modal').forEach(modal => { + modal.addEventListener('click', (e) => { + if (e.target === modal) { + modal.classList.remove('active'); + } + }); + }); + + // Hamburger menu toggle + document.getElementById('hamburgerMenu')!.addEventListener('click', () => { + const navTabs = document.getElementById('navTabs')!; + const hamburger = document.getElementById('hamburgerMenu')!; + navTabs.classList.toggle('show'); + hamburger.classList.toggle('active'); + }); + + // Close mobile menu when a tab is clicked + document.querySelectorAll('.nav-tab').forEach(tab => { + tab.addEventListener('click', () => { + if (window.innerWidth <= 768) { + const navTabs = document.getElementById('navTabs')!; + const hamburger = document.getElementById('hamburgerMenu')!; + navTabs.classList.remove('show'); + hamburger.classList.remove('active'); + } + }); + }); + + // Close mobile menu when clicking outside + document.addEventListener('click', (e) => { + if (window.innerWidth <= 768) { + const navTabs = document.getElementById('navTabs')!; + const hamburger = document.getElementById('hamburgerMenu')!; + if (!navTabs.contains(e.target as Node) && !hamburger.contains(e.target as Node)) { + navTabs.classList.remove('show'); + hamburger.classList.remove('active'); + } + } + }); + + // Date navigator + document.getElementById('prevDayBtn')!.addEventListener('click', () => this.navigateDate(-1)); + document.getElementById('nextDayBtn')!.addEventListener('click', () => this.navigateDate(1)); + document.getElementById('goTodayBtn')!.addEventListener('click', () => { + this.selectedDate = new Date(); + this.updateDateNavigator(); + this.render(); + }); + } + + // ======================== + // Tab Navigation + // ======================== + switchTab(tabName: string): void { + // Hide all tabs + document.querySelectorAll('.tab-content').forEach(tab => { + tab.classList.remove('active'); + }); + + // Remove active from all nav tabs + document.querySelectorAll('.nav-tab').forEach(tab => { + tab.classList.remove('active'); + }); + + // Show selected tab + document.getElementById(`${tabName}-tab`)!.classList.add('active'); + document.querySelector(`[data-tab="${tabName}"]`)!.classList.add('active'); + + // Re-render based on tab + if (tabName === 'dashboard') { + this.renderDashboard(); + } else if (tabName === 'tasks') { + this.renderTasks(); + } else if (tabName === 'projects') { + this.renderProjects(); + } else if (tabName === 'habits') { + this.renderHabits(); + } else if (tabName === 'finances') { + this.renderFinances(); + } else if (tabName === 'shop') { + this.renderShop(); + } else if (tabName === 'settings') { + this.renderSettings(); + } + } + + switchFinanceTab(tabName: string): void { + document.querySelectorAll('.finance-content').forEach(tab => { + tab.classList.remove('active'); + }); + + document.querySelectorAll('.finance-tab').forEach(tab => { + tab.classList.remove('active'); + }); + + document.getElementById(`${tabName}-content`)!.classList.add('active'); + document.querySelector(`[data-finance-tab="${tabName}"]`)!.classList.add('active'); + } + + // ======================== + // Dashboard Rendering + // ======================== + renderDashboard(): void { + const today = this.getSelectedDateStr(); + const tasks = storage.getTasks(); + const habits = storage.getHabits(); + const userStats = storage.getUserStats(); + + // Update header stats + document.getElementById('totalPoints')!.textContent = String(userStats.totalPoints); + document.getElementById('userLevel')!.textContent = String(userStats.level); + document.getElementById('dailyStreak')!.textContent = String(userStats.dailyStreak); + + // Update level progress + const settings = storage.getSettings(); + const completedTasksCount = tasks.filter(t => t.completed).length; + const tasksInCurrentLevel = completedTasksCount % settings.tasksPerLevel; + const tasksNeeded = settings.tasksPerLevel; + document.getElementById('levelProgress')!.textContent = `${tasksInCurrentLevel}/${tasksNeeded} tasks`; + + // Selected day's overview + const todayTasks = tasks.filter(t => t.dueDate === today && !t.completed); + const completedToday = tasks.filter(t => t.completedDate === today); + const todayDay = this.selectedDate.getDay(); + + // Find incomplete habits for selected day + const incompleteHabits = habits.filter(habit => { + const isValidDay = !habit.daysOfWeek || habit.daysOfWeek.includes(todayDay); + if (!isValidDay) return false; + const todaysCompletions = storage.countHabitCompletionsForDate(habit.id, today); + const targetGoal = habit.targetGoal || 1; + return todaysCompletions < targetGoal; + }); + + document.getElementById('todayTasksCount')!.textContent = String(todayTasks.length); + document.getElementById('completedTodayCount')!.textContent = String(completedToday.length); + document.getElementById('incompleteHabitsCount')!.textContent = String(incompleteHabits.length); + + // Render incomplete habits list + const incompleteHabitsList = document.getElementById('incompleteHabitsList')!; + if (incompleteHabits.length === 0) { + incompleteHabitsList.innerHTML = '

    All habits completed for today! 🎉

    '; + } else { + incompleteHabitsList.innerHTML = incompleteHabits.map(habit => { + const todaysCompletions = storage.countHabitCompletionsForDate(habit.id, today); + const targetGoal = habit.targetGoal || 1; + const percentage = Math.min(100, Math.round((todaysCompletions / targetGoal) * 100)); + return ` +
    +
    +
    + ${habit.icon} + ${habit.name} +
    + ${todaysCompletions}/${targetGoal} (${percentage}%) +
    +
    + `; + }).join(''); + + // Add click handlers to navigate to habits + document.querySelectorAll('.habit-item').forEach(item => { + item.addEventListener('click', () => { + this.switchTab('habits'); + }); + }); + } + + // Recent activity + this.renderRecentActivity(); + + // Active projects + this.renderProjectsSummary(); + } + + renderRecentActivity(): void { + const data = storage.getData(); + const activities: Activity[] = []; + + // Collect activities from tasks + data.tasks.filter(t => t.completedDate).forEach(t => { + activities.push({ + type: 'task', + message: `Completed task: ${t.title}`, + date: t.completedDate!, + icon: '✓' + }); + }); + + // Collect activities from habits + if (data.dailyHabitLogs) { + data.dailyHabitLogs.slice(-10).forEach(log => { + const habit = data.habits.find(h => h.id === log.habitId); + if (habit) { + activities.push({ + type: 'habit', + message: `Completed habit: ${habit.name}`, + date: log.date, + icon: '⭐' + }); + } + }); + } + + // Sort by date + activities.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + + const activityList = document.getElementById('recentActivity')!; + if (activities.length === 0) { + activityList.innerHTML = '

    No activity yet. Start completing tasks!

    '; + } else { + activityList.innerHTML = activities.slice(0, 5).map(activity => ` +
    +
    ${activity.icon} ${activity.message}
    +
    ${activity.date}
    +
    + `).join(''); + } + } + + renderProjectsSummary(): void { + const projects = storage.getProjects(); + const tasks = storage.getTasks(); + const container = document.getElementById('projectsSummary')!; + + if (projects.length === 0) { + container.innerHTML = '

    No active projects. Create one to organize your tasks!

    '; + return; + } + + container.innerHTML = projects.map(project => { + const projectTasks = tasks.filter(t => t.projectId === project.id); + const completedTasks = projectTasks.filter(t => t.completed); + const percentage = projectTasks.length === 0 ? 0 : Math.round((completedTasks.length / projectTasks.length) * 100); + + return ` +
    +
    ${project.name}
    +
    +
    +
    +
    ${completedTasks.length}/${projectTasks.length} tasks (${percentage}%)
    +
    + `; + }).join(''); + } + + // ======================== + // Tasks Management + // ======================== + toggleTaskView(): void { + this.tasksExpanded = !this.tasksExpanded; + const btn = document.getElementById('toggleTaskViewBtn')!; + btn.textContent = this.tasksExpanded ? '⊟ Collapse Details' : '⊞ Expand Details'; + document.getElementById('taskList')!.classList.toggle('expanded', this.tasksExpanded); + } + + renderTasks(): void { + this.updateCategoryFilter(); + this.filterTasks(); + } + + filterTasks(): void { + const tasks = storage.getTasks(); + const categoryFilter = (document.getElementById('categoryFilter') as HTMLSelectElement).value; + const statusFilter = (document.getElementById('statusFilter') as HTMLSelectElement).value; + const searchTerm = (document.getElementById('searchTasks') as HTMLInputElement).value.toLowerCase(); + const today = this.getSelectedDateStr(); + const filtersActive = categoryFilter || statusFilter || searchTerm; + + let filtered = tasks.filter(task => { + // Category filter + if (categoryFilter && task.category !== categoryFilter) return false; + + // Status filter + if (statusFilter) { + if (statusFilter === 'completed' && !task.completed) return false; + if (statusFilter === 'pending' && task.completed) return false; + if (statusFilter === 'overdue' && (!task.dueDate || task.completed || task.dueDate >= today)) return false; + } + + // Search filter + if (searchTerm && !task.title.toLowerCase().includes(searchTerm) && !task.description?.toLowerCase().includes(searchTerm)) return false; + + return true; + }); + + const taskList = document.getElementById('taskList')!; + if (filtered.length === 0) { + taskList.innerHTML = '

    No tasks found.

    '; + return; + } + + let html = ''; + + if (filtersActive) { + html = filtered.map(task => this.renderTaskItem(task)).join(''); + } else { + const priorityOrder: Record = { high: 0, medium: 1, low: 2 }; + + const dueToday = filtered.filter(task => + !task.dueDate || task.dueDate <= today + ); + + const upcoming = filtered + .filter(task => + !task.completed && + task.dueDate && + task.dueDate > today && + task.repeatType !== 'daily' + ) + .sort((a, b) => { + if (a.dueDate! < b.dueDate!) return -1; + if (a.dueDate! > b.dueDate!) return 1; + return (priorityOrder[a.priority] ?? 1) - (priorityOrder[b.priority] ?? 1); + }); + + if (dueToday.length > 0) { + html += `

    Tasks Due Today

    `; + html += dueToday.map(task => this.renderTaskItem(task)).join(''); + } + + if (upcoming.length > 0) { + html += `

    Upcoming Tasks

    `; + html += upcoming.map(task => this.renderTaskItem(task)).join(''); + } + + if (!html) { + html = '

    No tasks found.

    '; + } + } + + taskList.innerHTML = html; + + // Add event listeners to task items + document.querySelectorAll('.task-checkbox').forEach(checkbox => { + checkbox.addEventListener('change', (e) => { + const taskId = (e.target as HTMLInputElement).dataset.taskId!; + this.toggleTask(taskId); + }); + }); + + document.querySelectorAll('.task-item').forEach(item => { + item.addEventListener('click', (e) => { + if (!(e.target as HTMLElement).classList.contains('task-checkbox')) { + this.openTaskModal((item as HTMLElement).dataset.taskId!); + } + }); + }); + } + + renderTaskItem(task: Task): string { + const today = storage.formatDate(new Date()); + let status = 'pending'; + if (task.completed) { + status = 'completed'; + } else if (task.dueDate && task.dueDate < today) { + status = 'overdue'; + } + + return ` +
    + +
    +
    ${task.title}
    + ${task.description ? `
    ${task.description}
    ` : ''} +
    + ${task.category ? `📂 ${task.category}` : ''} + ${task.dueDate ? `📅 ${task.dueDate}` : ''} + ${task.priority} + ${task.repeatType !== 'none' ? `🔁 ${task.repeatType}` : ''} + ${task.points ? `⭐ ${task.points} pts` : ''} +
    +
    +
    ${status}
    +
    + `; + } + + updateCategoryFilter(): void { + const tasks = storage.getTasks(); + const categories = [...new Set(tasks.map(t => t.category).filter((c): c is string => !!c))]; + const select = document.getElementById('categoryFilter') as HTMLSelectElement; + const currentValue = select.value; + select.innerHTML = '' + + categories.map(cat => ``).join(''); + select.value = currentValue; + } + + openTaskModal(taskId: string | null = null): void { + this.currentEditingTaskId = taskId; + const modal = document.getElementById('taskModal')!; + const form = document.getElementById('taskForm') as HTMLFormElement; + const deleteBtn = document.getElementById('deleteTaskBtn') as HTMLElement; + + form.reset(); + deleteBtn.style.display = 'none'; + + // Update project dropdown + this.updateProjectSelect(); + + // Clear all task day checkboxes + document.querySelectorAll('input[name="taskDay"]').forEach(checkbox => { + checkbox.checked = false; + }); + + if (taskId) { + const task = storage.getTasks().find(t => t.id === taskId); + if (task) { + (document.getElementById('taskTitle') as HTMLInputElement).value = task.title; + (document.getElementById('taskDescription') as HTMLTextAreaElement).value = task.description || ''; + (document.getElementById('taskDueDate') as HTMLInputElement).value = task.dueDate || ''; + (document.getElementById('taskCategory') as HTMLSelectElement).value = task.category || ''; + (document.getElementById('taskPriority') as HTMLSelectElement).value = task.priority || 'medium'; + (document.getElementById('taskPoints') as HTMLInputElement).value = String(task.points || 10); + (document.getElementById('taskRepeatType') as HTMLSelectElement).value = task.repeatType || 'none'; + (document.getElementById('taskProject') as HTMLSelectElement).value = task.projectId || ''; + (document.getElementById('taskRepeatUnit') as HTMLInputElement).value = String(task.repeatUnit || 1); + + if (task.repeatType === 'custom') { + (document.getElementById('customRepeatDays') as HTMLInputElement).value = String(task.customRepeatDays || ''); + } + if (task.repeatType === 'movable') { + (document.getElementById('movableRepeatDays') as HTMLInputElement).value = String(task.movableRepeatDays || ''); + } + + // Load daysOfWeek if available + if (task.daysOfWeek && Array.isArray(task.daysOfWeek)) { + task.daysOfWeek.forEach(day => { + const checkbox = document.querySelector(`input[name="taskDay"][value="${day}"]`); + if (checkbox) { + checkbox.checked = true; + } + }); + } + + deleteBtn.style.display = 'block'; + + this.updateRepeatTypeUI(task.repeatType); + } + } else { + (document.getElementById('taskRepeatUnit') as HTMLInputElement).value = '1'; + } + + // Load categories + this.loadCategoryDropdown('task'); + + modal.classList.add('active'); + } + + closeTaskModal(): void { + document.getElementById('taskModal')!.classList.remove('active'); + this.currentEditingTaskId = null; + + // If project detail modal is open, refresh it + if (document.getElementById('projectDetailModal')!.classList.contains('active')) { + this.openProjectDetailModal(this.currentEditingProjectId!); + } + } + + updateRepeatTypeUI(repeatType: string): void { + const customGroup = document.getElementById('customRepeatGroup') as HTMLElement; + const movableGroup = document.getElementById('movableRepeatGroup') as HTMLElement; + const repeatUnitGroup = document.getElementById('repeatUnitGroup') as HTMLElement; + const taskDaysGroup = document.getElementById('taskDaysGroup') as HTMLElement; + const repeatUnitLabel = document.getElementById('repeatUnitLabel') as HTMLElement; + + customGroup.style.display = repeatType === 'custom' ? 'block' : 'none'; + movableGroup.style.display = repeatType === 'movable' ? 'block' : 'none'; + + // Show repeat unit for daily, weekly, monthly, yearly + if (['daily', 'weekly', 'monthly', 'yearly'].includes(repeatType)) { + repeatUnitGroup.style.display = 'block'; + const labels: Record = { + daily: 'day(s)', + weekly: 'week(s)', + monthly: 'month(s)', + yearly: 'year(s)' + }; + repeatUnitLabel.textContent = labels[repeatType] || 'unit(s)'; + } else { + repeatUnitGroup.style.display = 'none'; + } + + // Show days selector for weekly and daily + taskDaysGroup.style.display = (['weekly', 'daily'].includes(repeatType)) ? 'block' : 'none'; + } + + updateProjectSelect(): void { + const projects = storage.getProjects(); + const select = document.getElementById('taskProject') as HTMLSelectElement; + select.innerHTML = '' + + projects.map(p => ``).join(''); + } + + saveTask(e: Event): void { + e.preventDefault(); + + // Get selected days for weekly/daily tasks + const selectedDays = Array.from(document.querySelectorAll('input[name="taskDay"]:checked')) + .map(checkbox => parseInt(checkbox.value)); + + const task: Partial = { + title: (document.getElementById('taskTitle') as HTMLInputElement).value, + description: (document.getElementById('taskDescription') as HTMLTextAreaElement).value, + dueDate: (document.getElementById('taskDueDate') as HTMLInputElement).value, + category: (document.getElementById('taskCategory') as HTMLSelectElement).value, + priority: (document.getElementById('taskPriority') as HTMLSelectElement).value as Task['priority'], + points: parseInt((document.getElementById('taskPoints') as HTMLInputElement).value), + repeatType: (document.getElementById('taskRepeatType') as HTMLSelectElement).value as Task['repeatType'], + projectId: (document.getElementById('taskProject') as HTMLSelectElement).value || null + }; + + // Add repeatUnit for daily, weekly, monthly, yearly tasks + if (['daily', 'weekly', 'monthly', 'yearly'].includes(task.repeatType!)) { + task.repeatUnit = parseInt((document.getElementById('taskRepeatUnit') as HTMLInputElement).value) || 1; + if (selectedDays.length > 0) { + task.daysOfWeek = selectedDays; + } + } + + if (task.repeatType === 'custom') { + task.customRepeatDays = parseInt((document.getElementById('customRepeatDays') as HTMLInputElement).value); + } + if (task.repeatType === 'movable') { + task.movableRepeatDays = parseInt((document.getElementById('movableRepeatDays') as HTMLInputElement).value); + } + + if (this.currentEditingTaskId) { + storage.updateTask(this.currentEditingTaskId, task); + } else { + storage.addTask(task); + } + + this.closeTaskModal(); + this.renderTasks(); + this.renderProjects(); + } + + deleteTask(): void { + if (this.currentEditingTaskId) { + if (confirm('Are you sure you want to delete this task?')) { + storage.deleteTask(this.currentEditingTaskId); + this.closeTaskModal(); + this.renderTasks(); + this.renderProjects(); + } + } + } + + toggleTask(taskId: string): void { + const task = storage.getTasks().find(t => t.id === taskId); + if (task) { + task.completed = !task.completed; + if (task.completed) { + task.completedDate = this.getSelectedDateStr(); + storage.addPoints(task.points, 'tasks'); + storage.updateDailyStreak(true); + // If repeatable, immediately create next task with recalculated due date + if (task.repeatType !== 'none') { + this.createNextRecurringTask(task); + } + } else { + // If uncompleting, also recalculate level + task.completedDate = null; + } + storage.updateTask(taskId, task); + storage.updateLevel(); + this.renderTasks(); + this.renderDashboard(); + this.renderProjects(); + } + } + + nextOccurrenceOfDays(fromDate: Date, days: number[]): Date { + const next = new Date(fromDate); + for (let i = 1; i <= 7; i++) { + next.setDate(next.getDate() + 1); + if (days.includes(next.getDay())) { + return next; + } + } + return next; + } + + createNextRecurringTask(completedTask: Task): void { + const [year, month, day] = completedTask.completedDate!.split('-').map(Number); + const completionDate = new Date(year, month - 1, day); + const nextDueDate = new Date(completionDate); + const hasDaysOfWeek = completedTask.daysOfWeek && completedTask.daysOfWeek.length > 0; + + switch (completedTask.repeatType) { + case 'daily': + if (hasDaysOfWeek) { + const next = this.nextOccurrenceOfDays(completionDate, completedTask.daysOfWeek!); + nextDueDate.setTime(next.getTime()); + } else { + nextDueDate.setDate(nextDueDate.getDate() + (completedTask.repeatUnit || 1)); + } + break; + case 'weekly': + if (hasDaysOfWeek) { + const next = this.nextOccurrenceOfDays(completionDate, completedTask.daysOfWeek!); + next.setDate(next.getDate() + 7 * ((completedTask.repeatUnit || 1) - 1)); + nextDueDate.setTime(next.getTime()); + } else { + nextDueDate.setDate(nextDueDate.getDate() + 7 * (completedTask.repeatUnit || 1)); + } + break; + case 'monthly': + nextDueDate.setMonth(nextDueDate.getMonth() + (completedTask.repeatUnit || 1)); + break; + case 'yearly': + nextDueDate.setFullYear(nextDueDate.getFullYear() + (completedTask.repeatUnit || 1)); + break; + case 'custom': + nextDueDate.setDate(nextDueDate.getDate() + (completedTask.customRepeatDays || 1)); + break; + case 'movable': + nextDueDate.setDate(nextDueDate.getDate() + (completedTask.movableRepeatDays || 1)); + break; + default: + return; + } + + const newTask: Partial = { + title: completedTask.title, + description: completedTask.description, + category: completedTask.category, + priority: completedTask.priority, + points: completedTask.points, + projectId: completedTask.projectId, + repeatType: completedTask.repeatType, + repeatUnit: completedTask.repeatUnit, + customRepeatDays: completedTask.customRepeatDays, + movableRepeatDays: completedTask.movableRepeatDays, + daysOfWeek: completedTask.daysOfWeek, + dueDate: storage.formatDate(nextDueDate) + }; + + storage.addTask(newTask); + } + + // ======================== + // Projects Management + // ======================== + renderProjects(): void { + const projects = storage.getProjects(); + const container = document.getElementById('projectsList')!; + + if (projects.length === 0) { + container.innerHTML = '

    No projects yet. Create one to organize your tasks!

    '; + return; + } + + container.innerHTML = projects.map(project => this.renderProjectCard(project)).join(''); + + document.querySelectorAll('.project-card').forEach(card => { + card.addEventListener('click', () => { + this.openProjectDetailModal((card as HTMLElement).dataset.projectId!); + }); + }); + } + + renderProjectCard(project: { id: string; name: string; description?: string; color?: string }): string { + const tasks = storage.getTasks().filter(t => t.projectId === project.id); + const completed = tasks.filter(t => t.completed).length; + const percentage = tasks.length === 0 ? 0 : Math.round((completed / tasks.length) * 100); + + return ` +
    +
    ${project.name}
    + ${project.description ? `
    ${project.description}
    ` : ''} +
    +
    + Tasks + ${tasks.length} +
    +
    + Completed + ${completed} +
    +
    + Progress + ${percentage}% +
    +
    +
    +
    +
    +
    + `; + } + + openProjectModal(projectId: string | null = null): void { + this.currentEditingProjectId = projectId; + const modal = document.getElementById('projectModal')!; + const form = document.getElementById('projectForm') as HTMLFormElement; + const deleteBtn = document.getElementById('deleteProjectBtn') as HTMLElement; + + form.reset(); + deleteBtn.style.display = 'none'; + + if (projectId) { + const project = storage.getProjects().find(p => p.id === projectId); + if (project) { + (document.getElementById('projectName') as HTMLInputElement).value = project.name; + (document.getElementById('projectDescription') as HTMLTextAreaElement).value = project.description || ''; + (document.getElementById('projectColor') as HTMLSelectElement).value = project.color || 'blue'; + deleteBtn.style.display = 'block'; + } + } + + modal.classList.add('active'); + } + + closeProjectModal(): void { + document.getElementById('projectModal')!.classList.remove('active'); + this.currentEditingProjectId = null; + } + + saveProject(e: Event): void { + e.preventDefault(); + + const project = { + name: (document.getElementById('projectName') as HTMLInputElement).value, + description: (document.getElementById('projectDescription') as HTMLTextAreaElement).value, + color: (document.getElementById('projectColor') as HTMLSelectElement).value + }; + + if (this.currentEditingProjectId) { + storage.updateProject(this.currentEditingProjectId, project); + } else { + storage.addProject(project); + } + + this.closeProjectModal(); + this.renderProjects(); + this.updateProjectSelect(); + } + + deleteProject(): void { + if (this.currentEditingProjectId) { + if (confirm('Are you sure you want to delete this project? This will not delete the tasks in it.')) { + storage.deleteProject(this.currentEditingProjectId); + this.closeProjectModal(); + this.renderProjects(); + } + } + } + + openProjectDetailModal(projectId: string): void { + const project = storage.getProjects().find(p => p.id === projectId); + if (!project) return; + + this.currentEditingProjectId = projectId; + const modal = document.getElementById('projectDetailModal')!; + + document.getElementById('projectDetailTitle')!.textContent = project.name; + document.getElementById('projectDetailDescription')!.textContent = project.description || 'No description'; + + const tasks = storage.getTasks().filter(t => t.projectId === projectId); + const completed = tasks.filter(t => t.completed).length; + const percentage = tasks.length === 0 ? 0 : Math.round((completed / tasks.length) * 100); + + document.getElementById('projectDetailTotalTasks')!.textContent = String(tasks.length); + document.getElementById('projectDetailCompletedTasks')!.textContent = String(completed); + document.getElementById('projectDetailProgress')!.textContent = percentage + '%'; + + this.renderProjectDetailTasks(tasks); + + modal.classList.add('active'); + } + + closeProjectDetailModal(): void { + document.getElementById('projectDetailModal')!.classList.remove('active'); + this.currentEditingProjectId = null; + } + + editProjectFromDetail(): void { + const projectId = this.currentEditingProjectId; + this.closeProjectDetailModal(); + this.openProjectModal(projectId); + } + + renderProjectDetailTasks(tasks: Task[]): void { + const container = document.getElementById('projectDetailTaskList')!; + + if (tasks.length === 0) { + container.innerHTML = '

    No tasks in this project yet.

    '; + return; + } + + container.innerHTML = tasks.map(task => ` +
    + +
    +
    ${task.title}
    +
    + ${task.dueDate ? `📅 ${task.dueDate}` : ''} + ${task.priority ? `⚡ ${task.priority}` : ''} + ${task.category ? `📂 ${task.category}` : ''} +
    +
    +
    + `).join(''); + + document.querySelectorAll('#projectDetailTaskList .task-item').forEach(item => { + const checkbox = item.querySelector('.task-checkbox')!; + checkbox.addEventListener('click', (e) => { + e.stopPropagation(); + this.toggleTask((item as HTMLElement).dataset.taskId!); + this.openProjectDetailModal(this.currentEditingProjectId!); + }); + + item.addEventListener('click', (e) => { + if (!(e.target as HTMLElement).classList.contains('task-checkbox')) { + this.closeProjectDetailModal(); + this.switchTab('tasks'); + this.openTaskModal((item as HTMLElement).dataset.taskId!); + } + }); + }); + } + + // ======================== + // Habits Management + // ======================== + renderHabits(): void { + const habits = storage.getHabits(); + const container = document.getElementById('habitsList')!; + + if (habits.length === 0) { + container.innerHTML = '

    No habits yet. Create daily habits to build streaks!

    '; + return; + } + + container.innerHTML = habits.map(habit => this.renderHabitCard(habit)).join(''); + + document.querySelectorAll('.habit-card').forEach(card => { + card.addEventListener('click', (e) => { + if (!(e.target as HTMLElement).classList.contains('habit-checkbox')) { + this.openHabitModal((card as HTMLElement).dataset.habitId!); + } + }); + }); + + document.querySelectorAll('.habit-checkbox').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const habitId = (e.target as HTMLElement).dataset.habitId!; + this.completeHabit(habitId); + }); + }); + } + + renderHabitCard(habit: Habit): string { + const selectedDayOfWeek = this.selectedDate.getDay(); + const isValidDay = !habit.daysOfWeek || habit.daysOfWeek.includes(selectedDayOfWeek); + const selectedDateStr = this.getSelectedDateStr(); + const isPastDay = !this.isSelectedDateToday(); + + const todaysCompletions = storage.countHabitCompletionsForDate(habit.id, selectedDateStr); + const targetGoal = habit.targetGoal || 1; + const percentage = Math.min(100, Math.round((todaysCompletions / targetGoal) * 100)); + const isComplete = todaysCompletions >= targetGoal; + + const btnLabel = !isValidDay + ? '✗ Not Scheduled' + : isComplete + ? (isPastDay ? '✓ Logged' : '✓ Done for Today') + : (isPastDay ? '+ Log Past Day' : '+ Complete'); + + return ` +
    +
    ${habit.icon}
    +
    ${habit.name}
    + ${habit.description ? `
    ${habit.description}
    ` : ''} +
    +
    + Streak + ${habit.streak || 0} +
    +
    + Points + ${habit.points} +
    +
    + Progress + ${todaysCompletions}/${targetGoal} +
    +
    +
    +
    +
    +
    +
    + ${isComplete ? '✓ Complete!' : `${percentage}% Complete`} +
    +
    + +
    + `; + } + + openHabitModal(habitId: string | null = null): void { + this.currentEditingHabitId = habitId; + const modal = document.getElementById('habitModal')!; + const form = document.getElementById('habitForm') as HTMLFormElement; + const deleteBtn = document.getElementById('deleteHabitBtn') as HTMLElement; + + form.reset(); + deleteBtn.style.display = 'none'; + (document.getElementById('habitIcon') as HTMLInputElement).value = '⭐'; + document.getElementById('habitIconDisplay')!.textContent = '⭐'; + + document.querySelectorAll('input[name="habitDay"]').forEach(checkbox => { + checkbox.checked = false; + }); + + if (habitId) { + const habit = storage.getHabits().find(h => h.id === habitId); + if (habit) { + (document.getElementById('habitName') as HTMLInputElement).value = habit.name; + (document.getElementById('habitDescription') as HTMLTextAreaElement).value = habit.description || ''; + (document.getElementById('habitIcon') as HTMLInputElement).value = habit.icon || '⭐'; + document.getElementById('habitIconDisplay')!.textContent = habit.icon || '⭐'; + (document.getElementById('habitCategory') as HTMLSelectElement).value = habit.category || ''; + (document.getElementById('habitPoints') as HTMLInputElement).value = String(habit.points || 5); + (document.getElementById('habitTargetGoal') as HTMLInputElement).value = String(habit.targetGoal || 1); + deleteBtn.style.display = 'block'; + + if (habit.daysOfWeek && Array.isArray(habit.daysOfWeek)) { + habit.daysOfWeek.forEach(day => { + const checkbox = document.querySelector(`input[name="habitDay"][value="${day}"]`); + if (checkbox) { + checkbox.checked = true; + } + }); + } else { + document.querySelectorAll('input[name="habitDay"]').forEach(checkbox => { + checkbox.checked = true; + }); + } + } + } else { + document.querySelectorAll('input[name="habitDay"]').forEach(checkbox => { + checkbox.checked = true; + }); + } + + this.loadCategoryDropdown('habit'); + modal.classList.add('active'); + } + + closeHabitModal(): void { + document.getElementById('habitModal')!.classList.remove('active'); + this.currentEditingHabitId = null; + } + + saveHabit(e: Event): void { + e.preventDefault(); + + const selectedDays = Array.from(document.querySelectorAll('input[name="habitDay"]:checked')) + .map(checkbox => parseInt(checkbox.value)); + + const habit: Partial = { + name: (document.getElementById('habitName') as HTMLInputElement).value, + description: (document.getElementById('habitDescription') as HTMLTextAreaElement).value, + icon: (document.getElementById('habitIcon') as HTMLInputElement).value, + category: (document.getElementById('habitCategory') as HTMLSelectElement).value || null, + points: parseInt((document.getElementById('habitPoints') as HTMLInputElement).value), + targetGoal: parseInt((document.getElementById('habitTargetGoal') as HTMLInputElement).value) || 1, + daysOfWeek: selectedDays.length > 0 ? selectedDays : [0, 1, 2, 3, 4, 5, 6] + }; + + if (this.currentEditingHabitId) { + storage.updateHabit(this.currentEditingHabitId, habit); + } else { + storage.addHabit(habit); + } + + this.closeHabitModal(); + this.renderHabits(); + } + + deleteHabit(): void { + if (this.currentEditingHabitId) { + if (confirm('Are you sure you want to delete this habit?')) { + storage.deleteHabit(this.currentEditingHabitId); + this.closeHabitModal(); + this.renderHabits(); + } + } + } + + completeHabit(habitId: string): void { + const habit = storage.getHabits().find(h => h.id === habitId); + if (habit) { + const selectedDayOfWeek = this.selectedDate.getDay(); + const isValidDay = !habit.daysOfWeek || habit.daysOfWeek.includes(selectedDayOfWeek); + + if (isValidDay) { + storage.logHabitCompletion(habitId, this.selectedDate); + storage.addPoints(habit.points, 'habits'); + storage.updateDailyStreak(true); + this.renderHabits(); + this.renderDashboard(); + } + } + } + + openEmojiPicker(): void { + const modal = document.getElementById('emojiModal')!; + const emojiGrid = document.getElementById('emojiGrid')!; + + emojiGrid.innerHTML = this.emojis.map(emoji => + `` + ).join(''); + + document.querySelectorAll('.emoji-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + e.preventDefault(); + this.selectEmoji((e.target as HTMLElement).dataset.emoji!); + }); + }); + + modal.classList.add('active'); + } + + closeEmojiPicker(): void { + document.getElementById('emojiModal')!.classList.remove('active'); + } + + selectEmoji(emoji: string): void { + (document.getElementById('habitIcon') as HTMLInputElement).value = emoji; + document.getElementById('habitIconDisplay')!.textContent = emoji; + this.closeEmojiPicker(); + } + + // ======================== + // Category Management + // ======================== + loadCategoryDropdown(type: string): void { + const select = document.getElementById(`${type}Category`) as HTMLSelectElement; + const categories = storage.getCategories(); + + const currentValue = select.value; + + const emptyOption = document.createElement('option'); + emptyOption.value = ''; + emptyOption.textContent = 'Select category...'; + + const addNewOption = document.createElement('option'); + addNewOption.value = '__add_new__'; + addNewOption.textContent = '+ Add New Category'; + + select.innerHTML = ''; + select.appendChild(emptyOption); + + categories.forEach(cat => { + const option = document.createElement('option'); + option.value = cat; + option.textContent = cat; + select.appendChild(option); + }); + + select.appendChild(addNewOption); + select.value = currentValue; + } + + handleCategoryChange(type: string, value: string): void { + const inputDiv = document.getElementById(`${type}CategoryInput`) as HTMLElement; + const textInput = document.getElementById(`${type}CategoryText`) as HTMLInputElement; + + if (value === '__add_new__') { + inputDiv.style.display = 'block'; + textInput.value = ''; + textInput.focus(); + } else { + inputDiv.style.display = 'none'; + } + } + + handleAddCategory(type: string): void { + const textInput = document.getElementById(`${type}CategoryText`) as HTMLInputElement; + const categoryName = textInput.value.trim(); + + if (categoryName) { + if (storage.addCategory(categoryName)) { + this.loadCategoryDropdown(type); + const select = document.getElementById(`${type}Category`) as HTMLSelectElement; + select.value = categoryName; + (document.getElementById(`${type}CategoryInput`) as HTMLElement).style.display = 'none'; + textInput.value = ''; + } else { + alert('This category already exists!'); + } + } else { + alert('Please enter a category name'); + } + } + + cancelAddCategory(type: string): void { + (document.getElementById(`${type}CategoryInput`) as HTMLElement).style.display = 'none'; + (document.getElementById(`${type}CategoryText`) as HTMLInputElement).value = ''; + (document.getElementById(`${type}Category`) as HTMLSelectElement).value = ''; + } + + // ======================== + // Settings Category Management + // ======================== + renderCategoryManagement(): void { + const list = document.getElementById('categoryList'); + if (!list) return; + const categories = storage.getCategories(); + list.innerHTML = categories.length === 0 + ? '
  • No categories yet.
  • ' + : categories.map(cat => ` +
  • + ${this.escapeHtml(cat)} + + +
  • `).join(''); + } + + escapeHtml(str: string): string { + return String(str).replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); + } + + startEditCategory(name: string, btn: HTMLElement): void { + const li = btn.closest('li')!; + const nameSpan = li.querySelector('.category-name') as HTMLElement; + nameSpan.style.display = 'none'; + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'category-edit-input'; + input.value = name; + li.insertBefore(input, nameSpan); + btn.textContent = 'Save'; + (btn as any).onclick = () => this.saveEditCategory(name, input, btn); + const cancelBtn = document.createElement('button'); + cancelBtn.className = 'btn btn-secondary btn-sm'; + cancelBtn.textContent = 'Cancel'; + cancelBtn.onclick = () => this.renderCategoryManagement(); + btn.after(cancelBtn); + input.focus(); + } + + saveEditCategory(oldName: string, input: HTMLInputElement, _btn: HTMLElement): void { + const newName = input.value.trim(); + if (!newName) { + alert('Please enter a category name.'); + return; + } + if (newName === oldName) { + this.renderCategoryManagement(); + return; + } + if (!storage.updateCategory(oldName, newName)) { + alert('A category with that name already exists.'); + return; + } + this.renderCategoryManagement(); + } + + deleteCategoryItem(name: string): void { + if (!confirm(`Delete category "${name}"? All related items will have their category cleared.`)) return; + storage.deleteCategory(name); + this.renderCategoryManagement(); + } + + addCategoryFromSettings(): void { + const input = document.getElementById('newCategoryText') as HTMLInputElement; + const name = input.value.trim(); + if (!name) { + alert('Please enter a category name.'); + return; + } + if (!storage.addCategory(name)) { + alert('This category already exists.'); + return; + } + input.value = ''; + this.renderCategoryManagement(); + } + + // ======================== + // Finance Management + // ======================== + initializeFinanceDateFilter(): void { + const today = new Date(); + const firstDay = new Date(today.getFullYear(), today.getMonth(), 1); + const lastDay = new Date(today.getFullYear(), today.getMonth() + 1, 0); + + (document.getElementById('financeStartDate') as HTMLInputElement).valueAsDate = firstDay; + (document.getElementById('financeEndDate') as HTMLInputElement).valueAsDate = lastDay; + } + + resetFinanceFilter(): void { + this.initializeFinanceDateFilter(); + this.renderFinances(); + } + + getFinanceDateRange(): { startDate: string; endDate: string } { + const startDate = (document.getElementById('financeStartDate') as HTMLInputElement).value; + const endDate = (document.getElementById('financeEndDate') as HTMLInputElement).value; + return { startDate, endDate }; + } + + filterFinanceItemsByDate(items: FinanceItem[]): FilteredFinanceItem[] { + const { startDate, endDate } = this.getFinanceDateRange(); + + if (!startDate && !endDate) { + return items.map(item => ({ ...item, monthlyAmount: item.amount })); + } + + return items.filter(item => { + if (!item.date) return false; + + if (item.recurring === 'yearly' || item.recurring === 'monthly') { + if (endDate && item.date > endDate) return false; + return true; + } + + if (startDate && item.date < startDate) return false; + if (endDate && item.date > endDate) return false; + + return true; + }).map(item => ({ + ...item, + monthlyAmount: item.recurring === 'yearly' ? item.amount / 12 : item.amount + })); + } + + renderFinances(): void { + this.updateFinanceSummary(); + this.renderExpenses(); + this.renderRevenue(); + this.renderCharges(); + } + + updateFinanceSummary(): void { + const expenses = this.filterFinanceItemsByDate(storage.getExpenses()); + const revenue = this.filterFinanceItemsByDate(storage.getRevenue()); + const charges = this.filterFinanceItemsByDate(storage.getCharges()); + + const totalExpenses = expenses.reduce((sum, e) => sum + (e.monthlyAmount || 0), 0); + const totalCharges = charges.reduce((sum, c) => sum + (c.monthlyAmount || 0), 0); + const totalRevenue = revenue.reduce((sum, r) => sum + (r.monthlyAmount || 0), 0); + const net = totalRevenue - totalExpenses - totalCharges; + + document.getElementById('totalIncome')!.textContent = '$' + totalRevenue.toFixed(2); + document.getElementById('totalExpenses')!.textContent = '$' + (totalExpenses + totalCharges).toFixed(2); + document.getElementById('netBalance')!.textContent = '$' + net.toFixed(2); + } + + renderExpenses(): void { + const expenses = this.filterFinanceItemsByDate(storage.getExpenses()); + this.renderFinanceList(expenses, 'expensesList', 'expense'); + } + + renderRevenue(): void { + const revenue = this.filterFinanceItemsByDate(storage.getRevenue()); + this.renderFinanceList(revenue, 'revenueList', 'revenue', true); + } + + renderCharges(): void { + const charges = this.filterFinanceItemsByDate(storage.getCharges()); + this.renderFinanceList(charges, 'chargesList', 'charge'); + } + + renderFinanceList(items: FilteredFinanceItem[], containerId: string, type: string, isIncome: boolean = false): void { + const container = document.getElementById(containerId)!; + + if (items.length === 0) { + container.innerHTML = '

    No items. Add one to get started!

    '; + return; + } + + container.innerHTML = items.map(item => { + const displayAmount = (item.monthlyAmount !== undefined ? item.monthlyAmount : item.amount).toFixed(2); + const monthlyLabel = item.recurring === 'yearly' ? ' /mo' : ''; + return ` +
    +
    +
    ${item.description}
    +
    + ${item.category ? `📂 ${item.category}` : ''} + 📅 ${item.date || 'N/A'} + ${item.recurring ? `🔁 ${item.recurring}` : ''} +
    +
    +
    + ${isIncome ? '+' : '-'}$${displayAmount}${monthlyLabel} +
    +
    + `; + }).join(''); + + document.querySelectorAll('.finance-item').forEach(item => { + item.addEventListener('click', () => { + this.openFinanceModal((item as HTMLElement).dataset.financeType!, (item as HTMLElement).dataset.financeId!); + }); + }); + } + + openFinanceModal(type: string, financeId: string | null = null): void { + this.currentEditingFinanceType = type; + this.currentEditingFinanceId = financeId; + const modal = document.getElementById('financeModal')!; + const form = document.getElementById('financeForm') as HTMLFormElement; + const deleteBtn = document.getElementById('deleteFinanceBtn') as HTMLElement; + const recurringGroup = document.getElementById('financeRecurringGroup') as HTMLElement; + + form.reset(); + deleteBtn.style.display = 'none'; + (document.getElementById('financeDate') as HTMLInputElement).valueAsDate = new Date(); + + this.loadCategoryDropdown('finance'); + + recurringGroup.style.display = ['expense', 'revenue'].includes(type) ? 'block' : 'none'; + + const titles: Record = { expense: 'Add Expense', revenue: 'Add Revenue', charge: 'Add Other Charge' }; + document.getElementById('financeModalTitle')!.textContent = financeId ? `Edit ${type}` : titles[type]; + + if (financeId) { + let item: FinanceItem | undefined; + if (type === 'expense') item = storage.getExpenses().find(e => e.id === financeId); + else if (type === 'revenue') item = storage.getRevenue().find(r => r.id === financeId); + else if (type === 'charge') item = storage.getCharges().find(c => c.id === financeId); + + if (item) { + (document.getElementById('financeDescription') as HTMLInputElement).value = item.description; + (document.getElementById('financeAmount') as HTMLInputElement).value = String(item.amount); + (document.getElementById('financeDate') as HTMLInputElement).value = item.date || ''; + (document.getElementById('financeCategory') as HTMLSelectElement).value = item.category || ''; + if (item.recurring) { + (document.getElementById('financeRecurring') as HTMLSelectElement).value = item.recurring; + } + deleteBtn.style.display = 'block'; + } + } + + modal.classList.add('active'); + } + + closeFinanceModal(): void { + document.getElementById('financeModal')!.classList.remove('active'); + this.currentEditingFinanceId = null; + this.currentEditingFinanceType = null; + } + + saveFinance(e: Event): void { + e.preventDefault(); + + const financeItem: Partial = { + description: (document.getElementById('financeDescription') as HTMLInputElement).value, + amount: parseFloat((document.getElementById('financeAmount') as HTMLInputElement).value), + date: (document.getElementById('financeDate') as HTMLInputElement).value, + category: (document.getElementById('financeCategory') as HTMLSelectElement).value + }; + + if (['expense', 'revenue'].includes(this.currentEditingFinanceType!)) { + financeItem.recurring = (document.getElementById('financeRecurring') as HTMLSelectElement).value as FinanceItem['recurring']; + } + + if (this.currentEditingFinanceType === 'expense') { + if (this.currentEditingFinanceId) { + storage.updateExpense(this.currentEditingFinanceId, financeItem); + } else { + storage.addExpense(financeItem); + } + } else if (this.currentEditingFinanceType === 'revenue') { + if (this.currentEditingFinanceId) { + storage.updateRevenue(this.currentEditingFinanceId, financeItem); + } else { + storage.addRevenue(financeItem); + } + } else if (this.currentEditingFinanceType === 'charge') { + if (this.currentEditingFinanceId) { + storage.updateCharge(this.currentEditingFinanceId, financeItem); + } else { + storage.addCharge(financeItem); + } + } + + this.closeFinanceModal(); + this.renderFinances(); + } + + deleteFinance(): void { + if (this.currentEditingFinanceId) { + if (confirm('Are you sure you want to delete this item?')) { + if (this.currentEditingFinanceType === 'expense') { + storage.deleteExpense(this.currentEditingFinanceId); + } else if (this.currentEditingFinanceType === 'revenue') { + storage.deleteRevenue(this.currentEditingFinanceId); + } else if (this.currentEditingFinanceType === 'charge') { + storage.deleteCharge(this.currentEditingFinanceId); + } + this.closeFinanceModal(); + this.renderFinances(); + } + } + } + + // ======================== + // Shop/Rewards Management + // ======================== + renderShop(): void { + const rewards = storage.getRewards(); + const userStats = storage.getUserStats(); + const container = document.getElementById('rewardsList')!; + + document.getElementById('shopPointsDisplay')!.textContent = String(userStats.totalPoints); + + if (rewards.length === 0) { + container.innerHTML = '

    No rewards yet. Add rewards to spend your points on!

    '; + return; + } + + container.innerHTML = rewards.map(reward => { + let alreadyPurchased = false; + if (reward.repeatable === false) { + const purchaseHistory = storage.getData().purchaseHistory || []; + alreadyPurchased = purchaseHistory.some(ph => ph.rewardId === reward.id); + } + const disabled = userStats.totalPoints < reward.cost || alreadyPurchased; + let purchaseLabel = 'Purchase'; + if (userStats.totalPoints < reward.cost) purchaseLabel = 'Not Enough Points'; + if (alreadyPurchased) purchaseLabel = 'Purchased'; + + return ` +
    +
    ${reward.name}
    + ${reward.description ? `
    ${reward.description}
    ` : ''} +
    +
    + Cost + ${reward.cost} pts +
    +
    + ${reward.repeatable === false ? 'One-time' : 'Repeatable'} +
    +
    +
    + + +
    +
    + `; + }).join(''); + + document.querySelectorAll('.purchase-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const card = (e.target as HTMLElement).closest('[data-reward-id]') as HTMLElement; + this.purchaseReward(card.dataset.rewardId!); + }); + }); + + document.querySelectorAll('.edit-reward-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const card = (e.target as HTMLElement).closest('[data-reward-id]') as HTMLElement; + this.openRewardModal(card.dataset.rewardId!); + }); + }); + } + + openRewardModal(rewardId: string | null = null): void { + this.currentEditingRewardId = rewardId; + const modal = document.getElementById('rewardModal')!; + const form = document.getElementById('rewardForm') as HTMLFormElement; + const deleteBtn = document.getElementById('deleteRewardBtn') as HTMLElement; + + form.reset(); + deleteBtn.style.display = 'none'; + + document.getElementById('rewardModalTitle')!.textContent = rewardId ? 'Edit Reward' : 'Add Reward'; + + if (rewardId) { + const reward = storage.getRewards().find(r => r.id === rewardId); + if (reward) { + (document.getElementById('rewardName') as HTMLInputElement).value = reward.name; + (document.getElementById('rewardDescription') as HTMLTextAreaElement).value = reward.description || ''; + (document.getElementById('rewardCost') as HTMLInputElement).value = String(reward.cost); + (document.getElementById('rewardRepeatable') as HTMLSelectElement).value = String(reward.repeatable === undefined ? true : reward.repeatable); + deleteBtn.style.display = 'block'; + } + } + + modal.classList.add('active'); + } + + closeRewardModal(): void { + document.getElementById('rewardModal')!.classList.remove('active'); + this.currentEditingRewardId = null; + } + + saveReward(e: Event): void { + e.preventDefault(); + + const reward = { + name: (document.getElementById('rewardName') as HTMLInputElement).value, + description: (document.getElementById('rewardDescription') as HTMLTextAreaElement).value, + cost: parseInt((document.getElementById('rewardCost') as HTMLInputElement).value), + repeatable: (document.getElementById('rewardRepeatable') as HTMLSelectElement).value === 'true' + }; + + if (this.currentEditingRewardId) { + storage.updateReward(this.currentEditingRewardId, reward); + } else { + storage.addReward(reward); + } + + this.closeRewardModal(); + this.renderShop(); + } + + deleteReward(): void { + if (this.currentEditingRewardId) { + if (confirm('Are you sure you want to delete this reward?')) { + storage.deleteReward(this.currentEditingRewardId); + this.closeRewardModal(); + this.renderShop(); + } + } + } + + purchaseReward(rewardId: string): void { + const reward = storage.getRewards().find(r => r.id === rewardId); + if (!reward) return; + + if (confirm(`Purchase "${reward.name}" for ${reward.cost} points?`)) { + const result = storage.purchaseReward(rewardId); + + if (result.success) { + alert(`Congratulations! You've purchased: ${reward.name}!\n\nEnjoy your reward! 🎉`); + this.renderShop(); + this.renderDashboard(); + } else { + alert(result.message); + } + } + } + + // ======================== + // Settings + // ======================== + renderSettings(): void { + this.loadSettings(); + this.updateSettingsStatus(); + this.renderCategoryManagement(); + } + + loadSettings(): void { + const settings = storage.getSettings(); + const tasksPerLevelInput = document.getElementById('tasksPerLevel') as HTMLInputElement | null; + if (tasksPerLevelInput) { + tasksPerLevelInput.value = String(settings.tasksPerLevel || 30); + } + } + + updateSettingsStatus(): void { + const settings = storage.getSettings(); + const tasks = storage.getTasks(); + const completedTasksCount = tasks.filter(t => t.completed).length; + const userStats = storage.getUserStats(); + const tasksInCurrentLevel = completedTasksCount % settings.tasksPerLevel; + const tasksToNext = settings.tasksPerLevel - tasksInCurrentLevel; + + const currentLevelEl = document.getElementById('settingsCurrentLevel'); + const totalCompletedEl = document.getElementById('settingsTotalCompleted'); + const tasksToNextEl = document.getElementById('settingsTasksToNext'); + + if (currentLevelEl) currentLevelEl.textContent = String(userStats.level); + if (totalCompletedEl) totalCompletedEl.textContent = String(completedTasksCount); + if (tasksToNextEl) tasksToNextEl.textContent = String(tasksToNext); + } + + saveTasksPerLevel(): void { + const tasksPerLevel = parseInt((document.getElementById('tasksPerLevel') as HTMLInputElement).value); + + if (isNaN(tasksPerLevel) || tasksPerLevel < 1) { + alert('Please enter a valid number (minimum 1)'); + return; + } + + storage.updateSettings({ tasksPerLevel }); + alert('Settings saved! Level has been recalculated.'); + this.updateSettingsStatus(); + this.renderDashboard(); + } + + exportData(): void { + const data = storage.exportData(); + const blob = new Blob([data], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `task-manager-backup-${new Date().toISOString().split('T')[0]}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + + importData(e: Event): void { + const file = (e.target as HTMLInputElement).files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (event) => { + try { + if (storage.importData(event.target!.result as string)) { + alert('Data imported successfully! Refreshing...'); + location.reload(); + } else { + alert('Invalid file format. Please upload a valid Task Manager backup.'); + } + } catch (error) { + alert('Error importing file: ' + (error as Error).message); + } + }; + reader.readAsText(file); + } + + // ======================== + // Recurring Tasks Processing + // ======================== + processRecurringTasks(): void { + // New recurring tasks are created immediately when a repeatable task is completed + // via createNextRecurringTask(). The original completed task stays in history. + } + + // ======================== + // General Rendering + // ======================== + render(): void { + document.getElementById('dataVersion')!.textContent = STORAGE_VERSION; + const lastUpdated = storage.getData().lastUpdated; + document.getElementById('lastUpdated')!.textContent = lastUpdated ? new Date(lastUpdated).toLocaleString() : 'Never'; + + this.updateDateNavigator(); + this.renderDashboard(); + } + + // ======================== + // Date Navigation + // ======================== + getSelectedDateStr(): string { + return storage.formatDate(this.selectedDate); + } + + isSelectedDateToday(): boolean { + return this.getSelectedDateStr() === storage.formatDate(new Date()); + } + + navigateDate(delta: number): void { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const minDate = new Date(today); + minDate.setDate(minDate.getDate() - 6); + + const newDate = new Date(this.selectedDate); + newDate.setDate(newDate.getDate() + delta); + newDate.setHours(0, 0, 0, 0); + + if (newDate >= minDate && newDate <= today) { + this.selectedDate = newDate; + this.updateDateNavigator(); + const activeTab = document.querySelector('.nav-tab.active') as HTMLElement | null; + if (activeTab) { + this.switchTab(activeTab.dataset.tab!); + } else { + this.renderDashboard(); + } + } + } + + formatDisplayDate(date: Date): string { + const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + return `${days[date.getDay()]}, ${months[date.getMonth()]} ${date.getDate()}`; + } + + updateDateNavigator(): void { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const minDate = new Date(today); + minDate.setDate(minDate.getDate() - 6); + + const sel = new Date(this.selectedDate); + sel.setHours(0, 0, 0, 0); + + const isToday = sel.getTime() === today.getTime(); + const isPastLimit = sel.getTime() <= minDate.getTime(); + + const displayStr = isToday + ? `Today — ${this.formatDisplayDate(this.selectedDate)}` + : this.formatDisplayDate(this.selectedDate); + + document.getElementById('selectedDateDisplay')!.textContent = displayStr; + (document.getElementById('prevDayBtn') as HTMLButtonElement).disabled = isPastLimit; + (document.getElementById('nextDayBtn') as HTMLButtonElement).disabled = isToday; + (document.getElementById('goTodayBtn') as HTMLElement).style.display = isToday ? 'none' : 'inline-block'; + } +} + +// Initialize the app when DOM is ready +document.addEventListener('DOMContentLoaded', () => { + (window as any).app = new TaskManager(); +}); + +export { TaskManager }; diff --git a/src/storage.ts b/src/storage.ts new file mode 100644 index 0000000..e5e888e --- /dev/null +++ b/src/storage.ts @@ -0,0 +1,763 @@ +// ======================== +// Storage Management with Versioning +// ======================== + +const STORAGE_VERSION = '1.0.0'; +const STORAGE_KEY = 'taskManagerData'; +const DATA_SCHEMA_VERSION = 1; + +// ======================== +// Type Definitions +// ======================== + +export interface Task { + id: string; + title: string; + description?: string; + dueDate?: string; + category?: string | null; + priority: 'low' | 'medium' | 'high'; + points: number; + repeatType: 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom' | 'movable'; + repeatUnit?: number; + customRepeatDays?: number; + movableRepeatDays?: number; + daysOfWeek?: number[]; + projectId?: string | null; + completed: boolean; + completedDate?: string | null; + createdDate: string; +} + +export interface Project { + id: string; + name: string; + description?: string; + color?: string; + createdDate: string; +} + +export interface Habit { + id: string; + name: string; + description?: string; + icon: string; + category?: string | null; + points: number; + targetGoal: number; + daysOfWeek?: number[]; + streak: number; + lastCompletedDate?: string | null; + createdDate: string; +} + +export interface HabitLog { + id: string; + habitId: string; + date: string; + timestamp: string; +} + +export interface FinanceItem { + id: string; + description: string; + amount: number; + date?: string; + category?: string | null; + recurring?: 'once' | 'monthly' | 'yearly'; + createdDate: string; +} + +export interface Reward { + id: string; + name: string; + description?: string; + cost: number; + repeatable: boolean; + purchased: boolean; + createdDate: string; +} + +export interface Purchase { + id: string; + rewardId: string; + rewardName: string; + rewardDescription?: string; + cost: number; + purchaseDate: string; +} + +export interface PointsBreakdown { + tasks: number; + projects: number; + habits: number; + streakBonus: number; + [key: string]: number; +} + +export interface UserStats { + totalPoints: number; + level: number; + dailyStreak: number; + lastActivityDate: string | null; + pointsBreakdown: PointsBreakdown; +} + +export interface Settings { + tasksPerLevel: number; +} + +export interface AppData { + version: string; + schemaVersion: number; + lastUpdated: string; + tasks: Task[]; + projects: Project[]; + habits: Habit[]; + dailyHabitLogs: HabitLog[]; + expenses: FinanceItem[]; + revenue: FinanceItem[]; + charges: FinanceItem[]; + rewards: Reward[]; + purchaseHistory: Purchase[]; + categories: string[]; + userStats: UserStats; + settings: Settings; +} + +export interface PurchaseResult { + success: boolean; + message?: string; + purchase?: Purchase; +} + +export class StorageManager { + constructor() { + this.initializeStorage(); + } + + initializeStorage(): void { + const existingData = localStorage.getItem(STORAGE_KEY); + if (!existingData) { + this.createInitialData(); + } + } + + createInitialData(): void { + const initialData: AppData = { + version: STORAGE_VERSION, + schemaVersion: DATA_SCHEMA_VERSION, + lastUpdated: new Date().toISOString(), + tasks: [], + projects: [], + habits: [], + dailyHabitLogs: [], + expenses: [], + revenue: [], + charges: [], + rewards: [], + purchaseHistory: [], + categories: ['Work', 'Personal', 'Home', 'Shopping', 'Health', 'Fitness', 'Learning', 'Productivity', 'Food', 'Transportation', 'Entertainment', 'Utilities', 'Income'], + userStats: { + totalPoints: 0, + level: 1, + dailyStreak: 0, + lastActivityDate: null, + pointsBreakdown: { + tasks: 0, + projects: 0, + habits: 0, + streakBonus: 0 + } + }, + settings: { + tasksPerLevel: 30 + } + }; + + localStorage.setItem(STORAGE_KEY, JSON.stringify(initialData)); + } + + getData(): AppData { + const data = localStorage.getItem(STORAGE_KEY); + return data ? JSON.parse(data) as AppData : this.getDefaultData(); + } + + private getDefaultData(): AppData { + this.createInitialData(); + return JSON.parse(localStorage.getItem(STORAGE_KEY)!) as AppData; + } + + saveData(data: AppData): void { + data.lastUpdated = new Date().toISOString(); + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); + } + + // Task Management + addTask(task: Partial): Task { + const data = this.getData(); + const newTask: Task = { + ...task, + id: this.generateId(), + createdDate: new Date().toISOString(), + completed: false, + title: task.title || '', + priority: task.priority || 'medium', + points: task.points || 10, + repeatType: task.repeatType || 'none', + } as Task; + data.tasks.push(newTask); + this.saveData(data); + return newTask; + } + + updateTask(taskId: string, updates: Partial): Task | undefined { + const data = this.getData(); + const task = data.tasks.find(t => t.id === taskId); + if (task) { + Object.assign(task, updates); + this.saveData(data); + } + return task; + } + + deleteTask(taskId: string): void { + const data = this.getData(); + data.tasks = data.tasks.filter(t => t.id !== taskId); + this.saveData(data); + } + + getTasks(): Task[] { + const data = this.getData(); + return data.tasks || []; + } + + // Project Management + addProject(project: Partial): Project { + const data = this.getData(); + const newProject: Project = { + ...project, + id: this.generateId(), + createdDate: new Date().toISOString(), + name: project.name || '', + } as Project; + data.projects.push(newProject); + this.saveData(data); + return newProject; + } + + updateProject(projectId: string, updates: Partial): Project | undefined { + const data = this.getData(); + const project = data.projects.find(p => p.id === projectId); + if (project) { + Object.assign(project, updates); + this.saveData(data); + } + return project; + } + + deleteProject(projectId: string): void { + const data = this.getData(); + data.projects = data.projects.filter(p => p.id !== projectId); + // Remove project from all tasks + data.tasks = data.tasks.map(t => { + if (t.projectId === projectId) { + t.projectId = null; + } + return t; + }); + this.saveData(data); + } + + getProjects(): Project[] { + const data = this.getData(); + return data.projects || []; + } + + // Habit Management + addHabit(habit: Partial): Habit { + const data = this.getData(); + const newHabit: Habit = { + ...habit, + id: this.generateId(), + createdDate: new Date().toISOString(), + streak: 0, + lastCompletedDate: null, + targetGoal: habit.targetGoal || 1, + name: habit.name || '', + icon: habit.icon || '⭐', + points: habit.points || 10, + } as Habit; + data.habits.push(newHabit); + this.saveData(data); + return newHabit; + } + + updateHabit(habitId: string, updates: Partial): Habit | undefined { + const data = this.getData(); + const habit = data.habits.find(h => h.id === habitId); + if (habit) { + Object.assign(habit, updates); + if (!habit.targetGoal) habit.targetGoal = 1; + this.saveData(data); + } + return habit; + } + + deleteHabit(habitId: string): void { + const data = this.getData(); + data.habits = data.habits.filter(h => h.id !== habitId); + this.saveData(data); + } + + getHabits(): Habit[] { + const data = this.getData(); + return data.habits || []; + } + + logHabitCompletion(habitId: string, date: Date = new Date()): void { + const data = this.getData(); + const dateStr = this.formatDate(date); + + data.dailyHabitLogs.push({ + id: this.generateId(), + habitId, + date: dateStr, + timestamp: new Date().toISOString() + }); + + // Update habit streak + const habit = data.habits.find(h => h.id === habitId); + if (habit) { + habit.lastCompletedDate = dateStr; + habit.streak = (habit.streak || 0) + 1; + } + + this.saveData(data); + } + + isHabitCompletedToday(habitId: string): boolean { + const data = this.getData(); + const todayStr = this.formatDate(new Date()); + return data.dailyHabitLogs.some(log => log.habitId === habitId && log.date === todayStr); + } + + countHabitCompletionsToday(habitId: string): number { + const data = this.getData(); + const todayStr = this.formatDate(new Date()); + return data.dailyHabitLogs.filter(log => log.habitId === habitId && log.date === todayStr).length; + } + + countHabitCompletionsForDate(habitId: string, dateStr: string): number { + const data = this.getData(); + return data.dailyHabitLogs.filter(log => log.habitId === habitId && log.date === dateStr).length; + } + + // Finance Management + addExpense(expense: Partial): FinanceItem { + const data = this.getData(); + const newExpense: FinanceItem = { + ...expense, + id: this.generateId(), + createdDate: new Date().toISOString(), + description: expense.description || '', + amount: expense.amount || 0, + } as FinanceItem; + data.expenses.push(newExpense); + this.saveData(data); + return newExpense; + } + + updateExpense(expenseId: string, updates: Partial): FinanceItem | undefined { + const data = this.getData(); + const expense = data.expenses.find(e => e.id === expenseId); + if (expense) { + Object.assign(expense, updates); + this.saveData(data); + } + return expense; + } + + deleteExpense(expenseId: string): void { + const data = this.getData(); + data.expenses = data.expenses.filter(e => e.id !== expenseId); + this.saveData(data); + } + + getExpenses(): FinanceItem[] { + const data = this.getData(); + return data.expenses || []; + } + + addRevenue(revenue: Partial): FinanceItem { + const data = this.getData(); + const newRevenue: FinanceItem = { + ...revenue, + id: this.generateId(), + createdDate: new Date().toISOString(), + description: revenue.description || '', + amount: revenue.amount || 0, + } as FinanceItem; + data.revenue.push(newRevenue); + this.saveData(data); + return newRevenue; + } + + updateRevenue(revenueId: string, updates: Partial): FinanceItem | undefined { + const data = this.getData(); + const item = data.revenue.find(r => r.id === revenueId); + if (item) { + Object.assign(item, updates); + this.saveData(data); + } + return item; + } + + deleteRevenue(revenueId: string): void { + const data = this.getData(); + data.revenue = data.revenue.filter(r => r.id !== revenueId); + this.saveData(data); + } + + getRevenue(): FinanceItem[] { + const data = this.getData(); + return data.revenue || []; + } + + // Other Charges Management + addCharge(charge: Partial): FinanceItem { + const data = this.getData(); + if (!data.charges) { + data.charges = []; + } + const newCharge: FinanceItem = { + ...charge, + id: this.generateId(), + createdDate: new Date().toISOString(), + description: charge.description || '', + amount: charge.amount || 0, + } as FinanceItem; + data.charges.push(newCharge); + this.saveData(data); + return newCharge; + } + + updateCharge(chargeId: string, updates: Partial): FinanceItem | undefined { + const data = this.getData(); + if (!data.charges) { + data.charges = []; + } + const charge = data.charges.find(c => c.id === chargeId); + if (charge) { + Object.assign(charge, updates); + this.saveData(data); + } + return charge; + } + + deleteCharge(chargeId: string): void { + const data = this.getData(); + if (!data.charges) { + data.charges = []; + } + data.charges = data.charges.filter(c => c.id !== chargeId); + this.saveData(data); + } + + getCharges(): FinanceItem[] { + const data = this.getData(); + return data.charges || []; + } + + // Rewards Shop Management + addReward(reward: Partial): Reward { + const data = this.getData(); + if (!data.rewards) { + data.rewards = []; + } + const newReward: Reward = { + ...reward, + id: this.generateId(), + createdDate: new Date().toISOString(), + purchased: false, + repeatable: typeof reward.repeatable === 'undefined' ? true : reward.repeatable, + name: reward.name || '', + cost: reward.cost || 0, + } as Reward; + data.rewards.push(newReward); + this.saveData(data); + return newReward; + } + + updateReward(rewardId: string, updates: Partial): Reward | undefined { + const data = this.getData(); + if (!data.rewards) { + data.rewards = []; + } + const reward = data.rewards.find(r => r.id === rewardId); + if (reward) { + Object.assign(reward, updates); + if (typeof reward.repeatable === 'undefined') reward.repeatable = true; + this.saveData(data); + } + return reward; + } + + deleteReward(rewardId: string): void { + const data = this.getData(); + if (!data.rewards) { + data.rewards = []; + } + data.rewards = data.rewards.filter(r => r.id !== rewardId); + this.saveData(data); + } + + getRewards(): Reward[] { + const data = this.getData(); + return data.rewards || []; + } + + purchaseReward(rewardId: string): PurchaseResult { + const data = this.getData(); + if (!data.rewards) { + data.rewards = []; + } + if (!data.purchaseHistory) { + data.purchaseHistory = []; + } + + const reward = data.rewards.find(r => r.id === rewardId); + if (!reward) { + return { success: false, message: 'Reward not found' }; + } + + if (data.userStats.totalPoints < reward.cost) { + return { success: false, message: 'Not enough points' }; + } + + // If one-time and already purchased, block + if (reward.repeatable === false) { + const alreadyPurchased = data.purchaseHistory.some(ph => ph.rewardId === reward.id); + if (alreadyPurchased) { + return { success: false, message: 'This reward can only be purchased once.' }; + } + } + + // Deduct points + data.userStats.totalPoints -= reward.cost; + + // Add to purchase history + const purchase: Purchase = { + id: this.generateId(), + rewardId: reward.id, + rewardName: reward.name, + rewardDescription: reward.description, + cost: reward.cost, + purchaseDate: new Date().toISOString() + }; + data.purchaseHistory.push(purchase); + + this.saveData(data); + return { success: true, purchase }; + } + + getPurchaseHistory(): Purchase[] { + const data = this.getData(); + return data.purchaseHistory || []; + } + + // Points Management + addPoints(amount: number, source: string): void { + const data = this.getData(); + data.userStats.totalPoints += amount; + if (data.userStats.pointsBreakdown[source] !== undefined) { + data.userStats.pointsBreakdown[source] += amount; + } + // Level is now calculated based on completed tasks, not points + this.updateLevel(); + this.saveData(data); + } + + updateLevel(): void { + const data = this.getData(); + const settings = this.getSettings(); + const completedTasksCount = data.tasks.filter(t => t.completed).length; + data.userStats.level = Math.floor(completedTasksCount / settings.tasksPerLevel) + 1; + this.saveData(data); + } + + updateDailyStreak(increment: boolean = true): void { + const data = this.getData(); + const today = this.formatDate(new Date()); + + if (increment) { + if (data.userStats.lastActivityDate !== today) { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + if (data.userStats.lastActivityDate !== this.formatDate(yesterday)) { + data.userStats.dailyStreak = 1; + } else { + data.userStats.dailyStreak += 1; + } + } + } + + data.userStats.lastActivityDate = today; + this.saveData(data); + } + + getUserStats(): UserStats { + const data = this.getData(); + return data.userStats; + } + + // Utility Methods + generateId(): string { + return Date.now().toString(36) + Math.random().toString(36).substr(2); + } + + formatDate(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + } + + exportData(): string { + const data = this.getData(); + return JSON.stringify(data, null, 2); + } + + importData(jsonString: string): boolean { + try { + const data = JSON.parse(jsonString) as Partial; + // Validate that it has the required structure + if (data.version && data.tasks !== undefined && data.projects !== undefined) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); + return true; + } + return false; + } catch (e) { + console.error('Import error:', e); + return false; + } + } + + clearAllData(): boolean { + if (confirm('Are you sure you want to clear all data? This cannot be undone.')) { + localStorage.removeItem(STORAGE_KEY); + this.createInitialData(); + return true; + } + return false; + } + + // ======================== + // Category Management + // ======================== + getCategories(): string[] { + const data = this.getData(); + // Migrate old per-type structure to a single shared array + if (data.categories && !Array.isArray(data.categories)) { + const catObj = data.categories as unknown as Record; + const merged = [...new Set([ + ...(catObj['tasks'] || []), + ...(catObj['habits'] || []), + ...(catObj['finance'] || []) + ])]; + data.categories = merged; + this.saveData(data); + } + return Array.isArray(data.categories) ? data.categories : []; + } + + addCategory(categoryName: string): boolean { + const data = this.getData(); + if (!Array.isArray(data.categories)) data.categories = []; + + const trimmedName = categoryName.trim(); + if (trimmedName && !data.categories.includes(trimmedName)) { + data.categories.push(trimmedName); + this.saveData(data); + return true; + } + return false; + } + + updateCategory(oldName: string, newName: string): boolean { + const data = this.getData(); + if (!Array.isArray(data.categories)) return false; + + const trimmedNew = newName.trim(); + if (!trimmedNew || data.categories.includes(trimmedNew)) return false; + + const idx = data.categories.indexOf(oldName); + if (idx === -1) return false; + + data.categories[idx] = trimmedNew; + + // Propagate rename to all item types + data.tasks = data.tasks.map(t => t.category === oldName ? { ...t, category: trimmedNew } : t); + data.habits = data.habits.map(h => h.category === oldName ? { ...h, category: trimmedNew } : h); + data.expenses = data.expenses.map(e => e.category === oldName ? { ...e, category: trimmedNew } : e); + data.revenue = data.revenue.map(r => r.category === oldName ? { ...r, category: trimmedNew } : r); + if (data.charges) { + data.charges = data.charges.map(c => c.category === oldName ? { ...c, category: trimmedNew } : c); + } + + this.saveData(data); + return true; + } + + deleteCategory(categoryName: string): boolean { + const data = this.getData(); + if (!Array.isArray(data.categories)) return false; + + const idx = data.categories.indexOf(categoryName); + if (idx === -1) return false; + + data.categories.splice(idx, 1); + + // Clear category from all item types + data.tasks = data.tasks.map(t => t.category === categoryName ? { ...t, category: null } : t); + data.habits = data.habits.map(h => h.category === categoryName ? { ...h, category: null } : h); + data.expenses = data.expenses.map(e => e.category === categoryName ? { ...e, category: null } : e); + data.revenue = data.revenue.map(r => r.category === categoryName ? { ...r, category: null } : r); + if (data.charges) { + data.charges = data.charges.map(c => c.category === categoryName ? { ...c, category: null } : c); + } + + this.saveData(data); + return true; + } + + // ======================== + // Settings Management + // ======================== + getSettings(): Settings { + const data = this.getData(); + // Ensure settings exist with defaults + if (!data.settings) { + data.settings = { + tasksPerLevel: 30 + }; + this.saveData(data); + } + return data.settings; + } + + updateSettings(settings: Partial): void { + const data = this.getData(); + if (!data.settings) { + data.settings = { tasksPerLevel: 30 }; + } + Object.assign(data.settings, settings); + // Recalculate level with new settings + this.updateLevel(); + this.saveData(data); + } +} + +// Initialize global storage manager +const storage = new StorageManager(); +export { storage, STORAGE_VERSION, STORAGE_KEY }; diff --git a/tests/storage.test.ts b/tests/storage.test.ts new file mode 100644 index 0000000..a4b184a --- /dev/null +++ b/tests/storage.test.ts @@ -0,0 +1,494 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { StorageManager } from '../src/storage'; +import type { Task, Habit, FinanceItem, Reward } from '../src/storage'; + +// Mock localStorage +const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: vi.fn((key: string) => store[key] ?? null), + setItem: vi.fn((key: string, value: string) => { store[key] = value; }), + removeItem: vi.fn((key: string) => { delete store[key]; }), + clear: vi.fn(() => { store = {}; }), + get length() { return Object.keys(store).length; }, + key: vi.fn((index: number) => Object.keys(store)[index] ?? null), + }; +})(); + +Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock }); + +// Mock confirm/alert since they're used in clearAllData +vi.stubGlobal('confirm', vi.fn(() => true)); +vi.stubGlobal('alert', vi.fn()); + +describe('StorageManager', () => { + let storage: StorageManager; + + beforeEach(() => { + localStorageMock.clear(); + vi.clearAllMocks(); + storage = new StorageManager(); + }); + + // ======================== + // Initialization + // ======================== + describe('initialization', () => { + it('should create initial data if none exists', () => { + const data = storage.getData(); + expect(data).toBeDefined(); + expect(data.version).toBe('1.0.0'); + expect(data.tasks).toEqual([]); + expect(data.projects).toEqual([]); + expect(data.habits).toEqual([]); + expect(data.categories).toContain('Work'); + expect(data.userStats.totalPoints).toBe(0); + expect(data.userStats.level).toBe(1); + }); + + it('should not overwrite existing data', () => { + storage.addTask({ title: 'Test Task' }); + const storage2 = new StorageManager(); + expect(storage2.getTasks().length).toBe(1); + }); + }); + + // ======================== + // Task Management + // ======================== + describe('task management', () => { + it('should add a task', () => { + const task = storage.addTask({ title: 'Buy groceries', priority: 'high', points: 15 }); + expect(task.id).toBeDefined(); + expect(task.title).toBe('Buy groceries'); + expect(task.priority).toBe('high'); + expect(task.points).toBe(15); + expect(task.completed).toBe(false); + expect(task.createdDate).toBeDefined(); + }); + + it('should get all tasks', () => { + storage.addTask({ title: 'Task 1' }); + storage.addTask({ title: 'Task 2' }); + const tasks = storage.getTasks(); + expect(tasks.length).toBe(2); + }); + + it('should update a task', () => { + const task = storage.addTask({ title: 'Old title' }); + const updated = storage.updateTask(task.id, { title: 'New title' }); + expect(updated?.title).toBe('New title'); + }); + + it('should return undefined when updating non-existent task', () => { + const result = storage.updateTask('nonexistent', { title: 'Test' }); + expect(result).toBeUndefined(); + }); + + it('should delete a task', () => { + const task = storage.addTask({ title: 'To delete' }); + storage.deleteTask(task.id); + expect(storage.getTasks().length).toBe(0); + }); + + it('should set default values for task fields', () => { + const task = storage.addTask({ title: 'Minimal' }); + expect(task.priority).toBe('medium'); + expect(task.points).toBe(10); + expect(task.repeatType).toBe('none'); + }); + }); + + // ======================== + // Project Management + // ======================== + describe('project management', () => { + it('should add a project', () => { + const project = storage.addProject({ name: 'Website Redesign', color: 'blue' }); + expect(project.id).toBeDefined(); + expect(project.name).toBe('Website Redesign'); + expect(project.color).toBe('blue'); + }); + + it('should get all projects', () => { + storage.addProject({ name: 'Project 1' }); + storage.addProject({ name: 'Project 2' }); + expect(storage.getProjects().length).toBe(2); + }); + + it('should update a project', () => { + const project = storage.addProject({ name: 'Old Name' }); + const updated = storage.updateProject(project.id, { name: 'New Name' }); + expect(updated?.name).toBe('New Name'); + }); + + it('should delete a project and unlink tasks', () => { + const project = storage.addProject({ name: 'To Delete' }); + storage.addTask({ title: 'Linked Task', projectId: project.id }); + storage.deleteProject(project.id); + expect(storage.getProjects().length).toBe(0); + const tasks = storage.getTasks(); + expect(tasks[0].projectId).toBeNull(); + }); + }); + + // ======================== + // Habit Management + // ======================== + describe('habit management', () => { + it('should add a habit', () => { + const habit = storage.addHabit({ name: 'Exercise', icon: '💪', points: 20 }); + expect(habit.id).toBeDefined(); + expect(habit.name).toBe('Exercise'); + expect(habit.streak).toBe(0); + expect(habit.targetGoal).toBe(1); + }); + + it('should get all habits', () => { + storage.addHabit({ name: 'Read' }); + storage.addHabit({ name: 'Meditate' }); + expect(storage.getHabits().length).toBe(2); + }); + + it('should update a habit', () => { + const habit = storage.addHabit({ name: 'Read' }); + storage.updateHabit(habit.id, { name: 'Read Books', targetGoal: 3 }); + const updated = storage.getHabits().find(h => h.id === habit.id); + expect(updated?.name).toBe('Read Books'); + expect(updated?.targetGoal).toBe(3); + }); + + it('should delete a habit', () => { + const habit = storage.addHabit({ name: 'To Delete' }); + storage.deleteHabit(habit.id); + expect(storage.getHabits().length).toBe(0); + }); + + it('should log habit completion and increment streak', () => { + const habit = storage.addHabit({ name: 'Exercise' }); + storage.logHabitCompletion(habit.id, new Date()); + const updated = storage.getHabits().find(h => h.id === habit.id); + expect(updated?.streak).toBe(1); + }); + + it('should count habit completions for today', () => { + const habit = storage.addHabit({ name: 'Push-ups', targetGoal: 3 }); + storage.logHabitCompletion(habit.id, new Date()); + storage.logHabitCompletion(habit.id, new Date()); + expect(storage.countHabitCompletionsToday(habit.id)).toBe(2); + }); + + it('should check if habit is completed today', () => { + const habit = storage.addHabit({ name: 'Meditate' }); + expect(storage.isHabitCompletedToday(habit.id)).toBe(false); + storage.logHabitCompletion(habit.id, new Date()); + expect(storage.isHabitCompletedToday(habit.id)).toBe(true); + }); + + it('should count habit completions for a specific date', () => { + const habit = storage.addHabit({ name: 'Exercise' }); + const date = new Date(2025, 0, 15); + storage.logHabitCompletion(habit.id, date); + storage.logHabitCompletion(habit.id, date); + expect(storage.countHabitCompletionsForDate(habit.id, '2025-01-15')).toBe(2); + expect(storage.countHabitCompletionsForDate(habit.id, '2025-01-16')).toBe(0); + }); + }); + + // ======================== + // Finance Management + // ======================== + describe('expense management', () => { + it('should add an expense', () => { + const expense = storage.addExpense({ description: 'Coffee', amount: 4.50, date: '2025-01-15' }); + expect(expense.id).toBeDefined(); + expect(expense.description).toBe('Coffee'); + expect(expense.amount).toBe(4.50); + }); + + it('should get all expenses', () => { + storage.addExpense({ description: 'Coffee', amount: 4.50 }); + storage.addExpense({ description: 'Lunch', amount: 12.00 }); + expect(storage.getExpenses().length).toBe(2); + }); + + it('should update an expense', () => { + const expense = storage.addExpense({ description: 'Cofee', amount: 4.50 }); + storage.updateExpense(expense.id, { description: 'Coffee' }); + const updated = storage.getExpenses().find(e => e.id === expense.id); + expect(updated?.description).toBe('Coffee'); + }); + + it('should delete an expense', () => { + const expense = storage.addExpense({ description: 'To Delete', amount: 10 }); + storage.deleteExpense(expense.id); + expect(storage.getExpenses().length).toBe(0); + }); + }); + + describe('revenue management', () => { + it('should add revenue', () => { + const rev = storage.addRevenue({ description: 'Salary', amount: 5000 }); + expect(rev.id).toBeDefined(); + expect(rev.amount).toBe(5000); + }); + + it('should update revenue', () => { + const rev = storage.addRevenue({ description: 'Salary', amount: 5000 }); + storage.updateRevenue(rev.id, { amount: 5500 }); + const updated = storage.getRevenue().find(r => r.id === rev.id); + expect(updated?.amount).toBe(5500); + }); + + it('should delete revenue', () => { + const rev = storage.addRevenue({ description: 'Salary', amount: 5000 }); + storage.deleteRevenue(rev.id); + expect(storage.getRevenue().length).toBe(0); + }); + }); + + describe('charge management', () => { + it('should add a charge', () => { + const charge = storage.addCharge({ description: 'Rent', amount: 1200 }); + expect(charge.id).toBeDefined(); + expect(charge.description).toBe('Rent'); + }); + + it('should update a charge', () => { + const charge = storage.addCharge({ description: 'Rent', amount: 1200 }); + storage.updateCharge(charge.id, { amount: 1300 }); + const updated = storage.getCharges().find(c => c.id === charge.id); + expect(updated?.amount).toBe(1300); + }); + + it('should delete a charge', () => { + const charge = storage.addCharge({ description: 'Rent', amount: 1200 }); + storage.deleteCharge(charge.id); + expect(storage.getCharges().length).toBe(0); + }); + }); + + // ======================== + // Rewards Shop + // ======================== + describe('rewards shop', () => { + it('should add a reward', () => { + const reward = storage.addReward({ name: 'Movie Night', cost: 100 }); + expect(reward.id).toBeDefined(); + expect(reward.name).toBe('Movie Night'); + expect(reward.repeatable).toBe(true); + expect(reward.purchased).toBe(false); + }); + + it('should update a reward', () => { + const reward = storage.addReward({ name: 'Movie', cost: 100 }); + storage.updateReward(reward.id, { name: 'Movie Night', cost: 150 }); + const updated = storage.getRewards().find(r => r.id === reward.id); + expect(updated?.name).toBe('Movie Night'); + expect(updated?.cost).toBe(150); + }); + + it('should delete a reward', () => { + const reward = storage.addReward({ name: 'Movie Night', cost: 100 }); + storage.deleteReward(reward.id); + expect(storage.getRewards().length).toBe(0); + }); + + it('should purchase a reward and deduct points', () => { + storage.addPoints(200, 'tasks'); + const reward = storage.addReward({ name: 'Movie Night', cost: 100 }); + const result = storage.purchaseReward(reward.id); + expect(result.success).toBe(true); + expect(storage.getUserStats().totalPoints).toBe(100); + expect(storage.getPurchaseHistory().length).toBe(1); + }); + + it('should fail to purchase with insufficient points', () => { + const reward = storage.addReward({ name: 'Expensive', cost: 9999 }); + const result = storage.purchaseReward(reward.id); + expect(result.success).toBe(false); + expect(result.message).toBe('Not enough points'); + }); + + it('should prevent re-purchasing one-time rewards', () => { + storage.addPoints(500, 'tasks'); + const reward = storage.addReward({ name: 'One-time', cost: 100, repeatable: false }); + storage.purchaseReward(reward.id); + const result = storage.purchaseReward(reward.id); + expect(result.success).toBe(false); + expect(result.message).toBe('This reward can only be purchased once.'); + }); + + it('should allow re-purchasing repeatable rewards', () => { + storage.addPoints(500, 'tasks'); + const reward = storage.addReward({ name: 'Repeatable', cost: 100, repeatable: true }); + storage.purchaseReward(reward.id); + const result = storage.purchaseReward(reward.id); + expect(result.success).toBe(true); + }); + + it('should fail for non-existent reward', () => { + const result = storage.purchaseReward('nonexistent'); + expect(result.success).toBe(false); + expect(result.message).toBe('Reward not found'); + }); + }); + + // ======================== + // Points & Leveling + // ======================== + describe('points and leveling', () => { + it('should add points', () => { + storage.addPoints(50, 'tasks'); + expect(storage.getUserStats().totalPoints).toBe(50); + expect(storage.getUserStats().pointsBreakdown.tasks).toBe(50); + }); + + it('should calculate level based on completed tasks', () => { + // Default: 30 tasks per level + for (let i = 0; i < 31; i++) { + const task = storage.addTask({ title: `Task ${i}` }); + storage.updateTask(task.id, { completed: true }); + } + storage.updateLevel(); + const data = storage.getData(); + expect(data.userStats.level).toBe(2); // 31 / 30 = 1.03, floor + 1 = 2 + }); + + it('should update daily streak', () => { + storage.updateDailyStreak(true); + expect(storage.getUserStats().dailyStreak).toBe(1); + }); + }); + + // ======================== + // Categories + // ======================== + describe('category management', () => { + it('should get default categories', () => { + const categories = storage.getCategories(); + expect(categories).toContain('Work'); + expect(categories).toContain('Personal'); + }); + + it('should add a new category', () => { + const result = storage.addCategory('Custom'); + expect(result).toBe(true); + expect(storage.getCategories()).toContain('Custom'); + }); + + it('should not add duplicate categories', () => { + const result = storage.addCategory('Work'); + expect(result).toBe(false); + }); + + it('should not add empty categories', () => { + const result = storage.addCategory(' '); + expect(result).toBe(false); + }); + + it('should update a category and propagate to items', () => { + storage.addTask({ title: 'Test', category: 'Work' }); + storage.addHabit({ name: 'Test', category: 'Work' }); + const result = storage.updateCategory('Work', 'Career'); + expect(result).toBe(true); + expect(storage.getCategories()).toContain('Career'); + expect(storage.getCategories()).not.toContain('Work'); + expect(storage.getTasks()[0].category).toBe('Career'); + expect(storage.getHabits()[0].category).toBe('Career'); + }); + + it('should not update to an existing category name', () => { + const result = storage.updateCategory('Work', 'Personal'); + expect(result).toBe(false); + }); + + it('should delete a category and clear from items', () => { + storage.addTask({ title: 'Test', category: 'Work' }); + const result = storage.deleteCategory('Work'); + expect(result).toBe(true); + expect(storage.getCategories()).not.toContain('Work'); + expect(storage.getTasks()[0].category).toBeNull(); + }); + }); + + // ======================== + // Settings + // ======================== + describe('settings management', () => { + it('should return default settings', () => { + const settings = storage.getSettings(); + expect(settings.tasksPerLevel).toBe(30); + }); + + it('should update settings', () => { + storage.updateSettings({ tasksPerLevel: 50 }); + expect(storage.getSettings().tasksPerLevel).toBe(50); + }); + }); + + // ======================== + // Data Export/Import + // ======================== + describe('data export and import', () => { + it('should export data as JSON string', () => { + storage.addTask({ title: 'Test' }); + const exported = storage.exportData(); + const parsed = JSON.parse(exported); + expect(parsed.tasks.length).toBe(1); + }); + + it('should import valid data', () => { + const data = { + version: '1.0.0', + tasks: [{ id: '1', title: 'Imported', completed: false }], + projects: [], + }; + const result = storage.importData(JSON.stringify(data)); + expect(result).toBe(true); + }); + + it('should reject invalid JSON', () => { + const result = storage.importData('not json'); + expect(result).toBe(false); + }); + + it('should reject data without required fields', () => { + const result = storage.importData(JSON.stringify({ foo: 'bar' })); + expect(result).toBe(false); + }); + }); + + // ======================== + // Utility Methods + // ======================== + describe('utility methods', () => { + it('should generate unique IDs', () => { + const id1 = storage.generateId(); + const id2 = storage.generateId(); + expect(id1).not.toBe(id2); + }); + + it('should format date correctly', () => { + const date = new Date(2025, 0, 5); // January 5, 2025 + expect(storage.formatDate(date)).toBe('2025-01-05'); + }); + + it('should format date with padding', () => { + const date = new Date(2025, 8, 9); // September 9, 2025 + expect(storage.formatDate(date)).toBe('2025-09-09'); + }); + }); + + // ======================== + // Clear All Data + // ======================== + describe('clearAllData', () => { + it('should clear all data and reinitialize', () => { + storage.addTask({ title: 'To be cleared' }); + expect(storage.getTasks().length).toBe(1); + storage.clearAllData(); + expect(storage.getTasks().length).toBe(0); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..176f784 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "outDir": "./js", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "bundler", + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "tests", "e2e"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..da75b02 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'jsdom', + globals: true, + include: ['tests/**/*.test.ts'], + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + }, + }, +});