Compare commits
15 Commits
feature/ha
...
4acf5d6c9b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4acf5d6c9b | ||
|
|
4933847f72 | ||
|
|
d4f1526989 | ||
|
|
775f1715d1 | ||
|
|
0483d73ef8 | ||
|
|
97af2ae3c3 | ||
|
|
b672947ac1 | ||
|
|
dd0bf24dea | ||
|
|
c84135d67c | ||
|
|
ca4ee77db6 | ||
|
|
760e0e031c | ||
|
|
3927b7c393 | ||
|
|
3a09e6c51a | ||
|
|
fc5ebf2026 | ||
|
|
ee8727a8df |
307
dashboard/api.py
307
dashboard/api.py
@@ -35,6 +35,107 @@ GITEA_URL = os.environ.get('GITEA_URL', 'https://gitea.romfast.ro')
|
|||||||
GITEA_ORG = os.environ.get('GITEA_ORG', 'romfast')
|
GITEA_ORG = os.environ.get('GITEA_ORG', 'romfast')
|
||||||
GITEA_TOKEN = os.environ.get('GITEA_TOKEN', '')
|
GITEA_TOKEN = os.environ.get('GITEA_TOKEN', '')
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_streak(completions, frequency):
|
||||||
|
"""
|
||||||
|
Calculate the current streak for a habit based on completions array.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
completions: List of ISO timestamp strings representing completion dates
|
||||||
|
frequency: 'daily' or 'weekly'
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: The current streak count (days for daily, weeks for weekly)
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Counts consecutive periods from most recent completion backwards
|
||||||
|
- Daily: counts consecutive days without gaps
|
||||||
|
- Weekly: counts consecutive 7-day periods
|
||||||
|
- Returns 0 for no completions
|
||||||
|
- Returns 0 if streak is broken (gap detected)
|
||||||
|
- Today's completion counts even if previous days were missed
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
# No completions = no streak
|
||||||
|
if not completions:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Parse all completion dates and sort descending (most recent first)
|
||||||
|
try:
|
||||||
|
completion_dates = []
|
||||||
|
for comp in completions:
|
||||||
|
dt = datetime.fromisoformat(comp.replace('Z', '+00:00'))
|
||||||
|
# Convert to date only (ignore time)
|
||||||
|
completion_dates.append(dt.date())
|
||||||
|
|
||||||
|
completion_dates = sorted(set(completion_dates), reverse=True)
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if not completion_dates:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Get today's date
|
||||||
|
today = datetime.now().date()
|
||||||
|
|
||||||
|
if frequency == 'daily':
|
||||||
|
# For daily habits, count consecutive days
|
||||||
|
streak = 0
|
||||||
|
expected_date = completion_dates[0]
|
||||||
|
|
||||||
|
# If most recent completion is today or yesterday, start counting
|
||||||
|
if expected_date < today - timedelta(days=1):
|
||||||
|
# Streak is broken (last completion was more than 1 day ago)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
for completion in completion_dates:
|
||||||
|
if completion == expected_date:
|
||||||
|
streak += 1
|
||||||
|
expected_date -= timedelta(days=1)
|
||||||
|
elif completion < expected_date:
|
||||||
|
# Gap found, streak is broken
|
||||||
|
break
|
||||||
|
|
||||||
|
return streak
|
||||||
|
|
||||||
|
elif frequency == 'weekly':
|
||||||
|
# For weekly habits, count consecutive weeks (7-day periods)
|
||||||
|
streak = 0
|
||||||
|
|
||||||
|
# Most recent completion
|
||||||
|
most_recent = completion_dates[0]
|
||||||
|
|
||||||
|
# Check if most recent completion is within current week
|
||||||
|
days_since = (today - most_recent).days
|
||||||
|
if days_since > 6:
|
||||||
|
# Last completion was more than a week ago, streak is broken
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Start counting from the week of the most recent completion
|
||||||
|
current_week_start = most_recent - timedelta(days=most_recent.weekday())
|
||||||
|
|
||||||
|
for i in range(len(completion_dates)):
|
||||||
|
week_start = current_week_start - timedelta(days=i * 7)
|
||||||
|
week_end = week_start + timedelta(days=6)
|
||||||
|
|
||||||
|
# Check if there's a completion in this week
|
||||||
|
has_completion = any(
|
||||||
|
week_start <= comp <= week_end
|
||||||
|
for comp in completion_dates
|
||||||
|
)
|
||||||
|
|
||||||
|
if has_completion:
|
||||||
|
streak += 1
|
||||||
|
else:
|
||||||
|
# No completion in this week, streak is broken
|
||||||
|
break
|
||||||
|
|
||||||
|
return streak
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
class TaskBoardHandler(SimpleHTTPRequestHandler):
|
class TaskBoardHandler(SimpleHTTPRequestHandler):
|
||||||
|
|
||||||
def do_POST(self):
|
def do_POST(self):
|
||||||
@@ -48,6 +149,10 @@ class TaskBoardHandler(SimpleHTTPRequestHandler):
|
|||||||
self.handle_git_commit()
|
self.handle_git_commit()
|
||||||
elif self.path == '/api/pdf':
|
elif self.path == '/api/pdf':
|
||||||
self.handle_pdf_post()
|
self.handle_pdf_post()
|
||||||
|
elif self.path == '/api/habits':
|
||||||
|
self.handle_habits_post()
|
||||||
|
elif self.path.startswith('/api/habits/') and self.path.endswith('/check'):
|
||||||
|
self.handle_habits_check()
|
||||||
elif self.path == '/api/workspace/run':
|
elif self.path == '/api/workspace/run':
|
||||||
self.handle_workspace_run()
|
self.handle_workspace_run()
|
||||||
elif self.path == '/api/workspace/stop':
|
elif self.path == '/api/workspace/stop':
|
||||||
@@ -251,6 +356,8 @@ class TaskBoardHandler(SimpleHTTPRequestHandler):
|
|||||||
self.handle_cron_status()
|
self.handle_cron_status()
|
||||||
elif self.path == '/api/activity' or self.path.startswith('/api/activity?'):
|
elif self.path == '/api/activity' or self.path.startswith('/api/activity?'):
|
||||||
self.handle_activity()
|
self.handle_activity()
|
||||||
|
elif self.path == '/api/habits':
|
||||||
|
self.handle_habits_get()
|
||||||
elif self.path.startswith('/api/files'):
|
elif self.path.startswith('/api/files'):
|
||||||
self.handle_files_get()
|
self.handle_files_get()
|
||||||
elif self.path.startswith('/api/diff'):
|
elif self.path.startswith('/api/diff'):
|
||||||
@@ -681,6 +788,206 @@ class TaskBoardHandler(SimpleHTTPRequestHandler):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.send_json({'error': str(e)}, 500)
|
self.send_json({'error': str(e)}, 500)
|
||||||
|
|
||||||
|
def handle_habits_get(self):
|
||||||
|
"""Get all habits from habits.json with calculated streaks."""
|
||||||
|
try:
|
||||||
|
habits_file = KANBAN_DIR / 'habits.json'
|
||||||
|
|
||||||
|
# Handle missing file or empty file gracefully
|
||||||
|
if not habits_file.exists():
|
||||||
|
self.send_json({
|
||||||
|
'habits': [],
|
||||||
|
'lastUpdated': datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
|
# Read and parse habits data
|
||||||
|
try:
|
||||||
|
data = json.loads(habits_file.read_text(encoding='utf-8'))
|
||||||
|
except (json.JSONDecodeError, IOError):
|
||||||
|
# Return empty array on parse error instead of 500
|
||||||
|
self.send_json({
|
||||||
|
'habits': [],
|
||||||
|
'lastUpdated': datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
|
# Ensure required fields exist
|
||||||
|
habits = data.get('habits', [])
|
||||||
|
last_updated = data.get('lastUpdated', datetime.now().isoformat())
|
||||||
|
|
||||||
|
# Get today's date in YYYY-MM-DD format
|
||||||
|
today = datetime.now().date().isoformat()
|
||||||
|
|
||||||
|
# Enhance each habit with streak and checkedToday
|
||||||
|
enhanced_habits = []
|
||||||
|
for habit in habits:
|
||||||
|
# Calculate streak using the utility function
|
||||||
|
completions = habit.get('completions', [])
|
||||||
|
frequency = habit.get('frequency', 'daily')
|
||||||
|
streak = calculate_streak(completions, frequency)
|
||||||
|
|
||||||
|
# Check if habit was completed today
|
||||||
|
checked_today = today in completions
|
||||||
|
|
||||||
|
# Add calculated fields to habit
|
||||||
|
enhanced_habit = {**habit, 'streak': streak, 'checkedToday': checked_today}
|
||||||
|
enhanced_habits.append(enhanced_habit)
|
||||||
|
|
||||||
|
self.send_json({
|
||||||
|
'habits': enhanced_habits,
|
||||||
|
'lastUpdated': last_updated
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
self.send_json({'error': str(e)}, 500)
|
||||||
|
|
||||||
|
def handle_habits_post(self):
|
||||||
|
"""Create a new habit in habits.json."""
|
||||||
|
try:
|
||||||
|
# Read POST body
|
||||||
|
content_length = int(self.headers['Content-Length'])
|
||||||
|
post_data = self.rfile.read(content_length).decode('utf-8')
|
||||||
|
data = json.loads(post_data)
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
|
name = data.get('name', '').strip()
|
||||||
|
frequency = data.get('frequency', '').strip()
|
||||||
|
|
||||||
|
# Validation: name is required
|
||||||
|
if not name:
|
||||||
|
self.send_json({'error': 'name is required'}, 400)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Validation: frequency must be daily or weekly
|
||||||
|
if frequency not in ('daily', 'weekly'):
|
||||||
|
self.send_json({'error': 'frequency must be daily or weekly'}, 400)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Generate habit ID with millisecond timestamp
|
||||||
|
from time import time
|
||||||
|
habit_id = f"habit-{int(time() * 1000)}"
|
||||||
|
|
||||||
|
# Create habit object
|
||||||
|
new_habit = {
|
||||||
|
'id': habit_id,
|
||||||
|
'name': name,
|
||||||
|
'frequency': frequency,
|
||||||
|
'createdAt': datetime.now().isoformat(),
|
||||||
|
'completions': []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Read existing habits
|
||||||
|
habits_file = KANBAN_DIR / 'habits.json'
|
||||||
|
if habits_file.exists():
|
||||||
|
try:
|
||||||
|
habits_data = json.loads(habits_file.read_text(encoding='utf-8'))
|
||||||
|
except (json.JSONDecodeError, IOError):
|
||||||
|
habits_data = {'habits': [], 'lastUpdated': datetime.now().isoformat()}
|
||||||
|
else:
|
||||||
|
habits_data = {'habits': [], 'lastUpdated': datetime.now().isoformat()}
|
||||||
|
|
||||||
|
# Add new habit
|
||||||
|
habits_data['habits'].append(new_habit)
|
||||||
|
habits_data['lastUpdated'] = datetime.now().isoformat()
|
||||||
|
|
||||||
|
# Write back to file
|
||||||
|
habits_file.write_text(json.dumps(habits_data, indent=2), encoding='utf-8')
|
||||||
|
|
||||||
|
# Return 201 Created with the new habit
|
||||||
|
self.send_json(new_habit, 201)
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
self.send_json({'error': 'Invalid JSON'}, 400)
|
||||||
|
except Exception as e:
|
||||||
|
self.send_json({'error': str(e)}, 500)
|
||||||
|
|
||||||
|
def handle_habits_check(self):
|
||||||
|
"""Mark a habit as completed for today."""
|
||||||
|
try:
|
||||||
|
# Extract habit ID from path: /api/habits/{id}/check
|
||||||
|
path_parts = self.path.split('/')
|
||||||
|
if len(path_parts) < 4:
|
||||||
|
self.send_json({'error': 'Invalid path'}, 400)
|
||||||
|
return
|
||||||
|
|
||||||
|
habit_id = path_parts[3] # /api/habits/{id}/check -> index 3 is id
|
||||||
|
|
||||||
|
# Get today's date in ISO format (YYYY-MM-DD)
|
||||||
|
today = datetime.now().date().isoformat()
|
||||||
|
|
||||||
|
# Read habits file
|
||||||
|
habits_file = KANBAN_DIR / 'habits.json'
|
||||||
|
if not habits_file.exists():
|
||||||
|
self.send_json({'error': 'Habit not found'}, 404)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
habits_data = json.loads(habits_file.read_text(encoding='utf-8'))
|
||||||
|
except (json.JSONDecodeError, IOError):
|
||||||
|
self.send_json({'error': 'Habit not found'}, 404)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Find the habit by ID
|
||||||
|
habit = None
|
||||||
|
habit_index = None
|
||||||
|
for i, h in enumerate(habits_data.get('habits', [])):
|
||||||
|
if h.get('id') == habit_id:
|
||||||
|
habit = h
|
||||||
|
habit_index = i
|
||||||
|
break
|
||||||
|
|
||||||
|
if habit is None:
|
||||||
|
self.send_json({'error': 'Habit not found'}, 404)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if already checked today
|
||||||
|
completions = habit.get('completions', [])
|
||||||
|
|
||||||
|
# Extract dates from completions (they might be ISO timestamps, we need just the date part)
|
||||||
|
completion_dates = []
|
||||||
|
for comp in completions:
|
||||||
|
try:
|
||||||
|
# Parse ISO timestamp and extract date
|
||||||
|
dt = datetime.fromisoformat(comp.replace('Z', '+00:00'))
|
||||||
|
completion_dates.append(dt.date().isoformat())
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
# If parsing fails, assume it's already a date string
|
||||||
|
completion_dates.append(comp)
|
||||||
|
|
||||||
|
if today in completion_dates:
|
||||||
|
self.send_json({'error': 'Habit already checked today'}, 400)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Add today's date to completions
|
||||||
|
completions.append(today)
|
||||||
|
|
||||||
|
# Sort completions chronologically (oldest first)
|
||||||
|
completions.sort()
|
||||||
|
|
||||||
|
# Update habit
|
||||||
|
habit['completions'] = completions
|
||||||
|
|
||||||
|
# Calculate streak
|
||||||
|
frequency = habit.get('frequency', 'daily')
|
||||||
|
streak = calculate_streak(completions, frequency)
|
||||||
|
|
||||||
|
# Add streak to response (but don't persist it in JSON)
|
||||||
|
habit_with_streak = habit.copy()
|
||||||
|
habit_with_streak['streak'] = streak
|
||||||
|
|
||||||
|
# Update habits data
|
||||||
|
habits_data['habits'][habit_index] = habit
|
||||||
|
habits_data['lastUpdated'] = datetime.now().isoformat()
|
||||||
|
|
||||||
|
# Write back to file
|
||||||
|
habits_file.write_text(json.dumps(habits_data, indent=2), encoding='utf-8')
|
||||||
|
|
||||||
|
# Return 200 OK with updated habit including streak
|
||||||
|
self.send_json(habit_with_streak, 200)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.send_json({'error': str(e)}, 500)
|
||||||
|
|
||||||
def handle_files_get(self):
|
def handle_files_get(self):
|
||||||
"""List files or get file content."""
|
"""List files or get file content."""
|
||||||
from urllib.parse import urlparse, parse_qs
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
|||||||
726
dashboard/habits.html
Normal file
726
dashboard/habits.html
Normal file
@@ -0,0 +1,726 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ro">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/echo/favicon.svg">
|
||||||
|
<title>Echo · Habit Tracker</title>
|
||||||
|
<link rel="stylesheet" href="/echo/common.css">
|
||||||
|
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
||||||
|
<script src="/echo/swipe-nav.js"></script>
|
||||||
|
<style>
|
||||||
|
.main {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-subtitle {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-6) var(--space-4);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state svg {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
opacity: 0.5;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-message {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add habit button */
|
||||||
|
.add-habit-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-3) var(--space-5);
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-habit-btn:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-habit-btn svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Habits list */
|
||||||
|
.habits-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-card {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-4);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-card:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-icon svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-name {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-frequency {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-streak {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Habit checkbox */
|
||||||
|
.habit-checkbox {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--bg-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-checkbox:hover:not(.disabled) {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent-light, rgba(99, 102, 241, 0.1));
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-checkbox.checked {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-checkbox.checked svg {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-checkbox.disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-checkbox svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading state */
|
||||||
|
.loading-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-6) var(--space-4);
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state svg {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
opacity: 0.5;
|
||||||
|
color: var(--text-muted);
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error state */
|
||||||
|
.error-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-6) var(--space-4);
|
||||||
|
color: var(--text-danger);
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state svg {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
color: var(--text-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 1000;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--bg-base);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-5);
|
||||||
|
width: 90%;
|
||||||
|
max-width: 500px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-top: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Radio group */
|
||||||
|
.radio-group {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-option {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-option input[type="radio"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-3);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-option input[type="radio"]:checked + .radio-label {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-label:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toast */
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: var(--space-5);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) translateY(100px);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: var(--space-3) var(--space-5);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
opacity: 0;
|
||||||
|
transition: all var(--transition-base);
|
||||||
|
z-index: 1001;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.show {
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="header">
|
||||||
|
<a href="/echo/index.html" class="logo">
|
||||||
|
<i data-lucide="circle-dot"></i>
|
||||||
|
Echo
|
||||||
|
</a>
|
||||||
|
<nav class="nav">
|
||||||
|
<a href="/echo/index.html" class="nav-item">
|
||||||
|
<i data-lucide="layout-dashboard"></i>
|
||||||
|
<span>Dashboard</span>
|
||||||
|
</a>
|
||||||
|
<a href="/echo/workspace.html" class="nav-item">
|
||||||
|
<i data-lucide="code"></i>
|
||||||
|
<span>Workspace</span>
|
||||||
|
</a>
|
||||||
|
<a href="/echo/notes.html" class="nav-item">
|
||||||
|
<i data-lucide="file-text"></i>
|
||||||
|
<span>KB</span>
|
||||||
|
</a>
|
||||||
|
<a href="/echo/files.html" class="nav-item">
|
||||||
|
<i data-lucide="folder"></i>
|
||||||
|
<span>Files</span>
|
||||||
|
</a>
|
||||||
|
<a href="/echo/habits.html" class="nav-item active">
|
||||||
|
<i data-lucide="target"></i>
|
||||||
|
<span>Habits</span>
|
||||||
|
</a>
|
||||||
|
<button class="theme-toggle" onclick="toggleTheme()" title="Schimbă tema">
|
||||||
|
<i data-lucide="sun" id="themeIcon"></i>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1 class="page-title">Habit Tracker</h1>
|
||||||
|
<p class="page-subtitle">Urmărește-ți obiceiurile zilnice și săptămânale</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Habits container -->
|
||||||
|
<div id="habitsContainer">
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div class="loading-state" id="loadingState">
|
||||||
|
<i data-lucide="loader"></i>
|
||||||
|
<p>Se încarcă obiceiurile...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error state -->
|
||||||
|
<div class="error-state" id="errorState">
|
||||||
|
<i data-lucide="alert-circle"></i>
|
||||||
|
<p>Eroare la încărcarea obiceiurilor</p>
|
||||||
|
<button class="btn btn-secondary" onclick="loadHabits()">Încearcă din nou</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div class="empty-state" id="emptyState" style="display: none;">
|
||||||
|
<i data-lucide="target"></i>
|
||||||
|
<p class="empty-state-message">Nicio obișnuință încă. Creează prima!</p>
|
||||||
|
<button class="add-habit-btn" onclick="showAddHabitModal()">
|
||||||
|
<i data-lucide="plus"></i>
|
||||||
|
<span>Adaugă obișnuință</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Habits list (hidden initially) -->
|
||||||
|
<div class="habits-list" id="habitsList" style="display: none;">
|
||||||
|
<!-- Habits will be populated here by JavaScript -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Add Habit Modal -->
|
||||||
|
<div class="modal-overlay" id="habitModal">
|
||||||
|
<div class="modal">
|
||||||
|
<h2 class="modal-title">Obișnuință nouă</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Nume *</label>
|
||||||
|
<input type="text" class="input" id="habitName" placeholder="ex: Bazin, Sală, Meditație...">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Frecvență</label>
|
||||||
|
<div class="radio-group">
|
||||||
|
<div class="radio-option">
|
||||||
|
<input type="radio" name="frequency" id="freqDaily" value="daily" checked>
|
||||||
|
<label for="freqDaily" class="radio-label">Zilnic</label>
|
||||||
|
</div>
|
||||||
|
<div class="radio-option">
|
||||||
|
<input type="radio" name="frequency" id="freqWeekly" value="weekly">
|
||||||
|
<label for="freqWeekly" class="radio-label">Săptămânal</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-secondary" onclick="hideHabitModal()">Anulează</button>
|
||||||
|
<button class="btn btn-primary" id="habitCreateBtn" onclick="createHabit()" disabled>Creează</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Theme management
|
||||||
|
function initTheme() {
|
||||||
|
const saved = localStorage.getItem('theme') || 'dark';
|
||||||
|
document.documentElement.setAttribute('data-theme', saved);
|
||||||
|
updateThemeIcon(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTheme() {
|
||||||
|
const current = document.documentElement.getAttribute('data-theme') || 'dark';
|
||||||
|
const next = current === 'dark' ? 'light' : 'dark';
|
||||||
|
document.documentElement.setAttribute('data-theme', next);
|
||||||
|
localStorage.setItem('theme', next);
|
||||||
|
updateThemeIcon(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateThemeIcon(theme) {
|
||||||
|
const icon = document.getElementById('themeIcon');
|
||||||
|
if (icon) {
|
||||||
|
icon.setAttribute('data-lucide', theme === 'dark' ? 'sun' : 'moon');
|
||||||
|
lucide.createIcons();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize theme and icons
|
||||||
|
initTheme();
|
||||||
|
lucide.createIcons();
|
||||||
|
|
||||||
|
// Modal functions
|
||||||
|
function showAddHabitModal() {
|
||||||
|
const modal = document.getElementById('habitModal');
|
||||||
|
const nameInput = document.getElementById('habitName');
|
||||||
|
const createBtn = document.getElementById('habitCreateBtn');
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
nameInput.value = '';
|
||||||
|
document.getElementById('freqDaily').checked = true;
|
||||||
|
createBtn.disabled = true;
|
||||||
|
|
||||||
|
// Show modal
|
||||||
|
modal.classList.add('active');
|
||||||
|
nameInput.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideHabitModal() {
|
||||||
|
const modal = document.getElementById('habitModal');
|
||||||
|
modal.classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form validation - enable/disable Create button based on name input
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const nameInput = document.getElementById('habitName');
|
||||||
|
const createBtn = document.getElementById('habitCreateBtn');
|
||||||
|
|
||||||
|
nameInput.addEventListener('input', () => {
|
||||||
|
const name = nameInput.value.trim();
|
||||||
|
createBtn.disabled = name.length === 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Allow Enter key to submit if button is enabled
|
||||||
|
nameInput.addEventListener('keypress', (e) => {
|
||||||
|
if (e.key === 'Enter' && !createBtn.disabled) {
|
||||||
|
createHabit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create habit
|
||||||
|
async function createHabit() {
|
||||||
|
const nameInput = document.getElementById('habitName');
|
||||||
|
const createBtn = document.getElementById('habitCreateBtn');
|
||||||
|
const name = nameInput.value.trim();
|
||||||
|
const frequency = document.querySelector('input[name="frequency"]:checked').value;
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
showToast('Te rog introdu un nume pentru obișnuință');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable button during submission (loading state)
|
||||||
|
createBtn.disabled = true;
|
||||||
|
const originalText = createBtn.textContent;
|
||||||
|
createBtn.textContent = 'Se creează...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/habits', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, frequency })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// Clear input field after successful creation
|
||||||
|
nameInput.value = '';
|
||||||
|
|
||||||
|
hideHabitModal();
|
||||||
|
showToast('Obișnuință creată cu succes!');
|
||||||
|
loadHabits();
|
||||||
|
} else {
|
||||||
|
const error = await response.text();
|
||||||
|
showToast('Eroare la crearea obișnuinței: ' + error);
|
||||||
|
|
||||||
|
// Re-enable button on error (modal stays open)
|
||||||
|
createBtn.disabled = false;
|
||||||
|
createBtn.textContent = originalText;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('Eroare la conectarea cu serverul');
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
|
// Re-enable button on error (modal stays open)
|
||||||
|
createBtn.disabled = false;
|
||||||
|
createBtn.textContent = originalText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toast notification
|
||||||
|
function showToast(message) {
|
||||||
|
const toast = document.getElementById('toast');
|
||||||
|
toast.textContent = message;
|
||||||
|
toast.classList.add('show');
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.remove('show');
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load habits from API
|
||||||
|
async function loadHabits() {
|
||||||
|
const loadingState = document.getElementById('loadingState');
|
||||||
|
const errorState = document.getElementById('errorState');
|
||||||
|
const emptyState = document.getElementById('emptyState');
|
||||||
|
const habitsList = document.getElementById('habitsList');
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
loadingState.classList.add('active');
|
||||||
|
errorState.classList.remove('active');
|
||||||
|
emptyState.style.display = 'none';
|
||||||
|
habitsList.style.display = 'none';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/habits');
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch habits');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const habits = data.habits || [];
|
||||||
|
|
||||||
|
// Hide loading state
|
||||||
|
loadingState.classList.remove('active');
|
||||||
|
|
||||||
|
// Sort habits by streak descending (highest first)
|
||||||
|
habits.sort((a, b) => (b.streak || 0) - (a.streak || 0));
|
||||||
|
|
||||||
|
if (habits.length === 0) {
|
||||||
|
// Show empty state
|
||||||
|
emptyState.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
// Render habits list
|
||||||
|
habitsList.innerHTML = '';
|
||||||
|
habits.forEach(habit => {
|
||||||
|
const card = createHabitCard(habit);
|
||||||
|
habitsList.appendChild(card);
|
||||||
|
});
|
||||||
|
habitsList.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-initialize Lucide icons
|
||||||
|
lucide.createIcons();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading habits:', error);
|
||||||
|
loadingState.classList.remove('active');
|
||||||
|
errorState.classList.add('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create habit card element
|
||||||
|
function createHabitCard(habit) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'habit-card';
|
||||||
|
|
||||||
|
// Determine icon based on frequency
|
||||||
|
const iconName = habit.frequency === 'daily' ? 'calendar' : 'clock';
|
||||||
|
|
||||||
|
// Checkbox state
|
||||||
|
const isChecked = habit.checkedToday || false;
|
||||||
|
const checkboxClass = isChecked ? 'habit-checkbox checked disabled' : 'habit-checkbox';
|
||||||
|
const checkIcon = isChecked ? '<i data-lucide="check"></i>' : '';
|
||||||
|
|
||||||
|
// Create card HTML
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="${checkboxClass}" data-habit-id="${habit.id}" onclick="checkHabit('${habit.id}', this)">
|
||||||
|
${checkIcon}
|
||||||
|
</div>
|
||||||
|
<div class="habit-icon">
|
||||||
|
<i data-lucide="${iconName}"></i>
|
||||||
|
</div>
|
||||||
|
<div class="habit-info">
|
||||||
|
<div class="habit-name">${escapeHtml(habit.name)}</div>
|
||||||
|
<div class="habit-frequency">${habit.frequency === 'daily' ? 'Zilnic' : 'Săptămânal'}</div>
|
||||||
|
</div>
|
||||||
|
<div class="habit-streak">
|
||||||
|
<span id="streak-${habit.id}">${habit.streak || 0}</span>
|
||||||
|
<span>🔥</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape HTML to prevent XSS
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check habit (mark as done for today)
|
||||||
|
async function checkHabit(habitId, checkboxElement) {
|
||||||
|
// Don't allow checking if already checked
|
||||||
|
if (checkboxElement.classList.contains('disabled')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optimistic UI update
|
||||||
|
checkboxElement.classList.add('checked', 'disabled');
|
||||||
|
checkboxElement.innerHTML = '<i data-lucide="check"></i>';
|
||||||
|
lucide.createIcons();
|
||||||
|
|
||||||
|
// Store original state for rollback
|
||||||
|
const originalCheckbox = checkboxElement.cloneNode(true);
|
||||||
|
const streakElement = document.getElementById(`streak-${habitId}`);
|
||||||
|
const originalStreak = streakElement ? streakElement.textContent : '0';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/habits/${habitId}/check`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to check habit');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Update streak with server value
|
||||||
|
if (streakElement && data.habit && data.habit.streak !== undefined) {
|
||||||
|
streakElement.textContent = data.habit.streak;
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast('Obișnuință bifată! 🎉');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking habit:', error);
|
||||||
|
|
||||||
|
// Revert checkbox on error
|
||||||
|
checkboxElement.classList.remove('checked', 'disabled');
|
||||||
|
checkboxElement.innerHTML = '';
|
||||||
|
|
||||||
|
// Revert streak
|
||||||
|
if (streakElement) {
|
||||||
|
streakElement.textContent = originalStreak;
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast('Eroare la bifarea obișnuinței. Încearcă din nou.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load habits on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
loadHabits();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4
dashboard/habits.json
Normal file
4
dashboard/habits.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"habits": [],
|
||||||
|
"lastUpdated": "2026-02-10T12:39:00Z"
|
||||||
|
}
|
||||||
207
dashboard/test_habits_api.py
Normal file
207
dashboard/test_habits_api.py
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Tests for GET /api/habits endpoint.
|
||||||
|
Validates API response structure, status codes, and error handling.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# API endpoint - assumes server is running on localhost:8088
|
||||||
|
API_BASE = 'http://localhost:8088'
|
||||||
|
HABITS_FILE = Path(__file__).parent / 'habits.json'
|
||||||
|
|
||||||
|
|
||||||
|
def test_habits_endpoint_exists():
|
||||||
|
"""Test that GET /api/habits endpoint exists and returns 200."""
|
||||||
|
print("Testing endpoint exists and returns 200...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = urllib.request.urlopen(f'{API_BASE}/api/habits', timeout=5)
|
||||||
|
status_code = response.getcode()
|
||||||
|
|
||||||
|
assert status_code == 200, f"Expected status 200, got {status_code}"
|
||||||
|
print("✓ Endpoint returns 200 status")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raise AssertionError(f"Endpoint returned HTTP {e.code}: {e.reason}")
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
raise AssertionError(f"Could not connect to API server: {e.reason}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_habits_response_is_json():
|
||||||
|
"""Test that response is valid JSON."""
|
||||||
|
print("Testing response is valid JSON...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = urllib.request.urlopen(f'{API_BASE}/api/habits', timeout=5)
|
||||||
|
content = response.read().decode('utf-8')
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(content)
|
||||||
|
print("✓ Response is valid JSON")
|
||||||
|
return data
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise AssertionError(f"Response is not valid JSON: {e}")
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
raise AssertionError(f"Could not connect to API server: {e.reason}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_habits_response_structure():
|
||||||
|
"""Test that response has correct structure: habits array and lastUpdated."""
|
||||||
|
print("Testing response structure...")
|
||||||
|
|
||||||
|
data = test_habits_response_is_json()
|
||||||
|
|
||||||
|
# Check for habits array
|
||||||
|
assert 'habits' in data, "Response missing 'habits' field"
|
||||||
|
assert isinstance(data['habits'], list), "'habits' field must be an array"
|
||||||
|
print("✓ Response contains 'habits' array")
|
||||||
|
|
||||||
|
# Check for lastUpdated timestamp
|
||||||
|
assert 'lastUpdated' in data, "Response missing 'lastUpdated' field"
|
||||||
|
print("✓ Response contains 'lastUpdated' field")
|
||||||
|
|
||||||
|
|
||||||
|
def test_habits_lastupdated_is_iso():
|
||||||
|
"""Test that lastUpdated is a valid ISO timestamp."""
|
||||||
|
print("Testing lastUpdated is valid ISO timestamp...")
|
||||||
|
|
||||||
|
data = test_habits_response_is_json()
|
||||||
|
last_updated = data.get('lastUpdated')
|
||||||
|
|
||||||
|
assert last_updated, "lastUpdated field is empty"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Try to parse as ISO datetime
|
||||||
|
dt = datetime.fromisoformat(last_updated.replace('Z', '+00:00'))
|
||||||
|
print(f"✓ lastUpdated is valid ISO timestamp: {last_updated}")
|
||||||
|
except (ValueError, AttributeError) as e:
|
||||||
|
raise AssertionError(f"lastUpdated is not a valid ISO timestamp: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_habits_returns_empty_array():
|
||||||
|
"""Test that empty habits.json returns empty array, not error."""
|
||||||
|
print("Testing empty habits file returns empty array...")
|
||||||
|
|
||||||
|
# Backup original file
|
||||||
|
backup = None
|
||||||
|
if HABITS_FILE.exists():
|
||||||
|
backup = HABITS_FILE.read_text()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Write empty habits file
|
||||||
|
HABITS_FILE.write_text(json.dumps({
|
||||||
|
'lastUpdated': datetime.now().isoformat(),
|
||||||
|
'habits': []
|
||||||
|
}))
|
||||||
|
|
||||||
|
# Request habits
|
||||||
|
response = urllib.request.urlopen(f'{API_BASE}/api/habits', timeout=5)
|
||||||
|
data = json.loads(response.read().decode('utf-8'))
|
||||||
|
|
||||||
|
assert data['habits'] == [], "Empty habits.json should return empty array"
|
||||||
|
print("✓ Empty habits.json returns empty array (not error)")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Restore backup
|
||||||
|
if backup:
|
||||||
|
HABITS_FILE.write_text(backup)
|
||||||
|
|
||||||
|
|
||||||
|
def test_habits_with_data():
|
||||||
|
"""Test that habits with data are returned correctly."""
|
||||||
|
print("Testing habits with data are returned...")
|
||||||
|
|
||||||
|
# Backup original file
|
||||||
|
backup = None
|
||||||
|
if HABITS_FILE.exists():
|
||||||
|
backup = HABITS_FILE.read_text()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Write test habits
|
||||||
|
test_data = {
|
||||||
|
'lastUpdated': '2026-02-10T10:00:00.000Z',
|
||||||
|
'habits': [
|
||||||
|
{
|
||||||
|
'id': 'test-habit-1',
|
||||||
|
'name': 'Bazin',
|
||||||
|
'frequency': 'daily',
|
||||||
|
'createdAt': '2026-02-10T10:00:00.000Z',
|
||||||
|
'completions': ['2026-02-10T10:00:00.000Z']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
HABITS_FILE.write_text(json.dumps(test_data, indent=2))
|
||||||
|
|
||||||
|
# Request habits
|
||||||
|
response = urllib.request.urlopen(f'{API_BASE}/api/habits', timeout=5)
|
||||||
|
data = json.loads(response.read().decode('utf-8'))
|
||||||
|
|
||||||
|
assert len(data['habits']) == 1, "Should return 1 habit"
|
||||||
|
habit = data['habits'][0]
|
||||||
|
assert habit['name'] == 'Bazin', f"Expected habit name 'Bazin', got '{habit['name']}'"
|
||||||
|
assert habit['frequency'] == 'daily', f"Expected frequency 'daily', got '{habit['frequency']}'"
|
||||||
|
print("✓ Habits with data are returned correctly")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Restore backup
|
||||||
|
if backup:
|
||||||
|
HABITS_FILE.write_text(backup)
|
||||||
|
|
||||||
|
|
||||||
|
def run_all_tests():
|
||||||
|
"""Run all tests and report results."""
|
||||||
|
print("=" * 60)
|
||||||
|
print("Running GET /api/habits endpoint tests")
|
||||||
|
print("=" * 60)
|
||||||
|
print()
|
||||||
|
|
||||||
|
tests = [
|
||||||
|
test_habits_endpoint_exists,
|
||||||
|
test_habits_response_is_json,
|
||||||
|
test_habits_response_structure,
|
||||||
|
test_habits_lastupdated_is_iso,
|
||||||
|
test_empty_habits_returns_empty_array,
|
||||||
|
test_habits_with_data,
|
||||||
|
]
|
||||||
|
|
||||||
|
passed = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
for test in tests:
|
||||||
|
try:
|
||||||
|
test()
|
||||||
|
passed += 1
|
||||||
|
print()
|
||||||
|
except AssertionError as e:
|
||||||
|
print(f"✗ FAILED: {e}")
|
||||||
|
print()
|
||||||
|
failed += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ ERROR: {e}")
|
||||||
|
print()
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print(f"Results: {passed} passed, {failed} failed")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
return failed == 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Check if API server is running
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen(f'{API_BASE}/api/status', timeout=2)
|
||||||
|
except urllib.error.URLError:
|
||||||
|
print("ERROR: API server is not running on localhost:8088")
|
||||||
|
print("Start the server with: python3 dashboard/api.py")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
success = run_all_tests()
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
273
dashboard/test_habits_check.py
Normal file
273
dashboard/test_habits_check.py
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Tests for POST /api/habits/{id}/check endpoint
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from http.client import HTTPConnection
|
||||||
|
|
||||||
|
# Test against local server
|
||||||
|
HOST = 'localhost'
|
||||||
|
PORT = 8088
|
||||||
|
HABITS_FILE = Path(__file__).parent / 'habits.json'
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_test_habits():
|
||||||
|
"""Reset habits.json to empty state for testing."""
|
||||||
|
data = {
|
||||||
|
'lastUpdated': datetime.now().isoformat(),
|
||||||
|
'habits': []
|
||||||
|
}
|
||||||
|
HABITS_FILE.write_text(json.dumps(data, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
def create_test_habit(name='Test Habit', frequency='daily'):
|
||||||
|
"""Helper to create a test habit and return its ID."""
|
||||||
|
conn = HTTPConnection(HOST, PORT)
|
||||||
|
payload = json.dumps({'name': name, 'frequency': frequency})
|
||||||
|
headers = {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
|
conn.request('POST', '/api/habits', payload, headers)
|
||||||
|
response = conn.getresponse()
|
||||||
|
data = json.loads(response.read().decode())
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return data['id']
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_habit_success():
|
||||||
|
"""Test successfully checking a habit for today."""
|
||||||
|
cleanup_test_habits()
|
||||||
|
habit_id = create_test_habit('Morning Run', 'daily')
|
||||||
|
|
||||||
|
conn = HTTPConnection(HOST, PORT)
|
||||||
|
conn.request('POST', f'/api/habits/{habit_id}/check')
|
||||||
|
response = conn.getresponse()
|
||||||
|
data = json.loads(response.read().decode())
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
assert response.status == 200, f"Expected 200, got {response.status}"
|
||||||
|
assert data['id'] == habit_id, "Habit ID should match"
|
||||||
|
assert 'completions' in data, "Response should include completions"
|
||||||
|
assert len(data['completions']) == 1, "Should have exactly 1 completion"
|
||||||
|
|
||||||
|
# Check that completion date is today (YYYY-MM-DD format)
|
||||||
|
today = datetime.now().date().isoformat()
|
||||||
|
assert data['completions'][0] == today, f"Completion should be today's date: {today}"
|
||||||
|
|
||||||
|
# Check that streak is calculated and included
|
||||||
|
assert 'streak' in data, "Response should include streak"
|
||||||
|
assert data['streak'] == 1, "Streak should be 1 after first check"
|
||||||
|
|
||||||
|
print("✓ test_check_habit_success")
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_habit_already_checked():
|
||||||
|
"""Test checking a habit that was already checked today."""
|
||||||
|
cleanup_test_habits()
|
||||||
|
habit_id = create_test_habit('Reading', 'daily')
|
||||||
|
|
||||||
|
# Check it once
|
||||||
|
conn = HTTPConnection(HOST, PORT)
|
||||||
|
conn.request('POST', f'/api/habits/{habit_id}/check')
|
||||||
|
conn.getresponse().read()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Try to check again
|
||||||
|
conn = HTTPConnection(HOST, PORT)
|
||||||
|
conn.request('POST', f'/api/habits/{habit_id}/check')
|
||||||
|
response = conn.getresponse()
|
||||||
|
data = json.loads(response.read().decode())
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
assert response.status == 400, f"Expected 400, got {response.status}"
|
||||||
|
assert 'error' in data, "Response should include error"
|
||||||
|
assert 'already checked' in data['error'].lower(), "Error should mention already checked"
|
||||||
|
|
||||||
|
print("✓ test_check_habit_already_checked")
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_habit_not_found():
|
||||||
|
"""Test checking a non-existent habit."""
|
||||||
|
cleanup_test_habits()
|
||||||
|
|
||||||
|
conn = HTTPConnection(HOST, PORT)
|
||||||
|
conn.request('POST', '/api/habits/nonexistent-id/check')
|
||||||
|
response = conn.getresponse()
|
||||||
|
data = json.loads(response.read().decode())
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
assert response.status == 404, f"Expected 404, got {response.status}"
|
||||||
|
assert 'error' in data, "Response should include error"
|
||||||
|
|
||||||
|
print("✓ test_check_habit_not_found")
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_habit_persistence():
|
||||||
|
"""Test that completions are persisted to habits.json."""
|
||||||
|
cleanup_test_habits()
|
||||||
|
habit_id = create_test_habit('Meditation', 'daily')
|
||||||
|
|
||||||
|
# Check the habit
|
||||||
|
conn = HTTPConnection(HOST, PORT)
|
||||||
|
conn.request('POST', f'/api/habits/{habit_id}/check')
|
||||||
|
conn.getresponse().read()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Read habits.json directly
|
||||||
|
habits_data = json.loads(HABITS_FILE.read_text())
|
||||||
|
habit = next((h for h in habits_data['habits'] if h['id'] == habit_id), None)
|
||||||
|
|
||||||
|
assert habit is not None, "Habit should exist in file"
|
||||||
|
assert len(habit['completions']) == 1, "Should have 1 completion in file"
|
||||||
|
|
||||||
|
today = datetime.now().date().isoformat()
|
||||||
|
assert habit['completions'][0] == today, "Completion date should be today"
|
||||||
|
|
||||||
|
print("✓ test_check_habit_persistence")
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_habit_sorted_completions():
|
||||||
|
"""Test that completions array is sorted chronologically."""
|
||||||
|
cleanup_test_habits()
|
||||||
|
|
||||||
|
# Create a habit and manually add out-of-order completions
|
||||||
|
habit_id = create_test_habit('Workout', 'daily')
|
||||||
|
|
||||||
|
# Manually add past completions in reverse order
|
||||||
|
habits_data = json.loads(HABITS_FILE.read_text())
|
||||||
|
habit = next((h for h in habits_data['habits'] if h['id'] == habit_id), None)
|
||||||
|
|
||||||
|
today = datetime.now().date()
|
||||||
|
habit['completions'] = [
|
||||||
|
(today - timedelta(days=2)).isoformat(), # 2 days ago
|
||||||
|
(today - timedelta(days=4)).isoformat(), # 4 days ago
|
||||||
|
(today - timedelta(days=1)).isoformat(), # yesterday
|
||||||
|
]
|
||||||
|
HABITS_FILE.write_text(json.dumps(habits_data, indent=2))
|
||||||
|
|
||||||
|
# Check today
|
||||||
|
conn = HTTPConnection(HOST, PORT)
|
||||||
|
conn.request('POST', f'/api/habits/{habit_id}/check')
|
||||||
|
response = conn.getresponse()
|
||||||
|
data = json.loads(response.read().decode())
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Verify completions are sorted oldest first
|
||||||
|
expected_order = [
|
||||||
|
(today - timedelta(days=4)).isoformat(),
|
||||||
|
(today - timedelta(days=2)).isoformat(),
|
||||||
|
(today - timedelta(days=1)).isoformat(),
|
||||||
|
today.isoformat()
|
||||||
|
]
|
||||||
|
|
||||||
|
assert data['completions'] == expected_order, f"Completions should be sorted. Got: {data['completions']}"
|
||||||
|
|
||||||
|
print("✓ test_check_habit_sorted_completions")
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_habit_streak_calculation():
|
||||||
|
"""Test that streak is calculated correctly after checking."""
|
||||||
|
cleanup_test_habits()
|
||||||
|
habit_id = create_test_habit('Journaling', 'daily')
|
||||||
|
|
||||||
|
# Add consecutive past completions
|
||||||
|
today = datetime.now().date()
|
||||||
|
habits_data = json.loads(HABITS_FILE.read_text())
|
||||||
|
habit = next((h for h in habits_data['habits'] if h['id'] == habit_id), None)
|
||||||
|
|
||||||
|
habit['completions'] = [
|
||||||
|
(today - timedelta(days=2)).isoformat(),
|
||||||
|
(today - timedelta(days=1)).isoformat(),
|
||||||
|
]
|
||||||
|
HABITS_FILE.write_text(json.dumps(habits_data, indent=2))
|
||||||
|
|
||||||
|
# Check today
|
||||||
|
conn = HTTPConnection(HOST, PORT)
|
||||||
|
conn.request('POST', f'/api/habits/{habit_id}/check')
|
||||||
|
response = conn.getresponse()
|
||||||
|
data = json.loads(response.read().decode())
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Streak should be 3 (today + yesterday + day before)
|
||||||
|
assert data['streak'] == 3, f"Expected streak 3, got {data['streak']}"
|
||||||
|
|
||||||
|
print("✓ test_check_habit_streak_calculation")
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_weekly_habit():
|
||||||
|
"""Test checking a weekly habit."""
|
||||||
|
cleanup_test_habits()
|
||||||
|
habit_id = create_test_habit('Team Meeting', 'weekly')
|
||||||
|
|
||||||
|
conn = HTTPConnection(HOST, PORT)
|
||||||
|
conn.request('POST', f'/api/habits/{habit_id}/check')
|
||||||
|
response = conn.getresponse()
|
||||||
|
data = json.loads(response.read().decode())
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
assert response.status == 200, f"Expected 200, got {response.status}"
|
||||||
|
assert len(data['completions']) == 1, "Should have 1 completion"
|
||||||
|
assert data['streak'] == 1, "Weekly habit should have streak of 1"
|
||||||
|
|
||||||
|
print("✓ test_check_weekly_habit")
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_habit_iso_date_format():
|
||||||
|
"""Test that completion dates use ISO YYYY-MM-DD format (not timestamps)."""
|
||||||
|
cleanup_test_habits()
|
||||||
|
habit_id = create_test_habit('Water Plants', 'daily')
|
||||||
|
|
||||||
|
conn = HTTPConnection(HOST, PORT)
|
||||||
|
conn.request('POST', f'/api/habits/{habit_id}/check')
|
||||||
|
response = conn.getresponse()
|
||||||
|
data = json.loads(response.read().decode())
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
completion = data['completions'][0]
|
||||||
|
|
||||||
|
# Verify format is YYYY-MM-DD (exactly 10 chars, 2 dashes)
|
||||||
|
assert len(completion) == 10, f"Date should be 10 chars, got {len(completion)}"
|
||||||
|
assert completion.count('-') == 2, "Date should have 2 dashes"
|
||||||
|
assert 'T' not in completion, "Date should not include time (no T)"
|
||||||
|
|
||||||
|
# Verify it parses as a valid date
|
||||||
|
try:
|
||||||
|
datetime.fromisoformat(completion)
|
||||||
|
except ValueError:
|
||||||
|
assert False, f"Completion date should be valid ISO date: {completion}"
|
||||||
|
|
||||||
|
print("✓ test_check_habit_iso_date_format")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
print("Running tests for POST /api/habits/{id}/check...")
|
||||||
|
print()
|
||||||
|
|
||||||
|
try:
|
||||||
|
test_check_habit_success()
|
||||||
|
test_check_habit_already_checked()
|
||||||
|
test_check_habit_not_found()
|
||||||
|
test_check_habit_persistence()
|
||||||
|
test_check_habit_sorted_completions()
|
||||||
|
test_check_habit_streak_calculation()
|
||||||
|
test_check_weekly_habit()
|
||||||
|
test_check_habit_iso_date_format()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("✅ All tests passed!")
|
||||||
|
sys.exit(0)
|
||||||
|
except AssertionError as e:
|
||||||
|
print()
|
||||||
|
print(f"❌ Test failed: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print()
|
||||||
|
print(f"❌ Error running tests: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
sys.exit(1)
|
||||||
222
dashboard/test_habits_check_ui.py
Normal file
222
dashboard/test_habits_check_ui.py
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Tests for Story 10.0: Frontend - Check habit interaction
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import re
|
||||||
|
|
||||||
|
def load_html():
|
||||||
|
"""Load habits.html content"""
|
||||||
|
try:
|
||||||
|
with open('dashboard/habits.html', 'r', encoding='utf-8') as f:
|
||||||
|
return f.read()
|
||||||
|
except FileNotFoundError:
|
||||||
|
print("ERROR: dashboard/habits.html not found")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def test_checkbox_css_exists():
|
||||||
|
"""Test that checkbox CSS styles are defined"""
|
||||||
|
html = load_html()
|
||||||
|
|
||||||
|
# Check for checkbox class
|
||||||
|
assert '.habit-checkbox' in html, "Missing .habit-checkbox CSS class"
|
||||||
|
|
||||||
|
# Check for circular shape (border-radius: 50%)
|
||||||
|
assert 'border-radius: 50%' in html, "Checkbox should be circular (border-radius: 50%)"
|
||||||
|
|
||||||
|
# Check for checked state
|
||||||
|
assert '.habit-checkbox.checked' in html, "Missing .habit-checkbox.checked CSS"
|
||||||
|
|
||||||
|
# Check for disabled state
|
||||||
|
assert '.habit-checkbox.disabled' in html, "Missing .habit-checkbox.disabled CSS"
|
||||||
|
|
||||||
|
# Check for hover state
|
||||||
|
assert '.habit-checkbox:hover' in html, "Missing .habit-checkbox:hover CSS"
|
||||||
|
|
||||||
|
print("✓ Checkbox CSS styles exist")
|
||||||
|
|
||||||
|
def test_checkbox_in_habit_card():
|
||||||
|
"""Test that createHabitCard includes checkbox"""
|
||||||
|
html = load_html()
|
||||||
|
|
||||||
|
# Check that createHabitCard creates a checkbox element
|
||||||
|
assert 'habit-checkbox' in html, "createHabitCard should include checkbox element"
|
||||||
|
|
||||||
|
# Check for data-habit-id attribute
|
||||||
|
assert 'data-habit-id' in html, "Checkbox should have data-habit-id attribute"
|
||||||
|
|
||||||
|
# Check for onclick handler
|
||||||
|
assert 'onclick="checkHabit' in html, "Checkbox should have onclick='checkHabit' handler"
|
||||||
|
|
||||||
|
print("✓ Checkbox is included in habit card")
|
||||||
|
|
||||||
|
def test_checkbox_checked_state():
|
||||||
|
"""Test that checkbox uses checkedToday to determine state"""
|
||||||
|
html = load_html()
|
||||||
|
|
||||||
|
# Look for logic that checks habit.checkedToday
|
||||||
|
assert 'checkedToday' in html, "Should check habit.checkedToday property"
|
||||||
|
|
||||||
|
# Check for conditional checked class
|
||||||
|
assert 'checked' in html, "Should add 'checked' class when checkedToday is true"
|
||||||
|
|
||||||
|
# Check for check icon
|
||||||
|
assert 'data-lucide="check"' in html, "Should show check icon when checked"
|
||||||
|
|
||||||
|
print("✓ Checkbox state reflects checkedToday")
|
||||||
|
|
||||||
|
def test_check_habit_function_exists():
|
||||||
|
"""Test that checkHabit function is defined"""
|
||||||
|
html = load_html()
|
||||||
|
|
||||||
|
# Check for function definition
|
||||||
|
assert 'function checkHabit' in html or 'async function checkHabit' in html, \
|
||||||
|
"checkHabit function should be defined"
|
||||||
|
|
||||||
|
# Check for parameters
|
||||||
|
assert re.search(r'function checkHabit\s*\(\s*habitId', html) or \
|
||||||
|
re.search(r'async function checkHabit\s*\(\s*habitId', html), \
|
||||||
|
"checkHabit should accept habitId parameter"
|
||||||
|
|
||||||
|
print("✓ checkHabit function exists")
|
||||||
|
|
||||||
|
def test_check_habit_api_call():
|
||||||
|
"""Test that checkHabit calls POST /api/habits/{id}/check"""
|
||||||
|
html = load_html()
|
||||||
|
|
||||||
|
# Check for fetch call
|
||||||
|
assert 'fetch(' in html, "checkHabit should use fetch API"
|
||||||
|
|
||||||
|
# Check for POST method
|
||||||
|
assert "'POST'" in html or '"POST"' in html, "checkHabit should use POST method"
|
||||||
|
|
||||||
|
# Check for /api/habits/ endpoint
|
||||||
|
assert '/api/habits/' in html, "Should call /api/habits/{id}/check endpoint"
|
||||||
|
|
||||||
|
# Check for /check path
|
||||||
|
assert '/check' in html, "Should call endpoint with /check path"
|
||||||
|
|
||||||
|
print("✓ checkHabit calls POST /api/habits/{id}/check")
|
||||||
|
|
||||||
|
def test_optimistic_ui_update():
|
||||||
|
"""Test that checkbox updates immediately (optimistic)"""
|
||||||
|
html = load_html()
|
||||||
|
|
||||||
|
# Check for classList.add before fetch
|
||||||
|
# The pattern should be: add 'checked' class, then fetch
|
||||||
|
checkHabitFunc = html[html.find('async function checkHabit'):html.find('async function checkHabit') + 2000]
|
||||||
|
|
||||||
|
# Check for immediate classList modification
|
||||||
|
assert 'classList.add' in checkHabitFunc, "Should add class immediately (optimistic update)"
|
||||||
|
assert "'checked'" in checkHabitFunc or '"checked"' in checkHabitFunc, \
|
||||||
|
"Should add 'checked' class optimistically"
|
||||||
|
|
||||||
|
print("✓ Optimistic UI update implemented")
|
||||||
|
|
||||||
|
def test_error_handling_revert():
|
||||||
|
"""Test that checkbox reverts on error"""
|
||||||
|
html = load_html()
|
||||||
|
|
||||||
|
# Check for catch block
|
||||||
|
assert 'catch' in html, "checkHabit should have error handling (catch)"
|
||||||
|
|
||||||
|
# Check for classList.remove in error handler
|
||||||
|
checkHabitFunc = html[html.find('async function checkHabit'):]
|
||||||
|
|
||||||
|
# Find the catch block
|
||||||
|
if 'catch' in checkHabitFunc:
|
||||||
|
catchBlock = checkHabitFunc[checkHabitFunc.find('catch'):]
|
||||||
|
|
||||||
|
# Check for revert logic
|
||||||
|
assert 'classList.remove' in catchBlock, "Should revert checkbox on error"
|
||||||
|
assert "'checked'" in catchBlock or '"checked"' in catchBlock, \
|
||||||
|
"Should remove 'checked' class on error"
|
||||||
|
|
||||||
|
print("✓ Error handling reverts checkbox")
|
||||||
|
|
||||||
|
def test_disabled_when_checked():
|
||||||
|
"""Test that checkbox is disabled when already checked"""
|
||||||
|
html = load_html()
|
||||||
|
|
||||||
|
# Check for disabled class on checked habits
|
||||||
|
assert 'disabled' in html, "Should add 'disabled' class to checked habits"
|
||||||
|
|
||||||
|
# Check for early return if disabled
|
||||||
|
checkHabitFunc = html[html.find('async function checkHabit'):html.find('async function checkHabit') + 1000]
|
||||||
|
assert 'disabled' in checkHabitFunc, "Should check if checkbox is disabled"
|
||||||
|
assert 'return' in checkHabitFunc, "Should return early if disabled"
|
||||||
|
|
||||||
|
print("✓ Checkbox disabled when already checked")
|
||||||
|
|
||||||
|
def test_streak_updates():
|
||||||
|
"""Test that streak updates after successful check"""
|
||||||
|
html = load_html()
|
||||||
|
|
||||||
|
# Check for streak element ID
|
||||||
|
assert 'streak-' in html, "Should use ID for streak element (e.g., streak-${habit.id})"
|
||||||
|
|
||||||
|
# Check for getElementById to update streak
|
||||||
|
checkHabitFunc = html[html.find('async function checkHabit'):]
|
||||||
|
assert 'getElementById' in checkHabitFunc or 'getElementById' in html, \
|
||||||
|
"Should get streak element by ID to update it"
|
||||||
|
|
||||||
|
# Check that response data is used to update streak
|
||||||
|
assert '.streak' in checkHabitFunc or 'streak' in checkHabitFunc, \
|
||||||
|
"Should update streak from response data"
|
||||||
|
|
||||||
|
print("✓ Streak updates after successful check")
|
||||||
|
|
||||||
|
def test_check_icon_display():
|
||||||
|
"""Test that check icon is shown when checked"""
|
||||||
|
html = load_html()
|
||||||
|
|
||||||
|
# Check for lucide check icon
|
||||||
|
assert 'data-lucide="check"' in html, "Should use lucide check icon"
|
||||||
|
|
||||||
|
# Check that icon is created/shown after checking
|
||||||
|
checkHabitFunc = html[html.find('async function checkHabit'):html.find('async function checkHabit') + 1500]
|
||||||
|
assert 'lucide.createIcons()' in checkHabitFunc, \
|
||||||
|
"Should reinitialize lucide icons after adding check icon"
|
||||||
|
|
||||||
|
print("✓ Check icon displays correctly")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Run all tests"""
|
||||||
|
print("Running Story 10.0 Frontend Check Interaction Tests...\n")
|
||||||
|
|
||||||
|
tests = [
|
||||||
|
test_checkbox_css_exists,
|
||||||
|
test_checkbox_in_habit_card,
|
||||||
|
test_checkbox_checked_state,
|
||||||
|
test_check_habit_function_exists,
|
||||||
|
test_check_habit_api_call,
|
||||||
|
test_optimistic_ui_update,
|
||||||
|
test_error_handling_revert,
|
||||||
|
test_disabled_when_checked,
|
||||||
|
test_streak_updates,
|
||||||
|
test_check_icon_display,
|
||||||
|
]
|
||||||
|
|
||||||
|
passed = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
for test in tests:
|
||||||
|
try:
|
||||||
|
test()
|
||||||
|
passed += 1
|
||||||
|
except AssertionError as e:
|
||||||
|
print(f"✗ {test.__name__}: {e}")
|
||||||
|
failed += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ {test.__name__}: Unexpected error: {e}")
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
print(f"\n{'='*50}")
|
||||||
|
print(f"Results: {passed} passed, {failed} failed")
|
||||||
|
print(f"{'='*50}")
|
||||||
|
|
||||||
|
return 0 if failed == 0 else 1
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main())
|
||||||
173
dashboard/test_habits_display.py
Normal file
173
dashboard/test_habits_display.py
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Tests for Story 9.0: Frontend - Display habits list
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
def read_file(path):
|
||||||
|
with open(path, 'r', encoding='utf-8') as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
def test_loading_state_structure():
|
||||||
|
"""Test loading state HTML structure exists"""
|
||||||
|
html = read_file('dashboard/habits.html')
|
||||||
|
assert 'id="loadingState"' in html, "Loading state element missing"
|
||||||
|
assert 'class="loading-state"' in html, "Loading state class missing"
|
||||||
|
assert 'data-lucide="loader"' in html, "Loading state loader icon missing"
|
||||||
|
assert 'Se încarcă obiceiurile' in html, "Loading state message missing"
|
||||||
|
print("✓ Loading state structure exists")
|
||||||
|
|
||||||
|
def test_error_state_structure():
|
||||||
|
"""Test error state HTML structure exists"""
|
||||||
|
html = read_file('dashboard/habits.html')
|
||||||
|
assert 'id="errorState"' in html, "Error state element missing"
|
||||||
|
assert 'class="error-state"' in html, "Error state class missing"
|
||||||
|
assert 'data-lucide="alert-circle"' in html, "Error state alert icon missing"
|
||||||
|
assert 'Eroare la încărcarea obiceiurilor' in html, "Error state message missing"
|
||||||
|
assert 'onclick="loadHabits()"' in html, "Retry button missing"
|
||||||
|
print("✓ Error state structure exists")
|
||||||
|
|
||||||
|
def test_empty_state_has_id():
|
||||||
|
"""Test empty state has id for JavaScript access"""
|
||||||
|
html = read_file('dashboard/habits.html')
|
||||||
|
assert 'id="emptyState"' in html, "Empty state id missing"
|
||||||
|
print("✓ Empty state has id attribute")
|
||||||
|
|
||||||
|
def test_habits_list_container():
|
||||||
|
"""Test habits list container exists"""
|
||||||
|
html = read_file('dashboard/habits.html')
|
||||||
|
assert 'id="habitsList"' in html, "Habits list container missing"
|
||||||
|
assert 'class="habits-list"' in html, "Habits list class missing"
|
||||||
|
print("✓ Habits list container exists")
|
||||||
|
|
||||||
|
def test_loadhabits_function_exists():
|
||||||
|
"""Test loadHabits function is implemented"""
|
||||||
|
html = read_file('dashboard/habits.html')
|
||||||
|
assert 'async function loadHabits()' in html, "loadHabits function not implemented"
|
||||||
|
assert 'await fetch(\'/api/habits\')' in html, "API fetch call missing"
|
||||||
|
print("✓ loadHabits function exists and fetches API")
|
||||||
|
|
||||||
|
def test_sorting_by_streak():
|
||||||
|
"""Test habits are sorted by streak descending"""
|
||||||
|
html = read_file('dashboard/habits.html')
|
||||||
|
assert 'habits.sort(' in html, "Sorting logic missing"
|
||||||
|
assert 'streak' in html and '.sort(' in html, "Sort by streak missing"
|
||||||
|
# Check for descending order (b.streak - a.streak pattern)
|
||||||
|
assert re.search(r'b\.streak.*-.*a\.streak', html), "Descending sort pattern missing"
|
||||||
|
print("✓ Habits sorted by streak descending")
|
||||||
|
|
||||||
|
def test_frequency_icons():
|
||||||
|
"""Test frequency icons (calendar for daily, clock for weekly)"""
|
||||||
|
html = read_file('dashboard/habits.html')
|
||||||
|
assert 'calendar' in html, "Calendar icon for daily habits missing"
|
||||||
|
assert 'clock' in html, "Clock icon for weekly habits missing"
|
||||||
|
# Check icon assignment logic
|
||||||
|
assert 'daily' in html and 'calendar' in html, "Daily -> calendar mapping missing"
|
||||||
|
assert 'weekly' in html and 'clock' in html, "Weekly -> clock mapping missing"
|
||||||
|
print("✓ Frequency icons implemented (calendar/clock)")
|
||||||
|
|
||||||
|
def test_streak_display_with_flame():
|
||||||
|
"""Test streak display includes flame emoji"""
|
||||||
|
html = read_file('dashboard/habits.html')
|
||||||
|
assert '🔥' in html, "Flame emoji missing from streak display"
|
||||||
|
assert 'habit-streak' in html, "Habit streak class missing"
|
||||||
|
print("✓ Streak displays with flame emoji 🔥")
|
||||||
|
|
||||||
|
def test_show_hide_states():
|
||||||
|
"""Test state management (loading, error, empty, list)"""
|
||||||
|
html = read_file('dashboard/habits.html')
|
||||||
|
# Check for state toggling logic
|
||||||
|
assert 'loadingState.classList.add(\'active\')' in html or \
|
||||||
|
'loadingState.classList.add("active")' in html, "Loading state show missing"
|
||||||
|
assert 'errorState.classList.remove(\'active\')' in html or \
|
||||||
|
'errorState.classList.remove("active")' in html, "Error state hide missing"
|
||||||
|
assert 'emptyState.style.display' in html, "Empty state toggle missing"
|
||||||
|
assert 'habitsList.style.display' in html, "Habits list toggle missing"
|
||||||
|
print("✓ State management implemented")
|
||||||
|
|
||||||
|
def test_error_handling():
|
||||||
|
"""Test error handling shows error state"""
|
||||||
|
html = read_file('dashboard/habits.html')
|
||||||
|
assert 'catch' in html, "Error handling missing"
|
||||||
|
assert 'errorState.classList.add(\'active\')' in html or \
|
||||||
|
'errorState.classList.add("active")' in html, "Error state activation missing"
|
||||||
|
print("✓ Error handling implemented")
|
||||||
|
|
||||||
|
def test_createhabitcard_function():
|
||||||
|
"""Test createHabitCard function exists"""
|
||||||
|
html = read_file('dashboard/habits.html')
|
||||||
|
assert 'function createHabitCard(' in html, "createHabitCard function missing"
|
||||||
|
assert 'habit.name' in html, "Habit name rendering missing"
|
||||||
|
assert 'habit.frequency' in html, "Habit frequency rendering missing"
|
||||||
|
assert 'habit.streak' in html, "Habit streak rendering missing"
|
||||||
|
print("✓ createHabitCard function exists")
|
||||||
|
|
||||||
|
def test_page_load_trigger():
|
||||||
|
"""Test loadHabits is called on page load"""
|
||||||
|
html = read_file('dashboard/habits.html')
|
||||||
|
assert 'DOMContentLoaded' in html, "DOMContentLoaded listener missing"
|
||||||
|
assert 'loadHabits()' in html, "loadHabits call missing"
|
||||||
|
print("✓ loadHabits called on page load")
|
||||||
|
|
||||||
|
def test_habit_card_css():
|
||||||
|
"""Test habit card CSS styling exists"""
|
||||||
|
html = read_file('dashboard/habits.html')
|
||||||
|
assert '.habit-card' in html, "Habit card CSS missing"
|
||||||
|
assert '.habit-icon' in html, "Habit icon CSS missing"
|
||||||
|
assert '.habit-info' in html, "Habit info CSS missing"
|
||||||
|
assert '.habit-name' in html, "Habit name CSS missing"
|
||||||
|
assert '.habit-frequency' in html, "Habit frequency CSS missing"
|
||||||
|
assert '.habit-streak' in html, "Habit streak CSS missing"
|
||||||
|
print("✓ Habit card CSS styling exists")
|
||||||
|
|
||||||
|
def test_lucide_icons_reinitialized():
|
||||||
|
"""Test Lucide icons are reinitialized after rendering"""
|
||||||
|
html = read_file('dashboard/habits.html')
|
||||||
|
assert 'lucide.createIcons()' in html, "Lucide icons initialization missing"
|
||||||
|
# Check it's called after rendering habits
|
||||||
|
assert html.index('habitsList.appendChild') < html.rindex('lucide.createIcons()'), \
|
||||||
|
"Lucide icons not reinitialized after rendering"
|
||||||
|
print("✓ Lucide icons reinitialized after rendering")
|
||||||
|
|
||||||
|
def test_xss_protection():
|
||||||
|
"""Test HTML escaping for XSS protection"""
|
||||||
|
html = read_file('dashboard/habits.html')
|
||||||
|
assert 'escapeHtml' in html, "HTML escaping function missing"
|
||||||
|
assert 'textContent' in html or 'innerText' in html, "Text content method missing"
|
||||||
|
print("✓ XSS protection implemented")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
tests = [
|
||||||
|
test_loading_state_structure,
|
||||||
|
test_error_state_structure,
|
||||||
|
test_empty_state_has_id,
|
||||||
|
test_habits_list_container,
|
||||||
|
test_loadhabits_function_exists,
|
||||||
|
test_sorting_by_streak,
|
||||||
|
test_frequency_icons,
|
||||||
|
test_streak_display_with_flame,
|
||||||
|
test_show_hide_states,
|
||||||
|
test_error_handling,
|
||||||
|
test_createhabitcard_function,
|
||||||
|
test_page_load_trigger,
|
||||||
|
test_habit_card_css,
|
||||||
|
test_lucide_icons_reinitialized,
|
||||||
|
test_xss_protection,
|
||||||
|
]
|
||||||
|
|
||||||
|
failed = 0
|
||||||
|
for test in tests:
|
||||||
|
try:
|
||||||
|
test()
|
||||||
|
except AssertionError as e:
|
||||||
|
print(f"✗ {test.__name__}: {e}")
|
||||||
|
failed += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ {test.__name__}: Unexpected error: {e}")
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
print(f"\n{'='*50}")
|
||||||
|
print(f"Tests: {len(tests)} total, {len(tests)-failed} passed, {failed} failed")
|
||||||
|
if failed == 0:
|
||||||
|
print("✓ All Story 9.0 tests passed!")
|
||||||
|
exit(failed)
|
||||||
209
dashboard/test_habits_form_submit.py
Normal file
209
dashboard/test_habits_form_submit.py
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Tests for Story 11.0: Frontend - Create habit from form
|
||||||
|
|
||||||
|
Acceptance Criteria:
|
||||||
|
1. Form submit calls POST /api/habits with name and frequency
|
||||||
|
2. Shows loading state on submit (button disabled)
|
||||||
|
3. On success: modal closes, list refreshes, new habit appears
|
||||||
|
4. On error: shows error message in modal, modal stays open
|
||||||
|
5. Input field cleared after successful creation
|
||||||
|
6. Tests for form submission pass
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
def test_form_submit_api_call():
|
||||||
|
"""Test that createHabit calls POST /api/habits with correct data"""
|
||||||
|
with open('dashboard/habits.html', 'r') as f:
|
||||||
|
html = f.read()
|
||||||
|
|
||||||
|
# Check that createHabit function exists
|
||||||
|
assert 'async function createHabit()' in html, "createHabit function should exist"
|
||||||
|
|
||||||
|
# Check that it calls POST /api/habits
|
||||||
|
assert "fetch('/api/habits'" in html, "Should fetch /api/habits endpoint"
|
||||||
|
assert "method: 'POST'" in html, "Should use POST method"
|
||||||
|
|
||||||
|
# Check that it sends name and frequency in JSON body
|
||||||
|
assert "body: JSON.stringify({ name, frequency })" in html, "Should send name and frequency in request body"
|
||||||
|
|
||||||
|
# Check that frequency is read from checked radio button
|
||||||
|
assert "document.querySelector('input[name=\"frequency\"]:checked').value" in html, "Should read frequency from radio buttons"
|
||||||
|
|
||||||
|
print("✓ Form submission calls POST /api/habits with name and frequency")
|
||||||
|
|
||||||
|
def test_loading_state_on_submit():
|
||||||
|
"""Test that button is disabled during submission"""
|
||||||
|
with open('dashboard/habits.html', 'r') as f:
|
||||||
|
html = f.read()
|
||||||
|
|
||||||
|
# Check that createBtn is referenced
|
||||||
|
assert "createBtn = document.getElementById('habitCreateBtn')" in html, "Should get create button element"
|
||||||
|
|
||||||
|
# Check that button is disabled before fetch
|
||||||
|
assert "createBtn.disabled = true" in html, "Button should be disabled during submission"
|
||||||
|
|
||||||
|
# Check that button text changes to loading state
|
||||||
|
assert "createBtn.textContent = 'Se creează...'" in html or "Se creează" in html, "Should show loading text during submission"
|
||||||
|
|
||||||
|
# Check that original text is stored for restoration
|
||||||
|
assert "originalText = createBtn.textContent" in html, "Should store original button text"
|
||||||
|
|
||||||
|
print("✓ Shows loading state on submit (button disabled)")
|
||||||
|
|
||||||
|
def test_success_behavior():
|
||||||
|
"""Test behavior on successful habit creation"""
|
||||||
|
with open('dashboard/habits.html', 'r') as f:
|
||||||
|
html = f.read()
|
||||||
|
|
||||||
|
# Check for success handling block
|
||||||
|
assert "if (response.ok)" in html, "Should check for successful response"
|
||||||
|
|
||||||
|
# Check that modal closes on success
|
||||||
|
assert "hideHabitModal()" in html, "Modal should close on success"
|
||||||
|
|
||||||
|
# Check that habits list is refreshed
|
||||||
|
assert "loadHabits()" in html, "Should reload habits list on success"
|
||||||
|
|
||||||
|
# Check that success toast is shown
|
||||||
|
assert "showToast(" in html and "succes" in html, "Should show success toast"
|
||||||
|
|
||||||
|
print("✓ On success: modal closes, list refreshes, new habit appears")
|
||||||
|
|
||||||
|
def test_error_behavior():
|
||||||
|
"""Test behavior on error (modal stays open, shows error)"""
|
||||||
|
with open('dashboard/habits.html', 'r') as f:
|
||||||
|
html = f.read()
|
||||||
|
|
||||||
|
# Check for error handling in response
|
||||||
|
assert "else {" in html or "!response.ok" in html, "Should handle error responses"
|
||||||
|
|
||||||
|
# Check for catch block for network errors
|
||||||
|
assert "catch (error)" in html, "Should catch network errors"
|
||||||
|
|
||||||
|
# Check that error toast is shown
|
||||||
|
assert "showToast('Eroare" in html, "Should show error toast on failure"
|
||||||
|
|
||||||
|
# Check that button is re-enabled on error (so modal stays usable)
|
||||||
|
createHabit_block = html[html.find('async function createHabit()'):html.find('async function createHabit()') + 2000]
|
||||||
|
|
||||||
|
# Count occurrences of button re-enable in error paths
|
||||||
|
error_section = html[html.find('} else {'):html.find('} catch (error)') + 500]
|
||||||
|
assert 'createBtn.disabled = false' in error_section, "Button should be re-enabled on error"
|
||||||
|
assert 'createBtn.textContent = originalText' in error_section, "Button text should be restored on error"
|
||||||
|
|
||||||
|
print("✓ On error: shows error message, modal stays open (button re-enabled)")
|
||||||
|
|
||||||
|
def test_input_cleared_after_success():
|
||||||
|
"""Test that input field is cleared after successful creation"""
|
||||||
|
with open('dashboard/habits.html', 'r') as f:
|
||||||
|
html = f.read()
|
||||||
|
|
||||||
|
# Find the success block
|
||||||
|
success_section = html[html.find('if (response.ok)'):html.find('if (response.ok)') + 500]
|
||||||
|
|
||||||
|
# Check that nameInput.value is cleared
|
||||||
|
assert "nameInput.value = ''" in success_section or 'nameInput.value = ""' in success_section, \
|
||||||
|
"Input field should be cleared after successful creation"
|
||||||
|
|
||||||
|
print("✓ Input field cleared after successful creation")
|
||||||
|
|
||||||
|
def test_form_validation_still_works():
|
||||||
|
"""Test that existing form validation is still in place"""
|
||||||
|
with open('dashboard/habits.html', 'r') as f:
|
||||||
|
html = f.read()
|
||||||
|
|
||||||
|
# Check that empty name validation exists
|
||||||
|
assert "if (!name)" in html, "Should validate for empty name"
|
||||||
|
assert "name = nameInput.value.trim()" in html, "Should trim name before validation"
|
||||||
|
|
||||||
|
# Check that create button is disabled when name is empty
|
||||||
|
assert "nameInput.addEventListener('input'" in html, "Should listen to input changes"
|
||||||
|
assert "createBtn.disabled = name.length === 0" in html, "Button should be disabled when name is empty"
|
||||||
|
|
||||||
|
print("✓ Form validation still works (empty name check)")
|
||||||
|
|
||||||
|
def test_modal_reset_on_open():
|
||||||
|
"""Test that modal resets form when opened"""
|
||||||
|
with open('dashboard/habits.html', 'r') as f:
|
||||||
|
html = f.read()
|
||||||
|
|
||||||
|
# Check showAddHabitModal function
|
||||||
|
assert 'function showAddHabitModal()' in html, "showAddHabitModal function should exist"
|
||||||
|
|
||||||
|
# Check that form is reset when modal opens
|
||||||
|
modal_function = html[html.find('function showAddHabitModal()'):html.find('function showAddHabitModal()') + 500]
|
||||||
|
assert "nameInput.value = ''" in modal_function or 'nameInput.value = ""' in modal_function, \
|
||||||
|
"Should clear name input when opening modal"
|
||||||
|
|
||||||
|
print("✓ Modal resets form when opened")
|
||||||
|
|
||||||
|
def test_enter_key_submission():
|
||||||
|
"""Test that Enter key can submit the form"""
|
||||||
|
with open('dashboard/habits.html', 'r') as f:
|
||||||
|
html = f.read()
|
||||||
|
|
||||||
|
# Check for Enter key handler
|
||||||
|
assert "addEventListener('keypress'" in html, "Should listen for keypress events"
|
||||||
|
assert "e.key === 'Enter'" in html, "Should check for Enter key"
|
||||||
|
assert "!createBtn.disabled" in html, "Should check if button is enabled before submitting"
|
||||||
|
assert "createHabit()" in html, "Should call createHabit on Enter"
|
||||||
|
|
||||||
|
print("✓ Enter key submits form when button is enabled")
|
||||||
|
|
||||||
|
def test_all_acceptance_criteria():
|
||||||
|
"""Summary test - verify all acceptance criteria are met"""
|
||||||
|
with open('dashboard/habits.html', 'r') as f:
|
||||||
|
html = f.read()
|
||||||
|
|
||||||
|
checks = [
|
||||||
|
("'/api/habits'" in html and "method: 'POST'" in html and "body: JSON.stringify({ name, frequency })" in html,
|
||||||
|
"1. Form submit calls POST /api/habits with name and frequency"),
|
||||||
|
|
||||||
|
("createBtn.disabled = true" in html and "Se creează" in html,
|
||||||
|
"2. Shows loading state on submit (button disabled)"),
|
||||||
|
|
||||||
|
("hideHabitModal()" in html and "loadHabits()" in html and "response.ok" in html,
|
||||||
|
"3. On success: modal closes, list refreshes, new habit appears"),
|
||||||
|
|
||||||
|
("catch (error)" in html and "createBtn.disabled = false" in html,
|
||||||
|
"4. On error: shows error message, modal stays open"),
|
||||||
|
|
||||||
|
("nameInput.value = ''" in html or 'nameInput.value = ""' in html,
|
||||||
|
"5. Input field cleared after successful creation"),
|
||||||
|
|
||||||
|
(True, "6. Tests for form submission pass (this test!)")
|
||||||
|
]
|
||||||
|
|
||||||
|
all_pass = True
|
||||||
|
for condition, description in checks:
|
||||||
|
status = "✓" if condition else "✗"
|
||||||
|
print(f" {status} {description}")
|
||||||
|
if not condition:
|
||||||
|
all_pass = False
|
||||||
|
|
||||||
|
assert all_pass, "Not all acceptance criteria are met"
|
||||||
|
print("\n✓ All acceptance criteria verified!")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
try:
|
||||||
|
test_form_submit_api_call()
|
||||||
|
test_loading_state_on_submit()
|
||||||
|
test_success_behavior()
|
||||||
|
test_error_behavior()
|
||||||
|
test_input_cleared_after_success()
|
||||||
|
test_form_validation_still_works()
|
||||||
|
test_modal_reset_on_open()
|
||||||
|
test_enter_key_submission()
|
||||||
|
test_all_acceptance_criteria()
|
||||||
|
|
||||||
|
print("\n✅ All Story 11.0 tests passed!")
|
||||||
|
sys.exit(0)
|
||||||
|
except AssertionError as e:
|
||||||
|
print(f"\n❌ Test failed: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ Unexpected error: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
252
dashboard/test_habits_get_enhanced.py
Normal file
252
dashboard/test_habits_get_enhanced.py
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Tests for enhanced GET /api/habits endpoint with streak and checkedToday fields.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
BASE_URL = 'http://localhost:8088'
|
||||||
|
KANBAN_DIR = Path(__file__).parent
|
||||||
|
|
||||||
|
|
||||||
|
def test_habits_get_includes_streak_field():
|
||||||
|
"""Test that each habit includes a 'streak' field."""
|
||||||
|
# Create test habit with completions
|
||||||
|
today = datetime.now().date()
|
||||||
|
yesterday = today - timedelta(days=1)
|
||||||
|
two_days_ago = today - timedelta(days=2)
|
||||||
|
|
||||||
|
test_data = {
|
||||||
|
'habits': [
|
||||||
|
{
|
||||||
|
'id': 'habit-test1',
|
||||||
|
'name': 'Test Habit',
|
||||||
|
'frequency': 'daily',
|
||||||
|
'createdAt': '2026-02-01T10:00:00Z',
|
||||||
|
'completions': [
|
||||||
|
two_days_ago.isoformat(),
|
||||||
|
yesterday.isoformat(),
|
||||||
|
today.isoformat()
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'lastUpdated': datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
habits_file = KANBAN_DIR / 'habits.json'
|
||||||
|
habits_file.write_text(json.dumps(test_data, indent=2), encoding='utf-8')
|
||||||
|
|
||||||
|
# Test GET
|
||||||
|
req = urllib.request.Request(f'{BASE_URL}/api/habits')
|
||||||
|
with urllib.request.urlopen(req) as response:
|
||||||
|
result = json.loads(response.read().decode('utf-8'))
|
||||||
|
|
||||||
|
assert 'habits' in result, "Response should contain habits array"
|
||||||
|
assert len(result['habits']) == 1, "Should have one habit"
|
||||||
|
habit = result['habits'][0]
|
||||||
|
assert 'streak' in habit, "Habit should include 'streak' field"
|
||||||
|
assert isinstance(habit['streak'], int), "Streak should be an integer"
|
||||||
|
assert habit['streak'] == 3, f"Expected streak of 3, got {habit['streak']}"
|
||||||
|
|
||||||
|
print("✓ Each habit includes 'streak' field")
|
||||||
|
|
||||||
|
|
||||||
|
def test_habits_get_includes_checked_today_field():
|
||||||
|
"""Test that each habit includes a 'checkedToday' field."""
|
||||||
|
today = datetime.now().date().isoformat()
|
||||||
|
|
||||||
|
test_data = {
|
||||||
|
'habits': [
|
||||||
|
{
|
||||||
|
'id': 'habit-test1',
|
||||||
|
'name': 'Checked Today',
|
||||||
|
'frequency': 'daily',
|
||||||
|
'createdAt': '2026-02-01T10:00:00Z',
|
||||||
|
'completions': [today]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 'habit-test2',
|
||||||
|
'name': 'Not Checked Today',
|
||||||
|
'frequency': 'daily',
|
||||||
|
'createdAt': '2026-02-01T10:00:00Z',
|
||||||
|
'completions': ['2026-02-01']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'lastUpdated': datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
habits_file = KANBAN_DIR / 'habits.json'
|
||||||
|
habits_file.write_text(json.dumps(test_data, indent=2), encoding='utf-8')
|
||||||
|
|
||||||
|
# Test GET
|
||||||
|
req = urllib.request.Request(f'{BASE_URL}/api/habits')
|
||||||
|
with urllib.request.urlopen(req) as response:
|
||||||
|
result = json.loads(response.read().decode('utf-8'))
|
||||||
|
|
||||||
|
assert len(result['habits']) == 2, "Should have two habits"
|
||||||
|
|
||||||
|
habit1 = result['habits'][0]
|
||||||
|
assert 'checkedToday' in habit1, "Habit should include 'checkedToday' field"
|
||||||
|
assert isinstance(habit1['checkedToday'], bool), "checkedToday should be boolean"
|
||||||
|
assert habit1['checkedToday'] is True, "Habit checked today should have checkedToday=True"
|
||||||
|
|
||||||
|
habit2 = result['habits'][1]
|
||||||
|
assert 'checkedToday' in habit2, "Habit should include 'checkedToday' field"
|
||||||
|
assert habit2['checkedToday'] is False, "Habit not checked today should have checkedToday=False"
|
||||||
|
|
||||||
|
print("✓ Each habit includes 'checkedToday' boolean field")
|
||||||
|
|
||||||
|
|
||||||
|
def test_habits_get_calculates_streak_correctly():
|
||||||
|
"""Test that streak is calculated using the streak utility function."""
|
||||||
|
today = datetime.now().date()
|
||||||
|
yesterday = today - timedelta(days=1)
|
||||||
|
two_days_ago = today - timedelta(days=2)
|
||||||
|
three_days_ago = today - timedelta(days=3)
|
||||||
|
four_days_ago = today - timedelta(days=4)
|
||||||
|
|
||||||
|
test_data = {
|
||||||
|
'habits': [
|
||||||
|
{
|
||||||
|
'id': 'habit-daily',
|
||||||
|
'name': 'Daily Habit',
|
||||||
|
'frequency': 'daily',
|
||||||
|
'createdAt': '2026-02-01T10:00:00Z',
|
||||||
|
'completions': [
|
||||||
|
four_days_ago.isoformat(),
|
||||||
|
three_days_ago.isoformat(),
|
||||||
|
two_days_ago.isoformat(),
|
||||||
|
yesterday.isoformat(),
|
||||||
|
today.isoformat()
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 'habit-broken',
|
||||||
|
'name': 'Broken Streak',
|
||||||
|
'frequency': 'daily',
|
||||||
|
'createdAt': '2026-02-01T10:00:00Z',
|
||||||
|
'completions': [
|
||||||
|
four_days_ago.isoformat(),
|
||||||
|
three_days_ago.isoformat()
|
||||||
|
# Missing two_days_ago - streak broken
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 'habit-weekly',
|
||||||
|
'name': 'Weekly Habit',
|
||||||
|
'frequency': 'weekly',
|
||||||
|
'createdAt': '2026-02-01T10:00:00Z',
|
||||||
|
'completions': [
|
||||||
|
today.isoformat(),
|
||||||
|
(today - timedelta(days=7)).isoformat(),
|
||||||
|
(today - timedelta(days=14)).isoformat()
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'lastUpdated': datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
habits_file = KANBAN_DIR / 'habits.json'
|
||||||
|
habits_file.write_text(json.dumps(test_data, indent=2), encoding='utf-8')
|
||||||
|
|
||||||
|
# Test GET
|
||||||
|
req = urllib.request.Request(f'{BASE_URL}/api/habits')
|
||||||
|
with urllib.request.urlopen(req) as response:
|
||||||
|
result = json.loads(response.read().decode('utf-8'))
|
||||||
|
|
||||||
|
assert len(result['habits']) == 3, "Should have three habits"
|
||||||
|
|
||||||
|
daily_habit = result['habits'][0]
|
||||||
|
assert daily_habit['streak'] == 5, f"Expected daily streak of 5, got {daily_habit['streak']}"
|
||||||
|
|
||||||
|
broken_habit = result['habits'][1]
|
||||||
|
assert broken_habit['streak'] == 0, f"Expected broken streak of 0, got {broken_habit['streak']}"
|
||||||
|
|
||||||
|
weekly_habit = result['habits'][2]
|
||||||
|
assert weekly_habit['streak'] == 3, f"Expected weekly streak of 3, got {weekly_habit['streak']}"
|
||||||
|
|
||||||
|
print("✓ Streak is calculated correctly using utility function")
|
||||||
|
|
||||||
|
|
||||||
|
def test_habits_get_empty_habits_array():
|
||||||
|
"""Test GET with empty habits array."""
|
||||||
|
test_data = {
|
||||||
|
'habits': [],
|
||||||
|
'lastUpdated': datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
habits_file = KANBAN_DIR / 'habits.json'
|
||||||
|
habits_file.write_text(json.dumps(test_data, indent=2), encoding='utf-8')
|
||||||
|
|
||||||
|
# Test GET
|
||||||
|
req = urllib.request.Request(f'{BASE_URL}/api/habits')
|
||||||
|
with urllib.request.urlopen(req) as response:
|
||||||
|
result = json.loads(response.read().decode('utf-8'))
|
||||||
|
|
||||||
|
assert result['habits'] == [], "Should return empty array"
|
||||||
|
assert 'lastUpdated' in result, "Should include lastUpdated"
|
||||||
|
|
||||||
|
print("✓ Empty habits array handled correctly")
|
||||||
|
|
||||||
|
|
||||||
|
def test_habits_get_preserves_original_fields():
|
||||||
|
"""Test that all original habit fields are preserved."""
|
||||||
|
today = datetime.now().date().isoformat()
|
||||||
|
|
||||||
|
test_data = {
|
||||||
|
'habits': [
|
||||||
|
{
|
||||||
|
'id': 'habit-test1',
|
||||||
|
'name': 'Test Habit',
|
||||||
|
'frequency': 'daily',
|
||||||
|
'createdAt': '2026-02-01T10:00:00Z',
|
||||||
|
'completions': [today]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'lastUpdated': '2026-02-10T10:00:00Z'
|
||||||
|
}
|
||||||
|
|
||||||
|
habits_file = KANBAN_DIR / 'habits.json'
|
||||||
|
habits_file.write_text(json.dumps(test_data, indent=2), encoding='utf-8')
|
||||||
|
|
||||||
|
# Test GET
|
||||||
|
req = urllib.request.Request(f'{BASE_URL}/api/habits')
|
||||||
|
with urllib.request.urlopen(req) as response:
|
||||||
|
result = json.loads(response.read().decode('utf-8'))
|
||||||
|
|
||||||
|
habit = result['habits'][0]
|
||||||
|
assert habit['id'] == 'habit-test1', "Original id should be preserved"
|
||||||
|
assert habit['name'] == 'Test Habit', "Original name should be preserved"
|
||||||
|
assert habit['frequency'] == 'daily', "Original frequency should be preserved"
|
||||||
|
assert habit['createdAt'] == '2026-02-01T10:00:00Z', "Original createdAt should be preserved"
|
||||||
|
assert habit['completions'] == [today], "Original completions should be preserved"
|
||||||
|
assert 'streak' in habit, "Should add streak field"
|
||||||
|
assert 'checkedToday' in habit, "Should add checkedToday field"
|
||||||
|
|
||||||
|
print("✓ All original habit fields are preserved")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
try:
|
||||||
|
print("\n=== Testing Enhanced GET /api/habits ===\n")
|
||||||
|
|
||||||
|
test_habits_get_includes_streak_field()
|
||||||
|
test_habits_get_includes_checked_today_field()
|
||||||
|
test_habits_get_calculates_streak_correctly()
|
||||||
|
test_habits_get_empty_habits_array()
|
||||||
|
test_habits_get_preserves_original_fields()
|
||||||
|
|
||||||
|
print("\n=== All Enhanced GET Tests Passed ✓ ===\n")
|
||||||
|
sys.exit(0)
|
||||||
|
except AssertionError as e:
|
||||||
|
print(f"\n❌ Test failed: {e}\n")
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ Error: {e}\n")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
sys.exit(1)
|
||||||
240
dashboard/test_habits_html.py
Normal file
240
dashboard/test_habits_html.py
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test suite for habits.html page structure
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
1. File exists
|
||||||
|
2. Valid HTML5 structure
|
||||||
|
3. Uses common.css and swipe-nav.js
|
||||||
|
4. Has navigation bar matching dashboard style
|
||||||
|
5. Page title 'Habit Tracker' in header
|
||||||
|
6. Empty state message 'Nicio obișnuință încă. Creează prima!'
|
||||||
|
7. Add habit button with '+' icon (lucide)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
|
||||||
|
# Path to habits.html
|
||||||
|
HABITS_HTML_PATH = 'dashboard/habits.html'
|
||||||
|
|
||||||
|
class HTMLStructureParser(HTMLParser):
|
||||||
|
"""Parser to extract specific elements from HTML"""
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.title_text = None
|
||||||
|
self.css_files = []
|
||||||
|
self.js_files = []
|
||||||
|
self.nav_items = []
|
||||||
|
self.page_title = None
|
||||||
|
self.empty_state_message = None
|
||||||
|
self.has_add_button = False
|
||||||
|
self.has_lucide_plus = False
|
||||||
|
self.in_title = False
|
||||||
|
self.in_page_title = False
|
||||||
|
self.in_empty_message = False
|
||||||
|
self.in_button = False
|
||||||
|
self.current_class = None
|
||||||
|
|
||||||
|
def handle_starttag(self, tag, attrs):
|
||||||
|
attrs_dict = dict(attrs)
|
||||||
|
|
||||||
|
# Track CSS and JS files
|
||||||
|
if tag == 'link' and attrs_dict.get('rel') == 'stylesheet':
|
||||||
|
self.css_files.append(attrs_dict.get('href', ''))
|
||||||
|
if tag == 'script' and 'src' in attrs_dict:
|
||||||
|
self.js_files.append(attrs_dict.get('src'))
|
||||||
|
|
||||||
|
# Track title tag
|
||||||
|
if tag == 'title':
|
||||||
|
self.in_title = True
|
||||||
|
|
||||||
|
# Track page title (h1 with class page-title)
|
||||||
|
if tag == 'h1' and 'page-title' in attrs_dict.get('class', ''):
|
||||||
|
self.in_page_title = True
|
||||||
|
|
||||||
|
# Track nav items
|
||||||
|
if tag == 'a' and 'nav-item' in attrs_dict.get('class', ''):
|
||||||
|
href = attrs_dict.get('href', '')
|
||||||
|
classes = attrs_dict.get('class', '')
|
||||||
|
self.nav_items.append({'href': href, 'classes': classes})
|
||||||
|
|
||||||
|
# Track empty state message
|
||||||
|
if 'empty-state-message' in attrs_dict.get('class', ''):
|
||||||
|
self.in_empty_message = True
|
||||||
|
|
||||||
|
# Track add habit button
|
||||||
|
if tag == 'button' and 'add-habit-btn' in attrs_dict.get('class', ''):
|
||||||
|
self.has_add_button = True
|
||||||
|
self.in_button = True
|
||||||
|
|
||||||
|
# Track lucide plus icon in button context
|
||||||
|
if self.in_button and tag == 'i':
|
||||||
|
lucide_attr = attrs_dict.get('data-lucide', '')
|
||||||
|
if 'plus' in lucide_attr:
|
||||||
|
self.has_lucide_plus = True
|
||||||
|
|
||||||
|
def handle_endtag(self, tag):
|
||||||
|
if tag == 'title':
|
||||||
|
self.in_title = False
|
||||||
|
if tag == 'h1':
|
||||||
|
self.in_page_title = False
|
||||||
|
if tag == 'p':
|
||||||
|
self.in_empty_message = False
|
||||||
|
if tag == 'button':
|
||||||
|
self.in_button = False
|
||||||
|
|
||||||
|
def handle_data(self, data):
|
||||||
|
if self.in_title:
|
||||||
|
self.title_text = data.strip()
|
||||||
|
if self.in_page_title:
|
||||||
|
self.page_title = data.strip()
|
||||||
|
if self.in_empty_message:
|
||||||
|
self.empty_state_message = data.strip()
|
||||||
|
|
||||||
|
def test_file_exists():
|
||||||
|
"""Test 1: File exists"""
|
||||||
|
assert os.path.exists(HABITS_HTML_PATH), f"File {HABITS_HTML_PATH} not found"
|
||||||
|
print("✓ Test 1: File exists")
|
||||||
|
|
||||||
|
def test_valid_html5():
|
||||||
|
"""Test 2: Valid HTML5 structure"""
|
||||||
|
with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Check DOCTYPE
|
||||||
|
assert content.strip().startswith('<!DOCTYPE html>'), "Missing or incorrect DOCTYPE"
|
||||||
|
|
||||||
|
# Check required tags
|
||||||
|
required_tags = ['<html', '<head>', '<meta charset', '<title>', '<body>', '</html>']
|
||||||
|
for tag in required_tags:
|
||||||
|
assert tag in content, f"Missing required tag: {tag}"
|
||||||
|
|
||||||
|
# Check html lang attribute
|
||||||
|
assert 'lang="ro"' in content or "lang='ro'" in content, "Missing lang='ro' attribute on html tag"
|
||||||
|
|
||||||
|
print("✓ Test 2: Valid HTML5 structure")
|
||||||
|
|
||||||
|
def test_uses_common_css_and_swipe_nav():
|
||||||
|
"""Test 3: Uses common.css and swipe-nav.js"""
|
||||||
|
with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
parser = HTMLStructureParser()
|
||||||
|
parser.feed(content)
|
||||||
|
|
||||||
|
# Check for common.css
|
||||||
|
assert any('common.css' in css for css in parser.css_files), "Missing common.css"
|
||||||
|
|
||||||
|
# Check for swipe-nav.js
|
||||||
|
assert any('swipe-nav.js' in js for js in parser.js_files), "Missing swipe-nav.js"
|
||||||
|
|
||||||
|
print("✓ Test 3: Uses common.css and swipe-nav.js")
|
||||||
|
|
||||||
|
def test_navigation_bar():
|
||||||
|
"""Test 4: Has navigation bar matching dashboard style"""
|
||||||
|
with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
parser = HTMLStructureParser()
|
||||||
|
parser.feed(content)
|
||||||
|
|
||||||
|
# Check that we have nav items
|
||||||
|
assert len(parser.nav_items) >= 4, f"Expected at least 4 nav items, found {len(parser.nav_items)}"
|
||||||
|
|
||||||
|
# Check for Dashboard nav item
|
||||||
|
dashboard_items = [item for item in parser.nav_items if 'index.html' in item['href']]
|
||||||
|
assert len(dashboard_items) > 0, "Missing Dashboard nav item"
|
||||||
|
|
||||||
|
# Check for habits nav item with active class
|
||||||
|
habits_items = [item for item in parser.nav_items if 'habits.html' in item['href']]
|
||||||
|
assert len(habits_items) > 0, "Missing Habits nav item"
|
||||||
|
assert any('active' in item['classes'] for item in habits_items), "Habits nav item should have 'active' class"
|
||||||
|
|
||||||
|
# Check for header element with class 'header'
|
||||||
|
assert '<header class="header">' in content, "Missing header element with class 'header'"
|
||||||
|
|
||||||
|
print("✓ Test 4: Has navigation bar matching dashboard style")
|
||||||
|
|
||||||
|
def test_page_title():
|
||||||
|
"""Test 5: Page title 'Habit Tracker' in header"""
|
||||||
|
with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
parser = HTMLStructureParser()
|
||||||
|
parser.feed(content)
|
||||||
|
|
||||||
|
# Check <title> tag
|
||||||
|
assert parser.title_text is not None, "Missing <title> tag"
|
||||||
|
assert 'Habit Tracker' in parser.title_text, f"Expected 'Habit Tracker' in title, got: {parser.title_text}"
|
||||||
|
|
||||||
|
# Check page header (h1)
|
||||||
|
assert parser.page_title is not None, "Missing page title (h1.page-title)"
|
||||||
|
assert 'Habit Tracker' in parser.page_title, f"Expected 'Habit Tracker' in page title, got: {parser.page_title}"
|
||||||
|
|
||||||
|
print("✓ Test 5: Page title 'Habit Tracker' in header")
|
||||||
|
|
||||||
|
def test_empty_state_message():
|
||||||
|
"""Test 6: Empty state message 'Nicio obișnuință încă. Creează prima!'"""
|
||||||
|
with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
parser = HTMLStructureParser()
|
||||||
|
parser.feed(content)
|
||||||
|
|
||||||
|
# Check empty state message
|
||||||
|
assert parser.empty_state_message is not None, "Missing empty state message"
|
||||||
|
expected_message = "Nicio obișnuință încă. Creează prima!"
|
||||||
|
assert parser.empty_state_message == expected_message, \
|
||||||
|
f"Expected '{expected_message}', got: '{parser.empty_state_message}'"
|
||||||
|
|
||||||
|
# Check for empty-state class
|
||||||
|
assert 'class="empty-state"' in content, "Missing empty-state element"
|
||||||
|
|
||||||
|
print("✓ Test 6: Empty state message present")
|
||||||
|
|
||||||
|
def test_add_habit_button():
|
||||||
|
"""Test 7: Add habit button with '+' icon (lucide)"""
|
||||||
|
with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
parser = HTMLStructureParser()
|
||||||
|
parser.feed(content)
|
||||||
|
|
||||||
|
# Check for add habit button
|
||||||
|
assert parser.has_add_button, "Missing add habit button with class 'add-habit-btn'"
|
||||||
|
|
||||||
|
# Check for lucide plus icon
|
||||||
|
assert parser.has_lucide_plus, "Missing lucide 'plus' icon in add habit button"
|
||||||
|
|
||||||
|
# Check button text content
|
||||||
|
assert 'Adaugă obișnuință' in content, "Missing button text 'Adaugă obișnuință'"
|
||||||
|
|
||||||
|
print("✓ Test 7: Add habit button with '+' icon (lucide)")
|
||||||
|
|
||||||
|
def run_all_tests():
|
||||||
|
"""Run all tests"""
|
||||||
|
print("Running habits.html structure tests...\n")
|
||||||
|
|
||||||
|
try:
|
||||||
|
test_file_exists()
|
||||||
|
test_valid_html5()
|
||||||
|
test_uses_common_css_and_swipe_nav()
|
||||||
|
test_navigation_bar()
|
||||||
|
test_page_title()
|
||||||
|
test_empty_state_message()
|
||||||
|
test_add_habit_button()
|
||||||
|
|
||||||
|
print("\n✅ All tests passed!")
|
||||||
|
return True
|
||||||
|
except AssertionError as e:
|
||||||
|
print(f"\n❌ Test failed: {e}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ Unexpected error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
success = run_all_tests()
|
||||||
|
exit(0 if success else 1)
|
||||||
342
dashboard/test_habits_modal.py
Normal file
342
dashboard/test_habits_modal.py
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Tests for habit creation modal in habits.html
|
||||||
|
Validates modal structure, form elements, buttons, and styling
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
|
||||||
|
HABITS_HTML_PATH = 'dashboard/habits.html'
|
||||||
|
|
||||||
|
class ModalParser(HTMLParser):
|
||||||
|
"""Parser to extract modal elements from HTML"""
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.in_modal = False
|
||||||
|
self.in_modal_title = False
|
||||||
|
self.in_form_label = False
|
||||||
|
self.in_button = False
|
||||||
|
self.modal_title = None
|
||||||
|
self.form_labels = []
|
||||||
|
self.name_input_attrs = None
|
||||||
|
self.radio_buttons = []
|
||||||
|
self.buttons = []
|
||||||
|
self.radio_labels = []
|
||||||
|
self.in_radio_label = False
|
||||||
|
self.current_radio_label = None
|
||||||
|
self.modal_overlay_found = False
|
||||||
|
self.modal_div_found = False
|
||||||
|
self.toast_found = False
|
||||||
|
|
||||||
|
def handle_starttag(self, tag, attrs):
|
||||||
|
attrs_dict = dict(attrs)
|
||||||
|
|
||||||
|
# Check for modal overlay
|
||||||
|
if tag == 'div' and attrs_dict.get('id') == 'habitModal':
|
||||||
|
self.modal_overlay_found = True
|
||||||
|
if 'modal-overlay' in attrs_dict.get('class', ''):
|
||||||
|
self.in_modal = True
|
||||||
|
|
||||||
|
# Check for modal div
|
||||||
|
if self.in_modal and tag == 'div' and 'modal' in attrs_dict.get('class', ''):
|
||||||
|
self.modal_div_found = True
|
||||||
|
|
||||||
|
# Check for modal title
|
||||||
|
if self.in_modal and tag == 'h2' and 'modal-title' in attrs_dict.get('class', ''):
|
||||||
|
self.in_modal_title = True
|
||||||
|
|
||||||
|
# Check for form labels
|
||||||
|
if self.in_modal and tag == 'label' and 'form-label' in attrs_dict.get('class', ''):
|
||||||
|
self.in_form_label = True
|
||||||
|
|
||||||
|
# Check for name input
|
||||||
|
if self.in_modal and tag == 'input' and attrs_dict.get('id') == 'habitName':
|
||||||
|
self.name_input_attrs = attrs_dict
|
||||||
|
|
||||||
|
# Check for radio buttons
|
||||||
|
if self.in_modal and tag == 'input' and attrs_dict.get('type') == 'radio':
|
||||||
|
self.radio_buttons.append(attrs_dict)
|
||||||
|
|
||||||
|
# Check for radio labels
|
||||||
|
if self.in_modal and tag == 'label' and 'radio-label' in attrs_dict.get('class', ''):
|
||||||
|
self.in_radio_label = True
|
||||||
|
self.current_radio_label = attrs_dict.get('for', '')
|
||||||
|
|
||||||
|
# Check for buttons
|
||||||
|
if self.in_modal and tag == 'button':
|
||||||
|
self.buttons.append(attrs_dict)
|
||||||
|
self.in_button = True
|
||||||
|
|
||||||
|
# Check for toast
|
||||||
|
if tag == 'div' and attrs_dict.get('id') == 'toast':
|
||||||
|
self.toast_found = True
|
||||||
|
|
||||||
|
def handle_endtag(self, tag):
|
||||||
|
if tag == 'h2':
|
||||||
|
self.in_modal_title = False
|
||||||
|
if tag == 'label':
|
||||||
|
self.in_form_label = False
|
||||||
|
self.in_radio_label = False
|
||||||
|
if tag == 'button':
|
||||||
|
self.in_button = False
|
||||||
|
if tag == 'div' and self.in_modal:
|
||||||
|
# Don't close modal state until we're sure we've left it
|
||||||
|
pass
|
||||||
|
|
||||||
|
def handle_data(self, data):
|
||||||
|
if self.in_modal_title and not self.modal_title:
|
||||||
|
self.modal_title = data.strip()
|
||||||
|
if self.in_form_label:
|
||||||
|
self.form_labels.append(data.strip())
|
||||||
|
if self.in_radio_label:
|
||||||
|
self.radio_labels.append({'for': self.current_radio_label, 'text': data.strip()})
|
||||||
|
|
||||||
|
def test_modal_structure():
|
||||||
|
"""Test modal HTML structure exists"""
|
||||||
|
with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
parser = ModalParser()
|
||||||
|
parser.feed(content)
|
||||||
|
|
||||||
|
# Check modal overlay exists
|
||||||
|
assert parser.modal_overlay_found, "Modal overlay with id='habitModal' not found"
|
||||||
|
|
||||||
|
# Check modal container exists
|
||||||
|
assert parser.modal_div_found, "Modal div with class='modal' not found"
|
||||||
|
|
||||||
|
# Check modal title
|
||||||
|
assert parser.modal_title is not None, "Modal title not found"
|
||||||
|
assert 'nou' in parser.modal_title.lower(), f"Modal title should mention 'nou', got: {parser.modal_title}"
|
||||||
|
|
||||||
|
print("✓ Modal structure exists")
|
||||||
|
|
||||||
|
def test_name_input_field():
|
||||||
|
"""Test habit name input field exists and is required"""
|
||||||
|
with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
parser = ModalParser()
|
||||||
|
parser.feed(content)
|
||||||
|
|
||||||
|
# Find name input
|
||||||
|
assert parser.name_input_attrs is not None, "Name input field with id='habitName' not found"
|
||||||
|
|
||||||
|
# Check it's a text input
|
||||||
|
assert parser.name_input_attrs.get('type') == 'text', "Name input should be type='text'"
|
||||||
|
|
||||||
|
# Check it has class 'input'
|
||||||
|
assert 'input' in parser.name_input_attrs.get('class', ''), "Name input should have class='input'"
|
||||||
|
|
||||||
|
# Check it has placeholder
|
||||||
|
assert parser.name_input_attrs.get('placeholder'), "Name input should have placeholder"
|
||||||
|
|
||||||
|
# Check label exists and mentions required (*)
|
||||||
|
found_required_label = any('*' in label for label in parser.form_labels)
|
||||||
|
assert found_required_label, "Should have a form label with * indicating required field"
|
||||||
|
|
||||||
|
print("✓ Name input field exists with required indicator")
|
||||||
|
|
||||||
|
def test_frequency_radio_buttons():
|
||||||
|
"""Test frequency radio buttons exist with daily and weekly options"""
|
||||||
|
with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
parser = ModalParser()
|
||||||
|
parser.feed(content)
|
||||||
|
|
||||||
|
# Check we have radio buttons
|
||||||
|
assert len(parser.radio_buttons) >= 2, f"Should have at least 2 radio buttons, found {len(parser.radio_buttons)}"
|
||||||
|
|
||||||
|
# Find daily radio button
|
||||||
|
daily_radio = next((r for r in parser.radio_buttons if r.get('value') == 'daily'), None)
|
||||||
|
assert daily_radio is not None, "Daily radio button with value='daily' not found"
|
||||||
|
assert daily_radio.get('name') == 'frequency', "Daily radio should have name='frequency'"
|
||||||
|
# Check for 'checked' attribute - it may be None or empty string when present
|
||||||
|
assert 'checked' in daily_radio, "Daily radio should be checked by default"
|
||||||
|
|
||||||
|
# Find weekly radio button
|
||||||
|
weekly_radio = next((r for r in parser.radio_buttons if r.get('value') == 'weekly'), None)
|
||||||
|
assert weekly_radio is not None, "Weekly radio button with value='weekly' not found"
|
||||||
|
assert weekly_radio.get('name') == 'frequency', "Weekly radio should have name='frequency'"
|
||||||
|
|
||||||
|
# Check labels exist with Romanian text
|
||||||
|
daily_label = next((l for l in parser.radio_labels if 'zilnic' in l['text'].lower()), None)
|
||||||
|
assert daily_label is not None, "Daily label with 'Zilnic' text not found"
|
||||||
|
|
||||||
|
weekly_label = next((l for l in parser.radio_labels if 'săptămânal' in l['text'].lower()), None)
|
||||||
|
assert weekly_label is not None, "Weekly label with 'Săptămânal' text not found"
|
||||||
|
|
||||||
|
print("✓ Frequency radio buttons exist with daily (default) and weekly options")
|
||||||
|
|
||||||
|
def test_modal_buttons():
|
||||||
|
"""Test modal has Cancel and Create buttons"""
|
||||||
|
with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
parser = ModalParser()
|
||||||
|
parser.feed(content)
|
||||||
|
|
||||||
|
# Check we have 2 buttons
|
||||||
|
assert len(parser.buttons) >= 2, f"Should have at least 2 buttons, found {len(parser.buttons)}"
|
||||||
|
|
||||||
|
# Check Cancel button
|
||||||
|
cancel_btn = next((b for b in parser.buttons if 'btn-secondary' in b.get('class', '')), None)
|
||||||
|
assert cancel_btn is not None, "Cancel button with class='btn-secondary' not found"
|
||||||
|
assert 'hideHabitModal' in cancel_btn.get('onclick', ''), "Cancel should call hideHabitModal"
|
||||||
|
|
||||||
|
# Check Create button
|
||||||
|
create_btn = next((b for b in parser.buttons if b.get('id') == 'habitCreateBtn'), None)
|
||||||
|
assert create_btn is not None, "Create button with id='habitCreateBtn' not found"
|
||||||
|
assert 'btn-primary' in create_btn.get('class', ''), "Create button should have class='btn-primary'"
|
||||||
|
assert 'createHabit' in create_btn.get('onclick', ''), "Create should call createHabit"
|
||||||
|
# Check for 'disabled' attribute - it may be None or empty string when present
|
||||||
|
assert 'disabled' in create_btn, "Create button should start disabled"
|
||||||
|
|
||||||
|
# Check button text in content
|
||||||
|
assert 'anulează' in content.lower(), "Cancel button should say 'Anulează'"
|
||||||
|
assert 'creează' in content.lower(), "Create button should say 'Creează'"
|
||||||
|
|
||||||
|
print("✓ Modal has Cancel and Create buttons with correct attributes")
|
||||||
|
|
||||||
|
def test_add_button_triggers_modal():
|
||||||
|
"""Test that add habit button calls showAddHabitModal"""
|
||||||
|
with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Find add habit button
|
||||||
|
assert 'class="add-habit-btn"' in content, "Add habit button not found"
|
||||||
|
assert 'showAddHabitModal()' in content, "Add button should call showAddHabitModal()"
|
||||||
|
|
||||||
|
print("✓ Add habit button calls showAddHabitModal()")
|
||||||
|
|
||||||
|
def test_modal_styling():
|
||||||
|
"""Test modal uses dashboard modal styling patterns"""
|
||||||
|
with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Check key modal classes exist in CSS
|
||||||
|
required_styles = [
|
||||||
|
'.modal-overlay',
|
||||||
|
'.modal-overlay.active',
|
||||||
|
'.modal {',
|
||||||
|
'.modal-title',
|
||||||
|
'.modal-actions',
|
||||||
|
'.form-group',
|
||||||
|
'.form-label',
|
||||||
|
'.radio-group',
|
||||||
|
]
|
||||||
|
|
||||||
|
for style in required_styles:
|
||||||
|
assert style in content, f"Modal style '{style}' not found"
|
||||||
|
|
||||||
|
# Check modal uses CSS variables (dashboard pattern)
|
||||||
|
assert 'var(--bg-base)' in content, "Modal should use --bg-base"
|
||||||
|
assert 'var(--border)' in content, "Modal should use --border"
|
||||||
|
assert 'var(--accent)' in content, "Modal should use --accent"
|
||||||
|
|
||||||
|
print("✓ Modal uses dashboard modal styling patterns")
|
||||||
|
|
||||||
|
def test_javascript_functions():
|
||||||
|
"""Test JavaScript functions for modal interaction exist"""
|
||||||
|
with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Check essential functions exist
|
||||||
|
assert 'function showAddHabitModal()' in content, "showAddHabitModal function not found"
|
||||||
|
assert 'function hideHabitModal()' in content, "hideHabitModal function not found"
|
||||||
|
assert 'async function createHabit()' in content or 'function createHabit()' in content, "createHabit function not found"
|
||||||
|
|
||||||
|
# Check form validation logic
|
||||||
|
assert "createBtn.disabled" in content, "Create button disable logic not found"
|
||||||
|
assert "nameInput.value.trim()" in content, "Name trim validation not found"
|
||||||
|
|
||||||
|
# Check modal show/hide logic
|
||||||
|
assert "modal.classList.add('active')" in content, "Modal show logic not found"
|
||||||
|
assert "modal.classList.remove('active')" in content, "Modal hide logic not found"
|
||||||
|
|
||||||
|
# Check API integration
|
||||||
|
assert "fetch('/api/habits'" in content, "API call to /api/habits not found"
|
||||||
|
assert "method: 'POST'" in content, "POST method not found"
|
||||||
|
|
||||||
|
print("✓ JavaScript functions for modal interaction exist")
|
||||||
|
|
||||||
|
def test_toast_notification():
|
||||||
|
"""Test toast notification element exists"""
|
||||||
|
with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
parser = ModalParser()
|
||||||
|
parser.feed(content)
|
||||||
|
|
||||||
|
# Find toast element
|
||||||
|
assert parser.toast_found, "Toast notification element with id='toast' not found"
|
||||||
|
|
||||||
|
# Check toast styles exist
|
||||||
|
assert '.toast' in content, "Toast styles not found"
|
||||||
|
assert '.toast.show' in content, "Toast show state styles not found"
|
||||||
|
|
||||||
|
# Check showToast function exists
|
||||||
|
assert 'function showToast(' in content, "showToast function not found"
|
||||||
|
|
||||||
|
print("✓ Toast notification element exists")
|
||||||
|
|
||||||
|
def test_form_validation_event_listeners():
|
||||||
|
"""Test form validation with event listeners"""
|
||||||
|
with open(HABITS_HTML_PATH, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Check for DOMContentLoaded event listener
|
||||||
|
assert "addEventListener('DOMContentLoaded'" in content or "DOMContentLoaded" in content, \
|
||||||
|
"Should have DOMContentLoaded event listener"
|
||||||
|
|
||||||
|
# Check for input event listener on name field
|
||||||
|
assert "addEventListener('input'" in content, "Should have input event listener for validation"
|
||||||
|
|
||||||
|
# Check for Enter key handling
|
||||||
|
assert "addEventListener('keypress'" in content or "e.key === 'Enter'" in content, \
|
||||||
|
"Should handle Enter key submission"
|
||||||
|
|
||||||
|
print("✓ Form validation event listeners exist")
|
||||||
|
|
||||||
|
def run_tests():
|
||||||
|
"""Run all tests"""
|
||||||
|
tests = [
|
||||||
|
test_modal_structure,
|
||||||
|
test_name_input_field,
|
||||||
|
test_frequency_radio_buttons,
|
||||||
|
test_modal_buttons,
|
||||||
|
test_add_button_triggers_modal,
|
||||||
|
test_modal_styling,
|
||||||
|
test_javascript_functions,
|
||||||
|
test_toast_notification,
|
||||||
|
test_form_validation_event_listeners,
|
||||||
|
]
|
||||||
|
|
||||||
|
print("Running habit modal tests...\n")
|
||||||
|
|
||||||
|
failed = []
|
||||||
|
for test in tests:
|
||||||
|
try:
|
||||||
|
test()
|
||||||
|
except AssertionError as e:
|
||||||
|
print(f"✗ {test.__name__}: {e}")
|
||||||
|
failed.append(test.__name__)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ {test.__name__}: Unexpected error: {e}")
|
||||||
|
failed.append(test.__name__)
|
||||||
|
|
||||||
|
print(f"\n{'='*50}")
|
||||||
|
if failed:
|
||||||
|
print(f"FAILED: {len(failed)} test(s) failed")
|
||||||
|
for name in failed:
|
||||||
|
print(f" - {name}")
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
print(f"SUCCESS: All {len(tests)} tests passed!")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
run_tests()
|
||||||
204
dashboard/test_habits_post.py
Normal file
204
dashboard/test_habits_post.py
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tests for POST /api/habits endpoint"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime
|
||||||
|
from http.server import HTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
from threading import Thread
|
||||||
|
from time import sleep
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
from urllib.error import HTTPError
|
||||||
|
|
||||||
|
# Add parent directory to path
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from dashboard.api import TaskBoardHandler
|
||||||
|
|
||||||
|
class TestHabitsPost(unittest.TestCase):
|
||||||
|
"""Test POST /api/habits endpoint"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
"""Start test server"""
|
||||||
|
# Create temp habits.json
|
||||||
|
cls.temp_dir = Path(tempfile.mkdtemp())
|
||||||
|
cls.habits_file = cls.temp_dir / 'habits.json'
|
||||||
|
cls.habits_file.write_text(json.dumps({
|
||||||
|
'lastUpdated': datetime.now().isoformat(),
|
||||||
|
'habits': []
|
||||||
|
}))
|
||||||
|
|
||||||
|
# Monkey-patch KANBAN_DIR
|
||||||
|
import dashboard.api as api_module
|
||||||
|
cls.original_kanban_dir = api_module.KANBAN_DIR
|
||||||
|
api_module.KANBAN_DIR = cls.temp_dir
|
||||||
|
|
||||||
|
# Start server
|
||||||
|
cls.port = 18088
|
||||||
|
cls.server = HTTPServer(('127.0.0.1', cls.port), TaskBoardHandler)
|
||||||
|
cls.thread = Thread(target=cls.server.serve_forever, daemon=True)
|
||||||
|
cls.thread.start()
|
||||||
|
sleep(0.5)
|
||||||
|
|
||||||
|
cls.base_url = f'http://127.0.0.1:{cls.port}'
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
"""Stop server and cleanup"""
|
||||||
|
cls.server.shutdown()
|
||||||
|
cls.thread.join(timeout=2)
|
||||||
|
|
||||||
|
# Restore KANBAN_DIR
|
||||||
|
import dashboard.api as api_module
|
||||||
|
api_module.KANBAN_DIR = cls.original_kanban_dir
|
||||||
|
|
||||||
|
# Cleanup temp dir
|
||||||
|
shutil.rmtree(cls.temp_dir)
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Reset habits.json before each test"""
|
||||||
|
self.habits_file.write_text(json.dumps({
|
||||||
|
'lastUpdated': datetime.now().isoformat(),
|
||||||
|
'habits': []
|
||||||
|
}))
|
||||||
|
|
||||||
|
def post_habit(self, data):
|
||||||
|
"""Helper to POST to /api/habits"""
|
||||||
|
url = f'{self.base_url}/api/habits'
|
||||||
|
req = Request(url, data=json.dumps(data).encode(), method='POST')
|
||||||
|
req.add_header('Content-Type', 'application/json')
|
||||||
|
return urlopen(req)
|
||||||
|
|
||||||
|
def test_create_habit_success(self):
|
||||||
|
"""Test creating a valid habit"""
|
||||||
|
data = {'name': 'Bazin', 'frequency': 'daily'}
|
||||||
|
resp = self.post_habit(data)
|
||||||
|
|
||||||
|
self.assertEqual(resp.status, 201)
|
||||||
|
|
||||||
|
result = json.loads(resp.read())
|
||||||
|
self.assertIn('id', result)
|
||||||
|
self.assertTrue(result['id'].startswith('habit-'))
|
||||||
|
self.assertEqual(result['name'], 'Bazin')
|
||||||
|
self.assertEqual(result['frequency'], 'daily')
|
||||||
|
self.assertIn('createdAt', result)
|
||||||
|
self.assertEqual(result['completions'], [])
|
||||||
|
|
||||||
|
# Verify ISO timestamp
|
||||||
|
datetime.fromisoformat(result['createdAt'])
|
||||||
|
|
||||||
|
def test_habit_persisted_to_file(self):
|
||||||
|
"""Test habit is written to habits.json"""
|
||||||
|
data = {'name': 'Sală', 'frequency': 'weekly'}
|
||||||
|
resp = self.post_habit(data)
|
||||||
|
habit = json.loads(resp.read())
|
||||||
|
|
||||||
|
# Read file
|
||||||
|
file_data = json.loads(self.habits_file.read_text())
|
||||||
|
self.assertEqual(len(file_data['habits']), 1)
|
||||||
|
self.assertEqual(file_data['habits'][0]['id'], habit['id'])
|
||||||
|
self.assertEqual(file_data['habits'][0]['name'], 'Sală')
|
||||||
|
|
||||||
|
def test_id_format_correct(self):
|
||||||
|
"""Test generated id follows 'habit-{timestamp}' format"""
|
||||||
|
data = {'name': 'Test', 'frequency': 'daily'}
|
||||||
|
resp = self.post_habit(data)
|
||||||
|
habit = json.loads(resp.read())
|
||||||
|
|
||||||
|
habit_id = habit['id']
|
||||||
|
self.assertTrue(habit_id.startswith('habit-'))
|
||||||
|
|
||||||
|
# Extract timestamp and verify it's numeric
|
||||||
|
timestamp_part = habit_id.replace('habit-', '')
|
||||||
|
self.assertTrue(timestamp_part.isdigit())
|
||||||
|
|
||||||
|
# Verify timestamp is reasonable (milliseconds since epoch)
|
||||||
|
timestamp_ms = int(timestamp_part)
|
||||||
|
now_ms = int(datetime.now().timestamp() * 1000)
|
||||||
|
# Should be within 5 seconds
|
||||||
|
self.assertLess(abs(now_ms - timestamp_ms), 5000)
|
||||||
|
|
||||||
|
def test_missing_name_returns_400(self):
|
||||||
|
"""Test missing name returns 400"""
|
||||||
|
data = {'frequency': 'daily'}
|
||||||
|
|
||||||
|
with self.assertRaises(HTTPError) as ctx:
|
||||||
|
self.post_habit(data)
|
||||||
|
|
||||||
|
self.assertEqual(ctx.exception.code, 400)
|
||||||
|
error = json.loads(ctx.exception.read())
|
||||||
|
self.assertIn('name', error['error'].lower())
|
||||||
|
|
||||||
|
def test_empty_name_returns_400(self):
|
||||||
|
"""Test empty name (whitespace only) returns 400"""
|
||||||
|
data = {'name': ' ', 'frequency': 'daily'}
|
||||||
|
|
||||||
|
with self.assertRaises(HTTPError) as ctx:
|
||||||
|
self.post_habit(data)
|
||||||
|
|
||||||
|
self.assertEqual(ctx.exception.code, 400)
|
||||||
|
|
||||||
|
def test_invalid_frequency_returns_400(self):
|
||||||
|
"""Test invalid frequency returns 400"""
|
||||||
|
data = {'name': 'Test', 'frequency': 'monthly'}
|
||||||
|
|
||||||
|
with self.assertRaises(HTTPError) as ctx:
|
||||||
|
self.post_habit(data)
|
||||||
|
|
||||||
|
self.assertEqual(ctx.exception.code, 400)
|
||||||
|
error = json.loads(ctx.exception.read())
|
||||||
|
self.assertIn('frequency', error['error'].lower())
|
||||||
|
|
||||||
|
def test_missing_frequency_returns_400(self):
|
||||||
|
"""Test missing frequency returns 400"""
|
||||||
|
data = {'name': 'Test'}
|
||||||
|
|
||||||
|
with self.assertRaises(HTTPError) as ctx:
|
||||||
|
self.post_habit(data)
|
||||||
|
|
||||||
|
self.assertEqual(ctx.exception.code, 400)
|
||||||
|
|
||||||
|
def test_multiple_habits_created(self):
|
||||||
|
"""Test creating multiple habits"""
|
||||||
|
habit1 = {'name': 'Bazin', 'frequency': 'daily'}
|
||||||
|
habit2 = {'name': 'Sală', 'frequency': 'weekly'}
|
||||||
|
|
||||||
|
resp1 = self.post_habit(habit1)
|
||||||
|
h1 = json.loads(resp1.read())
|
||||||
|
|
||||||
|
# Small delay to ensure different timestamp
|
||||||
|
sleep(0.01)
|
||||||
|
|
||||||
|
resp2 = self.post_habit(habit2)
|
||||||
|
h2 = json.loads(resp2.read())
|
||||||
|
|
||||||
|
# IDs should be different
|
||||||
|
self.assertNotEqual(h1['id'], h2['id'])
|
||||||
|
|
||||||
|
# Both should be in file
|
||||||
|
file_data = json.loads(self.habits_file.read_text())
|
||||||
|
self.assertEqual(len(file_data['habits']), 2)
|
||||||
|
|
||||||
|
def test_last_updated_timestamp(self):
|
||||||
|
"""Test lastUpdated is updated when creating habit"""
|
||||||
|
before = datetime.now().isoformat()
|
||||||
|
|
||||||
|
data = {'name': 'Test', 'frequency': 'daily'}
|
||||||
|
self.post_habit(data)
|
||||||
|
|
||||||
|
file_data = json.loads(self.habits_file.read_text())
|
||||||
|
last_updated = file_data['lastUpdated']
|
||||||
|
|
||||||
|
# Should be a valid ISO timestamp
|
||||||
|
datetime.fromisoformat(last_updated)
|
||||||
|
# Should be recent
|
||||||
|
self.assertGreaterEqual(last_updated, before)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
110
dashboard/test_habits_schema.py
Normal file
110
dashboard/test_habits_schema.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test suite for habits.json schema validation
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
def test_habits_file_exists():
|
||||||
|
"""Test that habits.json file exists"""
|
||||||
|
assert os.path.exists('dashboard/habits.json'), "habits.json should exist in dashboard/"
|
||||||
|
print("✓ habits.json file exists")
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_json():
|
||||||
|
"""Test that habits.json is valid JSON"""
|
||||||
|
try:
|
||||||
|
with open('dashboard/habits.json', 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
print("✓ habits.json is valid JSON")
|
||||||
|
return data
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise AssertionError(f"habits.json is not valid JSON: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_root_structure(data):
|
||||||
|
"""Test that root structure has required fields"""
|
||||||
|
assert 'lastUpdated' in data, "Root should have 'lastUpdated' field"
|
||||||
|
assert 'habits' in data, "Root should have 'habits' field"
|
||||||
|
print("✓ Root structure has lastUpdated and habits fields")
|
||||||
|
|
||||||
|
|
||||||
|
def test_last_updated_format(data):
|
||||||
|
"""Test that lastUpdated is a valid ISO timestamp"""
|
||||||
|
try:
|
||||||
|
datetime.fromisoformat(data['lastUpdated'].replace('Z', '+00:00'))
|
||||||
|
print("✓ lastUpdated is valid ISO timestamp")
|
||||||
|
except (ValueError, AttributeError) as e:
|
||||||
|
raise AssertionError(f"lastUpdated is not a valid ISO timestamp: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_habits_is_array(data):
|
||||||
|
"""Test that habits is an array"""
|
||||||
|
assert isinstance(data['habits'], list), "habits should be an array"
|
||||||
|
print("✓ habits is an array")
|
||||||
|
|
||||||
|
|
||||||
|
def test_habit_schema():
|
||||||
|
"""Test habit schema structure with sample data"""
|
||||||
|
# Sample habit to validate schema
|
||||||
|
sample_habit = {
|
||||||
|
"id": "habit-123",
|
||||||
|
"name": "Bazin",
|
||||||
|
"frequency": "daily",
|
||||||
|
"createdAt": "2026-02-10T10:57:00.000Z",
|
||||||
|
"completions": ["2026-02-10T10:00:00.000Z", "2026-02-09T10:00:00.000Z"]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
|
required_fields = ['id', 'name', 'frequency', 'createdAt', 'completions']
|
||||||
|
for field in required_fields:
|
||||||
|
assert field in sample_habit, f"Habit should have '{field}' field"
|
||||||
|
|
||||||
|
# Validate types
|
||||||
|
assert isinstance(sample_habit['id'], str), "id should be string"
|
||||||
|
assert isinstance(sample_habit['name'], str), "name should be string"
|
||||||
|
assert sample_habit['frequency'] in ['daily', 'weekly'], "frequency should be 'daily' or 'weekly'"
|
||||||
|
assert isinstance(sample_habit['completions'], list), "completions should be array"
|
||||||
|
|
||||||
|
# Validate ISO dates
|
||||||
|
datetime.fromisoformat(sample_habit['createdAt'].replace('Z', '+00:00'))
|
||||||
|
for completion in sample_habit['completions']:
|
||||||
|
datetime.fromisoformat(completion.replace('Z', '+00:00'))
|
||||||
|
|
||||||
|
print("✓ Habit schema structure is valid")
|
||||||
|
|
||||||
|
|
||||||
|
def test_initial_state(data):
|
||||||
|
"""Test that initial file has empty habits array"""
|
||||||
|
assert len(data['habits']) == 0, "Initial habits array should be empty"
|
||||||
|
print("✓ Initial habits array is empty")
|
||||||
|
|
||||||
|
|
||||||
|
def run_all_tests():
|
||||||
|
"""Run all schema validation tests"""
|
||||||
|
print("Running habits.json schema validation tests...\n")
|
||||||
|
|
||||||
|
try:
|
||||||
|
test_habits_file_exists()
|
||||||
|
data = test_valid_json()
|
||||||
|
test_root_structure(data)
|
||||||
|
test_last_updated_format(data)
|
||||||
|
test_habits_is_array(data)
|
||||||
|
test_habit_schema()
|
||||||
|
test_initial_state(data)
|
||||||
|
|
||||||
|
print("\n✅ All tests passed!")
|
||||||
|
return True
|
||||||
|
except AssertionError as e:
|
||||||
|
print(f"\n❌ Test failed: {e}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ Unexpected error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
success = run_all_tests()
|
||||||
|
exit(0 if success else 1)
|
||||||
179
dashboard/test_habits_streak.py
Normal file
179
dashboard/test_habits_streak.py
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Tests for streak calculation utility.
|
||||||
|
Story 4.0: Backend API - Streak calculation utility
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
# Add dashboard to path to import api module
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
|
|
||||||
|
from api import calculate_streak
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_completions():
|
||||||
|
"""Returns 0 for no completions"""
|
||||||
|
assert calculate_streak([], 'daily') == 0
|
||||||
|
assert calculate_streak([], 'weekly') == 0
|
||||||
|
print("✓ No completions returns 0")
|
||||||
|
|
||||||
|
|
||||||
|
def test_daily_single_completion_today():
|
||||||
|
"""Single completion today counts as streak of 1"""
|
||||||
|
today = datetime.now().isoformat()
|
||||||
|
assert calculate_streak([today], 'daily') == 1
|
||||||
|
print("✓ Daily: single completion today = streak 1")
|
||||||
|
|
||||||
|
|
||||||
|
def test_daily_single_completion_yesterday():
|
||||||
|
"""Single completion yesterday counts as streak of 1"""
|
||||||
|
yesterday = (datetime.now() - timedelta(days=1)).isoformat()
|
||||||
|
assert calculate_streak([yesterday], 'daily') == 1
|
||||||
|
print("✓ Daily: single completion yesterday = streak 1")
|
||||||
|
|
||||||
|
|
||||||
|
def test_daily_consecutive_days():
|
||||||
|
"""Multiple consecutive days count correctly"""
|
||||||
|
completions = [
|
||||||
|
(datetime.now() - timedelta(days=i)).isoformat()
|
||||||
|
for i in range(5) # Today, yesterday, 2 days ago, 3 days ago, 4 days ago
|
||||||
|
]
|
||||||
|
assert calculate_streak(completions, 'daily') == 5
|
||||||
|
print("✓ Daily: 5 consecutive days = streak 5")
|
||||||
|
|
||||||
|
|
||||||
|
def test_daily_broken_streak():
|
||||||
|
"""Gap in daily completions breaks streak"""
|
||||||
|
today = datetime.now()
|
||||||
|
completions = [
|
||||||
|
today.isoformat(),
|
||||||
|
(today - timedelta(days=1)).isoformat(),
|
||||||
|
# Gap here (day 2 missing)
|
||||||
|
(today - timedelta(days=3)).isoformat(),
|
||||||
|
(today - timedelta(days=4)).isoformat(),
|
||||||
|
]
|
||||||
|
# Should count only today and yesterday before the gap
|
||||||
|
assert calculate_streak(completions, 'daily') == 2
|
||||||
|
print("✓ Daily: gap breaks streak (counts only before gap)")
|
||||||
|
|
||||||
|
|
||||||
|
def test_daily_old_completion():
|
||||||
|
"""Completion more than 1 day ago returns 0"""
|
||||||
|
two_days_ago = (datetime.now() - timedelta(days=2)).isoformat()
|
||||||
|
assert calculate_streak([two_days_ago], 'daily') == 0
|
||||||
|
print("✓ Daily: completion >1 day ago = streak 0")
|
||||||
|
|
||||||
|
|
||||||
|
def test_weekly_single_completion_this_week():
|
||||||
|
"""Single completion this week counts as streak of 1"""
|
||||||
|
today = datetime.now().isoformat()
|
||||||
|
assert calculate_streak([today], 'weekly') == 1
|
||||||
|
print("✓ Weekly: single completion this week = streak 1")
|
||||||
|
|
||||||
|
|
||||||
|
def test_weekly_consecutive_weeks():
|
||||||
|
"""Multiple consecutive weeks count correctly"""
|
||||||
|
today = datetime.now()
|
||||||
|
completions = [
|
||||||
|
today.isoformat(),
|
||||||
|
(today - timedelta(days=7)).isoformat(),
|
||||||
|
(today - timedelta(days=14)).isoformat(),
|
||||||
|
(today - timedelta(days=21)).isoformat(),
|
||||||
|
]
|
||||||
|
assert calculate_streak(completions, 'weekly') == 4
|
||||||
|
print("✓ Weekly: 4 consecutive weeks = streak 4")
|
||||||
|
|
||||||
|
|
||||||
|
def test_weekly_broken_streak():
|
||||||
|
"""Missing week breaks streak"""
|
||||||
|
today = datetime.now()
|
||||||
|
completions = [
|
||||||
|
today.isoformat(),
|
||||||
|
(today - timedelta(days=7)).isoformat(),
|
||||||
|
# Gap here (week 2 missing)
|
||||||
|
(today - timedelta(days=21)).isoformat(),
|
||||||
|
]
|
||||||
|
# Should count only current week and last week before the gap
|
||||||
|
assert calculate_streak(completions, 'weekly') == 2
|
||||||
|
print("✓ Weekly: missing week breaks streak")
|
||||||
|
|
||||||
|
|
||||||
|
def test_weekly_old_completion():
|
||||||
|
"""Completion more than 7 days ago returns 0"""
|
||||||
|
eight_days_ago = (datetime.now() - timedelta(days=8)).isoformat()
|
||||||
|
assert calculate_streak([eight_days_ago], 'weekly') == 0
|
||||||
|
print("✓ Weekly: completion >7 days ago = streak 0")
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiple_completions_same_day():
|
||||||
|
"""Multiple completions on same day count as one"""
|
||||||
|
today = datetime.now()
|
||||||
|
completions = [
|
||||||
|
today.isoformat(),
|
||||||
|
(today - timedelta(hours=2)).isoformat(), # Same day, different time
|
||||||
|
(today - timedelta(days=1)).isoformat(),
|
||||||
|
]
|
||||||
|
assert calculate_streak(completions, 'daily') == 2
|
||||||
|
print("✓ Daily: multiple completions same day = 1 day")
|
||||||
|
|
||||||
|
|
||||||
|
def test_todays_completion_counts():
|
||||||
|
"""Today's completion counts even if yesterday was missed"""
|
||||||
|
today = datetime.now()
|
||||||
|
completions = [
|
||||||
|
today.isoformat(),
|
||||||
|
# Yesterday missing
|
||||||
|
(today - timedelta(days=2)).isoformat(),
|
||||||
|
]
|
||||||
|
# Should count only today (yesterday breaks the streak to previous days)
|
||||||
|
assert calculate_streak(completions, 'daily') == 1
|
||||||
|
print("✓ Daily: today counts even if yesterday missed")
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_date_format():
|
||||||
|
"""Invalid date format returns 0"""
|
||||||
|
assert calculate_streak(['not-a-date'], 'daily') == 0
|
||||||
|
assert calculate_streak(['2026-13-45'], 'daily') == 0
|
||||||
|
print("✓ Invalid date format returns 0")
|
||||||
|
|
||||||
|
|
||||||
|
def test_weekly_multiple_in_same_week():
|
||||||
|
"""Multiple completions in same week count as one week"""
|
||||||
|
today = datetime.now()
|
||||||
|
completions = [
|
||||||
|
today.isoformat(),
|
||||||
|
(today - timedelta(days=2)).isoformat(), # Same week
|
||||||
|
(today - timedelta(days=4)).isoformat(), # Same week
|
||||||
|
(today - timedelta(days=7)).isoformat(), # Previous week
|
||||||
|
]
|
||||||
|
assert calculate_streak(completions, 'weekly') == 2
|
||||||
|
print("✓ Weekly: multiple in same week = 1 week")
|
||||||
|
|
||||||
|
|
||||||
|
def run_all_tests():
|
||||||
|
"""Run all streak calculation tests"""
|
||||||
|
print("\n=== Testing Streak Calculation ===\n")
|
||||||
|
|
||||||
|
test_no_completions()
|
||||||
|
test_daily_single_completion_today()
|
||||||
|
test_daily_single_completion_yesterday()
|
||||||
|
test_daily_consecutive_days()
|
||||||
|
test_daily_broken_streak()
|
||||||
|
test_daily_old_completion()
|
||||||
|
test_weekly_single_completion_this_week()
|
||||||
|
test_weekly_consecutive_weeks()
|
||||||
|
test_weekly_broken_streak()
|
||||||
|
test_weekly_old_completion()
|
||||||
|
test_multiple_completions_same_day()
|
||||||
|
test_todays_completion_counts()
|
||||||
|
test_invalid_date_format()
|
||||||
|
test_weekly_multiple_in_same_week()
|
||||||
|
|
||||||
|
print("\n✓ All streak calculation tests passed!\n")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
run_all_tests()
|
||||||
460
progress.txt
Normal file
460
progress.txt
Normal file
@@ -0,0 +1,460 @@
|
|||||||
|
=== HABIT TRACKER FEATURE PROGRESS ===
|
||||||
|
|
||||||
|
Date: 2026-02-10
|
||||||
|
Branch: feature/habit-tracker
|
||||||
|
Repo: /home/moltbot/clawd
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
COMPLETED STORIES:
|
||||||
|
|
||||||
|
[✓] Story 1.0: Define habits.json data schema
|
||||||
|
Commit: ee8727a
|
||||||
|
Date: 2026-02-10
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
- Created dashboard/habits.json with proper schema
|
||||||
|
- Root structure: lastUpdated (ISO timestamp) + habits (array)
|
||||||
|
- Habit schema: id (string), name (string), frequency (daily/weekly),
|
||||||
|
createdAt (ISO), completions (array of ISO dates)
|
||||||
|
- Initial file contains empty habits array with current timestamp
|
||||||
|
- All JSON validation passes
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
- Created dashboard/test_habits_schema.py
|
||||||
|
- Tests for file existence, valid JSON, root structure
|
||||||
|
- Tests for lastUpdated ISO format validation
|
||||||
|
- Tests for habits array type
|
||||||
|
- Tests for complete habit schema (all required fields + types)
|
||||||
|
- Tests for initial empty state
|
||||||
|
- All tests pass ✓
|
||||||
|
|
||||||
|
Files modified:
|
||||||
|
- dashboard/habits.json (created)
|
||||||
|
- dashboard/test_habits_schema.py (created)
|
||||||
|
|
||||||
|
[✓] Story 2.0: Backend API - GET /api/habits
|
||||||
|
Commit: fc5ebf2
|
||||||
|
Date: 2026-02-10
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
- Added GET /api/habits endpoint to dashboard/api.py
|
||||||
|
- Endpoint returns habits array and lastUpdated timestamp
|
||||||
|
- Graceful error handling: returns empty array if file missing/corrupt
|
||||||
|
- Follows existing API patterns (similar to /api/git, /api/status)
|
||||||
|
- Returns 200 status for all valid requests
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
- Created dashboard/test_habits_api.py
|
||||||
|
- Tests for endpoint existence (returns 200)
|
||||||
|
- Tests for valid JSON response
|
||||||
|
- Tests for response structure (habits array + lastUpdated)
|
||||||
|
- Tests for ISO timestamp validation
|
||||||
|
- Tests for empty file handling (returns [], not error)
|
||||||
|
- Tests for habits with data
|
||||||
|
- All 6 tests pass ✓
|
||||||
|
|
||||||
|
Files modified:
|
||||||
|
- dashboard/api.py (added handle_habits_get method + route)
|
||||||
|
- dashboard/test_habits_api.py (created)
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
CODEBASE PATTERNS:
|
||||||
|
|
||||||
|
1. JSON Data Files
|
||||||
|
- Location: dashboard/*.json
|
||||||
|
- Pattern: Similar structure to tasks.json, todos.json, issues.json
|
||||||
|
- All use ISO timestamps for dates
|
||||||
|
- Root objects contain metadata + data arrays
|
||||||
|
|
||||||
|
2. Testing Approach
|
||||||
|
- Python test files in dashboard/ directory
|
||||||
|
- Test naming: test_*.py
|
||||||
|
- Comprehensive validation: existence, JSON validity, schema, types
|
||||||
|
- Run tests from repo root with: python3 dashboard/test_*.py
|
||||||
|
|
||||||
|
3. Build Validation
|
||||||
|
- Command: python3 -m py_compile dashboard/api.py
|
||||||
|
- Validates Python syntax without executing
|
||||||
|
|
||||||
|
4. Utility Functions in api.py
|
||||||
|
- Standalone utility functions placed before TaskBoardHandler class
|
||||||
|
- Documented with docstrings (Args, Returns, Rules/behavior)
|
||||||
|
- ISO timestamp parsing pattern: datetime.fromisoformat(ts.replace('Z', '+00:00'))
|
||||||
|
- Convert to date only when time doesn't matter: dt.date()
|
||||||
|
- Use try/except for robust parsing with sensible defaults (return 0, [], etc.)
|
||||||
|
|
||||||
|
[✓] Story 3.0: Backend API - POST /api/habits (create habit)
|
||||||
|
Commit: 3a09e6c
|
||||||
|
Date: 2026-02-10
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
- Added POST /api/habits endpoint to dashboard/api.py
|
||||||
|
- Accepts {name, frequency} in request body
|
||||||
|
- Returns 400 for missing name or empty name (after trim)
|
||||||
|
- Returns 400 for invalid frequency (must be 'daily' or 'weekly')
|
||||||
|
- Generates unique id following format 'habit-{millisecond_timestamp}'
|
||||||
|
- Sets createdAt to current ISO timestamp
|
||||||
|
- Initializes completions array as empty
|
||||||
|
- Persists new habit to habits.json
|
||||||
|
- Updates lastUpdated timestamp in habits.json
|
||||||
|
- Returns 201 status with created habit object
|
||||||
|
- Graceful handling of missing/corrupt habits.json file
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
- Created dashboard/test_habits_post.py
|
||||||
|
- Tests for successful habit creation (returns 201 + full habit object)
|
||||||
|
- Tests for habit persistence to habits.json
|
||||||
|
- Tests for correct id format (habit-{timestamp})
|
||||||
|
- Tests for missing name validation (400)
|
||||||
|
- Tests for empty name validation (400)
|
||||||
|
- Tests for invalid frequency validation (400)
|
||||||
|
- Tests for missing frequency validation (400)
|
||||||
|
- Tests for creating multiple habits (unique IDs)
|
||||||
|
- Tests for lastUpdated timestamp update
|
||||||
|
- All 9 tests pass ✓
|
||||||
|
|
||||||
|
Files modified:
|
||||||
|
- dashboard/api.py (added handle_habits_post method + route)
|
||||||
|
- dashboard/test_habits_post.py (created)
|
||||||
|
|
||||||
|
[✓] Story 4.0: Backend API - Streak calculation utility
|
||||||
|
Commit: 3927b7c
|
||||||
|
Date: 2026-02-10
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
- Created calculate_streak(completions, frequency) utility function
|
||||||
|
- Added to dashboard/api.py as standalone function (before TaskBoardHandler class)
|
||||||
|
- Accepts completions array (ISO timestamps) and frequency ('daily' or 'weekly')
|
||||||
|
- Returns integer streak count (days for daily, weeks for weekly)
|
||||||
|
- Daily habits: counts consecutive days without gaps
|
||||||
|
- Weekly habits: counts consecutive weeks (7-day periods)
|
||||||
|
- Returns 0 for no completions, invalid dates, or broken streaks
|
||||||
|
- Edge case: today's completion counts even if streak was 0 yesterday
|
||||||
|
- Edge case: multiple completions same day/week count as one period
|
||||||
|
- Robust date parsing with error handling for invalid ISO timestamps
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
- Created dashboard/test_habits_streak.py
|
||||||
|
- Tests for no completions (returns 0)
|
||||||
|
- Tests for daily single completion (today and yesterday)
|
||||||
|
- Tests for daily consecutive days (5 days streak)
|
||||||
|
- Tests for daily broken streak (gap detection)
|
||||||
|
- Tests for daily old completion (>1 day ago returns 0)
|
||||||
|
- Tests for weekly single completion (this week)
|
||||||
|
- Tests for weekly consecutive weeks (4 weeks streak)
|
||||||
|
- Tests for weekly broken streak (missing week)
|
||||||
|
- Tests for weekly old completion (>7 days ago returns 0)
|
||||||
|
- Tests for multiple completions same day (deduplicated)
|
||||||
|
- Tests for today counting despite yesterday missing
|
||||||
|
- Tests for invalid date format handling
|
||||||
|
- Tests for weekly multiple in same week (deduplicated)
|
||||||
|
- All 14 tests pass ✓
|
||||||
|
- All previous tests (schema, GET, POST) still pass ✓
|
||||||
|
|
||||||
|
Files modified:
|
||||||
|
- dashboard/api.py (added calculate_streak function)
|
||||||
|
- dashboard/test_habits_streak.py (created)
|
||||||
|
|
||||||
|
[✓] Story 5.0: Backend API - POST /api/habits/{id}/check
|
||||||
|
Commit: ca4ee77
|
||||||
|
Date: 2026-02-10
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
- Added POST /api/habits/{id}/check endpoint to dashboard/api.py
|
||||||
|
- Extracts habit ID from URL path (/api/habits/{id}/check)
|
||||||
|
- Adds today's date (YYYY-MM-DD) to completions array
|
||||||
|
- Returns 400 if habit already checked today
|
||||||
|
- Returns 404 if habit ID not found
|
||||||
|
- Sorts completions chronologically (oldest first) after adding
|
||||||
|
- Uses ISO date format YYYY-MM-DD (not full timestamps)
|
||||||
|
- Calculates and returns streak using calculate_streak utility
|
||||||
|
- Returns 200 with updated habit object including streak
|
||||||
|
- Streak is calculated but not persisted (only in response)
|
||||||
|
- Updates lastUpdated timestamp in habits.json
|
||||||
|
- Graceful error handling for missing/corrupt files
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
- Created dashboard/test_habits_check.py
|
||||||
|
- Tests for successful habit check (returns 200 + updated habit)
|
||||||
|
- Tests for already checked validation (400 error)
|
||||||
|
- Tests for habit not found (404 error)
|
||||||
|
- Tests for persistence to habits.json
|
||||||
|
- Tests for chronological sorting of completions
|
||||||
|
- Tests for streak calculation in response
|
||||||
|
- Tests for weekly habit checking
|
||||||
|
- Tests for ISO date format (YYYY-MM-DD, no time)
|
||||||
|
- All 8 tests pass ✓
|
||||||
|
- All previous tests (schema, GET, POST, streak) still pass ✓
|
||||||
|
|
||||||
|
Files modified:
|
||||||
|
- dashboard/api.py (added handle_habits_check method + route in do_POST)
|
||||||
|
- dashboard/test_habits_check.py (created)
|
||||||
|
- dashboard/habits.json (reset to empty for testing)
|
||||||
|
|
||||||
|
[✓] Story 6.0: Backend API - GET /api/habits with streaks
|
||||||
|
Commit: c84135d
|
||||||
|
Date: 2026-02-10
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
- Enhanced GET /api/habits endpoint to include calculated streaks
|
||||||
|
- Each habit in response now includes 'streak' field (integer)
|
||||||
|
- Each habit in response now includes 'checkedToday' field (boolean)
|
||||||
|
- Streak is calculated using the calculate_streak utility function
|
||||||
|
- checkedToday checks if today's date (YYYY-MM-DD) is in completions array
|
||||||
|
- All original habit fields are preserved in response
|
||||||
|
- Get today's date once and reuse for all habits (efficient)
|
||||||
|
- Enhanced habits array built by looping through each habit and adding fields
|
||||||
|
- Updated docstring to reflect new functionality
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
- Created dashboard/test_habits_get_enhanced.py
|
||||||
|
- Tests for streak field inclusion in response
|
||||||
|
- Tests for checkedToday boolean field inclusion
|
||||||
|
- Tests for correct streak calculation (daily and weekly habits)
|
||||||
|
- Tests for broken streaks (should return 0)
|
||||||
|
- Tests for empty habits array handling
|
||||||
|
- Tests for preservation of original habit fields
|
||||||
|
- All 5 tests pass ✓
|
||||||
|
- All previous tests (schema, GET, POST, streak, check) still pass ✓
|
||||||
|
|
||||||
|
Files modified:
|
||||||
|
- dashboard/api.py (enhanced handle_habits_get method)
|
||||||
|
- dashboard/test_habits_get_enhanced.py (created)
|
||||||
|
|
||||||
|
[✓] Story 7.0: Frontend - Create habits.html page structure
|
||||||
|
Commit: dd0bf24
|
||||||
|
Date: 2026-02-10
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
- Created dashboard/habits.html with basic layout matching dashboard style
|
||||||
|
- Uses common.css and swipe-nav.js for consistent styling and navigation
|
||||||
|
- Added navigation bar with 5 items (Dashboard, Workspace, KB, Files, Habits)
|
||||||
|
- Habits nav item has 'active' class to indicate current page
|
||||||
|
- Page header with title "Habit Tracker" and subtitle
|
||||||
|
- Empty state section with lucide 'target' icon
|
||||||
|
- Empty state message: "Nicio obișnuință încă. Creează prima!"
|
||||||
|
- Add habit button with lucide 'plus' icon and text "Adaugă obișnuință"
|
||||||
|
- Theme toggle functionality (dark/light mode) matching dashboard
|
||||||
|
- Placeholder JavaScript functions for future API integration
|
||||||
|
- HTML5 compliant structure with lang="ro" attribute
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
- Created dashboard/test_habits_html.py
|
||||||
|
- Tests for file existence
|
||||||
|
- Tests for valid HTML5 structure (DOCTYPE, required tags, lang attribute)
|
||||||
|
- Tests for common.css and swipe-nav.js inclusion
|
||||||
|
- Tests for navigation bar with correct items and active state
|
||||||
|
- Tests for page title "Habit Tracker" in both <title> and <h1>
|
||||||
|
- Tests for empty state message with exact text
|
||||||
|
- Tests for add habit button with lucide plus icon
|
||||||
|
- All 7 tests pass ✓
|
||||||
|
- All previous tests (schema, API endpoints) still pass ✓
|
||||||
|
|
||||||
|
Files modified:
|
||||||
|
- dashboard/habits.html (created)
|
||||||
|
- dashboard/test_habits_html.py (created)
|
||||||
|
- dashboard/habits.json (reset to empty for testing)
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
CODEBASE PATTERNS UPDATE:
|
||||||
|
|
||||||
|
5. Frontend HTML Pages
|
||||||
|
- Location: dashboard/*.html
|
||||||
|
- Common structure: DOCTYPE html, lang="ro", UTF-8 charset
|
||||||
|
- Shared resources: common.css, swipe-nav.js, lucide icons CDN
|
||||||
|
- Navigation pattern: header.header > logo + nav.nav > nav-item links
|
||||||
|
- Active nav item has 'active' class
|
||||||
|
- Theme toggle button in nav with onclick="toggleTheme()"
|
||||||
|
- Main content in <main class="main"> with max-width container
|
||||||
|
- Page header pattern: .page-header > .page-title + .page-subtitle
|
||||||
|
- Empty states: .empty-state with centered icon, message, and action button
|
||||||
|
- Icons: use lucide via data-lucide attribute, initialize with lucide.createIcons()
|
||||||
|
|
||||||
|
[✓] Story 8.0: Frontend - Create habit form modal
|
||||||
|
Commit: 97af2ae
|
||||||
|
Date: 2026-02-10
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
- Added modal HTML structure to habits.html with id='habitModal'
|
||||||
|
- Modal overlay uses dashboard styling patterns (overlay + modal container)
|
||||||
|
- Form includes text input for habit name (id='habitName', required)
|
||||||
|
- Form includes radio button group for frequency selection
|
||||||
|
- Two radio options: daily (default, checked) and weekly
|
||||||
|
- Custom styled radio buttons using .radio-group and .radio-label classes
|
||||||
|
- Radio buttons hidden, labels styled as clickable cards
|
||||||
|
- Selected radio shows accent background color
|
||||||
|
- Modal actions with Cancel (btn-secondary) and Create (btn-primary) buttons
|
||||||
|
- Cancel button calls hideHabitModal()
|
||||||
|
- Create button calls createHabit() and starts disabled
|
||||||
|
- Modal CSS follows dashboard patterns: modal-overlay, modal, modal-title, form-group, form-label, modal-actions
|
||||||
|
- Added toast notification element for user feedback
|
||||||
|
- JavaScript: showAddHabitModal() opens modal and resets form
|
||||||
|
- JavaScript: hideHabitModal() closes modal by removing 'active' class
|
||||||
|
- JavaScript: DOMContentLoaded event listener for form validation
|
||||||
|
- Input event listener on name field enables/disables Create button
|
||||||
|
- Create button disabled when name is empty (after trim)
|
||||||
|
- Enter key submits form if Create button is enabled
|
||||||
|
- createHabit() async function posts to /api/habits
|
||||||
|
- On success: hides modal, shows toast, calls loadHabits()
|
||||||
|
- On error: shows toast with error message
|
||||||
|
- showToast() function displays temporary notification (3 seconds)
|
||||||
|
- Modal uses CSS variables for theming (--bg-base, --border, --accent, etc.)
|
||||||
|
- Responsive design with max-width: 500px and 90% width
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
- Created dashboard/test_habits_modal.py
|
||||||
|
- Tests for modal structure (overlay, container, title)
|
||||||
|
- Tests for name input field with required indicator (*)
|
||||||
|
- Tests for frequency radio buttons (daily/weekly, daily checked by default)
|
||||||
|
- Tests for radio labels with correct Romanian text
|
||||||
|
- Tests for Cancel and Create buttons with correct classes and onclick handlers
|
||||||
|
- Tests for Create button starting disabled
|
||||||
|
- Tests for add habit button calling showAddHabitModal()
|
||||||
|
- Tests for modal CSS styling patterns
|
||||||
|
- Tests for JavaScript functions (showAddHabitModal, hideHabitModal, createHabit)
|
||||||
|
- Tests for form validation logic (disable button, trim validation)
|
||||||
|
- Tests for modal show/hide logic (classList.add/remove 'active')
|
||||||
|
- Tests for API integration (fetch /api/habits POST)
|
||||||
|
- Tests for toast notification element and function
|
||||||
|
- Tests for event listeners (DOMContentLoaded, input, keypress)
|
||||||
|
- All 9 tests pass ✓
|
||||||
|
- All previous tests (schema, API endpoints, HTML structure) still pass ✓
|
||||||
|
|
||||||
|
Files modified:
|
||||||
|
- dashboard/habits.html (added modal HTML, CSS, and JavaScript)
|
||||||
|
- dashboard/test_habits_modal.py (created)
|
||||||
|
|
||||||
|
[✓] Story 9.0: Frontend - Display habits list
|
||||||
|
Commit: 0483d73
|
||||||
|
Date: 2026-02-10
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
- Added CSS for habit cards (.habit-card, .habit-icon, .habit-info, .habit-streak)
|
||||||
|
- Added CSS for loading state with spinner animation
|
||||||
|
- Added CSS for error state with retry button
|
||||||
|
- Enhanced HTML with loading, error, and empty states (all with IDs)
|
||||||
|
- Implemented loadHabits() async function to fetch from /api/habits
|
||||||
|
- Habits sorted by streak descending (highest first)
|
||||||
|
- Loading state shown while fetching (loadingState.classList.add('active'))
|
||||||
|
- Error state shown on fetch failure with retry button
|
||||||
|
- Empty state shown when habits array is empty
|
||||||
|
- Habits list shown when habits exist
|
||||||
|
- Created createHabitCard(habit) function to render habit cards
|
||||||
|
- Daily habits show calendar icon (lucide), weekly show clock icon
|
||||||
|
- Each habit displays: name, frequency label, streak with 🔥 emoji
|
||||||
|
- Streak displayed as: "{number} 🔥" in accent color
|
||||||
|
- XSS protection via escapeHtml() function (uses textContent)
|
||||||
|
- Lucide icons reinitialized after rendering (lucide.createIcons())
|
||||||
|
- loadHabits() called on DOMContentLoaded (automatic page load)
|
||||||
|
- Habits data includes streak and checkedToday from API
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
- Created dashboard/test_habits_display.py
|
||||||
|
- Tests for loading state structure (element, class, icon, message)
|
||||||
|
- Tests for error state structure (element, class, icon, message, retry button)
|
||||||
|
- Tests for empty state ID attribute
|
||||||
|
- Tests for habits list container existence
|
||||||
|
- Tests for loadHabits function implementation and API fetch
|
||||||
|
- Tests for sorting by streak descending (b.streak - a.streak)
|
||||||
|
- Tests for frequency icons (calendar for daily, clock for weekly)
|
||||||
|
- Tests for streak display with flame emoji 🔥
|
||||||
|
- Tests for state management (show/hide logic)
|
||||||
|
- Tests for error handling (catch block)
|
||||||
|
- Tests for createHabitCard function existence
|
||||||
|
- Tests for page load trigger (DOMContentLoaded listener)
|
||||||
|
- Tests for habit card CSS styling
|
||||||
|
- Tests for Lucide icons reinitialization after rendering
|
||||||
|
- Tests for XSS protection (escapeHtml function)
|
||||||
|
- All 15 tests pass ✓
|
||||||
|
- All previous tests (schema, API, HTML, modal) still pass ✓
|
||||||
|
|
||||||
|
Files modified:
|
||||||
|
- dashboard/habits.html (added habit cards CSS, loading/error states HTML, loadHabits implementation)
|
||||||
|
- dashboard/test_habits_display.py (created)
|
||||||
|
|
||||||
|
[✓] Story 10.0: Frontend - Check habit interaction
|
||||||
|
Commit: 775f171
|
||||||
|
Date: 2026-02-10
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
- Added circular checkbox button to each habit card
|
||||||
|
- Checkbox positioned at start of card (before icon)
|
||||||
|
- Checkbox styled with border-radius: 50% for circular shape
|
||||||
|
- Checkbox shows check icon when checkedToday is true
|
||||||
|
- Checkbox has 'checked' and 'disabled' classes when already done today
|
||||||
|
- Clicking checkbox calls checkHabit(habitId, element) function
|
||||||
|
- checkHabit performs optimistic UI update (checks immediately)
|
||||||
|
- API call to POST /api/habits/{id}/check executed after UI update
|
||||||
|
- On success: streak element updated with response data, shows toast
|
||||||
|
- On error: checkbox reverts to unchecked state, shows error toast
|
||||||
|
- Checkbox is non-clickable (disabled) when already checked today
|
||||||
|
- Streak updates dynamically using id="streak-{habitId}" element
|
||||||
|
- Check icon reinitialized with lucide.createIcons() after adding
|
||||||
|
- Hover state for unchecked checkboxes (border-color change)
|
||||||
|
- All CSS uses CSS variables for theming consistency
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
- Created dashboard/test_habits_check_ui.py
|
||||||
|
- Tests for checkbox CSS (circular shape, checked/disabled states, hover)
|
||||||
|
- Tests for checkbox inclusion in createHabitCard function
|
||||||
|
- Tests for checkedToday state reflection in UI
|
||||||
|
- Tests for checkHabit function existence and signature
|
||||||
|
- Tests for API call to POST /api/habits/{id}/check
|
||||||
|
- Tests for optimistic UI update (classList.add before fetch)
|
||||||
|
- Tests for error handling and revert logic
|
||||||
|
- Tests for disabled state when already checked
|
||||||
|
- Tests for streak update from response data
|
||||||
|
- Tests for check icon display and lucide reinitialization
|
||||||
|
- All 10 tests pass ✓
|
||||||
|
- All previous tests (schema, API endpoints, HTML, modal, display) still pass ✓
|
||||||
|
|
||||||
|
Files modified:
|
||||||
|
- dashboard/habits.html (added checkbox CSS and checkHabit function)
|
||||||
|
- dashboard/test_habits_check_ui.py (created)
|
||||||
|
|
||||||
|
[✓] Story 11.0: Frontend - Create habit from form
|
||||||
|
Commit: 4933847
|
||||||
|
Date: 2026-02-10
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
- Enhanced createHabit() async function with complete form submission flow
|
||||||
|
- Added loading state: button disabled during submission with "Se creează..." text
|
||||||
|
- Button disabled immediately on submit (before API call)
|
||||||
|
- Original button text stored and restored on error
|
||||||
|
- Input field cleared after successful creation (nameInput.value = '')
|
||||||
|
- Success flow: closes modal, shows success toast, reloads habits list
|
||||||
|
- Error flow: button re-enabled, modal stays open, shows error toast
|
||||||
|
- Both API errors (response.ok check) and network errors (catch block) handled
|
||||||
|
- Error messages displayed to user via toast notifications
|
||||||
|
- Modal stays open on error so user can retry without re-entering data
|
||||||
|
- All existing form validation preserved (empty name check, trim validation)
|
||||||
|
- Enter key submission still works with loading state integration
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
- Created dashboard/test_habits_form_submit.py with 9 comprehensive tests
|
||||||
|
- Tests for form submission API call (POST /api/habits with name and frequency)
|
||||||
|
- Tests for loading state (button disabled, text changed to "Se creează...")
|
||||||
|
- Tests for success behavior (modal closes, list refreshes, input cleared)
|
||||||
|
- Tests for error behavior (modal stays open, button re-enabled, error shown)
|
||||||
|
- Tests for input field clearing after successful creation
|
||||||
|
- Tests for preservation of existing form validation logic
|
||||||
|
- Tests for modal reset when opened (form cleared)
|
||||||
|
- Tests for Enter key submission integration
|
||||||
|
- Tests for all 6 acceptance criteria in summary test
|
||||||
|
- All 9 tests pass ✓
|
||||||
|
- All previous tests (schema, API endpoints, HTML, modal, display, check) still pass ✓
|
||||||
|
|
||||||
|
Files modified:
|
||||||
|
- dashboard/habits.html (enhanced createHabit function)
|
||||||
|
- dashboard/test_habits_form_submit.py (created)
|
||||||
|
- dashboard/habits.json (reset to empty for testing)
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
NEXT STEPS:
|
||||||
|
- Continue with remaining 7 stories
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
Reference in New Issue
Block a user