diff --git a/dashboard/api.py b/dashboard/api.py
new file mode 100644
index 0000000..19c750c
--- /dev/null
+++ b/dashboard/api.py
@@ -0,0 +1,2583 @@
+#!/usr/bin/env python3
+"""
+Simple API server for Echo Task Board.
+Handles YouTube summarization requests.
+"""
+
+import json
+import shutil
+import subprocess
+import sys
+import re
+import os
+import signal
+import uuid
+from http.server import HTTPServer, SimpleHTTPRequestHandler
+from urllib.parse import parse_qs, urlparse
+from datetime import datetime
+from pathlib import Path
+
+# Import habits helpers
+sys.path.insert(0, str(Path(__file__).parent))
+import habits_helpers
+
+BASE_DIR = Path(__file__).parent.parent
+TOOLS_DIR = BASE_DIR / 'tools'
+NOTES_DIR = BASE_DIR / 'kb' / 'youtube'
+KANBAN_DIR = BASE_DIR / 'dashboard'
+WORKSPACE_DIR = Path('/home/moltbot/workspace')
+HABITS_FILE = KANBAN_DIR / 'habits.json'
+
+# Eco (echo-core) constants
+ECO_SERVICES = ['echo-core', 'echo-whatsapp-bridge', 'echo-taskboard']
+ECHO_CORE_DIR = Path('/home/moltbot/echo-core')
+ECHO_LOG_FILE = ECHO_CORE_DIR / 'logs' / 'echo-core.log'
+ECHO_SESSIONS_FILE = ECHO_CORE_DIR / 'sessions' / 'active.json'
+
+# Load .env file if present
+_env_file = Path(__file__).parent / '.env'
+if _env_file.exists():
+ for line in _env_file.read_text().splitlines():
+ line = line.strip()
+ if line and not line.startswith('#') and '=' in line:
+ k, v = line.split('=', 1)
+ os.environ.setdefault(k.strip(), v.strip())
+
+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', '')
+
+class TaskBoardHandler(SimpleHTTPRequestHandler):
+
+ def do_POST(self):
+ if self.path == '/api/youtube':
+ self.handle_youtube()
+ elif self.path == '/api/files':
+ self.handle_files_post()
+ elif self.path == '/api/refresh-index':
+ self.handle_refresh_index()
+ elif self.path == '/api/git-commit':
+ self.handle_git_commit()
+ elif self.path == '/api/pdf':
+ self.handle_pdf_post()
+ elif self.path == '/api/habits':
+ self.handle_habits_post()
+ elif self.path.startswith('/api/habits/') and self.path.endswith('/check'):
+ self.handle_habits_check()
+ elif self.path.startswith('/api/habits/') and self.path.endswith('/skip'):
+ self.handle_habits_skip()
+ elif self.path == '/api/workspace/run':
+ self.handle_workspace_run()
+ elif self.path == '/api/workspace/stop':
+ self.handle_workspace_stop()
+ elif self.path == '/api/workspace/git/commit':
+ self.handle_workspace_git_commit()
+ elif self.path == '/api/workspace/git/push':
+ self.handle_workspace_git_push()
+ elif self.path == '/api/workspace/delete':
+ self.handle_workspace_delete()
+ elif self.path == '/api/eco/restart':
+ self.handle_eco_restart()
+ elif self.path == '/api/eco/stop':
+ self.handle_eco_stop()
+ elif self.path == '/api/eco/sessions/clear':
+ self.handle_eco_sessions_clear()
+ elif self.path == '/api/eco/git-commit':
+ self.handle_eco_git_commit()
+ elif self.path == '/api/eco/restart-taskboard':
+ self.handle_eco_restart_taskboard()
+ else:
+ self.send_error(404)
+
+ def do_PUT(self):
+ if self.path.startswith('/api/habits/'):
+ self.handle_habits_put()
+ else:
+ self.send_error(404)
+
+ def do_DELETE(self):
+ if self.path.startswith('/api/habits/') and '/check' in self.path:
+ self.handle_habits_uncheck()
+ elif self.path.startswith('/api/habits/'):
+ self.handle_habits_delete()
+ else:
+ self.send_error(404)
+
+ def handle_git_commit(self):
+ """Run git commit and push."""
+ try:
+ script = TOOLS_DIR / 'git_commit.py'
+ result = subprocess.run(
+ [sys.executable, str(script), '--push'],
+ capture_output=True,
+ text=True,
+ timeout=60,
+ cwd=str(BASE_DIR)
+ )
+
+ output = result.stdout + result.stderr
+
+ # Parse files count
+ files_match = re.search(r'Files changed: (\d+)', output)
+ files = int(files_match.group(1)) if files_match else 0
+
+ if result.returncode == 0 or 'Pushing...' in output:
+ self.send_json({
+ 'success': True,
+ 'files': files,
+ 'output': output
+ })
+ else:
+ self.send_json({
+ 'success': False,
+ 'error': output or 'Unknown error'
+ })
+ except Exception as e:
+ self.send_json({'success': False, 'error': str(e)}, 500)
+
+ def handle_refresh_index(self):
+ """Regenerate memory/kb/index.json"""
+ try:
+ script = TOOLS_DIR / 'update_notes_index.py'
+ result = subprocess.run(
+ [sys.executable, str(script)],
+ capture_output=True,
+ text=True,
+ timeout=30
+ )
+
+ if result.returncode == 0:
+ # Parse output for stats
+ output = result.stdout
+ total_match = re.search(r'with (\d+) notes', output)
+ total = int(total_match.group(1)) if total_match else 0
+
+ self.send_json({
+ 'success': True,
+ 'message': f'Index regenerat cu {total} notițe',
+ 'total': total,
+ 'output': output
+ })
+ else:
+ self.send_json({
+ 'success': False,
+ 'error': result.stderr or 'Unknown error'
+ }, 500)
+ except subprocess.TimeoutExpired:
+ self.send_json({'success': False, 'error': 'Timeout'}, 500)
+ except Exception as e:
+ self.send_json({'success': False, 'error': str(e)}, 500)
+
+ def handle_files_post(self):
+ """Save file content."""
+ try:
+ content_length = int(self.headers['Content-Length'])
+ post_data = self.rfile.read(content_length).decode('utf-8')
+ data = json.loads(post_data)
+
+ path = data.get('path', '')
+ content = data.get('content', '')
+
+ # Allow access to clawd and workspace
+ allowed_dirs = [
+ Path('/home/moltbot/clawd'),
+ Path('/home/moltbot/workspace')
+ ]
+
+ # Try to resolve against each allowed directory
+ target = None
+ workspace = None
+ for base in allowed_dirs:
+ try:
+ candidate = (base / path).resolve()
+ # Check if candidate is within ANY allowed directory (handles symlinks)
+ if any(str(candidate).startswith(str(d)) for d in allowed_dirs):
+ target = candidate
+ workspace = base
+ break
+ except:
+ continue
+
+ if target is None:
+ self.send_json({'error': 'Access denied'}, 403)
+ return
+
+ # Create parent dirs if needed
+ target.parent.mkdir(parents=True, exist_ok=True)
+
+ # Write file
+ target.write_text(content, encoding='utf-8')
+
+ self.send_json({
+ 'status': 'saved',
+ 'path': path,
+ 'size': len(content)
+ })
+ except Exception as e:
+ self.send_json({'error': str(e)}, 500)
+
+ def handle_pdf_post(self):
+ """Convert markdown to PDF (text-based, not image) using venv script."""
+ try:
+ content_length = int(self.headers['Content-Length'])
+ post_data = self.rfile.read(content_length).decode('utf-8')
+ data = json.loads(post_data)
+
+ markdown_content = data.get('markdown', '')
+ filename = data.get('filename', 'document.pdf')
+
+ if not markdown_content:
+ self.send_json({'error': 'No markdown content'}, 400)
+ return
+
+ # Call PDF generator script in venv
+ venv_python = BASE_DIR / 'venv' / 'bin' / 'python3'
+ pdf_script = TOOLS_DIR / 'generate_pdf.py'
+
+ if not venv_python.exists():
+ self.send_json({'error': 'Venv Python not found'}, 500)
+ return
+
+ if not pdf_script.exists():
+ self.send_json({'error': 'PDF generator script not found'}, 500)
+ return
+
+ # Prepare input JSON
+ input_data = json.dumps({
+ 'markdown': markdown_content,
+ 'filename': filename
+ })
+
+ # Call script with stdin
+ result = subprocess.run(
+ [str(venv_python), str(pdf_script)],
+ input=input_data.encode('utf-8'),
+ capture_output=True,
+ timeout=30
+ )
+
+ if result.returncode != 0:
+ # Error from script
+ error_msg = result.stderr.decode('utf-8', errors='replace')
+ try:
+ error_json = json.loads(error_msg)
+ self.send_json(error_json, 500)
+ except:
+ self.send_json({'error': error_msg}, 500)
+ return
+
+ # PDF bytes from stdout
+ pdf_bytes = result.stdout
+
+ # Send as file download
+ self.send_response(200)
+ self.send_header('Content-Type', 'application/pdf')
+ self.send_header('Content-Disposition', f'attachment; filename="{filename}"')
+ self.send_header('Content-Length', str(len(pdf_bytes)))
+ self.end_headers()
+ self.wfile.write(pdf_bytes)
+
+ except subprocess.TimeoutExpired:
+ self.send_json({'error': 'PDF generation timeout'}, 500)
+ except Exception as e:
+ self.send_json({'error': str(e)}, 500)
+
+ def do_GET(self):
+ if self.path == '/api/status':
+ self.send_json({'status': 'ok', 'time': datetime.now().isoformat()})
+ elif self.path == '/api/git' or self.path.startswith('/api/git?'):
+ self.handle_git_status()
+ elif self.path == '/api/agents' or self.path.startswith('/api/agents?'):
+ self.handle_agents_status()
+ elif self.path == '/api/cron' or self.path.startswith('/api/cron?'):
+ 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'):
+ self.handle_git_diff()
+ elif self.path == '/api/workspace' or self.path.startswith('/api/workspace?'):
+ self.handle_workspace_list()
+ elif self.path.startswith('/api/workspace/git/diff'):
+ self.handle_workspace_git_diff()
+ elif self.path.startswith('/api/workspace/logs'):
+ self.handle_workspace_logs()
+ elif self.path == '/api/eco/status' or self.path.startswith('/api/eco/status?'):
+ self.handle_eco_status()
+ elif self.path == '/api/eco/sessions' or self.path.startswith('/api/eco/sessions?'):
+ self.handle_eco_sessions()
+ elif self.path.startswith('/api/eco/sessions/content'):
+ self.handle_eco_session_content()
+ elif self.path.startswith('/api/eco/logs'):
+ self.handle_eco_logs()
+ elif self.path == '/api/eco/doctor':
+ self.handle_eco_doctor()
+ elif self.path == '/api/eco/git' or self.path.startswith('/api/eco/git?'):
+ self.handle_eco_git_status()
+ elif self.path.startswith('/api/'):
+ self.send_error(404)
+ else:
+ # Serve static files
+ super().do_GET()
+
+ def handle_git_status(self):
+ """Get git status for dashboard."""
+ try:
+ workspace = Path('/home/moltbot/clawd')
+
+ # Get current branch
+ branch = subprocess.run(
+ ['git', 'branch', '--show-current'],
+ cwd=workspace, capture_output=True, text=True, timeout=5
+ ).stdout.strip()
+
+ # Get last commit
+ last_commit = subprocess.run(
+ ['git', 'log', '-1', '--format=%h|%s|%cr'],
+ cwd=workspace, capture_output=True, text=True, timeout=5
+ ).stdout.strip()
+
+ commit_parts = last_commit.split('|') if last_commit else ['', '', '']
+
+ # Get uncommitted files
+ status_output = subprocess.run(
+ ['git', 'status', '--short'],
+ cwd=workspace, capture_output=True, text=True, timeout=5
+ ).stdout.strip()
+
+ uncommitted = status_output.split('\n') if status_output else []
+ uncommitted = [f for f in uncommitted if f.strip()]
+
+ # Get diff stats if there are uncommitted files
+ diff_stat = ''
+ if uncommitted:
+ diff_stat = subprocess.run(
+ ['git', 'diff', '--stat', '--cached'],
+ cwd=workspace, capture_output=True, text=True, timeout=5
+ ).stdout.strip()
+ if not diff_stat:
+ diff_stat = subprocess.run(
+ ['git', 'diff', '--stat'],
+ cwd=workspace, capture_output=True, text=True, timeout=5
+ ).stdout.strip()
+
+ # Parse uncommitted into structured format
+ # Format: XY PATH where XY is 2 chars (index + working tree status)
+ # Examples: "M AGENTS.md" (staged), " M tools.md" (unstaged), "?? file" (untracked)
+ # The format varies: sometimes 1 space after status, sometimes 2
+ uncommitted_parsed = []
+ for line in uncommitted:
+ if len(line) >= 2:
+ status = line[:2].strip() # Get 2 chars and strip whitespace
+ filepath = line[2:].strip() # Get everything after position 2 and strip
+ if filepath: # Only add if filepath is not empty
+ uncommitted_parsed.append({'status': status, 'path': filepath})
+
+ self.send_json({
+ 'branch': branch,
+ 'lastCommit': {
+ 'hash': commit_parts[0] if len(commit_parts) > 0 else '',
+ 'message': commit_parts[1] if len(commit_parts) > 1 else '',
+ 'time': commit_parts[2] if len(commit_parts) > 2 else ''
+ },
+ 'uncommitted': uncommitted,
+ 'uncommittedParsed': uncommitted_parsed,
+ 'uncommittedCount': len(uncommitted),
+ 'diffStat': diff_stat,
+ 'clean': len(uncommitted) == 0
+ })
+ except Exception as e:
+ self.send_json({'error': str(e)}, 500)
+
+ def handle_git_diff(self):
+ """Get git diff for a specific file."""
+ from urllib.parse import urlparse, parse_qs
+ parsed = urlparse(self.path)
+ params = parse_qs(parsed.query)
+
+ filepath = params.get('path', [''])[0]
+
+ if not filepath:
+ self.send_json({'error': 'path required'}, 400)
+ return
+
+ try:
+ workspace = Path('/home/moltbot/clawd')
+
+ # Security check
+ target = (workspace / filepath).resolve()
+ if not str(target).startswith(str(workspace)):
+ self.send_json({'error': 'Access denied'}, 403)
+ return
+
+ # Get diff (try staged first, then unstaged)
+ diff = subprocess.run(
+ ['git', 'diff', '--cached', '--', filepath],
+ cwd=workspace, capture_output=True, text=True, timeout=10
+ ).stdout
+
+ if not diff:
+ diff = subprocess.run(
+ ['git', 'diff', '--', filepath],
+ cwd=workspace, capture_output=True, text=True, timeout=10
+ ).stdout
+
+ # If still no diff, file might be untracked - show full content
+ if not diff:
+ status = subprocess.run(
+ ['git', 'status', '--short', '--', filepath],
+ cwd=workspace, capture_output=True, text=True, timeout=5
+ ).stdout.strip()
+
+ if status.startswith('??'):
+ # Untracked file - show as new
+ if target.exists():
+ content = target.read_text(encoding='utf-8', errors='replace')[:50000]
+ diff = f"+++ b/{filepath}\n" + '\n'.join(f'+{line}' for line in content.split('\n'))
+
+ self.send_json({
+ 'path': filepath,
+ 'diff': diff or 'No changes',
+ 'hasDiff': bool(diff)
+ })
+ except Exception as e:
+ self.send_json({'error': str(e)}, 500)
+
+ def handle_agents_status(self):
+ """Get agents status - fast version reading session files directly."""
+ try:
+ # Define known agents
+ agents_config = [
+ {'id': 'echo', 'name': 'Echo', 'emoji': '🌀'},
+ {'id': 'echo-work', 'name': 'Work', 'emoji': '⚡'},
+ {'id': 'echo-health', 'name': 'Health', 'emoji': '❤️'},
+ {'id': 'echo-growth', 'name': 'Growth', 'emoji': '🪜'},
+ {'id': 'echo-sprijin', 'name': 'Sprijin', 'emoji': '⭕'},
+ {'id': 'echo-scout', 'name': 'Scout', 'emoji': '⚜️'},
+ ]
+
+ # Check active sessions by reading session files directly (fast)
+ active_agents = set()
+ sessions_base = Path.home() / '.clawdbot' / 'agents'
+
+ if sessions_base.exists():
+ for agent_dir in sessions_base.iterdir():
+ if agent_dir.is_dir():
+ sessions_file = agent_dir / 'sessions' / 'sessions.json'
+ if sessions_file.exists():
+ try:
+ data = json.loads(sessions_file.read_text())
+ # sessions.json is an object with session keys
+ now = datetime.now().timestamp() * 1000
+ for key, sess in data.items():
+ if isinstance(sess, dict):
+ last_active = sess.get('updatedAt', 0)
+ if now - last_active < 30 * 60 * 1000: # 30 min
+ active_agents.add(agent_dir.name)
+ break
+ except:
+ pass
+
+ # Build response
+ agents = []
+ for cfg in agents_config:
+ agents.append({
+ 'id': cfg['id'],
+ 'name': cfg['name'],
+ 'emoji': cfg['emoji'],
+ 'active': cfg['id'] in active_agents
+ })
+
+ self.send_json({'agents': agents})
+ except Exception as e:
+ self.send_json({'error': str(e)}, 500)
+
+ def handle_cron_status(self):
+ """Get cron jobs status from ~/.clawdbot/cron/jobs.json"""
+ try:
+ jobs_file = Path.home() / '.clawdbot' / 'cron' / 'jobs.json'
+
+ if not jobs_file.exists():
+ self.send_json({'jobs': [], 'error': 'No jobs file found'})
+ return
+
+ data = json.loads(jobs_file.read_text())
+ all_jobs = data.get('jobs', [])
+
+ # Filter enabled jobs and format for dashboard
+ now_ms = datetime.now().timestamp() * 1000
+ today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
+ today_start_ms = today_start.timestamp() * 1000
+
+ jobs = []
+ for job in all_jobs:
+ if not job.get('enabled', False):
+ continue
+
+ # Parse cron expression to get time
+ schedule = job.get('schedule', {})
+ expr = schedule.get('expr', '')
+
+ # Simple cron parsing for display - convert UTC to Bucharest
+ parts = expr.split()
+ if len(parts) >= 2:
+ minute = parts[0]
+ hour = parts[1]
+ if minute.isdigit() and (hour.isdigit() or '-' in hour):
+ # Handle hour ranges like "7-17"
+ if '-' in hour:
+ hour_start, hour_end = hour.split('-')
+ hour = hour_start # Show first hour
+ # Convert UTC to Bucharest (UTC+2 winter, UTC+3 summer)
+ from datetime import timezone as dt_timezone
+ from zoneinfo import ZoneInfo
+ try:
+ bucharest = ZoneInfo('Europe/Bucharest')
+ utc_hour = int(hour)
+ utc_minute = int(minute)
+ # Create UTC datetime for today
+ utc_dt = datetime.now(dt_timezone.utc).replace(hour=utc_hour, minute=utc_minute, second=0, microsecond=0)
+ local_dt = utc_dt.astimezone(bucharest)
+ time_str = f"{local_dt.hour:02d}:{local_dt.minute:02d}"
+ except:
+ time_str = f"{int(hour):02d}:{int(minute):02d}"
+ else:
+ time_str = expr[:15]
+ else:
+ time_str = expr[:15]
+
+ # Check if ran today
+ state = job.get('state', {})
+ last_run = state.get('lastRunAtMs', 0)
+ ran_today = last_run >= today_start_ms
+ last_status = state.get('lastStatus', 'unknown')
+
+ jobs.append({
+ 'id': job.get('id'),
+ 'name': job.get('name'),
+ 'agentId': job.get('agentId'),
+ 'time': time_str,
+ 'schedule': expr,
+ 'ranToday': ran_today,
+ 'lastStatus': last_status if ran_today else None,
+ 'lastRunAtMs': last_run,
+ 'nextRunAtMs': state.get('nextRunAtMs')
+ })
+
+ # Sort by time
+ jobs.sort(key=lambda j: j['time'])
+
+ self.send_json({
+ 'jobs': jobs,
+ 'total': len(jobs),
+ 'ranToday': sum(1 for j in jobs if j['ranToday'])
+ })
+ except Exception as e:
+ self.send_json({'error': str(e)}, 500)
+
+ def handle_activity(self):
+ """Aggregate activity from multiple sources: cron jobs, git commits, file changes."""
+ from datetime import timezone as dt_timezone
+ from zoneinfo import ZoneInfo
+
+ try:
+ activities = []
+ bucharest = ZoneInfo('Europe/Bucharest')
+ workspace = Path('/home/moltbot/clawd')
+
+ # 1. Cron jobs ran today
+ try:
+ result = subprocess.run(
+ ['clawdbot', 'cron', 'list', '--json'],
+ capture_output=True, text=True, timeout=10
+ )
+ if result.returncode == 0:
+ cron_data = json.loads(result.stdout)
+ today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
+ today_start_ms = today_start.timestamp() * 1000
+
+ for job in cron_data.get('jobs', []):
+ state = job.get('state', {})
+ last_run = state.get('lastRunAtMs', 0)
+ if last_run >= today_start_ms:
+ run_time = datetime.fromtimestamp(last_run / 1000, tz=dt_timezone.utc)
+ local_time = run_time.astimezone(bucharest)
+ activities.append({
+ 'type': 'cron',
+ 'icon': 'clock',
+ 'text': f"Job: {job.get('name', 'unknown')}",
+ 'agent': job.get('agentId', 'echo'),
+ 'time': local_time.strftime('%H:%M'),
+ 'timestamp': last_run,
+ 'status': state.get('lastStatus', 'ok')
+ })
+ except:
+ pass
+
+ # 2. Git commits (last 24h)
+ try:
+ result = subprocess.run(
+ ['git', 'log', '--oneline', '--since=24 hours ago', '--format=%H|%s|%at'],
+ cwd=workspace, capture_output=True, text=True, timeout=10
+ )
+ if result.returncode == 0:
+ for line in result.stdout.strip().split('\n'):
+ if '|' in line:
+ parts = line.split('|')
+ if len(parts) >= 3:
+ commit_hash, message, timestamp = parts[0], parts[1], int(parts[2])
+ commit_time = datetime.fromtimestamp(timestamp, tz=dt_timezone.utc)
+ local_time = commit_time.astimezone(bucharest)
+ activities.append({
+ 'type': 'git',
+ 'icon': 'git-commit',
+ 'text': message[:60] + ('...' if len(message) > 60 else ''),
+ 'agent': 'git',
+ 'time': local_time.strftime('%H:%M'),
+ 'timestamp': timestamp * 1000,
+ 'commitHash': commit_hash[:8]
+ })
+ except:
+ pass
+
+ # 2b. Git uncommitted files
+ try:
+ result = subprocess.run(
+ ['git', 'status', '--short'],
+ cwd=workspace, capture_output=True, text=True, timeout=10
+ )
+ if result.returncode == 0 and result.stdout.strip():
+ for line in result.stdout.strip().split('\n'):
+ if len(line) >= 4:
+ # Git status format: XY filename (XY = 2 chars status)
+ # Handle both "M " and " M" formats
+ status = line[:2]
+ # Find filepath - skip status chars and any spaces
+ filepath = line[2:].lstrip()
+ if not filepath:
+ continue
+ status_clean = status.strip()
+ status_labels = {'M': 'modificat', 'A': 'adăugat', 'D': 'șters', '??': 'nou', 'R': 'redenumit'}
+ status_label = status_labels.get(status_clean, status_clean)
+ activities.append({
+ 'type': 'git-file',
+ 'icon': 'file-diff',
+ 'text': f"{filepath}",
+ 'agent': f"git ({status_label})",
+ 'time': 'acum',
+ 'timestamp': int(datetime.now().timestamp() * 1000),
+ 'path': filepath,
+ 'gitStatus': status_clean
+ })
+ except:
+ pass
+
+ # 3. Recent files in memory/kb/ (last 24h)
+ try:
+ kb_dir = workspace / 'kb'
+ cutoff = datetime.now().timestamp() - (24 * 3600)
+ for md_file in kb_dir.rglob('*.md'):
+ stat = md_file.stat()
+ if stat.st_mtime > cutoff:
+ file_time = datetime.fromtimestamp(stat.st_mtime, tz=dt_timezone.utc)
+ local_time = file_time.astimezone(bucharest)
+ rel_path = md_file.relative_to(workspace)
+ activities.append({
+ 'type': 'file',
+ 'icon': 'file-text',
+ 'text': f"Fișier: {md_file.name}",
+ 'agent': str(rel_path.parent),
+ 'time': local_time.strftime('%H:%M'),
+ 'timestamp': int(stat.st_mtime * 1000),
+ 'path': str(rel_path)
+ })
+ except:
+ pass
+
+ # 4. Tasks from tasks.json
+ try:
+ tasks_file = workspace / 'dashboard' / 'tasks.json'
+ if tasks_file.exists():
+ tasks_data = json.loads(tasks_file.read_text())
+ for col in tasks_data.get('columns', []):
+ for task in col.get('tasks', []):
+ ts_str = task.get('completed') or task.get('created', '')
+ if ts_str:
+ try:
+ ts = datetime.fromisoformat(ts_str.replace('Z', '+00:00'))
+ if ts.timestamp() > (datetime.now().timestamp() - 7 * 24 * 3600):
+ local_time = ts.astimezone(bucharest)
+ activities.append({
+ 'type': 'task',
+ 'icon': 'check-circle' if task.get('completed') else 'circle',
+ 'text': task.get('title', ''),
+ 'agent': task.get('agent', 'Echo'),
+ 'time': local_time.strftime('%d %b %H:%M'),
+ 'timestamp': int(ts.timestamp() * 1000),
+ 'status': 'done' if task.get('completed') else col['id']
+ })
+ except:
+ pass
+ except:
+ pass
+
+ # Sort by timestamp descending
+ activities.sort(key=lambda x: x.get('timestamp', 0), reverse=True)
+
+ # Limit to 30 items
+ activities = activities[:30]
+
+ self.send_json({
+ 'activities': activities,
+ 'total': len(activities)
+ })
+ 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
+ parsed = urlparse(self.path)
+ params = parse_qs(parsed.query)
+
+ path = params.get('path', [''])[0]
+ action = params.get('action', ['list'])[0]
+
+ # Security: only allow access within allowed directories
+ allowed_dirs = [
+ Path('/home/moltbot/clawd'),
+ Path('/home/moltbot/workspace')
+ ]
+
+ # Try to resolve against each allowed directory
+ target = None
+ workspace = None
+ for base in allowed_dirs:
+ try:
+ candidate = (base / path).resolve()
+ # Check if candidate is within ANY allowed directory (handles symlinks)
+ if any(str(candidate).startswith(str(d)) for d in allowed_dirs):
+ target = candidate
+ workspace = base
+ break
+ except:
+ continue
+
+ if target is None:
+ self.send_json({'error': 'Access denied'}, 403)
+ return
+
+ if action == 'list':
+ if not target.exists():
+ self.send_json({'error': 'Path not found'}, 404)
+ return
+
+ if target.is_file():
+ # Return file content
+ try:
+ content = target.read_text(encoding='utf-8', errors='replace')
+ self.send_json({
+ 'type': 'file',
+ 'path': path,
+ 'name': target.name,
+ 'content': content[:100000], # Limit to 100KB
+ 'size': target.stat().st_size,
+ 'truncated': target.stat().st_size > 100000
+ })
+ except Exception as e:
+ self.send_json({'error': str(e)}, 500)
+ else:
+ # List directory
+ items = []
+ try:
+ for item in sorted(target.iterdir()):
+ stat = item.stat()
+ # Build relative path from original request path
+ item_path = f"{path}/{item.name}" if path else item.name
+ items.append({
+ 'name': item.name,
+ 'type': 'dir' if item.is_dir() else 'file',
+ 'size': stat.st_size if item.is_file() else None,
+ 'mtime': stat.st_mtime,
+ 'path': item_path
+ })
+ self.send_json({
+ 'type': 'dir',
+ 'path': path,
+ 'items': items
+ })
+ except Exception as e:
+ self.send_json({'error': str(e)}, 500)
+ else:
+ self.send_json({'error': 'Unknown action'}, 400)
+
+ def handle_workspace_list(self):
+ """List projects in ~/workspace/ with Ralph status, git info, etc."""
+ try:
+ projects = []
+ if not WORKSPACE_DIR.exists():
+ self.send_json({'projects': []})
+ return
+
+ for project_dir in sorted(WORKSPACE_DIR.iterdir()):
+ if not project_dir.is_dir() or project_dir.name.startswith('.'):
+ continue
+
+ ralph_dir = project_dir / 'scripts' / 'ralph'
+ prd_json = ralph_dir / 'prd.json'
+ tasks_dir = project_dir / 'tasks'
+
+ proj = {
+ 'name': project_dir.name,
+ 'path': str(project_dir),
+ 'hasRalph': ralph_dir.exists(),
+ 'hasPrd': any(tasks_dir.glob('prd-*.md')) if tasks_dir.exists() else False,
+ 'hasMain': (project_dir / 'main.py').exists(),
+ 'hasVenv': (project_dir / 'venv').exists(),
+ 'hasReadme': (project_dir / 'README.md').exists(),
+ 'ralph': None,
+ 'process': {'running': False, 'pid': None, 'port': None},
+ 'git': None
+ }
+
+ # Ralph status
+ if prd_json.exists():
+ try:
+ prd = json.loads(prd_json.read_text())
+ stories = prd.get('userStories', [])
+ complete = sum(1 for s in stories if s.get('passes'))
+
+ # Check ralph PID
+ ralph_pid = None
+ ralph_running = False
+ pid_file = ralph_dir / '.ralph.pid'
+ if pid_file.exists():
+ try:
+ pid = int(pid_file.read_text().strip())
+ os.kill(pid, 0) # Check if alive
+ ralph_running = True
+ ralph_pid = pid
+ except (ValueError, ProcessLookupError, PermissionError):
+ pass
+
+ # Last iteration time from logs
+ last_iter = None
+ logs_dir = ralph_dir / 'logs'
+ if logs_dir.exists():
+ log_files = sorted(logs_dir.glob('iteration-*.log'), key=lambda f: f.stat().st_mtime, reverse=True)
+ if log_files:
+ mtime = log_files[0].stat().st_mtime
+ last_iter = datetime.fromtimestamp(mtime).strftime('%Y-%m-%d %H:%M')
+
+ tech = prd.get('techStack', {})
+ proj['ralph'] = {
+ 'running': ralph_running,
+ 'pid': ralph_pid,
+ 'storiesTotal': len(stories),
+ 'storiesComplete': complete,
+ 'lastIteration': last_iter,
+ 'stories': [
+ {'id': s.get('id', ''), 'title': s.get('title', ''), 'passes': s.get('passes', False)}
+ for s in stories
+ ]
+ }
+ proj['techStack'] = {
+ 'type': tech.get('type', ''),
+ 'commands': tech.get('commands', {}),
+ 'port': tech.get('port'),
+ }
+ except (json.JSONDecodeError, IOError):
+ pass
+
+ # Check if main.py is running
+ if proj['hasMain']:
+ try:
+ result = subprocess.run(
+ ['pgrep', '-f', f'python.*{project_dir.name}/main.py'],
+ capture_output=True, text=True, timeout=3
+ )
+ if result.stdout.strip():
+ pids = result.stdout.strip().split('\n')
+ port = None
+ if prd_json.exists():
+ try:
+ prd_data = json.loads(prd_json.read_text())
+ port = prd_data.get('techStack', {}).get('port')
+ except (json.JSONDecodeError, IOError):
+ pass
+ proj['process'] = {
+ 'running': True,
+ 'pid': int(pids[0]),
+ 'port': port
+ }
+ except Exception:
+ pass
+
+ # Git info
+ if (project_dir / '.git').exists():
+ try:
+ branch = subprocess.run(
+ ['git', 'branch', '--show-current'],
+ cwd=project_dir, capture_output=True, text=True, timeout=5
+ ).stdout.strip()
+
+ last_commit = subprocess.run(
+ ['git', 'log', '-1', '--format=%h - %s'],
+ cwd=project_dir, capture_output=True, text=True, timeout=5
+ ).stdout.strip()
+
+ status_out = subprocess.run(
+ ['git', 'status', '--short'],
+ cwd=project_dir, capture_output=True, text=True, timeout=5
+ ).stdout.strip()
+ uncommitted = len([l for l in status_out.split('\n') if l.strip()]) if status_out else 0
+
+ proj['git'] = {
+ 'branch': branch,
+ 'lastCommit': last_commit,
+ 'uncommitted': uncommitted
+ }
+ except Exception:
+ pass
+
+ projects.append(proj)
+
+ self.send_json({'projects': projects})
+ except Exception as e:
+ self.send_json({'error': str(e)}, 500)
+
+ def _read_post_json(self):
+ """Helper to read JSON POST body."""
+ content_length = int(self.headers['Content-Length'])
+ post_data = self.rfile.read(content_length).decode('utf-8')
+ return json.loads(post_data)
+
+ def _validate_project(self, name):
+ """Validate project name and return its path, or None."""
+ if not name or '/' in name or '..' in name:
+ return None
+ project_dir = WORKSPACE_DIR / name
+ if not project_dir.exists() or not project_dir.is_dir():
+ return None
+ # Ensure it resolves within workspace
+ if not str(project_dir.resolve()).startswith(str(WORKSPACE_DIR)):
+ return None
+ return project_dir
+
+ def handle_workspace_run(self):
+ """Start a project process (main.py, ralph.sh, or pytest)."""
+ try:
+ data = self._read_post_json()
+ project_name = data.get('project', '')
+ command = data.get('command', '')
+
+ project_dir = self._validate_project(project_name)
+ if not project_dir:
+ self.send_json({'success': False, 'error': 'Invalid project'}, 400)
+ return
+
+ allowed_commands = {'main', 'ralph', 'test'}
+ if command not in allowed_commands:
+ self.send_json({'success': False, 'error': f'Invalid command. Allowed: {", ".join(allowed_commands)}'}, 400)
+ return
+
+ ralph_dir = project_dir / 'scripts' / 'ralph'
+
+ if command == 'main':
+ main_py = project_dir / 'main.py'
+ if not main_py.exists():
+ self.send_json({'success': False, 'error': 'No main.py found'}, 404)
+ return
+
+ # Use venv python if available
+ venv_python = project_dir / 'venv' / 'bin' / 'python'
+ python_cmd = str(venv_python) if venv_python.exists() else sys.executable
+
+ log_path = ralph_dir / 'logs' / 'main.log' if ralph_dir.exists() else project_dir / 'main.log'
+ log_path.parent.mkdir(parents=True, exist_ok=True)
+
+ with open(log_path, 'a') as log_file:
+ proc = subprocess.Popen(
+ [python_cmd, 'main.py'],
+ cwd=str(project_dir),
+ stdout=log_file,
+ stderr=log_file,
+ start_new_session=True
+ )
+
+ self.send_json({'success': True, 'pid': proc.pid, 'log': str(log_path)})
+
+ elif command == 'ralph':
+ ralph_sh = ralph_dir / 'ralph.sh'
+ if not ralph_sh.exists():
+ self.send_json({'success': False, 'error': 'No ralph.sh found'}, 404)
+ return
+
+ log_path = ralph_dir / 'logs' / 'ralph.log'
+ log_path.parent.mkdir(parents=True, exist_ok=True)
+
+ with open(log_path, 'a') as log_file:
+ proc = subprocess.Popen(
+ ['bash', str(ralph_sh)],
+ cwd=str(project_dir),
+ stdout=log_file,
+ stderr=log_file,
+ start_new_session=True
+ )
+
+ # Write PID
+ pid_file = ralph_dir / '.ralph.pid'
+ pid_file.write_text(str(proc.pid))
+
+ self.send_json({'success': True, 'pid': proc.pid, 'log': str(log_path)})
+
+ elif command == 'test':
+ # Run pytest synchronously (with timeout)
+ venv_python = project_dir / 'venv' / 'bin' / 'python'
+ python_cmd = str(venv_python) if venv_python.exists() else sys.executable
+
+ result = subprocess.run(
+ [python_cmd, '-m', 'pytest', '-v', '--tb=short'],
+ cwd=str(project_dir),
+ capture_output=True, text=True,
+ timeout=120
+ )
+
+ self.send_json({
+ 'success': result.returncode == 0,
+ 'output': result.stdout + result.stderr,
+ 'returncode': result.returncode
+ })
+
+ except subprocess.TimeoutExpired:
+ self.send_json({'success': False, 'error': 'Test timeout (120s)'}, 500)
+ except Exception as e:
+ self.send_json({'success': False, 'error': str(e)}, 500)
+
+ def handle_workspace_stop(self):
+ """Stop a project process."""
+ try:
+ data = self._read_post_json()
+ project_name = data.get('project', '')
+ target = data.get('target', '')
+
+ project_dir = self._validate_project(project_name)
+ if not project_dir:
+ self.send_json({'success': False, 'error': 'Invalid project'}, 400)
+ return
+
+ if target not in ('main', 'ralph'):
+ self.send_json({'success': False, 'error': 'Invalid target. Use: main, ralph'}, 400)
+ return
+
+ if target == 'ralph':
+ pid_file = project_dir / 'scripts' / 'ralph' / '.ralph.pid'
+ if pid_file.exists():
+ try:
+ pid = int(pid_file.read_text().strip())
+ # Verify the process belongs to our user and is within workspace
+ proc_cwd = Path(f'/proc/{pid}/cwd').resolve()
+ if str(proc_cwd).startswith(str(WORKSPACE_DIR)):
+ os.killpg(os.getpgid(pid), signal.SIGTERM)
+ self.send_json({'success': True, 'message': f'Ralph stopped (PID {pid})'})
+ else:
+ self.send_json({'success': False, 'error': 'Process not in workspace'}, 403)
+ except ProcessLookupError:
+ self.send_json({'success': True, 'message': 'Process already stopped'})
+ except PermissionError:
+ self.send_json({'success': False, 'error': 'Permission denied'}, 403)
+ else:
+ self.send_json({'success': False, 'error': 'No PID file found'}, 404)
+
+ elif target == 'main':
+ # Find main.py process for this project
+ try:
+ result = subprocess.run(
+ ['pgrep', '-f', f'python.*{project_dir.name}/main.py'],
+ capture_output=True, text=True, timeout=3
+ )
+ if result.stdout.strip():
+ pid = int(result.stdout.strip().split('\n')[0])
+ proc_cwd = Path(f'/proc/{pid}/cwd').resolve()
+ if str(proc_cwd).startswith(str(WORKSPACE_DIR)):
+ os.kill(pid, signal.SIGTERM)
+ self.send_json({'success': True, 'message': f'Main stopped (PID {pid})'})
+ else:
+ self.send_json({'success': False, 'error': 'Process not in workspace'}, 403)
+ else:
+ self.send_json({'success': True, 'message': 'No running process found'})
+ except Exception as e:
+ self.send_json({'success': False, 'error': str(e)}, 500)
+
+ except Exception as e:
+ self.send_json({'success': False, 'error': str(e)}, 500)
+
+ def handle_workspace_git_diff(self):
+ """Get git diff for a workspace project."""
+ try:
+ parsed = urlparse(self.path)
+ params = parse_qs(parsed.query)
+ project_name = params.get('project', [''])[0]
+
+ project_dir = self._validate_project(project_name)
+ if not project_dir:
+ self.send_json({'error': 'Invalid project'}, 400)
+ return
+
+ if not (project_dir / '.git').exists():
+ self.send_json({'error': 'Not a git repository'}, 400)
+ return
+
+ status = subprocess.run(
+ ['git', 'status', '--short'],
+ cwd=str(project_dir), capture_output=True, text=True, timeout=10
+ ).stdout.strip()
+
+ diff = subprocess.run(
+ ['git', 'diff'],
+ cwd=str(project_dir), capture_output=True, text=True, timeout=10
+ ).stdout
+
+ diff_cached = subprocess.run(
+ ['git', 'diff', '--cached'],
+ cwd=str(project_dir), capture_output=True, text=True, timeout=10
+ ).stdout
+
+ combined_diff = ''
+ if diff_cached:
+ combined_diff += '=== Staged Changes ===\n' + diff_cached
+ if diff:
+ if combined_diff:
+ combined_diff += '\n'
+ combined_diff += '=== Unstaged Changes ===\n' + diff
+
+ self.send_json({
+ 'project': project_name,
+ 'status': status,
+ 'diff': combined_diff,
+ 'hasDiff': bool(status)
+ })
+ except subprocess.TimeoutExpired:
+ self.send_json({'error': 'Timeout'}, 500)
+ except Exception as e:
+ self.send_json({'error': str(e)}, 500)
+
+ def handle_workspace_git_commit(self):
+ """Commit all changes in a workspace project."""
+ try:
+ data = self._read_post_json()
+ project_name = data.get('project', '')
+ message = data.get('message', '').strip()
+
+ project_dir = self._validate_project(project_name)
+ if not project_dir:
+ self.send_json({'success': False, 'error': 'Invalid project'}, 400)
+ return
+
+ if not (project_dir / '.git').exists():
+ self.send_json({'success': False, 'error': 'Not a git repository'}, 400)
+ return
+
+ # Check if there's anything to commit
+ porcelain = subprocess.run(
+ ['git', 'status', '--porcelain'],
+ cwd=str(project_dir), capture_output=True, text=True, timeout=10
+ ).stdout.strip()
+
+ if not porcelain:
+ self.send_json({'success': False, 'error': 'Nothing to commit'})
+ return
+
+ files_changed = len([l for l in porcelain.split('\n') if l.strip()])
+
+ # Auto-message if empty
+ if not message:
+ now = datetime.now().strftime('%Y-%m-%d %H:%M')
+ message = f'Update: {now} ({files_changed} files)'
+
+ # Stage all and commit
+ subprocess.run(
+ ['git', 'add', '-A'],
+ cwd=str(project_dir), capture_output=True, text=True, timeout=10
+ )
+
+ result = subprocess.run(
+ ['git', 'commit', '-m', message],
+ cwd=str(project_dir), capture_output=True, text=True, timeout=30
+ )
+
+ output = result.stdout + result.stderr
+
+ if result.returncode == 0:
+ self.send_json({
+ 'success': True,
+ 'message': message,
+ 'output': output,
+ 'filesChanged': files_changed
+ })
+ else:
+ self.send_json({'success': False, 'error': output or 'Commit failed'})
+ except subprocess.TimeoutExpired:
+ self.send_json({'success': False, 'error': 'Timeout'}, 500)
+ except Exception as e:
+ self.send_json({'success': False, 'error': str(e)}, 500)
+
+ def _ensure_gitea_remote(self, project_dir, project_name):
+ """Create Gitea repo and add remote if no origin exists. Returns (ok, message)."""
+ import urllib.request
+
+ if not GITEA_TOKEN:
+ return False, 'GITEA_TOKEN not set'
+
+ # Create repo via Gitea API
+ api_url = f'{GITEA_URL}/api/v1/orgs/{GITEA_ORG}/repos'
+ payload = json.dumps({'name': project_name, 'private': True, 'auto_init': False}).encode()
+ req = urllib.request.Request(api_url, data=payload, method='POST', headers={
+ 'Authorization': f'token {GITEA_TOKEN}',
+ 'Content-Type': 'application/json'
+ })
+ try:
+ resp = urllib.request.urlopen(req, timeout=15)
+ resp.read()
+ except urllib.error.HTTPError as e:
+ body = e.read().decode(errors='replace')
+ if e.code == 409:
+ pass # repo already exists, fine
+ else:
+ return False, f'Gitea API error {e.code}: {body}'
+
+ # Add remote with token auth
+ remote_url = f'{GITEA_URL}/{GITEA_ORG}/{project_name}.git'
+ # Insert token into URL for push auth
+ auth_url = remote_url.replace('https://', f'https://gitea:{GITEA_TOKEN}@')
+ subprocess.run(
+ ['git', 'remote', 'add', 'origin', auth_url],
+ cwd=str(project_dir), capture_output=True, text=True, timeout=5
+ )
+ return True, f'Created repo {GITEA_ORG}/{project_name}'
+
+ def handle_workspace_git_push(self):
+ """Push a workspace project to its remote, creating Gitea repo if needed."""
+ try:
+ data = self._read_post_json()
+ project_name = data.get('project', '')
+
+ project_dir = self._validate_project(project_name)
+ if not project_dir:
+ self.send_json({'success': False, 'error': 'Invalid project'}, 400)
+ return
+
+ if not (project_dir / '.git').exists():
+ self.send_json({'success': False, 'error': 'Not a git repository'}, 400)
+ return
+
+ created_msg = ''
+ # Check remote exists, create if not
+ remote_check = subprocess.run(
+ ['git', 'remote', 'get-url', 'origin'],
+ cwd=str(project_dir), capture_output=True, text=True, timeout=10
+ )
+ if remote_check.returncode != 0:
+ ok, msg = self._ensure_gitea_remote(project_dir, project_name)
+ if not ok:
+ self.send_json({'success': False, 'error': msg})
+ return
+ created_msg = msg + '\n'
+
+ # Push (set upstream on first push)
+ result = subprocess.run(
+ ['git', 'push', '-u', 'origin', 'HEAD'],
+ cwd=str(project_dir), capture_output=True, text=True, timeout=60
+ )
+
+ output = result.stdout + result.stderr
+
+ if result.returncode == 0:
+ self.send_json({'success': True, 'output': created_msg + (output or 'Pushed successfully')})
+ else:
+ self.send_json({'success': False, 'error': output or 'Push failed'})
+ except subprocess.TimeoutExpired:
+ self.send_json({'success': False, 'error': 'Push timeout (60s)'}, 500)
+ except Exception as e:
+ self.send_json({'success': False, 'error': str(e)}, 500)
+
+ def handle_workspace_delete(self):
+ """Delete a workspace project."""
+ try:
+ data = self._read_post_json()
+ project_name = data.get('project', '')
+ confirm = data.get('confirm', '')
+
+ project_dir = self._validate_project(project_name)
+ if not project_dir:
+ self.send_json({'success': False, 'error': 'Invalid project'}, 400)
+ return
+
+ if confirm != project_name:
+ self.send_json({'success': False, 'error': 'Confirmation does not match project name'}, 400)
+ return
+
+ # Check for running processes
+ try:
+ result = subprocess.run(
+ ['pgrep', '-f', f'{project_dir.name}/(main\\.py|ralph)'],
+ capture_output=True, text=True, timeout=5
+ )
+ if result.stdout.strip():
+ self.send_json({'success': False, 'error': 'Project has running processes. Stop them first.'})
+ return
+ except subprocess.TimeoutExpired:
+ pass
+
+ shutil.rmtree(str(project_dir))
+
+ self.send_json({
+ 'success': True,
+ 'message': f'Project {project_name} deleted'
+ })
+ except Exception as e:
+ self.send_json({'success': False, 'error': str(e)}, 500)
+
+ def handle_workspace_logs(self):
+ """Get last N lines from a project log."""
+ try:
+ parsed = urlparse(self.path)
+ params = parse_qs(parsed.query)
+
+ project_name = params.get('project', [''])[0]
+ log_type = params.get('type', ['ralph'])[0]
+ lines_count = min(int(params.get('lines', ['100'])[0]), 500)
+
+ project_dir = self._validate_project(project_name)
+ if not project_dir:
+ self.send_json({'error': 'Invalid project'}, 400)
+ return
+
+ ralph_dir = project_dir / 'scripts' / 'ralph'
+
+ # Determine log file
+ if log_type == 'ralph':
+ log_file = ralph_dir / 'logs' / 'ralph.log'
+ if not log_file.exists():
+ # Try ralph-test.log
+ log_file = ralph_dir / 'logs' / 'ralph-test.log'
+ elif log_type == 'main':
+ log_file = ralph_dir / 'logs' / 'main.log' if ralph_dir.exists() else project_dir / 'main.log'
+ elif log_type == 'progress':
+ log_file = ralph_dir / 'progress.txt'
+ else:
+ # Try iteration log
+ if log_type.startswith('iteration-'):
+ log_file = ralph_dir / 'logs' / f'{log_type}.log'
+ else:
+ self.send_json({'error': 'Invalid log type'}, 400)
+ return
+
+ if not log_file.exists():
+ self.send_json({
+ 'project': project_name,
+ 'type': log_type,
+ 'lines': [],
+ 'total': 0
+ })
+ return
+
+ # Security: ensure path is within workspace
+ if not str(log_file.resolve()).startswith(str(WORKSPACE_DIR)):
+ self.send_json({'error': 'Access denied'}, 403)
+ return
+
+ content = log_file.read_text(encoding='utf-8', errors='replace')
+ all_lines = content.split('\n')
+ total = len(all_lines)
+ last_lines = all_lines[-lines_count:] if len(all_lines) > lines_count else all_lines
+
+ self.send_json({
+ 'project': project_name,
+ 'type': log_type,
+ 'lines': last_lines,
+ 'total': total
+ })
+ except Exception as e:
+ self.send_json({'error': str(e)}, 500)
+
+ def handle_youtube(self):
+ try:
+ content_length = int(self.headers['Content-Length'])
+ post_data = self.rfile.read(content_length).decode('utf-8')
+ data = json.loads(post_data)
+
+ url = data.get('url', '').strip()
+
+ if not url or 'youtube.com' not in url and 'youtu.be' not in url:
+ self.send_json({'error': 'URL YouTube invalid'}, 400)
+ return
+
+ # Process synchronously (simpler, avoids fork issues)
+ try:
+ print(f"Processing YouTube URL: {url}")
+ result = process_youtube(url)
+ print(f"Processing result: {result}")
+ self.send_json({
+ 'status': 'done',
+ 'message': 'Notița a fost creată! Refresh pagina Notes.'
+ })
+ except Exception as e:
+ import traceback
+ print(f"YouTube processing error: {e}")
+ traceback.print_exc()
+ self.send_json({
+ 'status': 'error',
+ 'message': f'Eroare: {str(e)}'
+ }, 500)
+
+ except Exception as e:
+ self.send_json({'error': str(e)}, 500)
+
+ def handle_habits_get(self):
+ """Get all habits with enriched stats."""
+ try:
+ # Read habits file
+ if not HABITS_FILE.exists():
+ self.send_json([])
+ return
+
+ with open(HABITS_FILE, 'r', encoding='utf-8') as f:
+ data = json.load(f)
+
+ habits = data.get('habits', [])
+
+ # Enrich each habit with calculated stats
+ enriched_habits = []
+ for habit in habits:
+ # Calculate stats using helpers
+ current_streak = habits_helpers.calculate_streak(habit)
+ best_streak = habit.get('streak', {}).get('best', 0)
+ completion_rate = habits_helpers.get_completion_rate(habit, days=30)
+ weekly_summary = habits_helpers.get_weekly_summary(habit)
+
+ # Add stats to habit
+ enriched = habit.copy()
+ enriched['current_streak'] = current_streak
+ enriched['best_streak'] = best_streak
+ enriched['completion_rate_30d'] = completion_rate
+ enriched['weekly_summary'] = weekly_summary
+ enriched['should_check_today'] = habits_helpers.should_check_today(habit)
+
+ enriched_habits.append(enriched)
+
+ # Sort by priority ascending (lower number = higher priority)
+ enriched_habits.sort(key=lambda h: h.get('priority', 999))
+
+ self.send_json(enriched_habits)
+ except Exception as e:
+ self.send_json({'error': str(e)}, 500)
+
+ def handle_habits_post(self):
+ """Create a new habit."""
+ try:
+ # Read request body
+ content_length = int(self.headers['Content-Length'])
+ post_data = self.rfile.read(content_length).decode('utf-8')
+ data = json.loads(post_data)
+
+ # Validate required fields
+ name = data.get('name', '').strip()
+ if not name:
+ self.send_json({'error': 'name is required'}, 400)
+ return
+
+ if len(name) > 100:
+ self.send_json({'error': 'name must be max 100 characters'}, 400)
+ return
+
+ # Validate color (hex format)
+ color = data.get('color', '#3b82f6')
+ if color and not re.match(r'^#[0-9A-Fa-f]{6}$', color):
+ self.send_json({'error': 'color must be valid hex format (#RRGGBB)'}, 400)
+ return
+
+ # Validate frequency type
+ frequency_type = data.get('frequency', {}).get('type', 'daily')
+ valid_types = ['daily', 'specific_days', 'x_per_week', 'weekly', 'monthly', 'custom']
+ if frequency_type not in valid_types:
+ self.send_json({'error': f'frequency.type must be one of: {", ".join(valid_types)}'}, 400)
+ return
+
+ # Create new habit
+ habit_id = str(uuid.uuid4())
+ now = datetime.now().isoformat()
+
+ new_habit = {
+ 'id': habit_id,
+ 'name': name,
+ 'category': data.get('category', 'other'),
+ 'color': color,
+ 'icon': data.get('icon', 'check-circle'),
+ 'priority': data.get('priority', 5),
+ 'notes': data.get('notes', ''),
+ 'reminderTime': data.get('reminderTime', ''),
+ 'frequency': data.get('frequency', {'type': 'daily'}),
+ 'streak': {
+ 'current': 0,
+ 'best': 0,
+ 'lastCheckIn': None
+ },
+ 'lives': 3,
+ 'completions': [],
+ 'createdAt': now,
+ 'updatedAt': now
+ }
+
+ # Read existing habits
+ if HABITS_FILE.exists():
+ with open(HABITS_FILE, 'r', encoding='utf-8') as f:
+ habits_data = json.load(f)
+ else:
+ habits_data = {'lastUpdated': '', 'habits': []}
+
+ # Add new habit
+ habits_data['habits'].append(new_habit)
+ habits_data['lastUpdated'] = now
+
+ # Save to file
+ with open(HABITS_FILE, 'w', encoding='utf-8') as f:
+ json.dump(habits_data, f, indent=2)
+
+ # Return created habit with 201 status
+ self.send_json(new_habit, 201)
+ except json.JSONDecodeError:
+ self.send_json({'error': 'Invalid JSON'}, 400)
+ except Exception as e:
+ self.send_json({'error': str(e)}, 500)
+
+ def handle_habits_put(self):
+ """Update an existing habit."""
+ try:
+ # Extract habit ID from path
+ path_parts = self.path.split('/')
+ if len(path_parts) < 4:
+ self.send_json({'error': 'Invalid path'}, 400)
+ return
+
+ habit_id = path_parts[3]
+
+ # Read request body
+ content_length = int(self.headers['Content-Length'])
+ post_data = self.rfile.read(content_length).decode('utf-8')
+ data = json.loads(post_data)
+
+ # Read existing habits
+ if not HABITS_FILE.exists():
+ self.send_json({'error': 'Habit not found'}, 404)
+ return
+
+ with open(HABITS_FILE, 'r', encoding='utf-8') as f:
+ habits_data = json.load(f)
+
+ # Find habit to update
+ habits = habits_data.get('habits', [])
+ habit_index = None
+ for i, habit in enumerate(habits):
+ if habit['id'] == habit_id:
+ habit_index = i
+ break
+
+ if habit_index is None:
+ self.send_json({'error': 'Habit not found'}, 404)
+ return
+
+ # Validate allowed fields
+ allowed_fields = ['name', 'category', 'color', 'icon', 'priority', 'notes', 'frequency', 'reminderTime']
+
+ # Validate name if provided
+ if 'name' in data:
+ name = data['name'].strip()
+ if not name:
+ self.send_json({'error': 'name cannot be empty'}, 400)
+ return
+ if len(name) > 100:
+ self.send_json({'error': 'name must be max 100 characters'}, 400)
+ return
+
+ # Validate color if provided
+ if 'color' in data:
+ color = data['color']
+ if color and not re.match(r'^#[0-9A-Fa-f]{6}$', color):
+ self.send_json({'error': 'color must be valid hex format (#RRGGBB)'}, 400)
+ return
+
+ # Validate frequency type if provided
+ if 'frequency' in data:
+ frequency_type = data.get('frequency', {}).get('type', 'daily')
+ valid_types = ['daily', 'specific_days', 'x_per_week', 'weekly', 'monthly', 'custom']
+ if frequency_type not in valid_types:
+ self.send_json({'error': f'frequency.type must be one of: {", ".join(valid_types)}'}, 400)
+ return
+
+ # Update only allowed fields
+ habit = habits[habit_index]
+ for field in allowed_fields:
+ if field in data:
+ habit[field] = data[field]
+
+ # Update timestamp
+ habit['updatedAt'] = datetime.now().isoformat()
+
+ # Save to file
+ habits_data['lastUpdated'] = datetime.now().isoformat()
+ with open(HABITS_FILE, 'w', encoding='utf-8') as f:
+ json.dump(habits_data, f, indent=2)
+
+ # Return updated habit
+ self.send_json(habit)
+ except json.JSONDecodeError:
+ self.send_json({'error': 'Invalid JSON'}, 400)
+ except Exception as e:
+ self.send_json({'error': str(e)}, 500)
+
+ def handle_habits_delete(self):
+ """Delete a habit."""
+ try:
+ # Extract habit ID from path
+ path_parts = self.path.split('/')
+ if len(path_parts) < 4:
+ self.send_json({'error': 'Invalid path'}, 400)
+ return
+
+ habit_id = path_parts[3]
+
+ # Read existing habits
+ if not HABITS_FILE.exists():
+ self.send_json({'error': 'Habit not found'}, 404)
+ return
+
+ with open(HABITS_FILE, 'r', encoding='utf-8') as f:
+ habits_data = json.load(f)
+
+ # Find and remove habit
+ habits = habits_data.get('habits', [])
+ habit_found = False
+ for i, habit in enumerate(habits):
+ if habit['id'] == habit_id:
+ habits.pop(i)
+ habit_found = True
+ break
+
+ if not habit_found:
+ self.send_json({'error': 'Habit not found'}, 404)
+ return
+
+ # Save to file
+ habits_data['lastUpdated'] = datetime.now().isoformat()
+ with open(HABITS_FILE, 'w', encoding='utf-8') as f:
+ json.dump(habits_data, f, indent=2)
+
+ # Return 204 No Content
+ self.send_response(204)
+ self.send_header('Access-Control-Allow-Origin', '*')
+ self.end_headers()
+ except Exception as e:
+ self.send_json({'error': str(e)}, 500)
+
+ def handle_habits_check(self):
+ """Check in on a habit (complete it for today)."""
+ try:
+ # Extract habit ID from path (/api/habits/{id}/check)
+ path_parts = self.path.split('/')
+ if len(path_parts) < 5:
+ self.send_json({'error': 'Invalid path'}, 400)
+ return
+
+ habit_id = path_parts[3]
+
+ # Read optional body (note, rating, mood)
+ body_data = {}
+ content_length = self.headers.get('Content-Length')
+ if content_length:
+ post_data = self.rfile.read(int(content_length)).decode('utf-8')
+ if post_data.strip():
+ try:
+ body_data = json.loads(post_data)
+ except json.JSONDecodeError:
+ self.send_json({'error': 'Invalid JSON'}, 400)
+ return
+
+ # Read existing habits
+ if not HABITS_FILE.exists():
+ self.send_json({'error': 'Habit not found'}, 404)
+ return
+
+ with open(HABITS_FILE, 'r', encoding='utf-8') as f:
+ habits_data = json.load(f)
+
+ # Find habit
+ habit = None
+ for h in habits_data.get('habits', []):
+ if h['id'] == habit_id:
+ habit = h
+ break
+
+ if not habit:
+ self.send_json({'error': 'Habit not found'}, 404)
+ return
+
+ # Verify habit is relevant for today
+ if not habits_helpers.should_check_today(habit):
+ self.send_json({'error': 'Habit is not relevant for today based on its frequency'}, 400)
+ return
+
+ # Verify not already checked today
+ today = datetime.now().date().isoformat()
+ completions = habit.get('completions', [])
+ for completion in completions:
+ if completion.get('date') == today:
+ self.send_json({'error': 'Habit already checked in today'}, 409)
+ return
+
+ # Create completion entry
+ completion_entry = {
+ 'date': today,
+ 'type': 'check' # Distinguish from 'skip' for life restore logic
+ }
+
+ # Add optional fields
+ if 'note' in body_data:
+ completion_entry['note'] = body_data['note']
+ if 'rating' in body_data:
+ rating = body_data['rating']
+ if not isinstance(rating, int) or rating < 1 or rating > 5:
+ self.send_json({'error': 'rating must be an integer between 1 and 5'}, 400)
+ return
+ completion_entry['rating'] = rating
+ if 'mood' in body_data:
+ mood = body_data['mood']
+ if mood not in ['happy', 'neutral', 'sad']:
+ self.send_json({'error': 'mood must be one of: happy, neutral, sad'}, 400)
+ return
+ completion_entry['mood'] = mood
+
+ # Add completion to habit
+ habit['completions'].append(completion_entry)
+
+ # Recalculate streak
+ current_streak = habits_helpers.calculate_streak(habit)
+ habit['streak']['current'] = current_streak
+
+ # Update best streak if current is higher
+ if current_streak > habit['streak']['best']:
+ habit['streak']['best'] = current_streak
+
+ # Update lastCheckIn
+ habit['streak']['lastCheckIn'] = today
+
+ # Check for weekly lives recovery (+1 life if ≥1 check-in in previous week)
+ new_lives, was_awarded = habits_helpers.check_and_award_weekly_lives(habit)
+ lives_awarded_this_checkin = False
+
+ if was_awarded:
+ habit['lives'] = new_lives
+ habit['lastLivesAward'] = today
+ lives_awarded_this_checkin = True
+
+ # Update timestamp
+ habit['updatedAt'] = datetime.now().isoformat()
+ habits_data['lastUpdated'] = habit['updatedAt']
+
+ # Save to file
+ with open(HABITS_FILE, 'w', encoding='utf-8') as f:
+ json.dump(habits_data, f, indent=2)
+
+ # Enrich habit with calculated stats before returning
+ current_streak = habits_helpers.calculate_streak(habit)
+ best_streak = habit.get('streak', {}).get('best', 0)
+ completion_rate = habits_helpers.get_completion_rate(habit, days=30)
+ weekly_summary = habits_helpers.get_weekly_summary(habit)
+
+ enriched_habit = habit.copy()
+ enriched_habit['current_streak'] = current_streak
+ enriched_habit['best_streak'] = best_streak
+ enriched_habit['completion_rate_30d'] = completion_rate
+ enriched_habit['weekly_summary'] = weekly_summary
+ enriched_habit['should_check_today'] = habits_helpers.should_check_today(habit)
+ enriched_habit['livesAwarded'] = lives_awarded_this_checkin
+
+ # Return enriched habit
+ self.send_json(enriched_habit, 200)
+ except Exception as e:
+ self.send_json({'error': str(e)}, 500)
+
+ def handle_habits_uncheck(self):
+ """Uncheck a habit (remove completion for a specific date)."""
+ try:
+ # Extract habit ID from path (/api/habits/{id}/check)
+ path_parts = self.path.split('?')[0].split('/')
+ if len(path_parts) < 5:
+ self.send_json({'error': 'Invalid path'}, 400)
+ return
+
+ habit_id = path_parts[3]
+
+ # Parse query string for date parameter
+ parsed = urlparse(self.path)
+ query_params = parse_qs(parsed.query)
+
+ # Get date from query string (required)
+ if 'date' not in query_params:
+ self.send_json({'error': 'date parameter is required (format: YYYY-MM-DD)'}, 400)
+ return
+
+ target_date = query_params['date'][0]
+
+ # Validate date format
+ try:
+ datetime.fromisoformat(target_date)
+ except ValueError:
+ self.send_json({'error': 'Invalid date format. Use YYYY-MM-DD'}, 400)
+ return
+
+ # Read existing habits
+ if not HABITS_FILE.exists():
+ self.send_json({'error': 'Habit not found'}, 404)
+ return
+
+ with open(HABITS_FILE, 'r', encoding='utf-8') as f:
+ habits_data = json.load(f)
+
+ # Find habit
+ habit = None
+ for h in habits_data.get('habits', []):
+ if h['id'] == habit_id:
+ habit = h
+ break
+
+ if not habit:
+ self.send_json({'error': 'Habit not found'}, 404)
+ return
+
+ # Find and remove the completion for the specified date
+ completions = habit.get('completions', [])
+ completion_found = False
+ for i, completion in enumerate(completions):
+ if completion.get('date') == target_date:
+ completions.pop(i)
+ completion_found = True
+ break
+
+ if not completion_found:
+ self.send_json({'error': 'No completion found for the specified date'}, 404)
+ return
+
+ # Recalculate streak after removing completion
+ current_streak = habits_helpers.calculate_streak(habit)
+ habit['streak']['current'] = current_streak
+
+ # Update best streak if needed (best never decreases, but we keep it for consistency)
+ if current_streak > habit['streak']['best']:
+ habit['streak']['best'] = current_streak
+
+ # Update timestamp
+ habit['updatedAt'] = datetime.now().isoformat()
+ habits_data['lastUpdated'] = habit['updatedAt']
+
+ # Save to file
+ with open(HABITS_FILE, 'w', encoding='utf-8') as f:
+ json.dump(habits_data, f, indent=2)
+
+ # Enrich habit with calculated stats before returning
+ best_streak = habit.get('streak', {}).get('best', 0)
+ completion_rate = habits_helpers.get_completion_rate(habit, days=30)
+ weekly_summary = habits_helpers.get_weekly_summary(habit)
+
+ enriched_habit = habit.copy()
+ enriched_habit['current_streak'] = current_streak
+ enriched_habit['best_streak'] = best_streak
+ enriched_habit['completion_rate_30d'] = completion_rate
+ enriched_habit['weekly_summary'] = weekly_summary
+ enriched_habit['should_check_today'] = habits_helpers.should_check_today(habit)
+
+ # Return enriched habit
+ self.send_json(enriched_habit, 200)
+ except Exception as e:
+ self.send_json({'error': str(e)}, 500)
+
+ def handle_habits_skip(self):
+ """Skip a day using a life to preserve streak."""
+ try:
+ # Extract habit ID from path (/api/habits/{id}/skip)
+ path_parts = self.path.split('/')
+ if len(path_parts) < 5:
+ self.send_json({'error': 'Invalid path'}, 400)
+ return
+
+ habit_id = path_parts[3]
+
+ # Read existing habits
+ if not HABITS_FILE.exists():
+ self.send_json({'error': 'Habit not found'}, 404)
+ return
+
+ with open(HABITS_FILE, 'r', encoding='utf-8') as f:
+ habits_data = json.load(f)
+
+ # Find habit
+ habit = None
+ for h in habits_data.get('habits', []):
+ if h['id'] == habit_id:
+ habit = h
+ break
+
+ if not habit:
+ self.send_json({'error': 'Habit not found'}, 404)
+ return
+
+ # Verify lives > 0
+ current_lives = habit.get('lives', 3)
+ if current_lives <= 0:
+ self.send_json({'error': 'No lives remaining'}, 400)
+ return
+
+ # Decrement lives by 1
+ habit['lives'] = current_lives - 1
+
+ # Add completion entry with type='skip'
+ today = datetime.now().date().isoformat()
+ completion_entry = {
+ 'date': today,
+ 'type': 'skip'
+ }
+ habit['completions'].append(completion_entry)
+
+ # Update timestamp
+ habit['updatedAt'] = datetime.now().isoformat()
+ habits_data['lastUpdated'] = habit['updatedAt']
+
+ # Save to file
+ with open(HABITS_FILE, 'w', encoding='utf-8') as f:
+ json.dump(habits_data, f, indent=2)
+
+ # Enrich habit with calculated stats before returning
+ current_streak = habits_helpers.calculate_streak(habit)
+ best_streak = habit.get('streak', {}).get('best', 0)
+ completion_rate = habits_helpers.get_completion_rate(habit, days=30)
+ weekly_summary = habits_helpers.get_weekly_summary(habit)
+
+ enriched_habit = habit.copy()
+ enriched_habit['current_streak'] = current_streak
+ enriched_habit['best_streak'] = best_streak
+ enriched_habit['completion_rate_30d'] = completion_rate
+ enriched_habit['weekly_summary'] = weekly_summary
+ enriched_habit['should_check_today'] = habits_helpers.should_check_today(habit)
+
+ # Return enriched habit
+ self.send_json(enriched_habit, 200)
+ except Exception as e:
+ self.send_json({'error': str(e)}, 500)
+
+ # ── Eco (echo-core) handlers ──────────────────────────────────────
+
+ def handle_eco_status(self):
+ """Get status of echo-core services + active sessions."""
+ try:
+ services = []
+ for svc in ECO_SERVICES:
+ info = {'name': svc, 'active': False, 'pid': None, 'uptime': None, 'memory': None}
+
+ result = subprocess.run(
+ ['systemctl', '--user', 'is-active', svc],
+ capture_output=True, text=True, timeout=5
+ )
+ info['active'] = result.stdout.strip() == 'active'
+
+ if info['active']:
+ # PID
+ result = subprocess.run(
+ ['systemctl', '--user', 'show', '-p', 'MainPID', '--value', svc],
+ capture_output=True, text=True, timeout=5
+ )
+ pid = result.stdout.strip()
+ if pid and pid != '0':
+ info['pid'] = int(pid)
+
+ # Uptime via systemctl timestamp
+ try:
+ r = subprocess.run(
+ ['systemctl', '--user', 'show', '-p', 'ActiveEnterTimestamp', '--value', svc],
+ capture_output=True, text=True, timeout=5
+ )
+ ts = r.stdout.strip()
+ if ts:
+ start = datetime.strptime(ts, '%a %Y-%m-%d %H:%M:%S %Z')
+ info['uptime'] = int((datetime.utcnow() - start).total_seconds())
+ except Exception:
+ pass
+
+ # Memory (VmRSS from /proc)
+ try:
+ for line in Path(f'/proc/{pid}/status').read_text().splitlines():
+ if line.startswith('VmRSS:'):
+ info['memory'] = line.split(':')[1].strip()
+ break
+ except Exception:
+ pass
+
+ services.append(info)
+
+ self.send_json({'services': services})
+ except Exception as e:
+ self.send_json({'error': str(e)}, 500)
+
+ def _eco_channel_map(self):
+ """Build channel_id -> {name, platform, is_group} from config.json."""
+ config_file = ECHO_CORE_DIR / 'config.json'
+ m = {}
+ try:
+ cfg = json.loads(config_file.read_text())
+ for name, ch in cfg.get('channels', {}).items():
+ m[str(ch['id'])] = {'name': name, 'platform': 'discord'}
+ for name, ch in cfg.get('telegram_channels', {}).items():
+ m[str(ch['id'])] = {'name': name, 'platform': 'telegram'}
+ for name, ch in cfg.get('whatsapp_channels', {}).items():
+ m[str(ch['id'])] = {'name': name, 'platform': 'whatsapp', 'is_group': True}
+ for admin_id in cfg.get('bot', {}).get('admins', []):
+ m.setdefault(str(admin_id), {'name': f'TG DM', 'platform': 'telegram'})
+ wa_owner = cfg.get('whatsapp', {}).get('owner', '')
+ if wa_owner:
+ m.setdefault(f'wa-{wa_owner}', {'name': 'WA Owner', 'platform': 'whatsapp'})
+ except Exception:
+ pass
+ return m
+
+ def _eco_enrich_sessions(self):
+ """Return enriched sessions list sorted by last_message_at desc."""
+ raw = {}
+ if ECHO_SESSIONS_FILE.exists():
+ try:
+ raw = json.loads(ECHO_SESSIONS_FILE.read_text())
+ except Exception:
+ pass
+ cmap = self._eco_channel_map()
+ sessions = []
+ if isinstance(raw, dict):
+ for ch_id, sdata in raw.items():
+ if 'MagicMock' in ch_id:
+ continue
+ entry = dict(sdata) if isinstance(sdata, dict) else {}
+ entry['channel_id'] = ch_id
+ if ch_id in cmap:
+ entry['platform'] = cmap[ch_id]['platform']
+ entry['channel_name'] = cmap[ch_id]['name']
+ entry['is_group'] = cmap[ch_id].get('is_group', False)
+ elif ch_id.startswith('wa-') or '@g.us' in ch_id or '@s.whatsapp.net' in ch_id:
+ entry['platform'] = 'whatsapp'
+ entry['is_group'] = '@g.us' in ch_id
+ entry['channel_name'] = ('WA Grup' if entry['is_group'] else 'WA DM')
+ elif ch_id.isdigit() and len(ch_id) >= 17:
+ entry['platform'] = 'discord'
+ entry['channel_name'] = 'Discord #' + ch_id[-6:]
+ elif ch_id.isdigit():
+ entry['platform'] = 'telegram'
+ entry['channel_name'] = 'TG ' + ch_id
+ else:
+ entry['platform'] = 'unknown'
+ entry['channel_name'] = ch_id[:20]
+ sessions.append(entry)
+ sessions.sort(key=lambda s: s.get('last_message_at', ''), reverse=True)
+ return sessions
+
+ def handle_eco_sessions(self):
+ """Return enriched sessions list."""
+ try:
+ self.send_json({'sessions': self._eco_enrich_sessions()})
+ except Exception as e:
+ self.send_json({'error': str(e)}, 500)
+
+ def handle_eco_session_content(self):
+ """Return conversation messages from a session transcript."""
+ try:
+ params = parse_qs(urlparse(self.path).query)
+ session_id = params.get('id', [''])[0]
+ if not session_id or '/' in session_id or '..' in session_id:
+ self.send_json({'error': 'Invalid session id'}, 400)
+ return
+
+ transcript = Path.home() / '.claude' / 'projects' / '-home-moltbot-echo-core' / f'{session_id}.jsonl'
+ if not transcript.exists():
+ self.send_json({'messages': [], 'error': 'Transcript not found'})
+ return
+
+ messages = []
+ for line in transcript.read_text().splitlines():
+ try:
+ d = json.loads(line)
+ except Exception:
+ continue
+ t = d.get('type', '')
+ if t == 'user':
+ msg = d.get('message', {})
+ content = msg.get('content', '')
+ if isinstance(content, str):
+ # Strip [EXTERNAL CONTENT] wrappers
+ text = content.replace('[EXTERNAL CONTENT]\n', '').replace('\n[END EXTERNAL CONTENT]', '').strip()
+ if text:
+ messages.append({'role': 'user', 'text': text[:2000]})
+ elif t == 'assistant':
+ msg = d.get('message', {})
+ content = msg.get('content', '')
+ if isinstance(content, list):
+ parts = []
+ for block in content:
+ if block.get('type') == 'text':
+ parts.append(block['text'])
+ text = '\n'.join(parts).strip()
+ if text:
+ messages.append({'role': 'assistant', 'text': text[:2000]})
+ elif isinstance(content, str) and content.strip():
+ messages.append({'role': 'assistant', 'text': content[:2000]})
+
+ self.send_json({'messages': messages})
+ except Exception as e:
+ self.send_json({'error': str(e)}, 500)
+
+ def handle_eco_restart(self):
+ """Restart an echo-core service (not taskboard)."""
+ try:
+ data = self._read_post_json()
+ svc = data.get('service', '')
+
+ if svc not in ECO_SERVICES:
+ self.send_json({'success': False, 'error': f'Unknown service: {svc}'}, 400)
+ return
+ if svc == 'echo-taskboard':
+ self.send_json({'success': False, 'error': 'Cannot restart taskboard from itself'}, 400)
+ return
+
+ result = subprocess.run(
+ ['systemctl', '--user', 'restart', svc],
+ capture_output=True, text=True, timeout=30
+ )
+ if result.returncode == 0:
+ self.send_json({'success': True, 'message': f'{svc} restarted'})
+ else:
+ self.send_json({'success': False, 'error': result.stderr.strip()}, 500)
+ except Exception as e:
+ self.send_json({'success': False, 'error': str(e)}, 500)
+
+ def handle_eco_stop(self):
+ """Stop an echo-core service (not taskboard)."""
+ try:
+ data = self._read_post_json()
+ svc = data.get('service', '')
+
+ if svc not in ECO_SERVICES:
+ self.send_json({'success': False, 'error': f'Unknown service: {svc}'}, 400)
+ return
+ if svc == 'echo-taskboard':
+ self.send_json({'success': False, 'error': 'Cannot stop taskboard from itself'}, 400)
+ return
+
+ result = subprocess.run(
+ ['systemctl', '--user', 'stop', svc],
+ capture_output=True, text=True, timeout=30
+ )
+ if result.returncode == 0:
+ self.send_json({'success': True, 'message': f'{svc} stopped'})
+ else:
+ self.send_json({'success': False, 'error': result.stderr.strip()}, 500)
+ except Exception as e:
+ self.send_json({'success': False, 'error': str(e)}, 500)
+
+ def handle_eco_logs(self):
+ """Return last N lines from echo-core.log."""
+ try:
+ params = parse_qs(urlparse(self.path).query)
+ lines = min(int(params.get('lines', ['100'])[0]), 500)
+
+ if not ECHO_LOG_FILE.exists():
+ self.send_json({'lines': ['(log file not found)']})
+ return
+
+ result = subprocess.run(
+ ['tail', '-n', str(lines), str(ECHO_LOG_FILE)],
+ capture_output=True, text=True, timeout=10
+ )
+ self.send_json({'lines': result.stdout.splitlines()})
+ except Exception as e:
+ self.send_json({'error': str(e)}, 500)
+
+ def handle_eco_doctor(self):
+ """Run health checks on echo-core ecosystem."""
+ checks = []
+
+ # 1. Services
+ for svc in ECO_SERVICES:
+ try:
+ r = subprocess.run(
+ ['systemctl', '--user', 'is-active', svc],
+ capture_output=True, text=True, timeout=5
+ )
+ active = r.stdout.strip() == 'active'
+ checks.append({
+ 'name': f'Service: {svc}',
+ 'pass': active,
+ 'detail': 'active' if active else r.stdout.strip()
+ })
+ except Exception as e:
+ checks.append({'name': f'Service: {svc}', 'pass': False, 'detail': str(e)})
+
+ # 2. Disk space
+ try:
+ st = shutil.disk_usage('/')
+ pct_free = (st.free / st.total) * 100
+ checks.append({
+ 'name': 'Disk space',
+ 'pass': pct_free > 5,
+ 'detail': f'{pct_free:.1f}% free ({st.free // (1024**3)} GB)'
+ })
+ except Exception as e:
+ checks.append({'name': 'Disk space', 'pass': False, 'detail': str(e)})
+
+ # 3. Log file
+ try:
+ if ECHO_LOG_FILE.exists():
+ size = ECHO_LOG_FILE.stat().st_size
+ size_mb = size / (1024 * 1024)
+ checks.append({
+ 'name': 'Log file',
+ 'pass': size_mb < 100,
+ 'detail': f'{size_mb:.1f} MB'
+ })
+ else:
+ checks.append({'name': 'Log file', 'pass': False, 'detail': 'Not found'})
+ except Exception as e:
+ checks.append({'name': 'Log file', 'pass': False, 'detail': str(e)})
+
+ # 4. Sessions file
+ try:
+ if ECHO_SESSIONS_FILE.exists():
+ data = json.loads(ECHO_SESSIONS_FILE.read_text())
+ count = len(data) if isinstance(data, list) else len(data.keys()) if isinstance(data, dict) else 0
+ checks.append({'name': 'Sessions file', 'pass': True, 'detail': f'{count} active'})
+ else:
+ checks.append({'name': 'Sessions file', 'pass': False, 'detail': 'Not found'})
+ except Exception as e:
+ checks.append({'name': 'Sessions file', 'pass': False, 'detail': str(e)})
+
+ # 5. Config
+ config_file = ECHO_CORE_DIR / 'config.json'
+ try:
+ if config_file.exists():
+ json.loads(config_file.read_text())
+ checks.append({'name': 'Config', 'pass': True, 'detail': 'Valid JSON'})
+ else:
+ checks.append({'name': 'Config', 'pass': False, 'detail': 'Not found'})
+ except Exception as e:
+ checks.append({'name': 'Config', 'pass': False, 'detail': str(e)})
+
+ # 6. WhatsApp bridge log
+ wa_log = ECHO_CORE_DIR / 'logs' / 'whatsapp-bridge.log'
+ try:
+ if wa_log.exists():
+ # Check last line for errors
+ r = subprocess.run(
+ ['tail', '-1', str(wa_log)],
+ capture_output=True, text=True, timeout=5
+ )
+ last = r.stdout.strip()
+ has_error = 'error' in last.lower() or 'fatal' in last.lower()
+ checks.append({
+ 'name': 'WhatsApp bridge log',
+ 'pass': not has_error,
+ 'detail': last[:80] if last else 'Empty'
+ })
+ else:
+ checks.append({'name': 'WhatsApp bridge log', 'pass': False, 'detail': 'Not found'})
+ except Exception as e:
+ checks.append({'name': 'WhatsApp bridge log', 'pass': False, 'detail': str(e)})
+
+ # 7. Claude CLI
+ try:
+ r = subprocess.run(
+ ['which', 'claude'],
+ capture_output=True, text=True, timeout=5
+ )
+ found = r.returncode == 0
+ checks.append({
+ 'name': 'Claude CLI',
+ 'pass': found,
+ 'detail': r.stdout.strip() if found else 'Not in PATH'
+ })
+ except Exception as e:
+ checks.append({'name': 'Claude CLI', 'pass': False, 'detail': str(e)})
+
+ self.send_json({'checks': checks})
+
+ def handle_eco_git_status(self):
+ """Get git status for echo-core repo."""
+ try:
+ workspace = ECHO_CORE_DIR
+
+ branch = subprocess.run(
+ ['git', 'branch', '--show-current'],
+ cwd=workspace, capture_output=True, text=True, timeout=5
+ ).stdout.strip()
+
+ last_commit = subprocess.run(
+ ['git', 'log', '-1', '--format=%h|%s|%cr'],
+ cwd=workspace, capture_output=True, text=True, timeout=5
+ ).stdout.strip()
+
+ commit_parts = last_commit.split('|') if last_commit else ['', '', '']
+
+ status_output = subprocess.run(
+ ['git', 'status', '--short'],
+ cwd=workspace, capture_output=True, text=True, timeout=5
+ ).stdout.strip()
+
+ uncommitted = [f for f in status_output.split('\n') if f.strip()] if status_output else []
+
+ uncommitted_parsed = []
+ for line in uncommitted:
+ if len(line) >= 2:
+ status = line[:2].strip()
+ filepath = line[2:].strip()
+ if filepath:
+ uncommitted_parsed.append({'status': status, 'path': filepath})
+
+ self.send_json({
+ 'branch': branch,
+ 'clean': len(uncommitted) == 0,
+ 'uncommittedCount': len(uncommitted),
+ 'uncommittedParsed': uncommitted_parsed,
+ 'lastCommit': {
+ 'hash': commit_parts[0] if len(commit_parts) > 0 else '',
+ 'message': commit_parts[1] if len(commit_parts) > 1 else '',
+ 'time': commit_parts[2] if len(commit_parts) > 2 else '',
+ },
+ })
+ except Exception as e:
+ self.send_json({'error': str(e)}, 500)
+
+ def handle_eco_git_commit(self):
+ """Run git add, commit, and push for echo-core repo."""
+ try:
+ workspace = ECHO_CORE_DIR
+
+ # Stage all changes
+ subprocess.run(
+ ['git', 'add', '-A'],
+ cwd=workspace, capture_output=True, text=True, timeout=10
+ )
+
+ # Check if there's anything to commit
+ status = subprocess.run(
+ ['git', 'status', '--porcelain'],
+ cwd=workspace, capture_output=True, text=True, timeout=5
+ ).stdout.strip()
+
+ if not status:
+ self.send_json({'success': True, 'files': 0, 'output': 'Nothing to commit'})
+ return
+
+ files_count = len([l for l in status.split('\n') if l.strip()])
+
+ # Commit
+ commit_result = subprocess.run(
+ ['git', 'commit', '-m', 'chore: auto-commit from dashboard'],
+ cwd=workspace, capture_output=True, text=True, timeout=30
+ )
+
+ # Push
+ push_result = subprocess.run(
+ ['git', 'push'],
+ cwd=workspace, capture_output=True, text=True, timeout=30
+ )
+
+ output = commit_result.stdout + commit_result.stderr + push_result.stdout + push_result.stderr
+
+ if commit_result.returncode == 0:
+ self.send_json({'success': True, 'files': files_count, 'output': output})
+ else:
+ self.send_json({'success': False, 'error': output or 'Commit failed'})
+ except Exception as e:
+ self.send_json({'success': False, 'error': str(e)}, 500)
+
+ def handle_eco_restart_taskboard(self):
+ """Restart the taskboard itself. Sends response then exits; systemd restarts."""
+ import threading
+ self.send_json({'success': True, 'message': 'Restarting taskboard in 1s...'})
+
+ def _exit():
+ import time
+ time.sleep(1)
+ os._exit(0)
+
+ threading.Thread(target=_exit, daemon=True).start()
+
+ def handle_eco_sessions_clear(self):
+ """Clear active sessions (all or specific channel)."""
+ try:
+ data = self._read_post_json()
+ channel = data.get('channel', None)
+
+ if not ECHO_SESSIONS_FILE.exists():
+ self.send_json({'success': True, 'message': 'No sessions file'})
+ return
+
+ if channel:
+ # Remove specific channel
+ sessions = json.loads(ECHO_SESSIONS_FILE.read_text())
+ if isinstance(sessions, list):
+ sessions = [s for s in sessions if s.get('channel') != channel]
+ elif isinstance(sessions, dict):
+ sessions.pop(channel, None)
+ ECHO_SESSIONS_FILE.write_text(json.dumps(sessions, indent=2))
+ self.send_json({'success': True, 'message': f'Cleared session: {channel}'})
+ else:
+ # Clear all
+ if isinstance(json.loads(ECHO_SESSIONS_FILE.read_text()), list):
+ ECHO_SESSIONS_FILE.write_text('[]')
+ else:
+ ECHO_SESSIONS_FILE.write_text('{}')
+ self.send_json({'success': True, 'message': 'All sessions cleared'})
+ except Exception as e:
+ self.send_json({'success': False, 'error': str(e)}, 500)
+
+ def send_json(self, data, code=200):
+ self.send_response(code)
+ self.send_header('Content-Type', 'application/json')
+ self.send_header('Access-Control-Allow-Origin', '*')
+ self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate')
+ self.send_header('Pragma', 'no-cache')
+ self.send_header('Expires', '0')
+ self.end_headers()
+ self.wfile.write(json.dumps(data).encode())
+
+ def do_OPTIONS(self):
+ self.send_response(200)
+ self.send_header('Access-Control-Allow-Origin', '*')
+ self.send_header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
+ self.send_header('Access-Control-Allow-Headers', 'Content-Type')
+ self.end_headers()
+
+
+def process_youtube(url):
+ """Download subtitles, summarize, save note."""
+ import time
+
+ # Get video info and subtitles
+ yt_dlp = os.path.expanduser('~/.local/bin/yt-dlp')
+
+ # Get title
+ result = subprocess.run(
+ [yt_dlp, '--dump-json', '--no-download', url],
+ capture_output=True, text=True, timeout=30
+ )
+
+ if result.returncode != 0:
+ print(f"Failed to get video info: {result.stderr}")
+ return
+
+ info = json.loads(result.stdout)
+ title = info.get('title', 'Unknown')
+ duration = info.get('duration', 0)
+ video_id = info.get('id', 'unknown')
+
+ # Download subtitles
+ temp_dir = Path('/tmp/yt_subs')
+ temp_dir.mkdir(exist_ok=True)
+
+ for f in temp_dir.glob('*'):
+ f.unlink()
+
+ subprocess.run([
+ yt_dlp, '--write-auto-subs', '--sub-langs', 'en',
+ '--skip-download', '--sub-format', 'vtt',
+ '-o', str(temp_dir / '%(id)s'),
+ url
+ ], capture_output=True, timeout=120)
+
+ # Find and read subtitle file
+ transcript = None
+ for sub_file in temp_dir.glob('*.vtt'):
+ content = sub_file.read_text(encoding='utf-8', errors='replace')
+ transcript = clean_vtt(content)
+ break
+
+ if not transcript:
+ print("No subtitles found")
+ return
+
+ # Create note filename
+ date_str = datetime.now().strftime('%Y-%m-%d')
+ slug = re.sub(r'[^\w\s-]', '', title.lower())[:50].strip().replace(' ', '-')
+ filename = f"{date_str}_{slug}.md"
+
+ # Create simple note (without AI summary for now - just transcript)
+ note_content = f"""# {title}
+
+**Video:** {url}
+**Duration:** {duration // 60}:{duration % 60:02d}
+**Saved:** {date_str}
+**Tags:** #youtube #to-summarize
+
+---
+
+## Transcript
+
+{transcript[:15000]}
+
+---
+
+*Notă: Sumarizarea va fi adăugată de Echo.*
+"""
+
+ # Save note
+ NOTES_DIR.mkdir(parents=True, exist_ok=True)
+ note_path = NOTES_DIR / filename
+ note_path.write_text(note_content, encoding='utf-8')
+
+ # Update index
+ subprocess.run([
+ sys.executable, str(TOOLS_DIR / 'update_notes_index.py')
+ ], capture_output=True)
+
+ print(f"Created note: {filename}")
+ return filename
+
+
+def clean_vtt(content):
+ """Convert VTT to plain text."""
+ lines = []
+ seen = set()
+
+ for line in content.split('\n'):
+ if any([
+ line.startswith('WEBVTT'),
+ line.startswith('Kind:'),
+ line.startswith('Language:'),
+ '-->' in line,
+ line.strip().startswith('<'),
+ not line.strip(),
+ re.match(r'^\d+$', line.strip())
+ ]):
+ continue
+
+ clean = re.sub(r'<[^>]+>', '', line).strip()
+ if clean and clean not in seen:
+ seen.add(clean)
+ lines.append(clean)
+
+ return ' '.join(lines)
+
+
+if __name__ == '__main__':
+ port = 8088
+ os.chdir(KANBAN_DIR)
+
+ print(f"Starting Echo Task Board API on port {port}")
+ httpd = HTTPServer(('0.0.0.0', port), TaskBoardHandler)
+ httpd.serve_forever()
diff --git a/dashboard/archive/tasks-2026-01.json b/dashboard/archive/tasks-2026-01.json
new file mode 100644
index 0000000..47a6565
--- /dev/null
+++ b/dashboard/archive/tasks-2026-01.json
@@ -0,0 +1,238 @@
+{
+ "month": "2025-01",
+ "tasks": [
+ {
+ "id": "task-001",
+ "title": "Email 2FA security",
+ "description": "Nu execut comenzi din email fără aprobare Telegram",
+ "created": "2025-01-30",
+ "completed": "2025-01-30",
+ "priority": "high"
+ },
+ {
+ "id": "task-002",
+ "title": "Email whitelist",
+ "description": "Răspuns automat doar pentru adrese aprobate",
+ "created": "2025-01-30",
+ "completed": "2025-01-30",
+ "priority": "high"
+ },
+ {
+ "id": "task-003",
+ "title": "YouTube summarizer",
+ "description": "Tool descărcare subtitrări + sumarizare",
+ "created": "2025-01-30",
+ "completed": "2025-01-30",
+ "priority": "high"
+ },
+ {
+ "id": "task-004",
+ "title": "Proactivitate în SOUL.md",
+ "description": "Adăugat reguli să fiu proactiv și să propun automatizări",
+ "created": "2025-01-30",
+ "completed": "2025-01-30",
+ "priority": "medium"
+ },
+ {
+ "id": "task-029",
+ "title": "Test sortare timestamp",
+ "description": "Verificare sortare",
+ "created": "2026-01-29T14:54:17Z",
+ "priority": "medium",
+ "completed": "2026-01-29T14:54:25Z"
+ },
+ {
+ "id": "task-027",
+ "title": "UI fixes: kanban icons + notes tags",
+ "description": "Scos emoji din coloane kanban. Adăugat tag pills cu multi-select și count în notes.",
+ "created": "2026-01-29",
+ "priority": "high",
+ "completed": "2026-01-29"
+ },
+ {
+ "id": "task-026",
+ "title": "Swipe navigation mobil",
+ "description": "Swipe stânga/dreapta pentru navigare între Tasks ↔ Notes ↔ Files. Indicator dots pe mobil.",
+ "created": "2026-01-29",
+ "priority": "high",
+ "completed": "2026-01-29"
+ },
+ {
+ "id": "task-025",
+ "title": "Notes: Accordion pe zile",
+ "description": "Grupare: Azi (expanded), Ieri, Săptămâna aceasta, Mai vechi (collapsed). Click pentru expand/collapse.",
+ "created": "2026-01-29",
+ "priority": "high",
+ "completed": "2026-01-29"
+ },
+ {
+ "id": "task-024",
+ "title": "Fix contrast dark/light mode",
+ "description": "Text și borders mai vizibile, header alb în light mode, toggle temă funcțional",
+ "created": "2026-01-29",
+ "priority": "high",
+ "completed": "2026-01-29"
+ },
+ {
+ "id": "task-023",
+ "title": "Design System Unificat",
+ "description": "common.css + Lucide icons + UI modern pe toate paginile: Tasks, Notes, Files",
+ "created": "2026-01-29",
+ "priority": "high",
+ "completed": "2026-01-29"
+ },
+ {
+ "id": "task-022",
+ "title": "Unificare stil navigare",
+ "description": "Nav unificat pe toate paginile: 📋 Tasks | 📝 Notes | 📁 Files cu iconuri și stil consistent",
+ "created": "2026-01-29",
+ "priority": "high",
+ "completed": "2026-01-29"
+ },
+ {
+ "id": "task-021",
+ "title": "UI/UX Redesign v2",
+ "description": "Kanban: doar In Progress expandat. Notes: mobile tabs. Files: Browse/Editor tabs cu grid.",
+ "created": "2026-01-29",
+ "priority": "high",
+ "completed": "2026-01-29"
+ },
+ {
+ "id": "task-020",
+ "title": "UI Responsive & Compact",
+ "description": "Coloane colapsabile, task-uri compacte (click expand), sidebar toggle, Done minimizat by default",
+ "created": "2026-01-29",
+ "priority": "high",
+ "completed": "2026-01-29"
+ },
+ {
+ "id": "task-019",
+ "title": "Comparare bilanț 12/2025 vs 12/2024",
+ "description": "Doar S1002 modificat! Câmpuri noi: AN_CAEN, d_audit_intern. Raport: bilant_compare/2025_vs_2024/",
+ "created": "2026-01-29",
+ "priority": "high",
+ "completed": "2026-01-29"
+ },
+ {
+ "id": "task-018",
+ "title": "Comparare bilanț ANAF 2024 vs 2023",
+ "description": "Comparat XSD-uri S1002-S1005. Raport: anaf-monitor/bilant_compare/RAPORT_DIFERENTE_2024_vs_2023.md",
+ "created": "2026-01-29",
+ "priority": "high",
+ "completed": "2026-01-29"
+ },
+ {
+ "id": "task-017",
+ "title": "Scrie un haiku",
+ "description": "Biți în noaptea grea / Claude răspunde în liniște / Ecou digital",
+ "created": "2026-01-29",
+ "priority": "medium",
+ "completed": "2026-01-29"
+ },
+ {
+ "id": "task-005",
+ "title": "Kanban board",
+ "description": "Interfață web pentru vizualizare task-uri",
+ "created": "2025-01-30",
+ "priority": "high",
+ "completed": "2026-01-29"
+ },
+ {
+ "id": "task-008",
+ "title": "YouTube Notes interface",
+ "description": "Interfață pentru vizualizare notițe cu search",
+ "created": "2026-01-29",
+ "priority": "high"
+ },
+ {
+ "id": "task-009",
+ "title": "Search în notițe",
+ "description": "Căutare în titlu, tags și conținut",
+ "created": "2026-01-29",
+ "priority": "medium"
+ },
+ {
+ "id": "task-010",
+ "title": "Sumarizare: Claude Code Do Work Pattern",
+ "description": "https://youtu.be/I9-tdhxiH7w",
+ "created": "2026-01-29",
+ "priority": "high"
+ },
+ {
+ "id": "task-011",
+ "title": "File Explorer în Task Board",
+ "description": "Interfață pentru browse/edit fișiere din workspace",
+ "created": "2026-01-29",
+ "priority": "high"
+ },
+ {
+ "id": "task-013",
+ "title": "Kanban interactiv cu drag & drop",
+ "description": "Adăugat: drag-drop, add/edit/delete tasks, priorități, salvare automată",
+ "created": "2026-01-29",
+ "priority": "high"
+ },
+ {
+ "id": "task-014",
+ "title": "Sumarizare: It Got Worse (Clawdbot)...",
+ "description": "https://youtu.be/rPAKq2oQVBs?si=6sJk41XsCrQQt6Lg",
+ "created": "2026-01-29",
+ "priority": "medium",
+ "completed": "2026-01-29"
+ },
+ {
+ "id": "task-015",
+ "title": "Sumarizare: Greșeli post cu apă",
+ "description": "https://youtu.be/4QjkI0sf64M",
+ "created": "2026-01-29",
+ "priority": "medium"
+ },
+ {
+ "id": "task-016",
+ "title": "Sumarizare: GSD Framework Claude Code",
+ "description": "https://www.youtube.com/watch?v=l94A53kIUB0",
+ "created": "2026-01-29",
+ "priority": "high"
+ },
+ {
+ "id": "task-028",
+ "title": "ANAF Monitor - verificare (test)",
+ "description": "Testare manuală cron job",
+ "created": "2026-01-29",
+ "priority": "medium",
+ "completed": "2026-01-29"
+ },
+ {
+ "id": "task-030",
+ "title": "Test task tracking",
+ "description": "",
+ "created": "2026-01-30T20:12:25Z",
+ "priority": "medium",
+ "completed": "2026-01-30T20:12:29Z"
+ },
+ {
+ "id": "task-031",
+ "title": "Fix notes tag coloring on expand",
+ "description": "",
+ "created": "2026-01-30T20:16:46Z",
+ "priority": "medium",
+ "completed": "2026-01-30T20:17:08Z"
+ },
+ {
+ "id": "task-032",
+ "title": "Fix cron jobs timezone Bucharest",
+ "description": "",
+ "created": "2026-01-30T20:21:26Z",
+ "priority": "medium",
+ "completed": "2026-01-30T20:21:44Z"
+ },
+ {
+ "id": "task-033",
+ "title": "Redirect coaching to @health, reports to @work",
+ "description": "",
+ "created": "2026-01-30T20:25:22Z",
+ "priority": "medium",
+ "completed": "2026-01-30T20:26:37Z"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/dashboard/archive/tasks-2026-02.json b/dashboard/archive/tasks-2026-02.json
new file mode 100644
index 0000000..aa2be6d
--- /dev/null
+++ b/dashboard/archive/tasks-2026-02.json
@@ -0,0 +1,64 @@
+{
+ "month": "2026-02",
+ "tasks": [
+ {
+ "id": "task-034",
+ "title": "Actualizare documentație canale agenți",
+ "description": "",
+ "created": "2026-02-01T12:15:41Z",
+ "priority": "medium",
+ "completed": "2026-02-01T12:15:44Z"
+ },
+ {
+ "id": "task-035",
+ "title": "Restructurare echipă: șterg work, unific health+growth→self",
+ "description": "",
+ "created": "2026-02-01T12:20:59Z",
+ "priority": "medium",
+ "completed": "2026-02-01T12:23:32Z"
+ },
+ {
+ "id": "task-036",
+ "title": "Unificare în 1 agent cu tehnici diminuare dezavantaje",
+ "description": "",
+ "created": "2026-02-01T13:27:51Z",
+ "priority": "medium",
+ "completed": "2026-02-01T13:30:01Z"
+ },
+ {
+ "id": "task-037",
+ "title": "Coaching dimineață - Asumarea eforturilor (Zoltan Vereș)",
+ "description": "",
+ "created": "2026-02-02T07:01:14Z",
+ "priority": "medium"
+ },
+ {
+ "id": "task-038",
+ "title": "Raport dimineata trimis pe email",
+ "description": "",
+ "created": "2026-02-03T06:31:08Z",
+ "priority": "medium"
+ },
+ {
+ "id": "task-039",
+ "title": "Raport seară 3 feb trimis pe email",
+ "description": "",
+ "created": "2026-02-03T18:01:12Z",
+ "priority": "medium"
+ },
+ {
+ "id": "task-040",
+ "title": "Job night-execute: 2 video-uri YouTube procesate",
+ "description": "",
+ "created": "2026-02-03T21:02:31Z",
+ "priority": "medium"
+ },
+ {
+ "id": "task-041",
+ "title": "Raport dimineață trimis pe email",
+ "description": "",
+ "created": "2026-02-04T06:31:05Z",
+ "priority": "medium"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/dashboard/archive_tasks.py b/dashboard/archive_tasks.py
new file mode 100644
index 0000000..586acff
--- /dev/null
+++ b/dashboard/archive_tasks.py
@@ -0,0 +1,97 @@
+#!/usr/bin/env python3
+"""
+Archive old Done tasks to monthly archive files.
+Run periodically (heartbeat) to keep tasks.json small.
+"""
+
+import json
+import os
+from datetime import datetime, timedelta
+from pathlib import Path
+
+TASKS_FILE = Path(__file__).parent / "tasks.json"
+ARCHIVE_DIR = Path(__file__).parent / "archive"
+DAYS_TO_KEEP = 7 # Keep Done tasks for 7 days before archiving
+
+def archive_old_tasks():
+ if not TASKS_FILE.exists():
+ print("No tasks.json found")
+ return
+
+ with open(TASKS_FILE, 'r') as f:
+ data = json.load(f)
+
+ # Find Done column
+ done_col = None
+ for col in data['columns']:
+ if col['id'] == 'done':
+ done_col = col
+ break
+
+ if not done_col:
+ print("No Done column found")
+ return
+
+ # Calculate cutoff date
+ cutoff = (datetime.now() - timedelta(days=DAYS_TO_KEEP)).strftime('%Y-%m-%d')
+
+ # Separate old and recent tasks
+ old_tasks = []
+ recent_tasks = []
+
+ for task in done_col['tasks']:
+ completed = task.get('completed', task.get('created', ''))
+ if completed and completed < cutoff:
+ old_tasks.append(task)
+ else:
+ recent_tasks.append(task)
+
+ if not old_tasks:
+ print(f"No tasks older than {DAYS_TO_KEEP} days to archive")
+ return
+
+ # Create archive directory
+ ARCHIVE_DIR.mkdir(exist_ok=True)
+
+ # Group old tasks by month
+ by_month = {}
+ for task in old_tasks:
+ completed = task.get('completed', task.get('created', ''))[:7] # YYYY-MM
+ if completed not in by_month:
+ by_month[completed] = []
+ by_month[completed].append(task)
+
+ # Write to monthly archive files
+ for month, tasks in by_month.items():
+ archive_file = ARCHIVE_DIR / f"tasks-{month}.json"
+
+ # Load existing archive
+ if archive_file.exists():
+ with open(archive_file, 'r') as f:
+ archive = json.load(f)
+ else:
+ archive = {"month": month, "tasks": []}
+
+ # Add new tasks (avoid duplicates by ID)
+ existing_ids = {t['id'] for t in archive['tasks']}
+ for task in tasks:
+ if task['id'] not in existing_ids:
+ archive['tasks'].append(task)
+
+ # Save archive
+ with open(archive_file, 'w') as f:
+ json.dump(archive, f, indent=2, ensure_ascii=False)
+
+ print(f"Archived {len(tasks)} tasks to {archive_file.name}")
+
+ # Update tasks.json with only recent Done tasks
+ done_col['tasks'] = recent_tasks
+ data['lastUpdated'] = datetime.now().isoformat()
+
+ with open(TASKS_FILE, 'w') as f:
+ json.dump(data, f, indent=2, ensure_ascii=False)
+
+ print(f"Kept {len(recent_tasks)} recent Done tasks, archived {len(old_tasks)}")
+
+if __name__ == "__main__":
+ archive_old_tasks()
diff --git a/dashboard/common.css b/dashboard/common.css
new file mode 100644
index 0000000..9713ec7
--- /dev/null
+++ b/dashboard/common.css
@@ -0,0 +1,448 @@
+/*
+ * Echo Design System
+ * Modern, minimalist, unified UI
+ */
+
+/* ============================================
+ CSS Variables - Design Tokens
+ ============================================ */
+:root {
+ /* Colors - Dark theme (high contrast) */
+ --bg-base: #13131a;
+ --bg-surface: rgba(255, 255, 255, 0.12);
+ --bg-surface-hover: rgba(255, 255, 255, 0.16);
+ --bg-surface-active: rgba(255, 255, 255, 0.20);
+ --bg-elevated: rgba(255, 255, 255, 0.14);
+
+ --text-primary: #ffffff;
+ --text-secondary: #f5f5f5;
+ --text-muted: #e5e5e5;
+
+ --accent: #3b82f6;
+ --accent-hover: #2563eb;
+ --accent-subtle: rgba(59, 130, 246, 0.2);
+
+ --border: rgba(255, 255, 255, 0.3);
+ --border-focus: rgba(59, 130, 246, 0.7);
+
+ /* Header specific */
+ --header-bg: rgba(19, 19, 26, 0.95);
+
+ --success: #22c55e;
+ --warning: #eab308;
+ --error: #ef4444;
+
+ /* Spacing (8px grid) */
+ --space-1: 4px;
+ --space-2: 8px;
+ --space-3: 12px;
+ --space-4: 16px;
+ --space-5: 20px;
+ --space-6: 24px;
+ --space-8: 32px;
+ --space-10: 40px;
+
+ /* Typography */
+ --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', sans-serif;
+ --font-mono: 'JetBrains Mono', 'Fira Code', monospace;
+
+ --text-xs: 0.75rem;
+ --text-sm: 0.875rem;
+ --text-base: 1rem;
+ --text-lg: 1.125rem;
+ --text-xl: 1.25rem;
+
+ /* Radius */
+ --radius-sm: 4px;
+ --radius-md: 8px;
+ --radius-lg: 12px;
+ --radius-full: 9999px;
+
+ /* Shadows */
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
+ --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
+ --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
+
+ /* Transitions */
+ --transition-fast: 0.15s ease;
+ --transition-base: 0.2s ease;
+}
+
+/* Light theme */
+[data-theme="light"] {
+ --bg-base: #f8f9fa;
+ --bg-surface: rgba(0, 0, 0, 0.04);
+ --bg-surface-hover: rgba(0, 0, 0, 0.08);
+ --bg-surface-active: rgba(0, 0, 0, 0.12);
+ --bg-elevated: rgba(0, 0, 0, 0.06);
+
+ --text-primary: #1a1a1a;
+ --text-secondary: #444444;
+ --text-muted: #666666;
+
+ --border: rgba(0, 0, 0, 0.12);
+ --border-focus: rgba(59, 130, 246, 0.5);
+
+ --accent-subtle: rgba(59, 130, 246, 0.12);
+
+ /* Header light */
+ --header-bg: rgba(255, 255, 255, 0.95);
+}
+
+/* ============================================
+ Reset & Base
+ ============================================ */
+*, *::before, *::after {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+html {
+ font-size: 16px;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+body {
+ font-family: var(--font-sans);
+ background: var(--bg-base);
+ color: var(--text-secondary);
+ line-height: 1.5;
+ min-height: 100vh;
+}
+
+/* ============================================
+ Header / Navigation
+ ============================================ */
+.header {
+ position: sticky;
+ top: 0;
+ z-index: 100;
+ background: var(--header-bg);
+ backdrop-filter: blur(12px);
+ border-bottom: 1px solid var(--border);
+ padding: var(--space-3) var(--space-5);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: var(--space-4);
+}
+
+.logo {
+ font-size: var(--text-lg);
+ font-weight: 600;
+ color: var(--text-primary);
+ text-decoration: none;
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+}
+
+.logo svg {
+ width: 24px;
+ height: 24px;
+ color: var(--accent);
+}
+
+.nav {
+ display: flex;
+ gap: var(--space-1);
+}
+
+.nav-item {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-4);
+ color: var(--text-secondary);
+ text-decoration: none;
+ font-size: var(--text-sm);
+ font-weight: 500;
+ border-radius: var(--radius-md);
+ transition: all var(--transition-fast);
+}
+
+.nav-item:hover {
+ color: var(--text-primary);
+ background: var(--bg-surface-hover);
+}
+
+.nav-item.active {
+ color: var(--text-primary);
+ background: var(--accent-subtle);
+}
+
+.nav-item svg {
+ width: 18px;
+ height: 18px;
+}
+
+/* Theme toggle */
+.theme-toggle {
+ padding: var(--space-2);
+ background: transparent;
+ border: none;
+ color: var(--text-muted);
+ cursor: pointer;
+ border-radius: var(--radius-md);
+ display: flex;
+ align-items: center;
+ transition: all var(--transition-fast);
+}
+
+.theme-toggle:hover {
+ color: var(--text-primary);
+ background: var(--bg-surface-hover);
+}
+
+.theme-toggle svg {
+ width: 18px;
+ height: 18px;
+}
+
+/* ============================================
+ Cards
+ ============================================ */
+.card {
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ padding: var(--space-4);
+ transition: all var(--transition-base);
+}
+
+.card:hover {
+ background: var(--bg-surface-hover);
+ border-color: var(--border-focus);
+}
+
+.card-title {
+ font-size: var(--text-base);
+ font-weight: 600;
+ color: var(--text-primary);
+ margin-bottom: var(--space-1);
+}
+
+.card-meta {
+ font-size: var(--text-xs);
+ color: var(--text-muted);
+}
+
+/* ============================================
+ Buttons
+ ============================================ */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-4);
+ font-size: var(--text-sm);
+ font-weight: 500;
+ border: none;
+ border-radius: var(--radius-md);
+ cursor: pointer;
+ transition: all var(--transition-fast);
+}
+
+.btn svg {
+ width: 16px;
+ height: 16px;
+}
+
+.btn-primary {
+ background: var(--accent);
+ color: white;
+}
+
+.btn-primary:hover {
+ background: var(--accent-hover);
+}
+
+.btn-secondary {
+ background: var(--bg-surface);
+ color: var(--text-secondary);
+ border: 1px solid var(--border);
+}
+
+.btn-secondary:hover {
+ background: var(--bg-surface-hover);
+ color: var(--text-primary);
+}
+
+.btn-ghost {
+ background: transparent;
+ color: var(--text-secondary);
+}
+
+.btn-ghost:hover {
+ background: var(--bg-surface);
+ color: var(--text-primary);
+}
+
+.btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* ============================================
+ Inputs
+ ============================================ */
+.input {
+ width: 100%;
+ padding: var(--space-3) var(--space-4);
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ color: var(--text-primary);
+ font-size: var(--text-sm);
+ font-family: inherit;
+ outline: none;
+ transition: border-color var(--transition-fast);
+}
+
+.input:focus {
+ border-color: var(--accent);
+}
+
+.input::placeholder {
+ color: var(--text-muted);
+}
+
+/* Select dropdowns - fix for dark mode visibility */
+select.input {
+ background: var(--bg-elevated);
+}
+
+select.input option {
+ background: var(--bg-base);
+ color: var(--text-primary);
+}
+
+/* ============================================
+ Tags / Badges
+ ============================================ */
+.tag {
+ display: inline-flex;
+ align-items: center;
+ padding: var(--space-1) var(--space-2);
+ font-size: var(--text-xs);
+ color: var(--text-muted);
+ background: var(--bg-surface);
+ border-radius: var(--radius-sm);
+}
+
+.badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 20px;
+ padding: 2px 6px;
+ font-size: var(--text-xs);
+ font-weight: 600;
+ color: var(--text-primary);
+ background: var(--bg-surface-active);
+ border-radius: var(--radius-full);
+}
+
+/* ============================================
+ Grid Layouts
+ ============================================ */
+.grid {
+ display: grid;
+ gap: var(--space-4);
+}
+
+.grid-2 { grid-template-columns: repeat(2, 1fr); }
+.grid-3 { grid-template-columns: repeat(3, 1fr); }
+.grid-auto { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
+
+/* ============================================
+ Status Colors
+ ============================================ */
+.status-success { color: var(--success); }
+.status-warning { color: var(--warning); }
+.status-error { color: var(--error); }
+
+/* ============================================
+ Scrollbar
+ ============================================ */
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--bg-surface-active);
+ border-radius: var(--radius-full);
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: var(--text-muted);
+}
+
+/* ============================================
+ Responsive
+ ============================================ */
+@media (max-width: 768px) {
+ /* Larger base font for mobile readability */
+ html {
+ font-size: 18px;
+ }
+
+ .header {
+ padding: var(--space-3);
+ }
+
+ .nav-item span {
+ display: none;
+ }
+
+ .nav-item {
+ padding: var(--space-2);
+ }
+
+ .grid-2, .grid-3 {
+ grid-template-columns: 1fr;
+ }
+
+ /* Larger touch targets */
+ .btn, .input, .tag {
+ min-height: 44px;
+ font-size: var(--text-base);
+ }
+
+ .card {
+ padding: var(--space-5);
+ }
+
+ .card-title {
+ font-size: var(--text-lg);
+ }
+}
+
+/* ============================================
+ Utilities
+ ============================================ */
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ border: 0;
+}
+
+.flex { display: flex; }
+.flex-col { flex-direction: column; }
+.items-center { align-items: center; }
+.justify-between { justify-content: space-between; }
+.gap-2 { gap: var(--space-2); }
+.gap-4 { gap: var(--space-4); }
diff --git a/dashboard/eco.html b/dashboard/eco.html
new file mode 100644
index 0000000..3485e0f
--- /dev/null
+++ b/dashboard/eco.html
@@ -0,0 +1,1252 @@
+
+
+
+
+
+
+ Echo · Eco
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dashboard/favicon.svg b/dashboard/favicon.svg
new file mode 100644
index 0000000..9fac3a0
--- /dev/null
+++ b/dashboard/favicon.svg
@@ -0,0 +1,4 @@
+
diff --git a/dashboard/files.html b/dashboard/files.html
new file mode 100644
index 0000000..326b89f
--- /dev/null
+++ b/dashboard/files.html
@@ -0,0 +1,1935 @@
+
+
+
+
+
+
+ Echo · Files
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dashboard/grup-sprijin.html b/dashboard/grup-sprijin.html
new file mode 100644
index 0000000..c0b5aec
--- /dev/null
+++ b/dashboard/grup-sprijin.html
@@ -0,0 +1,501 @@
+
+
+
+
+
+ Echo · Grup Sprijin
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dashboard/habits.html b/dashboard/habits.html
new file mode 100644
index 0000000..f532f01
--- /dev/null
+++ b/dashboard/habits.html
@@ -0,0 +1,3494 @@
+
+
+
+
+
+
+ Echo · Habits
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0 completed this week
+ 0 skipped this week
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dashboard/habits.json b/dashboard/habits.json
new file mode 100644
index 0000000..68b5f89
--- /dev/null
+++ b/dashboard/habits.json
@@ -0,0 +1,123 @@
+{
+ "lastUpdated": "2026-03-31T19:39:08.013266",
+ "habits": [
+ {
+ "id": "95c15eef-3a14-4985-a61e-0b64b72851b0",
+ "name": "Bazin \u0219i Saun\u0103",
+ "category": "health",
+ "color": "#EF4444",
+ "icon": "target",
+ "priority": 50,
+ "notes": "",
+ "reminderTime": "19:00",
+ "frequency": {
+ "type": "x_per_week",
+ "count": 5
+ },
+ "streak": {
+ "current": 1,
+ "best": 6,
+ "lastCheckIn": "2026-03-31"
+ },
+ "lives": 2,
+ "completions": [
+ {
+ "date": "2026-02-11",
+ "type": "check"
+ },
+ {
+ "date": "2026-02-13",
+ "type": "check"
+ },
+ {
+ "date": "2026-02-14",
+ "type": "check"
+ },
+ {
+ "date": "2026-02-15",
+ "type": "check"
+ },
+ {
+ "date": "2026-02-16",
+ "type": "check"
+ },
+ {
+ "date": "2026-02-17",
+ "type": "check"
+ },
+ {
+ "date": "2026-02-18",
+ "type": "check"
+ },
+ {
+ "date": "2026-02-23",
+ "type": "check"
+ },
+ {
+ "date": "2026-03-31",
+ "type": "check"
+ }
+ ],
+ "createdAt": "2026-02-11T00:54:03.447063",
+ "updatedAt": "2026-03-31T19:39:08.013266",
+ "lastLivesAward": "2026-02-23"
+ },
+ {
+ "id": "ceddaa7e-caf9-4038-94bb-da486c586bf8",
+ "name": "Fotocitire",
+ "category": "growth",
+ "color": "#10B981",
+ "icon": "camera",
+ "priority": 30,
+ "notes": "",
+ "reminderTime": "",
+ "frequency": {
+ "type": "x_per_week",
+ "count": 3
+ },
+ "streak": {
+ "current": 1,
+ "best": 6,
+ "lastCheckIn": "2026-02-23"
+ },
+ "lives": 4,
+ "completions": [
+ {
+ "date": "2026-02-11",
+ "type": "check"
+ },
+ {
+ "date": "2026-02-13",
+ "type": "check"
+ },
+ {
+ "date": "2026-02-14",
+ "type": "check"
+ },
+ {
+ "date": "2026-02-15",
+ "type": "check"
+ },
+ {
+ "date": "2026-02-16",
+ "type": "check"
+ },
+ {
+ "date": "2026-02-17",
+ "type": "check"
+ },
+ {
+ "date": "2026-02-18",
+ "type": "check"
+ },
+ {
+ "date": "2026-02-23",
+ "type": "check"
+ }
+ ],
+ "createdAt": "2026-02-11T01:58:44.779904",
+ "updatedAt": "2026-02-23T13:08:19.884995",
+ "lastLivesAward": "2026-02-23"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/dashboard/habits_helpers.py b/dashboard/habits_helpers.py
new file mode 100644
index 0000000..a4e9780
--- /dev/null
+++ b/dashboard/habits_helpers.py
@@ -0,0 +1,387 @@
+"""
+Habit Tracker Helper Functions
+
+This module provides core helper functions for calculating streaks,
+checking relevance, and computing stats for habits.
+"""
+
+from datetime import datetime, timedelta
+from typing import Dict, List, Any, Optional
+
+
+def calculate_streak(habit: Dict[str, Any]) -> int:
+ """
+ Calculate the current streak for a habit based on its frequency type.
+ Skips maintain the streak (don't break it) but don't count toward the total.
+
+ Args:
+ habit: Dict containing habit data with frequency, completions, etc.
+
+ Returns:
+ int: Current streak count (days, weeks, or months depending on frequency)
+ """
+ frequency_type = habit.get("frequency", {}).get("type", "daily")
+ completions = habit.get("completions", [])
+
+ if not completions:
+ return 0
+
+ # Sort completions by date (newest first)
+ sorted_completions = sorted(
+ [c for c in completions if c.get("date")],
+ key=lambda x: x["date"],
+ reverse=True
+ )
+
+ if not sorted_completions:
+ return 0
+
+ if frequency_type == "daily":
+ return _calculate_daily_streak(sorted_completions)
+ elif frequency_type == "specific_days":
+ return _calculate_specific_days_streak(habit, sorted_completions)
+ elif frequency_type == "x_per_week":
+ return _calculate_x_per_week_streak(habit, sorted_completions)
+ elif frequency_type == "weekly":
+ return _calculate_weekly_streak(sorted_completions)
+ elif frequency_type == "monthly":
+ return _calculate_monthly_streak(sorted_completions)
+ elif frequency_type == "custom":
+ return _calculate_custom_streak(habit, sorted_completions)
+
+ return 0
+
+
+def _calculate_daily_streak(completions: List[Dict[str, Any]]) -> int:
+ """
+ Calculate streak for daily habits (consecutive days).
+ Skips maintain the streak (don't break it) but don't count toward the total.
+ """
+ streak = 0
+ today = datetime.now().date()
+ expected_date = today
+
+ for completion in completions:
+ completion_date = datetime.fromisoformat(completion["date"]).date()
+ completion_type = completion.get("type", "check")
+
+ if completion_date == expected_date:
+ # Only count 'check' completions toward streak total
+ # 'skip' completions maintain the streak but don't extend it
+ if completion_type == "check":
+ streak += 1
+ expected_date = completion_date - timedelta(days=1)
+ elif completion_date < expected_date:
+ # Gap found, streak breaks
+ break
+
+ return streak
+
+
+def _calculate_specific_days_streak(habit: Dict[str, Any], completions: List[Dict[str, Any]]) -> int:
+ """Calculate streak for specific days habits (only count relevant days)."""
+ relevant_days = set(habit.get("frequency", {}).get("days", []))
+ if not relevant_days:
+ return 0
+
+ streak = 0
+ today = datetime.now().date()
+ current_date = today
+
+ # Find the most recent relevant day
+ while current_date.weekday() not in relevant_days:
+ current_date -= timedelta(days=1)
+
+ for completion in completions:
+ completion_date = datetime.fromisoformat(completion["date"]).date()
+
+ if completion_date == current_date:
+ streak += 1
+ # Move to previous relevant day
+ current_date -= timedelta(days=1)
+ while current_date.weekday() not in relevant_days:
+ current_date -= timedelta(days=1)
+ elif completion_date < current_date:
+ # Check if we missed a relevant day
+ temp_date = current_date
+ found_gap = False
+ while temp_date > completion_date:
+ if temp_date.weekday() in relevant_days:
+ found_gap = True
+ break
+ temp_date -= timedelta(days=1)
+ if found_gap:
+ break
+
+ return streak
+
+
+def _calculate_x_per_week_streak(habit: Dict[str, Any], completions: List[Dict[str, Any]]) -> int:
+ """Calculate streak for x_per_week habits (consecutive days with check-ins).
+
+ For x_per_week habits, streak counts consecutive DAYS with check-ins,
+ not consecutive weeks meeting the target. The weekly target (e.g., 4/week)
+ is a goal, but streak measures the chain of check-in days.
+ """
+ # Use the same logic as daily habits - count consecutive check-in days
+ return _calculate_daily_streak(completions)
+
+
+def _calculate_weekly_streak(completions: List[Dict[str, Any]]) -> int:
+ """Calculate streak for weekly habits (consecutive days with check-ins).
+
+ For weekly habits, streak counts consecutive DAYS with check-ins,
+ just like daily habits. The weekly frequency just means you should
+ check in at least once per week.
+ """
+ return _calculate_daily_streak(completions)
+
+
+def _calculate_monthly_streak(completions: List[Dict[str, Any]]) -> int:
+ """Calculate streak for monthly habits (consecutive days with check-ins).
+
+ For monthly habits, streak counts consecutive DAYS with check-ins,
+ just like daily habits. The monthly frequency just means you should
+ check in at least once per month.
+ """
+ return _calculate_daily_streak(completions)
+
+
+def _calculate_custom_streak(habit: Dict[str, Any], completions: List[Dict[str, Any]]) -> int:
+ """Calculate streak for custom interval habits (every X days)."""
+ interval = habit.get("frequency", {}).get("interval", 1)
+ if interval <= 0:
+ return 0
+
+ streak = 0
+ expected_date = datetime.now().date()
+
+ for completion in completions:
+ completion_date = datetime.fromisoformat(completion["date"]).date()
+
+ # Allow completion within the interval window
+ days_diff = (expected_date - completion_date).days
+ if 0 <= days_diff <= interval - 1:
+ streak += 1
+ expected_date = completion_date - timedelta(days=interval)
+ else:
+ break
+
+ return streak
+
+
+def should_check_today(habit: Dict[str, Any]) -> bool:
+ """
+ Check if a habit is relevant for today based on its frequency type.
+
+ Args:
+ habit: Dict containing habit data with frequency settings
+
+ Returns:
+ bool: True if the habit should be checked today
+ """
+ frequency_type = habit.get("frequency", {}).get("type", "daily")
+ today = datetime.now().date()
+ weekday = today.weekday() # 0=Monday, 6=Sunday
+
+ if frequency_type == "daily":
+ return True
+
+ elif frequency_type == "specific_days":
+ relevant_days = set(habit.get("frequency", {}).get("days", []))
+ return weekday in relevant_days
+
+ elif frequency_type == "x_per_week":
+ # Always relevant for x_per_week (can check any day)
+ return True
+
+ elif frequency_type == "weekly":
+ # Always relevant (can check any day of the week)
+ return True
+
+ elif frequency_type == "monthly":
+ # Always relevant (can check any day of the month)
+ return True
+
+ elif frequency_type == "custom":
+ # Check if enough days have passed since last completion
+ completions = habit.get("completions", [])
+ if not completions:
+ return True
+
+ interval = habit.get("frequency", {}).get("interval", 1)
+ last_completion = max(completions, key=lambda x: x.get("date", ""))
+ last_date = datetime.fromisoformat(last_completion["date"]).date()
+ days_since = (today - last_date).days
+
+ return days_since >= interval
+
+ return False
+
+
+def get_completion_rate(habit: Dict[str, Any], days: int = 30) -> float:
+ """
+ Calculate the completion rate as a percentage over the last N days.
+
+ Args:
+ habit: Dict containing habit data
+ days: Number of days to look back (default 30)
+
+ Returns:
+ float: Completion rate as percentage (0-100)
+ """
+ frequency_type = habit.get("frequency", {}).get("type", "daily")
+ completions = habit.get("completions", [])
+
+ today = datetime.now().date()
+ start_date = today - timedelta(days=days - 1)
+
+ # Count relevant days and checked days
+ relevant_days = 0
+ checked_dates = set()
+
+ for completion in completions:
+ completion_date = datetime.fromisoformat(completion["date"]).date()
+ if start_date <= completion_date <= today:
+ checked_dates.add(completion_date)
+
+ # Calculate relevant days based on frequency type
+ if frequency_type == "daily":
+ relevant_days = days
+
+ elif frequency_type == "specific_days":
+ relevant_day_set = set(habit.get("frequency", {}).get("days", []))
+ current = start_date
+ while current <= today:
+ if current.weekday() in relevant_day_set:
+ relevant_days += 1
+ current += timedelta(days=1)
+
+ elif frequency_type == "x_per_week":
+ target_per_week = habit.get("frequency", {}).get("count", 1)
+ num_weeks = days // 7
+ relevant_days = num_weeks * target_per_week
+
+ elif frequency_type == "weekly":
+ num_weeks = days // 7
+ relevant_days = num_weeks
+
+ elif frequency_type == "monthly":
+ num_months = days // 30
+ relevant_days = num_months
+
+ elif frequency_type == "custom":
+ interval = habit.get("frequency", {}).get("interval", 1)
+ relevant_days = days // interval if interval > 0 else 0
+
+ if relevant_days == 0:
+ return 0.0
+
+ checked_days = len(checked_dates)
+ return (checked_days / relevant_days) * 100
+
+
+def get_weekly_summary(habit: Dict[str, Any]) -> Dict[str, str]:
+ """
+ Get a summary of the current week showing status for each day.
+
+ Args:
+ habit: Dict containing habit data
+
+ Returns:
+ Dict mapping day names to status: "checked", "skipped", "missed", or "upcoming"
+ """
+ frequency_type = habit.get("frequency", {}).get("type", "daily")
+ completions = habit.get("completions", [])
+
+ today = datetime.now().date()
+
+ # Start of current week (Monday)
+ start_of_week = today - timedelta(days=today.weekday())
+
+ # Create completion map
+ completion_map = {}
+ for completion in completions:
+ completion_date = datetime.fromisoformat(completion["date"]).date()
+ if completion_date >= start_of_week:
+ completion_type = completion.get("type", "check")
+ completion_map[completion_date] = completion_type
+
+ # Build summary for each day of the week
+ summary = {}
+ day_names = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
+
+ for i, day_name in enumerate(day_names):
+ day_date = start_of_week + timedelta(days=i)
+
+ if day_date > today:
+ summary[day_name] = "upcoming"
+ elif day_date in completion_map:
+ if completion_map[day_date] == "skip":
+ summary[day_name] = "skipped"
+ else:
+ summary[day_name] = "checked"
+ else:
+ # Check if this day was relevant
+ if frequency_type == "specific_days":
+ relevant_days = set(habit.get("frequency", {}).get("days", []))
+ if day_date.weekday() not in relevant_days:
+ summary[day_name] = "not_relevant"
+ else:
+ summary[day_name] = "missed"
+ else:
+ summary[day_name] = "missed"
+
+ return summary
+
+
+def check_and_award_weekly_lives(habit: Dict[str, Any]) -> tuple[int, bool]:
+ """
+ Check if habit qualifies for weekly lives recovery and award +1 life if eligible.
+
+ Awards +1 life if:
+ - At least one check-in in the previous week (Monday-Sunday)
+ - Not already awarded this week
+
+ Args:
+ habit: Dict containing habit data with completions and lastLivesAward
+
+ Returns:
+ tuple[int, bool]: (new_lives_count, was_awarded)
+ """
+ completions = habit.get("completions", [])
+ current_lives = habit.get("lives", 3)
+
+ today = datetime.now().date()
+
+ # Calculate current week start (Monday 00:00)
+ current_week_start = today - timedelta(days=today.weekday())
+
+ # Check if already awarded this week
+ last_lives_award = habit.get("lastLivesAward")
+ if last_lives_award:
+ last_award_date = datetime.fromisoformat(last_lives_award).date()
+ if last_award_date >= current_week_start:
+ # Already awarded this week
+ return (current_lives, False)
+
+ # Calculate previous week boundaries
+ previous_week_start = current_week_start - timedelta(days=7)
+ previous_week_end = current_week_start - timedelta(days=1)
+
+ # Count check-ins in previous week
+ checkins_in_previous_week = 0
+ for completion in completions:
+ completion_date = datetime.fromisoformat(completion["date"]).date()
+ completion_type = completion.get("type", "check")
+
+ if previous_week_start <= completion_date <= previous_week_end:
+ if completion_type == "check":
+ checkins_in_previous_week += 1
+
+ # Award life if at least 1 check-in found
+ if checkins_in_previous_week >= 1:
+ new_lives = current_lives + 1
+ return (new_lives, True)
+
+ return (current_lives, False)
diff --git a/dashboard/index.html b/dashboard/index.html
new file mode 100644
index 0000000..5d6addf
--- /dev/null
+++ b/dashboard/index.html
@@ -0,0 +1,2494 @@
+
+
+
+
+
+
+ Echo · Dashboard
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Ultima verificare: -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Issue nou
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Todo nou
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dashboard/issues.json b/dashboard/issues.json
new file mode 100644
index 0000000..8a67fd4
--- /dev/null
+++ b/dashboard/issues.json
@@ -0,0 +1,69 @@
+{
+ "lastUpdated": "2026-03-31T20:02:48.501Z",
+ "programs": [
+ "ROACONT",
+ "ROAGEST",
+ "ROAIMOB",
+ "ROAFACTURARE",
+ "ROADEF",
+ "ROASTART",
+ "ROAPRINT",
+ "ROAWEB",
+ "Clawdbot",
+ "Personal",
+ "Altele"
+ ],
+ "issues": [
+ {
+ "id": "ROA-004",
+ "title": "Banca-Plati-Plata comision bancar 627- ar aparea si campul de Lucrare/Comanda",
+ "description": "Banca-Plati-Plata comision bancar 627- ar aparea si campul de Lucrare/Comanda",
+ "program": "ROACONT",
+ "owner": "robert",
+ "priority": "important",
+ "status": "done",
+ "created": "2026-02-12T13:19:01.786Z",
+ "deadline": null,
+ "completed": "2026-02-13T23:06:16.567Z"
+ },
+ {
+ "id": "ROA-002",
+ "title": "D406 - verificare SAFT account Id gol",
+ "description": "",
+ "program": "ROACONT",
+ "owner": "robert",
+ "priority": "urgent-important",
+ "status": "done",
+ "created": "2026-02-02T11:25:18.115Z",
+ "deadline": "2026-02-02",
+ "updated": "2026-02-02T22:27:06.428Z",
+ "completed": "2026-02-03T17:20:07.195Z"
+ },
+ {
+ "id": "ROA-001",
+ "title": "D101: Mutare impozit precedent RD49→RD50",
+ "description": "RD 49 = în urma inspecției fiscale\nRD 50 = impozit precedent\nFormularul nu recalculează impozitul de 16%\nRD 40 se modifică și la 4.1",
+ "program": "ROACONT",
+ "owner": "marius",
+ "priority": "important",
+ "status": "done",
+ "created": "2026-01-30T15:10:00Z",
+ "deadline": "2026-02-06",
+ "updated": "2026-02-02T22:26:59.690Z",
+ "completed": "2026-02-05T21:53:55.392Z"
+ },
+ {
+ "id": "ROA-003",
+ "title": "Auto-copiere manoperă din devize stimative în devize reale",
+ "description": "",
+ "program": "ROAGEST",
+ "owner": "robert",
+ "priority": "backlog",
+ "status": "done",
+ "created": "2026-02-12T10:03:13.378157+00:00",
+ "deadline": null,
+ "updated": "2026-02-13T13:03:45.355Z",
+ "completed": "2026-03-31T20:02:48.489Z"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/dashboard/notes.html b/dashboard/notes.html
new file mode 100644
index 0000000..1d8c743
--- /dev/null
+++ b/dashboard/notes.html
@@ -0,0 +1,1328 @@
+
+
+
+
+
+
+ Echo · KB
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
|
+
+
|
+
+
|
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dashboard/status.json b/dashboard/status.json
new file mode 100644
index 0000000..55141f6
--- /dev/null
+++ b/dashboard/status.json
@@ -0,0 +1,28 @@
+{
+ "git": {
+ "status": "4 fișiere",
+ "clean": false,
+ "files": 4
+ },
+ "lastReport": {
+ "type": "evening",
+ "summary": "notes.html îmbunătățit (filtre colorate), rețetă salvată",
+ "time": "30 Jan 2026, 22:00"
+ },
+ "anaf": {
+ "ok": false,
+ "status": "MODIFICĂRI",
+ "message": "1 modificări detectate",
+ "lastCheck": "03 Apr 2026, 22:07",
+ "changesCount": 1,
+ "changes": [
+ {
+ "name": "Pagina principală descărcare declarații",
+ "url": "https://static.anaf.ro/static/10/Anaf/Declaratii_R/descarcare_declaratii.htm",
+ "summary": [
+ "Pagina s-a modificat"
+ ]
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/dashboard/swipe-nav.js b/dashboard/swipe-nav.js
new file mode 100644
index 0000000..da10322
--- /dev/null
+++ b/dashboard/swipe-nav.js
@@ -0,0 +1,123 @@
+/**
+ * Swipe Navigation for Echo
+ * Swipe left/right to navigate between pages
+ */
+(function() {
+ const pages = ['index.html', 'eco.html', 'notes.html', 'habits.html', 'files.html', 'workspace.html'];
+
+ // Get current page index
+ function getCurrentIndex() {
+ const path = window.location.pathname;
+ let filename = path.split('/').pop() || 'index.html';
+ // Handle /echo/ without filename
+ if (filename === '' || filename === 'echo') filename = 'index.html';
+ const idx = pages.indexOf(filename);
+ return idx >= 0 ? idx : 0;
+ }
+
+ // Navigate to page
+ function navigateTo(index) {
+ if (index >= 0 && index < pages.length) {
+ window.location.href = pages[index];
+ }
+ }
+
+ // Swipe detection
+ let touchStartX = 0;
+ let touchStartY = 0;
+ let touchEndX = 0;
+ let touchEndY = 0;
+
+ const minSwipeDistance = 80;
+ const maxVerticalDistance = 100;
+
+ document.addEventListener('touchstart', function(e) {
+ touchStartX = e.changedTouches[0].screenX;
+ touchStartY = e.changedTouches[0].screenY;
+ }, { passive: true });
+
+ document.addEventListener('touchend', function(e) {
+ touchEndX = e.changedTouches[0].screenX;
+ touchEndY = e.changedTouches[0].screenY;
+ handleSwipe();
+ }, { passive: true });
+
+ function handleSwipe() {
+ const deltaX = touchEndX - touchStartX;
+ const deltaY = Math.abs(touchEndY - touchStartY);
+
+ // Ignore if vertical swipe or too short
+ if (deltaY > maxVerticalDistance) return;
+ if (Math.abs(deltaX) < minSwipeDistance) return;
+
+ const currentIndex = getCurrentIndex();
+
+ if (deltaX > 0) {
+ // Swipe right → previous page
+ navigateTo(currentIndex - 1);
+ } else {
+ // Swipe left → next page
+ navigateTo(currentIndex + 1);
+ }
+ }
+
+ // Visual indicator (optional dots)
+ function createIndicator() {
+ const indicator = document.createElement('div');
+ indicator.className = 'swipe-indicator';
+ indicator.innerHTML = pages.map((_, i) =>
+ ``
+ ).join('');
+ document.body.appendChild(indicator);
+ }
+
+ // Add indicator styles
+ const style = document.createElement('style');
+ style.textContent = `
+ .swipe-indicator {
+ position: fixed;
+ bottom: 24px;
+ left: 50%;
+ transform: translateX(-50%);
+ display: flex;
+ gap: 10px;
+ z-index: 9999;
+ padding: 10px 16px;
+ background: rgba(50, 50, 60, 0.9);
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ border-radius: 24px;
+ backdrop-filter: blur(8px);
+ }
+ .swipe-dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ background: rgba(255, 255, 255, 0.3);
+ border: 1px solid rgba(255, 255, 255, 0.5);
+ transition: all 0.2s;
+ }
+ .swipe-dot.active {
+ background: #3b82f6;
+ border-color: #3b82f6;
+ transform: scale(1.3);
+ box-shadow: 0 0 8px rgba(59, 130, 246, 0.6);
+ }
+ @media (min-width: 769px) {
+ .swipe-indicator { display: none; }
+ }
+ `;
+ document.head.appendChild(style);
+
+ // Init after DOM ready
+ function init() {
+ if ('ontouchstart' in window || navigator.maxTouchPoints > 0) {
+ createIndicator();
+ }
+ }
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', init);
+ } else {
+ init();
+ }
+})();
diff --git a/dashboard/tests/test_habits_api.py b/dashboard/tests/test_habits_api.py
new file mode 100644
index 0000000..39507d2
--- /dev/null
+++ b/dashboard/tests/test_habits_api.py
@@ -0,0 +1,1129 @@
+#!/usr/bin/env python3
+"""Tests for habits API endpoints (GET and POST)."""
+
+import json
+import sys
+import subprocess
+import tempfile
+import shutil
+from pathlib import Path
+from datetime import datetime, timedelta
+from http.server import HTTPServer
+import threading
+import time
+import urllib.request
+import urllib.error
+
+# Add parent directory to path so we can import api module
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+# Mock the habits file to a temp location for testing
+import api
+original_habits_file = api.HABITS_FILE
+
+def setup_test_env():
+ """Set up temporary test environment."""
+ temp_dir = Path(tempfile.mkdtemp())
+ api.HABITS_FILE = temp_dir / 'habits.json'
+
+ # Create empty habits file
+ api.HABITS_FILE.write_text(json.dumps({
+ 'lastUpdated': '',
+ 'habits': []
+ }))
+
+ return temp_dir
+
+def cleanup_test_env(temp_dir):
+ """Clean up temporary test environment."""
+ api.HABITS_FILE = original_habits_file
+ shutil.rmtree(temp_dir)
+
+def start_test_server():
+ """Start test server in background thread with random available port."""
+ server = HTTPServer(('localhost', 0), api.TaskBoardHandler) # Port 0 = random
+ port = server.server_address[1] # Get actual assigned port
+ thread = threading.Thread(target=server.serve_forever)
+ thread.daemon = True
+ thread.start()
+ time.sleep(0.3) # Give server time to start
+ return server, port
+
+def http_get(path, port=8765):
+ """Make HTTP GET request."""
+ url = f'http://localhost:{port}{path}'
+ try:
+ with urllib.request.urlopen(url) as response:
+ return response.status, json.loads(response.read().decode())
+ except urllib.error.HTTPError as e:
+ return e.code, json.loads(e.read().decode()) if e.headers.get('Content-Type') == 'application/json' else {}
+
+def http_post(path, data, port=8765):
+ """Make HTTP POST request."""
+ url = f'http://localhost:{port}{path}'
+ req = urllib.request.Request(
+ url,
+ data=json.dumps(data).encode(),
+ headers={'Content-Type': 'application/json'}
+ )
+ try:
+ with urllib.request.urlopen(req) as response:
+ return response.status, json.loads(response.read().decode())
+ except urllib.error.HTTPError as e:
+ return e.code, json.loads(e.read().decode()) if e.headers.get('Content-Type') == 'application/json' else {}
+
+def http_put(path, data, port=8765):
+ """Make HTTP PUT request."""
+ url = f'http://localhost:{port}{path}'
+ req = urllib.request.Request(
+ url,
+ data=json.dumps(data).encode(),
+ headers={'Content-Type': 'application/json'},
+ method='PUT'
+ )
+ try:
+ with urllib.request.urlopen(req) as response:
+ return response.status, json.loads(response.read().decode())
+ except urllib.error.HTTPError as e:
+ return e.code, json.loads(e.read().decode()) if e.headers.get('Content-Type') == 'application/json' else {}
+
+def http_delete(path, port=8765):
+ """Make HTTP DELETE request."""
+ url = f'http://localhost:{port}{path}'
+ req = urllib.request.Request(url, method='DELETE')
+ try:
+ with urllib.request.urlopen(req) as response:
+ # Handle JSON response if present
+ if response.headers.get('Content-Type') == 'application/json':
+ return response.status, json.loads(response.read().decode())
+ else:
+ return response.status, None
+ except urllib.error.HTTPError as e:
+ return e.code, json.loads(e.read().decode()) if e.headers.get('Content-Type') == 'application/json' else {}
+
+# Test 1: GET /api/habits returns empty array when no habits
+def test_get_habits_empty():
+ temp_dir = setup_test_env()
+ server, port = start_test_server()
+
+ try:
+ status, data = http_get('/api/habits', port)
+ assert status == 200, f"Expected 200, got {status}"
+ assert data == [], f"Expected empty array, got {data}"
+ print("✓ Test 1: GET /api/habits returns empty array")
+ finally:
+ server.shutdown()
+ cleanup_test_env(temp_dir)
+
+# Test 2: POST /api/habits creates new habit with valid input
+def test_post_habit_valid():
+ temp_dir = setup_test_env()
+ server, port = start_test_server()
+
+ try:
+ habit_data = {
+ 'name': 'Morning Exercise',
+ 'category': 'health',
+ 'color': '#10b981',
+ 'icon': 'dumbbell',
+ 'priority': 1,
+ 'notes': 'Start with 10 push-ups',
+ 'reminderTime': '07:00',
+ 'frequency': {
+ 'type': 'daily'
+ }
+ }
+
+ status, data = http_post('/api/habits', habit_data, port)
+ assert status == 201, f"Expected 201, got {status}"
+ assert 'id' in data, "Response should include habit id"
+ assert data['name'] == 'Morning Exercise', f"Name mismatch: {data['name']}"
+ assert data['category'] == 'health', f"Category mismatch: {data['category']}"
+ assert data['streak']['current'] == 0, "Initial streak should be 0"
+ assert data['lives'] == 3, "Initial lives should be 3"
+ assert data['completions'] == [], "Initial completions should be empty"
+ print("✓ Test 2: POST /api/habits creates habit with 201")
+ finally:
+ server.shutdown()
+ cleanup_test_env(temp_dir)
+
+# Test 3: POST validates name is required
+def test_post_habit_missing_name():
+ temp_dir = setup_test_env()
+ server, port = start_test_server()
+
+ try:
+ status, data = http_post('/api/habits', {}, port)
+ assert status == 400, f"Expected 400, got {status}"
+ assert 'error' in data, "Response should include error"
+ assert 'name' in data['error'].lower(), f"Error should mention name: {data['error']}"
+ print("✓ Test 3: POST validates name is required")
+ finally:
+ server.shutdown()
+ cleanup_test_env(temp_dir)
+
+# Test 4: POST validates name max 100 chars
+def test_post_habit_name_too_long():
+ temp_dir = setup_test_env()
+ server, port = start_test_server()
+
+ try:
+ status, data = http_post('/api/habits', {'name': 'x' * 101}, port)
+ assert status == 400, f"Expected 400, got {status}"
+ assert 'error' in data, "Response should include error"
+ assert '100' in data['error'], f"Error should mention max length: {data['error']}"
+ print("✓ Test 4: POST validates name max 100 chars")
+ finally:
+ server.shutdown()
+ cleanup_test_env(temp_dir)
+
+# Test 5: POST validates color hex format
+def test_post_habit_invalid_color():
+ temp_dir = setup_test_env()
+ server, port = start_test_server()
+
+ try:
+ status, data = http_post('/api/habits', {
+ 'name': 'Test',
+ 'color': 'not-a-hex-color'
+ }, port)
+ assert status == 400, f"Expected 400, got {status}"
+ assert 'error' in data, "Response should include error"
+ assert 'color' in data['error'].lower(), f"Error should mention color: {data['error']}"
+ print("✓ Test 5: POST validates color hex format")
+ finally:
+ server.shutdown()
+ cleanup_test_env(temp_dir)
+
+# Test 6: POST validates frequency type
+def test_post_habit_invalid_frequency():
+ temp_dir = setup_test_env()
+ server, port = start_test_server()
+
+ try:
+ status, data = http_post('/api/habits', {
+ 'name': 'Test',
+ 'frequency': {'type': 'invalid_type'}
+ }, port)
+ assert status == 400, f"Expected 400, got {status}"
+ assert 'error' in data, "Response should include error"
+ assert 'frequency' in data['error'].lower(), f"Error should mention frequency: {data['error']}"
+ print("✓ Test 6: POST validates frequency type")
+ finally:
+ server.shutdown()
+ cleanup_test_env(temp_dir)
+
+# Test 7: GET /api/habits returns habits with stats enriched
+def test_get_habits_with_stats():
+ temp_dir = setup_test_env()
+ server, port = start_test_server()
+
+ try:
+ # Create a habit first
+ habit_data = {'name': 'Daily Reading', 'frequency': {'type': 'daily'}}
+ http_post('/api/habits', habit_data, port)
+
+ # Get habits
+ status, data = http_get('/api/habits', port)
+ assert status == 200, f"Expected 200, got {status}"
+ assert len(data) == 1, f"Expected 1 habit, got {len(data)}"
+
+ habit = data[0]
+ assert 'current_streak' in habit, "Should include current_streak"
+ assert 'best_streak' in habit, "Should include best_streak"
+ assert 'completion_rate_30d' in habit, "Should include completion_rate_30d"
+ assert 'weekly_summary' in habit, "Should include weekly_summary"
+ print("✓ Test 7: GET returns habits with stats enriched")
+ finally:
+ server.shutdown()
+ cleanup_test_env(temp_dir)
+
+# Test 8: GET /api/habits sorts by priority ascending
+def test_get_habits_sorted_by_priority():
+ temp_dir = setup_test_env()
+ server, port = start_test_server()
+
+ try:
+ # Create habits with different priorities
+ http_post('/api/habits', {'name': 'Low Priority', 'priority': 10}, port)
+ http_post('/api/habits', {'name': 'High Priority', 'priority': 1}, port)
+ http_post('/api/habits', {'name': 'Medium Priority', 'priority': 5}, port)
+
+ # Get habits
+ status, data = http_get('/api/habits', port)
+ assert status == 200, f"Expected 200, got {status}"
+ assert len(data) == 3, f"Expected 3 habits, got {len(data)}"
+
+ # Check sorting
+ assert data[0]['priority'] == 1, "First should be priority 1"
+ assert data[1]['priority'] == 5, "Second should be priority 5"
+ assert data[2]['priority'] == 10, "Third should be priority 10"
+ print("✓ Test 8: GET sorts habits by priority ascending")
+ finally:
+ server.shutdown()
+ cleanup_test_env(temp_dir)
+
+# Test 9: POST returns 400 for invalid JSON
+def test_post_habit_invalid_json():
+ temp_dir = setup_test_env()
+ server, port = start_test_server()
+
+ try:
+ url = f'http://localhost:{port}/api/habits'
+ req = urllib.request.Request(
+ url,
+ data=b'invalid json{',
+ headers={'Content-Type': 'application/json'}
+ )
+ try:
+ urllib.request.urlopen(req)
+ assert False, "Should have raised HTTPError"
+ except urllib.error.HTTPError as e:
+ assert e.code == 400, f"Expected 400, got {e.code}"
+ print("✓ Test 9: POST returns 400 for invalid JSON")
+ finally:
+ server.shutdown()
+ cleanup_test_env(temp_dir)
+
+# Test 10: POST initializes streak.current=0
+def test_post_habit_initial_streak():
+ temp_dir = setup_test_env()
+ server, port = start_test_server()
+
+ try:
+ status, data = http_post('/api/habits', {'name': 'Test Habit'}, port)
+ assert status == 201, f"Expected 201, got {status}"
+ assert data['streak']['current'] == 0, "Initial streak.current should be 0"
+ assert data['streak']['best'] == 0, "Initial streak.best should be 0"
+ assert data['streak']['lastCheckIn'] is None, "Initial lastCheckIn should be None"
+ print("✓ Test 10: POST initializes streak correctly")
+ finally:
+ server.shutdown()
+ cleanup_test_env(temp_dir)
+
+# Test 12: PUT /api/habits/{id} updates habit successfully
+def test_put_habit_valid():
+ temp_dir = setup_test_env()
+ server, port = start_test_server()
+
+ try:
+ # Create a habit first
+ habit_data = {
+ 'name': 'Original Name',
+ 'category': 'health',
+ 'color': '#10b981',
+ 'priority': 3
+ }
+ status, created_habit = http_post('/api/habits', habit_data, port)
+ habit_id = created_habit['id']
+
+ # Update the habit
+ update_data = {
+ 'name': 'Updated Name',
+ 'category': 'productivity',
+ 'color': '#ef4444',
+ 'priority': 1,
+ 'notes': 'New notes'
+ }
+ status, updated_habit = http_put(f'/api/habits/{habit_id}', update_data, port)
+
+ assert status == 200, f"Expected 200, got {status}"
+ assert updated_habit['name'] == 'Updated Name', "Name not updated"
+ assert updated_habit['category'] == 'productivity', "Category not updated"
+ assert updated_habit['color'] == '#ef4444', "Color not updated"
+ assert updated_habit['priority'] == 1, "Priority not updated"
+ assert updated_habit['notes'] == 'New notes', "Notes not updated"
+ assert updated_habit['id'] == habit_id, "ID should not change"
+ print("✓ Test 12: PUT /api/habits/{id} updates habit successfully")
+ finally:
+ server.shutdown()
+ cleanup_test_env(temp_dir)
+
+# Test 13: PUT /api/habits/{id} does not allow editing protected fields
+def test_put_habit_protected_fields():
+ temp_dir = setup_test_env()
+ server, port = start_test_server()
+
+ try:
+ # Create a habit first
+ habit_data = {'name': 'Test Habit'}
+ status, created_habit = http_post('/api/habits', habit_data, port)
+ habit_id = created_habit['id']
+ original_created_at = created_habit['createdAt']
+
+ # Try to update protected fields
+ update_data = {
+ 'name': 'Updated Name',
+ 'id': 'new-id',
+ 'createdAt': '2020-01-01T00:00:00',
+ 'streak': {'current': 100, 'best': 200},
+ 'lives': 10,
+ 'completions': [{'date': '2025-01-01'}]
+ }
+ status, updated_habit = http_put(f'/api/habits/{habit_id}', update_data, port)
+
+ assert status == 200, f"Expected 200, got {status}"
+ assert updated_habit['name'] == 'Updated Name', "Name should be updated"
+ assert updated_habit['id'] == habit_id, "ID should not change"
+ assert updated_habit['createdAt'] == original_created_at, "createdAt should not change"
+ assert updated_habit['streak']['current'] == 0, "streak should not change"
+ assert updated_habit['lives'] == 3, "lives should not change"
+ assert updated_habit['completions'] == [], "completions should not change"
+ print("✓ Test 13: PUT /api/habits/{id} does not allow editing protected fields")
+ finally:
+ server.shutdown()
+ cleanup_test_env(temp_dir)
+
+# Test 14: PUT /api/habits/{id} returns 404 for non-existent habit
+def test_put_habit_not_found():
+ temp_dir = setup_test_env()
+ server, port = start_test_server()
+
+ try:
+ update_data = {'name': 'Updated Name'}
+ status, response = http_put('/api/habits/non-existent-id', update_data, port)
+
+ assert status == 404, f"Expected 404, got {status}"
+ assert 'error' in response, "Expected error message"
+ print("✓ Test 14: PUT /api/habits/{id} returns 404 for non-existent habit")
+ finally:
+ server.shutdown()
+ cleanup_test_env(temp_dir)
+
+# Test 15: PUT /api/habits/{id} validates input
+def test_put_habit_invalid_input():
+ temp_dir = setup_test_env()
+ server, port = start_test_server()
+
+ try:
+ # Create a habit first
+ habit_data = {'name': 'Test Habit'}
+ status, created_habit = http_post('/api/habits', habit_data, port)
+ habit_id = created_habit['id']
+
+ # Test invalid color
+ update_data = {'color': 'not-a-hex-color'}
+ status, response = http_put(f'/api/habits/{habit_id}', update_data, port)
+ assert status == 400, f"Expected 400 for invalid color, got {status}"
+
+ # Test empty name
+ update_data = {'name': ''}
+ status, response = http_put(f'/api/habits/{habit_id}', update_data, port)
+ assert status == 400, f"Expected 400 for empty name, got {status}"
+
+ # Test name too long
+ update_data = {'name': 'x' * 101}
+ status, response = http_put(f'/api/habits/{habit_id}', update_data, port)
+ assert status == 400, f"Expected 400 for long name, got {status}"
+
+ print("✓ Test 15: PUT /api/habits/{id} validates input")
+ finally:
+ server.shutdown()
+ cleanup_test_env(temp_dir)
+
+# Test 16: DELETE /api/habits/{id} removes habit successfully
+def test_delete_habit_success():
+ temp_dir = setup_test_env()
+ server, port = start_test_server()
+
+ try:
+ # Create a habit first
+ habit_data = {'name': 'Habit to Delete'}
+ status, created_habit = http_post('/api/habits', habit_data, port)
+ habit_id = created_habit['id']
+
+ # Verify habit exists
+ status, habits = http_get('/api/habits', port)
+ assert len(habits) == 1, "Should have 1 habit"
+
+ # Delete the habit
+ status, _ = http_delete(f'/api/habits/{habit_id}', port)
+ assert status == 204, f"Expected 204, got {status}"
+
+ # Verify habit is deleted
+ status, habits = http_get('/api/habits', port)
+ assert len(habits) == 0, "Should have 0 habits after deletion"
+ print("✓ Test 16: DELETE /api/habits/{id} removes habit successfully")
+ finally:
+ server.shutdown()
+ cleanup_test_env(temp_dir)
+
+# Test 17: DELETE /api/habits/{id} returns 404 for non-existent habit
+def test_delete_habit_not_found():
+ temp_dir = setup_test_env()
+ server, port = start_test_server()
+
+ try:
+ status, response = http_delete('/api/habits/non-existent-id', port)
+
+ assert status == 404, f"Expected 404, got {status}"
+ assert 'error' in response, "Expected error message"
+ print("✓ Test 17: DELETE /api/habits/{id} returns 404 for non-existent habit")
+ finally:
+ server.shutdown()
+ cleanup_test_env(temp_dir)
+
+# Test 18: do_OPTIONS includes PUT and DELETE methods
+def test_options_includes_put_delete():
+ temp_dir = setup_test_env()
+ server, port = start_test_server()
+
+ try:
+ # Make OPTIONS request
+ url = f'http://localhost:{port}/api/habits'
+ req = urllib.request.Request(url, method='OPTIONS')
+ with urllib.request.urlopen(req) as response:
+ allowed_methods = response.headers.get('Access-Control-Allow-Methods', '')
+ assert 'PUT' in allowed_methods, f"PUT not in allowed methods: {allowed_methods}"
+ assert 'DELETE' in allowed_methods, f"DELETE not in allowed methods: {allowed_methods}"
+ print("✓ Test 18: do_OPTIONS includes PUT and DELETE methods")
+ finally:
+ server.shutdown()
+ cleanup_test_env(temp_dir)
+
+# Test 20: POST /api/habits/{id}/check adds completion entry
+def test_check_in_basic():
+ temp_dir = setup_test_env()
+ server, port = start_test_server()
+
+ try:
+ # Create a daily habit
+ status, habit = http_post('/api/habits', {
+ 'name': 'Morning Exercise',
+ 'frequency': {'type': 'daily'}
+ }, port)
+ assert status == 201, f"Failed to create habit: {status}"
+ habit_id = habit['id']
+
+ # Check in on the habit
+ status, updated_habit = http_post(f'/api/habits/{habit_id}/check', {}, port)
+
+ assert status == 200, f"Expected 200, got {status}"
+ assert len(updated_habit['completions']) == 1, "Expected 1 completion"
+ assert updated_habit['completions'][0]['date'] == datetime.now().date().isoformat()
+ assert updated_habit['completions'][0]['type'] == 'check'
+ print("✓ Test 20: POST /api/habits/{id}/check adds completion entry")
+ finally:
+ server.shutdown()
+ cleanup_test_env(temp_dir)
+
+# Test 21: Check-in accepts optional note, rating, mood
+def test_check_in_with_details():
+ temp_dir = setup_test_env()
+ server, port = start_test_server()
+
+ try:
+ # Create a daily habit
+ status, habit = http_post('/api/habits', {
+ 'name': 'Meditation',
+ 'frequency': {'type': 'daily'}
+ }, port)
+ habit_id = habit['id']
+
+ # Check in with details
+ status, updated_habit = http_post(f'/api/habits/{habit_id}/check', {
+ 'note': 'Felt very relaxed today',
+ 'rating': 5,
+ 'mood': 'happy'
+ }, port)
+
+ assert status == 200, f"Expected 200, got {status}"
+ completion = updated_habit['completions'][0]
+ assert completion['note'] == 'Felt very relaxed today'
+ assert completion['rating'] == 5
+ assert completion['mood'] == 'happy'
+ print("✓ Test 21: Check-in accepts optional note, rating (1-5), and mood")
+ finally:
+ server.shutdown()
+ cleanup_test_env(temp_dir)
+
+# Test 22: Check-in returns 404 if habit not found
+def test_check_in_not_found():
+ temp_dir = setup_test_env()
+ server, port = start_test_server()
+
+ try:
+ status, response = http_post('/api/habits/non-existent-id/check', {}, port)
+
+ assert status == 404, f"Expected 404, got {status}"
+ assert 'error' in response
+ print("✓ Test 22: Check-in returns 404 if habit not found")
+ finally:
+ server.shutdown()
+ cleanup_test_env(temp_dir)
+
+# Test 23: Check-in returns 400 if habit not relevant for today
+def test_check_in_not_relevant():
+ temp_dir = setup_test_env()
+ server, port = start_test_server()
+
+ try:
+ # Create a habit for specific days (e.g., Monday only)
+ # If today is not Monday, it should fail
+ today_weekday = datetime.now().date().weekday()
+ different_day = (today_weekday + 1) % 7 # Pick a different day
+
+ status, habit = http_post('/api/habits', {
+ 'name': 'Monday Only Habit',
+ 'frequency': {
+ 'type': 'specific_days',
+ 'days': [different_day]
+ }
+ }, port)
+ habit_id = habit['id']
+
+ # Try to check in
+ status, response = http_post(f'/api/habits/{habit_id}/check', {}, port)
+
+ assert status == 400, f"Expected 400, got {status}"
+ assert 'not relevant' in response.get('error', '').lower()
+ print("✓ Test 23: Check-in returns 400 if habit not relevant for today")
+ finally:
+ server.shutdown()
+ cleanup_test_env(temp_dir)
+
+# Test 24: Check-in returns 409 if already checked today
+def test_check_in_already_checked():
+ temp_dir = setup_test_env()
+ server, port = start_test_server()
+
+ try:
+ # Create a daily habit
+ status, habit = http_post('/api/habits', {
+ 'name': 'Water Plants',
+ 'frequency': {'type': 'daily'}
+ }, port)
+ habit_id = habit['id']
+
+ # Check in once
+ status, _ = http_post(f'/api/habits/{habit_id}/check', {}, port)
+ assert status == 200, "First check-in should succeed"
+
+ # Try to check in again
+ status, response = http_post(f'/api/habits/{habit_id}/check', {}, port)
+
+ assert status == 409, f"Expected 409, got {status}"
+ assert 'already checked' in response.get('error', '').lower()
+ print("✓ Test 24: Check-in returns 409 if already checked today")
+ finally:
+ server.shutdown()
+ cleanup_test_env(temp_dir)
+
+# Test 25: Streak is recalculated after check-in
+def test_check_in_updates_streak():
+ temp_dir = setup_test_env()
+ server, port = start_test_server()
+
+ try:
+ # Create a daily habit
+ status, habit = http_post('/api/habits', {
+ 'name': 'Read',
+ 'frequency': {'type': 'daily'}
+ }, port)
+ habit_id = habit['id']
+
+ # Check in
+ status, updated_habit = http_post(f'/api/habits/{habit_id}/check', {}, port)
+
+ assert status == 200, f"Expected 200, got {status}"
+ assert updated_habit['streak']['current'] == 1, f"Expected streak 1, got {updated_habit['streak']['current']}"
+ assert updated_habit['streak']['best'] == 1, f"Expected best streak 1, got {updated_habit['streak']['best']}"
+ print("✓ Test 25: Streak current and best are recalculated after check-in")
+ finally:
+ server.shutdown()
+ cleanup_test_env(temp_dir)
+
+# Test 26: lastCheckIn is updated after check-in
+def test_check_in_updates_last_check_in():
+ temp_dir = setup_test_env()
+ server, port = start_test_server()
+
+ try:
+ # Create a daily habit
+ status, habit = http_post('/api/habits', {
+ 'name': 'Floss',
+ 'frequency': {'type': 'daily'}
+ }, port)
+ habit_id = habit['id']
+
+ # Initially lastCheckIn should be None
+ assert habit['streak']['lastCheckIn'] is None
+
+ # Check in
+ status, updated_habit = http_post(f'/api/habits/{habit_id}/check', {}, port)
+
+ today = datetime.now().date().isoformat()
+ assert updated_habit['streak']['lastCheckIn'] == today
+ print("✓ Test 26: lastCheckIn is updated to today's date")
+ finally:
+ server.shutdown()
+ cleanup_test_env(temp_dir)
+
+# Test 27: Lives are restored after 7 consecutive check-ins
+def test_check_in_life_restore():
+ temp_dir = setup_test_env()
+ server, port = start_test_server()
+
+ try:
+ # Create a daily habit and manually set up 6 previous check-ins
+ status, habit = http_post('/api/habits', {
+ 'name': 'Yoga',
+ 'frequency': {'type': 'daily'}
+ }, port)
+ habit_id = habit['id']
+
+ # Manually add 6 previous check-ins and reduce lives to 2
+ habits_data = json.loads(api.HABITS_FILE.read_text())
+ for h in habits_data['habits']:
+ if h['id'] == habit_id:
+ h['lives'] = 2
+ # Add 6 check-ins from previous days
+ for i in range(6, 0, -1):
+ past_date = (datetime.now().date() - timedelta(days=i)).isoformat()
+ h['completions'].append({
+ 'date': past_date,
+ 'type': 'check'
+ })
+ break
+ api.HABITS_FILE.write_text(json.dumps(habits_data, indent=2))
+
+ # Check in for today (7th consecutive)
+ status, updated_habit = http_post(f'/api/habits/{habit_id}/check', {}, port)
+
+ assert status == 200, f"Expected 200, got {status}"
+ assert updated_habit['lives'] == 3, f"Expected 3 lives restored, got {updated_habit['lives']}"
+ print("✓ Test 27: Lives are restored by 1 (max 3) after 7 consecutive check-ins")
+ finally:
+ server.shutdown()
+ cleanup_test_env(temp_dir)
+
+# Test 28: Check-in validates rating range
+def test_check_in_invalid_rating():
+ temp_dir = setup_test_env()
+ server, port = start_test_server()
+
+ try:
+ # Create a daily habit
+ status, habit = http_post('/api/habits', {
+ 'name': 'Journal',
+ 'frequency': {'type': 'daily'}
+ }, port)
+ habit_id = habit['id']
+
+ # Try to check in with invalid rating
+ status, response = http_post(f'/api/habits/{habit_id}/check', {
+ 'rating': 10 # Invalid, should be 1-5
+ }, port)
+
+ assert status == 400, f"Expected 400, got {status}"
+ assert 'rating' in response.get('error', '').lower()
+ print("✓ Test 28: Check-in validates rating is between 1 and 5")
+ finally:
+ server.shutdown()
+ cleanup_test_env(temp_dir)
+
+# Test 29: Check-in validates mood values
+def test_check_in_invalid_mood():
+ temp_dir = setup_test_env()
+ server, port = start_test_server()
+
+ try:
+ # Create a daily habit
+ status, habit = http_post('/api/habits', {
+ 'name': 'Gratitude',
+ 'frequency': {'type': 'daily'}
+ }, port)
+ habit_id = habit['id']
+
+ # Try to check in with invalid mood
+ status, response = http_post(f'/api/habits/{habit_id}/check', {
+ 'mood': 'excited' # Invalid, should be happy/neutral/sad
+ }, port)
+
+ assert status == 400, f"Expected 400, got {status}"
+ assert 'mood' in response.get('error', '').lower()
+ print("✓ Test 29: Check-in validates mood is one of: happy, neutral, sad")
+ finally:
+ server.shutdown()
+ cleanup_test_env(temp_dir)
+
+# Test 30: Skip basic - decrements lives
+def test_skip_basic():
+ temp_dir = setup_test_env()
+ server, port = start_test_server()
+
+ try:
+ # Create a habit
+ status, habit = http_post('/api/habits', {
+ 'name': 'Daily Exercise',
+ 'frequency': {'type': 'daily'}
+ }, port)
+ assert status == 201
+ habit_id = habit['id']
+
+ # Skip a day
+ status, response = http_post(f'/api/habits/{habit_id}/skip', {}, port)
+
+ assert status == 200, f"Expected 200, got {status}"
+ assert response['lives'] == 2, f"Expected 2 lives, got {response['lives']}"
+
+ # Verify completion entry was added with type='skip'
+ completions = response.get('completions', [])
+ assert len(completions) == 1, f"Expected 1 completion, got {len(completions)}"
+ assert completions[0]['type'] == 'skip', f"Expected type='skip', got {completions[0].get('type')}"
+ assert completions[0]['date'] == datetime.now().date().isoformat()
+
+ print("✓ Test 30: Skip decrements lives and adds skip completion")
+ finally:
+ server.shutdown()
+ cleanup_test_env(temp_dir)
+
+# Test 31: Skip preserves streak
+def test_skip_preserves_streak():
+ temp_dir = setup_test_env()
+ server, port = start_test_server()
+
+ try:
+ # Create a habit
+ status, habit = http_post('/api/habits', {
+ 'name': 'Daily Exercise',
+ 'frequency': {'type': 'daily'}
+ }, port)
+ assert status == 201
+ habit_id = habit['id']
+
+ # Check in to build a streak
+ http_post(f'/api/habits/{habit_id}/check', {}, port)
+
+ # Get current streak
+ status, habits = http_get('/api/habits', port)
+ current_streak = habits[0]['current_streak']
+ assert current_streak > 0
+
+ # Skip the next day (simulate by adding skip manually and checking streak doesn't break)
+ # Since we can't time travel, we'll verify that skip doesn't recalculate streak
+ status, response = http_post(f'/api/habits/{habit_id}/skip', {}, port)
+
+ assert status == 200, f"Expected 200, got {status}"
+ # Verify lives decremented
+ assert response['lives'] == 2
+ # The streak should remain unchanged (skip doesn't break it)
+ # Note: We can't verify streak preservation perfectly without time travel,
+ # but we verify the skip completion is added correctly
+ completions = response.get('completions', [])
+ skip_count = sum(1 for c in completions if c.get('type') == 'skip')
+ assert skip_count == 1
+
+ print("✓ Test 31: Skip preserves streak (doesn't break it)")
+ finally:
+ server.shutdown()
+ cleanup_test_env(temp_dir)
+
+# Test 32: Skip returns 404 for non-existent habit
+def test_skip_not_found():
+ temp_dir = setup_test_env()
+ server, port = start_test_server()
+
+ try:
+ status, response = http_post('/api/habits/nonexistent-id/skip', {}, port)
+
+ assert status == 404, f"Expected 404, got {status}"
+ assert 'not found' in response.get('error', '').lower()
+
+ print("✓ Test 32: Skip returns 404 for non-existent habit")
+ finally:
+ server.shutdown()
+ cleanup_test_env(temp_dir)
+
+# Test 33: Skip returns 400 when no lives remaining
+def test_skip_no_lives():
+ temp_dir = setup_test_env()
+ server, port = start_test_server()
+
+ try:
+ # Create a habit
+ status, habit = http_post('/api/habits', {
+ 'name': 'Daily Exercise',
+ 'frequency': {'type': 'daily'}
+ }, port)
+ assert status == 201
+ habit_id = habit['id']
+
+ # Use all 3 lives
+ for i in range(3):
+ status, response = http_post(f'/api/habits/{habit_id}/skip', {}, port)
+ assert status == 200, f"Skip {i+1} failed with status {status}"
+ assert response['lives'] == 2 - i, f"Expected {2-i} lives, got {response['lives']}"
+
+ # Try to skip again with no lives
+ status, response = http_post(f'/api/habits/{habit_id}/skip', {}, port)
+
+ assert status == 400, f"Expected 400, got {status}"
+ assert 'no lives remaining' in response.get('error', '').lower()
+
+ print("✓ Test 33: Skip returns 400 when no lives remaining")
+ finally:
+ server.shutdown()
+ cleanup_test_env(temp_dir)
+
+# Test 34: Skip returns updated habit with new lives count
+def test_skip_returns_updated_habit():
+ temp_dir = setup_test_env()
+ server, port = start_test_server()
+
+ try:
+ # Create a habit
+ status, habit = http_post('/api/habits', {
+ 'name': 'Daily Exercise',
+ 'frequency': {'type': 'daily'}
+ }, port)
+ assert status == 201
+ habit_id = habit['id']
+ original_updated_at = habit['updatedAt']
+
+ # Skip a day
+ status, response = http_post(f'/api/habits/{habit_id}/skip', {}, port)
+
+ assert status == 200
+ assert response['id'] == habit_id
+ assert response['lives'] == 2
+ assert response['updatedAt'] != original_updated_at, "updatedAt should be updated"
+ assert 'name' in response
+ assert 'frequency' in response
+ assert 'completions' in response
+
+ print("✓ Test 34: Skip returns updated habit with new lives count")
+ finally:
+ server.shutdown()
+ cleanup_test_env(temp_dir)
+
+# Test 35: DELETE uncheck - removes completion for specified date
+def test_uncheck_removes_completion():
+ temp_dir = setup_test_env()
+ server, port = start_test_server()
+
+ try:
+ # Create a habit
+ status, habit = http_post('/api/habits', {
+ 'name': 'Daily Exercise',
+ 'frequency': {'type': 'daily'}
+ }, port)
+ assert status == 201
+ habit_id = habit['id']
+
+ # Check in on a specific date
+ today = datetime.now().date().isoformat()
+ status, response = http_post(f'/api/habits/{habit_id}/check', {}, port)
+ assert status == 200
+ assert len(response['completions']) == 1
+ assert response['completions'][0]['date'] == today
+
+ # Uncheck the habit for today
+ status, response = http_delete(f'/api/habits/{habit_id}/check?date={today}', port)
+ assert status == 200
+ assert len(response['completions']) == 0, "Completion should be removed"
+ assert response['id'] == habit_id
+
+ print("✓ Test 35: DELETE uncheck removes completion for specified date")
+ finally:
+ server.shutdown()
+ cleanup_test_env(temp_dir)
+
+# Test 36: DELETE uncheck - returns 404 if no completion for date
+def test_uncheck_no_completion_for_date():
+ temp_dir = setup_test_env()
+ server, port = start_test_server()
+
+ try:
+ # Create a habit (but don't check in)
+ status, habit = http_post('/api/habits', {
+ 'name': 'Daily Exercise',
+ 'frequency': {'type': 'daily'}
+ }, port)
+ assert status == 201
+ habit_id = habit['id']
+
+ # Try to uncheck a date with no completion
+ today = datetime.now().date().isoformat()
+ status, response = http_delete(f'/api/habits/{habit_id}/check?date={today}', port)
+ assert status == 404
+ assert 'error' in response
+ assert 'No completion found' in response['error']
+
+ print("✓ Test 36: DELETE uncheck returns 404 if no completion for date")
+ finally:
+ server.shutdown()
+ cleanup_test_env(temp_dir)
+
+# Test 37: DELETE uncheck - returns 404 if habit not found
+def test_uncheck_habit_not_found():
+ temp_dir = setup_test_env()
+ server, port = start_test_server()
+
+ try:
+ today = datetime.now().date().isoformat()
+ status, response = http_delete(f'/api/habits/nonexistent-id/check?date={today}', port)
+ assert status == 404
+ assert 'error' in response
+ assert 'Habit not found' in response['error']
+
+ print("✓ Test 37: DELETE uncheck returns 404 if habit not found")
+ finally:
+ server.shutdown()
+ cleanup_test_env(temp_dir)
+
+# Test 38: DELETE uncheck - recalculates streak correctly
+def test_uncheck_recalculates_streak():
+ temp_dir = setup_test_env()
+ server, port = start_test_server()
+
+ try:
+ # Create a habit
+ status, habit = http_post('/api/habits', {
+ 'name': 'Daily Exercise',
+ 'frequency': {'type': 'daily'}
+ }, port)
+ assert status == 201
+ habit_id = habit['id']
+
+ # Check in for 3 consecutive days
+ today = datetime.now().date()
+ for i in range(3):
+ check_date = (today - timedelta(days=2-i)).isoformat()
+ # Manually add completion to the habit
+ with open(api.HABITS_FILE, 'r') as f:
+ data = json.load(f)
+ for h in data['habits']:
+ if h['id'] == habit_id:
+ h['completions'].append({'date': check_date, 'type': 'check'})
+ with open(api.HABITS_FILE, 'w') as f:
+ json.dump(data, f)
+
+ # Get habit to verify streak is 3
+ status, habit = http_get('/api/habits', port)
+ assert status == 200
+ habit = [h for h in habit if h['id'] == habit_id][0]
+ assert habit['current_streak'] == 3
+
+ # Uncheck the middle day
+ middle_date = (today - timedelta(days=1)).isoformat()
+ status, response = http_delete(f'/api/habits/{habit_id}/check?date={middle_date}', port)
+ assert status == 200
+
+ # Streak should now be 1 (only today counts)
+ assert response['streak']['current'] == 1, f"Expected streak 1, got {response['streak']['current']}"
+
+ print("✓ Test 38: DELETE uncheck recalculates streak correctly")
+ finally:
+ server.shutdown()
+ cleanup_test_env(temp_dir)
+
+# Test 39: DELETE uncheck - returns updated habit object
+def test_uncheck_returns_updated_habit():
+ temp_dir = setup_test_env()
+ server, port = start_test_server()
+
+ try:
+ # Create and check in
+ status, habit = http_post('/api/habits', {
+ 'name': 'Daily Exercise',
+ 'frequency': {'type': 'daily'}
+ }, port)
+ habit_id = habit['id']
+
+ today = datetime.now().date().isoformat()
+ status, _ = http_post(f'/api/habits/{habit_id}/check', {}, port)
+
+ # Uncheck and verify response structure
+ status, response = http_delete(f'/api/habits/{habit_id}/check?date={today}', port)
+ assert status == 200
+ assert 'id' in response
+ assert 'name' in response
+ assert 'completions' in response
+ assert 'streak' in response
+ assert 'updatedAt' in response
+
+ print("✓ Test 39: DELETE uncheck returns updated habit object")
+ finally:
+ server.shutdown()
+ cleanup_test_env(temp_dir)
+
+# Test 40: DELETE uncheck - requires date parameter
+def test_uncheck_requires_date():
+ temp_dir = setup_test_env()
+ server, port = start_test_server()
+
+ try:
+ # Create a habit
+ status, habit = http_post('/api/habits', {
+ 'name': 'Daily Exercise',
+ 'frequency': {'type': 'daily'}
+ }, port)
+ habit_id = habit['id']
+
+ # Try to uncheck without date parameter
+ status, response = http_delete(f'/api/habits/{habit_id}/check', port)
+ assert status == 400
+ assert 'error' in response
+ assert 'date parameter is required' in response['error']
+
+ print("✓ Test 40: DELETE uncheck requires date parameter")
+ finally:
+ server.shutdown()
+ cleanup_test_env(temp_dir)
+
+# Test 41: Typecheck passes
+def test_typecheck():
+ result = subprocess.run(
+ ['python3', '-m', 'py_compile', str(Path(__file__).parent.parent / 'api.py')],
+ capture_output=True
+ )
+ assert result.returncode == 0, f"Typecheck failed: {result.stderr.decode()}"
+ print("✓ Test 41: Typecheck passes")
+
+if __name__ == '__main__':
+ import subprocess
+
+ print("\n=== Running Habits API Tests ===\n")
+
+ test_get_habits_empty()
+ test_post_habit_valid()
+ test_post_habit_missing_name()
+ test_post_habit_name_too_long()
+ test_post_habit_invalid_color()
+ test_post_habit_invalid_frequency()
+ test_get_habits_with_stats()
+ test_get_habits_sorted_by_priority()
+ test_post_habit_invalid_json()
+ test_post_habit_initial_streak()
+ test_put_habit_valid()
+ test_put_habit_protected_fields()
+ test_put_habit_not_found()
+ test_put_habit_invalid_input()
+ test_delete_habit_success()
+ test_delete_habit_not_found()
+ test_options_includes_put_delete()
+ test_check_in_basic()
+ test_check_in_with_details()
+ test_check_in_not_found()
+ test_check_in_not_relevant()
+ test_check_in_already_checked()
+ test_check_in_updates_streak()
+ test_check_in_updates_last_check_in()
+ test_check_in_life_restore()
+ test_check_in_invalid_rating()
+ test_check_in_invalid_mood()
+ test_skip_basic()
+ test_skip_preserves_streak()
+ test_skip_not_found()
+ test_skip_no_lives()
+ test_skip_returns_updated_habit()
+ test_uncheck_removes_completion()
+ test_uncheck_no_completion_for_date()
+ test_uncheck_habit_not_found()
+ test_uncheck_recalculates_streak()
+ test_uncheck_returns_updated_habit()
+ test_uncheck_requires_date()
+ test_typecheck()
+
+ print("\n✅ All 41 tests passed!\n")
diff --git a/dashboard/tests/test_habits_frontend.py b/dashboard/tests/test_habits_frontend.py
new file mode 100644
index 0000000..8c93c6d
--- /dev/null
+++ b/dashboard/tests/test_habits_frontend.py
@@ -0,0 +1,2868 @@
+"""
+Test suite for Habits frontend page structure and navigation
+Story US-002: Frontend - Compact habit cards (~100px height)
+Story US-003: Frontend - Check/uncheck toggle behavior
+Story US-005: Frontend - Stats section collapse with chevron
+Story US-006: Frontend - Page structure, layout, and navigation link
+Story US-007: Frontend - Habit card component
+Story US-008: Frontend - Create habit modal with all options
+Story US-009: Frontend - Edit habit modal
+Story US-010: Frontend - Check-in interaction (click and long-press)
+Story US-011: Frontend - Skip, lives display, and delete confirmation
+Story US-012: Frontend - Filter and sort controls
+Story US-013: Frontend - Stats section and weekly summary
+Story US-014: Frontend - Mobile responsive and touch optimization
+"""
+
+import sys
+import os
+import subprocess
+from pathlib import Path
+
+# Add parent directory to path for imports
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+def test_habits_html_exists():
+ """Test 1: habits.html exists in dashboard/"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ assert habits_path.exists(), "habits.html should exist in dashboard/"
+ print("✓ Test 1: habits.html exists")
+
+def test_habits_html_structure():
+ """Test 2: Page includes common.css, Lucide icons, and swipe-nav.js"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ content = habits_path.read_text()
+
+ assert 'href="/echo/common.css"' in content, "Should include common.css"
+ assert 'lucide@latest/dist/umd/lucide.min.js' in content, "Should include Lucide icons"
+ assert 'src="/echo/swipe-nav.js"' in content, "Should include swipe-nav.js"
+ print("✓ Test 2: Page includes required CSS and JS")
+
+def test_page_has_header():
+ """Test 3: Page has header with 'Habits' title and 'Add Habit' button"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ content = habits_path.read_text()
+
+ assert 'class="page-title"' in content, "Should have page-title element"
+ assert '>Habits' in content, "Should have 'Habits' title"
+ assert 'Add Habit' in content, "Should have 'Add Habit' button"
+ assert 'showAddHabitModal()' in content, "Add Habit button should have onclick handler"
+ print("✓ Test 3: Page has header with title and Add Habit button")
+
+def test_empty_state():
+ """Test 4: Empty state message shown when no habits exist"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ content = habits_path.read_text()
+
+ assert 'No habits yet' in content, "Should have empty state message"
+ assert 'Create your first habit' in content, "Should have call-to-action"
+ assert 'empty-state' in content, "Should have empty-state class"
+ print("✓ Test 4: Empty state message present")
+
+def test_grid_container():
+ """Test 5: Grid container uses CSS grid with responsive breakpoints (1/2/3 columns)"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ content = habits_path.read_text()
+
+ assert 'habits-grid' in content, "Should have habits-grid class"
+ assert 'display: grid' in content, "Should use CSS grid"
+ assert 'grid-template-columns' in content, "Should define grid columns"
+
+ # Check responsive breakpoints
+ assert '@media (max-width: 768px)' in content or '@media (max-width:768px)' in content, \
+ "Should have mobile breakpoint"
+ assert 'grid-template-columns: 1fr' in content or 'grid-template-columns:1fr' in content, \
+ "Should have 1 column on mobile"
+
+ # Check for 2 or 3 column layouts
+ assert ('grid-template-columns: repeat(2, 1fr)' in content or
+ 'grid-template-columns:repeat(2,1fr)' in content or
+ 'grid-template-columns: repeat(3, 1fr)' in content or
+ 'grid-template-columns:repeat(3,1fr)' in content), \
+ "Should have multi-column layout for larger screens"
+
+ print("✓ Test 5: Grid container with responsive breakpoints")
+
+def test_index_navigation_link():
+ """Test 6: index.html navigation includes 'Habits' link with dumbbell icon"""
+ index_path = Path(__file__).parent.parent / 'index.html'
+ content = index_path.read_text()
+
+ assert '/echo/habits.html' in content, "Should link to /echo/habits.html"
+ assert 'dumbbell' in content, "Should have dumbbell icon"
+ assert '>Habits' in content, "Should have 'Habits' label"
+
+ # Check that Habits link is in the nav
+ nav_start = content.find('', nav_start)
+ nav_section = content[nav_start:nav_end]
+
+ assert '/echo/habits.html' in nav_section, "Habits link should be in navigation"
+ assert 'dumbbell' in nav_section, "Dumbbell icon should be in navigation"
+
+ print("✓ Test 6: index.html includes Habits navigation link")
+
+def test_page_fetches_habits():
+ """Test 7: Page fetches GET /echo/api/habits on load"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ content = habits_path.read_text()
+
+ assert "fetch('/echo/api/habits')" in content or 'fetch("/echo/api/habits")' in content, \
+ "Should fetch from /echo/api/habits"
+ assert 'loadHabits' in content, "Should have loadHabits function"
+
+ # Check that loadHabits is called on page load
+ # (either in inline script or as last statement)
+ assert content.count('loadHabits()') > 0, "loadHabits should be called"
+
+ print("✓ Test 7: Page fetches habits on load")
+
+def test_habit_card_rendering():
+ """Test 8: Placeholder habit card rendering exists"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ content = habits_path.read_text()
+
+ assert 'renderHabitCard' in content, "Should have renderHabitCard function"
+ assert 'habit-card' in content, "Should have habit-card class"
+ assert 'renderHabits' in content, "Should have renderHabits function"
+
+ print("✓ Test 8: Habit card rendering functions exist")
+
+def test_no_console_errors_structure():
+ """Test 9: No obvious console error sources (basic structure check)"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ content = habits_path.read_text()
+
+ # Check for basic script structure
+ assert '')
+ assert script_open == script_close, f"Script tags should match (found {script_open} opens, {script_close} closes)"
+
+ print("✓ Test 10: HTML structure is well-formed")
+
+def test_card_colored_border():
+ """Test 11: Habit card has colored left border matching habit.color"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ content = habits_path.read_text()
+
+ assert 'border-left-color' in content or 'borderLeftColor' in content, \
+ "Card should have colored left border"
+ assert 'habit.color' in content, "Card should use habit.color for border"
+ print("✓ Test 11: Card has colored left border")
+
+def test_card_header_icons():
+ """Test 12: Card header shows icon, name, settings, and delete"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ content = habits_path.read_text()
+
+ # Check for icon display
+ assert 'habit.icon' in content or 'habit-card-icon' in content, \
+ "Card should display habit icon"
+
+ # Check for name display
+ assert 'habit.name' in content or 'habit-card-name' in content, \
+ "Card should display habit name"
+
+ # Check for settings (gear) icon
+ assert 'settings' in content.lower(), "Card should have settings icon"
+
+ # Check for delete (trash) icon
+ assert 'trash' in content.lower(), "Card should have delete icon"
+
+ print("✓ Test 12: Card header has icon, name, settings, and delete")
+
+def test_card_streak_display():
+ """Test 13: Streak displays with fire emoji for current and trophy for best"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ content = habits_path.read_text()
+
+ assert '🔥' in content, "Card should have fire emoji for current streak"
+ assert '🏆' in content, "Card should have trophy emoji for best streak"
+ assert 'habit.streak' in content or 'streak?.current' in content or 'streak.current' in content, \
+ "Card should display streak.current"
+ assert 'streak?.best' in content or 'streak.best' in content, \
+ "Card should display streak.best"
+
+ print("✓ Test 13: Streak display with fire and trophy emojis")
+
+def test_card_checkin_button():
+ """Test 14: Check-in button is large and centered"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ content = habits_path.read_text()
+
+ assert 'habit-card-check-btn' in content or 'check-btn' in content or 'checkin' in content.lower(), \
+ "Card should have check-in button"
+ assert 'Check In' in content or 'Check in' in content, \
+ "Button should have 'Check In' text"
+
+ # Check for button styling (large/centered)
+ assert 'width: 100%' in content or 'width:100%' in content, \
+ "Check-in button should be full-width"
+
+ print("✓ Test 14: Check-in button is large and centered")
+
+def test_card_checkin_disabled_when_done():
+ """Test 15: Check-in button disabled when already checked today"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ content = habits_path.read_text()
+
+ assert 'disabled' in content, "Button should have disabled state"
+ assert 'Done today' in content or 'Done' in content, \
+ "Button should show 'Done today' when disabled"
+ assert 'isCheckedToday' in content or 'isDoneToday' in content, \
+ "Should have function to check if habit is done today"
+
+ print("✓ Test 15: Check-in button disabled when done today")
+
+def test_card_lives_display():
+ """Test 16: Lives display shows filled and empty hearts (total 3)"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ content = habits_path.read_text()
+
+ assert '❤️' in content or '♥' in content, "Card should have filled heart emoji"
+ assert '🖤' in content or '♡' in content, "Card should have empty heart emoji"
+ assert 'habit.lives' in content or 'renderLives' in content, \
+ "Card should display lives"
+
+ # Check for lives rendering function
+ assert 'renderLives' in content or 'lives' in content.lower(), \
+ "Should have lives rendering logic"
+
+ print("✓ Test 16: Lives display with hearts")
+
+def test_card_completion_rate():
+ """Test 17: Completion rate percentage is displayed"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ content = habits_path.read_text()
+
+ assert 'completion_rate' in content or 'completion' in content, \
+ "Card should display completion rate"
+ assert '(30d)' in content or '30d' in content, \
+ "Completion rate should show 30-day period"
+ assert '%' in content, "Completion rate should show percentage"
+
+ print("✓ Test 17: Completion rate displayed")
+
+def test_card_footer_category_priority():
+ """Test 18: Footer shows category badge and priority"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ content = habits_path.read_text()
+
+ assert 'habit.category' in content or 'habit-card-category' in content, \
+ "Card should display category"
+ assert 'habit.priority' in content or 'priority' in content.lower(), \
+ "Card should display priority"
+ assert 'habit-card-footer' in content or 'footer' in content.lower(), \
+ "Card should have footer section"
+
+ print("✓ Test 18: Footer shows category and priority")
+
+def test_card_lucide_createicons():
+ """Test 19: lucide.createIcons() is called after rendering cards"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ content = habits_path.read_text()
+
+ # Check that createIcons is called after rendering
+ render_pos = content.find('renderHabits')
+ if render_pos != -1:
+ after_render = content[render_pos:]
+ assert 'lucide.createIcons()' in after_render, \
+ "lucide.createIcons() should be called after rendering"
+
+ print("✓ Test 19: lucide.createIcons() called after rendering")
+
+def test_card_common_css_variables():
+ """Test 20: Card uses common.css variables for styling"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ content = habits_path.read_text()
+
+ # Check for common.css variable usage
+ assert '--bg-surface' in content or '--text-primary' in content or '--border' in content, \
+ "Card should use common.css variables"
+ assert 'var(--' in content, "Should use CSS variables"
+
+ print("✓ Test 20: Card uses common.css variables")
+
+def test_typecheck_us007():
+ """Test 21: Typecheck passes for US-007"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ assert habits_path.exists(), "habits.html should exist"
+
+ # Check that all functions are properly defined
+ content = habits_path.read_text()
+ assert 'function renderHabitCard(' in content, "renderHabitCard function should be defined"
+ assert 'function isCheckedToday(' in content, "isCheckedToday function should be defined"
+ assert 'function getLastCheckInfo(' in content, "getLastCheckInfo function should be defined"
+ assert 'function renderLives(' in content, "renderLives function should be defined"
+ assert 'function getPriorityLevel(' in content, "getPriorityLevel function should be defined"
+
+ print("✓ Test 21: Typecheck passes (all functions defined)")
+
+def test_modal_opens_on_add_habit_click():
+ """Test 22: Modal opens when clicking 'Add Habit' button"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ content = habits_path.read_text()
+
+ assert 'showAddHabitModal()' in content, "Add Habit button should call showAddHabitModal()"
+ assert 'function showAddHabitModal(' in content, "showAddHabitModal function should be defined"
+ assert 'modal-overlay' in content or 'habitModal' in content, "Should have modal overlay element"
+ print("✓ Test 22: Modal opens on Add Habit button click")
+
+def test_modal_closes_on_x_and_outside_click():
+ """Test 23: Modal closes on X button or clicking outside"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ content = habits_path.read_text()
+
+ assert 'closeHabitModal()' in content, "Should have closeHabitModal function"
+ assert 'modal-close' in content or 'onclick="closeHabitModal()"' in content, \
+ "X button should call closeHabitModal()"
+
+ # Check for click outside handler
+ assert 'e.target === modal' in content or 'event.target' in content, \
+ "Should handle clicking outside modal"
+ print("✓ Test 23: Modal closes on X button and clicking outside")
+
+def test_modal_has_all_form_fields():
+ """Test 24: Form has all required fields"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ content = habits_path.read_text()
+
+ # Required fields
+ assert 'habitName' in content or 'name' in content.lower(), "Form should have name field"
+ assert 'habitCategory' in content or 'category' in content.lower(), "Form should have category field"
+ assert 'habitPriority' in content or 'priority' in content.lower(), "Form should have priority field"
+ assert 'habitNotes' in content or 'notes' in content.lower(), "Form should have notes field"
+ assert 'frequencyType' in content or 'frequency' in content.lower(), "Form should have frequency field"
+ assert 'reminderTime' in content or 'reminder' in content.lower(), "Form should have reminder time field"
+
+ print("✓ Test 24: Form has all required fields")
+
+def test_color_picker_presets_and_custom():
+ """Test 25: Color picker shows preset swatches and custom hex input"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ content = habits_path.read_text()
+
+ assert 'color-picker' in content or 'colorSwatches' in content or 'color-swatch' in content, \
+ "Should have color picker"
+ assert 'customColor' in content or 'custom' in content.lower(), \
+ "Should have custom color input"
+ assert '#RRGGBB' in content or 'pattern=' in content, \
+ "Custom color should have hex pattern"
+ assert 'presetColors' in content or '#3B82F6' in content or '#EF4444' in content, \
+ "Should have preset colors"
+
+ print("✓ Test 25: Color picker with presets and custom hex")
+
+def test_icon_picker_grid():
+ """Test 26: Icon picker shows grid of common Lucide icons"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ content = habits_path.read_text()
+
+ assert 'icon-picker' in content or 'iconPicker' in content, \
+ "Should have icon picker"
+ assert 'icon-option' in content or 'commonIcons' in content, \
+ "Should have icon options"
+ assert 'selectIcon' in content, "Should have selectIcon function"
+
+ # Check for common icons
+ icon_count = sum([1 for icon in ['dumbbell', 'moon', 'book', 'brain', 'heart']
+ if icon in content])
+ assert icon_count >= 3, "Should have at least 3 common icons"
+
+ print("✓ Test 26: Icon picker with grid of Lucide icons")
+
+def test_frequency_params_conditional():
+ """Test 27: Frequency params display conditionally based on type"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ content = habits_path.read_text()
+
+ assert 'updateFrequencyParams' in content, "Should have updateFrequencyParams function"
+ assert 'frequencyParams' in content, "Should have frequency params container"
+ assert 'specific_days' in content, "Should handle specific_days frequency"
+ assert 'x_per_week' in content, "Should handle x_per_week frequency"
+ assert 'custom' in content.lower(), "Should handle custom frequency"
+
+ # Check for conditional rendering (day checkboxes for specific_days)
+ assert 'day-checkbox' in content or "['Mon', 'Tue'" in content or 'Mon' in content, \
+ "Should have day checkboxes for specific_days"
+
+ print("✓ Test 27: Frequency params display conditionally")
+
+def test_client_side_validation():
+ """Test 28: Client-side validation prevents submit without name"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ content = habits_path.read_text()
+
+ assert 'required' in content, "Name field should be required"
+ assert 'trim()' in content, "Should trim input values"
+
+ # Check for validation in submit function
+ submit_func = content[content.find('function submitHabitForm'):]
+ assert 'if (!name)' in submit_func or 'name.length' in submit_func, \
+ "Should validate name is not empty"
+ assert 'showToast' in submit_func and 'error' in submit_func, \
+ "Should show error toast for validation failures"
+
+ print("✓ Test 28: Client-side validation checks name required")
+
+def test_submit_posts_to_api():
+ """Test 29: Submit sends POST /echo/api/habits (or PUT for edit) and refreshes list"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ content = habits_path.read_text()
+
+ assert 'submitHabitForm' in content, "Should have submitHabitForm function"
+
+ submit_func = content[content.find('function submitHabitForm'):]
+ # Check for conditional URL and method (since US-009 added edit support)
+ assert ('/echo/api/habits' in submit_func), \
+ "Should use /echo/api/habits endpoint"
+ assert ("'POST'" in submit_func or '"POST"' in submit_func or "'PUT'" in submit_func or '"PUT"' in submit_func), \
+ "Should use POST or PUT method"
+ assert 'JSON.stringify' in submit_func, "Should send JSON body"
+ assert 'loadHabits()' in submit_func, "Should refresh habit list on success"
+
+ print("✓ Test 29: Submit POSTs to API and refreshes list")
+
+def test_loading_state_on_submit():
+ """Test 30: Loading state shown on submit button during API call"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ content = habits_path.read_text()
+
+ submit_func = content[content.find('function submitHabitForm'):]
+ assert 'disabled = true' in submit_func or '.disabled' in submit_func, \
+ "Submit button should be disabled during API call"
+ assert 'Creating' in submit_func or 'loading' in submit_func.lower(), \
+ "Should show loading text"
+ assert 'disabled = false' in submit_func, \
+ "Submit button should be re-enabled after API call"
+
+ print("✓ Test 30: Loading state on submit button")
+
+def test_toast_notifications():
+ """Test 31: Toast notification shown for success and error"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ content = habits_path.read_text()
+
+ assert 'showToast' in content, "Should have showToast function"
+ assert 'toast' in content, "Should have toast styling"
+
+ toast_func = content[content.find('function showToast'):]
+ assert 'success' in toast_func and 'error' in toast_func, \
+ "Toast should handle both success and error types"
+ assert 'check-circle' in toast_func or 'alert-circle' in toast_func, \
+ "Toast should show appropriate icons"
+ assert 'setTimeout' in toast_func or 'remove()' in toast_func, \
+ "Toast should auto-dismiss"
+
+ print("✓ Test 31: Toast notifications for success and error")
+
+def test_modal_no_console_errors():
+ """Test 32: No obvious console error sources in modal code"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ content = habits_path.read_text()
+
+ # Check that modal functions exist
+ assert 'function showAddHabitModal(' in content, "showAddHabitModal should be defined"
+ assert 'function closeHabitModal(' in content, "closeHabitModal should be defined"
+ assert 'function submitHabitForm(' in content, "submitHabitForm should be defined"
+ assert 'function updateFrequencyParams(' in content, "updateFrequencyParams should be defined"
+
+ # Check for proper error handling
+ submit_func = content[content.find('function submitHabitForm'):]
+ assert 'try' in submit_func and 'catch' in submit_func, \
+ "Submit function should have try-catch error handling"
+
+ print("✓ Test 32: No obvious console error sources")
+
+def test_typecheck_us008():
+ """Test 33: Typecheck passes for US-008 (all modal functions defined)"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ content = habits_path.read_text()
+
+ # Check all new functions are defined
+ required_functions = [
+ 'showAddHabitModal',
+ 'closeHabitModal',
+ 'initColorPicker',
+ 'selectColor',
+ 'initIconPicker',
+ 'selectIcon',
+ 'updateFrequencyParams',
+ 'submitHabitForm',
+ 'showToast'
+ ]
+
+ for func in required_functions:
+ assert f'function {func}(' in content or f'const {func} =' in content, \
+ f"{func} function should be defined"
+
+ print("✓ Test 33: Typecheck passes (all modal functions defined)")
+
+def test_edit_modal_opens_on_gear_icon():
+ """Test 34: Clicking gear icon on habit card opens edit modal"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ content = habits_path.read_text()
+
+ # Check that gear icon exists with onclick handler
+ assert 'settings' in content, "Should have settings icon (gear)"
+ assert "showEditHabitModal" in content, "Should have showEditHabitModal function call"
+
+ # Check that showEditHabitModal function is defined and not a placeholder
+ assert 'function showEditHabitModal(habitId)' in content, "showEditHabitModal should be defined"
+ assert 'editingHabitId = habitId' in content or 'editingHabitId=habitId' in content, \
+ "Should set editingHabitId"
+ assert 'const habit = habits.find(h => h.id === habitId)' in content or \
+ 'const habit=habits.find(h=>h.id===habitId)' in content, \
+ "Should find habit by ID"
+
+ print("✓ Test 34: Edit modal opens on gear icon click")
+
+def test_edit_modal_prepopulated():
+ """Test 35: Edit modal is pre-populated with all existing habit data"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ content = habits_path.read_text()
+
+ # Check that form fields are pre-populated
+ assert "getElementById('habitName').value = habit.name" in content or \
+ "getElementById('habitName').value=habit.name" in content, \
+ "Should pre-populate habit name"
+ assert "getElementById('habitCategory').value = habit.category" in content or \
+ "getElementById('habitCategory').value=habit.category" in content, \
+ "Should pre-populate category"
+ assert "getElementById('habitPriority').value = habit.priority" in content or \
+ "getElementById('habitPriority').value=habit.priority" in content, \
+ "Should pre-populate priority"
+ assert "getElementById('habitNotes').value = habit.notes" in content or \
+ "getElementById('habitNotes').value=habit.notes" in content, \
+ "Should pre-populate notes"
+ assert "getElementById('frequencyType').value = habit.frequency" in content or \
+ "getElementById('frequencyType').value=habit.frequency" in content, \
+ "Should pre-populate frequency type"
+
+ # Check color and icon selection
+ assert 'selectedColor = habit.color' in content or 'selectedColor=habit.color' in content, \
+ "Should set selectedColor from habit"
+ assert 'selectedIcon = habit.icon' in content or 'selectedIcon=habit.icon' in content, \
+ "Should set selectedIcon from habit"
+
+ print("✓ Test 35: Edit modal pre-populated with habit data")
+
+def test_edit_modal_title_and_button():
+ """Test 36: Modal title shows 'Edit Habit' and button shows 'Save Changes'"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ content = habits_path.read_text()
+
+ # Check that modal title is changed to Edit Habit
+ assert "modalTitle.textContent = 'Edit Habit'" in content or \
+ 'modalTitle.textContent="Edit Habit"' in content or \
+ "modalTitle.textContent='Edit Habit'" in content, \
+ "Should set modal title to 'Edit Habit'"
+
+ # Check that submit button text is changed
+ assert "submitBtnText.textContent = 'Save Changes'" in content or \
+ 'submitBtnText.textContent="Save Changes"' in content or \
+ "submitBtnText.textContent='Save Changes'" in content, \
+ "Should set button text to 'Save Changes'"
+
+ print("✓ Test 36: Modal title shows 'Edit Habit' and button shows 'Save Changes'")
+
+def test_edit_modal_frequency_params():
+ """Test 37: Frequency params display correctly for habit's current frequency type"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ content = habits_path.read_text()
+
+ # Check that updateFrequencyParams is called
+ assert 'updateFrequencyParams()' in content, "Should call updateFrequencyParams()"
+
+ # Check that frequency params are pre-populated for specific types
+ assert 'specific_days' in content and 'habit.frequency.days' in content, \
+ "Should handle specific_days frequency params"
+ assert 'x_per_week' in content and 'habit.frequency.count' in content, \
+ "Should handle x_per_week frequency params"
+ assert 'custom' in content and 'habit.frequency.interval' in content, \
+ "Should handle custom frequency params"
+
+ # Check that day checkboxes are pre-populated
+ assert 'cb.checked = habit.frequency.days.includes' in content or \
+ 'cb.checked=habit.frequency.days.includes' in content, \
+ "Should pre-select days for specific_days frequency"
+
+ print("✓ Test 37: Frequency params display correctly for current frequency")
+
+def test_edit_modal_icon_color_pickers():
+ """Test 38: Icon and color pickers show current selections"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ content = habits_path.read_text()
+
+ # Check that pickers are initialized after setting values
+ assert 'initColorPicker()' in content, "Should call initColorPicker()"
+ assert 'initIconPicker()' in content, "Should call initIconPicker()"
+
+ # Check that selectedColor and selectedIcon are set before initialization
+ showEditIndex = content.find('function showEditHabitModal')
+ initColorIndex = content.find('initColorPicker()', showEditIndex)
+ selectedColorIndex = content.find('selectedColor = habit.color', showEditIndex)
+
+ assert selectedColorIndex > 0 and selectedColorIndex < initColorIndex, \
+ "Should set selectedColor before calling initColorPicker()"
+
+ print("✓ Test 38: Icon and color pickers show current selections")
+
+def test_edit_modal_submit_put():
+ """Test 39: Submit sends PUT /echo/api/habits/{id} and refreshes list on success"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ content = habits_path.read_text()
+
+ # Check that editingHabitId is tracked
+ assert 'let editingHabitId' in content or 'editingHabitId' in content, \
+ "Should track editingHabitId"
+
+ # Check that isEditing is determined
+ assert 'const isEditing = editingHabitId !== null' in content or \
+ 'const isEditing=editingHabitId!==null' in content or \
+ 'isEditing = editingHabitId !== null' in content, \
+ "Should determine if editing"
+
+ # Check that URL and method are conditional
+ assert "const url = isEditing ? `/echo/api/habits/${editingHabitId}` : '/echo/api/habits'" in content or \
+ 'const url=isEditing?`/echo/api/habits/${editingHabitId}`' in content or \
+ "url = isEditing ? `/echo/api/habits/${editingHabitId}` : '/echo/api/habits'" in content, \
+ "URL should be conditional based on isEditing"
+
+ assert "const method = isEditing ? 'PUT' : 'POST'" in content or \
+ "const method=isEditing?'PUT':'POST'" in content or \
+ "method = isEditing ? 'PUT' : 'POST'" in content, \
+ "Method should be conditional (PUT for edit, POST for create)"
+
+ # Check that loadHabits is called after success
+ assert 'await loadHabits()' in content, "Should refresh habit list after success"
+
+ print("✓ Test 39: Submit sends PUT and refreshes list")
+
+def test_edit_modal_toast_messages():
+ """Test 40: Toast shown for success and error"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ content = habits_path.read_text()
+
+ # Check for conditional success message
+ assert "isEditing ? 'Habit updated!' : 'Habit created successfully!'" in content or \
+ "isEditing?'Habit updated!':'Habit created successfully!'" in content, \
+ "Should show different toast message for edit vs create"
+
+ # Check that error toast handles both edit and create
+ assert 'Failed to ${isEditing' in content or 'Failed to ' + '${isEditing' in content, \
+ "Error toast should be conditional"
+
+ print("✓ Test 40: Toast messages for success and error")
+
+def test_edit_modal_add_resets_state():
+ """Test 41: showAddHabitModal resets editingHabitId and modal UI"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ content = habits_path.read_text()
+
+ # Find showAddHabitModal function
+ add_modal_start = content.find('function showAddHabitModal()')
+ add_modal_end = content.find('function ', add_modal_start + 1)
+ add_modal_func = content[add_modal_start:add_modal_end]
+
+ # Check that editingHabitId is reset
+ assert 'editingHabitId = null' in add_modal_func or 'editingHabitId=null' in add_modal_func, \
+ "showAddHabitModal should reset editingHabitId to null"
+
+ # Check that modal title is reset to 'Add Habit'
+ assert "modalTitle.textContent = 'Add Habit'" in add_modal_func or \
+ 'modalTitle.textContent="Add Habit"' in add_modal_func, \
+ "Should reset modal title to 'Add Habit'"
+
+ # Check that button text is reset to 'Create Habit'
+ assert "submitBtnText.textContent = 'Create Habit'" in add_modal_func or \
+ 'submitBtnText.textContent="Create Habit"' in add_modal_func, \
+ "Should reset button text to 'Create Habit'"
+
+ print("✓ Test 41: showAddHabitModal resets editing state")
+
+def test_edit_modal_close_resets_state():
+ """Test 42: closeHabitModal resets editingHabitId"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ content = habits_path.read_text()
+
+ # Find closeHabitModal function
+ close_modal_start = content.find('function closeHabitModal()')
+ close_modal_end = content.find('function ', close_modal_start + 1)
+ close_modal_func = content[close_modal_start:close_modal_end]
+
+ # Check that editingHabitId is reset when closing
+ assert 'editingHabitId = null' in close_modal_func or 'editingHabitId=null' in close_modal_func, \
+ "closeHabitModal should reset editingHabitId to null"
+
+ print("✓ Test 42: closeHabitModal resets editing state")
+
+def test_edit_modal_no_console_errors():
+ """Test 43: No obvious console error sources in edit modal code"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ content = habits_path.read_text()
+
+ # Check for common error patterns
+ assert content.count('getElementById(') > 0, "Should use getElementById"
+
+ # Check that habit is validated before use
+ showEditIndex = content.find('function showEditHabitModal')
+ showEditEnd = content.find('\n }', showEditIndex + 500) # Find end of function
+ showEditFunc = content[showEditIndex:showEditEnd]
+
+ assert 'if (!habit)' in showEditFunc or 'if(!habit)' in showEditFunc, \
+ "Should check if habit exists before using it"
+ assert 'showToast' in showEditFunc and 'error' in showEditFunc, \
+ "Should show error toast if habit not found"
+
+ print("✓ Test 43: No obvious console error sources")
+
+def test_typecheck_us009():
+ """Test 44: Typecheck passes - all edit modal functions and variables defined"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ content = habits_path.read_text()
+
+ # Check that editingHabitId is declared
+ assert 'let editingHabitId' in content, "editingHabitId should be declared"
+
+ # Check that showEditHabitModal is fully implemented (not placeholder)
+ assert 'function showEditHabitModal(habitId)' in content, "showEditHabitModal should be defined"
+ assert 'alert' not in content[content.find('function showEditHabitModal'):content.find('function showEditHabitModal')+1000], \
+ "showEditHabitModal should not be a placeholder with alert()"
+
+ # Check that submitHabitForm handles both create and edit
+ assert 'const isEditing' in content or 'isEditing' in content, \
+ "submitHabitForm should determine if editing"
+
+ print("✓ Test 44: Typecheck passes (edit modal fully implemented)")
+
+def test_checkin_simple_click():
+ """Test 45: Simple click on check-in button sends POST request"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ content = habits_path.read_text()
+
+ # Check that checkInHabit function exists and does POST
+ assert 'function checkInHabit' in content or 'async function checkInHabit' in content, \
+ "checkInHabit function should be defined"
+
+ checkin_start = content.find('function checkInHabit')
+ checkin_end = content.find('\n }', checkin_start + 500)
+ checkin_func = content[checkin_start:checkin_end]
+
+ assert "fetch(`/echo/api/habits/${habitId}/check`" in checkin_func or \
+ 'fetch(`/echo/api/habits/${habitId}/check`' in checkin_func, \
+ "Should POST to /echo/api/habits/{id}/check"
+ assert "method: 'POST'" in checkin_func, "Should use POST method"
+
+ print("✓ Test 45: Simple click sends POST to check-in endpoint")
+
+def test_checkin_detail_modal_structure():
+ """Test 46: Check-in detail modal exists with note, rating, and mood fields"""
+ habits_path = Path(__file__).parent.parent / 'habits.html'
+ content = habits_path.read_text()
+
+ # Check modal exists
+ assert 'id="checkinModal"' in content, "Should have check-in detail modal"
+ assert 'Check-In Details' in content or 'Check-in Details' in content, \
+ "Modal should have title 'Check-In Details'"
+
+ # Check for note textarea
+ assert 'id="checkinNote"' in content, "Should have note textarea"
+ assert '