From cd07e43533b868363748b267500be5477905d200 Mon Sep 17 00:00:00 2001 From: Marius Mutu Date: Tue, 21 Apr 2026 07:01:30 +0000 Subject: [PATCH] feat(dashboard): copy from clawd --- dashboard/api.py | 2583 ++++++++++++ dashboard/archive/tasks-2026-01.json | 238 ++ dashboard/archive/tasks-2026-02.json | 64 + dashboard/archive_tasks.py | 97 + dashboard/common.css | 448 +++ dashboard/eco.html | 1252 ++++++ dashboard/favicon.svg | 4 + dashboard/files.html | 1935 +++++++++ dashboard/grup-sprijin.html | 501 +++ dashboard/habits.html | 3494 +++++++++++++++++ dashboard/habits.json | 123 + dashboard/habits_helpers.py | 387 ++ dashboard/index.html | 2494 ++++++++++++ dashboard/issues.json | 69 + dashboard/notes.html | 1328 +++++++ dashboard/status.json | 28 + dashboard/swipe-nav.js | 123 + dashboard/tests/test_habits_api.py | 1129 ++++++ dashboard/tests/test_habits_frontend.py | 2868 ++++++++++++++ dashboard/tests/test_habits_helpers.py | 573 +++ dashboard/tests/test_habits_integration.py | 555 +++ .../tests/test_weekly_lives_integration.py | 134 + dashboard/todos.json | 252 ++ dashboard/workspace.html | 1029 +++++ 24 files changed, 21708 insertions(+) create mode 100644 dashboard/api.py create mode 100644 dashboard/archive/tasks-2026-01.json create mode 100644 dashboard/archive/tasks-2026-02.json create mode 100644 dashboard/archive_tasks.py create mode 100644 dashboard/common.css create mode 100644 dashboard/eco.html create mode 100644 dashboard/favicon.svg create mode 100644 dashboard/files.html create mode 100644 dashboard/grup-sprijin.html create mode 100644 dashboard/habits.html create mode 100644 dashboard/habits.json create mode 100644 dashboard/habits_helpers.py create mode 100644 dashboard/index.html create mode 100644 dashboard/issues.json create mode 100644 dashboard/notes.html create mode 100644 dashboard/status.json create mode 100644 dashboard/swipe-nav.js create mode 100644 dashboard/tests/test_habits_api.py create mode 100644 dashboard/tests/test_habits_frontend.py create mode 100644 dashboard/tests/test_habits_helpers.py create mode 100644 dashboard/tests/test_habits_integration.py create mode 100644 dashboard/tests/test_weekly_lives_integration.py create mode 100644 dashboard/todos.json create mode 100644 dashboard/workspace.html 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 + + + + + + +
+ + +
+ +
+ + + +
+
+

+ + Services +

+ +
+
+
+
Loading...
+
+
+
+ + +
+
+

+ + Git +

+ +
+
+
+
+
+ +
+
+
+ Git + curat +
+
Se incarca...
+
+
+ + +
+ +
+
+ +
+
+
+
+ + +
+
+

+ + Sessions +

+
+ + + +
+
+
+
+
+
Loading...
+
+
+
+
+ + + + + + +
+ + + + 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 + + + + + + + + +
+ + +
+ +
+
+ +
+ +
+ +
+ + +
+ +
+ +
+ + + + + +
+ + + +
+
+ + +
+ + +
+ +
+
+ +
+
+
+
+ +

Se încarcă...

+
+
+
+ +
+
+
+ + Niciun fișier +
+
+ + + + + +
+ + +
+ + + +
+
+
+ +
+
+ +
+
+
+ + + + + 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 + + + + + +
+ + +
+ +
+ + +
+ + + +
+ + + + + + + +
+ +
+

Se încarcă...

+
+
+ + + + + + 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 + + + + + + +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ +
+ + +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+ + + + +
+
+ +

Loading habits...

+
+
+
+ + + + + + + + + + + + + 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 + + + + + + +
+ + +
+ +
+ + + +
+
+
+ + Status +
+
Se încarcă...
+
+ + + +
+ +
+
+ +
+
+
+ +
+ + +
+
+ +
+
+ + + + + +
+
+
+ +
+ + +
+
+ +
+
+ +
+
+ +
+ +
+
+
+
+ Issues +
+ 0 +
+
+ + +
+
+
+ + + + +
+
+
+ +

