diff --git a/.gitignore b/.gitignore index c9e9fdf..fde5cf9 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,6 @@ tools/anaf-monitor/versions.json tools/anaf-monitor/snapshots/ tools/anaf-monitor/monitor.log workspace/ + +# Claude Code session handoff +.claude/HANDOFF.md diff --git a/AGENTS.md b/AGENTS.md index dacf9ab..8635f1e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Model Selection -**Default: Sonet** +**Default: Sonnet** ** Pentru urmatoarele sarcini, foloseste Haiku** - Routine tasks, file checks, simple commands, status @@ -101,7 +101,7 @@ When I receive errors, bugs, or new feature requests: - **Fii selectiv** cu integrările externe (trade-off: capability vs risk) ### Daily Security Audit (Cron 09:30) -- Verifică: agents.md, soul.md, user.md, heartbeat.md, tools.md +- Verifică: agents.md, soul.md, user.md, identity.md, heartbeat.md, tools.md, cron-jobs.md, infrastructure.md - Caută: info outdated, reguli conflictuale, workflow-uri nedocumentate - Propune cleanup în #echo-work diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..82e0df7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,87 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Repository Overview + +This is a multi-project workspace for **Echo**, a personal AI assistant ecosystem owned by Marius. It contains three main components: a web dashboard, Python utility tools, and Antfarm (an agent workflow orchestration CLI). + +**Language:** Romanian is used extensively in docs, variable names, and comments. Marius prefers no emojis in conversation. + +## Project Structure + +- **dashboard/** — Web-based task/habit tracking SPA (vanilla JS + Python backend via `api.py`) +- **antfarm/** — Multi-agent workflow orchestration CLI (TypeScript + Node.js + SQLite) +- **tools/** — Python utility scripts (email, calendar, git, YouTube, ANAF monitor, Ralph workflow) +- **memory/** — Knowledge base and daily notes (`memory/YYYY-MM-DD.md`, `memory/kb/`) +- **skills/** — Agent skill definitions + +## Build & Run Commands + +### Antfarm (TypeScript) +```bash +cd antfarm && npm run build # tsc + copy HTML + chmod +cd antfarm && npm start # node dist/cli/cli.js +antfarm install # Install all bundled workflows +antfarm workflow run # Start a workflow run +antfarm dashboard # Web dashboard on port 3333 +``` +- Requires Node.js >= 22, ES modules (`"type": "module"`) +- TypeScript strict mode, target ES2022, module NodeNext +- No linter or formatter configured + +### Dashboard (Python) +```bash +python3 dashboard/api.py # Start HTTP server +pytest dashboard/tests/ # Run all dashboard tests +pytest dashboard/tests/test_habits_api.py # Run a single test file +``` + +### Tools (Python) +```bash +python3 tools/youtube_subs.py URL [lang] +python3 tools/email_send.py "dest" "subject" "body" +python3 tools/email_process.py [--save|--all] +python3 tools/calendar_check.py [today|week|travel] +python3 tools/git_commit.py --push +python3 tools/ralph_workflow.py # Autonomous code generation +``` + +## Architecture + +### Antfarm — Multi-Agent Workflows +- **Workflow pattern:** YAML-defined pipelines where specialized agents (planner, developer, verifier, tester, reviewer) execute steps sequentially +- **Fresh context per step:** Each agent runs in a clean session — no shared context window +- **State in SQLite:** `~/.openclaw/antfarm/antfarm.db` with WAL mode; tables: `runs`, `steps`, `stories` +- **Cron-based polling:** Agents poll for work at configurable intervals (120s–300s) +- **Tool-gating by role:** agents have restricted tool access (e.g., `verification` role = read + exec, NO write) +- **Bundled workflows:** `feature-dev` (7 agents), `bug-fix` (6 agents), `security-audit` (7 agents) +- **Key source files:** `src/cli/cli.ts` (entry), `src/db.ts` (SQLite), `src/installer/install.ts` (workflow provisioning), `src/installer/workflow-spec.ts` (YAML parsing), `src/installer/step-ops.ts` (step claim/complete/fail), `src/server/dashboard.ts` (HTTP API) + +### Dashboard — Habit Tracker & Task Board +- **Single-page app** with swipe navigation between pages (index, habits, notes, files, workspace) +- **Backend:** Python `SimpleHTTPRequestHandler` with `/api/` routing in `dashboard/api.py` +- **Data:** JSON files (`habits.json`, `todos.json`, `tasks.json`, `status.json`) +- **Frontend:** Vanilla JS + CSS with Lucide icons, design tokens for light/dark themes +- **Served over Tailscale:** `https://moltbot.tailf7372d.ts.net/echo/` + +### Ralph — Autonomous Code Generation +- Opus generates PRD/stories, Sonnet implements them +- `tools/ralph_prd_generator.py` → PRD + `prd.json` +- `tools/ralph_workflow.py` → launches the full loop +- Projects go in `~/workspace/` + +## Key Configuration + +- **AGENTS.md** — Agent behavior rules, model selection (Haiku/Sonnet/Opus), security policies +- **USER.md** — Marius's profile, preferences, and 80/20 work style +- **TOOLS.md** — Available tools with exact CLI invocations +- **antfarm/workflows/{id}/workflow.yml** — YAML workflow definitions +- **antfarm/agents/shared/** — Shared agent definitions (setup, verifier, pr) + +## Conventions + +- **Model selection:** Opus for planning/architecture, Sonnet for implementation/coding, Haiku for routine tasks +- **80/20 rule:** Minimal effort, maximum results — avoid over-engineering +- **Security:** Never store secrets in code; use `.env` files; `trash` over `rm`; confirm destructive actions +- **Git:** Main branch is `master`; remote is `gitea.romfast.ro/romfast/clawd` diff --git a/FEATURE_PDF_DOWNLOAD.md b/FEATURE_PDF_DOWNLOAD.md deleted file mode 100644 index 6c53bf2..0000000 --- a/FEATURE_PDF_DOWNLOAD.md +++ /dev/null @@ -1,206 +0,0 @@ -# PDF Download Feature - Implementation Complete ✅ - -## Overview -Added a "Download PDF" button to the Files Dashboard that converts markdown files to PDF and triggers a download. - -## What Was Changed - -### 1. Frontend (`/home/moltbot/clawd/dashboard/files.html`) - -#### Added Library -- **Line 4**: Included `html2pdf.js` from CDN - ```html - - ``` - - No system dependencies needed (pure JavaScript) - - Works client-side in browser - - 54KB minified, ~16KB gzipped - -#### Added Button (Line 226) -```html - -``` -- Placed next to Preview button in editor header -- Only visible when markdown (.md) files are open -- Uses download icon from Lucide - -#### Added JavaScript Function (after `toggleDiff`) -```javascript -function downloadPDF() { - // Validates file is markdown - // Renders preview HTML - // Configures PDF format (A4 portrait, margins) - // Triggers browser download with original filename -} -``` - -#### Updated `openFile()` Function -- Shows download button only for `.md` files (same as preview button) -- `document.getElementById('downloadPdfBtn').style.display = isMarkdown ? 'flex' : 'none';` - -## How It Works - -### User Flow -1. Browse to Files dashboard: `https://moltbot.tailf7372d.ts.net/echo/files.html` -2. Navigate to folder: `memory/kb/projects/grup-sprijin/biblioteca/` -3. Click any `.md` file (e.g., `fisa-2026-02-05-ancorare-oglinda.md`) -4. Click "📥" (download) button next to the eye (preview) button -5. Browser downloads PDF with name: `fisa-2026-02-05-ancorare-oglinda.pdf` - -### Technical Flow -1. **Button click** → `downloadPDF()` function triggered -2. **Validation** → Check file is `.md` and one is open -3. **Get HTML** → Clone the already-rendered markdown preview -4. **Configure PDF** → Set A4 format, margins, quality -5. **Generate** → html2pdf.js converts HTML to PDF in browser -6. **Download** → Browser's download mechanism saves to user's Downloads folder - -## Features - -✅ **Client-side conversion** - No server load, fast, works offline -✅ **Preserves markdown formatting** - Headers, lists, emphasis, blockquotes, code blocks -✅ **High quality output** - 2x scale canvas, JPEG quality 0.98 -✅ **Proper filename** - Uses original filename with `.pdf` extension -✅ **A4 paper format** - Standard European/international paper size -✅ **Margin control** - 10mm margins for printing -✅ **Status feedback** - Shows "Se pregătește PDF..." then "PDF descărcat: filename.pdf" -✅ **Error handling** - Validates file type and provides helpful error messages - -## Tested Scenarios - -### ✅ Test Case 1: Basic Markdown File -- **File**: `memory/kb/projects/grup-sprijin/biblioteca/fisa-2026-02-05-ancorare-oglinda.md` -- **Format**: Headers, paragraphs, lists, step-by-step instructions -- **Expected**: PDF with proper formatting -- **Status**: IMPLEMENTED - -### ✅ Test Case 2: Button Visibility -- **Scenario**: Open non-markdown file -- **Expected**: Download button hidden -- **Status**: IMPLEMENTED (controlled by `isMarkdown` check in `openFile()`) - -### ✅ Test Case 3: Error Handling -- **Scenario**: Click download without file open -- **Expected**: Shows error message -- **Status**: IMPLEMENTED (validation in `downloadPDF()`) - -## Browser Compatibility - -| Browser | Status | Notes | -|---------|--------|-------| -| Chrome/Chromium | ✅ Full support | Primary target | -| Firefox | ✅ Full support | Excellent compatibility | -| Safari | ✅ Full support | Works great | -| Edge | ✅ Full support | Based on Chromium | - -## File Structure - -``` -/home/moltbot/clawd/ -├── dashboard/ -│ ├── files.html (MODIFIED - Added PDF button + function) -│ ├── api.py (unchanged - no backend needed) -│ └── common.css (unchanged - button uses existing styles) -└── FEATURE_PDF_DOWNLOAD.md (NEW - this file) -``` - -## Dependencies - -- **html2pdf.js v0.10.1** - CDN hosted, no installation needed -- **marked.js** - Already present in project (markdown rendering) -- **Lucide icons** - Already present in project (download icon) - -## Performance - -- **Download button display**: < 1ms (CSS toggle) -- **PDF generation**: 2-5 seconds for typical document (depending on complexity) -- **File size**: Typically 50-200KB for a 2-3 page document - -## Limitations & Future Improvements - -⚠️ **Current Limitations:** -- PDF styling is basic (white background, standard fonts) -- Complex CSS from theme not carried over to PDF -- Very large markdown files (>50KB) may take longer to render - -📝 **Future Enhancements (if needed):** -- Add custom CSS for PDF styling (colors, fonts, branding) -- Support for other formats (txt, html) if time permits -- Progress bar for large documents -- Options dialog (page orientation, margins, quality) -- Batch download multiple files - -## How to Use - -### For Marius -1. Open Files dashboard: `https://moltbot.tailf7372d.ts.net/echo/files.html` -2. Navigate: `memory/kb/projects/grup-sprijin/biblioteca/` -3. Click any `.md` file -4. Click the download button (📥 icon next to eye icon) -5. PDF saves to your **Downloads** folder - -### For Group "Sprijin" Users -You can now easily share and print activity sheets: -- **Export for printing**: Download PDF and print locally -- **Share with others**: Email/send PDF file -- **Archive**: Keep PDF copies of session materials - -## Testing Instructions - -To test the feature: - -```bash -# 1. Navigate to files dashboard -https://moltbot.tailf7372d.ts.net/echo/files.html - -# 2. Go to test file location -Click: memory → kb → projects → grup-sprijin → biblioteca - -# 3. Open test file -Click: fisa-2026-02-05-ancorare-oglinda.md - -# 4. Verify button shows -Look for 📥 icon next to 👁️ (preview) button - -# 5. Download PDF -Click 📥 button - -# 6. Check Downloads folder -File should appear: fisa-2026-02-05-ancorare-oglinda.pdf -``` - -## Implementation Notes - -- **No backend changes needed** - Feature is 100% client-side -- **No additional packages** - Uses CDN-hosted library -- **Backward compatible** - Doesn't affect existing functionality -- **Responsive** - Button adapts to different screen sizes -- **Accessible** - Includes title attribute for tooltips - -## Author Notes - -This is a lightweight, user-friendly implementation that: -- Requires no system dependencies -- Works immediately in any modern browser -- Preserves markdown formatting -- Provides good UX with status feedback -- Can be extended later if needed - -The html2pdf.js library was chosen because: -1. ✅ Works client-side (no server load) -2. ✅ CDN hosted (no installation) -3. ✅ Good markdown → PDF conversion -4. ✅ Reliable browser support -5. ✅ Actively maintained - -## Status: ✅ COMPLETE & READY TO USE - -All acceptance criteria met: -- ✅ Button visible in preview panel -- ✅ Works for .md files -- ✅ Downloads with correct filename -- ✅ Preserves markdown formatting -- ✅ Works in Firefox/Chrome -- ✅ User gets proper feedback diff --git a/TEST_PDF_FEATURE.html b/TEST_PDF_FEATURE.html deleted file mode 100644 index 62f09a1..0000000 --- a/TEST_PDF_FEATURE.html +++ /dev/null @@ -1,234 +0,0 @@ - - - - - PDF Download Feature Test - - - - - -
-

📥 PDF Download Feature - Test Suite

- -
- ✅ Test 1: Libraries Loaded
- html2pdf.js: Checking...
- marked.js: Checking... -
- -
- ✅ Test 2: Markdown Rendering
- -
-
- -
- ✅ Test 3: PDF Generation
- -
-
- -
- ✅ Test 4: Full Workflow
- -
-
- -
- Test Status:
-
Ready to run tests...
-
- -

Test Markdown Content:

