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 @@ - - + +
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.
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 => `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 `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 += `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 `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 `No tasks in this project yet.
'; return; } - container.innerHTML = tasks.map(task => `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 `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}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 `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 ` +No activity yet. Start completing tasks!
'; + } else { + activityList.innerHTML = activities.slice(0, 5).map(activity => ` +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 ` +No tasks found.
'; + return; + } + + let html = ''; + + if (filtersActive) { + html = filtered.map(task => this.renderTaskItem(task)).join(''); + } else { + const priorityOrder: RecordNo 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 ` +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 ` +No tasks in this project yet.
'; + return; + } + + container.innerHTML = tasks.map(task => ` +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 ` +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 ` +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 ` +