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..726a054 100644 --- a/dashboard/api.py +++ b/dashboard/api.py @@ -35,6 +35,107 @@ GITEA_URL = os.environ.get('GITEA_URL', 'https://gitea.romfast.ro') GITEA_ORG = os.environ.get('GITEA_ORG', 'romfast') GITEA_TOKEN = os.environ.get('GITEA_TOKEN', '') + +def calculate_streak(completions, frequency): + """ + Calculate the current streak for a habit based on completions array. + + Args: + completions: List of ISO timestamp strings representing completion dates + frequency: 'daily' or 'weekly' + + Returns: + int: The current streak count (days for daily, weeks for weekly) + + Rules: + - Counts consecutive periods from most recent completion backwards + - Daily: counts consecutive days without gaps + - Weekly: counts consecutive 7-day periods + - Returns 0 for no completions + - Returns 0 if streak is broken (gap detected) + - Today's completion counts even if previous days were missed + """ + from datetime import datetime, timedelta + + # No completions = no streak + if not completions: + return 0 + + # Parse all completion dates and sort descending (most recent first) + try: + completion_dates = [] + for comp in completions: + dt = datetime.fromisoformat(comp.replace('Z', '+00:00')) + # Convert to date only (ignore time) + completion_dates.append(dt.date()) + + completion_dates = sorted(set(completion_dates), reverse=True) + except (ValueError, AttributeError): + return 0 + + if not completion_dates: + return 0 + + # Get today's date + today = datetime.now().date() + + if frequency == 'daily': + # For daily habits, count consecutive days + streak = 0 + expected_date = completion_dates[0] + + # If most recent completion is today or yesterday, start counting + if expected_date < today - timedelta(days=1): + # Streak is broken (last completion was more than 1 day ago) + return 0 + + for completion in completion_dates: + if completion == expected_date: + streak += 1 + expected_date -= timedelta(days=1) + elif completion < expected_date: + # Gap found, streak is broken + break + + return streak + + elif frequency == 'weekly': + # For weekly habits, count consecutive weeks (7-day periods) + streak = 0 + + # Most recent completion + most_recent = completion_dates[0] + + # Check if most recent completion is within current week + days_since = (today - most_recent).days + if days_since > 6: + # Last completion was more than a week ago, streak is broken + return 0 + + # Start counting from the week of the most recent completion + current_week_start = most_recent - timedelta(days=most_recent.weekday()) + + for i in range(len(completion_dates)): + week_start = current_week_start - timedelta(days=i * 7) + week_end = week_start + timedelta(days=6) + + # Check if there's a completion in this week + has_completion = any( + week_start <= comp <= week_end + for comp in completion_dates + ) + + if has_completion: + streak += 1 + else: + # No completion in this week, streak is broken + break + + return streak + + return 0 + + class TaskBoardHandler(SimpleHTTPRequestHandler): def do_POST(self): @@ -48,6 +149,10 @@ 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 == '/api/workspace/run': self.handle_workspace_run() elif self.path == '/api/workspace/stop': @@ -61,6 +166,12 @@ class TaskBoardHandler(SimpleHTTPRequestHandler): else: self.send_error(404) + def do_DELETE(self): + if 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 +362,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'): @@ -681,6 +794,259 @@ class TaskBoardHandler(SimpleHTTPRequestHandler): except Exception as e: self.send_json({'error': str(e)}, 500) + def handle_habits_get(self): + """Get all habits from habits.json with calculated streaks.""" + try: + habits_file = KANBAN_DIR / 'habits.json' + + # Handle missing file or empty file gracefully + if not habits_file.exists(): + self.send_json({ + 'habits': [], + 'lastUpdated': datetime.now().isoformat() + }) + return + + # Read and parse habits data + try: + data = json.loads(habits_file.read_text(encoding='utf-8')) + except (json.JSONDecodeError, IOError): + # Return empty array on parse error instead of 500 + self.send_json({ + 'habits': [], + 'lastUpdated': datetime.now().isoformat() + }) + return + + # Ensure required fields exist + habits = data.get('habits', []) + last_updated = data.get('lastUpdated', datetime.now().isoformat()) + + # Get today's date in YYYY-MM-DD format + today = datetime.now().date().isoformat() + + # Enhance each habit with streak and checkedToday + enhanced_habits = [] + for habit in habits: + # Calculate streak using the utility function + completions = habit.get('completions', []) + frequency = habit.get('frequency', 'daily') + streak = calculate_streak(completions, frequency) + + # Check if habit was completed today + checked_today = today in completions + + # Add calculated fields to habit + enhanced_habit = {**habit, 'streak': streak, 'checkedToday': checked_today} + enhanced_habits.append(enhanced_habit) + + self.send_json({ + 'habits': enhanced_habits, + 'lastUpdated': last_updated + }) + except Exception as e: + self.send_json({'error': str(e)}, 500) + + def handle_habits_post(self): + """Create a new habit in habits.json.""" + try: + # Read POST 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() + frequency = data.get('frequency', '').strip() + + # Validation: name is required + if not name: + self.send_json({'error': 'name is required'}, 400) + return + + # Validation: frequency must be daily or weekly + if frequency not in ('daily', 'weekly'): + self.send_json({'error': 'frequency must be daily or weekly'}, 400) + return + + # Generate habit ID with millisecond timestamp + from time import time + habit_id = f"habit-{int(time() * 1000)}" + + # Create habit object + new_habit = { + 'id': habit_id, + 'name': name, + 'frequency': frequency, + 'createdAt': datetime.now().isoformat(), + 'completions': [] + } + + # Read existing habits + habits_file = KANBAN_DIR / 'habits.json' + if habits_file.exists(): + try: + habits_data = json.loads(habits_file.read_text(encoding='utf-8')) + except (json.JSONDecodeError, IOError): + habits_data = {'habits': [], 'lastUpdated': datetime.now().isoformat()} + else: + habits_data = {'habits': [], 'lastUpdated': datetime.now().isoformat()} + + # Add new habit + habits_data['habits'].append(new_habit) + habits_data['lastUpdated'] = datetime.now().isoformat() + + # Write back to file + habits_file.write_text(json.dumps(habits_data, indent=2), encoding='utf-8') + + # Return 201 Created with the new habit + 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_check(self): + """Mark a habit as completed for today.""" + try: + # Extract habit ID from path: /api/habits/{id}/check + path_parts = self.path.split('/') + if len(path_parts) < 4: + self.send_json({'error': 'Invalid path'}, 400) + return + + habit_id = path_parts[3] # /api/habits/{id}/check -> index 3 is id + + # Get today's date in ISO format (YYYY-MM-DD) + today = datetime.now().date().isoformat() + + # Read habits file + habits_file = KANBAN_DIR / 'habits.json' + if not habits_file.exists(): + self.send_json({'error': 'Habit not found'}, 404) + return + + try: + habits_data = json.loads(habits_file.read_text(encoding='utf-8')) + except (json.JSONDecodeError, IOError): + self.send_json({'error': 'Habit not found'}, 404) + return + + # Find the habit by ID + habit = None + habit_index = None + for i, h in enumerate(habits_data.get('habits', [])): + if h.get('id') == habit_id: + habit = h + habit_index = i + break + + if habit is None: + self.send_json({'error': 'Habit not found'}, 404) + return + + # Check if already checked today + completions = habit.get('completions', []) + + # Extract dates from completions (they might be ISO timestamps, we need just the date part) + completion_dates = [] + for comp in completions: + try: + # Parse ISO timestamp and extract date + dt = datetime.fromisoformat(comp.replace('Z', '+00:00')) + completion_dates.append(dt.date().isoformat()) + except (ValueError, AttributeError): + # If parsing fails, assume it's already a date string + completion_dates.append(comp) + + if today in completion_dates: + self.send_json({'error': 'Habit already checked today'}, 400) + return + + # Add today's date to completions + completions.append(today) + + # Sort completions chronologically (oldest first) + completions.sort() + + # Update habit + habit['completions'] = completions + + # Calculate streak + frequency = habit.get('frequency', 'daily') + streak = calculate_streak(completions, frequency) + + # Add streak to response (but don't persist it in JSON) + habit_with_streak = habit.copy() + habit_with_streak['streak'] = streak + + # Update habits data + habits_data['habits'][habit_index] = habit + habits_data['lastUpdated'] = datetime.now().isoformat() + + # Write back to file + habits_file.write_text(json.dumps(habits_data, indent=2), encoding='utf-8') + + # Return 200 OK with updated habit including streak + self.send_json(habit_with_streak, 200) + + except Exception as e: + self.send_json({'error': str(e)}, 500) + + def handle_habits_delete(self): + """Delete a habit by ID.""" + try: + # Extract habit ID from path: /api/habits/{id} + path_parts = self.path.split('/') + if len(path_parts) < 4: + self.send_json({'error': 'Invalid path'}, 400) + return + + habit_id = path_parts[3] # /api/habits/{id} -> index 3 is id + + # Read habits file + habits_file = KANBAN_DIR / 'habits.json' + if not habits_file.exists(): + self.send_json({'error': 'Habit not found'}, 404) + return + + try: + habits_data = json.loads(habits_file.read_text(encoding='utf-8')) + except (json.JSONDecodeError, IOError): + self.send_json({'error': 'Habit not found'}, 404) + return + + # Find the habit by ID + habit_index = None + for i, h in enumerate(habits_data.get('habits', [])): + if h.get('id') == habit_id: + habit_index = i + break + + if habit_index is None: + self.send_json({'error': 'Habit not found'}, 404) + return + + # Remove the habit + deleted_habit = habits_data['habits'].pop(habit_index) + + # Update lastUpdated timestamp + habits_data['lastUpdated'] = datetime.now().isoformat() + + # Write back to file + habits_file.write_text(json.dumps(habits_data, indent=2), encoding='utf-8') + + # Return 200 OK with success message + self.send_json({ + 'success': True, + 'message': 'Habit deleted successfully', + 'id': habit_id + }, 200) + + except Exception as e: + self.send_json({'error': str(e)}, 500) + def handle_files_get(self): """List files or get file content.""" from urllib.parse import urlparse, parse_qs diff --git a/dashboard/habits.html b/dashboard/habits.html new file mode 100644 index 0000000..7128cb3 --- /dev/null +++ b/dashboard/habits.html @@ -0,0 +1,973 @@ + + + + + + + Echo · Habit Tracker + + + + + + +
+ + +
+ +
+ + + +
+ +
+ +

Se încarcă obiceiurile...

+
+ + +
+ +

Eroare la încărcarea obiceiurilor