Se încarcă...

+
+
+
+ + +
+
+
+
+ Todo's +
+ 0 +
+
+ + +
+
+
+
+ +

Se încarcă...

+
+
+
+ + +
+
+
+
+ Activity +
+ 0 +
+
+ + +
+
+
+
+ +

Se încarcă...

+
+
+
+
+
+ + +
+
+
+ Notă + +
+ +
+
+ + + + + + + +
+ + + + 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 + + + + + + + +
+ + +
+ +
+ + +
+
+ | +
+ | +
+ | +
+ +
+ + +
+
+ +
+
+ +

Se încarcă...

+
+
+
+ + +
+
+
+

Titlu

+ + + +
+
+
+
+
+
+ + + + 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 '>HabitsHabits') + nav_end = content.find('', nav_start) + nav_section = content[nav_start:nav_end] + + assert '/echo/habits.html' in nav_section, "Habits link should be in navigation" + assert 'dumbbell' in nav_section, "Dumbbell icon should be in navigation" + + print("✓ Test 6: index.html includes Habits navigation link") + +def test_page_fetches_habits(): + """Test 7: Page fetches GET /echo/api/habits on load""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + assert "fetch('/echo/api/habits')" in content or 'fetch("/echo/api/habits")' in content, \ + "Should fetch from /echo/api/habits" + assert 'loadHabits' in content, "Should have loadHabits function" + + # Check that loadHabits is called on page load + # (either in inline script or as last statement) + assert content.count('loadHabits()') > 0, "loadHabits should be called" + + print("✓ Test 7: Page fetches habits on load") + +def test_habit_card_rendering(): + """Test 8: Placeholder habit card rendering exists""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + assert 'renderHabitCard' in content, "Should have renderHabitCard function" + assert 'habit-card' in content, "Should have habit-card class" + assert 'renderHabits' in content, "Should have renderHabits function" + + print("✓ Test 8: Habit card rendering functions exist") + +def test_no_console_errors_structure(): + """Test 9: No obvious console error sources (basic structure check)""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + # Check for basic script structure + assert '') + assert script_open == script_close, f"Script tags should match (found {script_open} opens, {script_close} closes)" + + print("✓ Test 10: HTML structure is well-formed") + +def test_card_colored_border(): + """Test 11: Habit card has colored left border matching habit.color""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + assert 'border-left-color' in content or 'borderLeftColor' in content, \ + "Card should have colored left border" + assert 'habit.color' in content, "Card should use habit.color for border" + print("✓ Test 11: Card has colored left border") + +def test_card_header_icons(): + """Test 12: Card header shows icon, name, settings, and delete""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + # Check for icon display + assert 'habit.icon' in content or 'habit-card-icon' in content, \ + "Card should display habit icon" + + # Check for name display + assert 'habit.name' in content or 'habit-card-name' in content, \ + "Card should display habit name" + + # Check for settings (gear) icon + assert 'settings' in content.lower(), "Card should have settings icon" + + # Check for delete (trash) icon + assert 'trash' in content.lower(), "Card should have delete icon" + + print("✓ Test 12: Card header has icon, name, settings, and delete") + +def test_card_streak_display(): + """Test 13: Streak displays with fire emoji for current and trophy for best""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + assert '🔥' in content, "Card should have fire emoji for current streak" + assert '🏆' in content, "Card should have trophy emoji for best streak" + assert 'habit.streak' in content or 'streak?.current' in content or 'streak.current' in content, \ + "Card should display streak.current" + assert 'streak?.best' in content or 'streak.best' in content, \ + "Card should display streak.best" + + print("✓ Test 13: Streak display with fire and trophy emojis") + +def test_card_checkin_button(): + """Test 14: Check-in button is large and centered""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + assert 'habit-card-check-btn' in content or 'check-btn' in content or 'checkin' in content.lower(), \ + "Card should have check-in button" + assert 'Check In' in content or 'Check in' in content, \ + "Button should have 'Check In' text" + + # Check for button styling (large/centered) + assert 'width: 100%' in content or 'width:100%' in content, \ + "Check-in button should be full-width" + + print("✓ Test 14: Check-in button is large and centered") + +def test_card_checkin_disabled_when_done(): + """Test 15: Check-in button disabled when already checked today""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + assert 'disabled' in content, "Button should have disabled state" + assert 'Done today' in content or 'Done' in content, \ + "Button should show 'Done today' when disabled" + assert 'isCheckedToday' in content or 'isDoneToday' in content, \ + "Should have function to check if habit is done today" + + print("✓ Test 15: Check-in button disabled when done today") + +def test_card_lives_display(): + """Test 16: Lives display shows filled and empty hearts (total 3)""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + assert '❤️' in content or '♥' in content, "Card should have filled heart emoji" + assert '🖤' in content or '♡' in content, "Card should have empty heart emoji" + assert 'habit.lives' in content or 'renderLives' in content, \ + "Card should display lives" + + # Check for lives rendering function + assert 'renderLives' in content or 'lives' in content.lower(), \ + "Should have lives rendering logic" + + print("✓ Test 16: Lives display with hearts") + +def test_card_completion_rate(): + """Test 17: Completion rate percentage is displayed""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + assert 'completion_rate' in content or 'completion' in content, \ + "Card should display completion rate" + assert '(30d)' in content or '30d' in content, \ + "Completion rate should show 30-day period" + assert '%' in content, "Completion rate should show percentage" + + print("✓ Test 17: Completion rate displayed") + +def test_card_footer_category_priority(): + """Test 18: Footer shows category badge and priority""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + assert 'habit.category' in content or 'habit-card-category' in content, \ + "Card should display category" + assert 'habit.priority' in content or 'priority' in content.lower(), \ + "Card should display priority" + assert 'habit-card-footer' in content or 'footer' in content.lower(), \ + "Card should have footer section" + + print("✓ Test 18: Footer shows category and priority") + +def test_card_lucide_createicons(): + """Test 19: lucide.createIcons() is called after rendering cards""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + # Check that createIcons is called after rendering + render_pos = content.find('renderHabits') + if render_pos != -1: + after_render = content[render_pos:] + assert 'lucide.createIcons()' in after_render, \ + "lucide.createIcons() should be called after rendering" + + print("✓ Test 19: lucide.createIcons() called after rendering") + +def test_card_common_css_variables(): + """Test 20: Card uses common.css variables for styling""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + # Check for common.css variable usage + assert '--bg-surface' in content or '--text-primary' in content or '--border' in content, \ + "Card should use common.css variables" + assert 'var(--' in content, "Should use CSS variables" + + print("✓ Test 20: Card uses common.css variables") + +def test_typecheck_us007(): + """Test 21: Typecheck passes for US-007""" + habits_path = Path(__file__).parent.parent / 'habits.html' + assert habits_path.exists(), "habits.html should exist" + + # Check that all functions are properly defined + content = habits_path.read_text() + assert 'function renderHabitCard(' in content, "renderHabitCard function should be defined" + assert 'function isCheckedToday(' in content, "isCheckedToday function should be defined" + assert 'function getLastCheckInfo(' in content, "getLastCheckInfo function should be defined" + assert 'function renderLives(' in content, "renderLives function should be defined" + assert 'function getPriorityLevel(' in content, "getPriorityLevel function should be defined" + + print("✓ Test 21: Typecheck passes (all functions defined)") + +def test_modal_opens_on_add_habit_click(): + """Test 22: Modal opens when clicking 'Add Habit' button""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + assert 'showAddHabitModal()' in content, "Add Habit button should call showAddHabitModal()" + assert 'function showAddHabitModal(' in content, "showAddHabitModal function should be defined" + assert 'modal-overlay' in content or 'habitModal' in content, "Should have modal overlay element" + print("✓ Test 22: Modal opens on Add Habit button click") + +def test_modal_closes_on_x_and_outside_click(): + """Test 23: Modal closes on X button or clicking outside""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + assert 'closeHabitModal()' in content, "Should have closeHabitModal function" + assert 'modal-close' in content or 'onclick="closeHabitModal()"' in content, \ + "X button should call closeHabitModal()" + + # Check for click outside handler + assert 'e.target === modal' in content or 'event.target' in content, \ + "Should handle clicking outside modal" + print("✓ Test 23: Modal closes on X button and clicking outside") + +def test_modal_has_all_form_fields(): + """Test 24: Form has all required fields""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + # Required fields + assert 'habitName' in content or 'name' in content.lower(), "Form should have name field" + assert 'habitCategory' in content or 'category' in content.lower(), "Form should have category field" + assert 'habitPriority' in content or 'priority' in content.lower(), "Form should have priority field" + assert 'habitNotes' in content or 'notes' in content.lower(), "Form should have notes field" + assert 'frequencyType' in content or 'frequency' in content.lower(), "Form should have frequency field" + assert 'reminderTime' in content or 'reminder' in content.lower(), "Form should have reminder time field" + + print("✓ Test 24: Form has all required fields") + +def test_color_picker_presets_and_custom(): + """Test 25: Color picker shows preset swatches and custom hex input""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + assert 'color-picker' in content or 'colorSwatches' in content or 'color-swatch' in content, \ + "Should have color picker" + assert 'customColor' in content or 'custom' in content.lower(), \ + "Should have custom color input" + assert '#RRGGBB' in content or 'pattern=' in content, \ + "Custom color should have hex pattern" + assert 'presetColors' in content or '#3B82F6' in content or '#EF4444' in content, \ + "Should have preset colors" + + print("✓ Test 25: Color picker with presets and custom hex") + +def test_icon_picker_grid(): + """Test 26: Icon picker shows grid of common Lucide icons""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + assert 'icon-picker' in content or 'iconPicker' in content, \ + "Should have icon picker" + assert 'icon-option' in content or 'commonIcons' in content, \ + "Should have icon options" + assert 'selectIcon' in content, "Should have selectIcon function" + + # Check for common icons + icon_count = sum([1 for icon in ['dumbbell', 'moon', 'book', 'brain', 'heart'] + if icon in content]) + assert icon_count >= 3, "Should have at least 3 common icons" + + print("✓ Test 26: Icon picker with grid of Lucide icons") + +def test_frequency_params_conditional(): + """Test 27: Frequency params display conditionally based on type""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + assert 'updateFrequencyParams' in content, "Should have updateFrequencyParams function" + assert 'frequencyParams' in content, "Should have frequency params container" + assert 'specific_days' in content, "Should handle specific_days frequency" + assert 'x_per_week' in content, "Should handle x_per_week frequency" + assert 'custom' in content.lower(), "Should handle custom frequency" + + # Check for conditional rendering (day checkboxes for specific_days) + assert 'day-checkbox' in content or "['Mon', 'Tue'" in content or 'Mon' in content, \ + "Should have day checkboxes for specific_days" + + print("✓ Test 27: Frequency params display conditionally") + +def test_client_side_validation(): + """Test 28: Client-side validation prevents submit without name""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + assert 'required' in content, "Name field should be required" + assert 'trim()' in content, "Should trim input values" + + # Check for validation in submit function + submit_func = content[content.find('function submitHabitForm'):] + assert 'if (!name)' in submit_func or 'name.length' in submit_func, \ + "Should validate name is not empty" + assert 'showToast' in submit_func and 'error' in submit_func, \ + "Should show error toast for validation failures" + + print("✓ Test 28: Client-side validation checks name required") + +def test_submit_posts_to_api(): + """Test 29: Submit sends POST /echo/api/habits (or PUT for edit) and refreshes list""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + assert 'submitHabitForm' in content, "Should have submitHabitForm function" + + submit_func = content[content.find('function submitHabitForm'):] + # Check for conditional URL and method (since US-009 added edit support) + assert ('/echo/api/habits' in submit_func), \ + "Should use /echo/api/habits endpoint" + assert ("'POST'" in submit_func or '"POST"' in submit_func or "'PUT'" in submit_func or '"PUT"' in submit_func), \ + "Should use POST or PUT method" + assert 'JSON.stringify' in submit_func, "Should send JSON body" + assert 'loadHabits()' in submit_func, "Should refresh habit list on success" + + print("✓ Test 29: Submit POSTs to API and refreshes list") + +def test_loading_state_on_submit(): + """Test 30: Loading state shown on submit button during API call""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + submit_func = content[content.find('function submitHabitForm'):] + assert 'disabled = true' in submit_func or '.disabled' in submit_func, \ + "Submit button should be disabled during API call" + assert 'Creating' in submit_func or 'loading' in submit_func.lower(), \ + "Should show loading text" + assert 'disabled = false' in submit_func, \ + "Submit button should be re-enabled after API call" + + print("✓ Test 30: Loading state on submit button") + +def test_toast_notifications(): + """Test 31: Toast notification shown for success and error""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + assert 'showToast' in content, "Should have showToast function" + assert 'toast' in content, "Should have toast styling" + + toast_func = content[content.find('function showToast'):] + assert 'success' in toast_func and 'error' in toast_func, \ + "Toast should handle both success and error types" + assert 'check-circle' in toast_func or 'alert-circle' in toast_func, \ + "Toast should show appropriate icons" + assert 'setTimeout' in toast_func or 'remove()' in toast_func, \ + "Toast should auto-dismiss" + + print("✓ Test 31: Toast notifications for success and error") + +def test_modal_no_console_errors(): + """Test 32: No obvious console error sources in modal code""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + # Check that modal functions exist + assert 'function showAddHabitModal(' in content, "showAddHabitModal should be defined" + assert 'function closeHabitModal(' in content, "closeHabitModal should be defined" + assert 'function submitHabitForm(' in content, "submitHabitForm should be defined" + assert 'function updateFrequencyParams(' in content, "updateFrequencyParams should be defined" + + # Check for proper error handling + submit_func = content[content.find('function submitHabitForm'):] + assert 'try' in submit_func and 'catch' in submit_func, \ + "Submit function should have try-catch error handling" + + print("✓ Test 32: No obvious console error sources") + +def test_typecheck_us008(): + """Test 33: Typecheck passes for US-008 (all modal functions defined)""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + # Check all new functions are defined + required_functions = [ + 'showAddHabitModal', + 'closeHabitModal', + 'initColorPicker', + 'selectColor', + 'initIconPicker', + 'selectIcon', + 'updateFrequencyParams', + 'submitHabitForm', + 'showToast' + ] + + for func in required_functions: + assert f'function {func}(' in content or f'const {func} =' in content, \ + f"{func} function should be defined" + + print("✓ Test 33: Typecheck passes (all modal functions defined)") + +def test_edit_modal_opens_on_gear_icon(): + """Test 34: Clicking gear icon on habit card opens edit modal""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + # Check that gear icon exists with onclick handler + assert 'settings' in content, "Should have settings icon (gear)" + assert "showEditHabitModal" in content, "Should have showEditHabitModal function call" + + # Check that showEditHabitModal function is defined and not a placeholder + assert 'function showEditHabitModal(habitId)' in content, "showEditHabitModal should be defined" + assert 'editingHabitId = habitId' in content or 'editingHabitId=habitId' in content, \ + "Should set editingHabitId" + assert 'const habit = habits.find(h => h.id === habitId)' in content or \ + 'const habit=habits.find(h=>h.id===habitId)' in content, \ + "Should find habit by ID" + + print("✓ Test 34: Edit modal opens on gear icon click") + +def test_edit_modal_prepopulated(): + """Test 35: Edit modal is pre-populated with all existing habit data""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + # Check that form fields are pre-populated + assert "getElementById('habitName').value = habit.name" in content or \ + "getElementById('habitName').value=habit.name" in content, \ + "Should pre-populate habit name" + assert "getElementById('habitCategory').value = habit.category" in content or \ + "getElementById('habitCategory').value=habit.category" in content, \ + "Should pre-populate category" + assert "getElementById('habitPriority').value = habit.priority" in content or \ + "getElementById('habitPriority').value=habit.priority" in content, \ + "Should pre-populate priority" + assert "getElementById('habitNotes').value = habit.notes" in content or \ + "getElementById('habitNotes').value=habit.notes" in content, \ + "Should pre-populate notes" + assert "getElementById('frequencyType').value = habit.frequency" in content or \ + "getElementById('frequencyType').value=habit.frequency" in content, \ + "Should pre-populate frequency type" + + # Check color and icon selection + assert 'selectedColor = habit.color' in content or 'selectedColor=habit.color' in content, \ + "Should set selectedColor from habit" + assert 'selectedIcon = habit.icon' in content or 'selectedIcon=habit.icon' in content, \ + "Should set selectedIcon from habit" + + print("✓ Test 35: Edit modal pre-populated with habit data") + +def test_edit_modal_title_and_button(): + """Test 36: Modal title shows 'Edit Habit' and button shows 'Save Changes'""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + # Check that modal title is changed to Edit Habit + assert "modalTitle.textContent = 'Edit Habit'" in content or \ + 'modalTitle.textContent="Edit Habit"' in content or \ + "modalTitle.textContent='Edit Habit'" in content, \ + "Should set modal title to 'Edit Habit'" + + # Check that submit button text is changed + assert "submitBtnText.textContent = 'Save Changes'" in content or \ + 'submitBtnText.textContent="Save Changes"' in content or \ + "submitBtnText.textContent='Save Changes'" in content, \ + "Should set button text to 'Save Changes'" + + print("✓ Test 36: Modal title shows 'Edit Habit' and button shows 'Save Changes'") + +def test_edit_modal_frequency_params(): + """Test 37: Frequency params display correctly for habit's current frequency type""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + # Check that updateFrequencyParams is called + assert 'updateFrequencyParams()' in content, "Should call updateFrequencyParams()" + + # Check that frequency params are pre-populated for specific types + assert 'specific_days' in content and 'habit.frequency.days' in content, \ + "Should handle specific_days frequency params" + assert 'x_per_week' in content and 'habit.frequency.count' in content, \ + "Should handle x_per_week frequency params" + assert 'custom' in content and 'habit.frequency.interval' in content, \ + "Should handle custom frequency params" + + # Check that day checkboxes are pre-populated + assert 'cb.checked = habit.frequency.days.includes' in content or \ + 'cb.checked=habit.frequency.days.includes' in content, \ + "Should pre-select days for specific_days frequency" + + print("✓ Test 37: Frequency params display correctly for current frequency") + +def test_edit_modal_icon_color_pickers(): + """Test 38: Icon and color pickers show current selections""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + # Check that pickers are initialized after setting values + assert 'initColorPicker()' in content, "Should call initColorPicker()" + assert 'initIconPicker()' in content, "Should call initIconPicker()" + + # Check that selectedColor and selectedIcon are set before initialization + showEditIndex = content.find('function showEditHabitModal') + initColorIndex = content.find('initColorPicker()', showEditIndex) + selectedColorIndex = content.find('selectedColor = habit.color', showEditIndex) + + assert selectedColorIndex > 0 and selectedColorIndex < initColorIndex, \ + "Should set selectedColor before calling initColorPicker()" + + print("✓ Test 38: Icon and color pickers show current selections") + +def test_edit_modal_submit_put(): + """Test 39: Submit sends PUT /echo/api/habits/{id} and refreshes list on success""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + # Check that editingHabitId is tracked + assert 'let editingHabitId' in content or 'editingHabitId' in content, \ + "Should track editingHabitId" + + # Check that isEditing is determined + assert 'const isEditing = editingHabitId !== null' in content or \ + 'const isEditing=editingHabitId!==null' in content or \ + 'isEditing = editingHabitId !== null' in content, \ + "Should determine if editing" + + # Check that URL and method are conditional + assert "const url = isEditing ? `/echo/api/habits/${editingHabitId}` : '/echo/api/habits'" in content or \ + 'const url=isEditing?`/echo/api/habits/${editingHabitId}`' in content or \ + "url = isEditing ? `/echo/api/habits/${editingHabitId}` : '/echo/api/habits'" in content, \ + "URL should be conditional based on isEditing" + + assert "const method = isEditing ? 'PUT' : 'POST'" in content or \ + "const method=isEditing?'PUT':'POST'" in content or \ + "method = isEditing ? 'PUT' : 'POST'" in content, \ + "Method should be conditional (PUT for edit, POST for create)" + + # Check that loadHabits is called after success + assert 'await loadHabits()' in content, "Should refresh habit list after success" + + print("✓ Test 39: Submit sends PUT and refreshes list") + +def test_edit_modal_toast_messages(): + """Test 40: Toast shown for success and error""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + # Check for conditional success message + assert "isEditing ? 'Habit updated!' : 'Habit created successfully!'" in content or \ + "isEditing?'Habit updated!':'Habit created successfully!'" in content, \ + "Should show different toast message for edit vs create" + + # Check that error toast handles both edit and create + assert 'Failed to ${isEditing' in content or 'Failed to ' + '${isEditing' in content, \ + "Error toast should be conditional" + + print("✓ Test 40: Toast messages for success and error") + +def test_edit_modal_add_resets_state(): + """Test 41: showAddHabitModal resets editingHabitId and modal UI""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + # Find showAddHabitModal function + add_modal_start = content.find('function showAddHabitModal()') + add_modal_end = content.find('function ', add_modal_start + 1) + add_modal_func = content[add_modal_start:add_modal_end] + + # Check that editingHabitId is reset + assert 'editingHabitId = null' in add_modal_func or 'editingHabitId=null' in add_modal_func, \ + "showAddHabitModal should reset editingHabitId to null" + + # Check that modal title is reset to 'Add Habit' + assert "modalTitle.textContent = 'Add Habit'" in add_modal_func or \ + 'modalTitle.textContent="Add Habit"' in add_modal_func, \ + "Should reset modal title to 'Add Habit'" + + # Check that button text is reset to 'Create Habit' + assert "submitBtnText.textContent = 'Create Habit'" in add_modal_func or \ + 'submitBtnText.textContent="Create Habit"' in add_modal_func, \ + "Should reset button text to 'Create Habit'" + + print("✓ Test 41: showAddHabitModal resets editing state") + +def test_edit_modal_close_resets_state(): + """Test 42: closeHabitModal resets editingHabitId""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + # Find closeHabitModal function + close_modal_start = content.find('function closeHabitModal()') + close_modal_end = content.find('function ', close_modal_start + 1) + close_modal_func = content[close_modal_start:close_modal_end] + + # Check that editingHabitId is reset when closing + assert 'editingHabitId = null' in close_modal_func or 'editingHabitId=null' in close_modal_func, \ + "closeHabitModal should reset editingHabitId to null" + + print("✓ Test 42: closeHabitModal resets editing state") + +def test_edit_modal_no_console_errors(): + """Test 43: No obvious console error sources in edit modal code""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + # Check for common error patterns + assert content.count('getElementById(') > 0, "Should use getElementById" + + # Check that habit is validated before use + showEditIndex = content.find('function showEditHabitModal') + showEditEnd = content.find('\n }', showEditIndex + 500) # Find end of function + showEditFunc = content[showEditIndex:showEditEnd] + + assert 'if (!habit)' in showEditFunc or 'if(!habit)' in showEditFunc, \ + "Should check if habit exists before using it" + assert 'showToast' in showEditFunc and 'error' in showEditFunc, \ + "Should show error toast if habit not found" + + print("✓ Test 43: No obvious console error sources") + +def test_typecheck_us009(): + """Test 44: Typecheck passes - all edit modal functions and variables defined""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + # Check that editingHabitId is declared + assert 'let editingHabitId' in content, "editingHabitId should be declared" + + # Check that showEditHabitModal is fully implemented (not placeholder) + assert 'function showEditHabitModal(habitId)' in content, "showEditHabitModal should be defined" + assert 'alert' not in content[content.find('function showEditHabitModal'):content.find('function showEditHabitModal')+1000], \ + "showEditHabitModal should not be a placeholder with alert()" + + # Check that submitHabitForm handles both create and edit + assert 'const isEditing' in content or 'isEditing' in content, \ + "submitHabitForm should determine if editing" + + print("✓ Test 44: Typecheck passes (edit modal fully implemented)") + +def test_checkin_simple_click(): + """Test 45: Simple click on check-in button sends POST request""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + # Check that checkInHabit function exists and does POST + assert 'function checkInHabit' in content or 'async function checkInHabit' in content, \ + "checkInHabit function should be defined" + + checkin_start = content.find('function checkInHabit') + checkin_end = content.find('\n }', checkin_start + 500) + checkin_func = content[checkin_start:checkin_end] + + assert "fetch(`/echo/api/habits/${habitId}/check`" in checkin_func or \ + 'fetch(`/echo/api/habits/${habitId}/check`' in checkin_func, \ + "Should POST to /echo/api/habits/{id}/check" + assert "method: 'POST'" in checkin_func, "Should use POST method" + + print("✓ Test 45: Simple click sends POST to check-in endpoint") + +def test_checkin_detail_modal_structure(): + """Test 46: Check-in detail modal exists with note, rating, and mood fields""" + habits_path = Path(__file__).parent.parent / 'habits.html' + content = habits_path.read_text() + + # Check modal exists + assert 'id="checkinModal"' in content, "Should have check-in detail modal" + assert 'Check-In Details' in content or 'Check-in Details' in content, \ + "Modal should have title 'Check-In Details'" + + # Check for note textarea + assert 'id="checkinNote"' in content, "Should have note textarea" + assert '