-
# Fișă Întâlnire Grup Sprijin
-
-**Data:** Joi, 5 februarie 2026, ora 18:00
-**Tema:** Ancorare emoții pozitive & Oglinda celorlalți
-
-## 1. Check-in (15-20 min)
-
-**Întrebare de deschidere:**
-- Ce s-a întâmplat în ultimele două săptămâni?
-- Ce emoții ai avut?
-
-## 2. Exercițiu principal: Ancorarea emoțiilor
-
-**Scop:** Să învățăm să accesăm o emoție pozitivă.
-
-### Pași pentru exercițiu:
-
-1. **Alege emoția** (2 min)
-   - Ce emoție ți-ai dori să poți accesa mai ușor?
-
-2. **Găsește momentul** (5 min)
-   - Gândește-te la un moment din viața ta
-
-
- - - - diff --git a/analyze-mobile-menu.js b/analyze-mobile-menu.js deleted file mode 100644 index 4e58e9f..0000000 --- a/analyze-mobile-menu.js +++ /dev/null @@ -1,142 +0,0 @@ -// Analyze files.html mobile menu logic - -const testFiles = [ - { name: 'AGENTS.md', status: 'M', expectPreview: true, expectPDF: true, expectGitDiff: true }, - { name: 'FEATURE_PDF_DOWNLOAD.md', status: '??', expectPreview: true, expectPDF: true, expectGitDiff: false }, - { name: 'TOOLS.md', status: 'M', expectPreview: true, expectPDF: true, expectGitDiff: true }, - { name: 'dashboard/api.py', status: 'M', expectPreview: false, expectPDF: false, expectGitDiff: true }, - { name: 'memory/2026-02-05.md', status: '??', expectPreview: true, expectPDF: true, expectGitDiff: false } -]; - -console.log('========================================'); -console.log('MOBILE MENU LOGIC ANALYSIS'); -console.log('========================================\n'); - -console.log('Based on files.html openFile() function:\n'); -console.log('Lines 1256-1290 (approx):'); -console.log(' const isMarkdown = path.endsWith(\'.md\');'); -console.log(' previewBtn.style.display = isMarkdown ? \'flex\' : \'none\';'); -console.log(' downloadPdfBtn.style.display = isMarkdown ? \'flex\' : \'none\';'); -console.log(' previewMenuItem.classList.toggle(\'hidden\', !isMarkdown);'); -console.log(' downloadPdfMenuItem.classList.toggle(\'hidden\', !isMarkdown);'); -console.log(''); -console.log(' const hasGitChanges = !!getGitStatusForPath(path);'); -console.log(' diffBtn.style.display = hasGitChanges ? \'flex\' : \'none\';'); -console.log(' diffMenuItem.classList.remove(\'hidden\');'); -console.log(' diffMenuItem.disabled = !hasGitChanges;'); -console.log(''); -console.log('Mobile CSS (lines 813-825):'); -console.log(' @media (max-width: 768px) {'); -console.log(' #previewBtn, #downloadPdfBtn, #diffBtn, #reloadBtn {'); -console.log(' display: none !important;'); -console.log(' }'); -console.log(' .editor-menu-mobile {'); -console.log(' display: flex !important;'); -console.log(' }'); -console.log(' }'); -console.log('\n========================================'); -console.log('EXPECTED BEHAVIOR ON MOBILE (375px)'); -console.log('========================================\n'); - -testFiles.forEach(file => { - console.log(`\n${file.name} (git status: ${file.status})`); - console.log('─'.repeat(50)); - - const isMarkdown = file.name.endsWith('.md'); - const hasGitChanges = file.status !== '??'; // Untracked files don't have git diff - - console.log(` Is Markdown: ${isMarkdown}`); - console.log(` Has Git Changes: ${hasGitChanges} (status: ${file.status})`); - console.log(''); - console.log(' Hamburger Menu (⋮): VISIBLE (always on mobile)'); - console.log(''); - console.log(' Menu Items:'); - console.log(` Preview: ${isMarkdown ? 'VISIBLE' : 'HIDDEN (not markdown)'}`); - console.log(` Download PDF: ${isMarkdown ? 'VISIBLE' : 'HIDDEN (not markdown)'}`); - console.log(` Git Diff: ${hasGitChanges ? 'VISIBLE' : 'VISIBLE but DISABLED (no git changes)'}`); - console.log(` Reload: VISIBLE (always)`); - console.log(''); - - // Check expectations - const issues = []; - - if (file.expectPreview && !isMarkdown) { - issues.push('❌ Expected preview but file is not markdown'); - } else if (!file.expectPreview && isMarkdown) { - issues.push('❌ Did not expect preview but file is markdown'); - } - - if (file.expectPDF && !isMarkdown) { - issues.push('❌ Expected PDF but file is not markdown'); - } else if (!file.expectPDF && isMarkdown) { - issues.push('❌ Did not expect PDF but file is markdown'); - } - - if (file.expectGitDiff && !hasGitChanges) { - issues.push('❌ Expected git diff but file has no git changes (status: ??)'); - } else if (!file.expectGitDiff && hasGitChanges) { - issues.push('❌ Did not expect git diff but file has git changes'); - } - - if (issues.length > 0) { - console.log(' 🔴 ISSUES:'); - issues.forEach(issue => console.log(` ${issue}`)); - } else { - console.log(' ✅ Logic matches expectations'); - } -}); - -console.log('\n\n========================================'); -console.log('CODE BEHAVIOR ANALYSIS'); -console.log('========================================\n'); - -console.log('✅ CORRECT BEHAVIOR:'); -console.log(' - Hamburger menu (⋮) always visible on mobile'); -console.log(' - Preview/PDF menu items: shown only for .md files'); -console.log(' - Git Diff menu item: always shown but disabled for ?? files'); -console.log(' - Desktop buttons hidden on mobile with !important'); -console.log(''); - -console.log('⚠️ POTENTIAL ISSUE:'); -console.log(' The code shows diffMenuItem always visible but disabled for files'); -console.log(' with no git changes. This is technically correct but could be'); -console.log(' confusing for users (they see a disabled option).'); -console.log(''); -console.log(' Better approach would be:'); -console.log(' diffMenuItem.classList.toggle(\'hidden\', !hasGitChanges);'); -console.log(' instead of:'); -console.log(' diffMenuItem.classList.remove(\'hidden\');'); -console.log(' diffMenuItem.disabled = !hasGitChanges;'); -console.log(''); - -console.log('📝 RECOMMENDATION:'); -console.log(' Change line ~1288 from:'); -console.log(' diffMenuItem.classList.remove(\'hidden\');'); -console.log(' diffMenuItem.disabled = !hasGitChanges;'); -console.log(' to:'); -console.log(' diffMenuItem.classList.toggle(\'hidden\', !hasGitChanges);'); -console.log(''); - -console.log('\n========================================'); -console.log('SUMMARY'); -console.log('========================================\n'); - -console.log('The mobile menu logic is MOSTLY CORRECT:'); -console.log(''); -console.log('✅ Hamburger menu appears on mobile'); -console.log('✅ Preview/PDF shown only for markdown files'); -console.log('✅ Git Diff shown for files with git status (M, A, D, R)'); -console.log('⚠️ Git Diff shown but DISABLED for untracked (??) files'); -console.log(' (Could be improved by hiding instead of disabling)'); -console.log(''); -console.log('Expected behavior per file:'); -testFiles.forEach(file => { - const isMarkdown = file.name.endsWith('.md'); - const hasGitChanges = file.status !== '??'; - - const preview = isMarkdown ? '✓' : '✗'; - const pdf = isMarkdown ? '✓' : '✗'; - const diff = hasGitChanges ? '✓' : '✗ (disabled)'; - - console.log(` ${file.name.padEnd(30)} [${file.status}] → Preview:${preview} PDF:${pdf} Diff:${diff}`); -}); diff --git a/antfarm b/antfarm new file mode 160000 index 0000000..2fff211 --- /dev/null +++ b/antfarm @@ -0,0 +1 @@ +Subproject commit 2fff2115022afc796908f1a63124987437520b69 diff --git a/dashboard/api.py b/dashboard/api.py index cd93951..2247e68 100644 --- a/dashboard/api.py +++ b/dashboard/api.py @@ -11,16 +11,22 @@ import sys import re import os import signal +import uuid from http.server import HTTPServer, SimpleHTTPRequestHandler from urllib.parse import parse_qs, urlparse from datetime import datetime from pathlib import Path +# Import habits helpers +sys.path.insert(0, str(Path(__file__).parent)) +import habits_helpers + BASE_DIR = Path(__file__).parent.parent TOOLS_DIR = BASE_DIR / 'tools' NOTES_DIR = BASE_DIR / 'kb' / 'youtube' KANBAN_DIR = BASE_DIR / 'dashboard' WORKSPACE_DIR = Path('/home/moltbot/workspace') +HABITS_FILE = KANBAN_DIR / 'habits.json' # Load .env file if present _env_file = Path(__file__).parent / '.env' @@ -48,6 +54,12 @@ class TaskBoardHandler(SimpleHTTPRequestHandler): self.handle_git_commit() elif self.path == '/api/pdf': self.handle_pdf_post() + elif self.path == '/api/habits': + self.handle_habits_post() + elif self.path.startswith('/api/habits/') and self.path.endswith('/check'): + self.handle_habits_check() + elif self.path.startswith('/api/habits/') and self.path.endswith('/skip'): + self.handle_habits_skip() elif self.path == '/api/workspace/run': self.handle_workspace_run() elif self.path == '/api/workspace/stop': @@ -61,6 +73,20 @@ class TaskBoardHandler(SimpleHTTPRequestHandler): else: self.send_error(404) + def do_PUT(self): + if self.path.startswith('/api/habits/'): + self.handle_habits_put() + else: + self.send_error(404) + + def do_DELETE(self): + if self.path.startswith('/api/habits/') and '/check' in self.path: + self.handle_habits_uncheck() + elif self.path.startswith('/api/habits/'): + self.handle_habits_delete() + else: + self.send_error(404) + def handle_git_commit(self): """Run git commit and push.""" try: @@ -251,6 +277,8 @@ class TaskBoardHandler(SimpleHTTPRequestHandler): self.handle_cron_status() elif self.path == '/api/activity' or self.path.startswith('/api/activity?'): self.handle_activity() + elif self.path == '/api/habits': + self.handle_habits_get() elif self.path.startswith('/api/files'): self.handle_files_get() elif self.path.startswith('/api/diff'): @@ -1381,6 +1409,546 @@ class TaskBoardHandler(SimpleHTTPRequestHandler): except Exception as e: self.send_json({'error': str(e)}, 500) + def handle_habits_get(self): + """Get all habits with enriched stats.""" + try: + # Read habits file + if not HABITS_FILE.exists(): + self.send_json([]) + return + + with open(HABITS_FILE, 'r', encoding='utf-8') as f: + data = json.load(f) + + habits = data.get('habits', []) + + # Enrich each habit with calculated stats + enriched_habits = [] + for habit in habits: + # Calculate stats using helpers + current_streak = habits_helpers.calculate_streak(habit) + best_streak = habit.get('streak', {}).get('best', 0) + completion_rate = habits_helpers.get_completion_rate(habit, days=30) + weekly_summary = habits_helpers.get_weekly_summary(habit) + + # Add stats to habit + enriched = habit.copy() + enriched['current_streak'] = current_streak + enriched['best_streak'] = best_streak + enriched['completion_rate_30d'] = completion_rate + enriched['weekly_summary'] = weekly_summary + enriched['should_check_today'] = habits_helpers.should_check_today(habit) + + enriched_habits.append(enriched) + + # Sort by priority ascending (lower number = higher priority) + enriched_habits.sort(key=lambda h: h.get('priority', 999)) + + self.send_json(enriched_habits) + except Exception as e: + self.send_json({'error': str(e)}, 500) + + def handle_habits_post(self): + """Create a new habit.""" + try: + # Read request body + content_length = int(self.headers['Content-Length']) + post_data = self.rfile.read(content_length).decode('utf-8') + data = json.loads(post_data) + + # Validate required fields + name = data.get('name', '').strip() + if not name: + self.send_json({'error': 'name is required'}, 400) + return + + if len(name) > 100: + self.send_json({'error': 'name must be max 100 characters'}, 400) + return + + # Validate color (hex format) + color = data.get('color', '#3b82f6') + if color and not re.match(r'^#[0-9A-Fa-f]{6}$', color): + self.send_json({'error': 'color must be valid hex format (#RRGGBB)'}, 400) + return + + # Validate frequency type + frequency_type = data.get('frequency', {}).get('type', 'daily') + valid_types = ['daily', 'specific_days', 'x_per_week', 'weekly', 'monthly', 'custom'] + if frequency_type not in valid_types: + self.send_json({'error': f'frequency.type must be one of: {", ".join(valid_types)}'}, 400) + return + + # Create new habit + habit_id = str(uuid.uuid4()) + now = datetime.now().isoformat() + + new_habit = { + 'id': habit_id, + 'name': name, + 'category': data.get('category', 'other'), + 'color': color, + 'icon': data.get('icon', 'check-circle'), + 'priority': data.get('priority', 5), + 'notes': data.get('notes', ''), + 'reminderTime': data.get('reminderTime', ''), + 'frequency': data.get('frequency', {'type': 'daily'}), + 'streak': { + 'current': 0, + 'best': 0, + 'lastCheckIn': None + }, + 'lives': 3, + 'completions': [], + 'createdAt': now, + 'updatedAt': now + } + + # Read existing habits + if HABITS_FILE.exists(): + with open(HABITS_FILE, 'r', encoding='utf-8') as f: + habits_data = json.load(f) + else: + habits_data = {'lastUpdated': '', 'habits': []} + + # Add new habit + habits_data['habits'].append(new_habit) + habits_data['lastUpdated'] = now + + # Save to file + with open(HABITS_FILE, 'w', encoding='utf-8') as f: + json.dump(habits_data, f, indent=2) + + # Return created habit with 201 status + self.send_json(new_habit, 201) + except json.JSONDecodeError: + self.send_json({'error': 'Invalid JSON'}, 400) + except Exception as e: + self.send_json({'error': str(e)}, 500) + + def handle_habits_put(self): + """Update an existing habit.""" + try: + # Extract habit ID from path + path_parts = self.path.split('/') + if len(path_parts) < 4: + self.send_json({'error': 'Invalid path'}, 400) + return + + habit_id = path_parts[3] + + # Read request body + content_length = int(self.headers['Content-Length']) + post_data = self.rfile.read(content_length).decode('utf-8') + data = json.loads(post_data) + + # Read existing habits + if not HABITS_FILE.exists(): + self.send_json({'error': 'Habit not found'}, 404) + return + + with open(HABITS_FILE, 'r', encoding='utf-8') as f: + habits_data = json.load(f) + + # Find habit to update + habits = habits_data.get('habits', []) + habit_index = None + for i, habit in enumerate(habits): + if habit['id'] == habit_id: + habit_index = i + break + + if habit_index is None: + self.send_json({'error': 'Habit not found'}, 404) + return + + # Validate allowed fields + allowed_fields = ['name', 'category', 'color', 'icon', 'priority', 'notes', 'frequency', 'reminderTime'] + + # Validate name if provided + if 'name' in data: + name = data['name'].strip() + if not name: + self.send_json({'error': 'name cannot be empty'}, 400) + return + if len(name) > 100: + self.send_json({'error': 'name must be max 100 characters'}, 400) + return + + # Validate color if provided + if 'color' in data: + color = data['color'] + if color and not re.match(r'^#[0-9A-Fa-f]{6}$', color): + self.send_json({'error': 'color must be valid hex format (#RRGGBB)'}, 400) + return + + # Validate frequency type if provided + if 'frequency' in data: + frequency_type = data.get('frequency', {}).get('type', 'daily') + valid_types = ['daily', 'specific_days', 'x_per_week', 'weekly', 'monthly', 'custom'] + if frequency_type not in valid_types: + self.send_json({'error': f'frequency.type must be one of: {", ".join(valid_types)}'}, 400) + return + + # Update only allowed fields + habit = habits[habit_index] + for field in allowed_fields: + if field in data: + habit[field] = data[field] + + # Update timestamp + habit['updatedAt'] = datetime.now().isoformat() + + # Save to file + habits_data['lastUpdated'] = datetime.now().isoformat() + with open(HABITS_FILE, 'w', encoding='utf-8') as f: + json.dump(habits_data, f, indent=2) + + # Return updated habit + self.send_json(habit) + except json.JSONDecodeError: + self.send_json({'error': 'Invalid JSON'}, 400) + except Exception as e: + self.send_json({'error': str(e)}, 500) + + def handle_habits_delete(self): + """Delete a habit.""" + try: + # Extract habit ID from path + path_parts = self.path.split('/') + if len(path_parts) < 4: + self.send_json({'error': 'Invalid path'}, 400) + return + + habit_id = path_parts[3] + + # Read existing habits + if not HABITS_FILE.exists(): + self.send_json({'error': 'Habit not found'}, 404) + return + + with open(HABITS_FILE, 'r', encoding='utf-8') as f: + habits_data = json.load(f) + + # Find and remove habit + habits = habits_data.get('habits', []) + habit_found = False + for i, habit in enumerate(habits): + if habit['id'] == habit_id: + habits.pop(i) + habit_found = True + break + + if not habit_found: + self.send_json({'error': 'Habit not found'}, 404) + return + + # Save to file + habits_data['lastUpdated'] = datetime.now().isoformat() + with open(HABITS_FILE, 'w', encoding='utf-8') as f: + json.dump(habits_data, f, indent=2) + + # Return 204 No Content + self.send_response(204) + self.send_header('Access-Control-Allow-Origin', '*') + self.end_headers() + except Exception as e: + self.send_json({'error': str(e)}, 500) + + def handle_habits_check(self): + """Check in on a habit (complete it for today).""" + try: + # Extract habit ID from path (/api/habits/{id}/check) + path_parts = self.path.split('/') + if len(path_parts) < 5: + self.send_json({'error': 'Invalid path'}, 400) + return + + habit_id = path_parts[3] + + # Read optional body (note, rating, mood) + body_data = {} + content_length = self.headers.get('Content-Length') + if content_length: + post_data = self.rfile.read(int(content_length)).decode('utf-8') + if post_data.strip(): + try: + body_data = json.loads(post_data) + except json.JSONDecodeError: + self.send_json({'error': 'Invalid JSON'}, 400) + return + + # Read existing habits + if not HABITS_FILE.exists(): + self.send_json({'error': 'Habit not found'}, 404) + return + + with open(HABITS_FILE, 'r', encoding='utf-8') as f: + habits_data = json.load(f) + + # Find habit + habit = None + for h in habits_data.get('habits', []): + if h['id'] == habit_id: + habit = h + break + + if not habit: + self.send_json({'error': 'Habit not found'}, 404) + return + + # Verify habit is relevant for today + if not habits_helpers.should_check_today(habit): + self.send_json({'error': 'Habit is not relevant for today based on its frequency'}, 400) + return + + # Verify not already checked today + today = datetime.now().date().isoformat() + completions = habit.get('completions', []) + for completion in completions: + if completion.get('date') == today: + self.send_json({'error': 'Habit already checked in today'}, 409) + return + + # Create completion entry + completion_entry = { + 'date': today, + 'type': 'check' # Distinguish from 'skip' for life restore logic + } + + # Add optional fields + if 'note' in body_data: + completion_entry['note'] = body_data['note'] + if 'rating' in body_data: + rating = body_data['rating'] + if not isinstance(rating, int) or rating < 1 or rating > 5: + self.send_json({'error': 'rating must be an integer between 1 and 5'}, 400) + return + completion_entry['rating'] = rating + if 'mood' in body_data: + mood = body_data['mood'] + if mood not in ['happy', 'neutral', 'sad']: + self.send_json({'error': 'mood must be one of: happy, neutral, sad'}, 400) + return + completion_entry['mood'] = mood + + # Add completion to habit + habit['completions'].append(completion_entry) + + # Recalculate streak + current_streak = habits_helpers.calculate_streak(habit) + habit['streak']['current'] = current_streak + + # Update best streak if current is higher + if current_streak > habit['streak']['best']: + habit['streak']['best'] = current_streak + + # Update lastCheckIn + habit['streak']['lastCheckIn'] = today + + # Check for weekly lives recovery (+1 life if ≥1 check-in in previous week) + new_lives, was_awarded = habits_helpers.check_and_award_weekly_lives(habit) + lives_awarded_this_checkin = False + + if was_awarded: + habit['lives'] = new_lives + habit['lastLivesAward'] = today + lives_awarded_this_checkin = True + + # Update timestamp + habit['updatedAt'] = datetime.now().isoformat() + habits_data['lastUpdated'] = habit['updatedAt'] + + # Save to file + with open(HABITS_FILE, 'w', encoding='utf-8') as f: + json.dump(habits_data, f, indent=2) + + # Enrich habit with calculated stats before returning + current_streak = habits_helpers.calculate_streak(habit) + best_streak = habit.get('streak', {}).get('best', 0) + completion_rate = habits_helpers.get_completion_rate(habit, days=30) + weekly_summary = habits_helpers.get_weekly_summary(habit) + + enriched_habit = habit.copy() + enriched_habit['current_streak'] = current_streak + enriched_habit['best_streak'] = best_streak + enriched_habit['completion_rate_30d'] = completion_rate + enriched_habit['weekly_summary'] = weekly_summary + enriched_habit['should_check_today'] = habits_helpers.should_check_today(habit) + enriched_habit['livesAwarded'] = lives_awarded_this_checkin + + # Return enriched habit + self.send_json(enriched_habit, 200) + except Exception as e: + self.send_json({'error': str(e)}, 500) + + def handle_habits_uncheck(self): + """Uncheck a habit (remove completion for a specific date).""" + try: + # Extract habit ID from path (/api/habits/{id}/check) + path_parts = self.path.split('?')[0].split('/') + if len(path_parts) < 5: + self.send_json({'error': 'Invalid path'}, 400) + return + + habit_id = path_parts[3] + + # Parse query string for date parameter + parsed = urlparse(self.path) + query_params = parse_qs(parsed.query) + + # Get date from query string (required) + if 'date' not in query_params: + self.send_json({'error': 'date parameter is required (format: YYYY-MM-DD)'}, 400) + return + + target_date = query_params['date'][0] + + # Validate date format + try: + datetime.fromisoformat(target_date) + except ValueError: + self.send_json({'error': 'Invalid date format. Use YYYY-MM-DD'}, 400) + return + + # Read existing habits + if not HABITS_FILE.exists(): + self.send_json({'error': 'Habit not found'}, 404) + return + + with open(HABITS_FILE, 'r', encoding='utf-8') as f: + habits_data = json.load(f) + + # Find habit + habit = None + for h in habits_data.get('habits', []): + if h['id'] == habit_id: + habit = h + break + + if not habit: + self.send_json({'error': 'Habit not found'}, 404) + return + + # Find and remove the completion for the specified date + completions = habit.get('completions', []) + completion_found = False + for i, completion in enumerate(completions): + if completion.get('date') == target_date: + completions.pop(i) + completion_found = True + break + + if not completion_found: + self.send_json({'error': 'No completion found for the specified date'}, 404) + return + + # Recalculate streak after removing completion + current_streak = habits_helpers.calculate_streak(habit) + habit['streak']['current'] = current_streak + + # Update best streak if needed (best never decreases, but we keep it for consistency) + if current_streak > habit['streak']['best']: + habit['streak']['best'] = current_streak + + # Update timestamp + habit['updatedAt'] = datetime.now().isoformat() + habits_data['lastUpdated'] = habit['updatedAt'] + + # Save to file + with open(HABITS_FILE, 'w', encoding='utf-8') as f: + json.dump(habits_data, f, indent=2) + + # Enrich habit with calculated stats before returning + best_streak = habit.get('streak', {}).get('best', 0) + completion_rate = habits_helpers.get_completion_rate(habit, days=30) + weekly_summary = habits_helpers.get_weekly_summary(habit) + + enriched_habit = habit.copy() + enriched_habit['current_streak'] = current_streak + enriched_habit['best_streak'] = best_streak + enriched_habit['completion_rate_30d'] = completion_rate + enriched_habit['weekly_summary'] = weekly_summary + enriched_habit['should_check_today'] = habits_helpers.should_check_today(habit) + + # Return enriched habit + self.send_json(enriched_habit, 200) + except Exception as e: + self.send_json({'error': str(e)}, 500) + + def handle_habits_skip(self): + """Skip a day using a life to preserve streak.""" + try: + # Extract habit ID from path (/api/habits/{id}/skip) + path_parts = self.path.split('/') + if len(path_parts) < 5: + self.send_json({'error': 'Invalid path'}, 400) + return + + habit_id = path_parts[3] + + # Read existing habits + if not HABITS_FILE.exists(): + self.send_json({'error': 'Habit not found'}, 404) + return + + with open(HABITS_FILE, 'r', encoding='utf-8') as f: + habits_data = json.load(f) + + # Find habit + habit = None + for h in habits_data.get('habits', []): + if h['id'] == habit_id: + habit = h + break + + if not habit: + self.send_json({'error': 'Habit not found'}, 404) + return + + # Verify lives > 0 + current_lives = habit.get('lives', 3) + if current_lives <= 0: + self.send_json({'error': 'No lives remaining'}, 400) + return + + # Decrement lives by 1 + habit['lives'] = current_lives - 1 + + # Add completion entry with type='skip' + today = datetime.now().date().isoformat() + completion_entry = { + 'date': today, + 'type': 'skip' + } + habit['completions'].append(completion_entry) + + # Update timestamp + habit['updatedAt'] = datetime.now().isoformat() + habits_data['lastUpdated'] = habit['updatedAt'] + + # Save to file + with open(HABITS_FILE, 'w', encoding='utf-8') as f: + json.dump(habits_data, f, indent=2) + + # Enrich habit with calculated stats before returning + current_streak = habits_helpers.calculate_streak(habit) + best_streak = habit.get('streak', {}).get('best', 0) + completion_rate = habits_helpers.get_completion_rate(habit, days=30) + weekly_summary = habits_helpers.get_weekly_summary(habit) + + enriched_habit = habit.copy() + enriched_habit['current_streak'] = current_streak + enriched_habit['best_streak'] = best_streak + enriched_habit['completion_rate_30d'] = completion_rate + enriched_habit['weekly_summary'] = weekly_summary + enriched_habit['should_check_today'] = habits_helpers.should_check_today(habit) + + # Return enriched habit + self.send_json(enriched_habit, 200) + except Exception as e: + self.send_json({'error': str(e)}, 500) + def send_json(self, data, code=200): self.send_response(code) self.send_header('Content-Type', 'application/json') @@ -1394,7 +1962,7 @@ class TaskBoardHandler(SimpleHTTPRequestHandler): def do_OPTIONS(self): self.send_response(200) self.send_header('Access-Control-Allow-Origin', '*') - self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') + self.send_header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') self.send_header('Access-Control-Allow-Headers', 'Content-Type') self.end_headers() diff --git a/dashboard/archive/tasks-2026-02.json b/dashboard/archive/tasks-2026-02.json new file mode 100644 index 0000000..2687c0b --- /dev/null +++ b/dashboard/archive/tasks-2026-02.json @@ -0,0 +1,57 @@ +{ + "month": "2026-02", + "tasks": [ + { + "id": "task-034", + "title": "Actualizare documentație canale agenți", + "description": "", + "created": "2026-02-01T12:15:41Z", + "priority": "medium", + "completed": "2026-02-01T12:15:44Z" + }, + { + "id": "task-035", + "title": "Restructurare echipă: șterg work, unific health+growth→self", + "description": "", + "created": "2026-02-01T12:20:59Z", + "priority": "medium", + "completed": "2026-02-01T12:23:32Z" + }, + { + "id": "task-036", + "title": "Unificare în 1 agent cu tehnici diminuare dezavantaje", + "description": "", + "created": "2026-02-01T13:27:51Z", + "priority": "medium", + "completed": "2026-02-01T13:30:01Z" + }, + { + "id": "task-037", + "title": "Coaching dimineață - Asumarea eforturilor (Zoltan Vereș)", + "description": "", + "created": "2026-02-02T07:01:14Z", + "priority": "medium" + }, + { + "id": "task-038", + "title": "Raport dimineata trimis pe email", + "description": "", + "created": "2026-02-03T06:31:08Z", + "priority": "medium" + }, + { + "id": "task-039", + "title": "Raport seară 3 feb trimis pe email", + "description": "", + "created": "2026-02-03T18:01:12Z", + "priority": "medium" + }, + { + "id": "task-040", + "title": "Job night-execute: 2 video-uri YouTube procesate", + "description": "", + "created": "2026-02-03T21:02:31Z", + "priority": "medium" + } + ] +} \ No newline at end of file diff --git a/dashboard/files.html b/dashboard/files.html index dff270f..c9cc830 100644 --- a/dashboard/files.html +++ b/dashboard/files.html @@ -846,6 +846,10 @@ KB + + + Habits + Files diff --git a/dashboard/grup-sprijin.html b/dashboard/grup-sprijin.html index 5378087..c0b5aec 100644 --- a/dashboard/grup-sprijin.html +++ b/dashboard/grup-sprijin.html @@ -246,6 +246,10 @@ Notes + + + Habits + Files diff --git a/dashboard/habits.html b/dashboard/habits.html new file mode 100644 index 0000000..ada3027 --- /dev/null +++ b/dashboard/habits.html @@ -0,0 +1,3490 @@ + + + + + + + Echo · Habits + + + + + + +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ +
+ + +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+ + + + +
+
+ +