+ +
+ + + + + + +
+
+ + + + + + + +
+ + + + diff --git a/dashboard/habits.json b/dashboard/habits.json new file mode 100644 index 0000000..c62851d --- /dev/null +++ b/dashboard/habits.json @@ -0,0 +1,14 @@ +{ + "habits": [ + { + "id": "habit-test1", + "name": "Test Habit", + "frequency": "daily", + "createdAt": "2026-02-01T10:00:00Z", + "completions": [ + "2026-02-10" + ] + } + ], + "lastUpdated": "2026-02-10T13:51:07.626599" +} \ No newline at end of file diff --git a/dashboard/index.html b/dashboard/index.html index e15431c..67f66e9 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -1075,6 +1075,10 @@ Files + + + Habits + @@ -1600,10 +1604,34 @@ const msg = status.anaf.ok !== false ? 'Nicio modificare' : (status.anaf.message || 'Modificări!'); subtitle.textContent = `${msg} · ${lastCheck}`; - if (status.anaf.lastCheck) { - document.getElementById('anafLastCheck').textContent = - 'Ultima verificare: ' + status.anaf.lastCheck; + // Actualizează detaliile + const details = document.getElementById('anafDetails'); + let html = ''; + + // Adaugă detaliile modificărilor dacă există + if (status.anaf.changes && status.anaf.changes.length > 0) { + status.anaf.changes.forEach(change => { + const summaryText = change.summary && change.summary.length > 0 + ? ' - ' + change.summary.join(', ') + : ''; + html += ` +
+ + ${change.name}${summaryText} +
+ `; + }); + } else { + html = ` +
+ + Toate paginile sunt la zi +
+ `; } + + details.innerHTML = html; + lucide.createIcons(); } return status; diff --git a/dashboard/status.json b/dashboard/status.json index 69d43ce..8af62fa 100644 --- a/dashboard/status.json +++ b/dashboard/status.json @@ -13,7 +13,16 @@ "ok": false, "status": "MODIFICĂRI", "message": "1 modificări detectate", - "lastCheck": "09 Feb 2026, 14:00", - "changesCount": 1 + "lastCheck": "10 Feb 2026, 12:39", + "changesCount": 1, + "changes": [ + { + "name": "Declarația 100 - Obligații de plată la bugetul de stat", + "url": "https://static.anaf.ro/static/10/Anaf/Declaratii_R/100.html", + "summary": [ + "Soft A: 09.02.2026 → 10.02.2026" + ] + } + ] } } \ No newline at end of file diff --git a/dashboard/test_habits_api.py b/dashboard/test_habits_api.py new file mode 100644 index 0000000..09ebb83 --- /dev/null +++ b/dashboard/test_habits_api.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +""" +Tests for GET /api/habits endpoint. +Validates API response structure, status codes, and error handling. +""" + +import json +import urllib.request +import urllib.error +from pathlib import Path +from datetime import datetime + +# API endpoint - assumes server is running on localhost:8088 +API_BASE = 'http://localhost:8088' +HABITS_FILE = Path(__file__).parent / 'habits.json' + + +def test_habits_endpoint_exists(): + """Test that GET /api/habits endpoint exists and returns 200.""" + print("Testing endpoint exists and returns 200...") + + try: + response = urllib.request.urlopen(f'{API_BASE}/api/habits', timeout=5) + status_code = response.getcode() + + assert status_code == 200, f"Expected status 200, got {status_code}" + print("✓ Endpoint returns 200 status") + except urllib.error.HTTPError as e: + raise AssertionError(f"Endpoint returned HTTP {e.code}: {e.reason}") + except urllib.error.URLError as e: + raise AssertionError(f"Could not connect to API server: {e.reason}") + + +def test_habits_response_is_json(): + """Test that response is valid JSON.""" + print("Testing response is valid JSON...") + + try: + response = urllib.request.urlopen(f'{API_BASE}/api/habits', timeout=5) + content = response.read().decode('utf-8') + + try: + data = json.loads(content) + print("✓ Response is valid JSON") + return data + except json.JSONDecodeError as e: + raise AssertionError(f"Response is not valid JSON: {e}") + except urllib.error.URLError as e: + raise AssertionError(f"Could not connect to API server: {e.reason}") + + +def test_habits_response_structure(): + """Test that response has correct structure: habits array and lastUpdated.""" + print("Testing response structure...") + + data = test_habits_response_is_json() + + # Check for habits array + assert 'habits' in data, "Response missing 'habits' field" + assert isinstance(data['habits'], list), "'habits' field must be an array" + print("✓ Response contains 'habits' array") + + # Check for lastUpdated timestamp + assert 'lastUpdated' in data, "Response missing 'lastUpdated' field" + print("✓ Response contains 'lastUpdated' field") + + +def test_habits_lastupdated_is_iso(): + """Test that lastUpdated is a valid ISO timestamp.""" + print("Testing lastUpdated is valid ISO timestamp...") + + data = test_habits_response_is_json() + last_updated = data.get('lastUpdated') + + assert last_updated, "lastUpdated field is empty" + + try: + # Try to parse as ISO datetime + dt = datetime.fromisoformat(last_updated.replace('Z', '+00:00')) + print(f"✓ lastUpdated is valid ISO timestamp: {last_updated}") + except (ValueError, AttributeError) as e: + raise AssertionError(f"lastUpdated is not a valid ISO timestamp: {e}") + + +def test_empty_habits_returns_empty_array(): + """Test that empty habits.json returns empty array, not error.""" + print("Testing empty habits file returns empty array...") + + # Backup original file + backup = None + if HABITS_FILE.exists(): + backup = HABITS_FILE.read_text() + + try: + # Write empty habits file + HABITS_FILE.write_text(json.dumps({ + 'lastUpdated': datetime.now().isoformat(), + 'habits': [] + })) + + # Request habits + response = urllib.request.urlopen(f'{API_BASE}/api/habits', timeout=5) + data = json.loads(response.read().decode('utf-8')) + + assert data['habits'] == [], "Empty habits.json should return empty array" + print("✓ Empty habits.json returns empty array (not error)") + + finally: + # Restore backup + if backup: + HABITS_FILE.write_text(backup) + + +def test_habits_with_data(): + """Test that habits with data are returned correctly.""" + print("Testing habits with data are returned...") + + # Backup original file + backup = None + if HABITS_FILE.exists(): + backup = HABITS_FILE.read_text() + + try: + # Write test habits + test_data = { + 'lastUpdated': '2026-02-10T10:00:00.000Z', + 'habits': [ + { + 'id': 'test-habit-1', + 'name': 'Bazin', + 'frequency': 'daily', + 'createdAt': '2026-02-10T10:00:00.000Z', + 'completions': ['2026-02-10T10:00:00.000Z'] + } + ] + } + HABITS_FILE.write_text(json.dumps(test_data, indent=2)) + + # Request habits + response = urllib.request.urlopen(f'{API_BASE}/api/habits', timeout=5) + data = json.loads(response.read().decode('utf-8')) + + assert len(data['habits']) == 1, "Should return 1 habit" + habit = data['habits'][0] + assert habit['name'] == 'Bazin', f"Expected habit name 'Bazin', got '{habit['name']}'" + assert habit['frequency'] == 'daily', f"Expected frequency 'daily', got '{habit['frequency']}'" + print("✓ Habits with data are returned correctly") + + finally: + # Restore backup + if backup: + HABITS_FILE.write_text(backup) + + +def run_all_tests(): + """Run all tests and report results.""" + print("=" * 60) + print("Running GET /api/habits endpoint tests") + print("=" * 60) + print() + + tests = [ + test_habits_endpoint_exists, + test_habits_response_is_json, + test_habits_response_structure, + test_habits_lastupdated_is_iso, + test_empty_habits_returns_empty_array, + test_habits_with_data, + ] + + passed = 0 + failed = 0 + + for test in tests: + try: + test() + passed += 1 + print() + except AssertionError as e: + print(f"✗ FAILED: {e}") + print() + failed += 1 + except Exception as e: + print(f"✗ ERROR: {e}") + print() + failed += 1 + + print("=" * 60) + print(f"Results: {passed} passed, {failed} failed") + print("=" * 60) + + return failed == 0 + + +if __name__ == '__main__': + import sys + + # Check if API server is running + try: + urllib.request.urlopen(f'{API_BASE}/api/status', timeout=2) + except urllib.error.URLError: + print("ERROR: API server is not running on localhost:8088") + print("Start the server with: python3 dashboard/api.py") + sys.exit(1) + + success = run_all_tests() + sys.exit(0 if success else 1) diff --git a/dashboard/test_habits_card_styling.py b/dashboard/test_habits_card_styling.py new file mode 100644 index 0000000..6e463a3 --- /dev/null +++ b/dashboard/test_habits_card_styling.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +""" +Tests for Story 12.0: Frontend - Habit card styling +Tests styling enhancements for habit cards +""" + +def test_file_exists(): + """Test that habits.html exists""" + import os + assert os.path.exists('dashboard/habits.html'), "habits.html file should exist" + print("✓ habits.html exists") + +def test_card_border_radius(): + """Test that cards use --radius-lg border radius""" + with open('dashboard/habits.html', 'r') as f: + content = f.read() + + # Check that habit-card has border-radius: var(--radius-lg) + assert 'border-radius: var(--radius-lg);' in content, "habit-card should use --radius-lg border radius" + + # Check it's in the .habit-card CSS rule + habit_card_start = content.find('.habit-card {') + habit_card_end = content.find('}', habit_card_start) + habit_card_css = content[habit_card_start:habit_card_end] + assert 'border-radius: var(--radius-lg)' in habit_card_css, "habit-card should have --radius-lg in its CSS" + + print("✓ Cards use --radius-lg border radius") + +def test_streak_font_size(): + """Test that streak uses --text-xl font size""" + with open('dashboard/habits.html', 'r') as f: + content = f.read() + + # Find .habit-streak CSS rule + streak_start = content.find('.habit-streak {') + assert streak_start > 0, ".habit-streak CSS rule should exist" + + streak_end = content.find('}', streak_start) + streak_css = content[streak_start:streak_end] + + # Check for font-size: var(--text-xl) + assert 'font-size: var(--text-xl)' in streak_css, "Streak should use --text-xl font size" + + print("✓ Streak displayed prominently with --text-xl font size") + +def test_checked_habit_background(): + """Test that checked habits have subtle green background tint""" + with open('dashboard/habits.html', 'r') as f: + content = f.read() + + # Check for .habit-card.checked CSS rule + assert '.habit-card.checked' in content, "Should have .habit-card.checked CSS rule" + + # Find the CSS rule + checked_start = content.find('.habit-card.checked {') + assert checked_start > 0, ".habit-card.checked CSS rule should exist" + + checked_end = content.find('}', checked_start) + checked_css = content[checked_start:checked_end] + + # Check for green background (using rgba with green color and low opacity) + assert 'background: rgba(34, 197, 94, 0.1)' in checked_css, "Checked cards should have green background tint" + + # Check JavaScript adds 'checked' class to card + assert "card.classList.add('checked')" in content, "JavaScript should add 'checked' class to card" + + print("✓ Checked habits have subtle green background tint") + +def test_checkbox_pulse_animation(): + """Test that unchecked habits have subtle pulse animation on checkbox hover""" + with open('dashboard/habits.html', 'r') as f: + content = f.read() + + # Check for animation on hover (not disabled) + hover_start = content.find('.habit-checkbox:hover:not(.disabled) {') + assert hover_start > 0, "Should have hover rule for unchecked checkboxes" + + hover_end = content.find('}', hover_start) + hover_css = content[hover_start:hover_end] + + # Check for pulse animation + assert 'animation: pulse' in hover_css, "Unchecked checkboxes should have pulse animation on hover" + + # Check for @keyframes pulse definition + assert '@keyframes pulse' in content, "Should have pulse keyframes definition" + + # Verify pulse animation scales element + keyframes_start = content.find('@keyframes pulse {') + keyframes_end = content.find('}', keyframes_start) + keyframes_css = content[keyframes_start:keyframes_end] + assert 'scale(' in keyframes_css, "Pulse animation should scale the element" + + print("✓ Unchecked habits have subtle pulse animation on checkbox hover") + +def test_frequency_badge_styling(): + """Test that frequency badge uses dashboard tag styling""" + with open('dashboard/habits.html', 'r') as f: + content = f.read() + + # Find .habit-frequency CSS rule + freq_start = content.find('.habit-frequency {') + assert freq_start > 0, ".habit-frequency CSS rule should exist" + + freq_end = content.find('}', freq_start) + freq_css = content[freq_start:freq_end] + + # Check for tag-like styling + assert 'display: inline-block' in freq_css, "Frequency should be inline-block" + assert 'background: var(--bg-elevated)' in freq_css, "Frequency should use --bg-elevated" + assert 'border: 1px solid var(--border)' in freq_css, "Frequency should have border" + assert 'padding:' in freq_css, "Frequency should have padding" + assert 'border-radius:' in freq_css, "Frequency should have border-radius" + + print("✓ Frequency badge uses dashboard tag styling") + +def test_card_uses_css_variables(): + """Test that cards use --bg-surface with --border""" + with open('dashboard/habits.html', 'r') as f: + content = f.read() + + # Find .habit-card CSS rule + card_start = content.find('.habit-card {') + assert card_start > 0, ".habit-card CSS rule should exist" + + card_end = content.find('}', card_start) + card_css = content[card_start:card_end] + + # Check for CSS variables + assert 'background: var(--bg-surface)' in card_css, "Cards should use --bg-surface" + assert 'border: 1px solid var(--border)' in card_css, "Cards should use --border" + + print("✓ Cards use --bg-surface with --border") + +def test_mobile_responsiveness(): + """Test that cards are responsive on mobile (full width < 768px)""" + with open('dashboard/habits.html', 'r') as f: + content = f.read() + + # Check for media query + assert '@media (max-width: 768px)' in content, "Should have mobile media query" + + # Find mobile media query + mobile_start = content.find('@media (max-width: 768px)') + assert mobile_start > 0, "Mobile media query should exist" + + mobile_end = content.find('}', content.find('}', content.find('}', mobile_start) + 1) + 1) + mobile_css = content[mobile_start:mobile_end] + + # Check for habit-card width + assert '.habit-card {' in mobile_css or 'habit-card' in mobile_css, "Mobile styles should target habit-card" + assert 'width: 100%' in mobile_css, "Cards should be full width on mobile" + + # Check for reduced spacing + assert '.main {' in mobile_css, "Main container should have mobile styling" + + print("✓ Responsive on mobile (full width < 768px)") + +def test_checked_class_in_createHabitCard(): + """Test that createHabitCard adds 'checked' class to card""" + with open('dashboard/habits.html', 'r') as f: + content = f.read() + + # Find createHabitCard function + func_start = content.find('function createHabitCard(habit) {') + assert func_start > 0, "createHabitCard function should exist" + + func_end = content.find('return card;', func_start) + func_code = content[func_start:func_end] + + # Check for checked class logic + assert "isChecked ? 'habit-card checked' : 'habit-card'" in func_code, "Should add 'checked' class to card when habit is checked" + + print("✓ createHabitCard adds 'checked' class when appropriate") + +def test_all_acceptance_criteria(): + """Summary test: verify all 7 acceptance criteria are met""" + with open('dashboard/habits.html', 'r') as f: + content = f.read() + + criteria = [] + + # 1. Cards use --bg-surface with --border + card_start = content.find('.habit-card {') + card_end = content.find('}', card_start) + card_css = content[card_start:card_end] + if 'background: var(--bg-surface)' in card_css and 'border: 1px solid var(--border)' in card_css: + criteria.append("✓ Cards use --bg-surface with --border") + + # 2. Streak displayed prominently with --text-xl + streak_start = content.find('.habit-streak {') + streak_end = content.find('}', streak_start) + streak_css = content[streak_start:streak_end] + if 'font-size: var(--text-xl)' in streak_css: + criteria.append("✓ Streak displayed prominently with --text-xl") + + # 3. Checked habits have subtle green background tint + if '.habit-card.checked' in content and 'rgba(34, 197, 94, 0.1)' in content: + criteria.append("✓ Checked habits have subtle green background tint") + + # 4. Unchecked habits have subtle pulse animation on checkbox hover + if 'animation: pulse' in content and '@keyframes pulse' in content: + criteria.append("✓ Unchecked habits have pulse animation on hover") + + # 5. Frequency badge uses dashboard tag styling + freq_start = content.find('.habit-frequency {') + freq_end = content.find('}', freq_start) + freq_css = content[freq_start:freq_end] + if 'display: inline-block' in freq_css and 'background: var(--bg-elevated)' in freq_css: + criteria.append("✓ Frequency badge uses dashboard tag styling") + + # 6. Cards have --radius-lg border radius + if 'border-radius: var(--radius-lg)' in card_css: + criteria.append("✓ Cards have --radius-lg border radius") + + # 7. Responsive on mobile (full width < 768px) + if '@media (max-width: 768px)' in content and 'width: 100%' in content[content.find('@media (max-width: 768px)'):]: + criteria.append("✓ Responsive on mobile (full width < 768px)") + + for criterion in criteria: + print(criterion) + + assert len(criteria) == 7, f"Should meet all 7 acceptance criteria, met {len(criteria)}" + print(f"\n✓ All 7 acceptance criteria met!") + +if __name__ == '__main__': + import os + os.chdir('/home/moltbot/clawd') + + tests = [ + test_file_exists, + test_card_uses_css_variables, + test_card_border_radius, + test_streak_font_size, + test_checked_habit_background, + test_checkbox_pulse_animation, + test_frequency_badge_styling, + test_mobile_responsiveness, + test_checked_class_in_createHabitCard, + test_all_acceptance_criteria + ] + + print("Running tests for Story 12.0: Frontend - Habit card styling\n") + + for test in tests: + try: + test() + except AssertionError as e: + print(f"✗ {test.__name__} failed: {e}") + exit(1) + except Exception as e: + print(f"✗ {test.__name__} error: {e}") + exit(1) + + print(f"\n{'='*60}") + print(f"All {len(tests)} tests passed! ✓") + print(f"{'='*60}") diff --git a/dashboard/test_habits_check.py b/dashboard/test_habits_check.py new file mode 100644 index 0000000..214333c --- /dev/null +++ b/dashboard/test_habits_check.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python3 +""" +Tests for POST /api/habits/{id}/check endpoint +""" + +import json +import sys +import time +from datetime import datetime, timedelta +from pathlib import Path +from http.client import HTTPConnection + +# Test against local server +HOST = 'localhost' +PORT = 8088 +HABITS_FILE = Path(__file__).parent / 'habits.json' + + +def cleanup_test_habits(): + """Reset habits.json to empty state for testing.""" + data = { + 'lastUpdated': datetime.now().isoformat(), + 'habits': [] + } + HABITS_FILE.write_text(json.dumps(data, indent=2)) + + +def create_test_habit(name='Test Habit', frequency='daily'): + """Helper to create a test habit and return its ID.""" + conn = HTTPConnection(HOST, PORT) + payload = json.dumps({'name': name, 'frequency': frequency}) + headers = {'Content-Type': 'application/json'} + + conn.request('POST', '/api/habits', payload, headers) + response = conn.getresponse() + data = json.loads(response.read().decode()) + conn.close() + + return data['id'] + + +def test_check_habit_success(): + """Test successfully checking a habit for today.""" + cleanup_test_habits() + habit_id = create_test_habit('Morning Run', 'daily') + + conn = HTTPConnection(HOST, PORT) + conn.request('POST', f'/api/habits/{habit_id}/check') + response = conn.getresponse() + data = json.loads(response.read().decode()) + conn.close() + + assert response.status == 200, f"Expected 200, got {response.status}" + assert data['id'] == habit_id, "Habit ID should match" + assert 'completions' in data, "Response should include completions" + assert len(data['completions']) == 1, "Should have exactly 1 completion" + + # Check that completion date is today (YYYY-MM-DD format) + today = datetime.now().date().isoformat() + assert data['completions'][0] == today, f"Completion should be today's date: {today}" + + # Check that streak is calculated and included + assert 'streak' in data, "Response should include streak" + assert data['streak'] == 1, "Streak should be 1 after first check" + + print("✓ test_check_habit_success") + + +def test_check_habit_already_checked(): + """Test checking a habit that was already checked today.""" + cleanup_test_habits() + habit_id = create_test_habit('Reading', 'daily') + + # Check it once + conn = HTTPConnection(HOST, PORT) + conn.request('POST', f'/api/habits/{habit_id}/check') + conn.getresponse().read() + conn.close() + + # Try to check again + conn = HTTPConnection(HOST, PORT) + conn.request('POST', f'/api/habits/{habit_id}/check') + response = conn.getresponse() + data = json.loads(response.read().decode()) + conn.close() + + assert response.status == 400, f"Expected 400, got {response.status}" + assert 'error' in data, "Response should include error" + assert 'already checked' in data['error'].lower(), "Error should mention already checked" + + print("✓ test_check_habit_already_checked") + + +def test_check_habit_not_found(): + """Test checking a non-existent habit.""" + cleanup_test_habits() + + conn = HTTPConnection(HOST, PORT) + conn.request('POST', '/api/habits/nonexistent-id/check') + response = conn.getresponse() + data = json.loads(response.read().decode()) + conn.close() + + assert response.status == 404, f"Expected 404, got {response.status}" + assert 'error' in data, "Response should include error" + + print("✓ test_check_habit_not_found") + + +def test_check_habit_persistence(): + """Test that completions are persisted to habits.json.""" + cleanup_test_habits() + habit_id = create_test_habit('Meditation', 'daily') + + # Check the habit + conn = HTTPConnection(HOST, PORT) + conn.request('POST', f'/api/habits/{habit_id}/check') + conn.getresponse().read() + conn.close() + + # Read habits.json directly + habits_data = json.loads(HABITS_FILE.read_text()) + habit = next((h for h in habits_data['habits'] if h['id'] == habit_id), None) + + assert habit is not None, "Habit should exist in file" + assert len(habit['completions']) == 1, "Should have 1 completion in file" + + today = datetime.now().date().isoformat() + assert habit['completions'][0] == today, "Completion date should be today" + + print("✓ test_check_habit_persistence") + + +def test_check_habit_sorted_completions(): + """Test that completions array is sorted chronologically.""" + cleanup_test_habits() + + # Create a habit and manually add out-of-order completions + habit_id = create_test_habit('Workout', 'daily') + + # Manually add past completions in reverse order + habits_data = json.loads(HABITS_FILE.read_text()) + habit = next((h for h in habits_data['habits'] if h['id'] == habit_id), None) + + today = datetime.now().date() + habit['completions'] = [ + (today - timedelta(days=2)).isoformat(), # 2 days ago + (today - timedelta(days=4)).isoformat(), # 4 days ago + (today - timedelta(days=1)).isoformat(), # yesterday + ] + HABITS_FILE.write_text(json.dumps(habits_data, indent=2)) + + # Check today + conn = HTTPConnection(HOST, PORT) + conn.request('POST', f'/api/habits/{habit_id}/check') + response = conn.getresponse() + data = json.loads(response.read().decode()) + conn.close() + + # Verify completions are sorted oldest first + expected_order = [ + (today - timedelta(days=4)).isoformat(), + (today - timedelta(days=2)).isoformat(), + (today - timedelta(days=1)).isoformat(), + today.isoformat() + ] + + assert data['completions'] == expected_order, f"Completions should be sorted. Got: {data['completions']}" + + print("✓ test_check_habit_sorted_completions") + + +def test_check_habit_streak_calculation(): + """Test that streak is calculated correctly after checking.""" + cleanup_test_habits() + habit_id = create_test_habit('Journaling', 'daily') + + # Add consecutive past completions + today = datetime.now().date() + habits_data = json.loads(HABITS_FILE.read_text()) + habit = next((h for h in habits_data['habits'] if h['id'] == habit_id), None) + + habit['completions'] = [ + (today - timedelta(days=2)).isoformat(), + (today - timedelta(days=1)).isoformat(), + ] + HABITS_FILE.write_text(json.dumps(habits_data, indent=2)) + + # Check today + conn = HTTPConnection(HOST, PORT) + conn.request('POST', f'/api/habits/{habit_id}/check') + response = conn.getresponse() + data = json.loads(response.read().decode()) + conn.close() + + # Streak should be 3 (today + yesterday + day before) + assert data['streak'] == 3, f"Expected streak 3, got {data['streak']}" + + print("✓ test_check_habit_streak_calculation") + + +def test_check_weekly_habit(): + """Test checking a weekly habit.""" + cleanup_test_habits() + habit_id = create_test_habit('Team Meeting', 'weekly') + + conn = HTTPConnection(HOST, PORT) + conn.request('POST', f'/api/habits/{habit_id}/check') + response = conn.getresponse() + data = json.loads(response.read().decode()) + conn.close() + + assert response.status == 200, f"Expected 200, got {response.status}" + assert len(data['completions']) == 1, "Should have 1 completion" + assert data['streak'] == 1, "Weekly habit should have streak of 1" + + print("✓ test_check_weekly_habit") + + +def test_check_habit_iso_date_format(): + """Test that completion dates use ISO YYYY-MM-DD format (not timestamps).""" + cleanup_test_habits() + habit_id = create_test_habit('Water Plants', 'daily') + + conn = HTTPConnection(HOST, PORT) + conn.request('POST', f'/api/habits/{habit_id}/check') + response = conn.getresponse() + data = json.loads(response.read().decode()) + conn.close() + + completion = data['completions'][0] + + # Verify format is YYYY-MM-DD (exactly 10 chars, 2 dashes) + assert len(completion) == 10, f"Date should be 10 chars, got {len(completion)}" + assert completion.count('-') == 2, "Date should have 2 dashes" + assert 'T' not in completion, "Date should not include time (no T)" + + # Verify it parses as a valid date + try: + datetime.fromisoformat(completion) + except ValueError: + assert False, f"Completion date should be valid ISO date: {completion}" + + print("✓ test_check_habit_iso_date_format") + + +if __name__ == '__main__': + print("Running tests for POST /api/habits/{id}/check...") + print() + + try: + test_check_habit_success() + test_check_habit_already_checked() + test_check_habit_not_found() + test_check_habit_persistence() + test_check_habit_sorted_completions() + test_check_habit_streak_calculation() + test_check_weekly_habit() + test_check_habit_iso_date_format() + + print() + print("✅ All tests passed!") + sys.exit(0) + except AssertionError as e: + print() + print(f"❌ Test failed: {e}") + sys.exit(1) + except Exception as e: + print() + print(f"❌ Error running tests: {e}") + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/dashboard/test_habits_check_ui.py b/dashboard/test_habits_check_ui.py new file mode 100644 index 0000000..ef1ddd5 --- /dev/null +++ b/dashboard/test_habits_check_ui.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +""" +Tests for Story 10.0: Frontend - Check habit interaction +""" + +import sys +import re + +def load_html(): + """Load habits.html content""" + try: + with open('dashboard/habits.html', 'r', encoding='utf-8') as f: + return f.read() + except FileNotFoundError: + print("ERROR: dashboard/habits.html not found") + sys.exit(1) + +def test_checkbox_css_exists(): + """Test that checkbox CSS styles are defined""" + html = load_html() + + # Check for checkbox class + assert '.habit-checkbox' in html, "Missing .habit-checkbox CSS class" + + # Check for circular shape (border-radius: 50%) + assert 'border-radius: 50%' in html, "Checkbox should be circular (border-radius: 50%)" + + # Check for checked state + assert '.habit-checkbox.checked' in html, "Missing .habit-checkbox.checked CSS" + + # Check for disabled state + assert '.habit-checkbox.disabled' in html, "Missing .habit-checkbox.disabled CSS" + + # Check for hover state + assert '.habit-checkbox:hover' in html, "Missing .habit-checkbox:hover CSS" + + print("✓ Checkbox CSS styles exist") + +def test_checkbox_in_habit_card(): + """Test that createHabitCard includes checkbox""" + html = load_html() + + # Check that createHabitCard creates a checkbox element + assert 'habit-checkbox' in html, "createHabitCard should include checkbox element" + + # Check for data-habit-id attribute + assert 'data-habit-id' in html, "Checkbox should have data-habit-id attribute" + + # Check for onclick handler + assert 'onclick="checkHabit' in html, "Checkbox should have onclick='checkHabit' handler" + + print("✓ Checkbox is included in habit card") + +def test_checkbox_checked_state(): + """Test that checkbox uses checkedToday to determine state""" + html = load_html() + + # Look for logic that checks habit.checkedToday + assert 'checkedToday' in html, "Should check habit.checkedToday property" + + # Check for conditional checked class + assert 'checked' in html, "Should add 'checked' class when checkedToday is true" + + # Check for check icon + assert 'data-lucide="check"' in html, "Should show check icon when checked" + + print("✓ Checkbox state reflects checkedToday") + +def test_check_habit_function_exists(): + """Test that checkHabit function is defined""" + html = load_html() + + # Check for function definition + assert 'function checkHabit' in html or 'async function checkHabit' in html, \ + "checkHabit function should be defined" + + # Check for parameters + assert re.search(r'function checkHabit\s*\(\s*habitId', html) or \ + re.search(r'async function checkHabit\s*\(\s*habitId', html), \ + "checkHabit should accept habitId parameter" + + print("✓ checkHabit function exists") + +def test_check_habit_api_call(): + """Test that checkHabit calls POST /api/habits/{id}/check""" + html = load_html() + + # Check for fetch call + assert 'fetch(' in html, "checkHabit should use fetch API" + + # Check for POST method + assert "'POST'" in html or '"POST"' in html, "checkHabit should use POST method" + + # Check for /api/habits/ endpoint + assert '/api/habits/' in html, "Should call /api/habits/{id}/check endpoint" + + # Check for /check path + assert '/check' in html, "Should call endpoint with /check path" + + print("✓ checkHabit calls POST /api/habits/{id}/check") + +def test_optimistic_ui_update(): + """Test that checkbox updates immediately (optimistic)""" + html = load_html() + + # Check for classList.add before fetch + # The pattern should be: add 'checked' class, then fetch + checkHabitFunc = html[html.find('async function checkHabit'):html.find('async function checkHabit') + 2000] + + # Check for immediate classList modification + assert 'classList.add' in checkHabitFunc, "Should add class immediately (optimistic update)" + assert "'checked'" in checkHabitFunc or '"checked"' in checkHabitFunc, \ + "Should add 'checked' class optimistically" + + print("✓ Optimistic UI update implemented") + +def test_error_handling_revert(): + """Test that checkbox reverts on error""" + html = load_html() + + # Check for catch block + assert 'catch' in html, "checkHabit should have error handling (catch)" + + # Check for classList.remove in error handler + checkHabitFunc = html[html.find('async function checkHabit'):] + + # Find the catch block + if 'catch' in checkHabitFunc: + catchBlock = checkHabitFunc[checkHabitFunc.find('catch'):] + + # Check for revert logic + assert 'classList.remove' in catchBlock, "Should revert checkbox on error" + assert "'checked'" in catchBlock or '"checked"' in catchBlock, \ + "Should remove 'checked' class on error" + + print("✓ Error handling reverts checkbox") + +def test_disabled_when_checked(): + """Test that checkbox is disabled when already checked""" + html = load_html() + + # Check for disabled class on checked habits + assert 'disabled' in html, "Should add 'disabled' class to checked habits" + + # Check for early return if disabled + checkHabitFunc = html[html.find('async function checkHabit'):html.find('async function checkHabit') + 1000] + assert 'disabled' in checkHabitFunc, "Should check if checkbox is disabled" + assert 'return' in checkHabitFunc, "Should return early if disabled" + + print("✓ Checkbox disabled when already checked") + +def test_streak_updates(): + """Test that streak updates after successful check""" + html = load_html() + + # Check for streak element ID + assert 'streak-' in html, "Should use ID for streak element (e.g., streak-${habit.id})" + + # Check for getElementById to update streak + checkHabitFunc = html[html.find('async function checkHabit'):] + assert 'getElementById' in checkHabitFunc or 'getElementById' in html, \ + "Should get streak element by ID to update it" + + # Check that response data is used to update streak + assert '.streak' in checkHabitFunc or 'streak' in checkHabitFunc, \ + "Should update streak from response data" + + print("✓ Streak updates after successful check") + +def test_check_icon_display(): + """Test that check icon is shown when checked""" + html = load_html() + + # Check for lucide check icon + assert 'data-lucide="check"' in html, "Should use lucide check icon" + + # Check that icon is created/shown after checking + checkHabitFunc = html[html.find('async function checkHabit'):html.find('async function checkHabit') + 1500] + assert 'lucide.createIcons()' in checkHabitFunc, \ + "Should reinitialize lucide icons after adding check icon" + + print("✓ Check icon displays correctly") + +def main(): + """Run all tests""" + print("Running Story 10.0 Frontend Check Interaction Tests...\n") + + tests = [ + test_checkbox_css_exists, + test_checkbox_in_habit_card, + test_checkbox_checked_state, + test_check_habit_function_exists, + test_check_habit_api_call, + test_optimistic_ui_update, + test_error_handling_revert, + test_disabled_when_checked, + test_streak_updates, + test_check_icon_display, + ] + + passed = 0 + failed = 0 + + for test in tests: + try: + test() + passed += 1 + except AssertionError as e: + print(f"✗ {test.__name__}: {e}") + failed += 1 + except Exception as e: + print(f"✗ {test.__name__}: Unexpected error: {e}") + failed += 1 + + print(f"\n{'='*50}") + print(f"Results: {passed} passed, {failed} failed") + print(f"{'='*50}") + + return 0 if failed == 0 else 1 + +if __name__ == '__main__': + sys.exit(main()) diff --git a/dashboard/test_habits_delete.py b/dashboard/test_habits_delete.py new file mode 100644 index 0000000..72ecc63 --- /dev/null +++ b/dashboard/test_habits_delete.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +""" +Tests for Story 15.0: Backend - Delete habit endpoint +Tests the DELETE /api/habits/{id} endpoint functionality. +""" + +import json +import shutil +import sys +import tempfile +import unittest +from datetime import datetime +from http.server import HTTPServer +from pathlib import Path +from threading import Thread +from time import sleep +from urllib.request import Request, urlopen +from urllib.error import HTTPError + +# Add parent directory to path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from dashboard.api import TaskBoardHandler + +class TestHabitsDelete(unittest.TestCase): + """Test DELETE /api/habits/{id} endpoint""" + + @classmethod + def setUpClass(cls): + """Start test server""" + # Create temp habits.json + cls.temp_dir = Path(tempfile.mkdtemp()) + cls.habits_file = cls.temp_dir / 'habits.json' + cls.habits_file.write_text(json.dumps({ + 'lastUpdated': datetime.now().isoformat(), + 'habits': [] + })) + + # Monkey-patch KANBAN_DIR to use temp directory + import dashboard.api as api_module + cls.original_kanban_dir = api_module.KANBAN_DIR + api_module.KANBAN_DIR = cls.temp_dir + + # Start server in background thread + cls.port = 9022 + cls.server = HTTPServer(('127.0.0.1', cls.port), TaskBoardHandler) + cls.server_thread = Thread(target=cls.server.serve_forever, daemon=True) + cls.server_thread.start() + sleep(0.5) # Wait for server to start + + @classmethod + def tearDownClass(cls): + """Stop server and cleanup""" + cls.server.shutdown() + + # Restore original KANBAN_DIR + import dashboard.api as api_module + api_module.KANBAN_DIR = cls.original_kanban_dir + + # Cleanup temp directory + shutil.rmtree(cls.temp_dir) + + def setUp(self): + """Reset habits file before each test""" + self.habits_file.write_text(json.dumps({ + 'lastUpdated': datetime.now().isoformat(), + 'habits': [] + })) + + def api_call(self, method, path, body=None): + """Make API call and return (status, data)""" + url = f'http://127.0.0.1:{self.port}{path}' + headers = {'Content-Type': 'application/json'} + + if body: + data = json.dumps(body).encode('utf-8') + req = Request(url, data=data, headers=headers, method=method) + else: + req = Request(url, headers=headers, method=method) + + try: + with urlopen(req) as response: + return response.status, json.loads(response.read().decode('utf-8')) + except HTTPError as e: + return e.code, json.loads(e.read().decode('utf-8')) + + def test_01_delete_removes_habit_from_file(self): + """AC1: DELETE /api/habits/{id} removes habit from habits.json""" + # Create two habits + _, habit1 = self.api_call('POST', '/api/habits', {'name': 'Habit 1', 'frequency': 'daily'}) + _, habit2 = self.api_call('POST', '/api/habits', {'name': 'Habit 2', 'frequency': 'weekly'}) + + habit1_id = habit1['id'] + habit2_id = habit2['id'] + + # Delete first habit + status, _ = self.api_call('DELETE', f'/api/habits/{habit1_id}') + self.assertEqual(status, 200) + + # Verify it's removed from file + data = json.loads(self.habits_file.read_text()) + remaining_ids = [h['id'] for h in data['habits']] + + self.assertNotIn(habit1_id, remaining_ids, "Deleted habit still in file") + self.assertIn(habit2_id, remaining_ids, "Other habit was incorrectly deleted") + self.assertEqual(len(data['habits']), 1, "Should have exactly 1 habit remaining") + + def test_02_returns_200_with_success_message(self): + """AC2: Returns 200 with success message""" + # Create a habit + _, habit = self.api_call('POST', '/api/habits', {'name': 'Test Habit', 'frequency': 'daily'}) + habit_id = habit['id'] + + # Delete it + status, response = self.api_call('DELETE', f'/api/habits/{habit_id}') + + self.assertEqual(status, 200) + self.assertTrue(response.get('success'), "Response should have success=true") + self.assertIn('message', response, "Response should contain message field") + self.assertEqual(response.get('id'), habit_id, "Response should contain habit ID") + + def test_03_returns_404_if_not_found(self): + """AC3: Returns 404 if habit not found""" + status, response = self.api_call('DELETE', '/api/habits/nonexistent-id') + + self.assertEqual(status, 404) + self.assertIn('error', response, "Response should contain error message") + + def test_04_updates_lastUpdated_timestamp(self): + """AC4: Updates lastUpdated timestamp""" + # Get initial timestamp + data_before = json.loads(self.habits_file.read_text()) + timestamp_before = data_before['lastUpdated'] + + sleep(0.1) # Ensure timestamp difference + + # Create and delete a habit + _, habit = self.api_call('POST', '/api/habits', {'name': 'Test', 'frequency': 'daily'}) + self.api_call('DELETE', f'/api/habits/{habit["id"]}') + + # Check timestamp was updated + data_after = json.loads(self.habits_file.read_text()) + timestamp_after = data_after['lastUpdated'] + + self.assertNotEqual(timestamp_after, timestamp_before, "Timestamp should be updated") + + # Verify it's a valid ISO timestamp + try: + datetime.fromisoformat(timestamp_after.replace('Z', '+00:00')) + except ValueError: + self.fail(f"Invalid ISO timestamp: {timestamp_after}") + + def test_05_edge_cases(self): + """AC5: Tests for delete endpoint edge cases""" + # Create a habit + _, habit = self.api_call('POST', '/api/habits', {'name': 'Test', 'frequency': 'daily'}) + habit_id = habit['id'] + + # Delete it + status, _ = self.api_call('DELETE', f'/api/habits/{habit_id}') + self.assertEqual(status, 200) + + # Try to delete again (should return 404) + status, _ = self.api_call('DELETE', f'/api/habits/{habit_id}') + self.assertEqual(status, 404, "Should return 404 for already deleted habit") + + # Test invalid path (trailing slash) + status, _ = self.api_call('DELETE', '/api/habits/') + self.assertEqual(status, 404, "Should return 404 for invalid path") + + def test_06_missing_file_handling(self): + """Test graceful handling when habits.json is missing""" + # Remove file + self.habits_file.unlink() + + # Try to delete + status, response = self.api_call('DELETE', '/api/habits/some-id') + + self.assertEqual(status, 404) + self.assertIn('error', response) + + # Restore file for cleanup + self.habits_file.write_text(json.dumps({ + 'lastUpdated': datetime.now().isoformat(), + 'habits': [] + })) + +if __name__ == '__main__': + # Run tests + suite = unittest.TestLoader().loadTestsFromTestCase(TestHabitsDelete) + runner = unittest.TextTestRunner(verbosity=2) + result = runner.run(suite) + + # Print summary + if result.wasSuccessful(): + print("\n✅ All Story 15.0 acceptance criteria verified:") + print(" 1. DELETE /api/habits/{id} removes habit from habits.json ✓") + print(" 2. Returns 200 with success message ✓") + print(" 3. Returns 404 if habit not found ✓") + print(" 4. Updates lastUpdated timestamp ✓") + print(" 5. Tests for delete endpoint pass ✓") + + sys.exit(0 if result.wasSuccessful() else 1) diff --git a/dashboard/test_habits_delete_ui.py b/dashboard/test_habits_delete_ui.py new file mode 100644 index 0000000..a6488eb --- /dev/null +++ b/dashboard/test_habits_delete_ui.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python3 +""" +Test suite for Story 16.0: Frontend - Delete habit with confirmation +Tests the delete button and confirmation modal functionality. +""" + +import re + +def test_file_exists(): + """Test that habits.html exists""" + try: + with open('dashboard/habits.html', 'r', encoding='utf-8') as f: + content = f.read() + return True + except FileNotFoundError: + print("FAIL: habits.html not found") + return False + + +def test_delete_button_css(): + """AC1: Tests delete button styling (trash icon button using lucide)""" + with open('dashboard/habits.html', 'r', encoding='utf-8') as f: + content = f.read() + + # Check for delete button CSS class + if '.habit-delete-btn' not in content: + print("FAIL: .habit-delete-btn CSS class not found") + return False + + # Check for proper styling (size, border, hover state) + css_pattern = r'\.habit-delete-btn\s*\{[^}]*width:\s*32px[^}]*height:\s*32px' + if not re.search(css_pattern, content, re.DOTALL): + print("FAIL: Delete button sizing not found (32x32px)") + return False + + # Check for hover state with danger color + if '.habit-delete-btn:hover' not in content: + print("FAIL: Delete button hover state not found") + return False + + if 'var(--text-danger)' not in content: + print("FAIL: Danger color not used for delete button") + return False + + return True + + +def test_delete_button_in_card(): + """AC1: Tests that habit card includes trash icon button""" + with open('dashboard/habits.html', 'r', encoding='utf-8') as f: + content = f.read() + + # Check for trash-2 icon (lucide) in createHabitCard + if 'trash-2' not in content: + print("FAIL: trash-2 icon not found") + return False + + # Check for delete button in card HTML with onclick handler + pattern = r'habit-delete-btn.*onclick.*showDeleteModal' + if not re.search(pattern, content, re.DOTALL): + print("FAIL: Delete button with onclick handler not found in card") + return False + + return True + + +def test_confirmation_modal_structure(): + """AC2: Tests confirmation modal 'Ștergi obișnuința {name}?'""" + with open('dashboard/habits.html', 'r', encoding='utf-8') as f: + content = f.read() + + # Check for delete modal element + if 'id="deleteModal"' not in content: + print("FAIL: deleteModal element not found") + return False + + # Check for Romanian confirmation message + if 'Ștergi obișnuința' not in content: + print("FAIL: Romanian confirmation message not found") + return False + + # Check for habit name placeholder + if 'id="deleteHabitName"' not in content: + print("FAIL: deleteHabitName element for dynamic habit name not found") + return False + + return True + + +def test_confirmation_buttons(): + """AC3 & AC4: Tests Cancel and Delete buttons with correct styling""" + with open('dashboard/habits.html', 'r', encoding='utf-8') as f: + content = f.read() + + # Check for Cancel button + if 'onclick="hideDeleteModal()"' not in content: + print("FAIL: Cancel button with hideDeleteModal() not found") + return False + + # Check for Delete button + if 'onclick="confirmDelete()"' not in content: + print("FAIL: Delete button with confirmDelete() not found") + return False + + # AC4: Check for destructive red styling (btn-danger class) + if '.btn-danger' not in content: + print("FAIL: .btn-danger CSS class not found") + return False + + # Check that btn-danger uses danger color + css_pattern = r'\.btn-danger\s*\{[^}]*background:\s*var\(--text-danger\)' + if not re.search(css_pattern, content, re.DOTALL): + print("FAIL: btn-danger does not use danger color") + return False + + return True + + +def test_delete_api_call(): + """AC5: Tests DELETE API call and list removal on confirm""" + with open('dashboard/habits.html', 'r', encoding='utf-8') as f: + content = f.read() + + # Check for confirmDelete function + if 'async function confirmDelete()' not in content: + print("FAIL: confirmDelete async function not found") + return False + + # Check for DELETE method call to API + pattern = r"method:\s*['\"]DELETE['\"]" + if not re.search(pattern, content): + print("FAIL: DELETE method not found in confirmDelete") + return False + + # Check for API endpoint with habitToDelete variable + pattern = r"/api/habits/\$\{habitToDelete\}" + if not re.search(pattern, content): + print("FAIL: DELETE endpoint /api/habits/{id} not found") + return False + + # Check for loadHabits() call after successful deletion (removes from list) + if 'loadHabits()' not in content: + print("FAIL: loadHabits() not called after deletion (list won't refresh)") + return False + + return True + + +def test_error_handling(): + """AC6: Tests error message display if delete fails""" + with open('dashboard/habits.html', 'r', encoding='utf-8') as f: + content = f.read() + + # Check for error handling in confirmDelete + pattern = r'catch\s*\(error\)\s*\{[^}]*showToast' + if not re.search(pattern, content, re.DOTALL): + print("FAIL: Error handling with showToast not found in confirmDelete") + return False + + # Check for error message + if 'Eroare la ștergerea obișnuinței' not in content: + print("FAIL: Delete error message not found") + return False + + return True + + +def test_modal_functions(): + """Tests showDeleteModal and hideDeleteModal functions""" + with open('dashboard/habits.html', 'r', encoding='utf-8') as f: + content = f.read() + + # Check for showDeleteModal function + if 'function showDeleteModal(' not in content: + print("FAIL: showDeleteModal function not found") + return False + + # Check for hideDeleteModal function + if 'function hideDeleteModal(' not in content: + print("FAIL: hideDeleteModal function not found") + return False + + # Check for habitToDelete variable tracking + if 'habitToDelete' not in content: + print("FAIL: habitToDelete tracking variable not found") + return False + + return True + + +def test_modal_show_hide_logic(): + """Tests modal active class toggle for show/hide""" + with open('dashboard/habits.html', 'r', encoding='utf-8') as f: + content = f.read() + + # Check for classList.add('active') in showDeleteModal + pattern = r'showDeleteModal[^}]*classList\.add\(["\']active["\']\)' + if not re.search(pattern, content, re.DOTALL): + print("FAIL: Modal show logic (classList.add('active')) not found") + return False + + # Check for classList.remove('active') in hideDeleteModal + pattern = r'hideDeleteModal[^}]*classList\.remove\(["\']active["\']\)' + if not re.search(pattern, content, re.DOTALL): + print("FAIL: Modal hide logic (classList.remove('active')) not found") + return False + + return True + + +def test_acceptance_criteria_summary(): + """AC7: Summary test verifying all acceptance criteria""" + with open('dashboard/habits.html', 'r', encoding='utf-8') as f: + content = f.read() + + checks = { + 'AC1: Trash icon button': 'trash-2' in content and '.habit-delete-btn' in content, + 'AC2: Confirmation modal message': 'Ștergi obișnuința' in content and 'id="deleteHabitName"' in content, + 'AC3: Cancel and Delete buttons': 'hideDeleteModal()' in content and 'confirmDelete()' in content, + 'AC4: Red destructive style': '.btn-danger' in content and 'var(--text-danger)' in content, + 'AC5: DELETE endpoint call': 'method:' in content and 'DELETE' in content and '/api/habits/' in content, + 'AC6: Error handling': 'catch (error)' in content and 'Eroare la ștergerea' in content, + 'AC7: Delete interaction tests pass': True # This test itself + } + + all_passed = all(checks.values()) + + if not all_passed: + print("FAIL: Not all acceptance criteria met:") + for criterion, passed in checks.items(): + if not passed: + print(f" ✗ {criterion}") + + return all_passed + + +if __name__ == '__main__': + tests = [ + ("File exists", test_file_exists), + ("Delete button CSS styling", test_delete_button_css), + ("Delete button in habit card (trash icon)", test_delete_button_in_card), + ("Confirmation modal structure", test_confirmation_modal_structure), + ("Confirmation buttons (Cancel & Delete)", test_confirmation_buttons), + ("DELETE API call on confirm", test_delete_api_call), + ("Error handling for failed delete", test_error_handling), + ("Modal show/hide functions", test_modal_functions), + ("Modal active class toggle logic", test_modal_show_hide_logic), + ("All acceptance criteria summary", test_acceptance_criteria_summary), + ] + + passed = 0 + total = len(tests) + + print("Running Story 16.0 tests (Frontend - Delete habit with confirmation)...\n") + + for name, test_func in tests: + try: + result = test_func() + if result: + print(f"✓ {name}") + passed += 1 + else: + print(f"✗ {name}") + except Exception as e: + print(f"✗ {name} (exception: {e})") + + print(f"\n{passed}/{total} tests passed") + + if passed == total: + print("✓ All tests passed!") + exit(0) + else: + print(f"✗ {total - passed} test(s) failed") + exit(1) diff --git a/dashboard/test_habits_display.py b/dashboard/test_habits_display.py new file mode 100644 index 0000000..3b68be6 --- /dev/null +++ b/dashboard/test_habits_display.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +""" +Tests for Story 9.0: Frontend - Display habits list +""" +import re + +def read_file(path): + with open(path, 'r', encoding='utf-8') as f: + return f.read() + +def test_loading_state_structure(): + """Test loading state HTML structure exists""" + html = read_file('dashboard/habits.html') + assert 'id="loadingState"' in html, "Loading state element missing" + assert 'class="loading-state"' in html, "Loading state class missing" + assert 'data-lucide="loader"' in html, "Loading state loader icon missing" + assert 'Se încarcă obiceiurile' in html, "Loading state message missing" + print("✓ Loading state structure exists") + +def test_error_state_structure(): + """Test error state HTML structure exists""" + html = read_file('dashboard/habits.html') + assert 'id="errorState"' in html, "Error state element missing" + assert 'class="error-state"' in html, "Error state class missing" + assert 'data-lucide="alert-circle"' in html, "Error state alert icon missing" + assert 'Eroare la încărcarea obiceiurilor' in html, "Error state message missing" + assert 'onclick="loadHabits()"' in html, "Retry button missing" + print("✓ Error state structure exists") + +def test_empty_state_has_id(): + """Test empty state has id for JavaScript access""" + html = read_file('dashboard/habits.html') + assert 'id="emptyState"' in html, "Empty state id missing" + print("✓ Empty state has id attribute") + +def test_habits_list_container(): + """Test habits list container exists""" + html = read_file('dashboard/habits.html') + assert 'id="habitsList"' in html, "Habits list container missing" + assert 'class="habits-list"' in html, "Habits list class missing" + print("✓ Habits list container exists") + +def test_loadhabits_function_exists(): + """Test loadHabits function is implemented""" + html = read_file('dashboard/habits.html') + assert 'async function loadHabits()' in html, "loadHabits function not implemented" + assert 'await fetch(\'/api/habits\')' in html, "API fetch call missing" + print("✓ loadHabits function exists and fetches API") + +def test_sorting_by_streak(): + """Test habits are sorted by streak descending""" + html = read_file('dashboard/habits.html') + assert 'habits.sort(' in html, "Sorting logic missing" + assert 'streak' in html and '.sort(' in html, "Sort by streak missing" + # Check for descending order (b.streak - a.streak pattern) + assert re.search(r'b\.streak.*-.*a\.streak', html), "Descending sort pattern missing" + print("✓ Habits sorted by streak descending") + +def test_frequency_icons(): + """Test frequency icons (calendar for daily, clock for weekly)""" + html = read_file('dashboard/habits.html') + assert 'calendar' in html, "Calendar icon for daily habits missing" + assert 'clock' in html, "Clock icon for weekly habits missing" + # Check icon assignment logic + assert 'daily' in html and 'calendar' in html, "Daily -> calendar mapping missing" + assert 'weekly' in html and 'clock' in html, "Weekly -> clock mapping missing" + print("✓ Frequency icons implemented (calendar/clock)") + +def test_streak_display_with_flame(): + """Test streak display includes flame emoji""" + html = read_file('dashboard/habits.html') + assert '🔥' in html, "Flame emoji missing from streak display" + assert 'habit-streak' in html, "Habit streak class missing" + print("✓ Streak displays with flame emoji 🔥") + +def test_show_hide_states(): + """Test state management (loading, error, empty, list)""" + html = read_file('dashboard/habits.html') + # Check for state toggling logic + assert 'loadingState.classList.add(\'active\')' in html or \ + 'loadingState.classList.add("active")' in html, "Loading state show missing" + assert 'errorState.classList.remove(\'active\')' in html or \ + 'errorState.classList.remove("active")' in html, "Error state hide missing" + assert 'emptyState.style.display' in html, "Empty state toggle missing" + assert 'habitsList.style.display' in html, "Habits list toggle missing" + print("✓ State management implemented") + +def test_error_handling(): + """Test error handling shows error state""" + html = read_file('dashboard/habits.html') + assert 'catch' in html, "Error handling missing" + assert 'errorState.classList.add(\'active\')' in html or \ + 'errorState.classList.add("active")' in html, "Error state activation missing" + print("✓ Error handling implemented") + +def test_createhabitcard_function(): + """Test createHabitCard function exists""" + html = read_file('dashboard/habits.html') + assert 'function createHabitCard(' in html, "createHabitCard function missing" + assert 'habit.name' in html, "Habit name rendering missing" + assert 'habit.frequency' in html, "Habit frequency rendering missing" + assert 'habit.streak' in html, "Habit streak rendering missing" + print("✓ createHabitCard function exists") + +def test_page_load_trigger(): + """Test loadHabits is called on page load""" + html = read_file('dashboard/habits.html') + assert 'DOMContentLoaded' in html, "DOMContentLoaded listener missing" + assert 'loadHabits()' in html, "loadHabits call missing" + print("✓ loadHabits called on page load") + +def test_habit_card_css(): + """Test habit card CSS styling exists""" + html = read_file('dashboard/habits.html') + assert '.habit-card' in html, "Habit card CSS missing" + assert '.habit-icon' in html, "Habit icon CSS missing" + assert '.habit-info' in html, "Habit info CSS missing" + assert '.habit-name' in html, "Habit name CSS missing" + assert '.habit-frequency' in html, "Habit frequency CSS missing" + assert '.habit-streak' in html, "Habit streak CSS missing" + print("✓ Habit card CSS styling exists") + +def test_lucide_icons_reinitialized(): + """Test Lucide icons are reinitialized after rendering""" + html = read_file('dashboard/habits.html') + assert 'lucide.createIcons()' in html, "Lucide icons initialization missing" + # Check it's called after rendering habits + assert html.index('habitsList.appendChild') < html.rindex('lucide.createIcons()'), \ + "Lucide icons not reinitialized after rendering" + print("✓ Lucide icons reinitialized after rendering") + +def test_xss_protection(): + """Test HTML escaping for XSS protection""" + html = read_file('dashboard/habits.html') + assert 'escapeHtml' in html, "HTML escaping function missing" + assert 'textContent' in html or 'innerText' in html, "Text content method missing" + print("✓ XSS protection implemented") + +if __name__ == '__main__': + tests = [ + test_loading_state_structure, + test_error_state_structure, + test_empty_state_has_id, + test_habits_list_container, + test_loadhabits_function_exists, + test_sorting_by_streak, + test_frequency_icons, + test_streak_display_with_flame, + test_show_hide_states, + test_error_handling, + test_createhabitcard_function, + test_page_load_trigger, + test_habit_card_css, + test_lucide_icons_reinitialized, + test_xss_protection, + ] + + failed = 0 + for test in tests: + try: + test() + except AssertionError as e: + print(f"✗ {test.__name__}: {e}") + failed += 1 + except Exception as e: + print(f"✗ {test.__name__}: Unexpected error: {e}") + failed += 1 + + print(f"\n{'='*50}") + print(f"Tests: {len(tests)} total, {len(tests)-failed} passed, {failed} failed") + if failed == 0: + print("✓ All Story 9.0 tests passed!") + exit(failed) diff --git a/dashboard/test_habits_form_submit.py b/dashboard/test_habits_form_submit.py new file mode 100644 index 0000000..2ada102 --- /dev/null +++ b/dashboard/test_habits_form_submit.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +""" +Tests for Story 11.0: Frontend - Create habit from form + +Acceptance Criteria: + 1. Form submit calls POST /api/habits with name and frequency + 2. Shows loading state on submit (button disabled) + 3. On success: modal closes, list refreshes, new habit appears + 4. On error: shows error message in modal, modal stays open + 5. Input field cleared after successful creation + 6. Tests for form submission pass +""" + +import os +import sys + +def test_form_submit_api_call(): + """Test that createHabit calls POST /api/habits with correct data""" + with open('dashboard/habits.html', 'r') as f: + html = f.read() + + # Check that createHabit function exists + assert 'async function createHabit()' in html, "createHabit function should exist" + + # Check that it calls POST /api/habits + assert "fetch('/api/habits'" in html, "Should fetch /api/habits endpoint" + assert "method: 'POST'" in html, "Should use POST method" + + # Check that it sends name and frequency in JSON body + assert "body: JSON.stringify({ name, frequency })" in html, "Should send name and frequency in request body" + + # Check that frequency is read from checked radio button + assert "document.querySelector('input[name=\"frequency\"]:checked').value" in html, "Should read frequency from radio buttons" + + print("✓ Form submission calls POST /api/habits with name and frequency") + +def test_loading_state_on_submit(): + """Test that button is disabled during submission""" + with open('dashboard/habits.html', 'r') as f: + html = f.read() + + # Check that createBtn is referenced + assert "createBtn = document.getElementById('habitCreateBtn')" in html, "Should get create button element" + + # Check that button is disabled before fetch + assert "createBtn.disabled = true" in html, "Button should be disabled during submission" + + # Check that button text changes to loading state + assert "createBtn.textContent = 'Se creează...'" in html or "Se creează" in html, "Should show loading text during submission" + + # Check that original text is stored for restoration + assert "originalText = createBtn.textContent" in html, "Should store original button text" + + print("✓ Shows loading state on submit (button disabled)") + +def test_success_behavior(): + """Test behavior on successful habit creation""" + with open('dashboard/habits.html', 'r') as f: + html = f.read() + + # Check for success handling block + assert "if (response.ok)" in html, "Should check for successful response" + + # Check that modal closes on success + assert "hideHabitModal()" in html, "Modal should close on success" + + # Check that habits list is refreshed + assert "loadHabits()" in html, "Should reload habits list on success" + + # Check that success toast is shown + assert "showToast(" in html and "succes" in html, "Should show success toast" + + print("✓ On success: modal closes, list refreshes, new habit appears") + +def test_error_behavior(): + """Test behavior on error (modal stays open, shows error)""" + with open('dashboard/habits.html', 'r') as f: + html = f.read() + + # Check for error handling in response + assert "else {" in html or "!response.ok" in html, "Should handle error responses" + + # Check for catch block for network errors + assert "catch (error)" in html, "Should catch network errors" + + # Check that error toast is shown + assert "showToast('Eroare" in html, "Should show error toast on failure" + + # Check that button is re-enabled on error (so modal stays usable) + createHabit_block = html[html.find('async function createHabit()'):html.find('async function createHabit()') + 2000] + + # Count occurrences of button re-enable in error paths + error_section = html[html.find('} else {'):html.find('} catch (error)') + 500] + assert 'createBtn.disabled = false' in error_section, "Button should be re-enabled on error" + assert 'createBtn.textContent = originalText' in error_section, "Button text should be restored on error" + + print("✓ On error: shows error message, modal stays open (button re-enabled)") + +def test_input_cleared_after_success(): + """Test that input field is cleared after successful creation""" + with open('dashboard/habits.html', 'r') as f: + html = f.read() + + # Find the success block + success_section = html[html.find('if (response.ok)'):html.find('if (response.ok)') + 500] + + # Check that nameInput.value is cleared + assert "nameInput.value = ''" in success_section or 'nameInput.value = ""' in success_section, \ + "Input field should be cleared after successful creation" + + print("✓ Input field cleared after successful creation") + +def test_form_validation_still_works(): + """Test that existing form validation is still in place""" + with open('dashboard/habits.html', 'r') as f: + html = f.read() + + # Check that empty name validation exists + assert "if (!name)" in html, "Should validate for empty name" + assert "name = nameInput.value.trim()" in html, "Should trim name before validation" + + # Check that create button is disabled when name is empty + assert "nameInput.addEventListener('input'" in html, "Should listen to input changes" + assert "createBtn.disabled = name.length === 0" in html, "Button should be disabled when name is empty" + + print("✓ Form validation still works (empty name check)") + +def test_modal_reset_on_open(): + """Test that modal resets form when opened""" + with open('dashboard/habits.html', 'r') as f: + html = f.read() + + # Check showAddHabitModal function + assert 'function showAddHabitModal()' in html, "showAddHabitModal function should exist" + + # Check that form is reset when modal opens + modal_function = html[html.find('function showAddHabitModal()'):html.find('function showAddHabitModal()') + 500] + assert "nameInput.value = ''" in modal_function or 'nameInput.value = ""' in modal_function, \ + "Should clear name input when opening modal" + + print("✓ Modal resets form when opened") + +def test_enter_key_submission(): + """Test that Enter key can submit the form""" + with open('dashboard/habits.html', 'r') as f: + html = f.read() + + # Check for Enter key handler + assert "addEventListener('keypress'" in html, "Should listen for keypress events" + assert "e.key === 'Enter'" in html, "Should check for Enter key" + assert "!createBtn.disabled" in html, "Should check if button is enabled before submitting" + assert "createHabit()" in html, "Should call createHabit on Enter" + + print("✓ Enter key submits form when button is enabled") + +def test_all_acceptance_criteria(): + """Summary test - verify all acceptance criteria are met""" + with open('dashboard/habits.html', 'r') as f: + html = f.read() + + checks = [ + ("'/api/habits'" in html and "method: 'POST'" in html and "body: JSON.stringify({ name, frequency })" in html, + "1. Form submit calls POST /api/habits with name and frequency"), + + ("createBtn.disabled = true" in html and "Se creează" in html, + "2. Shows loading state on submit (button disabled)"), + + ("hideHabitModal()" in html and "loadHabits()" in html and "response.ok" in html, + "3. On success: modal closes, list refreshes, new habit appears"), + + ("catch (error)" in html and "createBtn.disabled = false" in html, + "4. On error: shows error message, modal stays open"), + + ("nameInput.value = ''" in html or 'nameInput.value = ""' in html, + "5. Input field cleared after successful creation"), + + (True, "6. Tests for form submission pass (this test!)") + ] + + all_pass = True + for condition, description in checks: + status = "✓" if condition else "✗" + print(f" {status} {description}") + if not condition: + all_pass = False + + assert all_pass, "Not all acceptance criteria are met" + print("\n✓ All acceptance criteria verified!") + +if __name__ == '__main__': + try: + test_form_submit_api_call() + test_loading_state_on_submit() + test_success_behavior() + test_error_behavior() + test_input_cleared_after_success() + test_form_validation_still_works() + test_modal_reset_on_open() + test_enter_key_submission() + test_all_acceptance_criteria() + + print("\n✅ All Story 11.0 tests passed!") + sys.exit(0) + except AssertionError as e: + print(f"\n❌ Test failed: {e}", file=sys.stderr) + sys.exit(1) + except Exception as e: + print(f"\n❌ Unexpected error: {e}", file=sys.stderr) + sys.exit(1) diff --git a/dashboard/test_habits_get_enhanced.py b/dashboard/test_habits_get_enhanced.py new file mode 100644 index 0000000..53e412a --- /dev/null +++ b/dashboard/test_habits_get_enhanced.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python3 +""" +Tests for enhanced GET /api/habits endpoint with streak and checkedToday fields. +""" + +import json +import sys +import urllib.request +from pathlib import Path +from datetime import datetime, timedelta + +BASE_URL = 'http://localhost:8088' +KANBAN_DIR = Path(__file__).parent + + +def test_habits_get_includes_streak_field(): + """Test that each habit includes a 'streak' field.""" + # Create test habit with completions + today = datetime.now().date() + yesterday = today - timedelta(days=1) + two_days_ago = today - timedelta(days=2) + + test_data = { + 'habits': [ + { + 'id': 'habit-test1', + 'name': 'Test Habit', + 'frequency': 'daily', + 'createdAt': '2026-02-01T10:00:00Z', + 'completions': [ + two_days_ago.isoformat(), + yesterday.isoformat(), + today.isoformat() + ] + } + ], + 'lastUpdated': datetime.now().isoformat() + } + + habits_file = KANBAN_DIR / 'habits.json' + habits_file.write_text(json.dumps(test_data, indent=2), encoding='utf-8') + + # Test GET + req = urllib.request.Request(f'{BASE_URL}/api/habits') + with urllib.request.urlopen(req) as response: + result = json.loads(response.read().decode('utf-8')) + + assert 'habits' in result, "Response should contain habits array" + assert len(result['habits']) == 1, "Should have one habit" + habit = result['habits'][0] + assert 'streak' in habit, "Habit should include 'streak' field" + assert isinstance(habit['streak'], int), "Streak should be an integer" + assert habit['streak'] == 3, f"Expected streak of 3, got {habit['streak']}" + + print("✓ Each habit includes 'streak' field") + + +def test_habits_get_includes_checked_today_field(): + """Test that each habit includes a 'checkedToday' field.""" + today = datetime.now().date().isoformat() + + test_data = { + 'habits': [ + { + 'id': 'habit-test1', + 'name': 'Checked Today', + 'frequency': 'daily', + 'createdAt': '2026-02-01T10:00:00Z', + 'completions': [today] + }, + { + 'id': 'habit-test2', + 'name': 'Not Checked Today', + 'frequency': 'daily', + 'createdAt': '2026-02-01T10:00:00Z', + 'completions': ['2026-02-01'] + } + ], + 'lastUpdated': datetime.now().isoformat() + } + + habits_file = KANBAN_DIR / 'habits.json' + habits_file.write_text(json.dumps(test_data, indent=2), encoding='utf-8') + + # Test GET + req = urllib.request.Request(f'{BASE_URL}/api/habits') + with urllib.request.urlopen(req) as response: + result = json.loads(response.read().decode('utf-8')) + + assert len(result['habits']) == 2, "Should have two habits" + + habit1 = result['habits'][0] + assert 'checkedToday' in habit1, "Habit should include 'checkedToday' field" + assert isinstance(habit1['checkedToday'], bool), "checkedToday should be boolean" + assert habit1['checkedToday'] is True, "Habit checked today should have checkedToday=True" + + habit2 = result['habits'][1] + assert 'checkedToday' in habit2, "Habit should include 'checkedToday' field" + assert habit2['checkedToday'] is False, "Habit not checked today should have checkedToday=False" + + print("✓ Each habit includes 'checkedToday' boolean field") + + +def test_habits_get_calculates_streak_correctly(): + """Test that streak is calculated using the streak utility function.""" + today = datetime.now().date() + yesterday = today - timedelta(days=1) + two_days_ago = today - timedelta(days=2) + three_days_ago = today - timedelta(days=3) + four_days_ago = today - timedelta(days=4) + + test_data = { + 'habits': [ + { + 'id': 'habit-daily', + 'name': 'Daily Habit', + 'frequency': 'daily', + 'createdAt': '2026-02-01T10:00:00Z', + 'completions': [ + four_days_ago.isoformat(), + three_days_ago.isoformat(), + two_days_ago.isoformat(), + yesterday.isoformat(), + today.isoformat() + ] + }, + { + 'id': 'habit-broken', + 'name': 'Broken Streak', + 'frequency': 'daily', + 'createdAt': '2026-02-01T10:00:00Z', + 'completions': [ + four_days_ago.isoformat(), + three_days_ago.isoformat() + # Missing two_days_ago - streak broken + ] + }, + { + 'id': 'habit-weekly', + 'name': 'Weekly Habit', + 'frequency': 'weekly', + 'createdAt': '2026-02-01T10:00:00Z', + 'completions': [ + today.isoformat(), + (today - timedelta(days=7)).isoformat(), + (today - timedelta(days=14)).isoformat() + ] + } + ], + 'lastUpdated': datetime.now().isoformat() + } + + habits_file = KANBAN_DIR / 'habits.json' + habits_file.write_text(json.dumps(test_data, indent=2), encoding='utf-8') + + # Test GET + req = urllib.request.Request(f'{BASE_URL}/api/habits') + with urllib.request.urlopen(req) as response: + result = json.loads(response.read().decode('utf-8')) + + assert len(result['habits']) == 3, "Should have three habits" + + daily_habit = result['habits'][0] + assert daily_habit['streak'] == 5, f"Expected daily streak of 5, got {daily_habit['streak']}" + + broken_habit = result['habits'][1] + assert broken_habit['streak'] == 0, f"Expected broken streak of 0, got {broken_habit['streak']}" + + weekly_habit = result['habits'][2] + assert weekly_habit['streak'] == 3, f"Expected weekly streak of 3, got {weekly_habit['streak']}" + + print("✓ Streak is calculated correctly using utility function") + + +def test_habits_get_empty_habits_array(): + """Test GET with empty habits array.""" + test_data = { + 'habits': [], + 'lastUpdated': datetime.now().isoformat() + } + + habits_file = KANBAN_DIR / 'habits.json' + habits_file.write_text(json.dumps(test_data, indent=2), encoding='utf-8') + + # Test GET + req = urllib.request.Request(f'{BASE_URL}/api/habits') + with urllib.request.urlopen(req) as response: + result = json.loads(response.read().decode('utf-8')) + + assert result['habits'] == [], "Should return empty array" + assert 'lastUpdated' in result, "Should include lastUpdated" + + print("✓ Empty habits array handled correctly") + + +def test_habits_get_preserves_original_fields(): + """Test that all original habit fields are preserved.""" + today = datetime.now().date().isoformat() + + test_data = { + 'habits': [ + { + 'id': 'habit-test1', + 'name': 'Test Habit', + 'frequency': 'daily', + 'createdAt': '2026-02-01T10:00:00Z', + 'completions': [today] + } + ], + 'lastUpdated': '2026-02-10T10:00:00Z' + } + + habits_file = KANBAN_DIR / 'habits.json' + habits_file.write_text(json.dumps(test_data, indent=2), encoding='utf-8') + + # Test GET + req = urllib.request.Request(f'{BASE_URL}/api/habits') + with urllib.request.urlopen(req) as response: + result = json.loads(response.read().decode('utf-8')) + + habit = result['habits'][0] + assert habit['id'] == 'habit-test1', "Original id should be preserved" + assert habit['name'] == 'Test Habit', "Original name should be preserved" + assert habit['frequency'] == 'daily', "Original frequency should be preserved" + assert habit['createdAt'] == '2026-02-01T10:00:00Z', "Original createdAt should be preserved" + assert habit['completions'] == [today], "Original completions should be preserved" + assert 'streak' in habit, "Should add streak field" + assert 'checkedToday' in habit, "Should add checkedToday field" + + print("✓ All original habit fields are preserved") + + +if __name__ == '__main__': + try: + print("\n=== Testing Enhanced GET /api/habits ===\n") + + test_habits_get_includes_streak_field() + test_habits_get_includes_checked_today_field() + test_habits_get_calculates_streak_correctly() + test_habits_get_empty_habits_array() + test_habits_get_preserves_original_fields() + + print("\n=== All Enhanced GET Tests Passed ✓ ===\n") + sys.exit(0) + except AssertionError as e: + print(f"\n❌ Test failed: {e}\n") + sys.exit(1) + except Exception as e: + print(f"\n❌ Error: {e}\n") + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/dashboard/test_habits_html.py b/dashboard/test_habits_html.py new file mode 100644 index 0000000..e56deca --- /dev/null +++ b/dashboard/test_habits_html.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +""" +Test suite for habits.html page structure + +Tests: +1. File exists +2. Valid HTML5 structure +3. Uses common.css and swipe-nav.js +4. Has navigation bar matching dashboard style +5. Page title 'Habit Tracker' in header +6. Empty state message 'Nicio obișnuință încă. Creează prima!' +7. Add habit button with '+' icon (lucide) +""" + +import os +import re +from html.parser import HTMLParser + +# Path to habits.html +HABITS_HTML_PATH = 'dashboard/habits.html' + +class HTMLStructureParser(HTMLParser): + """Parser to extract specific elements from HTML""" + def __init__(self): + super().__init__() + self.title_text = None + self.css_files = [] + self.js_files = [] + self.nav_items = [] + self.page_title = None + self.empty_state_message = None + self.has_add_button = False + self.has_lucide_plus = False + self.in_title = False + self.in_page_title = False + self.in_empty_message = False + self.in_button = False + self.current_class = None + + def handle_starttag(self, tag, attrs): + attrs_dict = dict(attrs) + + # Track CSS and JS files + if tag == 'link' and attrs_dict.get('rel') == 'stylesheet': + self.css_files.append(attrs_dict.get('href', '')) + if tag == 'script' and 'src' in attrs_dict: + self.js_files.append(attrs_dict.get('src')) + + # Track title tag + if tag == 'title': + self.in_title = True + + # Track page title (h1 with class page-title) + if tag == 'h1' and 'page-title' in attrs_dict.get('class', ''): + self.in_page_title = True + + # Track nav items + if tag == 'a' and 'nav-item' in attrs_dict.get('class', ''): + href = attrs_dict.get('href', '') + classes = attrs_dict.get('class', '') + self.nav_items.append({'href': href, 'classes': classes}) + + # Track empty state message + if 'empty-state-message' in attrs_dict.get('class', ''): + self.in_empty_message = True + + # Track add habit button + if tag == 'button' and 'add-habit-btn' in attrs_dict.get('class', ''): + self.has_add_button = True + self.in_button = True + + # Track lucide plus icon in button context + if self.in_button and tag == 'i': + lucide_attr = attrs_dict.get('data-lucide', '') + if 'plus' in lucide_attr: + self.has_lucide_plus = True + + def handle_endtag(self, tag): + if tag == 'title': + self.in_title = False + if tag == 'h1': + self.in_page_title = False + if tag == 'p': + self.in_empty_message = False + if tag == 'button': + self.in_button = False + + def handle_data(self, data): + if self.in_title: + self.title_text = data.strip() + if self.in_page_title: + self.page_title = data.strip() + if self.in_empty_message: + self.empty_state_message = data.strip() + +def test_file_exists(): + """Test 1: File exists""" + assert os.path.exists(HABITS_HTML_PATH), f"File {HABITS_HTML_PATH} not found" + print("✓ Test 1: File exists") + +def test_valid_html5(): + """Test 2: Valid HTML5 structure""" + with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f: + content = f.read() + + # Check DOCTYPE + assert content.strip().startswith(''), "Missing or incorrect DOCTYPE" + + # Check required tags + required_tags = ['', '', '', ''] + for tag in required_tags: + assert tag in content, f"Missing required tag: {tag}" + + # Check html lang attribute + assert 'lang="ro"' in content or "lang='ro'" in content, "Missing lang='ro' attribute on html tag" + + print("✓ Test 2: Valid HTML5 structure") + +def test_uses_common_css_and_swipe_nav(): + """Test 3: Uses common.css and swipe-nav.js""" + with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f: + content = f.read() + + parser = HTMLStructureParser() + parser.feed(content) + + # Check for common.css + assert any('common.css' in css for css in parser.css_files), "Missing common.css" + + # Check for swipe-nav.js + assert any('swipe-nav.js' in js for js in parser.js_files), "Missing swipe-nav.js" + + print("✓ Test 3: Uses common.css and swipe-nav.js") + +def test_navigation_bar(): + """Test 4: Has navigation bar matching dashboard style""" + with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f: + content = f.read() + + parser = HTMLStructureParser() + parser.feed(content) + + # Check that we have nav items + assert len(parser.nav_items) >= 4, f"Expected at least 4 nav items, found {len(parser.nav_items)}" + + # Check for Dashboard nav item + dashboard_items = [item for item in parser.nav_items if 'index.html' in item['href']] + assert len(dashboard_items) > 0, "Missing Dashboard nav item" + + # Check for habits nav item with active class + habits_items = [item for item in parser.nav_items if 'habits.html' in item['href']] + assert len(habits_items) > 0, "Missing Habits nav item" + assert any('active' in item['classes'] for item in habits_items), "Habits nav item should have 'active' class" + + # Check for header element with class 'header' + assert '
' in content, "Missing header element with class 'header'" + + print("✓ Test 4: Has navigation bar matching dashboard style") + +def test_page_title(): + """Test 5: Page title 'Habit Tracker' in header""" + with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f: + content = f.read() + + parser = HTMLStructureParser() + parser.feed(content) + + # Check tag + assert parser.title_text is not None, "Missing <title> tag" + assert 'Habit Tracker' in parser.title_text, f"Expected 'Habit Tracker' in title, got: {parser.title_text}" + + # Check page header (h1) + assert parser.page_title is not None, "Missing page title (h1.page-title)" + assert 'Habit Tracker' in parser.page_title, f"Expected 'Habit Tracker' in page title, got: {parser.page_title}" + + print("✓ Test 5: Page title 'Habit Tracker' in header") + +def test_empty_state_message(): + """Test 6: Empty state message 'Nicio obișnuință încă. Creează prima!'""" + with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f: + content = f.read() + + parser = HTMLStructureParser() + parser.feed(content) + + # Check empty state message + assert parser.empty_state_message is not None, "Missing empty state message" + expected_message = "Nicio obișnuință încă. Creează prima!" + assert parser.empty_state_message == expected_message, \ + f"Expected '{expected_message}', got: '{parser.empty_state_message}'" + + # Check for empty-state class + assert 'class="empty-state"' in content, "Missing empty-state element" + + print("✓ Test 6: Empty state message present") + +def test_add_habit_button(): + """Test 7: Add habit button with '+' icon (lucide)""" + with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f: + content = f.read() + + parser = HTMLStructureParser() + parser.feed(content) + + # Check for add habit button + assert parser.has_add_button, "Missing add habit button with class 'add-habit-btn'" + + # Check for lucide plus icon + assert parser.has_lucide_plus, "Missing lucide 'plus' icon in add habit button" + + # Check button text content + assert 'Adaugă obișnuință' in content, "Missing button text 'Adaugă obișnuință'" + + print("✓ Test 7: Add habit button with '+' icon (lucide)") + +def run_all_tests(): + """Run all tests""" + print("Running habits.html structure tests...\n") + + try: + test_file_exists() + test_valid_html5() + test_uses_common_css_and_swipe_nav() + test_navigation_bar() + test_page_title() + test_empty_state_message() + test_add_habit_button() + + print("\n✅ All tests passed!") + return True + except AssertionError as e: + print(f"\n❌ Test failed: {e}") + return False + except Exception as e: + print(f"\n❌ Unexpected error: {e}") + return False + +if __name__ == '__main__': + success = run_all_tests() + exit(0 if success else 1) diff --git a/dashboard/test_habits_integration.py b/dashboard/test_habits_integration.py new file mode 100644 index 0000000..aa9fdc5 --- /dev/null +++ b/dashboard/test_habits_integration.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 +""" +Integration test for complete habit lifecycle. +Tests: create, check multiple days, view streak, delete +""" + +import json +import os +import sys +import time +from datetime import datetime, timedelta +from http.server import HTTPServer +import threading +import urllib.request +import urllib.error + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from api import TaskBoardHandler + +# Test configuration +PORT = 8765 +BASE_URL = f"http://localhost:{PORT}" +HABITS_FILE = os.path.join(os.path.dirname(__file__), 'habits.json') + +# Global server instance +server = None +server_thread = None + + +def setup_server(): + """Start test server in background thread""" + global server, server_thread + server = HTTPServer(('localhost', PORT), TaskBoardHandler) + server_thread = threading.Thread(target=server.serve_forever, daemon=True) + server_thread.start() + time.sleep(0.5) # Give server time to start + + +def teardown_server(): + """Stop test server""" + global server + if server: + server.shutdown() + server.server_close() + + +def reset_habits_file(): + """Reset habits.json to empty state""" + data = { + "lastUpdated": datetime.utcnow().isoformat() + 'Z', + "habits": [] + } + with open(HABITS_FILE, 'w') as f: + json.dump(data, f, indent=2) + + +def make_request(method, path, body=None): + """Make HTTP request to test server""" + url = BASE_URL + path + headers = {'Content-Type': 'application/json'} if body else {} + data = json.dumps(body).encode('utf-8') if body else None + + req = urllib.request.Request(url, data=data, headers=headers, method=method) + + try: + with urllib.request.urlopen(req) as response: + response_data = response.read().decode('utf-8') + return response.status, json.loads(response_data) if response_data else None + except urllib.error.HTTPError as e: + response_data = e.read().decode('utf-8') + return e.code, json.loads(response_data) if response_data else None + + +def get_today(): + """Get today's date in YYYY-MM-DD format""" + return datetime.utcnow().strftime('%Y-%m-%d') + + +def get_yesterday(): + """Get yesterday's date in YYYY-MM-DD format""" + return (datetime.utcnow() - timedelta(days=1)).strftime('%Y-%m-%d') + + +def test_complete_habit_lifecycle(): + """ + Integration test: Complete habit flow from creation to deletion + """ + print("\n=== Integration Test: Complete Habit Lifecycle ===\n") + + # Step 1: Create daily habit 'Bazin' + print("Step 1: Creating daily habit 'Bazin'...") + status, response = make_request('POST', '/api/habits', { + 'name': 'Bazin', + 'frequency': 'daily' + }) + + assert status == 201, f"Expected 201, got {status}" + assert response['name'] == 'Bazin', "Habit name mismatch" + assert response['frequency'] == 'daily', "Frequency mismatch" + assert 'id' in response, "Missing habit ID" + habit_id = response['id'] + print(f"✓ Created habit: {habit_id}") + + # Step 2: Check it today (streak should be 1) + print("\nStep 2: Checking habit today (expecting streak = 1)...") + status, response = make_request('POST', f'/api/habits/{habit_id}/check', {}) + + assert status == 200, f"Expected 200, got {status}" + assert response['streak'] == 1, f"Expected streak=1, got {response['streak']}" + assert get_today() in response['completions'], "Today's date not in completions" + print(f"✓ Checked today, streak = {response['streak']}") + + # Step 3: Simulate checking yesterday (manually add to completions) + print("\nStep 3: Simulating yesterday's check (expecting streak = 2)...") + # Read current habits.json + with open(HABITS_FILE, 'r') as f: + data = json.load(f) + + # Find the habit and add yesterday's date + for habit in data['habits']: + if habit['id'] == habit_id: + habit['completions'].append(get_yesterday()) + habit['completions'].sort() # Keep chronological order + break + + # Write back to file + data['lastUpdated'] = datetime.utcnow().isoformat() + 'Z' + with open(HABITS_FILE, 'w') as f: + json.dump(data, f, indent=2) + + # Verify streak calculation by fetching habits + status, response = make_request('GET', '/api/habits', None) + assert status == 200, f"Expected 200, got {status}" + + habit = next((h for h in response['habits'] if h['id'] == habit_id), None) + assert habit is not None, "Habit not found in response" + assert habit['streak'] == 2, f"Expected streak=2 after adding yesterday, got {habit['streak']}" + print(f"✓ Added yesterday's completion, streak = {habit['streak']}") + + # Step 4: Verify streak calculation is correct + print("\nStep 4: Verifying streak calculation...") + assert len(habit['completions']) == 2, f"Expected 2 completions, got {len(habit['completions'])}" + assert get_yesterday() in habit['completions'], "Yesterday not in completions" + assert get_today() in habit['completions'], "Today not in completions" + assert habit['checkedToday'] == True, "checkedToday should be True" + print("✓ Streak calculation verified: 2 consecutive days") + + # Step 5: Delete habit successfully + print("\nStep 5: Deleting habit...") + status, response = make_request('DELETE', f'/api/habits/{habit_id}', None) + + assert status == 200, f"Expected 200, got {status}" + assert 'message' in response, "Missing success message" + print(f"✓ Deleted habit: {response['message']}") + + # Verify habit is gone + status, response = make_request('GET', '/api/habits', None) + assert status == 200, f"Expected 200, got {status}" + + habit = next((h for h in response['habits'] if h['id'] == habit_id), None) + assert habit is None, "Habit still exists after deletion" + print("✓ Verified habit no longer exists") + + print("\n=== All Integration Tests Passed ✓ ===\n") + + +def test_broken_streak(): + """ + Additional test: Verify broken streak returns 0 (with gap) + """ + print("\n=== Additional Test: Broken Streak (with gap) ===\n") + + # Create habit + status, response = make_request('POST', '/api/habits', { + 'name': 'Sală', + 'frequency': 'daily' + }) + assert status == 201 + habit_id = response['id'] + print(f"✓ Created habit: {habit_id}") + + # Add check from 3 days ago (creating a gap) + three_days_ago = (datetime.utcnow() - timedelta(days=3)).strftime('%Y-%m-%d') + with open(HABITS_FILE, 'r') as f: + data = json.load(f) + + for habit in data['habits']: + if habit['id'] == habit_id: + habit['completions'].append(three_days_ago) + break + + data['lastUpdated'] = datetime.utcnow().isoformat() + 'Z' + with open(HABITS_FILE, 'w') as f: + json.dump(data, f, indent=2) + + # Verify streak is 0 (>1 day gap means broken streak) + status, response = make_request('GET', '/api/habits', None) + habit = next((h for h in response['habits'] if h['id'] == habit_id), None) + assert habit['streak'] == 0, f"Expected streak=0 for broken streak, got {habit['streak']}" + print(f"✓ Broken streak (>1 day gap) correctly returns 0") + + # Cleanup + make_request('DELETE', f'/api/habits/{habit_id}', None) + print("✓ Cleanup complete") + + +def test_weekly_habit_streak(): + """ + Additional test: Weekly habit streak calculation + """ + print("\n=== Additional Test: Weekly Habit Streak ===\n") + + # Create weekly habit + status, response = make_request('POST', '/api/habits', { + 'name': 'Yoga', + 'frequency': 'weekly' + }) + assert status == 201 + habit_id = response['id'] + print(f"✓ Created weekly habit: {habit_id}") + + # Check today (streak = 1 week) + status, response = make_request('POST', f'/api/habits/{habit_id}/check', {}) + assert status == 200 + assert response['streak'] == 1, f"Expected streak=1 week, got {response['streak']}" + print(f"✓ Checked today, weekly streak = {response['streak']}") + + # Add check from 8 days ago (last week) + eight_days_ago = (datetime.utcnow() - timedelta(days=8)).strftime('%Y-%m-%d') + with open(HABITS_FILE, 'r') as f: + data = json.load(f) + + for habit in data['habits']: + if habit['id'] == habit_id: + habit['completions'].append(eight_days_ago) + habit['completions'].sort() + break + + data['lastUpdated'] = datetime.utcnow().isoformat() + 'Z' + with open(HABITS_FILE, 'w') as f: + json.dump(data, f, indent=2) + + # Verify streak is 2 weeks + status, response = make_request('GET', '/api/habits', None) + habit = next((h for h in response['habits'] if h['id'] == habit_id), None) + assert habit['streak'] == 2, f"Expected streak=2 weeks, got {habit['streak']}" + print(f"✓ Weekly streak calculation correct: {habit['streak']} weeks") + + # Cleanup + make_request('DELETE', f'/api/habits/{habit_id}', None) + print("✓ Cleanup complete") + + +if __name__ == '__main__': + try: + # Setup + reset_habits_file() + setup_server() + + # Run tests + test_complete_habit_lifecycle() + test_broken_streak() + test_weekly_habit_streak() + + print("\n🎉 All Integration Tests Passed! 🎉\n") + + except AssertionError as e: + print(f"\n❌ Test Failed: {e}\n") + sys.exit(1) + except Exception as e: + print(f"\n❌ Error: {e}\n") + import traceback + traceback.print_exc() + sys.exit(1) + finally: + # Cleanup + teardown_server() + reset_habits_file() diff --git a/dashboard/test_habits_mobile.py b/dashboard/test_habits_mobile.py new file mode 100644 index 0000000..ab78333 --- /dev/null +++ b/dashboard/test_habits_mobile.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +""" +Test suite for Story 14.0: Frontend - Responsive mobile design +Tests mobile responsiveness for habit tracker +""" + +import re +from pathlib import Path + +def test_file_exists(): + """AC: Test file exists""" + path = Path(__file__).parent / 'habits.html' + assert path.exists(), "habits.html should exist" + print("✓ File exists") + +def test_modal_fullscreen_mobile(): + """AC1: Modal is full-screen on mobile (< 768px)""" + path = Path(__file__).parent / 'habits.html' + content = path.read_text() + + # Check for mobile media query + assert '@media (max-width: 768px)' in content, "Should have mobile media query" + + # Find the mobile section by locating the media query and extracting content until the closing brace + media_start = content.find('@media (max-width: 768px)') + assert media_start != -1, "Should have mobile media query" + + # Extract a reasonable chunk after the media query (enough to include all mobile styles) + mobile_chunk = content[media_start:media_start + 3000] + + # Check for modal full-screen styles within mobile section + assert '.modal {' in mobile_chunk or '.modal{' in mobile_chunk, "Mobile section should include .modal styles" + assert 'width: 100%' in mobile_chunk, "Modal should have 100% width on mobile" + assert 'height: 100vh' in mobile_chunk, "Modal should have 100vh height on mobile" + assert 'max-height: 100vh' in mobile_chunk, "Modal should have 100vh max-height on mobile" + assert 'border-radius: 0' in mobile_chunk, "Modal should have no border-radius on mobile" + + print("✓ Modal is full-screen on mobile") + +def test_habit_cards_stack_vertically(): + """AC2: Habit cards stack vertically on mobile""" + path = Path(__file__).parent / 'habits.html' + content = path.read_text() + + # Check for habits-list with flex-direction: column + assert '.habits-list' in content, "Should have .habits-list class" + + # Extract habits-list styles + habits_list_match = re.search(r'\.habits-list\s*\{([^}]+)\}', content) + assert habits_list_match, "Should have .habits-list styles" + + habits_list_styles = habits_list_match.group(1) + assert 'display: flex' in habits_list_styles or 'display:flex' in habits_list_styles, "habits-list should use flexbox" + assert 'flex-direction: column' in habits_list_styles or 'flex-direction:column' in habits_list_styles, "habits-list should stack vertically" + + # Find the mobile section + media_start = content.find('@media (max-width: 768px)') + mobile_chunk = content[media_start:media_start + 3000] + + # Verify cards are full width on mobile + assert '.habit-card {' in mobile_chunk or '.habit-card{' in mobile_chunk, "Should have .habit-card mobile styles" + assert 'width: 100%' in mobile_chunk, "Should have 100% width on mobile" + + print("✓ Habit cards stack vertically on mobile") + +def test_touch_targets_44px(): + """AC3: Touch targets >= 44x44px for checkbox""" + path = Path(__file__).parent / 'habits.html' + content = path.read_text() + + # Find the mobile section + media_start = content.find('@media (max-width: 768px)') + assert media_start != -1, "Should have mobile media query" + + mobile_chunk = content[media_start:media_start + 3000] + + # Check for checkbox sizing in mobile section + assert '.habit-checkbox {' in mobile_chunk or '.habit-checkbox{' in mobile_chunk, "Should have .habit-checkbox styles in mobile section" + + # Extract width and height values from the mobile checkbox section + checkbox_section_start = mobile_chunk.find('.habit-checkbox') + checkbox_section = mobile_chunk[checkbox_section_start:checkbox_section_start + 300] + + width_match = re.search(r'width:\s*(\d+)px', checkbox_section) + height_match = re.search(r'height:\s*(\d+)px', checkbox_section) + + assert width_match, "Checkbox should have width specified" + assert height_match, "Checkbox should have height specified" + + width = int(width_match.group(1)) + height = int(height_match.group(1)) + + # Verify touch target size (44x44px minimum for accessibility) + assert width >= 44, f"Checkbox width should be >= 44px (got {width}px)" + assert height >= 44, f"Checkbox height should be >= 44px (got {height}px)" + + # Check for other touch targets (buttons) + assert 'min-height: 44px' in mobile_chunk, "Buttons should have min-height of 44px" + + print("✓ Touch targets are >= 44x44px") + +def test_mobile_optimized_keyboards(): + """AC4: Form inputs use mobile-optimized keyboards""" + path = Path(__file__).parent / 'habits.html' + content = path.read_text() + + # Check for input field + assert 'id="habitName"' in content, "Should have habitName input field" + + # Extract input element + input_match = re.search(r'<input[^>]+id="habitName"[^>]*>', content) + assert input_match, "Should have habitName input element" + + input_element = input_match.group(0) + + # Check for mobile-optimized attributes + # autocapitalize="words" for proper names + # autocomplete="off" to prevent autofill issues + assert 'autocapitalize="words"' in input_element or 'autocapitalize=\'words\'' in input_element, \ + "Input should have autocapitalize='words' for mobile optimization" + assert 'autocomplete="off"' in input_element or 'autocomplete=\'off\'' in input_element, \ + "Input should have autocomplete='off' to prevent autofill" + + # Verify type="text" is present (appropriate for habit names) + assert 'type="text"' in input_element, "Input should have type='text'" + + print("✓ Form inputs use mobile-optimized keyboards") + +def test_swipe_navigation(): + """AC5: Swipe navigation works (via swipe-nav.js)""" + path = Path(__file__).parent / 'habits.html' + content = path.read_text() + + # Check for swipe-nav.js inclusion + assert 'swipe-nav.js' in content, "Should include swipe-nav.js for mobile swipe navigation" + + # Verify script tag + assert '<script src="/echo/swipe-nav.js"></script>' in content, \ + "Should have proper script tag for swipe-nav.js" + + # Check for viewport meta tag (required for proper mobile rendering) + assert '<meta name="viewport"' in content, "Should have viewport meta tag" + assert 'width=device-width' in content, "Viewport should include width=device-width" + assert 'initial-scale=1.0' in content, "Viewport should include initial-scale=1.0" + + print("✓ Swipe navigation is enabled") + +def test_mobile_button_sizing(): + """Additional test: Verify all interactive elements have proper mobile sizing""" + path = Path(__file__).parent / 'habits.html' + content = path.read_text() + + # Find the mobile section + media_start = content.find('@media (max-width: 768px)') + mobile_chunk = content[media_start:media_start + 3000] + + # Check for add-habit-btn sizing + assert '.add-habit-btn {' in mobile_chunk or '.add-habit-btn{' in mobile_chunk, "Should have .add-habit-btn mobile styles" + assert 'min-height: 44px' in mobile_chunk, "Add habit button should have min-height 44px" + + # Check for generic .btn sizing + assert '.btn {' in mobile_chunk or '.btn{' in mobile_chunk, "Should have .btn mobile styles" + + # Check for radio labels sizing + assert '.radio-label {' in mobile_chunk or '.radio-label{' in mobile_chunk, "Should have .radio-label mobile styles" + + print("✓ All buttons and interactive elements have proper mobile sizing") + +def test_responsive_layout_structure(): + """Additional test: Verify responsive layout structure""" + path = Path(__file__).parent / 'habits.html' + content = path.read_text() + + # Find the mobile section + media_start = content.find('@media (max-width: 768px)') + mobile_chunk = content[media_start:media_start + 3000] + + # Verify main padding is adjusted for mobile + assert '.main {' in mobile_chunk or '.main{' in mobile_chunk, "Should have .main mobile styles" + main_section_start = mobile_chunk.find('.main') + main_section = mobile_chunk[main_section_start:main_section_start + 200] + assert 'padding' in main_section, "Main should have adjusted padding on mobile" + + print("✓ Responsive layout structure is correct") + +def test_all_acceptance_criteria(): + """Summary test: Verify all 6 acceptance criteria are met""" + path = Path(__file__).parent / 'habits.html' + content = path.read_text() + + # Find mobile section + media_start = content.find('@media (max-width: 768px)') + mobile_chunk = content[media_start:media_start + 3000] + + # AC1: Modal is full-screen on mobile + assert '.modal {' in mobile_chunk or '.modal{' in mobile_chunk, "AC1: Modal styles in mobile section" + assert 'width: 100%' in mobile_chunk, "AC1: Modal full-screen width" + assert 'height: 100vh' in mobile_chunk, "AC1: Modal full-screen height" + + # AC2: Habit cards stack vertically + habits_list_match = re.search(r'\.habits-list\s*\{([^}]+)\}', content) + assert habits_list_match and 'flex-direction: column' in habits_list_match.group(1), "AC2: Cards stack vertically" + + # AC3: Touch targets >= 44x44px + checkbox_section_start = mobile_chunk.find('.habit-checkbox') + checkbox_section = mobile_chunk[checkbox_section_start:checkbox_section_start + 300] + assert 'width: 44px' in checkbox_section, "AC3: Touch targets 44px width" + assert 'height: 44px' in checkbox_section, "AC3: Touch targets 44px height" + + # AC4: Mobile-optimized keyboards + input_match = re.search(r'<input[^>]+id="habitName"[^>]*>', content) + assert input_match and 'autocapitalize="words"' in input_match.group(0), "AC4: Mobile keyboards" + + # AC5: Swipe navigation + assert 'swipe-nav.js' in content, "AC5: Swipe navigation" + + # AC6: Tests pass (this test itself) + print("✓ All 6 acceptance criteria verified") + +def main(): + """Run all tests""" + tests = [ + test_file_exists, + test_modal_fullscreen_mobile, + test_habit_cards_stack_vertically, + test_touch_targets_44px, + test_mobile_optimized_keyboards, + test_swipe_navigation, + test_mobile_button_sizing, + test_responsive_layout_structure, + test_all_acceptance_criteria + ] + + print("Running Story 14.0 mobile responsiveness tests...\n") + + failed = [] + for test in tests: + try: + test() + except AssertionError as e: + failed.append((test.__name__, str(e))) + print(f"✗ {test.__name__}: {e}") + + print(f"\n{'='*60}") + if failed: + print(f"FAILED: {len(failed)} test(s) failed") + for name, error in failed: + print(f" - {name}: {error}") + return False + else: + print(f"SUCCESS: All {len(tests)} tests passed! ✓") + return True + +if __name__ == '__main__': + import sys + sys.exit(0 if main() else 1) diff --git a/dashboard/test_habits_modal.py b/dashboard/test_habits_modal.py new file mode 100644 index 0000000..874cbd7 --- /dev/null +++ b/dashboard/test_habits_modal.py @@ -0,0 +1,342 @@ +#!/usr/bin/env python3 +""" +Tests for habit creation modal in habits.html +Validates modal structure, form elements, buttons, and styling +""" + +import os +import sys +from html.parser import HTMLParser + +HABITS_HTML_PATH = 'dashboard/habits.html' + +class ModalParser(HTMLParser): + """Parser to extract modal elements from HTML""" + def __init__(self): + super().__init__() + self.in_modal = False + self.in_modal_title = False + self.in_form_label = False + self.in_button = False + self.modal_title = None + self.form_labels = [] + self.name_input_attrs = None + self.radio_buttons = [] + self.buttons = [] + self.radio_labels = [] + self.in_radio_label = False + self.current_radio_label = None + self.modal_overlay_found = False + self.modal_div_found = False + self.toast_found = False + + def handle_starttag(self, tag, attrs): + attrs_dict = dict(attrs) + + # Check for modal overlay + if tag == 'div' and attrs_dict.get('id') == 'habitModal': + self.modal_overlay_found = True + if 'modal-overlay' in attrs_dict.get('class', ''): + self.in_modal = True + + # Check for modal div + if self.in_modal and tag == 'div' and 'modal' in attrs_dict.get('class', ''): + self.modal_div_found = True + + # Check for modal title + if self.in_modal and tag == 'h2' and 'modal-title' in attrs_dict.get('class', ''): + self.in_modal_title = True + + # Check for form labels + if self.in_modal and tag == 'label' and 'form-label' in attrs_dict.get('class', ''): + self.in_form_label = True + + # Check for name input + if self.in_modal and tag == 'input' and attrs_dict.get('id') == 'habitName': + self.name_input_attrs = attrs_dict + + # Check for radio buttons + if self.in_modal and tag == 'input' and attrs_dict.get('type') == 'radio': + self.radio_buttons.append(attrs_dict) + + # Check for radio labels + if self.in_modal and tag == 'label' and 'radio-label' in attrs_dict.get('class', ''): + self.in_radio_label = True + self.current_radio_label = attrs_dict.get('for', '') + + # Check for buttons + if self.in_modal and tag == 'button': + self.buttons.append(attrs_dict) + self.in_button = True + + # Check for toast + if tag == 'div' and attrs_dict.get('id') == 'toast': + self.toast_found = True + + def handle_endtag(self, tag): + if tag == 'h2': + self.in_modal_title = False + if tag == 'label': + self.in_form_label = False + self.in_radio_label = False + if tag == 'button': + self.in_button = False + if tag == 'div' and self.in_modal: + # Don't close modal state until we're sure we've left it + pass + + def handle_data(self, data): + if self.in_modal_title and not self.modal_title: + self.modal_title = data.strip() + if self.in_form_label: + self.form_labels.append(data.strip()) + if self.in_radio_label: + self.radio_labels.append({'for': self.current_radio_label, 'text': data.strip()}) + +def test_modal_structure(): + """Test modal HTML structure exists""" + with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f: + content = f.read() + + parser = ModalParser() + parser.feed(content) + + # Check modal overlay exists + assert parser.modal_overlay_found, "Modal overlay with id='habitModal' not found" + + # Check modal container exists + assert parser.modal_div_found, "Modal div with class='modal' not found" + + # Check modal title + assert parser.modal_title is not None, "Modal title not found" + assert 'nou' in parser.modal_title.lower(), f"Modal title should mention 'nou', got: {parser.modal_title}" + + print("✓ Modal structure exists") + +def test_name_input_field(): + """Test habit name input field exists and is required""" + with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f: + content = f.read() + + parser = ModalParser() + parser.feed(content) + + # Find name input + assert parser.name_input_attrs is not None, "Name input field with id='habitName' not found" + + # Check it's a text input + assert parser.name_input_attrs.get('type') == 'text', "Name input should be type='text'" + + # Check it has class 'input' + assert 'input' in parser.name_input_attrs.get('class', ''), "Name input should have class='input'" + + # Check it has placeholder + assert parser.name_input_attrs.get('placeholder'), "Name input should have placeholder" + + # Check label exists and mentions required (*) + found_required_label = any('*' in label for label in parser.form_labels) + assert found_required_label, "Should have a form label with * indicating required field" + + print("✓ Name input field exists with required indicator") + +def test_frequency_radio_buttons(): + """Test frequency radio buttons exist with daily and weekly options""" + with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f: + content = f.read() + + parser = ModalParser() + parser.feed(content) + + # Check we have radio buttons + assert len(parser.radio_buttons) >= 2, f"Should have at least 2 radio buttons, found {len(parser.radio_buttons)}" + + # Find daily radio button + daily_radio = next((r for r in parser.radio_buttons if r.get('value') == 'daily'), None) + assert daily_radio is not None, "Daily radio button with value='daily' not found" + assert daily_radio.get('name') == 'frequency', "Daily radio should have name='frequency'" + # Check for 'checked' attribute - it may be None or empty string when present + assert 'checked' in daily_radio, "Daily radio should be checked by default" + + # Find weekly radio button + weekly_radio = next((r for r in parser.radio_buttons if r.get('value') == 'weekly'), None) + assert weekly_radio is not None, "Weekly radio button with value='weekly' not found" + assert weekly_radio.get('name') == 'frequency', "Weekly radio should have name='frequency'" + + # Check labels exist with Romanian text + daily_label = next((l for l in parser.radio_labels if 'zilnic' in l['text'].lower()), None) + assert daily_label is not None, "Daily label with 'Zilnic' text not found" + + weekly_label = next((l for l in parser.radio_labels if 'săptămânal' in l['text'].lower()), None) + assert weekly_label is not None, "Weekly label with 'Săptămânal' text not found" + + print("✓ Frequency radio buttons exist with daily (default) and weekly options") + +def test_modal_buttons(): + """Test modal has Cancel and Create buttons""" + with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f: + content = f.read() + + parser = ModalParser() + parser.feed(content) + + # Check we have 2 buttons + assert len(parser.buttons) >= 2, f"Should have at least 2 buttons, found {len(parser.buttons)}" + + # Check Cancel button + cancel_btn = next((b for b in parser.buttons if 'btn-secondary' in b.get('class', '')), None) + assert cancel_btn is not None, "Cancel button with class='btn-secondary' not found" + assert 'hideHabitModal' in cancel_btn.get('onclick', ''), "Cancel should call hideHabitModal" + + # Check Create button + create_btn = next((b for b in parser.buttons if b.get('id') == 'habitCreateBtn'), None) + assert create_btn is not None, "Create button with id='habitCreateBtn' not found" + assert 'btn-primary' in create_btn.get('class', ''), "Create button should have class='btn-primary'" + assert 'createHabit' in create_btn.get('onclick', ''), "Create should call createHabit" + # Check for 'disabled' attribute - it may be None or empty string when present + assert 'disabled' in create_btn, "Create button should start disabled" + + # Check button text in content + assert 'anulează' in content.lower(), "Cancel button should say 'Anulează'" + assert 'creează' in content.lower(), "Create button should say 'Creează'" + + print("✓ Modal has Cancel and Create buttons with correct attributes") + +def test_add_button_triggers_modal(): + """Test that add habit button calls showAddHabitModal""" + with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f: + content = f.read() + + # Find add habit button + assert 'class="add-habit-btn"' in content, "Add habit button not found" + assert 'showAddHabitModal()' in content, "Add button should call showAddHabitModal()" + + print("✓ Add habit button calls showAddHabitModal()") + +def test_modal_styling(): + """Test modal uses dashboard modal styling patterns""" + with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f: + content = f.read() + + # Check key modal classes exist in CSS + required_styles = [ + '.modal-overlay', + '.modal-overlay.active', + '.modal {', + '.modal-title', + '.modal-actions', + '.form-group', + '.form-label', + '.radio-group', + ] + + for style in required_styles: + assert style in content, f"Modal style '{style}' not found" + + # Check modal uses CSS variables (dashboard pattern) + assert 'var(--bg-base)' in content, "Modal should use --bg-base" + assert 'var(--border)' in content, "Modal should use --border" + assert 'var(--accent)' in content, "Modal should use --accent" + + print("✓ Modal uses dashboard modal styling patterns") + +def test_javascript_functions(): + """Test JavaScript functions for modal interaction exist""" + with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f: + content = f.read() + + # Check essential functions exist + assert 'function showAddHabitModal()' in content, "showAddHabitModal function not found" + assert 'function hideHabitModal()' in content, "hideHabitModal function not found" + assert 'async function createHabit()' in content or 'function createHabit()' in content, "createHabit function not found" + + # Check form validation logic + assert "createBtn.disabled" in content, "Create button disable logic not found" + assert "nameInput.value.trim()" in content, "Name trim validation not found" + + # Check modal show/hide logic + assert "modal.classList.add('active')" in content, "Modal show logic not found" + assert "modal.classList.remove('active')" in content, "Modal hide logic not found" + + # Check API integration + assert "fetch('/api/habits'" in content, "API call to /api/habits not found" + assert "method: 'POST'" in content, "POST method not found" + + print("✓ JavaScript functions for modal interaction exist") + +def test_toast_notification(): + """Test toast notification element exists""" + with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f: + content = f.read() + + parser = ModalParser() + parser.feed(content) + + # Find toast element + assert parser.toast_found, "Toast notification element with id='toast' not found" + + # Check toast styles exist + assert '.toast' in content, "Toast styles not found" + assert '.toast.show' in content, "Toast show state styles not found" + + # Check showToast function exists + assert 'function showToast(' in content, "showToast function not found" + + print("✓ Toast notification element exists") + +def test_form_validation_event_listeners(): + """Test form validation with event listeners""" + with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f: + content = f.read() + + # Check for DOMContentLoaded event listener + assert "addEventListener('DOMContentLoaded'" in content or "DOMContentLoaded" in content, \ + "Should have DOMContentLoaded event listener" + + # Check for input event listener on name field + assert "addEventListener('input'" in content, "Should have input event listener for validation" + + # Check for Enter key handling + assert "addEventListener('keypress'" in content or "e.key === 'Enter'" in content, \ + "Should handle Enter key submission" + + print("✓ Form validation event listeners exist") + +def run_tests(): + """Run all tests""" + tests = [ + test_modal_structure, + test_name_input_field, + test_frequency_radio_buttons, + test_modal_buttons, + test_add_button_triggers_modal, + test_modal_styling, + test_javascript_functions, + test_toast_notification, + test_form_validation_event_listeners, + ] + + print("Running habit modal tests...\n") + + failed = [] + for test in tests: + try: + test() + except AssertionError as e: + print(f"✗ {test.__name__}: {e}") + failed.append(test.__name__) + except Exception as e: + print(f"✗ {test.__name__}: Unexpected error: {e}") + failed.append(test.__name__) + + print(f"\n{'='*50}") + if failed: + print(f"FAILED: {len(failed)} test(s) failed") + for name in failed: + print(f" - {name}") + sys.exit(1) + else: + print(f"SUCCESS: All {len(tests)} tests passed!") + sys.exit(0) + +if __name__ == '__main__': + run_tests() diff --git a/dashboard/test_habits_navigation.py b/dashboard/test_habits_navigation.py new file mode 100644 index 0000000..9fad70e --- /dev/null +++ b/dashboard/test_habits_navigation.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python3 +""" +Test Suite for Story 13.0: Frontend - Add to dashboard navigation +Tests that Habit Tracker link is added to main navigation properly. +""" + +import os +import re + + +def test_file_existence(): + """Test that both index.html and habits.html exist.""" + assert os.path.exists('dashboard/index.html'), "index.html should exist" + assert os.path.exists('dashboard/habits.html'), "habits.html should exist" + print("✓ Both HTML files exist") + + +def test_index_habits_link(): + """Test that index.html includes Habits link pointing to /echo/habits.html.""" + with open('dashboard/index.html', 'r', encoding='utf-8') as f: + content = f.read() + + # Check for Habits link with correct href + assert 'href="/echo/habits.html"' in content, "index.html should have link to /echo/habits.html" + + # Check that Habits link exists in navigation + habits_link_pattern = r'<a[^>]*href="/echo/habits\.html"[^>]*class="nav-item"[^>]*>.*?<span>Habits</span>' + assert re.search(habits_link_pattern, content, re.DOTALL), "Habits link should be in nav-item format" + + print("✓ index.html includes Habits link to /echo/habits.html (AC1, AC2)") + + +def test_index_flame_icon(): + """Test that index.html Habits link uses flame icon (lucide).""" + with open('dashboard/index.html', 'r', encoding='utf-8') as f: + content = f.read() + + # Find the Habits nav item + habits_section = re.search( + r'<a[^>]*href="/echo/habits\.html"[^>]*>.*?</a>', + content, + re.DOTALL + ) + + assert habits_section, "Habits link should exist" + habits_html = habits_section.group(0) + + # Check for flame icon (lucide) + assert 'data-lucide="flame"' in habits_html, "Habits link should use lucide flame icon" + + print("✓ index.html Habits link uses flame icon (AC3)") + + +def test_habits_back_to_dashboard(): + """Test that habits.html navigation includes link back to dashboard.""" + with open('dashboard/habits.html', 'r', encoding='utf-8') as f: + content = f.read() + + # Check for Dashboard link + assert 'href="/echo/index.html"' in content, "habits.html should link back to dashboard" + + # Check that Dashboard link exists in navigation + dashboard_link_pattern = r'<a[^>]*href="/echo/index\.html"[^>]*class="nav-item"[^>]*>.*?<span>Dashboard</span>' + assert re.search(dashboard_link_pattern, content, re.DOTALL), "Dashboard link should be in nav-item format" + + print("✓ habits.html includes link back to dashboard (AC4)") + + +def test_habits_flame_icon(): + """Test that habits.html Habits link also uses flame icon.""" + with open('dashboard/habits.html', 'r', encoding='utf-8') as f: + content = f.read() + + # Find the Habits nav item in habits.html + habits_section = re.search( + r'<a[^>]*href="/echo/habits\.html"[^>]*class="nav-item active"[^>]*>.*?</a>', + content, + re.DOTALL + ) + + assert habits_section, "Habits link should exist in habits.html with active class" + habits_html = habits_section.group(0) + + # Check for flame icon (lucide) + assert 'data-lucide="flame"' in habits_html, "habits.html Habits link should use lucide flame icon" + + print("✓ habits.html Habits link uses flame icon (AC3)") + + +def test_active_state_styling(): + """Test that active state styling matches other nav items.""" + with open('dashboard/habits.html', 'r', encoding='utf-8') as f: + habits_content = f.read() + + with open('dashboard/index.html', 'r', encoding='utf-8') as f: + index_content = f.read() + + # Check that habits.html has 'active' class on Habits nav item + habits_active = re.search( + r'<a[^>]*href="/echo/habits\.html"[^>]*class="nav-item active"', + habits_content + ) + assert habits_active, "Habits nav item should have 'active' class in habits.html" + + # Check that index.html has 'active' class on Dashboard nav item (pattern to follow) + index_active = re.search( + r'<a[^>]*href="/echo/index\.html"[^>]*class="nav-item active"', + index_content + ) + assert index_active, "Dashboard nav item should have 'active' class in index.html" + + # Both should use the same pattern (nav-item active) + print("✓ Active state styling matches other nav items (AC5)") + + +def test_mobile_navigation(): + """Test that mobile navigation is supported (shared nav structure).""" + with open('dashboard/index.html', 'r', encoding='utf-8') as f: + index_content = f.read() + + with open('dashboard/habits.html', 'r', encoding='utf-8') as f: + habits_content = f.read() + + # Check that both files include swipe-nav.js for mobile navigation + assert 'swipe-nav.js' in index_content, "index.html should include swipe-nav.js for mobile navigation" + assert 'swipe-nav.js' in habits_content, "habits.html should include swipe-nav.js for mobile navigation" + + # Check that navigation uses the same class structure (nav-item) + # This ensures mobile navigation will work consistently + index_nav_items = len(re.findall(r'class="nav-item', index_content)) + habits_nav_items = len(re.findall(r'class="nav-item', habits_content)) + + assert index_nav_items >= 5, "index.html should have at least 5 nav items (including Habits)" + assert habits_nav_items >= 5, "habits.html should have at least 5 nav items" + + print("✓ Mobile navigation is supported (AC6)") + + +def test_navigation_completeness(): + """Test that navigation is complete on both pages.""" + with open('dashboard/index.html', 'r', encoding='utf-8') as f: + index_content = f.read() + + with open('dashboard/habits.html', 'r', encoding='utf-8') as f: + habits_content = f.read() + + # Define expected navigation items + nav_items = [ + ('Dashboard', '/echo/index.html', 'layout-dashboard'), + ('Workspace', '/echo/workspace.html', 'code'), + ('KB', '/echo/notes.html', 'file-text'), + ('Files', '/echo/files.html', 'folder'), + ('Habits', '/echo/habits.html', 'flame') + ] + + # Check all items exist in both files + for label, href, icon in nav_items: + assert href in index_content, f"index.html should have link to {href}" + assert href in habits_content, f"habits.html should have link to {href}" + + # Check flame icon specifically + assert 'data-lucide="flame"' in index_content, "index.html should have flame icon" + assert 'data-lucide="flame"' in habits_content, "habits.html should have flame icon" + + print("✓ Navigation is complete on both pages with all 5 items") + + +def test_all_acceptance_criteria(): + """Summary test: verify all 7 acceptance criteria are met.""" + print("\n=== Testing All Acceptance Criteria ===") + + with open('dashboard/index.html', 'r', encoding='utf-8') as f: + index_content = f.read() + + with open('dashboard/habits.html', 'r', encoding='utf-8') as f: + habits_content = f.read() + + # AC1: index.html navigation includes 'Habits' link + ac1 = 'href="/echo/habits.html"' in index_content and 'class="nav-item"' in index_content + print(f"AC1 - index.html has Habits link: {'✓' if ac1 else '✗'}") + + # AC2: Link points to /echo/habits.html + ac2 = 'href="/echo/habits.html"' in index_content + print(f"AC2 - Link points to /echo/habits.html: {'✓' if ac2 else '✗'}") + + # AC3: Uses flame icon (lucide) + ac3 = 'data-lucide="flame"' in index_content and 'data-lucide="flame"' in habits_content + print(f"AC3 - Uses flame icon: {'✓' if ac3 else '✗'}") + + # AC4: habits.html navigation includes link back to dashboard + ac4 = 'href="/echo/index.html"' in habits_content + print(f"AC4 - habits.html links back to dashboard: {'✓' if ac4 else '✗'}") + + # AC5: Active state styling matches + ac5_habits = bool(re.search(r'href="/echo/habits\.html"[^>]*class="nav-item active"', habits_content)) + ac5_index = bool(re.search(r'href="/echo/index\.html"[^>]*class="nav-item active"', index_content)) + ac5 = ac5_habits and ac5_index + print(f"AC5 - Active state styling matches: {'✓' if ac5 else '✗'}") + + # AC6: Mobile navigation supported + ac6 = 'swipe-nav.js' in index_content and 'swipe-nav.js' in habits_content + print(f"AC6 - Mobile navigation supported: {'✓' if ac6 else '✗'}") + + # AC7: Tests pass (this test itself) + ac7 = True + print(f"AC7 - Tests for navigation pass: {'✓' if ac7 else '✗'}") + + assert all([ac1, ac2, ac3, ac4, ac5, ac6, ac7]), "All acceptance criteria should pass" + print("\n✓ All 7 acceptance criteria met!") + + +if __name__ == '__main__': + print("Running Story 13.0 Navigation Tests...\n") + + try: + test_file_existence() + test_index_habits_link() + test_index_flame_icon() + test_habits_back_to_dashboard() + test_habits_flame_icon() + test_active_state_styling() + test_mobile_navigation() + test_navigation_completeness() + test_all_acceptance_criteria() + + print("\n" + "="*50) + print("✓ ALL TESTS PASSED") + print("="*50) + + except AssertionError as e: + print(f"\n✗ TEST FAILED: {e}") + exit(1) + except Exception as e: + print(f"\n✗ ERROR: {e}") + exit(1) diff --git a/dashboard/test_habits_post.py b/dashboard/test_habits_post.py new file mode 100644 index 0000000..4c1ba65 --- /dev/null +++ b/dashboard/test_habits_post.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +"""Tests for POST /api/habits endpoint""" + +import json +import shutil +import sys +import tempfile +import unittest +from datetime import datetime +from http.server import HTTPServer +from pathlib import Path +from threading import Thread +from time import sleep +from urllib.request import Request, urlopen +from urllib.error import HTTPError + +# Add parent directory to path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from dashboard.api import TaskBoardHandler + +class TestHabitsPost(unittest.TestCase): + """Test POST /api/habits endpoint""" + + @classmethod + def setUpClass(cls): + """Start test server""" + # Create temp habits.json + cls.temp_dir = Path(tempfile.mkdtemp()) + cls.habits_file = cls.temp_dir / 'habits.json' + cls.habits_file.write_text(json.dumps({ + 'lastUpdated': datetime.now().isoformat(), + 'habits': [] + })) + + # Monkey-patch KANBAN_DIR + import dashboard.api as api_module + cls.original_kanban_dir = api_module.KANBAN_DIR + api_module.KANBAN_DIR = cls.temp_dir + + # Start server + cls.port = 18088 + cls.server = HTTPServer(('127.0.0.1', cls.port), TaskBoardHandler) + cls.thread = Thread(target=cls.server.serve_forever, daemon=True) + cls.thread.start() + sleep(0.5) + + cls.base_url = f'http://127.0.0.1:{cls.port}' + + @classmethod + def tearDownClass(cls): + """Stop server and cleanup""" + cls.server.shutdown() + cls.thread.join(timeout=2) + + # Restore KANBAN_DIR + import dashboard.api as api_module + api_module.KANBAN_DIR = cls.original_kanban_dir + + # Cleanup temp dir + shutil.rmtree(cls.temp_dir) + + def setUp(self): + """Reset habits.json before each test""" + self.habits_file.write_text(json.dumps({ + 'lastUpdated': datetime.now().isoformat(), + 'habits': [] + })) + + def post_habit(self, data): + """Helper to POST to /api/habits""" + url = f'{self.base_url}/api/habits' + req = Request(url, data=json.dumps(data).encode(), method='POST') + req.add_header('Content-Type', 'application/json') + return urlopen(req) + + def test_create_habit_success(self): + """Test creating a valid habit""" + data = {'name': 'Bazin', 'frequency': 'daily'} + resp = self.post_habit(data) + + self.assertEqual(resp.status, 201) + + result = json.loads(resp.read()) + self.assertIn('id', result) + self.assertTrue(result['id'].startswith('habit-')) + self.assertEqual(result['name'], 'Bazin') + self.assertEqual(result['frequency'], 'daily') + self.assertIn('createdAt', result) + self.assertEqual(result['completions'], []) + + # Verify ISO timestamp + datetime.fromisoformat(result['createdAt']) + + def test_habit_persisted_to_file(self): + """Test habit is written to habits.json""" + data = {'name': 'Sală', 'frequency': 'weekly'} + resp = self.post_habit(data) + habit = json.loads(resp.read()) + + # Read file + file_data = json.loads(self.habits_file.read_text()) + self.assertEqual(len(file_data['habits']), 1) + self.assertEqual(file_data['habits'][0]['id'], habit['id']) + self.assertEqual(file_data['habits'][0]['name'], 'Sală') + + def test_id_format_correct(self): + """Test generated id follows 'habit-{timestamp}' format""" + data = {'name': 'Test', 'frequency': 'daily'} + resp = self.post_habit(data) + habit = json.loads(resp.read()) + + habit_id = habit['id'] + self.assertTrue(habit_id.startswith('habit-')) + + # Extract timestamp and verify it's numeric + timestamp_part = habit_id.replace('habit-', '') + self.assertTrue(timestamp_part.isdigit()) + + # Verify timestamp is reasonable (milliseconds since epoch) + timestamp_ms = int(timestamp_part) + now_ms = int(datetime.now().timestamp() * 1000) + # Should be within 5 seconds + self.assertLess(abs(now_ms - timestamp_ms), 5000) + + def test_missing_name_returns_400(self): + """Test missing name returns 400""" + data = {'frequency': 'daily'} + + with self.assertRaises(HTTPError) as ctx: + self.post_habit(data) + + self.assertEqual(ctx.exception.code, 400) + error = json.loads(ctx.exception.read()) + self.assertIn('name', error['error'].lower()) + + def test_empty_name_returns_400(self): + """Test empty name (whitespace only) returns 400""" + data = {'name': ' ', 'frequency': 'daily'} + + with self.assertRaises(HTTPError) as ctx: + self.post_habit(data) + + self.assertEqual(ctx.exception.code, 400) + + def test_invalid_frequency_returns_400(self): + """Test invalid frequency returns 400""" + data = {'name': 'Test', 'frequency': 'monthly'} + + with self.assertRaises(HTTPError) as ctx: + self.post_habit(data) + + self.assertEqual(ctx.exception.code, 400) + error = json.loads(ctx.exception.read()) + self.assertIn('frequency', error['error'].lower()) + + def test_missing_frequency_returns_400(self): + """Test missing frequency returns 400""" + data = {'name': 'Test'} + + with self.assertRaises(HTTPError) as ctx: + self.post_habit(data) + + self.assertEqual(ctx.exception.code, 400) + + def test_multiple_habits_created(self): + """Test creating multiple habits""" + habit1 = {'name': 'Bazin', 'frequency': 'daily'} + habit2 = {'name': 'Sală', 'frequency': 'weekly'} + + resp1 = self.post_habit(habit1) + h1 = json.loads(resp1.read()) + + # Small delay to ensure different timestamp + sleep(0.01) + + resp2 = self.post_habit(habit2) + h2 = json.loads(resp2.read()) + + # IDs should be different + self.assertNotEqual(h1['id'], h2['id']) + + # Both should be in file + file_data = json.loads(self.habits_file.read_text()) + self.assertEqual(len(file_data['habits']), 2) + + def test_last_updated_timestamp(self): + """Test lastUpdated is updated when creating habit""" + before = datetime.now().isoformat() + + data = {'name': 'Test', 'frequency': 'daily'} + self.post_habit(data) + + file_data = json.loads(self.habits_file.read_text()) + last_updated = file_data['lastUpdated'] + + # Should be a valid ISO timestamp + datetime.fromisoformat(last_updated) + # Should be recent + self.assertGreaterEqual(last_updated, before) + + +if __name__ == '__main__': + unittest.main() diff --git a/dashboard/test_habits_schema.py b/dashboard/test_habits_schema.py new file mode 100644 index 0000000..d7ae33e --- /dev/null +++ b/dashboard/test_habits_schema.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +""" +Test suite for habits.json schema validation +""" +import json +import os +from datetime import datetime + + +def test_habits_file_exists(): + """Test that habits.json file exists""" + assert os.path.exists('dashboard/habits.json'), "habits.json should exist in dashboard/" + print("✓ habits.json file exists") + + +def test_valid_json(): + """Test that habits.json is valid JSON""" + try: + with open('dashboard/habits.json', 'r') as f: + data = json.load(f) + print("✓ habits.json is valid JSON") + return data + except json.JSONDecodeError as e: + raise AssertionError(f"habits.json is not valid JSON: {e}") + + +def test_root_structure(data): + """Test that root structure has required fields""" + assert 'lastUpdated' in data, "Root should have 'lastUpdated' field" + assert 'habits' in data, "Root should have 'habits' field" + print("✓ Root structure has lastUpdated and habits fields") + + +def test_last_updated_format(data): + """Test that lastUpdated is a valid ISO timestamp""" + try: + datetime.fromisoformat(data['lastUpdated'].replace('Z', '+00:00')) + print("✓ lastUpdated is valid ISO timestamp") + except (ValueError, AttributeError) as e: + raise AssertionError(f"lastUpdated is not a valid ISO timestamp: {e}") + + +def test_habits_is_array(data): + """Test that habits is an array""" + assert isinstance(data['habits'], list), "habits should be an array" + print("✓ habits is an array") + + +def test_habit_schema(): + """Test habit schema structure with sample data""" + # Sample habit to validate schema + sample_habit = { + "id": "habit-123", + "name": "Bazin", + "frequency": "daily", + "createdAt": "2026-02-10T10:57:00.000Z", + "completions": ["2026-02-10T10:00:00.000Z", "2026-02-09T10:00:00.000Z"] + } + + # Validate required fields + required_fields = ['id', 'name', 'frequency', 'createdAt', 'completions'] + for field in required_fields: + assert field in sample_habit, f"Habit should have '{field}' field" + + # Validate types + assert isinstance(sample_habit['id'], str), "id should be string" + assert isinstance(sample_habit['name'], str), "name should be string" + assert sample_habit['frequency'] in ['daily', 'weekly'], "frequency should be 'daily' or 'weekly'" + assert isinstance(sample_habit['completions'], list), "completions should be array" + + # Validate ISO dates + datetime.fromisoformat(sample_habit['createdAt'].replace('Z', '+00:00')) + for completion in sample_habit['completions']: + datetime.fromisoformat(completion.replace('Z', '+00:00')) + + print("✓ Habit schema structure is valid") + + +def test_initial_state(data): + """Test that initial file has empty habits array""" + assert len(data['habits']) == 0, "Initial habits array should be empty" + print("✓ Initial habits array is empty") + + +def run_all_tests(): + """Run all schema validation tests""" + print("Running habits.json schema validation tests...\n") + + try: + test_habits_file_exists() + data = test_valid_json() + test_root_structure(data) + test_last_updated_format(data) + test_habits_is_array(data) + test_habit_schema() + test_initial_state(data) + + print("\n✅ All tests passed!") + return True + except AssertionError as e: + print(f"\n❌ Test failed: {e}") + return False + except Exception as e: + print(f"\n❌ Unexpected error: {e}") + return False + + +if __name__ == '__main__': + success = run_all_tests() + exit(0 if success else 1) diff --git a/dashboard/test_habits_streak.py b/dashboard/test_habits_streak.py new file mode 100644 index 0000000..209009b --- /dev/null +++ b/dashboard/test_habits_streak.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +""" +Tests for streak calculation utility. +Story 4.0: Backend API - Streak calculation utility +""" + +import sys +from pathlib import Path +from datetime import datetime, timedelta + +# Add dashboard to path to import api module +sys.path.insert(0, str(Path(__file__).parent)) + +from api import calculate_streak + + +def test_no_completions(): + """Returns 0 for no completions""" + assert calculate_streak([], 'daily') == 0 + assert calculate_streak([], 'weekly') == 0 + print("✓ No completions returns 0") + + +def test_daily_single_completion_today(): + """Single completion today counts as streak of 1""" + today = datetime.now().isoformat() + assert calculate_streak([today], 'daily') == 1 + print("✓ Daily: single completion today = streak 1") + + +def test_daily_single_completion_yesterday(): + """Single completion yesterday counts as streak of 1""" + yesterday = (datetime.now() - timedelta(days=1)).isoformat() + assert calculate_streak([yesterday], 'daily') == 1 + print("✓ Daily: single completion yesterday = streak 1") + + +def test_daily_consecutive_days(): + """Multiple consecutive days count correctly""" + completions = [ + (datetime.now() - timedelta(days=i)).isoformat() + for i in range(5) # Today, yesterday, 2 days ago, 3 days ago, 4 days ago + ] + assert calculate_streak(completions, 'daily') == 5 + print("✓ Daily: 5 consecutive days = streak 5") + + +def test_daily_broken_streak(): + """Gap in daily completions breaks streak""" + today = datetime.now() + completions = [ + today.isoformat(), + (today - timedelta(days=1)).isoformat(), + # Gap here (day 2 missing) + (today - timedelta(days=3)).isoformat(), + (today - timedelta(days=4)).isoformat(), + ] + # Should count only today and yesterday before the gap + assert calculate_streak(completions, 'daily') == 2 + print("✓ Daily: gap breaks streak (counts only before gap)") + + +def test_daily_old_completion(): + """Completion more than 1 day ago returns 0""" + two_days_ago = (datetime.now() - timedelta(days=2)).isoformat() + assert calculate_streak([two_days_ago], 'daily') == 0 + print("✓ Daily: completion >1 day ago = streak 0") + + +def test_weekly_single_completion_this_week(): + """Single completion this week counts as streak of 1""" + today = datetime.now().isoformat() + assert calculate_streak([today], 'weekly') == 1 + print("✓ Weekly: single completion this week = streak 1") + + +def test_weekly_consecutive_weeks(): + """Multiple consecutive weeks count correctly""" + today = datetime.now() + completions = [ + today.isoformat(), + (today - timedelta(days=7)).isoformat(), + (today - timedelta(days=14)).isoformat(), + (today - timedelta(days=21)).isoformat(), + ] + assert calculate_streak(completions, 'weekly') == 4 + print("✓ Weekly: 4 consecutive weeks = streak 4") + + +def test_weekly_broken_streak(): + """Missing week breaks streak""" + today = datetime.now() + completions = [ + today.isoformat(), + (today - timedelta(days=7)).isoformat(), + # Gap here (week 2 missing) + (today - timedelta(days=21)).isoformat(), + ] + # Should count only current week and last week before the gap + assert calculate_streak(completions, 'weekly') == 2 + print("✓ Weekly: missing week breaks streak") + + +def test_weekly_old_completion(): + """Completion more than 7 days ago returns 0""" + eight_days_ago = (datetime.now() - timedelta(days=8)).isoformat() + assert calculate_streak([eight_days_ago], 'weekly') == 0 + print("✓ Weekly: completion >7 days ago = streak 0") + + +def test_multiple_completions_same_day(): + """Multiple completions on same day count as one""" + today = datetime.now() + completions = [ + today.isoformat(), + (today - timedelta(hours=2)).isoformat(), # Same day, different time + (today - timedelta(days=1)).isoformat(), + ] + assert calculate_streak(completions, 'daily') == 2 + print("✓ Daily: multiple completions same day = 1 day") + + +def test_todays_completion_counts(): + """Today's completion counts even if yesterday was missed""" + today = datetime.now() + completions = [ + today.isoformat(), + # Yesterday missing + (today - timedelta(days=2)).isoformat(), + ] + # Should count only today (yesterday breaks the streak to previous days) + assert calculate_streak(completions, 'daily') == 1 + print("✓ Daily: today counts even if yesterday missed") + + +def test_invalid_date_format(): + """Invalid date format returns 0""" + assert calculate_streak(['not-a-date'], 'daily') == 0 + assert calculate_streak(['2026-13-45'], 'daily') == 0 + print("✓ Invalid date format returns 0") + + +def test_weekly_multiple_in_same_week(): + """Multiple completions in same week count as one week""" + today = datetime.now() + completions = [ + today.isoformat(), + (today - timedelta(days=2)).isoformat(), # Same week + (today - timedelta(days=4)).isoformat(), # Same week + (today - timedelta(days=7)).isoformat(), # Previous week + ] + assert calculate_streak(completions, 'weekly') == 2 + print("✓ Weekly: multiple in same week = 1 week") + + +def run_all_tests(): + """Run all streak calculation tests""" + print("\n=== Testing Streak Calculation ===\n") + + test_no_completions() + test_daily_single_completion_today() + test_daily_single_completion_yesterday() + test_daily_consecutive_days() + test_daily_broken_streak() + test_daily_old_completion() + test_weekly_single_completion_this_week() + test_weekly_consecutive_weeks() + test_weekly_broken_streak() + test_weekly_old_completion() + test_multiple_completions_same_day() + test_todays_completion_counts() + test_invalid_date_format() + test_weekly_multiple_in_same_week() + + print("\n✓ All streak calculation tests passed!\n") + + +if __name__ == '__main__': + run_all_tests() diff --git a/memory/2026-02-10.md b/memory/2026-02-10.md new file mode 100644 index 0000000..2e78121 --- /dev/null +++ b/memory/2026-02-10.md @@ -0,0 +1,21 @@ +# 2026-02-10 + +## Dashboard ANAF - Detalii Modificări + +**Context:** Marius a cerut să vadă ce modificări detectează ANAF Monitor în dashboard, nu doar mesaj generic "Modificări detectate". + +**Implementare:** +1. **monitor_v2.py** - modificat `update_dashboard_status()` să salveze detalii în `status.json`: + - Nume pagină modificată + - URL către pagina ANAF + - Rezumat modificări (ex: "Soft A: 09.02.2026 → 10.02.2026") + +2. **dashboard/index.html** - modificat `loadAnafStatus()` să afișeze detaliile: + - Link-uri clickabile către paginile ANAF + - Lista modificărilor pentru fiecare pagină + - Expandabil în secțiunea ANAF Monitor + +**Modificare reală detectată astăzi:** +- D100 (Declarația 100) - Soft A: 09.02.2026 → 10.02.2026 + +**Status:** Implementat, netestat în browser. Așteaptă commit. diff --git a/memory/kb/index.json b/memory/kb/index.json index dcb91f8..a0437d2 100644 --- a/memory/kb/index.json +++ b/memory/kb/index.json @@ -1,5 +1,48 @@ { "notes": [ + { + "file": "notes-data/tools/antfarm-workflow.md", + "title": "Antfarm Workflow - Echo", + "date": "2026-02-10", + "tags": [], + "domains": [], + "types": [], + "category": "tools", + "project": null, + "subdir": null, + "video": "", + "tldr": "**Update:** După ce învăț fluxul mai bine" + }, + { + "file": "memory/provocare-azi.md", + "title": "Provocarea Zilei - 2026-02-08", + "date": "2026-02-10", + "tags": [], + "domains": [], + "types": [ + "memory" + ], + "category": "memory", + "project": null, + "subdir": null, + "video": "", + "tldr": "- Monica Ion - Legea Fractalilor (Cele 7 Legi Universale)" + }, + { + "file": "memory/2026-02-10.md", + "title": "2026-02-10", + "date": "2026-02-10", + "tags": [], + "domains": [], + "types": [ + "memory" + ], + "category": "memory", + "project": null, + "subdir": null, + "video": "", + "tldr": "**Status:** Aștept confirmare de la Marius să lansez `antfarm workflow run feature-dev`." + }, { "file": "notes-data/coaching/2026-02-09-seara.md", "title": "Gândul de Seară - Duminică, 9 Februarie 2026", @@ -813,21 +856,6 @@ "video": "https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/articole/monica-ion/cele-7-legi-universale.md", "tldr": "Cele 7 Legi Universale sunt principii fundamentale care explică cum funcționează mintea, de ce trăim viața așa cum o trăim și cum putem genera transformare reală. Fiecare lege este susținută de istori..." }, - { - "file": "memory/provocare-azi.md", - "title": "Provocarea Zilei - 2026-02-08", - "date": "2026-02-08", - "tags": [], - "domains": [], - "types": [ - "memory" - ], - "category": "memory", - "project": null, - "subdir": null, - "video": "", - "tldr": "- Monica Ion - Legea Fractalilor (Cele 7 Legi Universale)" - }, { "file": "memory/jurnal-motivatie.md", "title": "Jurnal - Drumul spre regăsirea motivației", @@ -3346,7 +3374,7 @@ } ], "stats": { - "total": 194, + "total": 196, "by_domain": { "work": 59, "health": 34, @@ -3365,9 +3393,9 @@ "projects": 85, "reflectii": 3, "retete": 1, - "tools": 5, + "tools": 6, "youtube": 42, - "memory": 16 + "memory": 17 } }, "domains": [ diff --git a/memory/kb/tools/antfarm-workflow.md b/memory/kb/tools/antfarm-workflow.md new file mode 100644 index 0000000..90362c0 --- /dev/null +++ b/memory/kb/tools/antfarm-workflow.md @@ -0,0 +1,87 @@ +# Antfarm Workflow - Echo + +**Instalat:** github.com/snarktank/antfarm +**CLI:** `antfarm` (în PATH, global) +**Dashboard:** https://moltbot.tailf7372d.ts.net:3333 +**Docs:** ~/clawd/antfarm/README.md, ~/clawd/antfarm/docs/creating-workflows.md + +--- + +## Flux rapid (pentru Echo) + +### 1. Primesc request de la Marius +**EX:** "Vreau să construiesc un Habit tracker în dashboard" + +### 2. Lansez direct workflow-ul cu promptul lui Marius +```bash +cd ~/clawd +antfarm workflow run feature-dev "<prompt exact de la Marius>" +``` + +**NU:** +- ✗ Verific dacă e instalat (e instalat, permanent) +- ✗ Fac eu requirements/acceptance criteria (planner-ul face asta) +- ✗ Complicez task string-ul (simplitate = mai bine) + +**DA:** +- ✓ Trimit prompt-ul EXACT cum îl primesc de la Marius +- ✓ Planner-ul descompune în stories automat +- ✓ Developer-ul decide tehnologii/structură + +### 3. Monitorez progres +```bash +antfarm workflow status <run-id sau substring> +antfarm workflow runs # listă toate +``` + +### 4. Raportez când e gata +Agenții lucrează autonom (polling 15 min). Raportez când: +- Stories finalizate +- Erori care necesită intervenție +- PR creat pentru review + +--- + +## Workflows disponibile + +| Workflow | Când să-l folosesc | +|----------|-------------------| +| `feature-dev` | Features noi, refactoring, îmbunătățiri | +| `bug-fix` | Bug-uri cu pași de reproducere | +| `security-audit` | Audit securitate codebase | + +--- + +## Comenzi utile + +```bash +# Status rapid +antfarm workflow status <query> + +# Force trigger agent (skip 15min wait) +cron action=run jobId=antfarm/feature-dev/developer + +# Logs +antfarm logs 50 + +# Resume dacă failuit +antfarm workflow resume <run-id> + +# Dashboard +antfarm dashboard status +``` + +--- + +## Reguli importante + +1. **Task string = prompt exact de la Marius** (nu complica) +2. **Planner face requirements** (nu tu) +3. **Agenții sunt autonomi** (polling 15 min, nu trebuie să-i controlezi) +4. **Monitor dashboard** (https://moltbot.tailf7372d.ts.net:3333) +5. **Raportează doar când e relevant** (finalizare, erori, PR) + +--- + +**Creat:** 2026-02-10 +**Update:** După ce învăț fluxul mai bine diff --git a/progress.txt b/progress.txt new file mode 100644 index 0000000..f29cb5c --- /dev/null +++ b/progress.txt @@ -0,0 +1,707 @@ +=== HABIT TRACKER FEATURE PROGRESS === + +Date: 2026-02-10 +Branch: feature/habit-tracker +Repo: /home/moltbot/clawd + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +COMPLETED STORIES: + +[✓] Story 1.0: Define habits.json data schema + Commit: ee8727a + Date: 2026-02-10 + + Implementation: + - Created dashboard/habits.json with proper schema + - Root structure: lastUpdated (ISO timestamp) + habits (array) + - Habit schema: id (string), name (string), frequency (daily/weekly), + createdAt (ISO), completions (array of ISO dates) + - Initial file contains empty habits array with current timestamp + - All JSON validation passes + + Tests: + - Created dashboard/test_habits_schema.py + - Tests for file existence, valid JSON, root structure + - Tests for lastUpdated ISO format validation + - Tests for habits array type + - Tests for complete habit schema (all required fields + types) + - Tests for initial empty state + - All tests pass ✓ + + Files modified: + - dashboard/habits.json (created) + - dashboard/test_habits_schema.py (created) + +[✓] Story 2.0: Backend API - GET /api/habits + Commit: fc5ebf2 + Date: 2026-02-10 + + Implementation: + - Added GET /api/habits endpoint to dashboard/api.py + - Endpoint returns habits array and lastUpdated timestamp + - Graceful error handling: returns empty array if file missing/corrupt + - Follows existing API patterns (similar to /api/git, /api/status) + - Returns 200 status for all valid requests + + Tests: + - Created dashboard/test_habits_api.py + - Tests for endpoint existence (returns 200) + - Tests for valid JSON response + - Tests for response structure (habits array + lastUpdated) + - Tests for ISO timestamp validation + - Tests for empty file handling (returns [], not error) + - Tests for habits with data + - All 6 tests pass ✓ + + Files modified: + - dashboard/api.py (added handle_habits_get method + route) + - dashboard/test_habits_api.py (created) + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +CODEBASE PATTERNS: + +1. JSON Data Files + - Location: dashboard/*.json + - Pattern: Similar structure to tasks.json, todos.json, issues.json + - All use ISO timestamps for dates + - Root objects contain metadata + data arrays + +2. Testing Approach + - Python test files in dashboard/ directory + - Test naming: test_*.py + - Comprehensive validation: existence, JSON validity, schema, types + - Run tests from repo root with: python3 dashboard/test_*.py + +3. Build Validation + - Command: python3 -m py_compile dashboard/api.py + - Validates Python syntax without executing + +4. Utility Functions in api.py + - Standalone utility functions placed before TaskBoardHandler class + - Documented with docstrings (Args, Returns, Rules/behavior) + - ISO timestamp parsing pattern: datetime.fromisoformat(ts.replace('Z', '+00:00')) + - Convert to date only when time doesn't matter: dt.date() + - Use try/except for robust parsing with sensible defaults (return 0, [], etc.) + +[✓] Story 3.0: Backend API - POST /api/habits (create habit) + Commit: 3a09e6c + Date: 2026-02-10 + + Implementation: + - Added POST /api/habits endpoint to dashboard/api.py + - Accepts {name, frequency} in request body + - Returns 400 for missing name or empty name (after trim) + - Returns 400 for invalid frequency (must be 'daily' or 'weekly') + - Generates unique id following format 'habit-{millisecond_timestamp}' + - Sets createdAt to current ISO timestamp + - Initializes completions array as empty + - Persists new habit to habits.json + - Updates lastUpdated timestamp in habits.json + - Returns 201 status with created habit object + - Graceful handling of missing/corrupt habits.json file + + Tests: + - Created dashboard/test_habits_post.py + - Tests for successful habit creation (returns 201 + full habit object) + - Tests for habit persistence to habits.json + - Tests for correct id format (habit-{timestamp}) + - Tests for missing name validation (400) + - Tests for empty name validation (400) + - Tests for invalid frequency validation (400) + - Tests for missing frequency validation (400) + - Tests for creating multiple habits (unique IDs) + - Tests for lastUpdated timestamp update + - All 9 tests pass ✓ + + Files modified: + - dashboard/api.py (added handle_habits_post method + route) + - dashboard/test_habits_post.py (created) + +[✓] Story 4.0: Backend API - Streak calculation utility + Commit: 3927b7c + Date: 2026-02-10 + + Implementation: + - Created calculate_streak(completions, frequency) utility function + - Added to dashboard/api.py as standalone function (before TaskBoardHandler class) + - Accepts completions array (ISO timestamps) and frequency ('daily' or 'weekly') + - Returns integer streak count (days for daily, weeks for weekly) + - Daily habits: counts consecutive days without gaps + - Weekly habits: counts consecutive weeks (7-day periods) + - Returns 0 for no completions, invalid dates, or broken streaks + - Edge case: today's completion counts even if streak was 0 yesterday + - Edge case: multiple completions same day/week count as one period + - Robust date parsing with error handling for invalid ISO timestamps + + Tests: + - Created dashboard/test_habits_streak.py + - Tests for no completions (returns 0) + - Tests for daily single completion (today and yesterday) + - Tests for daily consecutive days (5 days streak) + - Tests for daily broken streak (gap detection) + - Tests for daily old completion (>1 day ago returns 0) + - Tests for weekly single completion (this week) + - Tests for weekly consecutive weeks (4 weeks streak) + - Tests for weekly broken streak (missing week) + - Tests for weekly old completion (>7 days ago returns 0) + - Tests for multiple completions same day (deduplicated) + - Tests for today counting despite yesterday missing + - Tests for invalid date format handling + - Tests for weekly multiple in same week (deduplicated) + - All 14 tests pass ✓ + - All previous tests (schema, GET, POST) still pass ✓ + + Files modified: + - dashboard/api.py (added calculate_streak function) + - dashboard/test_habits_streak.py (created) + +[✓] Story 5.0: Backend API - POST /api/habits/{id}/check + Commit: ca4ee77 + Date: 2026-02-10 + + Implementation: + - Added POST /api/habits/{id}/check endpoint to dashboard/api.py + - Extracts habit ID from URL path (/api/habits/{id}/check) + - Adds today's date (YYYY-MM-DD) to completions array + - Returns 400 if habit already checked today + - Returns 404 if habit ID not found + - Sorts completions chronologically (oldest first) after adding + - Uses ISO date format YYYY-MM-DD (not full timestamps) + - Calculates and returns streak using calculate_streak utility + - Returns 200 with updated habit object including streak + - Streak is calculated but not persisted (only in response) + - Updates lastUpdated timestamp in habits.json + - Graceful error handling for missing/corrupt files + + Tests: + - Created dashboard/test_habits_check.py + - Tests for successful habit check (returns 200 + updated habit) + - Tests for already checked validation (400 error) + - Tests for habit not found (404 error) + - Tests for persistence to habits.json + - Tests for chronological sorting of completions + - Tests for streak calculation in response + - Tests for weekly habit checking + - Tests for ISO date format (YYYY-MM-DD, no time) + - All 8 tests pass ✓ + - All previous tests (schema, GET, POST, streak) still pass ✓ + + Files modified: + - dashboard/api.py (added handle_habits_check method + route in do_POST) + - dashboard/test_habits_check.py (created) + - dashboard/habits.json (reset to empty for testing) + +[✓] Story 6.0: Backend API - GET /api/habits with streaks + Commit: c84135d + Date: 2026-02-10 + + Implementation: + - Enhanced GET /api/habits endpoint to include calculated streaks + - Each habit in response now includes 'streak' field (integer) + - Each habit in response now includes 'checkedToday' field (boolean) + - Streak is calculated using the calculate_streak utility function + - checkedToday checks if today's date (YYYY-MM-DD) is in completions array + - All original habit fields are preserved in response + - Get today's date once and reuse for all habits (efficient) + - Enhanced habits array built by looping through each habit and adding fields + - Updated docstring to reflect new functionality + + Tests: + - Created dashboard/test_habits_get_enhanced.py + - Tests for streak field inclusion in response + - Tests for checkedToday boolean field inclusion + - Tests for correct streak calculation (daily and weekly habits) + - Tests for broken streaks (should return 0) + - Tests for empty habits array handling + - Tests for preservation of original habit fields + - All 5 tests pass ✓ + - All previous tests (schema, GET, POST, streak, check) still pass ✓ + + Files modified: + - dashboard/api.py (enhanced handle_habits_get method) + - dashboard/test_habits_get_enhanced.py (created) + +[✓] Story 7.0: Frontend - Create habits.html page structure + Commit: dd0bf24 + Date: 2026-02-10 + + Implementation: + - Created dashboard/habits.html with basic layout matching dashboard style + - Uses common.css and swipe-nav.js for consistent styling and navigation + - Added navigation bar with 5 items (Dashboard, Workspace, KB, Files, Habits) + - Habits nav item has 'active' class to indicate current page + - Page header with title "Habit Tracker" and subtitle + - Empty state section with lucide 'target' icon + - Empty state message: "Nicio obișnuință încă. Creează prima!" + - Add habit button with lucide 'plus' icon and text "Adaugă obișnuință" + - Theme toggle functionality (dark/light mode) matching dashboard + - Placeholder JavaScript functions for future API integration + - HTML5 compliant structure with lang="ro" attribute + + Tests: + - Created dashboard/test_habits_html.py + - Tests for file existence + - Tests for valid HTML5 structure (DOCTYPE, required tags, lang attribute) + - Tests for common.css and swipe-nav.js inclusion + - Tests for navigation bar with correct items and active state + - Tests for page title "Habit Tracker" in both <title> and <h1> + - Tests for empty state message with exact text + - Tests for add habit button with lucide plus icon + - All 7 tests pass ✓ + - All previous tests (schema, API endpoints) still pass ✓ + + Files modified: + - dashboard/habits.html (created) + - dashboard/test_habits_html.py (created) + - dashboard/habits.json (reset to empty for testing) + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +CODEBASE PATTERNS UPDATE: + +5. Frontend HTML Pages + - Location: dashboard/*.html + - Common structure: DOCTYPE html, lang="ro", UTF-8 charset + - Shared resources: common.css, swipe-nav.js, lucide icons CDN + - Navigation pattern: header.header > logo + nav.nav > nav-item links + - Active nav item has 'active' class + - Theme toggle button in nav with onclick="toggleTheme()" + - Main content in <main class="main"> with max-width container + - Page header pattern: .page-header > .page-title + .page-subtitle + - Empty states: .empty-state with centered icon, message, and action button + - Icons: use lucide via data-lucide attribute, initialize with lucide.createIcons() + +6. Mobile Responsiveness + - Use @media (max-width: 768px) for mobile breakpoint + - Touch targets: minimum 44x44px for WCAG compliance (checkboxes, buttons) + - Modal pattern: full-screen on mobile (100% width/height, no border-radius) + - Input optimization: autocapitalize="words" for proper names, autocomplete="off" for sensitive fields + - Navigation: swipe-nav.js provides mobile swipe gestures + - Viewport: include <meta name="viewport" content="width=device-width, initial-scale=1.0"> + - All buttons should have min-height: 44px on mobile for easy tapping + - Flexbox direction already handles vertical stacking (flex-direction: column) + +[✓] Story 8.0: Frontend - Create habit form modal + Commit: 97af2ae + Date: 2026-02-10 + + Implementation: + - Added modal HTML structure to habits.html with id='habitModal' + - Modal overlay uses dashboard styling patterns (overlay + modal container) + - Form includes text input for habit name (id='habitName', required) + - Form includes radio button group for frequency selection + - Two radio options: daily (default, checked) and weekly + - Custom styled radio buttons using .radio-group and .radio-label classes + - Radio buttons hidden, labels styled as clickable cards + - Selected radio shows accent background color + - Modal actions with Cancel (btn-secondary) and Create (btn-primary) buttons + - Cancel button calls hideHabitModal() + - Create button calls createHabit() and starts disabled + - Modal CSS follows dashboard patterns: modal-overlay, modal, modal-title, form-group, form-label, modal-actions + - Added toast notification element for user feedback + - JavaScript: showAddHabitModal() opens modal and resets form + - JavaScript: hideHabitModal() closes modal by removing 'active' class + - JavaScript: DOMContentLoaded event listener for form validation + - Input event listener on name field enables/disables Create button + - Create button disabled when name is empty (after trim) + - Enter key submits form if Create button is enabled + - createHabit() async function posts to /api/habits + - On success: hides modal, shows toast, calls loadHabits() + - On error: shows toast with error message + - showToast() function displays temporary notification (3 seconds) + - Modal uses CSS variables for theming (--bg-base, --border, --accent, etc.) + - Responsive design with max-width: 500px and 90% width + + Tests: + - Created dashboard/test_habits_modal.py + - Tests for modal structure (overlay, container, title) + - Tests for name input field with required indicator (*) + - Tests for frequency radio buttons (daily/weekly, daily checked by default) + - Tests for radio labels with correct Romanian text + - Tests for Cancel and Create buttons with correct classes and onclick handlers + - Tests for Create button starting disabled + - Tests for add habit button calling showAddHabitModal() + - Tests for modal CSS styling patterns + - Tests for JavaScript functions (showAddHabitModal, hideHabitModal, createHabit) + - Tests for form validation logic (disable button, trim validation) + - Tests for modal show/hide logic (classList.add/remove 'active') + - Tests for API integration (fetch /api/habits POST) + - Tests for toast notification element and function + - Tests for event listeners (DOMContentLoaded, input, keypress) + - All 9 tests pass ✓ + - All previous tests (schema, API endpoints, HTML structure) still pass ✓ + + Files modified: + - dashboard/habits.html (added modal HTML, CSS, and JavaScript) + - dashboard/test_habits_modal.py (created) + +[✓] Story 9.0: Frontend - Display habits list + Commit: 0483d73 + Date: 2026-02-10 + + Implementation: + - Added CSS for habit cards (.habit-card, .habit-icon, .habit-info, .habit-streak) + - Added CSS for loading state with spinner animation + - Added CSS for error state with retry button + - Enhanced HTML with loading, error, and empty states (all with IDs) + - Implemented loadHabits() async function to fetch from /api/habits + - Habits sorted by streak descending (highest first) + - Loading state shown while fetching (loadingState.classList.add('active')) + - Error state shown on fetch failure with retry button + - Empty state shown when habits array is empty + - Habits list shown when habits exist + - Created createHabitCard(habit) function to render habit cards + - Daily habits show calendar icon (lucide), weekly show clock icon + - Each habit displays: name, frequency label, streak with 🔥 emoji + - Streak displayed as: "{number} 🔥" in accent color + - XSS protection via escapeHtml() function (uses textContent) + - Lucide icons reinitialized after rendering (lucide.createIcons()) + - loadHabits() called on DOMContentLoaded (automatic page load) + - Habits data includes streak and checkedToday from API + + Tests: + - Created dashboard/test_habits_display.py + - Tests for loading state structure (element, class, icon, message) + - Tests for error state structure (element, class, icon, message, retry button) + - Tests for empty state ID attribute + - Tests for habits list container existence + - Tests for loadHabits function implementation and API fetch + - Tests for sorting by streak descending (b.streak - a.streak) + - Tests for frequency icons (calendar for daily, clock for weekly) + - Tests for streak display with flame emoji 🔥 + - Tests for state management (show/hide logic) + - Tests for error handling (catch block) + - Tests for createHabitCard function existence + - Tests for page load trigger (DOMContentLoaded listener) + - Tests for habit card CSS styling + - Tests for Lucide icons reinitialization after rendering + - Tests for XSS protection (escapeHtml function) + - All 15 tests pass ✓ + - All previous tests (schema, API, HTML, modal) still pass ✓ + + Files modified: + - dashboard/habits.html (added habit cards CSS, loading/error states HTML, loadHabits implementation) + - dashboard/test_habits_display.py (created) + +[✓] Story 10.0: Frontend - Check habit interaction + Commit: 775f171 + Date: 2026-02-10 + + Implementation: + - Added circular checkbox button to each habit card + - Checkbox positioned at start of card (before icon) + - Checkbox styled with border-radius: 50% for circular shape + - Checkbox shows check icon when checkedToday is true + - Checkbox has 'checked' and 'disabled' classes when already done today + - Clicking checkbox calls checkHabit(habitId, element) function + - checkHabit performs optimistic UI update (checks immediately) + - API call to POST /api/habits/{id}/check executed after UI update + - On success: streak element updated with response data, shows toast + - On error: checkbox reverts to unchecked state, shows error toast + - Checkbox is non-clickable (disabled) when already checked today + - Streak updates dynamically using id="streak-{habitId}" element + - Check icon reinitialized with lucide.createIcons() after adding + - Hover state for unchecked checkboxes (border-color change) + - All CSS uses CSS variables for theming consistency + + Tests: + - Created dashboard/test_habits_check_ui.py + - Tests for checkbox CSS (circular shape, checked/disabled states, hover) + - Tests for checkbox inclusion in createHabitCard function + - Tests for checkedToday state reflection in UI + - Tests for checkHabit function existence and signature + - Tests for API call to POST /api/habits/{id}/check + - Tests for optimistic UI update (classList.add before fetch) + - Tests for error handling and revert logic + - Tests for disabled state when already checked + - Tests for streak update from response data + - Tests for check icon display and lucide reinitialization + - All 10 tests pass ✓ + - All previous tests (schema, API endpoints, HTML, modal, display) still pass ✓ + + Files modified: + - dashboard/habits.html (added checkbox CSS and checkHabit function) + - dashboard/test_habits_check_ui.py (created) + +[✓] Story 11.0: Frontend - Create habit from form + Commit: 4933847 + Date: 2026-02-10 + + Implementation: + - Enhanced createHabit() async function with complete form submission flow + - Added loading state: button disabled during submission with "Se creează..." text + - Button disabled immediately on submit (before API call) + - Original button text stored and restored on error + - Input field cleared after successful creation (nameInput.value = '') + - Success flow: closes modal, shows success toast, reloads habits list + - Error flow: button re-enabled, modal stays open, shows error toast + - Both API errors (response.ok check) and network errors (catch block) handled + - Error messages displayed to user via toast notifications + - Modal stays open on error so user can retry without re-entering data + - All existing form validation preserved (empty name check, trim validation) + - Enter key submission still works with loading state integration + + Tests: + - Created dashboard/test_habits_form_submit.py with 9 comprehensive tests + - Tests for form submission API call (POST /api/habits with name and frequency) + - Tests for loading state (button disabled, text changed to "Se creează...") + - Tests for success behavior (modal closes, list refreshes, input cleared) + - Tests for error behavior (modal stays open, button re-enabled, error shown) + - Tests for input field clearing after successful creation + - Tests for preservation of existing form validation logic + - Tests for modal reset when opened (form cleared) + - Tests for Enter key submission integration + - Tests for all 6 acceptance criteria in summary test + - All 9 tests pass ✓ + - All previous tests (schema, API endpoints, HTML, modal, display, check) still pass ✓ + + Files modified: + - dashboard/habits.html (enhanced createHabit function) + - dashboard/test_habits_form_submit.py (created) + - dashboard/habits.json (reset to empty for testing) + +[✓] Story 12.0: Frontend - Habit card styling + Commit: c1d4ed1 + Date: 2026-02-10 + + Implementation: + - Enhanced habit card styling to match dashboard aesthetic + - Changed card border-radius from --radius-md to --radius-lg for smoother appearance + - Changed streak font-size from --text-lg to --text-xl for prominent display + - Added green background tint (rgba(34, 197, 94, 0.1)) for checked habit cards + - Added 'checked' CSS class to habit-card when checkedToday is true + - Implemented pulse animation on checkbox hover for unchecked habits + - Animation scales checkbox subtly (1.0 to 1.05) with 1.5s ease-in-out timing + - Styled frequency badge as dashboard tag with inline-block, bg-elevated, border, padding + - Updated JavaScript createHabitCard to add 'checked' class to card element + - Updated JavaScript checkHabit to add 'checked' class on successful check + - Updated error rollback to remove 'checked' class if check fails + - Added mobile responsiveness with @media (max-width: 768px) query + - Mobile styles: full width cards, reduced padding, smaller icons (36px, 28px) + - All CSS uses CSS variables for theming consistency + + Tests: + - Created dashboard/test_habits_card_styling.py with 10 comprehensive tests + - Tests for file existence + - Tests for card using --bg-surface with --border (acceptance criteria 1) + - Tests for --radius-lg border radius on cards (acceptance criteria 6) + - Tests for streak using --text-xl font size (acceptance criteria 2) + - Tests for checked habit green background tint (acceptance criteria 3) + - Tests for pulse animation on unchecked checkbox hover (acceptance criteria 4) + - Tests for frequency badge dashboard tag styling (acceptance criteria 5) + - Tests for mobile responsiveness with full width cards (acceptance criteria 7) + - Tests for checked class in createHabitCard function + - Summary test verifying all 7 acceptance criteria + - All 10 tests pass ✓ (acceptance criteria 8) + - All previous tests (schema, API, HTML, modal, display, check, form) still pass ✓ + + Files modified: + - dashboard/habits.html (updated CSS and JavaScript for styling enhancements) + - dashboard/test_habits_card_styling.py (created) + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +NEXT STEPS: +- Continue with remaining 3 stories + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +[✓] Story 13.0: Frontend - Add to dashboard navigation + Commit: 1d56fe3 + Date: 2026-02-10 + + Implementation: + - Added Habits link to index.html navigation (5th nav item) + - Link points to /echo/habits.html with flame icon (lucide) + - Changed habits.html Habits icon from "target" to "flame" for consistency + - Navigation structure matches existing pattern (nav-item class) + - Dashboard link already existed in habits.html (links back properly) + - Active state styling uses same pattern as other nav items + - Mobile navigation supported via shared swipe-nav.js + - All 5 navigation items now present on both pages + - Flame icon (🔥 lucide) used consistently across both pages + + Tests: + - Created dashboard/test_habits_navigation.py with 9 comprehensive tests + - Tests for file existence + - Tests for index.html Habits link to /echo/habits.html (AC1, AC2) + - Tests for flame icon usage in index.html (AC3) + - Tests for habits.html link back to dashboard (AC4) + - Tests for flame icon usage in habits.html (AC3) + - Tests for active state styling consistency (AC5) + - Tests for mobile navigation support via swipe-nav.js (AC6) + - Tests for navigation completeness (all 5 items on both pages) + - Summary test verifying all 7 acceptance criteria + - All 9 tests pass ✓ (AC7) + - All previous tests (schema, API endpoints, HTML, modal, display, check, form, styling) still pass ✓ (except schema test expects empty habits.json) + + Files modified: + - dashboard/index.html (added Habits nav link with flame icon) + - dashboard/habits.html (changed icon from target to flame) + - dashboard/test_habits_navigation.py (created) + + +[✓] Story 14.0: Frontend - Responsive mobile design + Commit: 0011664 + Date: 2026-02-10 + + Implementation: + - Enhanced mobile responsiveness for habit tracker + - Modal is now full-screen on mobile (< 768px): 100% width/height, no border-radius + - Touch targets increased to 44x44px for checkboxes (from 28px) + - All buttons have min-height: 44px on mobile (add-habit-btn, .btn, .radio-label) + - Form input uses autocapitalize="words" for mobile-optimized keyboard + - Form input uses autocomplete="off" to prevent autofill issues + - Habit cards already stack vertically via flex-direction: column + - Cards are 100% width on mobile for optimal space usage + - Swipe navigation already enabled via swipe-nav.js inclusion + - Responsive padding adjustments for .main on mobile + - Icon sizes adjusted for mobile (habit-icon: 36px, checkbox icons: 20px) + - All interactive elements meet WCAG touch target guidelines (44x44px minimum) + + Tests: + - Created dashboard/test_habits_mobile.py with 9 comprehensive tests + - Tests for mobile media query existence (@media max-width: 768px) + - Tests for modal full-screen on mobile (100% width/height, 100vh, no border-radius) [AC1] + - Tests for habit cards stacking vertically (flex-direction: column, 100% width) [AC2] + - Tests for touch targets >= 44x44px (checkbox: 44px, buttons: min-height 44px) [AC3] + - Tests for mobile-optimized keyboards (autocapitalize="words", autocomplete="off") [AC4] + - Tests for swipe navigation (swipe-nav.js, viewport meta tag) [AC5] + - Tests for all button sizing (add-habit-btn, .btn, .radio-label with min-height) + - Tests for responsive layout structure (.main padding adjustment) + - Summary test verifying all 6 acceptance criteria [AC6] + - All 9 tests pass ✓ + - All previous tests (HTML structure, modal, display, check, form, styling, navigation) still pass ✓ + + Files modified: + - dashboard/habits.html (enhanced mobile CSS, added input attributes) + - dashboard/test_habits_mobile.py (created) + +[✓] Story 15.0: Backend - Delete habit endpoint + Commit: 0f9c0de + Date: 2026-02-10 + + Implementation: + - Added do_DELETE method to api.py for handling DELETE requests + - Route: DELETE /api/habits/{id} deletes habit by ID + - Extracts habit ID from URL path (/api/habits/{id}) + - Returns 404 if habit not found in habits.json + - Removes habit from habits array using list.pop(index) + - Updates lastUpdated timestamp after deletion + - Returns 200 with success message, including deleted habit ID + - Graceful error handling for missing/corrupt habits.json (returns 404) + - Follows existing API patterns (similar to handle_habits_check) + - Error responses include descriptive error messages + + Tests: + - Created dashboard/test_habits_delete.py with 6 comprehensive tests + - Tests for habit removal from habits.json file (AC1) + - Tests for 200 status with success message response (AC2) + - Tests for 404 when habit not found (AC3) + - Tests for lastUpdated timestamp update (AC4) + - Tests for edge cases: deleting already deleted habit, invalid paths + - Tests for graceful handling when habits.json is missing + - All 6 tests pass ✓ (AC5) + - All previous tests (schema, GET, POST, streak, check) still pass ✓ + + Files modified: + - dashboard/api.py (added do_DELETE method and handle_habits_delete) + - dashboard/test_habits_delete.py (created) + +[✓] Story 16.0: Frontend - Delete habit with confirmation + Commit: 46dc3a5 + Date: 2026-02-10 + + Implementation: + - Added delete button with trash icon (lucide trash-2) to each habit card + - Delete button styled with 32x32px size, border, hover state with red color + - Hover state changes border and background to danger color (rgba(239, 68, 68, 0.1)) + - Created delete confirmation modal (id='deleteModal') with modal-overlay pattern + - Confirmation modal shows message: "Ștergi obișnuința {name}?" with habit name + - Modal includes Cancel button (btn-secondary) and Delete button (btn-danger) + - Delete button uses destructive red styling (.btn-danger class) + - Added showDeleteModal(habitId, habitName) function to display confirmation + - Added hideDeleteModal() function to close modal + - Added confirmDelete() async function to execute DELETE API call + - Delete button disabled during deletion with loading text "Se șterge..." + - On successful delete: hides modal, shows success toast, reloads habits list + - On error: shows error toast, re-enables delete button, keeps modal open + - Habit name properly escaped for XSS protection when passed to modal + - All styling uses CSS variables for theme consistency + + Tests: + - Created dashboard/test_habits_delete_ui.py with 10 comprehensive tests + - Tests for delete button CSS styling (size, border, hover, danger color) [AC1] + - Tests for trash-2 icon inclusion in habit cards [AC1] + - Tests for confirmation modal structure with Romanian message [AC2] + - Tests for Cancel and Delete buttons with correct handlers [AC3] + - Tests for btn-danger destructive red styling [AC4] + - Tests for DELETE API call to /api/habits/{id} endpoint [AC5] + - Tests for loadHabits() call after successful deletion (list refresh) [AC5] + - Tests for error handling with toast notification [AC6] + - Tests for modal show/hide functions and active class toggle + - Summary test verifying all 7 acceptance criteria [AC7] + - All 10 tests pass ✓ + - All previous tests (schema, API endpoints, HTML, modal, display, check, form, styling, navigation, mobile) still pass ✓ + + Files modified: + - dashboard/habits.html (added delete button, modal, CSS, and JavaScript functions) + - dashboard/test_habits_delete_ui.py (created) + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +NEXT STEPS: +- Continue with remaining 1 story (17.0) + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +[✓] Story 17.0: Integration - End-to-end habit lifecycle test + Commit: bf215f7 + Date: 2026-02-10 + + Implementation: + - Created dashboard/test_habits_integration.py with comprehensive integration tests + - Main test: test_complete_habit_lifecycle() covers full habit flow + - Test creates daily habit 'Bazin' via POST /api/habits + - Checks habit today via POST /api/habits/{id}/check (streak = 1) + - Simulates yesterday's check by manipulating habits.json file + - Verifies streak calculation is correct (streak = 2 for consecutive days) + - Deletes habit successfully via DELETE /api/habits/{id} + - Verifies habit no longer exists after deletion + - Additional test: test_broken_streak() validates gap detection (streak = 0) + - Additional test: test_weekly_habit_streak() validates weekly habit streaks + - Tests use HTTP test server on port 8765 in background thread + - Comprehensive validation of all API endpoints working together + - Proper setup/teardown with habits.json reset before/after tests + + Tests: + - Created dashboard/test_habits_integration.py + - Main integration test passes all 5 steps (create, check, simulate, verify, delete) + - Tests for daily habit creation and checking (AC1, AC2) + - Tests for simulating yesterday's check and streak = 2 (AC3, AC4) + - Tests for habit deletion after lifecycle (AC5) + - Additional tests for broken streaks (gap > 1 day returns 0) + - Additional tests for weekly habit streak calculation (2 consecutive weeks) + - All tests pass ✓ (AC6) + - All previous tests (schema, API endpoints, HTML, modal, display, check, form, styling, navigation, mobile, delete) still pass ✓ + + Files modified: + - dashboard/test_habits_integration.py (created) + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +FEATURE COMPLETE! 🎉 + +All 17 stories completed successfully: +- Data schema and backend API (7 stories) +- Frontend UI and interactions (10 stories) +- Comprehensive integration tests + +The Habit Tracker feature is now fully implemented and tested. +Users can create habits (daily/weekly), track completions, view streaks, +and delete habits. The feature includes responsive design, proper error handling, +and full integration with the dashboard navigation. + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/tools/anaf-monitor/monitor_v2.py b/tools/anaf-monitor/monitor_v2.py index 8ee230e..4fae3fd 100644 --- a/tools/anaf-monitor/monitor_v2.py +++ b/tools/anaf-monitor/monitor_v2.py @@ -313,17 +313,33 @@ def check_page(page, saved_versions, saved_hashes): log(f"OK: {page_id}") return None -def update_dashboard_status(has_changes, changes_count): +def update_dashboard_status(has_changes, changes_count, changes_list=None): """Actualizează status.json pentru dashboard""" try: status = load_json(DASHBOARD_STATUS, {}) - status['anaf'] = { + anaf_status = { 'ok': not has_changes, 'status': 'MODIFICĂRI' if has_changes else 'OK', 'message': f'{changes_count} modificări detectate' if has_changes else 'Nicio modificare detectată', 'lastCheck': datetime.now().strftime('%d %b %Y, %H:%M'), 'changesCount': changes_count } + + # Adaugă detaliile modificărilor pentru dashboard + if has_changes and changes_list: + anaf_status['changes'] = [] + for change in changes_list: + change_detail = { + 'name': change.get('name', ''), + 'url': change.get('url', ''), + 'summary': [] + } + # Ia primele 3 modificări ca rezumat + if change.get('changes'): + change_detail['summary'] = change['changes'][:3] + anaf_status['changes'].append(change_detail) + + status['anaf'] = anaf_status save_json(DASHBOARD_STATUS, status) except Exception as e: log(f"ERROR updating dashboard status: {e}") @@ -345,7 +361,7 @@ def main(): save_json(HASHES_FILE, saved_hashes) # Update dashboard status - update_dashboard_status(len(all_changes) > 0, len(all_changes)) + update_dashboard_status(len(all_changes) > 0, len(all_changes), all_changes) log("=== Monitor complete ===")