Loading habits...

+
+
+
+ + + + + + + + + + + + + diff --git a/dashboard/habits.json b/dashboard/habits.json new file mode 100644 index 0000000..d54ea85 --- /dev/null +++ b/dashboard/habits.json @@ -0,0 +1,56 @@ +{ + "lastUpdated": "2026-02-11T10:59:50.675572", + "habits": [ + { + "id": "95c15eef-3a14-4985-a61e-0b64b72851b0", + "name": "Bazin \u0219i Saun\u0103", + "category": "health", + "color": "#EF4444", + "icon": "target", + "priority": 50, + "notes": "", + "reminderTime": "19:00", + "frequency": { + "type": "x_per_week", + "count": 5 + }, + "streak": { + "current": 0, + "best": 1, + "lastCheckIn": "2026-02-11" + }, + "lives": 0, + "completions": [], + "createdAt": "2026-02-11T00:54:03.447063", + "updatedAt": "2026-02-11T10:59:50.675572" + }, + { + "id": "ceddaa7e-caf9-4038-94bb-da486c586bf8", + "name": "Fotocitire", + "category": "growth", + "color": "#10B981", + "icon": "camera", + "priority": 30, + "notes": "", + "reminderTime": "", + "frequency": { + "type": "x_per_week", + "count": 3 + }, + "streak": { + "current": 1, + "best": 1, + "lastCheckIn": "2026-02-11" + }, + "lives": 2, + "completions": [ + { + "date": "2026-02-11", + "type": "check" + } + ], + "createdAt": "2026-02-11T01:58:44.779904", + "updatedAt": "2026-02-11T10:51:30.181867" + } + ] +} \ No newline at end of file diff --git a/dashboard/habits_helpers.py b/dashboard/habits_helpers.py new file mode 100644 index 0000000..a4e9780 --- /dev/null +++ b/dashboard/habits_helpers.py @@ -0,0 +1,387 @@ +""" +Habit Tracker Helper Functions + +This module provides core helper functions for calculating streaks, +checking relevance, and computing stats for habits. +""" + +from datetime import datetime, timedelta +from typing import Dict, List, Any, Optional + + +def calculate_streak(habit: Dict[str, Any]) -> int: + """ + Calculate the current streak for a habit based on its frequency type. + Skips maintain the streak (don't break it) but don't count toward the total. + + Args: + habit: Dict containing habit data with frequency, completions, etc. + + Returns: + int: Current streak count (days, weeks, or months depending on frequency) + """ + frequency_type = habit.get("frequency", {}).get("type", "daily") + completions = habit.get("completions", []) + + if not completions: + return 0 + + # Sort completions by date (newest first) + sorted_completions = sorted( + [c for c in completions if c.get("date")], + key=lambda x: x["date"], + reverse=True + ) + + if not sorted_completions: + return 0 + + if frequency_type == "daily": + return _calculate_daily_streak(sorted_completions) + elif frequency_type == "specific_days": + return _calculate_specific_days_streak(habit, sorted_completions) + elif frequency_type == "x_per_week": + return _calculate_x_per_week_streak(habit, sorted_completions) + elif frequency_type == "weekly": + return _calculate_weekly_streak(sorted_completions) + elif frequency_type == "monthly": + return _calculate_monthly_streak(sorted_completions) + elif frequency_type == "custom": + return _calculate_custom_streak(habit, sorted_completions) + + return 0 + + +def _calculate_daily_streak(completions: List[Dict[str, Any]]) -> int: + """ + Calculate streak for daily habits (consecutive days). + Skips maintain the streak (don't break it) but don't count toward the total. + """ + streak = 0 + today = datetime.now().date() + expected_date = today + + for completion in completions: + completion_date = datetime.fromisoformat(completion["date"]).date() + completion_type = completion.get("type", "check") + + if completion_date == expected_date: + # Only count 'check' completions toward streak total + # 'skip' completions maintain the streak but don't extend it + if completion_type == "check": + streak += 1 + expected_date = completion_date - timedelta(days=1) + elif completion_date < expected_date: + # Gap found, streak breaks + break + + return streak + + +def _calculate_specific_days_streak(habit: Dict[str, Any], completions: List[Dict[str, Any]]) -> int: + """Calculate streak for specific days habits (only count relevant days).""" + relevant_days = set(habit.get("frequency", {}).get("days", [])) + if not relevant_days: + return 0 + + streak = 0 + today = datetime.now().date() + current_date = today + + # Find the most recent relevant day + while current_date.weekday() not in relevant_days: + current_date -= timedelta(days=1) + + for completion in completions: + completion_date = datetime.fromisoformat(completion["date"]).date() + + if completion_date == current_date: + streak += 1 + # Move to previous relevant day + current_date -= timedelta(days=1) + while current_date.weekday() not in relevant_days: + current_date -= timedelta(days=1) + elif completion_date < current_date: + # Check if we missed a relevant day + temp_date = current_date + found_gap = False + while temp_date > completion_date: + if temp_date.weekday() in relevant_days: + found_gap = True + break + temp_date -= timedelta(days=1) + if found_gap: + break + + return streak + + +def _calculate_x_per_week_streak(habit: Dict[str, Any], completions: List[Dict[str, Any]]) -> int: + """Calculate streak for x_per_week habits (consecutive days with check-ins). + + For x_per_week habits, streak counts consecutive DAYS with check-ins, + not consecutive weeks meeting the target. The weekly target (e.g., 4/week) + is a goal, but streak measures the chain of check-in days. + """ + # Use the same logic as daily habits - count consecutive check-in days + return _calculate_daily_streak(completions) + + +def _calculate_weekly_streak(completions: List[Dict[str, Any]]) -> int: + """Calculate streak for weekly habits (consecutive days with check-ins). + + For weekly habits, streak counts consecutive DAYS with check-ins, + just like daily habits. The weekly frequency just means you should + check in at least once per week. + """ + return _calculate_daily_streak(completions) + + +def _calculate_monthly_streak(completions: List[Dict[str, Any]]) -> int: + """Calculate streak for monthly habits (consecutive days with check-ins). + + For monthly habits, streak counts consecutive DAYS with check-ins, + just like daily habits. The monthly frequency just means you should + check in at least once per month. + """ + return _calculate_daily_streak(completions) + + +def _calculate_custom_streak(habit: Dict[str, Any], completions: List[Dict[str, Any]]) -> int: + """Calculate streak for custom interval habits (every X days).""" + interval = habit.get("frequency", {}).get("interval", 1) + if interval <= 0: + return 0 + + streak = 0 + expected_date = datetime.now().date() + + for completion in completions: + completion_date = datetime.fromisoformat(completion["date"]).date() + + # Allow completion within the interval window + days_diff = (expected_date - completion_date).days + if 0 <= days_diff <= interval - 1: + streak += 1 + expected_date = completion_date - timedelta(days=interval) + else: + break + + return streak + + +def should_check_today(habit: Dict[str, Any]) -> bool: + """ + Check if a habit is relevant for today based on its frequency type. + + Args: + habit: Dict containing habit data with frequency settings + + Returns: + bool: True if the habit should be checked today + """ + frequency_type = habit.get("frequency", {}).get("type", "daily") + today = datetime.now().date() + weekday = today.weekday() # 0=Monday, 6=Sunday + + if frequency_type == "daily": + return True + + elif frequency_type == "specific_days": + relevant_days = set(habit.get("frequency", {}).get("days", [])) + return weekday in relevant_days + + elif frequency_type == "x_per_week": + # Always relevant for x_per_week (can check any day) + return True + + elif frequency_type == "weekly": + # Always relevant (can check any day of the week) + return True + + elif frequency_type == "monthly": + # Always relevant (can check any day of the month) + return True + + elif frequency_type == "custom": + # Check if enough days have passed since last completion + completions = habit.get("completions", []) + if not completions: + return True + + interval = habit.get("frequency", {}).get("interval", 1) + last_completion = max(completions, key=lambda x: x.get("date", "")) + last_date = datetime.fromisoformat(last_completion["date"]).date() + days_since = (today - last_date).days + + return days_since >= interval + + return False + + +def get_completion_rate(habit: Dict[str, Any], days: int = 30) -> float: + """ + Calculate the completion rate as a percentage over the last N days. + + Args: + habit: Dict containing habit data + days: Number of days to look back (default 30) + + Returns: + float: Completion rate as percentage (0-100) + """ + frequency_type = habit.get("frequency", {}).get("type", "daily") + completions = habit.get("completions", []) + + today = datetime.now().date() + start_date = today - timedelta(days=days - 1) + + # Count relevant days and checked days + relevant_days = 0 + checked_dates = set() + + for completion in completions: + completion_date = datetime.fromisoformat(completion["date"]).date() + if start_date <= completion_date <= today: + checked_dates.add(completion_date) + + # Calculate relevant days based on frequency type + if frequency_type == "daily": + relevant_days = days + + elif frequency_type == "specific_days": + relevant_day_set = set(habit.get("frequency", {}).get("days", [])) + current = start_date + while current <= today: + if current.weekday() in relevant_day_set: + relevant_days += 1 + current += timedelta(days=1) + + elif frequency_type == "x_per_week": + target_per_week = habit.get("frequency", {}).get("count", 1) + num_weeks = days // 7 + relevant_days = num_weeks * target_per_week + + elif frequency_type == "weekly": + num_weeks = days // 7 + relevant_days = num_weeks + + elif frequency_type == "monthly": + num_months = days // 30 + relevant_days = num_months + + elif frequency_type == "custom": + interval = habit.get("frequency", {}).get("interval", 1) + relevant_days = days // interval if interval > 0 else 0 + + if relevant_days == 0: + return 0.0 + + checked_days = len(checked_dates) + return (checked_days / relevant_days) * 100 + + +def get_weekly_summary(habit: Dict[str, Any]) -> Dict[str, str]: + """ + Get a summary of the current week showing status for each day. + + Args: + habit: Dict containing habit data + + Returns: + Dict mapping day names to status: "checked", "skipped", "missed", or "upcoming" + """ + frequency_type = habit.get("frequency", {}).get("type", "daily") + completions = habit.get("completions", []) + + today = datetime.now().date() + + # Start of current week (Monday) + start_of_week = today - timedelta(days=today.weekday()) + + # Create completion map + completion_map = {} + for completion in completions: + completion_date = datetime.fromisoformat(completion["date"]).date() + if completion_date >= start_of_week: + completion_type = completion.get("type", "check") + completion_map[completion_date] = completion_type + + # Build summary for each day of the week + summary = {} + day_names = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + + for i, day_name in enumerate(day_names): + day_date = start_of_week + timedelta(days=i) + + if day_date > today: + summary[day_name] = "upcoming" + elif day_date in completion_map: + if completion_map[day_date] == "skip": + summary[day_name] = "skipped" + else: + summary[day_name] = "checked" + else: + # Check if this day was relevant + if frequency_type == "specific_days": + relevant_days = set(habit.get("frequency", {}).get("days", [])) + if day_date.weekday() not in relevant_days: + summary[day_name] = "not_relevant" + else: + summary[day_name] = "missed" + else: + summary[day_name] = "missed" + + return summary + + +def check_and_award_weekly_lives(habit: Dict[str, Any]) -> tuple[int, bool]: + """ + Check if habit qualifies for weekly lives recovery and award +1 life if eligible. + + Awards +1 life if: + - At least one check-in in the previous week (Monday-Sunday) + - Not already awarded this week + + Args: + habit: Dict containing habit data with completions and lastLivesAward + + Returns: + tuple[int, bool]: (new_lives_count, was_awarded) + """ + completions = habit.get("completions", []) + current_lives = habit.get("lives", 3) + + today = datetime.now().date() + + # Calculate current week start (Monday 00:00) + current_week_start = today - timedelta(days=today.weekday()) + + # Check if already awarded this week + last_lives_award = habit.get("lastLivesAward") + if last_lives_award: + last_award_date = datetime.fromisoformat(last_lives_award).date() + if last_award_date >= current_week_start: + # Already awarded this week + return (current_lives, False) + + # Calculate previous week boundaries + previous_week_start = current_week_start - timedelta(days=7) + previous_week_end = current_week_start - timedelta(days=1) + + # Count check-ins in previous week + checkins_in_previous_week = 0 + for completion in completions: + completion_date = datetime.fromisoformat(completion["date"]).date() + completion_type = completion.get("type", "check") + + if previous_week_start <= completion_date <= previous_week_end: + if completion_type == "check": + checkins_in_previous_week += 1 + + # Award life if at least 1 check-in found + if checkins_in_previous_week >= 1: + new_lives = current_lives + 1 + return (new_lives, True) + + return (current_lives, False) diff --git a/dashboard/index.html b/dashboard/index.html index e15431c..bdf20cd 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -1071,6 +1071,10 @@ KB + + + Habits + Files diff --git a/dashboard/notes.html b/dashboard/notes.html index 6918f3b..6f3cab1 100644 --- a/dashboard/notes.html +++ b/dashboard/notes.html @@ -696,6 +696,10 @@ KB + + + Habits + Files diff --git a/dashboard/status.json b/dashboard/status.json index 69d43ce..b35ea02 100644 --- a/dashboard/status.json +++ b/dashboard/status.json @@ -13,7 +13,7 @@ "ok": false, "status": "MODIFICĂRI", "message": "1 modificări detectate", - "lastCheck": "09 Feb 2026, 14:00", + "lastCheck": "11 Feb 2026, 08:00", "changesCount": 1 } } \ No newline at end of file diff --git a/dashboard/swipe-nav.js b/dashboard/swipe-nav.js index 728016b..2c3b1ec 100644 --- a/dashboard/swipe-nav.js +++ b/dashboard/swipe-nav.js @@ -3,7 +3,7 @@ * Swipe left/right to navigate between pages */ (function() { - const pages = ['index.html', 'notes.html', 'files.html']; + const pages = ['index.html', 'notes.html', 'habits.html', 'files.html', 'workspace.html']; // Get current page index function getCurrentIndex() { @@ -45,7 +45,7 @@ function handleSwipe() { const deltaX = touchEndX - touchStartX; const deltaY = Math.abs(touchEndY - touchStartY); - + // Ignore if vertical swipe or too short if (deltaY > maxVerticalDistance) return; if (Math.abs(deltaX) < minSwipeDistance) return; diff --git a/dashboard/tasks.json b/dashboard/tasks.json index 3707df6..088b472 100644 --- a/dashboard/tasks.json +++ b/dashboard/tasks.json @@ -1,5 +1,5 @@ { - "lastUpdated": "2026-02-07T03:00:05.489098", + "lastUpdated": "2026-02-11T03:00:04.800665", "columns": [ { "id": "backlog", @@ -30,58 +30,6 @@ "id": "done", "name": "Done", "tasks": [ - { - "id": "task-034", - "title": "Actualizare documentație canale agenți", - "description": "", - "created": "2026-02-01T12:15:41Z", - "priority": "medium", - "completed": "2026-02-01T12:15:44Z" - }, - { - "id": "task-035", - "title": "Restructurare echipă: șterg work, unific health+growth→self", - "description": "", - "created": "2026-02-01T12:20:59Z", - "priority": "medium", - "completed": "2026-02-01T12:23:32Z" - }, - { - "id": "task-036", - "title": "Unificare în 1 agent cu tehnici diminuare dezavantaje", - "description": "", - "created": "2026-02-01T13:27:51Z", - "priority": "medium", - "completed": "2026-02-01T13:30:01Z" - }, - { - "id": "task-037", - "title": "Coaching dimineață - Asumarea eforturilor (Zoltan Vereș)", - "description": "", - "created": "2026-02-02T07:01:14Z", - "priority": "medium" - }, - { - "id": "task-038", - "title": "Raport dimineata trimis pe email", - "description": "", - "created": "2026-02-03T06:31:08Z", - "priority": "medium" - }, - { - "id": "task-039", - "title": "Raport seară 3 feb trimis pe email", - "description": "", - "created": "2026-02-03T18:01:12Z", - "priority": "medium" - }, - { - "id": "task-040", - "title": "Job night-execute: 2 video-uri YouTube procesate", - "description": "", - "created": "2026-02-03T21:02:31Z", - "priority": "medium" - }, { "id": "task-041", "title": "Raport dimineață trimis pe email", diff --git a/dashboard/tests/test_habits_api.py b/dashboard/tests/test_habits_api.py new file mode 100644 index 0000000..39507d2 --- /dev/null +++ b/dashboard/tests/test_habits_api.py @@ -0,0 +1,1129 @@ +#!/usr/bin/env python3 +"""Tests for habits API endpoints (GET and POST).""" + +import json +import sys +import subprocess +import tempfile +import shutil +from pathlib import Path +from datetime import datetime, timedelta +from http.server import HTTPServer +import threading +import time +import urllib.request +import urllib.error + +# Add parent directory to path so we can import api module +sys.path.insert(0, str(Path(__file__).parent.parent)) + +# Mock the habits file to a temp location for testing +import api +original_habits_file = api.HABITS_FILE + +def setup_test_env(): + """Set up temporary test environment.""" + temp_dir = Path(tempfile.mkdtemp()) + api.HABITS_FILE = temp_dir / 'habits.json' + + # Create empty habits file + api.HABITS_FILE.write_text(json.dumps({ + 'lastUpdated': '', + 'habits': [] + })) + + return temp_dir + +def cleanup_test_env(temp_dir): + """Clean up temporary test environment.""" + api.HABITS_FILE = original_habits_file + shutil.rmtree(temp_dir) + +def start_test_server(): + """Start test server in background thread with random available port.""" + server = HTTPServer(('localhost', 0), api.TaskBoardHandler) # Port 0 = random + port = server.server_address[1] # Get actual assigned port + thread = threading.Thread(target=server.serve_forever) + thread.daemon = True + thread.start() + time.sleep(0.3) # Give server time to start + return server, port + +def http_get(path, port=8765): + """Make HTTP GET request.""" + url = f'http://localhost:{port}{path}' + try: + with urllib.request.urlopen(url) as response: + return response.status, json.loads(response.read().decode()) + except urllib.error.HTTPError as e: + return e.code, json.loads(e.read().decode()) if e.headers.get('Content-Type') == 'application/json' else {} + +def http_post(path, data, port=8765): + """Make HTTP POST request.""" + url = f'http://localhost:{port}{path}' + req = urllib.request.Request( + url, + data=json.dumps(data).encode(), + headers={'Content-Type': 'application/json'} + ) + try: + with urllib.request.urlopen(req) as response: + return response.status, json.loads(response.read().decode()) + except urllib.error.HTTPError as e: + return e.code, json.loads(e.read().decode()) if e.headers.get('Content-Type') == 'application/json' else {} + +def http_put(path, data, port=8765): + """Make HTTP PUT request.""" + url = f'http://localhost:{port}{path}' + req = urllib.request.Request( + url, + data=json.dumps(data).encode(), + headers={'Content-Type': 'application/json'}, + method='PUT' + ) + try: + with urllib.request.urlopen(req) as response: + return response.status, json.loads(response.read().decode()) + except urllib.error.HTTPError as e: + return e.code, json.loads(e.read().decode()) if e.headers.get('Content-Type') == 'application/json' else {} + +def http_delete(path, port=8765): + """Make HTTP DELETE request.""" + url = f'http://localhost:{port}{path}' + req = urllib.request.Request(url, method='DELETE') + try: + with urllib.request.urlopen(req) as response: + # Handle JSON response if present + if response.headers.get('Content-Type') == 'application/json': + return response.status, json.loads(response.read().decode()) + else: + return response.status, None + except urllib.error.HTTPError as e: + return e.code, json.loads(e.read().decode()) if e.headers.get('Content-Type') == 'application/json' else {} + +# Test 1: GET /api/habits returns empty array when no habits +def test_get_habits_empty(): + temp_dir = setup_test_env() + server, port = start_test_server() + + try: + status, data = http_get('/api/habits', port) + assert status == 200, f"Expected 200, got {status}" + assert data == [], f"Expected empty array, got {data}" + print("✓ Test 1: GET /api/habits returns empty array") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 2: POST /api/habits creates new habit with valid input +def test_post_habit_valid(): + temp_dir = setup_test_env() + server, port = start_test_server() + + try: + habit_data = { + 'name': 'Morning Exercise', + 'category': 'health', + 'color': '#10b981', + 'icon': 'dumbbell', + 'priority': 1, + 'notes': 'Start with 10 push-ups', + 'reminderTime': '07:00', + 'frequency': { + 'type': 'daily' + } + } + + status, data = http_post('/api/habits', habit_data, port) + assert status == 201, f"Expected 201, got {status}" + assert 'id' in data, "Response should include habit id" + assert data['name'] == 'Morning Exercise', f"Name mismatch: {data['name']}" + assert data['category'] == 'health', f"Category mismatch: {data['category']}" + assert data['streak']['current'] == 0, "Initial streak should be 0" + assert data['lives'] == 3, "Initial lives should be 3" + assert data['completions'] == [], "Initial completions should be empty" + print("✓ Test 2: POST /api/habits creates habit with 201") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 3: POST validates name is required +def test_post_habit_missing_name(): + temp_dir = setup_test_env() + server, port = start_test_server() + + try: + status, data = http_post('/api/habits', {}, port) + assert status == 400, f"Expected 400, got {status}" + assert 'error' in data, "Response should include error" + assert 'name' in data['error'].lower(), f"Error should mention name: {data['error']}" + print("✓ Test 3: POST validates name is required") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 4: POST validates name max 100 chars +def test_post_habit_name_too_long(): + temp_dir = setup_test_env() + server, port = start_test_server() + + try: + status, data = http_post('/api/habits', {'name': 'x' * 101}, port) + assert status == 400, f"Expected 400, got {status}" + assert 'error' in data, "Response should include error" + assert '100' in data['error'], f"Error should mention max length: {data['error']}" + print("✓ Test 4: POST validates name max 100 chars") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 5: POST validates color hex format +def test_post_habit_invalid_color(): + temp_dir = setup_test_env() + server, port = start_test_server() + + try: + status, data = http_post('/api/habits', { + 'name': 'Test', + 'color': 'not-a-hex-color' + }, port) + assert status == 400, f"Expected 400, got {status}" + assert 'error' in data, "Response should include error" + assert 'color' in data['error'].lower(), f"Error should mention color: {data['error']}" + print("✓ Test 5: POST validates color hex format") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 6: POST validates frequency type +def test_post_habit_invalid_frequency(): + temp_dir = setup_test_env() + server, port = start_test_server() + + try: + status, data = http_post('/api/habits', { + 'name': 'Test', + 'frequency': {'type': 'invalid_type'} + }, port) + assert status == 400, f"Expected 400, got {status}" + assert 'error' in data, "Response should include error" + assert 'frequency' in data['error'].lower(), f"Error should mention frequency: {data['error']}" + print("✓ Test 6: POST validates frequency type") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 7: GET /api/habits returns habits with stats enriched +def test_get_habits_with_stats(): + temp_dir = setup_test_env() + server, port = start_test_server() + + try: + # Create a habit first + habit_data = {'name': 'Daily Reading', 'frequency': {'type': 'daily'}} + http_post('/api/habits', habit_data, port) + + # Get habits + status, data = http_get('/api/habits', port) + assert status == 200, f"Expected 200, got {status}" + assert len(data) == 1, f"Expected 1 habit, got {len(data)}" + + habit = data[0] + assert 'current_streak' in habit, "Should include current_streak" + assert 'best_streak' in habit, "Should include best_streak" + assert 'completion_rate_30d' in habit, "Should include completion_rate_30d" + assert 'weekly_summary' in habit, "Should include weekly_summary" + print("✓ Test 7: GET returns habits with stats enriched") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 8: GET /api/habits sorts by priority ascending +def test_get_habits_sorted_by_priority(): + temp_dir = setup_test_env() + server, port = start_test_server() + + try: + # Create habits with different priorities + http_post('/api/habits', {'name': 'Low Priority', 'priority': 10}, port) + http_post('/api/habits', {'name': 'High Priority', 'priority': 1}, port) + http_post('/api/habits', {'name': 'Medium Priority', 'priority': 5}, port) + + # Get habits + status, data = http_get('/api/habits', port) + assert status == 200, f"Expected 200, got {status}" + assert len(data) == 3, f"Expected 3 habits, got {len(data)}" + + # Check sorting + assert data[0]['priority'] == 1, "First should be priority 1" + assert data[1]['priority'] == 5, "Second should be priority 5" + assert data[2]['priority'] == 10, "Third should be priority 10" + print("✓ Test 8: GET sorts habits by priority ascending") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 9: POST returns 400 for invalid JSON +def test_post_habit_invalid_json(): + temp_dir = setup_test_env() + server, port = start_test_server() + + try: + url = f'http://localhost:{port}/api/habits' + req = urllib.request.Request( + url, + data=b'invalid json{', + headers={'Content-Type': 'application/json'} + ) + try: + urllib.request.urlopen(req) + assert False, "Should have raised HTTPError" + except urllib.error.HTTPError as e: + assert e.code == 400, f"Expected 400, got {e.code}" + print("✓ Test 9: POST returns 400 for invalid JSON") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 10: POST initializes streak.current=0 +def test_post_habit_initial_streak(): + temp_dir = setup_test_env() + server, port = start_test_server() + + try: + status, data = http_post('/api/habits', {'name': 'Test Habit'}, port) + assert status == 201, f"Expected 201, got {status}" + assert data['streak']['current'] == 0, "Initial streak.current should be 0" + assert data['streak']['best'] == 0, "Initial streak.best should be 0" + assert data['streak']['lastCheckIn'] is None, "Initial lastCheckIn should be None" + print("✓ Test 10: POST initializes streak correctly") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 12: PUT /api/habits/{id} updates habit successfully +def test_put_habit_valid(): + temp_dir = setup_test_env() + server, port = start_test_server() + + try: + # Create a habit first + habit_data = { + 'name': 'Original Name', + 'category': 'health', + 'color': '#10b981', + 'priority': 3 + } + status, created_habit = http_post('/api/habits', habit_data, port) + habit_id = created_habit['id'] + + # Update the habit + update_data = { + 'name': 'Updated Name', + 'category': 'productivity', + 'color': '#ef4444', + 'priority': 1, + 'notes': 'New notes' + } + status, updated_habit = http_put(f'/api/habits/{habit_id}', update_data, port) + + assert status == 200, f"Expected 200, got {status}" + assert updated_habit['name'] == 'Updated Name', "Name not updated" + assert updated_habit['category'] == 'productivity', "Category not updated" + assert updated_habit['color'] == '#ef4444', "Color not updated" + assert updated_habit['priority'] == 1, "Priority not updated" + assert updated_habit['notes'] == 'New notes', "Notes not updated" + assert updated_habit['id'] == habit_id, "ID should not change" + print("✓ Test 12: PUT /api/habits/{id} updates habit successfully") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 13: PUT /api/habits/{id} does not allow editing protected fields +def test_put_habit_protected_fields(): + temp_dir = setup_test_env() + server, port = start_test_server() + + try: + # Create a habit first + habit_data = {'name': 'Test Habit'} + status, created_habit = http_post('/api/habits', habit_data, port) + habit_id = created_habit['id'] + original_created_at = created_habit['createdAt'] + + # Try to update protected fields + update_data = { + 'name': 'Updated Name', + 'id': 'new-id', + 'createdAt': '2020-01-01T00:00:00', + 'streak': {'current': 100, 'best': 200}, + 'lives': 10, + 'completions': [{'date': '2025-01-01'}] + } + status, updated_habit = http_put(f'/api/habits/{habit_id}', update_data, port) + + assert status == 200, f"Expected 200, got {status}" + assert updated_habit['name'] == 'Updated Name', "Name should be updated" + assert updated_habit['id'] == habit_id, "ID should not change" + assert updated_habit['createdAt'] == original_created_at, "createdAt should not change" + assert updated_habit['streak']['current'] == 0, "streak should not change" + assert updated_habit['lives'] == 3, "lives should not change" + assert updated_habit['completions'] == [], "completions should not change" + print("✓ Test 13: PUT /api/habits/{id} does not allow editing protected fields") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 14: PUT /api/habits/{id} returns 404 for non-existent habit +def test_put_habit_not_found(): + temp_dir = setup_test_env() + server, port = start_test_server() + + try: + update_data = {'name': 'Updated Name'} + status, response = http_put('/api/habits/non-existent-id', update_data, port) + + assert status == 404, f"Expected 404, got {status}" + assert 'error' in response, "Expected error message" + print("✓ Test 14: PUT /api/habits/{id} returns 404 for non-existent habit") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 15: PUT /api/habits/{id} validates input +def test_put_habit_invalid_input(): + temp_dir = setup_test_env() + server, port = start_test_server() + + try: + # Create a habit first + habit_data = {'name': 'Test Habit'} + status, created_habit = http_post('/api/habits', habit_data, port) + habit_id = created_habit['id'] + + # Test invalid color + update_data = {'color': 'not-a-hex-color'} + status, response = http_put(f'/api/habits/{habit_id}', update_data, port) + assert status == 400, f"Expected 400 for invalid color, got {status}" + + # Test empty name + update_data = {'name': ''} + status, response = http_put(f'/api/habits/{habit_id}', update_data, port) + assert status == 400, f"Expected 400 for empty name, got {status}" + + # Test name too long + update_data = {'name': 'x' * 101} + status, response = http_put(f'/api/habits/{habit_id}', update_data, port) + assert status == 400, f"Expected 400 for long name, got {status}" + + print("✓ Test 15: PUT /api/habits/{id} validates input") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 16: DELETE /api/habits/{id} removes habit successfully +def test_delete_habit_success(): + temp_dir = setup_test_env() + server, port = start_test_server() + + try: + # Create a habit first + habit_data = {'name': 'Habit to Delete'} + status, created_habit = http_post('/api/habits', habit_data, port) + habit_id = created_habit['id'] + + # Verify habit exists + status, habits = http_get('/api/habits', port) + assert len(habits) == 1, "Should have 1 habit" + + # Delete the habit + status, _ = http_delete(f'/api/habits/{habit_id}', port) + assert status == 204, f"Expected 204, got {status}" + + # Verify habit is deleted + status, habits = http_get('/api/habits', port) + assert len(habits) == 0, "Should have 0 habits after deletion" + print("✓ Test 16: DELETE /api/habits/{id} removes habit successfully") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 17: DELETE /api/habits/{id} returns 404 for non-existent habit +def test_delete_habit_not_found(): + temp_dir = setup_test_env() + server, port = start_test_server() + + try: + status, response = http_delete('/api/habits/non-existent-id', port) + + assert status == 404, f"Expected 404, got {status}" + assert 'error' in response, "Expected error message" + print("✓ Test 17: DELETE /api/habits/{id} returns 404 for non-existent habit") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 18: do_OPTIONS includes PUT and DELETE methods +def test_options_includes_put_delete(): + temp_dir = setup_test_env() + server, port = start_test_server() + + try: + # Make OPTIONS request + url = f'http://localhost:{port}/api/habits' + req = urllib.request.Request(url, method='OPTIONS') + with urllib.request.urlopen(req) as response: + allowed_methods = response.headers.get('Access-Control-Allow-Methods', '') + assert 'PUT' in allowed_methods, f"PUT not in allowed methods: {allowed_methods}" + assert 'DELETE' in allowed_methods, f"DELETE not in allowed methods: {allowed_methods}" + print("✓ Test 18: do_OPTIONS includes PUT and DELETE methods") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 20: POST /api/habits/{id}/check adds completion entry +def test_check_in_basic(): + temp_dir = setup_test_env() + server, port = start_test_server() + + try: + # Create a daily habit + status, habit = http_post('/api/habits', { + 'name': 'Morning Exercise', + 'frequency': {'type': 'daily'} + }, port) + assert status == 201, f"Failed to create habit: {status}" + habit_id = habit['id'] + + # Check in on the habit + status, updated_habit = http_post(f'/api/habits/{habit_id}/check', {}, port) + + assert status == 200, f"Expected 200, got {status}" + assert len(updated_habit['completions']) == 1, "Expected 1 completion" + assert updated_habit['completions'][0]['date'] == datetime.now().date().isoformat() + assert updated_habit['completions'][0]['type'] == 'check' + print("✓ Test 20: POST /api/habits/{id}/check adds completion entry") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 21: Check-in accepts optional note, rating, mood +def test_check_in_with_details(): + temp_dir = setup_test_env() + server, port = start_test_server() + + try: + # Create a daily habit + status, habit = http_post('/api/habits', { + 'name': 'Meditation', + 'frequency': {'type': 'daily'} + }, port) + habit_id = habit['id'] + + # Check in with details + status, updated_habit = http_post(f'/api/habits/{habit_id}/check', { + 'note': 'Felt very relaxed today', + 'rating': 5, + 'mood': 'happy' + }, port) + + assert status == 200, f"Expected 200, got {status}" + completion = updated_habit['completions'][0] + assert completion['note'] == 'Felt very relaxed today' + assert completion['rating'] == 5 + assert completion['mood'] == 'happy' + print("✓ Test 21: Check-in accepts optional note, rating (1-5), and mood") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 22: Check-in returns 404 if habit not found +def test_check_in_not_found(): + temp_dir = setup_test_env() + server, port = start_test_server() + + try: + status, response = http_post('/api/habits/non-existent-id/check', {}, port) + + assert status == 404, f"Expected 404, got {status}" + assert 'error' in response + print("✓ Test 22: Check-in returns 404 if habit not found") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 23: Check-in returns 400 if habit not relevant for today +def test_check_in_not_relevant(): + temp_dir = setup_test_env() + server, port = start_test_server() + + try: + # Create a habit for specific days (e.g., Monday only) + # If today is not Monday, it should fail + today_weekday = datetime.now().date().weekday() + different_day = (today_weekday + 1) % 7 # Pick a different day + + status, habit = http_post('/api/habits', { + 'name': 'Monday Only Habit', + 'frequency': { + 'type': 'specific_days', + 'days': [different_day] + } + }, port) + habit_id = habit['id'] + + # Try to check in + status, response = http_post(f'/api/habits/{habit_id}/check', {}, port) + + assert status == 400, f"Expected 400, got {status}" + assert 'not relevant' in response.get('error', '').lower() + print("✓ Test 23: Check-in returns 400 if habit not relevant for today") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 24: Check-in returns 409 if already checked today +def test_check_in_already_checked(): + temp_dir = setup_test_env() + server, port = start_test_server() + + try: + # Create a daily habit + status, habit = http_post('/api/habits', { + 'name': 'Water Plants', + 'frequency': {'type': 'daily'} + }, port) + habit_id = habit['id'] + + # Check in once + status, _ = http_post(f'/api/habits/{habit_id}/check', {}, port) + assert status == 200, "First check-in should succeed" + + # Try to check in again + status, response = http_post(f'/api/habits/{habit_id}/check', {}, port) + + assert status == 409, f"Expected 409, got {status}" + assert 'already checked' in response.get('error', '').lower() + print("✓ Test 24: Check-in returns 409 if already checked today") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 25: Streak is recalculated after check-in +def test_check_in_updates_streak(): + temp_dir = setup_test_env() + server, port = start_test_server() + + try: + # Create a daily habit + status, habit = http_post('/api/habits', { + 'name': 'Read', + 'frequency': {'type': 'daily'} + }, port) + habit_id = habit['id'] + + # Check in + status, updated_habit = http_post(f'/api/habits/{habit_id}/check', {}, port) + + assert status == 200, f"Expected 200, got {status}" + assert updated_habit['streak']['current'] == 1, f"Expected streak 1, got {updated_habit['streak']['current']}" + assert updated_habit['streak']['best'] == 1, f"Expected best streak 1, got {updated_habit['streak']['best']}" + print("✓ Test 25: Streak current and best are recalculated after check-in") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 26: lastCheckIn is updated after check-in +def test_check_in_updates_last_check_in(): + temp_dir = setup_test_env() + server, port = start_test_server() + + try: + # Create a daily habit + status, habit = http_post('/api/habits', { + 'name': 'Floss', + 'frequency': {'type': 'daily'} + }, port) + habit_id = habit['id'] + + # Initially lastCheckIn should be None + assert habit['streak']['lastCheckIn'] is None + + # Check in + status, updated_habit = http_post(f'/api/habits/{habit_id}/check', {}, port) + + today = datetime.now().date().isoformat() + assert updated_habit['streak']['lastCheckIn'] == today + print("✓ Test 26: lastCheckIn is updated to today's date") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 27: Lives are restored after 7 consecutive check-ins +def test_check_in_life_restore(): + temp_dir = setup_test_env() + server, port = start_test_server() + + try: + # Create a daily habit and manually set up 6 previous check-ins + status, habit = http_post('/api/habits', { + 'name': 'Yoga', + 'frequency': {'type': 'daily'} + }, port) + habit_id = habit['id'] + + # Manually add 6 previous check-ins and reduce lives to 2 + habits_data = json.loads(api.HABITS_FILE.read_text()) + for h in habits_data['habits']: + if h['id'] == habit_id: + h['lives'] = 2 + # Add 6 check-ins from previous days + for i in range(6, 0, -1): + past_date = (datetime.now().date() - timedelta(days=i)).isoformat() + h['completions'].append({ + 'date': past_date, + 'type': 'check' + }) + break + api.HABITS_FILE.write_text(json.dumps(habits_data, indent=2)) + + # Check in for today (7th consecutive) + status, updated_habit = http_post(f'/api/habits/{habit_id}/check', {}, port) + + assert status == 200, f"Expected 200, got {status}" + assert updated_habit['lives'] == 3, f"Expected 3 lives restored, got {updated_habit['lives']}" + print("✓ Test 27: Lives are restored by 1 (max 3) after 7 consecutive check-ins") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 28: Check-in validates rating range +def test_check_in_invalid_rating(): + temp_dir = setup_test_env() + server, port = start_test_server() + + try: + # Create a daily habit + status, habit = http_post('/api/habits', { + 'name': 'Journal', + 'frequency': {'type': 'daily'} + }, port) + habit_id = habit['id'] + + # Try to check in with invalid rating + status, response = http_post(f'/api/habits/{habit_id}/check', { + 'rating': 10 # Invalid, should be 1-5 + }, port) + + assert status == 400, f"Expected 400, got {status}" + assert 'rating' in response.get('error', '').lower() + print("✓ Test 28: Check-in validates rating is between 1 and 5") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 29: Check-in validates mood values +def test_check_in_invalid_mood(): + temp_dir = setup_test_env() + server, port = start_test_server() + + try: + # Create a daily habit + status, habit = http_post('/api/habits', { + 'name': 'Gratitude', + 'frequency': {'type': 'daily'} + }, port) + habit_id = habit['id'] + + # Try to check in with invalid mood + status, response = http_post(f'/api/habits/{habit_id}/check', { + 'mood': 'excited' # Invalid, should be happy/neutral/sad + }, port) + + assert status == 400, f"Expected 400, got {status}" + assert 'mood' in response.get('error', '').lower() + print("✓ Test 29: Check-in validates mood is one of: happy, neutral, sad") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 30: Skip basic - decrements lives +def test_skip_basic(): + temp_dir = setup_test_env() + server, port = start_test_server() + + try: + # Create a habit + status, habit = http_post('/api/habits', { + 'name': 'Daily Exercise', + 'frequency': {'type': 'daily'} + }, port) + assert status == 201 + habit_id = habit['id'] + + # Skip a day + status, response = http_post(f'/api/habits/{habit_id}/skip', {}, port) + + assert status == 200, f"Expected 200, got {status}" + assert response['lives'] == 2, f"Expected 2 lives, got {response['lives']}" + + # Verify completion entry was added with type='skip' + completions = response.get('completions', []) + assert len(completions) == 1, f"Expected 1 completion, got {len(completions)}" + assert completions[0]['type'] == 'skip', f"Expected type='skip', got {completions[0].get('type')}" + assert completions[0]['date'] == datetime.now().date().isoformat() + + print("✓ Test 30: Skip decrements lives and adds skip completion") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 31: Skip preserves streak +def test_skip_preserves_streak(): + temp_dir = setup_test_env() + server, port = start_test_server() + + try: + # Create a habit + status, habit = http_post('/api/habits', { + 'name': 'Daily Exercise', + 'frequency': {'type': 'daily'} + }, port) + assert status == 201 + habit_id = habit['id'] + + # Check in to build a streak + http_post(f'/api/habits/{habit_id}/check', {}, port) + + # Get current streak + status, habits = http_get('/api/habits', port) + current_streak = habits[0]['current_streak'] + assert current_streak > 0 + + # Skip the next day (simulate by adding skip manually and checking streak doesn't break) + # Since we can't time travel, we'll verify that skip doesn't recalculate streak + status, response = http_post(f'/api/habits/{habit_id}/skip', {}, port) + + assert status == 200, f"Expected 200, got {status}" + # Verify lives decremented + assert response['lives'] == 2 + # The streak should remain unchanged (skip doesn't break it) + # Note: We can't verify streak preservation perfectly without time travel, + # but we verify the skip completion is added correctly + completions = response.get('completions', []) + skip_count = sum(1 for c in completions if c.get('type') == 'skip') + assert skip_count == 1 + + print("✓ Test 31: Skip preserves streak (doesn't break it)") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 32: Skip returns 404 for non-existent habit +def test_skip_not_found(): + temp_dir = setup_test_env() + server, port = start_test_server() + + try: + status, response = http_post('/api/habits/nonexistent-id/skip', {}, port) + + assert status == 404, f"Expected 404, got {status}" + assert 'not found' in response.get('error', '').lower() + + print("✓ Test 32: Skip returns 404 for non-existent habit") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 33: Skip returns 400 when no lives remaining +def test_skip_no_lives(): + temp_dir = setup_test_env() + server, port = start_test_server() + + try: + # Create a habit + status, habit = http_post('/api/habits', { + 'name': 'Daily Exercise', + 'frequency': {'type': 'daily'} + }, port) + assert status == 201 + habit_id = habit['id'] + + # Use all 3 lives + for i in range(3): + status, response = http_post(f'/api/habits/{habit_id}/skip', {}, port) + assert status == 200, f"Skip {i+1} failed with status {status}" + assert response['lives'] == 2 - i, f"Expected {2-i} lives, got {response['lives']}" + + # Try to skip again with no lives + status, response = http_post(f'/api/habits/{habit_id}/skip', {}, port) + + assert status == 400, f"Expected 400, got {status}" + assert 'no lives remaining' in response.get('error', '').lower() + + print("✓ Test 33: Skip returns 400 when no lives remaining") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 34: Skip returns updated habit with new lives count +def test_skip_returns_updated_habit(): + temp_dir = setup_test_env() + server, port = start_test_server() + + try: + # Create a habit + status, habit = http_post('/api/habits', { + 'name': 'Daily Exercise', + 'frequency': {'type': 'daily'} + }, port) + assert status == 201 + habit_id = habit['id'] + original_updated_at = habit['updatedAt'] + + # Skip a day + status, response = http_post(f'/api/habits/{habit_id}/skip', {}, port) + + assert status == 200 + assert response['id'] == habit_id + assert response['lives'] == 2 + assert response['updatedAt'] != original_updated_at, "updatedAt should be updated" + assert 'name' in response + assert 'frequency' in response + assert 'completions' in response + + print("✓ Test 34: Skip returns updated habit with new lives count") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 35: DELETE uncheck - removes completion for specified date +def test_uncheck_removes_completion(): + temp_dir = setup_test_env() + server, port = start_test_server() + + try: + # Create a habit + status, habit = http_post('/api/habits', { + 'name': 'Daily Exercise', + 'frequency': {'type': 'daily'} + }, port) + assert status == 201 + habit_id = habit['id'] + + # Check in on a specific date + today = datetime.now().date().isoformat() + status, response = http_post(f'/api/habits/{habit_id}/check', {}, port) + assert status == 200 + assert len(response['completions']) == 1 + assert response['completions'][0]['date'] == today + + # Uncheck the habit for today + status, response = http_delete(f'/api/habits/{habit_id}/check?date={today}', port) + assert status == 200 + assert len(response['completions']) == 0, "Completion should be removed" + assert response['id'] == habit_id + + print("✓ Test 35: DELETE uncheck removes completion for specified date") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 36: DELETE uncheck - returns 404 if no completion for date +def test_uncheck_no_completion_for_date(): + temp_dir = setup_test_env() + server, port = start_test_server() + + try: + # Create a habit (but don't check in) + status, habit = http_post('/api/habits', { + 'name': 'Daily Exercise', + 'frequency': {'type': 'daily'} + }, port) + assert status == 201 + habit_id = habit['id'] + + # Try to uncheck a date with no completion + today = datetime.now().date().isoformat() + status, response = http_delete(f'/api/habits/{habit_id}/check?date={today}', port) + assert status == 404 + assert 'error' in response + assert 'No completion found' in response['error'] + + print("✓ Test 36: DELETE uncheck returns 404 if no completion for date") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 37: DELETE uncheck - returns 404 if habit not found +def test_uncheck_habit_not_found(): + temp_dir = setup_test_env() + server, port = start_test_server() + + try: + today = datetime.now().date().isoformat() + status, response = http_delete(f'/api/habits/nonexistent-id/check?date={today}', port) + assert status == 404 + assert 'error' in response + assert 'Habit not found' in response['error'] + + print("✓ Test 37: DELETE uncheck returns 404 if habit not found") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 38: DELETE uncheck - recalculates streak correctly +def test_uncheck_recalculates_streak(): + temp_dir = setup_test_env() + server, port = start_test_server() + + try: + # Create a habit + status, habit = http_post('/api/habits', { + 'name': 'Daily Exercise', + 'frequency': {'type': 'daily'} + }, port) + assert status == 201 + habit_id = habit['id'] + + # Check in for 3 consecutive days + today = datetime.now().date() + for i in range(3): + check_date = (today - timedelta(days=2-i)).isoformat() + # Manually add completion to the habit + with open(api.HABITS_FILE, 'r') as f: + data = json.load(f) + for h in data['habits']: + if h['id'] == habit_id: + h['completions'].append({'date': check_date, 'type': 'check'}) + with open(api.HABITS_FILE, 'w') as f: + json.dump(data, f) + + # Get habit to verify streak is 3 + status, habit = http_get('/api/habits', port) + assert status == 200 + habit = [h for h in habit if h['id'] == habit_id][0] + assert habit['current_streak'] == 3 + + # Uncheck the middle day + middle_date = (today - timedelta(days=1)).isoformat() + status, response = http_delete(f'/api/habits/{habit_id}/check?date={middle_date}', port) + assert status == 200 + + # Streak should now be 1 (only today counts) + assert response['streak']['current'] == 1, f"Expected streak 1, got {response['streak']['current']}" + + print("✓ Test 38: DELETE uncheck recalculates streak correctly") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 39: DELETE uncheck - returns updated habit object +def test_uncheck_returns_updated_habit(): + temp_dir = setup_test_env() + server, port = start_test_server() + + try: + # Create and check in + status, habit = http_post('/api/habits', { + 'name': 'Daily Exercise', + 'frequency': {'type': 'daily'} + }, port) + habit_id = habit['id'] + + today = datetime.now().date().isoformat() + status, _ = http_post(f'/api/habits/{habit_id}/check', {}, port) + + # Uncheck and verify response structure + status, response = http_delete(f'/api/habits/{habit_id}/check?date={today}', port) + assert status == 200 + assert 'id' in response + assert 'name' in response + assert 'completions' in response + assert 'streak' in response + assert 'updatedAt' in response + + print("✓ Test 39: DELETE uncheck returns updated habit object") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 40: DELETE uncheck - requires date parameter +def test_uncheck_requires_date(): + temp_dir = setup_test_env() + server, port = start_test_server() + + try: + # Create a habit + status, habit = http_post('/api/habits', { + 'name': 'Daily Exercise', + 'frequency': {'type': 'daily'} + }, port) + habit_id = habit['id'] + + # Try to uncheck without date parameter + status, response = http_delete(f'/api/habits/{habit_id}/check', port) + assert status == 400 + assert 'error' in response + assert 'date parameter is required' in response['error'] + + print("✓ Test 40: DELETE uncheck requires date parameter") + finally: + server.shutdown() + cleanup_test_env(temp_dir) + +# Test 41: Typecheck passes +def test_typecheck(): + result = subprocess.run( + ['python3', '-m', 'py_compile', str(Path(__file__).parent.parent / 'api.py')], + capture_output=True + ) + assert result.returncode == 0, f"Typecheck failed: {result.stderr.decode()}" + print("✓ Test 41: Typecheck passes") + +if __name__ == '__main__': + import subprocess + + print("\n=== Running Habits API Tests ===\n") + + test_get_habits_empty() + test_post_habit_valid() + test_post_habit_missing_name() + test_post_habit_name_too_long() + test_post_habit_invalid_color() + test_post_habit_invalid_frequency() + test_get_habits_with_stats() + test_get_habits_sorted_by_priority() + test_post_habit_invalid_json() + test_post_habit_initial_streak() + test_put_habit_valid() + test_put_habit_protected_fields() + test_put_habit_not_found() + test_put_habit_invalid_input() + test_delete_habit_success() + test_delete_habit_not_found() + test_options_includes_put_delete() + test_check_in_basic() + test_check_in_with_details() + test_check_in_not_found() + test_check_in_not_relevant() + test_check_in_already_checked() + test_check_in_updates_streak() + test_check_in_updates_last_check_in() + test_check_in_life_restore() + test_check_in_invalid_rating() + test_check_in_invalid_mood() + test_skip_basic() + test_skip_preserves_streak() + test_skip_not_found() + test_skip_no_lives() + test_skip_returns_updated_habit() + test_uncheck_removes_completion() + test_uncheck_no_completion_for_date() + test_uncheck_habit_not_found() + test_uncheck_recalculates_streak() + test_uncheck_returns_updated_habit() + test_uncheck_requires_date() + test_typecheck() + + print("\n✅ All 41 tests passed!\n") diff --git a/dashboard/tests/test_habits_frontend.py b/dashboard/tests/test_habits_frontend.py new file mode 100644 index 0000000..8c93c6d --- /dev/null +++ b/dashboard/tests/test_habits_frontend.py @@ -0,0 +1,2868 @@ +""" +Test suite for Habits frontend page structure and navigation +Story US-002: Frontend - Compact habit cards (~100px height) +Story US-003: Frontend - Check/uncheck toggle behavior +Story US-005: Frontend - Stats section collapse with chevron +Story US-006: Frontend - Page structure, layout, and navigation link +Story US-007: Frontend - Habit card component +Story US-008: Frontend - Create habit modal with all options +Story US-009: Frontend - Edit habit modal +Story US-010: Frontend - Check-in interaction (click and long-press) +Story US-011: Frontend - Skip, lives display, and delete confirmation +Story US-012: Frontend - Filter and sort controls +Story US-013: Frontend - Stats section and weekly summary +Story US-014: Frontend - Mobile responsive and touch optimization +""" + +import sys +import os +import subprocess +from pathlib import Path + +# Add parent directory to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent)) + +def test_habits_html_exists(): + """Test 1: habits.html exists in dashboard/""" + habits_path = Path(__file__).parent.parent / 'habits.html' + assert habits_path.exists(), "habits.html should exist in dashboard/" + print("✓ Test 1: habits.html exists") + +def test_habits_html_structure(): + """Test 2: Page includes common.css, Lucide icons, and swipe-nav.js""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + assert 'href="/echo/common.css"' in content, "Should include common.css" + assert 'lucide@latest/dist/umd/lucide.min.js' in content, "Should include Lucide icons" + assert 'src="/echo/swipe-nav.js"' in content, "Should include swipe-nav.js" + print("✓ Test 2: Page includes required CSS and JS") + +def test_page_has_header(): + """Test 3: Page has header with 'Habits' title and 'Add Habit' button""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + assert 'class="page-title"' in content, "Should have page-title element" + assert '>HabitsHabits') + nav_end = content.find('', nav_start) + nav_section = content[nav_start:nav_end] + + assert '/echo/habits.html' in nav_section, "Habits link should be in navigation" + assert 'dumbbell' in nav_section, "Dumbbell icon should be in navigation" + + print("✓ Test 6: index.html includes Habits navigation link") + +def test_page_fetches_habits(): + """Test 7: Page fetches GET /echo/api/habits on load""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + assert "fetch('/echo/api/habits')" in content or 'fetch("/echo/api/habits")' in content, \ + "Should fetch from /echo/api/habits" + assert 'loadHabits' in content, "Should have loadHabits function" + + # Check that loadHabits is called on page load + # (either in inline script or as last statement) + assert content.count('loadHabits()') > 0, "loadHabits should be called" + + print("✓ Test 7: Page fetches habits on load") + +def test_habit_card_rendering(): + """Test 8: Placeholder habit card rendering exists""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + assert 'renderHabitCard' in content, "Should have renderHabitCard function" + assert 'habit-card' in content, "Should have habit-card class" + assert 'renderHabits' in content, "Should have renderHabits function" + + print("✓ Test 8: Habit card rendering functions exist") + +def test_no_console_errors_structure(): + """Test 9: No obvious console error sources (basic structure check)""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + # Check for basic script structure + assert '') + assert script_open == script_close, f"Script tags should match (found {script_open} opens, {script_close} closes)" + + print("✓ Test 10: HTML structure is well-formed") + +def test_card_colored_border(): + """Test 11: Habit card has colored left border matching habit.color""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + assert 'border-left-color' in content or 'borderLeftColor' in content, \ + "Card should have colored left border" + assert 'habit.color' in content, "Card should use habit.color for border" + print("✓ Test 11: Card has colored left border") + +def test_card_header_icons(): + """Test 12: Card header shows icon, name, settings, and delete""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + # Check for icon display + assert 'habit.icon' in content or 'habit-card-icon' in content, \ + "Card should display habit icon" + + # Check for name display + assert 'habit.name' in content or 'habit-card-name' in content, \ + "Card should display habit name" + + # Check for settings (gear) icon + assert 'settings' in content.lower(), "Card should have settings icon" + + # Check for delete (trash) icon + assert 'trash' in content.lower(), "Card should have delete icon" + + print("✓ Test 12: Card header has icon, name, settings, and delete") + +def test_card_streak_display(): + """Test 13: Streak displays with fire emoji for current and trophy for best""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + assert '🔥' in content, "Card should have fire emoji for current streak" + assert '🏆' in content, "Card should have trophy emoji for best streak" + assert 'habit.streak' in content or 'streak?.current' in content or 'streak.current' in content, \ + "Card should display streak.current" + assert 'streak?.best' in content or 'streak.best' in content, \ + "Card should display streak.best" + + print("✓ Test 13: Streak display with fire and trophy emojis") + +def test_card_checkin_button(): + """Test 14: Check-in button is large and centered""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + assert 'habit-card-check-btn' in content or 'check-btn' in content or 'checkin' in content.lower(), \ + "Card should have check-in button" + assert 'Check In' in content or 'Check in' in content, \ + "Button should have 'Check In' text" + + # Check for button styling (large/centered) + assert 'width: 100%' in content or 'width:100%' in content, \ + "Check-in button should be full-width" + + print("✓ Test 14: Check-in button is large and centered") + +def test_card_checkin_disabled_when_done(): + """Test 15: Check-in button disabled when already checked today""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + assert 'disabled' in content, "Button should have disabled state" + assert 'Done today' in content or 'Done' in content, \ + "Button should show 'Done today' when disabled" + assert 'isCheckedToday' in content or 'isDoneToday' in content, \ + "Should have function to check if habit is done today" + + print("✓ Test 15: Check-in button disabled when done today") + +def test_card_lives_display(): + """Test 16: Lives display shows filled and empty hearts (total 3)""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + assert '❤️' in content or '♥' in content, "Card should have filled heart emoji" + assert '🖤' in content or '♡' in content, "Card should have empty heart emoji" + assert 'habit.lives' in content or 'renderLives' in content, \ + "Card should display lives" + + # Check for lives rendering function + assert 'renderLives' in content or 'lives' in content.lower(), \ + "Should have lives rendering logic" + + print("✓ Test 16: Lives display with hearts") + +def test_card_completion_rate(): + """Test 17: Completion rate percentage is displayed""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + assert 'completion_rate' in content or 'completion' in content, \ + "Card should display completion rate" + assert '(30d)' in content or '30d' in content, \ + "Completion rate should show 30-day period" + assert '%' in content, "Completion rate should show percentage" + + print("✓ Test 17: Completion rate displayed") + +def test_card_footer_category_priority(): + """Test 18: Footer shows category badge and priority""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + assert 'habit.category' in content or 'habit-card-category' in content, \ + "Card should display category" + assert 'habit.priority' in content or 'priority' in content.lower(), \ + "Card should display priority" + assert 'habit-card-footer' in content or 'footer' in content.lower(), \ + "Card should have footer section" + + print("✓ Test 18: Footer shows category and priority") + +def test_card_lucide_createicons(): + """Test 19: lucide.createIcons() is called after rendering cards""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + # Check that createIcons is called after rendering + render_pos = content.find('renderHabits') + if render_pos != -1: + after_render = content[render_pos:] + assert 'lucide.createIcons()' in after_render, \ + "lucide.createIcons() should be called after rendering" + + print("✓ Test 19: lucide.createIcons() called after rendering") + +def test_card_common_css_variables(): + """Test 20: Card uses common.css variables for styling""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + # Check for common.css variable usage + assert '--bg-surface' in content or '--text-primary' in content or '--border' in content, \ + "Card should use common.css variables" + assert 'var(--' in content, "Should use CSS variables" + + print("✓ Test 20: Card uses common.css variables") + +def test_typecheck_us007(): + """Test 21: Typecheck passes for US-007""" + habits_path = Path(__file__).parent.parent / 'habits.html' + assert habits_path.exists(), "habits.html should exist" + + # Check that all functions are properly defined + content = habits_path.read_text() + assert 'function renderHabitCard(' in content, "renderHabitCard function should be defined" + assert 'function isCheckedToday(' in content, "isCheckedToday function should be defined" + assert 'function getLastCheckInfo(' in content, "getLastCheckInfo function should be defined" + assert 'function renderLives(' in content, "renderLives function should be defined" + assert 'function getPriorityLevel(' in content, "getPriorityLevel function should be defined" + + print("✓ Test 21: Typecheck passes (all functions defined)") + +def test_modal_opens_on_add_habit_click(): + """Test 22: Modal opens when clicking 'Add Habit' button""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + assert 'showAddHabitModal()' in content, "Add Habit button should call showAddHabitModal()" + assert 'function showAddHabitModal(' in content, "showAddHabitModal function should be defined" + assert 'modal-overlay' in content or 'habitModal' in content, "Should have modal overlay element" + print("✓ Test 22: Modal opens on Add Habit button click") + +def test_modal_closes_on_x_and_outside_click(): + """Test 23: Modal closes on X button or clicking outside""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + assert 'closeHabitModal()' in content, "Should have closeHabitModal function" + assert 'modal-close' in content or 'onclick="closeHabitModal()"' in content, \ + "X button should call closeHabitModal()" + + # Check for click outside handler + assert 'e.target === modal' in content or 'event.target' in content, \ + "Should handle clicking outside modal" + print("✓ Test 23: Modal closes on X button and clicking outside") + +def test_modal_has_all_form_fields(): + """Test 24: Form has all required fields""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + # Required fields + assert 'habitName' in content or 'name' in content.lower(), "Form should have name field" + assert 'habitCategory' in content or 'category' in content.lower(), "Form should have category field" + assert 'habitPriority' in content or 'priority' in content.lower(), "Form should have priority field" + assert 'habitNotes' in content or 'notes' in content.lower(), "Form should have notes field" + assert 'frequencyType' in content or 'frequency' in content.lower(), "Form should have frequency field" + assert 'reminderTime' in content or 'reminder' in content.lower(), "Form should have reminder time field" + + print("✓ Test 24: Form has all required fields") + +def test_color_picker_presets_and_custom(): + """Test 25: Color picker shows preset swatches and custom hex input""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + assert 'color-picker' in content or 'colorSwatches' in content or 'color-swatch' in content, \ + "Should have color picker" + assert 'customColor' in content or 'custom' in content.lower(), \ + "Should have custom color input" + assert '#RRGGBB' in content or 'pattern=' in content, \ + "Custom color should have hex pattern" + assert 'presetColors' in content or '#3B82F6' in content or '#EF4444' in content, \ + "Should have preset colors" + + print("✓ Test 25: Color picker with presets and custom hex") + +def test_icon_picker_grid(): + """Test 26: Icon picker shows grid of common Lucide icons""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + assert 'icon-picker' in content or 'iconPicker' in content, \ + "Should have icon picker" + assert 'icon-option' in content or 'commonIcons' in content, \ + "Should have icon options" + assert 'selectIcon' in content, "Should have selectIcon function" + + # Check for common icons + icon_count = sum([1 for icon in ['dumbbell', 'moon', 'book', 'brain', 'heart'] + if icon in content]) + assert icon_count >= 3, "Should have at least 3 common icons" + + print("✓ Test 26: Icon picker with grid of Lucide icons") + +def test_frequency_params_conditional(): + """Test 27: Frequency params display conditionally based on type""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + assert 'updateFrequencyParams' in content, "Should have updateFrequencyParams function" + assert 'frequencyParams' in content, "Should have frequency params container" + assert 'specific_days' in content, "Should handle specific_days frequency" + assert 'x_per_week' in content, "Should handle x_per_week frequency" + assert 'custom' in content.lower(), "Should handle custom frequency" + + # Check for conditional rendering (day checkboxes for specific_days) + assert 'day-checkbox' in content or "['Mon', 'Tue'" in content or 'Mon' in content, \ + "Should have day checkboxes for specific_days" + + print("✓ Test 27: Frequency params display conditionally") + +def test_client_side_validation(): + """Test 28: Client-side validation prevents submit without name""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + assert 'required' in content, "Name field should be required" + assert 'trim()' in content, "Should trim input values" + + # Check for validation in submit function + submit_func = content[content.find('function submitHabitForm'):] + assert 'if (!name)' in submit_func or 'name.length' in submit_func, \ + "Should validate name is not empty" + assert 'showToast' in submit_func and 'error' in submit_func, \ + "Should show error toast for validation failures" + + print("✓ Test 28: Client-side validation checks name required") + +def test_submit_posts_to_api(): + """Test 29: Submit sends POST /echo/api/habits (or PUT for edit) and refreshes list""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + assert 'submitHabitForm' in content, "Should have submitHabitForm function" + + submit_func = content[content.find('function submitHabitForm'):] + # Check for conditional URL and method (since US-009 added edit support) + assert ('/echo/api/habits' in submit_func), \ + "Should use /echo/api/habits endpoint" + assert ("'POST'" in submit_func or '"POST"' in submit_func or "'PUT'" in submit_func or '"PUT"' in submit_func), \ + "Should use POST or PUT method" + assert 'JSON.stringify' in submit_func, "Should send JSON body" + assert 'loadHabits()' in submit_func, "Should refresh habit list on success" + + print("✓ Test 29: Submit POSTs to API and refreshes list") + +def test_loading_state_on_submit(): + """Test 30: Loading state shown on submit button during API call""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + submit_func = content[content.find('function submitHabitForm'):] + assert 'disabled = true' in submit_func or '.disabled' in submit_func, \ + "Submit button should be disabled during API call" + assert 'Creating' in submit_func or 'loading' in submit_func.lower(), \ + "Should show loading text" + assert 'disabled = false' in submit_func, \ + "Submit button should be re-enabled after API call" + + print("✓ Test 30: Loading state on submit button") + +def test_toast_notifications(): + """Test 31: Toast notification shown for success and error""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + assert 'showToast' in content, "Should have showToast function" + assert 'toast' in content, "Should have toast styling" + + toast_func = content[content.find('function showToast'):] + assert 'success' in toast_func and 'error' in toast_func, \ + "Toast should handle both success and error types" + assert 'check-circle' in toast_func or 'alert-circle' in toast_func, \ + "Toast should show appropriate icons" + assert 'setTimeout' in toast_func or 'remove()' in toast_func, \ + "Toast should auto-dismiss" + + print("✓ Test 31: Toast notifications for success and error") + +def test_modal_no_console_errors(): + """Test 32: No obvious console error sources in modal code""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + # Check that modal functions exist + assert 'function showAddHabitModal(' in content, "showAddHabitModal should be defined" + assert 'function closeHabitModal(' in content, "closeHabitModal should be defined" + assert 'function submitHabitForm(' in content, "submitHabitForm should be defined" + assert 'function updateFrequencyParams(' in content, "updateFrequencyParams should be defined" + + # Check for proper error handling + submit_func = content[content.find('function submitHabitForm'):] + assert 'try' in submit_func and 'catch' in submit_func, \ + "Submit function should have try-catch error handling" + + print("✓ Test 32: No obvious console error sources") + +def test_typecheck_us008(): + """Test 33: Typecheck passes for US-008 (all modal functions defined)""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + # Check all new functions are defined + required_functions = [ + 'showAddHabitModal', + 'closeHabitModal', + 'initColorPicker', + 'selectColor', + 'initIconPicker', + 'selectIcon', + 'updateFrequencyParams', + 'submitHabitForm', + 'showToast' + ] + + for func in required_functions: + assert f'function {func}(' in content or f'const {func} =' in content, \ + f"{func} function should be defined" + + print("✓ Test 33: Typecheck passes (all modal functions defined)") + +def test_edit_modal_opens_on_gear_icon(): + """Test 34: Clicking gear icon on habit card opens edit modal""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + # Check that gear icon exists with onclick handler + assert 'settings' in content, "Should have settings icon (gear)" + assert "showEditHabitModal" in content, "Should have showEditHabitModal function call" + + # Check that showEditHabitModal function is defined and not a placeholder + assert 'function showEditHabitModal(habitId)' in content, "showEditHabitModal should be defined" + assert 'editingHabitId = habitId' in content or 'editingHabitId=habitId' in content, \ + "Should set editingHabitId" + assert 'const habit = habits.find(h => h.id === habitId)' in content or \ + 'const habit=habits.find(h=>h.id===habitId)' in content, \ + "Should find habit by ID" + + print("✓ Test 34: Edit modal opens on gear icon click") + +def test_edit_modal_prepopulated(): + """Test 35: Edit modal is pre-populated with all existing habit data""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + # Check that form fields are pre-populated + assert "getElementById('habitName').value = habit.name" in content or \ + "getElementById('habitName').value=habit.name" in content, \ + "Should pre-populate habit name" + assert "getElementById('habitCategory').value = habit.category" in content or \ + "getElementById('habitCategory').value=habit.category" in content, \ + "Should pre-populate category" + assert "getElementById('habitPriority').value = habit.priority" in content or \ + "getElementById('habitPriority').value=habit.priority" in content, \ + "Should pre-populate priority" + assert "getElementById('habitNotes').value = habit.notes" in content or \ + "getElementById('habitNotes').value=habit.notes" in content, \ + "Should pre-populate notes" + assert "getElementById('frequencyType').value = habit.frequency" in content or \ + "getElementById('frequencyType').value=habit.frequency" in content, \ + "Should pre-populate frequency type" + + # Check color and icon selection + assert 'selectedColor = habit.color' in content or 'selectedColor=habit.color' in content, \ + "Should set selectedColor from habit" + assert 'selectedIcon = habit.icon' in content or 'selectedIcon=habit.icon' in content, \ + "Should set selectedIcon from habit" + + print("✓ Test 35: Edit modal pre-populated with habit data") + +def test_edit_modal_title_and_button(): + """Test 36: Modal title shows 'Edit Habit' and button shows 'Save Changes'""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + # Check that modal title is changed to Edit Habit + assert "modalTitle.textContent = 'Edit Habit'" in content or \ + 'modalTitle.textContent="Edit Habit"' in content or \ + "modalTitle.textContent='Edit Habit'" in content, \ + "Should set modal title to 'Edit Habit'" + + # Check that submit button text is changed + assert "submitBtnText.textContent = 'Save Changes'" in content or \ + 'submitBtnText.textContent="Save Changes"' in content or \ + "submitBtnText.textContent='Save Changes'" in content, \ + "Should set button text to 'Save Changes'" + + print("✓ Test 36: Modal title shows 'Edit Habit' and button shows 'Save Changes'") + +def test_edit_modal_frequency_params(): + """Test 37: Frequency params display correctly for habit's current frequency type""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + # Check that updateFrequencyParams is called + assert 'updateFrequencyParams()' in content, "Should call updateFrequencyParams()" + + # Check that frequency params are pre-populated for specific types + assert 'specific_days' in content and 'habit.frequency.days' in content, \ + "Should handle specific_days frequency params" + assert 'x_per_week' in content and 'habit.frequency.count' in content, \ + "Should handle x_per_week frequency params" + assert 'custom' in content and 'habit.frequency.interval' in content, \ + "Should handle custom frequency params" + + # Check that day checkboxes are pre-populated + assert 'cb.checked = habit.frequency.days.includes' in content or \ + 'cb.checked=habit.frequency.days.includes' in content, \ + "Should pre-select days for specific_days frequency" + + print("✓ Test 37: Frequency params display correctly for current frequency") + +def test_edit_modal_icon_color_pickers(): + """Test 38: Icon and color pickers show current selections""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + # Check that pickers are initialized after setting values + assert 'initColorPicker()' in content, "Should call initColorPicker()" + assert 'initIconPicker()' in content, "Should call initIconPicker()" + + # Check that selectedColor and selectedIcon are set before initialization + showEditIndex = content.find('function showEditHabitModal') + initColorIndex = content.find('initColorPicker()', showEditIndex) + selectedColorIndex = content.find('selectedColor = habit.color', showEditIndex) + + assert selectedColorIndex > 0 and selectedColorIndex < initColorIndex, \ + "Should set selectedColor before calling initColorPicker()" + + print("✓ Test 38: Icon and color pickers show current selections") + +def test_edit_modal_submit_put(): + """Test 39: Submit sends PUT /echo/api/habits/{id} and refreshes list on success""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + # Check that editingHabitId is tracked + assert 'let editingHabitId' in content or 'editingHabitId' in content, \ + "Should track editingHabitId" + + # Check that isEditing is determined + assert 'const isEditing = editingHabitId !== null' in content or \ + 'const isEditing=editingHabitId!==null' in content or \ + 'isEditing = editingHabitId !== null' in content, \ + "Should determine if editing" + + # Check that URL and method are conditional + assert "const url = isEditing ? `/echo/api/habits/${editingHabitId}` : '/echo/api/habits'" in content or \ + 'const url=isEditing?`/echo/api/habits/${editingHabitId}`' in content or \ + "url = isEditing ? `/echo/api/habits/${editingHabitId}` : '/echo/api/habits'" in content, \ + "URL should be conditional based on isEditing" + + assert "const method = isEditing ? 'PUT' : 'POST'" in content or \ + "const method=isEditing?'PUT':'POST'" in content or \ + "method = isEditing ? 'PUT' : 'POST'" in content, \ + "Method should be conditional (PUT for edit, POST for create)" + + # Check that loadHabits is called after success + assert 'await loadHabits()' in content, "Should refresh habit list after success" + + print("✓ Test 39: Submit sends PUT and refreshes list") + +def test_edit_modal_toast_messages(): + """Test 40: Toast shown for success and error""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + # Check for conditional success message + assert "isEditing ? 'Habit updated!' : 'Habit created successfully!'" in content or \ + "isEditing?'Habit updated!':'Habit created successfully!'" in content, \ + "Should show different toast message for edit vs create" + + # Check that error toast handles both edit and create + assert 'Failed to ${isEditing' in content or 'Failed to ' + '${isEditing' in content, \ + "Error toast should be conditional" + + print("✓ Test 40: Toast messages for success and error") + +def test_edit_modal_add_resets_state(): + """Test 41: showAddHabitModal resets editingHabitId and modal UI""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + # Find showAddHabitModal function + add_modal_start = content.find('function showAddHabitModal()') + add_modal_end = content.find('function ', add_modal_start + 1) + add_modal_func = content[add_modal_start:add_modal_end] + + # Check that editingHabitId is reset + assert 'editingHabitId = null' in add_modal_func or 'editingHabitId=null' in add_modal_func, \ + "showAddHabitModal should reset editingHabitId to null" + + # Check that modal title is reset to 'Add Habit' + assert "modalTitle.textContent = 'Add Habit'" in add_modal_func or \ + 'modalTitle.textContent="Add Habit"' in add_modal_func, \ + "Should reset modal title to 'Add Habit'" + + # Check that button text is reset to 'Create Habit' + assert "submitBtnText.textContent = 'Create Habit'" in add_modal_func or \ + 'submitBtnText.textContent="Create Habit"' in add_modal_func, \ + "Should reset button text to 'Create Habit'" + + print("✓ Test 41: showAddHabitModal resets editing state") + +def test_edit_modal_close_resets_state(): + """Test 42: closeHabitModal resets editingHabitId""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + # Find closeHabitModal function + close_modal_start = content.find('function closeHabitModal()') + close_modal_end = content.find('function ', close_modal_start + 1) + close_modal_func = content[close_modal_start:close_modal_end] + + # Check that editingHabitId is reset when closing + assert 'editingHabitId = null' in close_modal_func or 'editingHabitId=null' in close_modal_func, \ + "closeHabitModal should reset editingHabitId to null" + + print("✓ Test 42: closeHabitModal resets editing state") + +def test_edit_modal_no_console_errors(): + """Test 43: No obvious console error sources in edit modal code""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + # Check for common error patterns + assert content.count('getElementById(') > 0, "Should use getElementById" + + # Check that habit is validated before use + showEditIndex = content.find('function showEditHabitModal') + showEditEnd = content.find('\n }', showEditIndex + 500) # Find end of function + showEditFunc = content[showEditIndex:showEditEnd] + + assert 'if (!habit)' in showEditFunc or 'if(!habit)' in showEditFunc, \ + "Should check if habit exists before using it" + assert 'showToast' in showEditFunc and 'error' in showEditFunc, \ + "Should show error toast if habit not found" + + print("✓ Test 43: No obvious console error sources") + +def test_typecheck_us009(): + """Test 44: Typecheck passes - all edit modal functions and variables defined""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + # Check that editingHabitId is declared + assert 'let editingHabitId' in content, "editingHabitId should be declared" + + # Check that showEditHabitModal is fully implemented (not placeholder) + assert 'function showEditHabitModal(habitId)' in content, "showEditHabitModal should be defined" + assert 'alert' not in content[content.find('function showEditHabitModal'):content.find('function showEditHabitModal')+1000], \ + "showEditHabitModal should not be a placeholder with alert()" + + # Check that submitHabitForm handles both create and edit + assert 'const isEditing' in content or 'isEditing' in content, \ + "submitHabitForm should determine if editing" + + print("✓ Test 44: Typecheck passes (edit modal fully implemented)") + +def test_checkin_simple_click(): + """Test 45: Simple click on check-in button sends POST request""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + # Check that checkInHabit function exists and does POST + assert 'function checkInHabit' in content or 'async function checkInHabit' in content, \ + "checkInHabit function should be defined" + + checkin_start = content.find('function checkInHabit') + checkin_end = content.find('\n }', checkin_start + 500) + checkin_func = content[checkin_start:checkin_end] + + assert "fetch(`/echo/api/habits/${habitId}/check`" in checkin_func or \ + 'fetch(`/echo/api/habits/${habitId}/check`' in checkin_func, \ + "Should POST to /echo/api/habits/{id}/check" + assert "method: 'POST'" in checkin_func, "Should use POST method" + + print("✓ Test 45: Simple click sends POST to check-in endpoint") + +def test_checkin_detail_modal_structure(): + """Test 46: Check-in detail modal exists with note, rating, and mood fields""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + # Check modal exists + assert 'id="checkinModal"' in content, "Should have check-in detail modal" + assert 'Check-In Details' in content or 'Check-in Details' in content, \ + "Modal should have title 'Check-In Details'" + + # Check for note textarea + assert 'id="checkinNote"' in content, "Should have note textarea" + assert '
' in content or \ + '