Feature: Habit Tracker with Streak Calculation #1

Closed
Marius wants to merge 26 commits from feature/habit-tracker into master
28 changed files with 6190 additions and 26 deletions

1
antfarm Submodule

Submodule antfarm added at 2fff211502

View File

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

973
dashboard/habits.html Normal file
View File

@@ -0,0 +1,973 @@
<!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-lg);
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-card.checked {
background: rgba(34, 197, 94, 0.1);
}
.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 {
display: inline-block;
font-size: var(--text-xs);
color: var(--text-secondary);
background: var(--bg-elevated);
border: 1px solid var(--border);
padding: 2px 8px;
border-radius: var(--radius-sm);
text-transform: capitalize;
}
.habit-streak {
display: flex;
align-items: center;
gap: var(--space-1);
font-size: var(--text-xl);
font-weight: 600;
color: var(--accent);
flex-shrink: 0;
}
/* Delete button */
.habit-delete-btn {
width: 32px;
height: 32px;
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: var(--bg-base);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all var(--transition-fast);
flex-shrink: 0;
}
.habit-delete-btn:hover {
border-color: var(--text-danger);
background: rgba(239, 68, 68, 0.1);
}
.habit-delete-btn svg {
width: 16px;
height: 16px;
color: var(--text-muted);
}
.habit-delete-btn:hover svg {
color: var(--text-danger);
}
/* 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));
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
.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;
}
/* Delete confirmation modal */
.confirm-modal {
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--space-5);
width: 90%;
max-width: 400px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
}
.confirm-modal-title {
font-size: var(--text-lg);
font-weight: 600;
margin-bottom: var(--space-3);
color: var(--text-primary);
}
.confirm-modal-message {
font-size: var(--text-sm);
color: var(--text-secondary);
margin-bottom: var(--space-5);
}
.confirm-modal-actions {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
}
.btn-danger {
background: var(--text-danger);
color: white;
border: 1px solid var(--text-danger);
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
}
.btn-danger:hover {
background: #dc2626;
border-color: #dc2626;
}
.btn-danger:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Mobile responsiveness */
@media (max-width: 768px) {
.main {
padding: var(--space-3);
}
.habit-card {
width: 100%;
}
.habits-list {
width: 100%;
}
.habit-icon {
width: 36px;
height: 36px;
}
.habit-icon svg {
width: 18px;
height: 18px;
}
/* Touch targets >= 44x44px for accessibility */
.habit-checkbox {
width: 44px;
height: 44px;
}
.habit-checkbox svg {
width: 20px;
height: 20px;
}
/* Full-screen modal on mobile */
.modal {
width: 100%;
max-width: 100%;
height: 100vh;
max-height: 100vh;
border-radius: 0;
padding: var(--space-5);
}
/* Larger touch targets for buttons */
.add-habit-btn {
padding: var(--space-4) var(--space-5);
min-height: 44px;
}
.btn {
min-height: 44px;
padding: var(--space-3) var(--space-5);
}
/* Larger radio buttons for touch */
.radio-label {
padding: var(--space-4);
min-height: 44px;
}
}
</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="flame"></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..." autocapitalize="words" autocomplete="off">
</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>
<!-- Delete Confirmation Modal -->
<div class="modal-overlay" id="deleteModal">
<div class="confirm-modal">
<h2 class="confirm-modal-title">Ștergi obișnuința?</h2>
<p class="confirm-modal-message" id="deleteModalMessage">
Ștergi obișnuința <strong id="deleteHabitName"></strong>?
</p>
<div class="confirm-modal-actions">
<button class="btn btn-secondary" onclick="hideDeleteModal()">Anulează</button>
<button class="btn btn-danger" id="confirmDeleteBtn" onclick="confirmDelete()">Șterge</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('/echo/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('/echo/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');
const isChecked = habit.checkedToday || false;
card.className = isChecked ? 'habit-card checked' : 'habit-card';
// Determine icon based on frequency
const iconName = habit.frequency === 'daily' ? 'calendar' : 'clock';
// Checkbox state
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>
<button class="habit-delete-btn" onclick="showDeleteModal('${habit.id}', '${escapeHtml(habit.name).replace(/'/g, "&#39;")}')">
<i data-lucide="trash-2"></i>
</button>
`;
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();
// Add 'checked' class to parent card for green background
const card = checkboxElement.closest('.habit-card');
if (card) {
card.classList.add('checked');
}
// 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(`/echo/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 card background
const card = checkboxElement.closest('.habit-card');
if (card) {
card.classList.remove('checked');
}
// Revert streak
if (streakElement) {
streakElement.textContent = originalStreak;
}
showToast('Eroare la bifarea obișnuinței. Încearcă din nou.');
}
}
// Delete habit functions
let habitToDelete = null;
function showDeleteModal(habitId, habitName) {
habitToDelete = habitId;
const modal = document.getElementById('deleteModal');
const nameElement = document.getElementById('deleteHabitName');
// Decode HTML entities for display
const tempDiv = document.createElement('div');
tempDiv.innerHTML = habitName;
nameElement.textContent = tempDiv.textContent;
modal.classList.add('active');
}
function hideDeleteModal() {
const modal = document.getElementById('deleteModal');
modal.classList.remove('active');
habitToDelete = null;
}
async function confirmDelete() {
if (!habitToDelete) {
return;
}
const deleteBtn = document.getElementById('confirmDeleteBtn');
// Disable button during deletion
deleteBtn.disabled = true;
const originalText = deleteBtn.textContent;
deleteBtn.textContent = 'Se șterge...';
try {
const response = await fetch(`/echo/api/habits/${habitToDelete}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' }
});
if (!response.ok) {
throw new Error('Failed to delete habit');
}
hideDeleteModal();
showToast('Obișnuință ștearsă cu succes');
loadHabits();
} catch (error) {
console.error('Error deleting habit:', error);
showToast('Eroare la ștergerea obișnuinței. Încearcă din nou.');
// Re-enable button on error
deleteBtn.disabled = false;
deleteBtn.textContent = originalText;
}
}
// Load habits on page load
document.addEventListener('DOMContentLoaded', () => {
loadHabits();
});
</script>
</body>
</html>

14
dashboard/habits.json Normal file
View File

@@ -0,0 +1,14 @@
{
"habits": [
{
"id": "habit-test1",
"name": "Test Habit",
"frequency": "daily",
"createdAt": "2026-02-01T10:00:00Z",
"completions": [
"2026-02-10"
]
}
],
"lastUpdated": "2026-02-10T13:51:07.626599"
}

View File

@@ -1075,6 +1075,10 @@
<i data-lucide="folder"></i>
<span>Files</span>
</a>
<a href="/echo/habits.html" class="nav-item">
<i data-lucide="flame"></i>
<span>Habits</span>
</a>
<button class="theme-toggle" onclick="toggleTheme()" title="Schimbă tema">
<i data-lucide="sun" id="themeIcon"></i>
</button>
@@ -1600,10 +1604,34 @@
const msg = status.anaf.ok !== false ? 'Nicio modificare' : (status.anaf.message || 'Modificări!');
subtitle.textContent = `${msg} · ${lastCheck}`;
if (status.anaf.lastCheck) {
document.getElementById('anafLastCheck').textContent =
'Ultima verificare: ' + status.anaf.lastCheck;
// Actualizează detaliile
const details = document.getElementById('anafDetails');
let html = '';
// Adaugă detaliile modificărilor dacă există
if (status.anaf.changes && status.anaf.changes.length > 0) {
status.anaf.changes.forEach(change => {
const summaryText = change.summary && change.summary.length > 0
? ' - ' + change.summary.join(', ')
: '';
html += `
<div class="status-detail-item uncommitted">
<i data-lucide="alert-circle"></i>
<span><a href="${change.url}" target="_blank" style="color:var(--warning)">${change.name}</a>${summaryText}</span>
</div>
`;
});
} else {
html = `
<div class="status-detail-item">
<i data-lucide="check-circle"></i>
<span>Toate paginile sunt la zi</span>
</div>
`;
}
details.innerHTML = html;
lucide.createIcons();
}
return status;

View File

@@ -13,7 +13,16 @@
"ok": false,
"status": "MODIFICĂRI",
"message": "1 modificări detectate",
"lastCheck": "09 Feb 2026, 14:00",
"changesCount": 1
"lastCheck": "10 Feb 2026, 12:39",
"changesCount": 1,
"changes": [
{
"name": "Declarația 100 - Obligații de plată la bugetul de stat",
"url": "https://static.anaf.ro/static/10/Anaf/Declaratii_R/100.html",
"summary": [
"Soft A: 09.02.2026 → 10.02.2026"
]
}
]
}
}

View 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)

View File

@@ -0,0 +1,256 @@
#!/usr/bin/env python3
"""
Tests for Story 12.0: Frontend - Habit card styling
Tests styling enhancements for habit cards
"""
def test_file_exists():
"""Test that habits.html exists"""
import os
assert os.path.exists('dashboard/habits.html'), "habits.html file should exist"
print("✓ habits.html exists")
def test_card_border_radius():
"""Test that cards use --radius-lg border radius"""
with open('dashboard/habits.html', 'r') as f:
content = f.read()
# Check that habit-card has border-radius: var(--radius-lg)
assert 'border-radius: var(--radius-lg);' in content, "habit-card should use --radius-lg border radius"
# Check it's in the .habit-card CSS rule
habit_card_start = content.find('.habit-card {')
habit_card_end = content.find('}', habit_card_start)
habit_card_css = content[habit_card_start:habit_card_end]
assert 'border-radius: var(--radius-lg)' in habit_card_css, "habit-card should have --radius-lg in its CSS"
print("✓ Cards use --radius-lg border radius")
def test_streak_font_size():
"""Test that streak uses --text-xl font size"""
with open('dashboard/habits.html', 'r') as f:
content = f.read()
# Find .habit-streak CSS rule
streak_start = content.find('.habit-streak {')
assert streak_start > 0, ".habit-streak CSS rule should exist"
streak_end = content.find('}', streak_start)
streak_css = content[streak_start:streak_end]
# Check for font-size: var(--text-xl)
assert 'font-size: var(--text-xl)' in streak_css, "Streak should use --text-xl font size"
print("✓ Streak displayed prominently with --text-xl font size")
def test_checked_habit_background():
"""Test that checked habits have subtle green background tint"""
with open('dashboard/habits.html', 'r') as f:
content = f.read()
# Check for .habit-card.checked CSS rule
assert '.habit-card.checked' in content, "Should have .habit-card.checked CSS rule"
# Find the CSS rule
checked_start = content.find('.habit-card.checked {')
assert checked_start > 0, ".habit-card.checked CSS rule should exist"
checked_end = content.find('}', checked_start)
checked_css = content[checked_start:checked_end]
# Check for green background (using rgba with green color and low opacity)
assert 'background: rgba(34, 197, 94, 0.1)' in checked_css, "Checked cards should have green background tint"
# Check JavaScript adds 'checked' class to card
assert "card.classList.add('checked')" in content, "JavaScript should add 'checked' class to card"
print("✓ Checked habits have subtle green background tint")
def test_checkbox_pulse_animation():
"""Test that unchecked habits have subtle pulse animation on checkbox hover"""
with open('dashboard/habits.html', 'r') as f:
content = f.read()
# Check for animation on hover (not disabled)
hover_start = content.find('.habit-checkbox:hover:not(.disabled) {')
assert hover_start > 0, "Should have hover rule for unchecked checkboxes"
hover_end = content.find('}', hover_start)
hover_css = content[hover_start:hover_end]
# Check for pulse animation
assert 'animation: pulse' in hover_css, "Unchecked checkboxes should have pulse animation on hover"
# Check for @keyframes pulse definition
assert '@keyframes pulse' in content, "Should have pulse keyframes definition"
# Verify pulse animation scales element
keyframes_start = content.find('@keyframes pulse {')
keyframes_end = content.find('}', keyframes_start)
keyframes_css = content[keyframes_start:keyframes_end]
assert 'scale(' in keyframes_css, "Pulse animation should scale the element"
print("✓ Unchecked habits have subtle pulse animation on checkbox hover")
def test_frequency_badge_styling():
"""Test that frequency badge uses dashboard tag styling"""
with open('dashboard/habits.html', 'r') as f:
content = f.read()
# Find .habit-frequency CSS rule
freq_start = content.find('.habit-frequency {')
assert freq_start > 0, ".habit-frequency CSS rule should exist"
freq_end = content.find('}', freq_start)
freq_css = content[freq_start:freq_end]
# Check for tag-like styling
assert 'display: inline-block' in freq_css, "Frequency should be inline-block"
assert 'background: var(--bg-elevated)' in freq_css, "Frequency should use --bg-elevated"
assert 'border: 1px solid var(--border)' in freq_css, "Frequency should have border"
assert 'padding:' in freq_css, "Frequency should have padding"
assert 'border-radius:' in freq_css, "Frequency should have border-radius"
print("✓ Frequency badge uses dashboard tag styling")
def test_card_uses_css_variables():
"""Test that cards use --bg-surface with --border"""
with open('dashboard/habits.html', 'r') as f:
content = f.read()
# Find .habit-card CSS rule
card_start = content.find('.habit-card {')
assert card_start > 0, ".habit-card CSS rule should exist"
card_end = content.find('}', card_start)
card_css = content[card_start:card_end]
# Check for CSS variables
assert 'background: var(--bg-surface)' in card_css, "Cards should use --bg-surface"
assert 'border: 1px solid var(--border)' in card_css, "Cards should use --border"
print("✓ Cards use --bg-surface with --border")
def test_mobile_responsiveness():
"""Test that cards are responsive on mobile (full width < 768px)"""
with open('dashboard/habits.html', 'r') as f:
content = f.read()
# Check for media query
assert '@media (max-width: 768px)' in content, "Should have mobile media query"
# Find mobile media query
mobile_start = content.find('@media (max-width: 768px)')
assert mobile_start > 0, "Mobile media query should exist"
mobile_end = content.find('}', content.find('}', content.find('}', mobile_start) + 1) + 1)
mobile_css = content[mobile_start:mobile_end]
# Check for habit-card width
assert '.habit-card {' in mobile_css or 'habit-card' in mobile_css, "Mobile styles should target habit-card"
assert 'width: 100%' in mobile_css, "Cards should be full width on mobile"
# Check for reduced spacing
assert '.main {' in mobile_css, "Main container should have mobile styling"
print("✓ Responsive on mobile (full width < 768px)")
def test_checked_class_in_createHabitCard():
"""Test that createHabitCard adds 'checked' class to card"""
with open('dashboard/habits.html', 'r') as f:
content = f.read()
# Find createHabitCard function
func_start = content.find('function createHabitCard(habit) {')
assert func_start > 0, "createHabitCard function should exist"
func_end = content.find('return card;', func_start)
func_code = content[func_start:func_end]
# Check for checked class logic
assert "isChecked ? 'habit-card checked' : 'habit-card'" in func_code, "Should add 'checked' class to card when habit is checked"
print("✓ createHabitCard adds 'checked' class when appropriate")
def test_all_acceptance_criteria():
"""Summary test: verify all 7 acceptance criteria are met"""
with open('dashboard/habits.html', 'r') as f:
content = f.read()
criteria = []
# 1. Cards use --bg-surface with --border
card_start = content.find('.habit-card {')
card_end = content.find('}', card_start)
card_css = content[card_start:card_end]
if 'background: var(--bg-surface)' in card_css and 'border: 1px solid var(--border)' in card_css:
criteria.append("✓ Cards use --bg-surface with --border")
# 2. Streak displayed prominently with --text-xl
streak_start = content.find('.habit-streak {')
streak_end = content.find('}', streak_start)
streak_css = content[streak_start:streak_end]
if 'font-size: var(--text-xl)' in streak_css:
criteria.append("✓ Streak displayed prominently with --text-xl")
# 3. Checked habits have subtle green background tint
if '.habit-card.checked' in content and 'rgba(34, 197, 94, 0.1)' in content:
criteria.append("✓ Checked habits have subtle green background tint")
# 4. Unchecked habits have subtle pulse animation on checkbox hover
if 'animation: pulse' in content and '@keyframes pulse' in content:
criteria.append("✓ Unchecked habits have pulse animation on hover")
# 5. Frequency badge uses dashboard tag styling
freq_start = content.find('.habit-frequency {')
freq_end = content.find('}', freq_start)
freq_css = content[freq_start:freq_end]
if 'display: inline-block' in freq_css and 'background: var(--bg-elevated)' in freq_css:
criteria.append("✓ Frequency badge uses dashboard tag styling")
# 6. Cards have --radius-lg border radius
if 'border-radius: var(--radius-lg)' in card_css:
criteria.append("✓ Cards have --radius-lg border radius")
# 7. Responsive on mobile (full width < 768px)
if '@media (max-width: 768px)' in content and 'width: 100%' in content[content.find('@media (max-width: 768px)'):]:
criteria.append("✓ Responsive on mobile (full width < 768px)")
for criterion in criteria:
print(criterion)
assert len(criteria) == 7, f"Should meet all 7 acceptance criteria, met {len(criteria)}"
print(f"\n✓ All 7 acceptance criteria met!")
if __name__ == '__main__':
import os
os.chdir('/home/moltbot/clawd')
tests = [
test_file_exists,
test_card_uses_css_variables,
test_card_border_radius,
test_streak_font_size,
test_checked_habit_background,
test_checkbox_pulse_animation,
test_frequency_badge_styling,
test_mobile_responsiveness,
test_checked_class_in_createHabitCard,
test_all_acceptance_criteria
]
print("Running tests for Story 12.0: Frontend - Habit card styling\n")
for test in tests:
try:
test()
except AssertionError as e:
print(f"{test.__name__} failed: {e}")
exit(1)
except Exception as e:
print(f"{test.__name__} error: {e}")
exit(1)
print(f"\n{'='*60}")
print(f"All {len(tests)} tests passed! ✓")
print(f"{'='*60}")

View 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)

View 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())

View File

@@ -0,0 +1,203 @@
#!/usr/bin/env python3
"""
Tests for Story 15.0: Backend - Delete habit endpoint
Tests the DELETE /api/habits/{id} endpoint functionality.
"""
import json
import shutil
import sys
import tempfile
import unittest
from datetime import datetime
from http.server import HTTPServer
from pathlib import Path
from threading import Thread
from time import sleep
from urllib.request import Request, urlopen
from urllib.error import HTTPError
# Add parent directory to path
sys.path.insert(0, str(Path(__file__).parent.parent))
from dashboard.api import TaskBoardHandler
class TestHabitsDelete(unittest.TestCase):
"""Test DELETE /api/habits/{id} endpoint"""
@classmethod
def setUpClass(cls):
"""Start test server"""
# Create temp habits.json
cls.temp_dir = Path(tempfile.mkdtemp())
cls.habits_file = cls.temp_dir / 'habits.json'
cls.habits_file.write_text(json.dumps({
'lastUpdated': datetime.now().isoformat(),
'habits': []
}))
# Monkey-patch KANBAN_DIR to use temp directory
import dashboard.api as api_module
cls.original_kanban_dir = api_module.KANBAN_DIR
api_module.KANBAN_DIR = cls.temp_dir
# Start server in background thread
cls.port = 9022
cls.server = HTTPServer(('127.0.0.1', cls.port), TaskBoardHandler)
cls.server_thread = Thread(target=cls.server.serve_forever, daemon=True)
cls.server_thread.start()
sleep(0.5) # Wait for server to start
@classmethod
def tearDownClass(cls):
"""Stop server and cleanup"""
cls.server.shutdown()
# Restore original KANBAN_DIR
import dashboard.api as api_module
api_module.KANBAN_DIR = cls.original_kanban_dir
# Cleanup temp directory
shutil.rmtree(cls.temp_dir)
def setUp(self):
"""Reset habits file before each test"""
self.habits_file.write_text(json.dumps({
'lastUpdated': datetime.now().isoformat(),
'habits': []
}))
def api_call(self, method, path, body=None):
"""Make API call and return (status, data)"""
url = f'http://127.0.0.1:{self.port}{path}'
headers = {'Content-Type': 'application/json'}
if body:
data = json.dumps(body).encode('utf-8')
req = Request(url, data=data, headers=headers, method=method)
else:
req = Request(url, headers=headers, method=method)
try:
with urlopen(req) as response:
return response.status, json.loads(response.read().decode('utf-8'))
except HTTPError as e:
return e.code, json.loads(e.read().decode('utf-8'))
def test_01_delete_removes_habit_from_file(self):
"""AC1: DELETE /api/habits/{id} removes habit from habits.json"""
# Create two habits
_, habit1 = self.api_call('POST', '/api/habits', {'name': 'Habit 1', 'frequency': 'daily'})
_, habit2 = self.api_call('POST', '/api/habits', {'name': 'Habit 2', 'frequency': 'weekly'})
habit1_id = habit1['id']
habit2_id = habit2['id']
# Delete first habit
status, _ = self.api_call('DELETE', f'/api/habits/{habit1_id}')
self.assertEqual(status, 200)
# Verify it's removed from file
data = json.loads(self.habits_file.read_text())
remaining_ids = [h['id'] for h in data['habits']]
self.assertNotIn(habit1_id, remaining_ids, "Deleted habit still in file")
self.assertIn(habit2_id, remaining_ids, "Other habit was incorrectly deleted")
self.assertEqual(len(data['habits']), 1, "Should have exactly 1 habit remaining")
def test_02_returns_200_with_success_message(self):
"""AC2: Returns 200 with success message"""
# Create a habit
_, habit = self.api_call('POST', '/api/habits', {'name': 'Test Habit', 'frequency': 'daily'})
habit_id = habit['id']
# Delete it
status, response = self.api_call('DELETE', f'/api/habits/{habit_id}')
self.assertEqual(status, 200)
self.assertTrue(response.get('success'), "Response should have success=true")
self.assertIn('message', response, "Response should contain message field")
self.assertEqual(response.get('id'), habit_id, "Response should contain habit ID")
def test_03_returns_404_if_not_found(self):
"""AC3: Returns 404 if habit not found"""
status, response = self.api_call('DELETE', '/api/habits/nonexistent-id')
self.assertEqual(status, 404)
self.assertIn('error', response, "Response should contain error message")
def test_04_updates_lastUpdated_timestamp(self):
"""AC4: Updates lastUpdated timestamp"""
# Get initial timestamp
data_before = json.loads(self.habits_file.read_text())
timestamp_before = data_before['lastUpdated']
sleep(0.1) # Ensure timestamp difference
# Create and delete a habit
_, habit = self.api_call('POST', '/api/habits', {'name': 'Test', 'frequency': 'daily'})
self.api_call('DELETE', f'/api/habits/{habit["id"]}')
# Check timestamp was updated
data_after = json.loads(self.habits_file.read_text())
timestamp_after = data_after['lastUpdated']
self.assertNotEqual(timestamp_after, timestamp_before, "Timestamp should be updated")
# Verify it's a valid ISO timestamp
try:
datetime.fromisoformat(timestamp_after.replace('Z', '+00:00'))
except ValueError:
self.fail(f"Invalid ISO timestamp: {timestamp_after}")
def test_05_edge_cases(self):
"""AC5: Tests for delete endpoint edge cases"""
# Create a habit
_, habit = self.api_call('POST', '/api/habits', {'name': 'Test', 'frequency': 'daily'})
habit_id = habit['id']
# Delete it
status, _ = self.api_call('DELETE', f'/api/habits/{habit_id}')
self.assertEqual(status, 200)
# Try to delete again (should return 404)
status, _ = self.api_call('DELETE', f'/api/habits/{habit_id}')
self.assertEqual(status, 404, "Should return 404 for already deleted habit")
# Test invalid path (trailing slash)
status, _ = self.api_call('DELETE', '/api/habits/')
self.assertEqual(status, 404, "Should return 404 for invalid path")
def test_06_missing_file_handling(self):
"""Test graceful handling when habits.json is missing"""
# Remove file
self.habits_file.unlink()
# Try to delete
status, response = self.api_call('DELETE', '/api/habits/some-id')
self.assertEqual(status, 404)
self.assertIn('error', response)
# Restore file for cleanup
self.habits_file.write_text(json.dumps({
'lastUpdated': datetime.now().isoformat(),
'habits': []
}))
if __name__ == '__main__':
# Run tests
suite = unittest.TestLoader().loadTestsFromTestCase(TestHabitsDelete)
runner = unittest.TextTestRunner(verbosity=2)
result = runner.run(suite)
# Print summary
if result.wasSuccessful():
print("\n✅ All Story 15.0 acceptance criteria verified:")
print(" 1. DELETE /api/habits/{id} removes habit from habits.json ✓")
print(" 2. Returns 200 with success message ✓")
print(" 3. Returns 404 if habit not found ✓")
print(" 4. Updates lastUpdated timestamp ✓")
print(" 5. Tests for delete endpoint pass ✓")
sys.exit(0 if result.wasSuccessful() else 1)

View File

@@ -0,0 +1,274 @@
#!/usr/bin/env python3
"""
Test suite for Story 16.0: Frontend - Delete habit with confirmation
Tests the delete button and confirmation modal functionality.
"""
import re
def test_file_exists():
"""Test that habits.html exists"""
try:
with open('dashboard/habits.html', 'r', encoding='utf-8') as f:
content = f.read()
return True
except FileNotFoundError:
print("FAIL: habits.html not found")
return False
def test_delete_button_css():
"""AC1: Tests delete button styling (trash icon button using lucide)"""
with open('dashboard/habits.html', 'r', encoding='utf-8') as f:
content = f.read()
# Check for delete button CSS class
if '.habit-delete-btn' not in content:
print("FAIL: .habit-delete-btn CSS class not found")
return False
# Check for proper styling (size, border, hover state)
css_pattern = r'\.habit-delete-btn\s*\{[^}]*width:\s*32px[^}]*height:\s*32px'
if not re.search(css_pattern, content, re.DOTALL):
print("FAIL: Delete button sizing not found (32x32px)")
return False
# Check for hover state with danger color
if '.habit-delete-btn:hover' not in content:
print("FAIL: Delete button hover state not found")
return False
if 'var(--text-danger)' not in content:
print("FAIL: Danger color not used for delete button")
return False
return True
def test_delete_button_in_card():
"""AC1: Tests that habit card includes trash icon button"""
with open('dashboard/habits.html', 'r', encoding='utf-8') as f:
content = f.read()
# Check for trash-2 icon (lucide) in createHabitCard
if 'trash-2' not in content:
print("FAIL: trash-2 icon not found")
return False
# Check for delete button in card HTML with onclick handler
pattern = r'habit-delete-btn.*onclick.*showDeleteModal'
if not re.search(pattern, content, re.DOTALL):
print("FAIL: Delete button with onclick handler not found in card")
return False
return True
def test_confirmation_modal_structure():
"""AC2: Tests confirmation modal 'Ștergi obișnuința {name}?'"""
with open('dashboard/habits.html', 'r', encoding='utf-8') as f:
content = f.read()
# Check for delete modal element
if 'id="deleteModal"' not in content:
print("FAIL: deleteModal element not found")
return False
# Check for Romanian confirmation message
if 'Ștergi obișnuința' not in content:
print("FAIL: Romanian confirmation message not found")
return False
# Check for habit name placeholder
if 'id="deleteHabitName"' not in content:
print("FAIL: deleteHabitName element for dynamic habit name not found")
return False
return True
def test_confirmation_buttons():
"""AC3 & AC4: Tests Cancel and Delete buttons with correct styling"""
with open('dashboard/habits.html', 'r', encoding='utf-8') as f:
content = f.read()
# Check for Cancel button
if 'onclick="hideDeleteModal()"' not in content:
print("FAIL: Cancel button with hideDeleteModal() not found")
return False
# Check for Delete button
if 'onclick="confirmDelete()"' not in content:
print("FAIL: Delete button with confirmDelete() not found")
return False
# AC4: Check for destructive red styling (btn-danger class)
if '.btn-danger' not in content:
print("FAIL: .btn-danger CSS class not found")
return False
# Check that btn-danger uses danger color
css_pattern = r'\.btn-danger\s*\{[^}]*background:\s*var\(--text-danger\)'
if not re.search(css_pattern, content, re.DOTALL):
print("FAIL: btn-danger does not use danger color")
return False
return True
def test_delete_api_call():
"""AC5: Tests DELETE API call and list removal on confirm"""
with open('dashboard/habits.html', 'r', encoding='utf-8') as f:
content = f.read()
# Check for confirmDelete function
if 'async function confirmDelete()' not in content:
print("FAIL: confirmDelete async function not found")
return False
# Check for DELETE method call to API
pattern = r"method:\s*['\"]DELETE['\"]"
if not re.search(pattern, content):
print("FAIL: DELETE method not found in confirmDelete")
return False
# Check for API endpoint with habitToDelete variable
pattern = r"/api/habits/\$\{habitToDelete\}"
if not re.search(pattern, content):
print("FAIL: DELETE endpoint /api/habits/{id} not found")
return False
# Check for loadHabits() call after successful deletion (removes from list)
if 'loadHabits()' not in content:
print("FAIL: loadHabits() not called after deletion (list won't refresh)")
return False
return True
def test_error_handling():
"""AC6: Tests error message display if delete fails"""
with open('dashboard/habits.html', 'r', encoding='utf-8') as f:
content = f.read()
# Check for error handling in confirmDelete
pattern = r'catch\s*\(error\)\s*\{[^}]*showToast'
if not re.search(pattern, content, re.DOTALL):
print("FAIL: Error handling with showToast not found in confirmDelete")
return False
# Check for error message
if 'Eroare la ștergerea obișnuinței' not in content:
print("FAIL: Delete error message not found")
return False
return True
def test_modal_functions():
"""Tests showDeleteModal and hideDeleteModal functions"""
with open('dashboard/habits.html', 'r', encoding='utf-8') as f:
content = f.read()
# Check for showDeleteModal function
if 'function showDeleteModal(' not in content:
print("FAIL: showDeleteModal function not found")
return False
# Check for hideDeleteModal function
if 'function hideDeleteModal(' not in content:
print("FAIL: hideDeleteModal function not found")
return False
# Check for habitToDelete variable tracking
if 'habitToDelete' not in content:
print("FAIL: habitToDelete tracking variable not found")
return False
return True
def test_modal_show_hide_logic():
"""Tests modal active class toggle for show/hide"""
with open('dashboard/habits.html', 'r', encoding='utf-8') as f:
content = f.read()
# Check for classList.add('active') in showDeleteModal
pattern = r'showDeleteModal[^}]*classList\.add\(["\']active["\']\)'
if not re.search(pattern, content, re.DOTALL):
print("FAIL: Modal show logic (classList.add('active')) not found")
return False
# Check for classList.remove('active') in hideDeleteModal
pattern = r'hideDeleteModal[^}]*classList\.remove\(["\']active["\']\)'
if not re.search(pattern, content, re.DOTALL):
print("FAIL: Modal hide logic (classList.remove('active')) not found")
return False
return True
def test_acceptance_criteria_summary():
"""AC7: Summary test verifying all acceptance criteria"""
with open('dashboard/habits.html', 'r', encoding='utf-8') as f:
content = f.read()
checks = {
'AC1: Trash icon button': 'trash-2' in content and '.habit-delete-btn' in content,
'AC2: Confirmation modal message': 'Ștergi obișnuința' in content and 'id="deleteHabitName"' in content,
'AC3: Cancel and Delete buttons': 'hideDeleteModal()' in content and 'confirmDelete()' in content,
'AC4: Red destructive style': '.btn-danger' in content and 'var(--text-danger)' in content,
'AC5: DELETE endpoint call': 'method:' in content and 'DELETE' in content and '/api/habits/' in content,
'AC6: Error handling': 'catch (error)' in content and 'Eroare la ștergerea' in content,
'AC7: Delete interaction tests pass': True # This test itself
}
all_passed = all(checks.values())
if not all_passed:
print("FAIL: Not all acceptance criteria met:")
for criterion, passed in checks.items():
if not passed:
print(f"{criterion}")
return all_passed
if __name__ == '__main__':
tests = [
("File exists", test_file_exists),
("Delete button CSS styling", test_delete_button_css),
("Delete button in habit card (trash icon)", test_delete_button_in_card),
("Confirmation modal structure", test_confirmation_modal_structure),
("Confirmation buttons (Cancel & Delete)", test_confirmation_buttons),
("DELETE API call on confirm", test_delete_api_call),
("Error handling for failed delete", test_error_handling),
("Modal show/hide functions", test_modal_functions),
("Modal active class toggle logic", test_modal_show_hide_logic),
("All acceptance criteria summary", test_acceptance_criteria_summary),
]
passed = 0
total = len(tests)
print("Running Story 16.0 tests (Frontend - Delete habit with confirmation)...\n")
for name, test_func in tests:
try:
result = test_func()
if result:
print(f"{name}")
passed += 1
else:
print(f"{name}")
except Exception as e:
print(f"{name} (exception: {e})")
print(f"\n{passed}/{total} tests passed")
if passed == total:
print("✓ All tests passed!")
exit(0)
else:
print(f"{total - passed} test(s) failed")
exit(1)

View 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)

View 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)

View 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)

View 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)

View File

@@ -0,0 +1,279 @@
#!/usr/bin/env python3
"""
Integration test for complete habit lifecycle.
Tests: create, check multiple days, view streak, delete
"""
import json
import os
import sys
import time
from datetime import datetime, timedelta
from http.server import HTTPServer
import threading
import urllib.request
import urllib.error
# Add parent directory to path for imports
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from api import TaskBoardHandler
# Test configuration
PORT = 8765
BASE_URL = f"http://localhost:{PORT}"
HABITS_FILE = os.path.join(os.path.dirname(__file__), 'habits.json')
# Global server instance
server = None
server_thread = None
def setup_server():
"""Start test server in background thread"""
global server, server_thread
server = HTTPServer(('localhost', PORT), TaskBoardHandler)
server_thread = threading.Thread(target=server.serve_forever, daemon=True)
server_thread.start()
time.sleep(0.5) # Give server time to start
def teardown_server():
"""Stop test server"""
global server
if server:
server.shutdown()
server.server_close()
def reset_habits_file():
"""Reset habits.json to empty state"""
data = {
"lastUpdated": datetime.utcnow().isoformat() + 'Z',
"habits": []
}
with open(HABITS_FILE, 'w') as f:
json.dump(data, f, indent=2)
def make_request(method, path, body=None):
"""Make HTTP request to test server"""
url = BASE_URL + path
headers = {'Content-Type': 'application/json'} if body else {}
data = json.dumps(body).encode('utf-8') if body else None
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req) as response:
response_data = response.read().decode('utf-8')
return response.status, json.loads(response_data) if response_data else None
except urllib.error.HTTPError as e:
response_data = e.read().decode('utf-8')
return e.code, json.loads(response_data) if response_data else None
def get_today():
"""Get today's date in YYYY-MM-DD format"""
return datetime.utcnow().strftime('%Y-%m-%d')
def get_yesterday():
"""Get yesterday's date in YYYY-MM-DD format"""
return (datetime.utcnow() - timedelta(days=1)).strftime('%Y-%m-%d')
def test_complete_habit_lifecycle():
"""
Integration test: Complete habit flow from creation to deletion
"""
print("\n=== Integration Test: Complete Habit Lifecycle ===\n")
# Step 1: Create daily habit 'Bazin'
print("Step 1: Creating daily habit 'Bazin'...")
status, response = make_request('POST', '/api/habits', {
'name': 'Bazin',
'frequency': 'daily'
})
assert status == 201, f"Expected 201, got {status}"
assert response['name'] == 'Bazin', "Habit name mismatch"
assert response['frequency'] == 'daily', "Frequency mismatch"
assert 'id' in response, "Missing habit ID"
habit_id = response['id']
print(f"✓ Created habit: {habit_id}")
# Step 2: Check it today (streak should be 1)
print("\nStep 2: Checking habit today (expecting streak = 1)...")
status, response = make_request('POST', f'/api/habits/{habit_id}/check', {})
assert status == 200, f"Expected 200, got {status}"
assert response['streak'] == 1, f"Expected streak=1, got {response['streak']}"
assert get_today() in response['completions'], "Today's date not in completions"
print(f"✓ Checked today, streak = {response['streak']}")
# Step 3: Simulate checking yesterday (manually add to completions)
print("\nStep 3: Simulating yesterday's check (expecting streak = 2)...")
# Read current habits.json
with open(HABITS_FILE, 'r') as f:
data = json.load(f)
# Find the habit and add yesterday's date
for habit in data['habits']:
if habit['id'] == habit_id:
habit['completions'].append(get_yesterday())
habit['completions'].sort() # Keep chronological order
break
# Write back to file
data['lastUpdated'] = datetime.utcnow().isoformat() + 'Z'
with open(HABITS_FILE, 'w') as f:
json.dump(data, f, indent=2)
# Verify streak calculation by fetching habits
status, response = make_request('GET', '/api/habits', None)
assert status == 200, f"Expected 200, got {status}"
habit = next((h for h in response['habits'] if h['id'] == habit_id), None)
assert habit is not None, "Habit not found in response"
assert habit['streak'] == 2, f"Expected streak=2 after adding yesterday, got {habit['streak']}"
print(f"✓ Added yesterday's completion, streak = {habit['streak']}")
# Step 4: Verify streak calculation is correct
print("\nStep 4: Verifying streak calculation...")
assert len(habit['completions']) == 2, f"Expected 2 completions, got {len(habit['completions'])}"
assert get_yesterday() in habit['completions'], "Yesterday not in completions"
assert get_today() in habit['completions'], "Today not in completions"
assert habit['checkedToday'] == True, "checkedToday should be True"
print("✓ Streak calculation verified: 2 consecutive days")
# Step 5: Delete habit successfully
print("\nStep 5: Deleting habit...")
status, response = make_request('DELETE', f'/api/habits/{habit_id}', None)
assert status == 200, f"Expected 200, got {status}"
assert 'message' in response, "Missing success message"
print(f"✓ Deleted habit: {response['message']}")
# Verify habit is gone
status, response = make_request('GET', '/api/habits', None)
assert status == 200, f"Expected 200, got {status}"
habit = next((h for h in response['habits'] if h['id'] == habit_id), None)
assert habit is None, "Habit still exists after deletion"
print("✓ Verified habit no longer exists")
print("\n=== All Integration Tests Passed ✓ ===\n")
def test_broken_streak():
"""
Additional test: Verify broken streak returns 0 (with gap)
"""
print("\n=== Additional Test: Broken Streak (with gap) ===\n")
# Create habit
status, response = make_request('POST', '/api/habits', {
'name': 'Sală',
'frequency': 'daily'
})
assert status == 201
habit_id = response['id']
print(f"✓ Created habit: {habit_id}")
# Add check from 3 days ago (creating a gap)
three_days_ago = (datetime.utcnow() - timedelta(days=3)).strftime('%Y-%m-%d')
with open(HABITS_FILE, 'r') as f:
data = json.load(f)
for habit in data['habits']:
if habit['id'] == habit_id:
habit['completions'].append(three_days_ago)
break
data['lastUpdated'] = datetime.utcnow().isoformat() + 'Z'
with open(HABITS_FILE, 'w') as f:
json.dump(data, f, indent=2)
# Verify streak is 0 (>1 day gap means broken streak)
status, response = make_request('GET', '/api/habits', None)
habit = next((h for h in response['habits'] if h['id'] == habit_id), None)
assert habit['streak'] == 0, f"Expected streak=0 for broken streak, got {habit['streak']}"
print(f"✓ Broken streak (>1 day gap) correctly returns 0")
# Cleanup
make_request('DELETE', f'/api/habits/{habit_id}', None)
print("✓ Cleanup complete")
def test_weekly_habit_streak():
"""
Additional test: Weekly habit streak calculation
"""
print("\n=== Additional Test: Weekly Habit Streak ===\n")
# Create weekly habit
status, response = make_request('POST', '/api/habits', {
'name': 'Yoga',
'frequency': 'weekly'
})
assert status == 201
habit_id = response['id']
print(f"✓ Created weekly habit: {habit_id}")
# Check today (streak = 1 week)
status, response = make_request('POST', f'/api/habits/{habit_id}/check', {})
assert status == 200
assert response['streak'] == 1, f"Expected streak=1 week, got {response['streak']}"
print(f"✓ Checked today, weekly streak = {response['streak']}")
# Add check from 8 days ago (last week)
eight_days_ago = (datetime.utcnow() - timedelta(days=8)).strftime('%Y-%m-%d')
with open(HABITS_FILE, 'r') as f:
data = json.load(f)
for habit in data['habits']:
if habit['id'] == habit_id:
habit['completions'].append(eight_days_ago)
habit['completions'].sort()
break
data['lastUpdated'] = datetime.utcnow().isoformat() + 'Z'
with open(HABITS_FILE, 'w') as f:
json.dump(data, f, indent=2)
# Verify streak is 2 weeks
status, response = make_request('GET', '/api/habits', None)
habit = next((h for h in response['habits'] if h['id'] == habit_id), None)
assert habit['streak'] == 2, f"Expected streak=2 weeks, got {habit['streak']}"
print(f"✓ Weekly streak calculation correct: {habit['streak']} weeks")
# Cleanup
make_request('DELETE', f'/api/habits/{habit_id}', None)
print("✓ Cleanup complete")
if __name__ == '__main__':
try:
# Setup
reset_habits_file()
setup_server()
# Run tests
test_complete_habit_lifecycle()
test_broken_streak()
test_weekly_habit_streak()
print("\n🎉 All Integration Tests Passed! 🎉\n")
except AssertionError as e:
print(f"\n❌ Test Failed: {e}\n")
sys.exit(1)
except Exception as e:
print(f"\n❌ Error: {e}\n")
import traceback
traceback.print_exc()
sys.exit(1)
finally:
# Cleanup
teardown_server()
reset_habits_file()

View File

@@ -0,0 +1,256 @@
#!/usr/bin/env python3
"""
Test suite for Story 14.0: Frontend - Responsive mobile design
Tests mobile responsiveness for habit tracker
"""
import re
from pathlib import Path
def test_file_exists():
"""AC: Test file exists"""
path = Path(__file__).parent / 'habits.html'
assert path.exists(), "habits.html should exist"
print("✓ File exists")
def test_modal_fullscreen_mobile():
"""AC1: Modal is full-screen on mobile (< 768px)"""
path = Path(__file__).parent / 'habits.html'
content = path.read_text()
# Check for mobile media query
assert '@media (max-width: 768px)' in content, "Should have mobile media query"
# Find the mobile section by locating the media query and extracting content until the closing brace
media_start = content.find('@media (max-width: 768px)')
assert media_start != -1, "Should have mobile media query"
# Extract a reasonable chunk after the media query (enough to include all mobile styles)
mobile_chunk = content[media_start:media_start + 3000]
# Check for modal full-screen styles within mobile section
assert '.modal {' in mobile_chunk or '.modal{' in mobile_chunk, "Mobile section should include .modal styles"
assert 'width: 100%' in mobile_chunk, "Modal should have 100% width on mobile"
assert 'height: 100vh' in mobile_chunk, "Modal should have 100vh height on mobile"
assert 'max-height: 100vh' in mobile_chunk, "Modal should have 100vh max-height on mobile"
assert 'border-radius: 0' in mobile_chunk, "Modal should have no border-radius on mobile"
print("✓ Modal is full-screen on mobile")
def test_habit_cards_stack_vertically():
"""AC2: Habit cards stack vertically on mobile"""
path = Path(__file__).parent / 'habits.html'
content = path.read_text()
# Check for habits-list with flex-direction: column
assert '.habits-list' in content, "Should have .habits-list class"
# Extract habits-list styles
habits_list_match = re.search(r'\.habits-list\s*\{([^}]+)\}', content)
assert habits_list_match, "Should have .habits-list styles"
habits_list_styles = habits_list_match.group(1)
assert 'display: flex' in habits_list_styles or 'display:flex' in habits_list_styles, "habits-list should use flexbox"
assert 'flex-direction: column' in habits_list_styles or 'flex-direction:column' in habits_list_styles, "habits-list should stack vertically"
# Find the mobile section
media_start = content.find('@media (max-width: 768px)')
mobile_chunk = content[media_start:media_start + 3000]
# Verify cards are full width on mobile
assert '.habit-card {' in mobile_chunk or '.habit-card{' in mobile_chunk, "Should have .habit-card mobile styles"
assert 'width: 100%' in mobile_chunk, "Should have 100% width on mobile"
print("✓ Habit cards stack vertically on mobile")
def test_touch_targets_44px():
"""AC3: Touch targets >= 44x44px for checkbox"""
path = Path(__file__).parent / 'habits.html'
content = path.read_text()
# Find the mobile section
media_start = content.find('@media (max-width: 768px)')
assert media_start != -1, "Should have mobile media query"
mobile_chunk = content[media_start:media_start + 3000]
# Check for checkbox sizing in mobile section
assert '.habit-checkbox {' in mobile_chunk or '.habit-checkbox{' in mobile_chunk, "Should have .habit-checkbox styles in mobile section"
# Extract width and height values from the mobile checkbox section
checkbox_section_start = mobile_chunk.find('.habit-checkbox')
checkbox_section = mobile_chunk[checkbox_section_start:checkbox_section_start + 300]
width_match = re.search(r'width:\s*(\d+)px', checkbox_section)
height_match = re.search(r'height:\s*(\d+)px', checkbox_section)
assert width_match, "Checkbox should have width specified"
assert height_match, "Checkbox should have height specified"
width = int(width_match.group(1))
height = int(height_match.group(1))
# Verify touch target size (44x44px minimum for accessibility)
assert width >= 44, f"Checkbox width should be >= 44px (got {width}px)"
assert height >= 44, f"Checkbox height should be >= 44px (got {height}px)"
# Check for other touch targets (buttons)
assert 'min-height: 44px' in mobile_chunk, "Buttons should have min-height of 44px"
print("✓ Touch targets are >= 44x44px")
def test_mobile_optimized_keyboards():
"""AC4: Form inputs use mobile-optimized keyboards"""
path = Path(__file__).parent / 'habits.html'
content = path.read_text()
# Check for input field
assert 'id="habitName"' in content, "Should have habitName input field"
# Extract input element
input_match = re.search(r'<input[^>]+id="habitName"[^>]*>', content)
assert input_match, "Should have habitName input element"
input_element = input_match.group(0)
# Check for mobile-optimized attributes
# autocapitalize="words" for proper names
# autocomplete="off" to prevent autofill issues
assert 'autocapitalize="words"' in input_element or 'autocapitalize=\'words\'' in input_element, \
"Input should have autocapitalize='words' for mobile optimization"
assert 'autocomplete="off"' in input_element or 'autocomplete=\'off\'' in input_element, \
"Input should have autocomplete='off' to prevent autofill"
# Verify type="text" is present (appropriate for habit names)
assert 'type="text"' in input_element, "Input should have type='text'"
print("✓ Form inputs use mobile-optimized keyboards")
def test_swipe_navigation():
"""AC5: Swipe navigation works (via swipe-nav.js)"""
path = Path(__file__).parent / 'habits.html'
content = path.read_text()
# Check for swipe-nav.js inclusion
assert 'swipe-nav.js' in content, "Should include swipe-nav.js for mobile swipe navigation"
# Verify script tag
assert '<script src="/echo/swipe-nav.js"></script>' in content, \
"Should have proper script tag for swipe-nav.js"
# Check for viewport meta tag (required for proper mobile rendering)
assert '<meta name="viewport"' in content, "Should have viewport meta tag"
assert 'width=device-width' in content, "Viewport should include width=device-width"
assert 'initial-scale=1.0' in content, "Viewport should include initial-scale=1.0"
print("✓ Swipe navigation is enabled")
def test_mobile_button_sizing():
"""Additional test: Verify all interactive elements have proper mobile sizing"""
path = Path(__file__).parent / 'habits.html'
content = path.read_text()
# Find the mobile section
media_start = content.find('@media (max-width: 768px)')
mobile_chunk = content[media_start:media_start + 3000]
# Check for add-habit-btn sizing
assert '.add-habit-btn {' in mobile_chunk or '.add-habit-btn{' in mobile_chunk, "Should have .add-habit-btn mobile styles"
assert 'min-height: 44px' in mobile_chunk, "Add habit button should have min-height 44px"
# Check for generic .btn sizing
assert '.btn {' in mobile_chunk or '.btn{' in mobile_chunk, "Should have .btn mobile styles"
# Check for radio labels sizing
assert '.radio-label {' in mobile_chunk or '.radio-label{' in mobile_chunk, "Should have .radio-label mobile styles"
print("✓ All buttons and interactive elements have proper mobile sizing")
def test_responsive_layout_structure():
"""Additional test: Verify responsive layout structure"""
path = Path(__file__).parent / 'habits.html'
content = path.read_text()
# Find the mobile section
media_start = content.find('@media (max-width: 768px)')
mobile_chunk = content[media_start:media_start + 3000]
# Verify main padding is adjusted for mobile
assert '.main {' in mobile_chunk or '.main{' in mobile_chunk, "Should have .main mobile styles"
main_section_start = mobile_chunk.find('.main')
main_section = mobile_chunk[main_section_start:main_section_start + 200]
assert 'padding' in main_section, "Main should have adjusted padding on mobile"
print("✓ Responsive layout structure is correct")
def test_all_acceptance_criteria():
"""Summary test: Verify all 6 acceptance criteria are met"""
path = Path(__file__).parent / 'habits.html'
content = path.read_text()
# Find mobile section
media_start = content.find('@media (max-width: 768px)')
mobile_chunk = content[media_start:media_start + 3000]
# AC1: Modal is full-screen on mobile
assert '.modal {' in mobile_chunk or '.modal{' in mobile_chunk, "AC1: Modal styles in mobile section"
assert 'width: 100%' in mobile_chunk, "AC1: Modal full-screen width"
assert 'height: 100vh' in mobile_chunk, "AC1: Modal full-screen height"
# AC2: Habit cards stack vertically
habits_list_match = re.search(r'\.habits-list\s*\{([^}]+)\}', content)
assert habits_list_match and 'flex-direction: column' in habits_list_match.group(1), "AC2: Cards stack vertically"
# AC3: Touch targets >= 44x44px
checkbox_section_start = mobile_chunk.find('.habit-checkbox')
checkbox_section = mobile_chunk[checkbox_section_start:checkbox_section_start + 300]
assert 'width: 44px' in checkbox_section, "AC3: Touch targets 44px width"
assert 'height: 44px' in checkbox_section, "AC3: Touch targets 44px height"
# AC4: Mobile-optimized keyboards
input_match = re.search(r'<input[^>]+id="habitName"[^>]*>', content)
assert input_match and 'autocapitalize="words"' in input_match.group(0), "AC4: Mobile keyboards"
# AC5: Swipe navigation
assert 'swipe-nav.js' in content, "AC5: Swipe navigation"
# AC6: Tests pass (this test itself)
print("✓ All 6 acceptance criteria verified")
def main():
"""Run all tests"""
tests = [
test_file_exists,
test_modal_fullscreen_mobile,
test_habit_cards_stack_vertically,
test_touch_targets_44px,
test_mobile_optimized_keyboards,
test_swipe_navigation,
test_mobile_button_sizing,
test_responsive_layout_structure,
test_all_acceptance_criteria
]
print("Running Story 14.0 mobile responsiveness tests...\n")
failed = []
for test in tests:
try:
test()
except AssertionError as e:
failed.append((test.__name__, str(e)))
print(f"{test.__name__}: {e}")
print(f"\n{'='*60}")
if failed:
print(f"FAILED: {len(failed)} test(s) failed")
for name, error in failed:
print(f" - {name}: {error}")
return False
else:
print(f"SUCCESS: All {len(tests)} tests passed! ✓")
return True
if __name__ == '__main__':
import sys
sys.exit(0 if main() else 1)

View 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()

View File

@@ -0,0 +1,235 @@
#!/usr/bin/env python3
"""
Test Suite for Story 13.0: Frontend - Add to dashboard navigation
Tests that Habit Tracker link is added to main navigation properly.
"""
import os
import re
def test_file_existence():
"""Test that both index.html and habits.html exist."""
assert os.path.exists('dashboard/index.html'), "index.html should exist"
assert os.path.exists('dashboard/habits.html'), "habits.html should exist"
print("✓ Both HTML files exist")
def test_index_habits_link():
"""Test that index.html includes Habits link pointing to /echo/habits.html."""
with open('dashboard/index.html', 'r', encoding='utf-8') as f:
content = f.read()
# Check for Habits link with correct href
assert 'href="/echo/habits.html"' in content, "index.html should have link to /echo/habits.html"
# Check that Habits link exists in navigation
habits_link_pattern = r'<a[^>]*href="/echo/habits\.html"[^>]*class="nav-item"[^>]*>.*?<span>Habits</span>'
assert re.search(habits_link_pattern, content, re.DOTALL), "Habits link should be in nav-item format"
print("✓ index.html includes Habits link to /echo/habits.html (AC1, AC2)")
def test_index_flame_icon():
"""Test that index.html Habits link uses flame icon (lucide)."""
with open('dashboard/index.html', 'r', encoding='utf-8') as f:
content = f.read()
# Find the Habits nav item
habits_section = re.search(
r'<a[^>]*href="/echo/habits\.html"[^>]*>.*?</a>',
content,
re.DOTALL
)
assert habits_section, "Habits link should exist"
habits_html = habits_section.group(0)
# Check for flame icon (lucide)
assert 'data-lucide="flame"' in habits_html, "Habits link should use lucide flame icon"
print("✓ index.html Habits link uses flame icon (AC3)")
def test_habits_back_to_dashboard():
"""Test that habits.html navigation includes link back to dashboard."""
with open('dashboard/habits.html', 'r', encoding='utf-8') as f:
content = f.read()
# Check for Dashboard link
assert 'href="/echo/index.html"' in content, "habits.html should link back to dashboard"
# Check that Dashboard link exists in navigation
dashboard_link_pattern = r'<a[^>]*href="/echo/index\.html"[^>]*class="nav-item"[^>]*>.*?<span>Dashboard</span>'
assert re.search(dashboard_link_pattern, content, re.DOTALL), "Dashboard link should be in nav-item format"
print("✓ habits.html includes link back to dashboard (AC4)")
def test_habits_flame_icon():
"""Test that habits.html Habits link also uses flame icon."""
with open('dashboard/habits.html', 'r', encoding='utf-8') as f:
content = f.read()
# Find the Habits nav item in habits.html
habits_section = re.search(
r'<a[^>]*href="/echo/habits\.html"[^>]*class="nav-item active"[^>]*>.*?</a>',
content,
re.DOTALL
)
assert habits_section, "Habits link should exist in habits.html with active class"
habits_html = habits_section.group(0)
# Check for flame icon (lucide)
assert 'data-lucide="flame"' in habits_html, "habits.html Habits link should use lucide flame icon"
print("✓ habits.html Habits link uses flame icon (AC3)")
def test_active_state_styling():
"""Test that active state styling matches other nav items."""
with open('dashboard/habits.html', 'r', encoding='utf-8') as f:
habits_content = f.read()
with open('dashboard/index.html', 'r', encoding='utf-8') as f:
index_content = f.read()
# Check that habits.html has 'active' class on Habits nav item
habits_active = re.search(
r'<a[^>]*href="/echo/habits\.html"[^>]*class="nav-item active"',
habits_content
)
assert habits_active, "Habits nav item should have 'active' class in habits.html"
# Check that index.html has 'active' class on Dashboard nav item (pattern to follow)
index_active = re.search(
r'<a[^>]*href="/echo/index\.html"[^>]*class="nav-item active"',
index_content
)
assert index_active, "Dashboard nav item should have 'active' class in index.html"
# Both should use the same pattern (nav-item active)
print("✓ Active state styling matches other nav items (AC5)")
def test_mobile_navigation():
"""Test that mobile navigation is supported (shared nav structure)."""
with open('dashboard/index.html', 'r', encoding='utf-8') as f:
index_content = f.read()
with open('dashboard/habits.html', 'r', encoding='utf-8') as f:
habits_content = f.read()
# Check that both files include swipe-nav.js for mobile navigation
assert 'swipe-nav.js' in index_content, "index.html should include swipe-nav.js for mobile navigation"
assert 'swipe-nav.js' in habits_content, "habits.html should include swipe-nav.js for mobile navigation"
# Check that navigation uses the same class structure (nav-item)
# This ensures mobile navigation will work consistently
index_nav_items = len(re.findall(r'class="nav-item', index_content))
habits_nav_items = len(re.findall(r'class="nav-item', habits_content))
assert index_nav_items >= 5, "index.html should have at least 5 nav items (including Habits)"
assert habits_nav_items >= 5, "habits.html should have at least 5 nav items"
print("✓ Mobile navigation is supported (AC6)")
def test_navigation_completeness():
"""Test that navigation is complete on both pages."""
with open('dashboard/index.html', 'r', encoding='utf-8') as f:
index_content = f.read()
with open('dashboard/habits.html', 'r', encoding='utf-8') as f:
habits_content = f.read()
# Define expected navigation items
nav_items = [
('Dashboard', '/echo/index.html', 'layout-dashboard'),
('Workspace', '/echo/workspace.html', 'code'),
('KB', '/echo/notes.html', 'file-text'),
('Files', '/echo/files.html', 'folder'),
('Habits', '/echo/habits.html', 'flame')
]
# Check all items exist in both files
for label, href, icon in nav_items:
assert href in index_content, f"index.html should have link to {href}"
assert href in habits_content, f"habits.html should have link to {href}"
# Check flame icon specifically
assert 'data-lucide="flame"' in index_content, "index.html should have flame icon"
assert 'data-lucide="flame"' in habits_content, "habits.html should have flame icon"
print("✓ Navigation is complete on both pages with all 5 items")
def test_all_acceptance_criteria():
"""Summary test: verify all 7 acceptance criteria are met."""
print("\n=== Testing All Acceptance Criteria ===")
with open('dashboard/index.html', 'r', encoding='utf-8') as f:
index_content = f.read()
with open('dashboard/habits.html', 'r', encoding='utf-8') as f:
habits_content = f.read()
# AC1: index.html navigation includes 'Habits' link
ac1 = 'href="/echo/habits.html"' in index_content and 'class="nav-item"' in index_content
print(f"AC1 - index.html has Habits link: {'' if ac1 else ''}")
# AC2: Link points to /echo/habits.html
ac2 = 'href="/echo/habits.html"' in index_content
print(f"AC2 - Link points to /echo/habits.html: {'' if ac2 else ''}")
# AC3: Uses flame icon (lucide)
ac3 = 'data-lucide="flame"' in index_content and 'data-lucide="flame"' in habits_content
print(f"AC3 - Uses flame icon: {'' if ac3 else ''}")
# AC4: habits.html navigation includes link back to dashboard
ac4 = 'href="/echo/index.html"' in habits_content
print(f"AC4 - habits.html links back to dashboard: {'' if ac4 else ''}")
# AC5: Active state styling matches
ac5_habits = bool(re.search(r'href="/echo/habits\.html"[^>]*class="nav-item active"', habits_content))
ac5_index = bool(re.search(r'href="/echo/index\.html"[^>]*class="nav-item active"', index_content))
ac5 = ac5_habits and ac5_index
print(f"AC5 - Active state styling matches: {'' if ac5 else ''}")
# AC6: Mobile navigation supported
ac6 = 'swipe-nav.js' in index_content and 'swipe-nav.js' in habits_content
print(f"AC6 - Mobile navigation supported: {'' if ac6 else ''}")
# AC7: Tests pass (this test itself)
ac7 = True
print(f"AC7 - Tests for navigation pass: {'' if ac7 else ''}")
assert all([ac1, ac2, ac3, ac4, ac5, ac6, ac7]), "All acceptance criteria should pass"
print("\n✓ All 7 acceptance criteria met!")
if __name__ == '__main__':
print("Running Story 13.0 Navigation Tests...\n")
try:
test_file_existence()
test_index_habits_link()
test_index_flame_icon()
test_habits_back_to_dashboard()
test_habits_flame_icon()
test_active_state_styling()
test_mobile_navigation()
test_navigation_completeness()
test_all_acceptance_criteria()
print("\n" + "="*50)
print("✓ ALL TESTS PASSED")
print("="*50)
except AssertionError as e:
print(f"\n✗ TEST FAILED: {e}")
exit(1)
except Exception as e:
print(f"\n✗ ERROR: {e}")
exit(1)

View 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()

View 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)

View 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()

21
memory/2026-02-10.md Normal file
View File

@@ -0,0 +1,21 @@
# 2026-02-10
## Dashboard ANAF - Detalii Modificări
**Context:** Marius a cerut să vadă ce modificări detectează ANAF Monitor în dashboard, nu doar mesaj generic "Modificări detectate".
**Implementare:**
1. **monitor_v2.py** - modificat `update_dashboard_status()` să salveze detalii în `status.json`:
- Nume pagină modificată
- URL către pagina ANAF
- Rezumat modificări (ex: "Soft A: 09.02.2026 → 10.02.2026")
2. **dashboard/index.html** - modificat `loadAnafStatus()` să afișeze detaliile:
- Link-uri clickabile către paginile ANAF
- Lista modificărilor pentru fiecare pagină
- Expandabil în secțiunea ANAF Monitor
**Modificare reală detectată astăzi:**
- D100 (Declarația 100) - Soft A: 09.02.2026 → 10.02.2026
**Status:** Implementat, netestat în browser. Așteaptă commit.

View File

@@ -1,5 +1,48 @@
{
"notes": [
{
"file": "notes-data/tools/antfarm-workflow.md",
"title": "Antfarm Workflow - Echo",
"date": "2026-02-10",
"tags": [],
"domains": [],
"types": [],
"category": "tools",
"project": null,
"subdir": null,
"video": "",
"tldr": "**Update:** După ce învăț fluxul mai bine"
},
{
"file": "memory/provocare-azi.md",
"title": "Provocarea Zilei - 2026-02-08",
"date": "2026-02-10",
"tags": [],
"domains": [],
"types": [
"memory"
],
"category": "memory",
"project": null,
"subdir": null,
"video": "",
"tldr": "- Monica Ion - Legea Fractalilor (Cele 7 Legi Universale)"
},
{
"file": "memory/2026-02-10.md",
"title": "2026-02-10",
"date": "2026-02-10",
"tags": [],
"domains": [],
"types": [
"memory"
],
"category": "memory",
"project": null,
"subdir": null,
"video": "",
"tldr": "**Status:** Aștept confirmare de la Marius să lansez `antfarm workflow run feature-dev`."
},
{
"file": "notes-data/coaching/2026-02-09-seara.md",
"title": "Gândul de Seară - Duminică, 9 Februarie 2026",
@@ -813,21 +856,6 @@
"video": "https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/articole/monica-ion/cele-7-legi-universale.md",
"tldr": "Cele 7 Legi Universale sunt principii fundamentale care explică cum funcționează mintea, de ce trăim viața așa cum o trăim și cum putem genera transformare reală. Fiecare lege este susținută de istori..."
},
{
"file": "memory/provocare-azi.md",
"title": "Provocarea Zilei - 2026-02-08",
"date": "2026-02-08",
"tags": [],
"domains": [],
"types": [
"memory"
],
"category": "memory",
"project": null,
"subdir": null,
"video": "",
"tldr": "- Monica Ion - Legea Fractalilor (Cele 7 Legi Universale)"
},
{
"file": "memory/jurnal-motivatie.md",
"title": "Jurnal - Drumul spre regăsirea motivației",
@@ -3346,7 +3374,7 @@
}
],
"stats": {
"total": 194,
"total": 196,
"by_domain": {
"work": 59,
"health": 34,
@@ -3365,9 +3393,9 @@
"projects": 85,
"reflectii": 3,
"retete": 1,
"tools": 5,
"tools": 6,
"youtube": 42,
"memory": 16
"memory": 17
}
},
"domains": [

View File

@@ -0,0 +1,87 @@
# Antfarm Workflow - Echo
**Instalat:** github.com/snarktank/antfarm
**CLI:** `antfarm` (în PATH, global)
**Dashboard:** https://moltbot.tailf7372d.ts.net:3333
**Docs:** ~/clawd/antfarm/README.md, ~/clawd/antfarm/docs/creating-workflows.md
---
## Flux rapid (pentru Echo)
### 1. Primesc request de la Marius
**EX:** "Vreau să construiesc un Habit tracker în dashboard"
### 2. Lansez direct workflow-ul cu promptul lui Marius
```bash
cd ~/clawd
antfarm workflow run feature-dev "<prompt exact de la Marius>"
```
**NU:**
- ✗ Verific dacă e instalat (e instalat, permanent)
- ✗ Fac eu requirements/acceptance criteria (planner-ul face asta)
- ✗ Complicez task string-ul (simplitate = mai bine)
**DA:**
- ✓ Trimit prompt-ul EXACT cum îl primesc de la Marius
- ✓ Planner-ul descompune în stories automat
- ✓ Developer-ul decide tehnologii/structură
### 3. Monitorez progres
```bash
antfarm workflow status <run-id sau substring>
antfarm workflow runs # listă toate
```
### 4. Raportez când e gata
Agenții lucrează autonom (polling 15 min). Raportez când:
- Stories finalizate
- Erori care necesită intervenție
- PR creat pentru review
---
## Workflows disponibile
| Workflow | Când să-l folosesc |
|----------|-------------------|
| `feature-dev` | Features noi, refactoring, îmbunătățiri |
| `bug-fix` | Bug-uri cu pași de reproducere |
| `security-audit` | Audit securitate codebase |
---
## Comenzi utile
```bash
# Status rapid
antfarm workflow status <query>
# Force trigger agent (skip 15min wait)
cron action=run jobId=antfarm/feature-dev/developer
# Logs
antfarm logs 50
# Resume dacă failuit
antfarm workflow resume <run-id>
# Dashboard
antfarm dashboard status
```
---
## Reguli importante
1. **Task string = prompt exact de la Marius** (nu complica)
2. **Planner face requirements** (nu tu)
3. **Agenții sunt autonomi** (polling 15 min, nu trebuie să-i controlezi)
4. **Monitor dashboard** (https://moltbot.tailf7372d.ts.net:3333)
5. **Raportează doar când e relevant** (finalizare, erori, PR)
---
**Creat:** 2026-02-10
**Update:** După ce învăț fluxul mai bine

707
progress.txt Normal file
View File

@@ -0,0 +1,707 @@
=== HABIT TRACKER FEATURE PROGRESS ===
Date: 2026-02-10
Branch: feature/habit-tracker
Repo: /home/moltbot/clawd
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
COMPLETED STORIES:
[✓] Story 1.0: Define habits.json data schema
Commit: ee8727a
Date: 2026-02-10
Implementation:
- Created dashboard/habits.json with proper schema
- Root structure: lastUpdated (ISO timestamp) + habits (array)
- Habit schema: id (string), name (string), frequency (daily/weekly),
createdAt (ISO), completions (array of ISO dates)
- Initial file contains empty habits array with current timestamp
- All JSON validation passes
Tests:
- Created dashboard/test_habits_schema.py
- Tests for file existence, valid JSON, root structure
- Tests for lastUpdated ISO format validation
- Tests for habits array type
- Tests for complete habit schema (all required fields + types)
- Tests for initial empty state
- All tests pass ✓
Files modified:
- dashboard/habits.json (created)
- dashboard/test_habits_schema.py (created)
[✓] Story 2.0: Backend API - GET /api/habits
Commit: fc5ebf2
Date: 2026-02-10
Implementation:
- Added GET /api/habits endpoint to dashboard/api.py
- Endpoint returns habits array and lastUpdated timestamp
- Graceful error handling: returns empty array if file missing/corrupt
- Follows existing API patterns (similar to /api/git, /api/status)
- Returns 200 status for all valid requests
Tests:
- Created dashboard/test_habits_api.py
- Tests for endpoint existence (returns 200)
- Tests for valid JSON response
- Tests for response structure (habits array + lastUpdated)
- Tests for ISO timestamp validation
- Tests for empty file handling (returns [], not error)
- Tests for habits with data
- All 6 tests pass ✓
Files modified:
- dashboard/api.py (added handle_habits_get method + route)
- dashboard/test_habits_api.py (created)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CODEBASE PATTERNS:
1. JSON Data Files
- Location: dashboard/*.json
- Pattern: Similar structure to tasks.json, todos.json, issues.json
- All use ISO timestamps for dates
- Root objects contain metadata + data arrays
2. Testing Approach
- Python test files in dashboard/ directory
- Test naming: test_*.py
- Comprehensive validation: existence, JSON validity, schema, types
- Run tests from repo root with: python3 dashboard/test_*.py
3. Build Validation
- Command: python3 -m py_compile dashboard/api.py
- Validates Python syntax without executing
4. Utility Functions in api.py
- Standalone utility functions placed before TaskBoardHandler class
- Documented with docstrings (Args, Returns, Rules/behavior)
- ISO timestamp parsing pattern: datetime.fromisoformat(ts.replace('Z', '+00:00'))
- Convert to date only when time doesn't matter: dt.date()
- Use try/except for robust parsing with sensible defaults (return 0, [], etc.)
[✓] Story 3.0: Backend API - POST /api/habits (create habit)
Commit: 3a09e6c
Date: 2026-02-10
Implementation:
- Added POST /api/habits endpoint to dashboard/api.py
- Accepts {name, frequency} in request body
- Returns 400 for missing name or empty name (after trim)
- Returns 400 for invalid frequency (must be 'daily' or 'weekly')
- Generates unique id following format 'habit-{millisecond_timestamp}'
- Sets createdAt to current ISO timestamp
- Initializes completions array as empty
- Persists new habit to habits.json
- Updates lastUpdated timestamp in habits.json
- Returns 201 status with created habit object
- Graceful handling of missing/corrupt habits.json file
Tests:
- Created dashboard/test_habits_post.py
- Tests for successful habit creation (returns 201 + full habit object)
- Tests for habit persistence to habits.json
- Tests for correct id format (habit-{timestamp})
- Tests for missing name validation (400)
- Tests for empty name validation (400)
- Tests for invalid frequency validation (400)
- Tests for missing frequency validation (400)
- Tests for creating multiple habits (unique IDs)
- Tests for lastUpdated timestamp update
- All 9 tests pass ✓
Files modified:
- dashboard/api.py (added handle_habits_post method + route)
- dashboard/test_habits_post.py (created)
[✓] Story 4.0: Backend API - Streak calculation utility
Commit: 3927b7c
Date: 2026-02-10
Implementation:
- Created calculate_streak(completions, frequency) utility function
- Added to dashboard/api.py as standalone function (before TaskBoardHandler class)
- Accepts completions array (ISO timestamps) and frequency ('daily' or 'weekly')
- Returns integer streak count (days for daily, weeks for weekly)
- Daily habits: counts consecutive days without gaps
- Weekly habits: counts consecutive weeks (7-day periods)
- Returns 0 for no completions, invalid dates, or broken streaks
- Edge case: today's completion counts even if streak was 0 yesterday
- Edge case: multiple completions same day/week count as one period
- Robust date parsing with error handling for invalid ISO timestamps
Tests:
- Created dashboard/test_habits_streak.py
- Tests for no completions (returns 0)
- Tests for daily single completion (today and yesterday)
- Tests for daily consecutive days (5 days streak)
- Tests for daily broken streak (gap detection)
- Tests for daily old completion (>1 day ago returns 0)
- Tests for weekly single completion (this week)
- Tests for weekly consecutive weeks (4 weeks streak)
- Tests for weekly broken streak (missing week)
- Tests for weekly old completion (>7 days ago returns 0)
- Tests for multiple completions same day (deduplicated)
- Tests for today counting despite yesterday missing
- Tests for invalid date format handling
- Tests for weekly multiple in same week (deduplicated)
- All 14 tests pass ✓
- All previous tests (schema, GET, POST) still pass ✓
Files modified:
- dashboard/api.py (added calculate_streak function)
- dashboard/test_habits_streak.py (created)
[✓] Story 5.0: Backend API - POST /api/habits/{id}/check
Commit: ca4ee77
Date: 2026-02-10
Implementation:
- Added POST /api/habits/{id}/check endpoint to dashboard/api.py
- Extracts habit ID from URL path (/api/habits/{id}/check)
- Adds today's date (YYYY-MM-DD) to completions array
- Returns 400 if habit already checked today
- Returns 404 if habit ID not found
- Sorts completions chronologically (oldest first) after adding
- Uses ISO date format YYYY-MM-DD (not full timestamps)
- Calculates and returns streak using calculate_streak utility
- Returns 200 with updated habit object including streak
- Streak is calculated but not persisted (only in response)
- Updates lastUpdated timestamp in habits.json
- Graceful error handling for missing/corrupt files
Tests:
- Created dashboard/test_habits_check.py
- Tests for successful habit check (returns 200 + updated habit)
- Tests for already checked validation (400 error)
- Tests for habit not found (404 error)
- Tests for persistence to habits.json
- Tests for chronological sorting of completions
- Tests for streak calculation in response
- Tests for weekly habit checking
- Tests for ISO date format (YYYY-MM-DD, no time)
- All 8 tests pass ✓
- All previous tests (schema, GET, POST, streak) still pass ✓
Files modified:
- dashboard/api.py (added handle_habits_check method + route in do_POST)
- dashboard/test_habits_check.py (created)
- dashboard/habits.json (reset to empty for testing)
[✓] Story 6.0: Backend API - GET /api/habits with streaks
Commit: c84135d
Date: 2026-02-10
Implementation:
- Enhanced GET /api/habits endpoint to include calculated streaks
- Each habit in response now includes 'streak' field (integer)
- Each habit in response now includes 'checkedToday' field (boolean)
- Streak is calculated using the calculate_streak utility function
- checkedToday checks if today's date (YYYY-MM-DD) is in completions array
- All original habit fields are preserved in response
- Get today's date once and reuse for all habits (efficient)
- Enhanced habits array built by looping through each habit and adding fields
- Updated docstring to reflect new functionality
Tests:
- Created dashboard/test_habits_get_enhanced.py
- Tests for streak field inclusion in response
- Tests for checkedToday boolean field inclusion
- Tests for correct streak calculation (daily and weekly habits)
- Tests for broken streaks (should return 0)
- Tests for empty habits array handling
- Tests for preservation of original habit fields
- All 5 tests pass ✓
- All previous tests (schema, GET, POST, streak, check) still pass ✓
Files modified:
- dashboard/api.py (enhanced handle_habits_get method)
- dashboard/test_habits_get_enhanced.py (created)
[✓] Story 7.0: Frontend - Create habits.html page structure
Commit: dd0bf24
Date: 2026-02-10
Implementation:
- Created dashboard/habits.html with basic layout matching dashboard style
- Uses common.css and swipe-nav.js for consistent styling and navigation
- Added navigation bar with 5 items (Dashboard, Workspace, KB, Files, Habits)
- Habits nav item has 'active' class to indicate current page
- Page header with title "Habit Tracker" and subtitle
- Empty state section with lucide 'target' icon
- Empty state message: "Nicio obișnuință încă. Creează prima!"
- Add habit button with lucide 'plus' icon and text "Adaugă obișnuință"
- Theme toggle functionality (dark/light mode) matching dashboard
- Placeholder JavaScript functions for future API integration
- HTML5 compliant structure with lang="ro" attribute
Tests:
- Created dashboard/test_habits_html.py
- Tests for file existence
- Tests for valid HTML5 structure (DOCTYPE, required tags, lang attribute)
- Tests for common.css and swipe-nav.js inclusion
- Tests for navigation bar with correct items and active state
- Tests for page title "Habit Tracker" in both <title> and <h1>
- Tests for empty state message with exact text
- Tests for add habit button with lucide plus icon
- All 7 tests pass ✓
- All previous tests (schema, API endpoints) still pass ✓
Files modified:
- dashboard/habits.html (created)
- dashboard/test_habits_html.py (created)
- dashboard/habits.json (reset to empty for testing)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CODEBASE PATTERNS UPDATE:
5. Frontend HTML Pages
- Location: dashboard/*.html
- Common structure: DOCTYPE html, lang="ro", UTF-8 charset
- Shared resources: common.css, swipe-nav.js, lucide icons CDN
- Navigation pattern: header.header > logo + nav.nav > nav-item links
- Active nav item has 'active' class
- Theme toggle button in nav with onclick="toggleTheme()"
- Main content in <main class="main"> with max-width container
- Page header pattern: .page-header > .page-title + .page-subtitle
- Empty states: .empty-state with centered icon, message, and action button
- Icons: use lucide via data-lucide attribute, initialize with lucide.createIcons()
6. Mobile Responsiveness
- Use @media (max-width: 768px) for mobile breakpoint
- Touch targets: minimum 44x44px for WCAG compliance (checkboxes, buttons)
- Modal pattern: full-screen on mobile (100% width/height, no border-radius)
- Input optimization: autocapitalize="words" for proper names, autocomplete="off" for sensitive fields
- Navigation: swipe-nav.js provides mobile swipe gestures
- Viewport: include <meta name="viewport" content="width=device-width, initial-scale=1.0">
- All buttons should have min-height: 44px on mobile for easy tapping
- Flexbox direction already handles vertical stacking (flex-direction: column)
[✓] Story 8.0: Frontend - Create habit form modal
Commit: 97af2ae
Date: 2026-02-10
Implementation:
- Added modal HTML structure to habits.html with id='habitModal'
- Modal overlay uses dashboard styling patterns (overlay + modal container)
- Form includes text input for habit name (id='habitName', required)
- Form includes radio button group for frequency selection
- Two radio options: daily (default, checked) and weekly
- Custom styled radio buttons using .radio-group and .radio-label classes
- Radio buttons hidden, labels styled as clickable cards
- Selected radio shows accent background color
- Modal actions with Cancel (btn-secondary) and Create (btn-primary) buttons
- Cancel button calls hideHabitModal()
- Create button calls createHabit() and starts disabled
- Modal CSS follows dashboard patterns: modal-overlay, modal, modal-title, form-group, form-label, modal-actions
- Added toast notification element for user feedback
- JavaScript: showAddHabitModal() opens modal and resets form
- JavaScript: hideHabitModal() closes modal by removing 'active' class
- JavaScript: DOMContentLoaded event listener for form validation
- Input event listener on name field enables/disables Create button
- Create button disabled when name is empty (after trim)
- Enter key submits form if Create button is enabled
- createHabit() async function posts to /api/habits
- On success: hides modal, shows toast, calls loadHabits()
- On error: shows toast with error message
- showToast() function displays temporary notification (3 seconds)
- Modal uses CSS variables for theming (--bg-base, --border, --accent, etc.)
- Responsive design with max-width: 500px and 90% width
Tests:
- Created dashboard/test_habits_modal.py
- Tests for modal structure (overlay, container, title)
- Tests for name input field with required indicator (*)
- Tests for frequency radio buttons (daily/weekly, daily checked by default)
- Tests for radio labels with correct Romanian text
- Tests for Cancel and Create buttons with correct classes and onclick handlers
- Tests for Create button starting disabled
- Tests for add habit button calling showAddHabitModal()
- Tests for modal CSS styling patterns
- Tests for JavaScript functions (showAddHabitModal, hideHabitModal, createHabit)
- Tests for form validation logic (disable button, trim validation)
- Tests for modal show/hide logic (classList.add/remove 'active')
- Tests for API integration (fetch /api/habits POST)
- Tests for toast notification element and function
- Tests for event listeners (DOMContentLoaded, input, keypress)
- All 9 tests pass ✓
- All previous tests (schema, API endpoints, HTML structure) still pass ✓
Files modified:
- dashboard/habits.html (added modal HTML, CSS, and JavaScript)
- dashboard/test_habits_modal.py (created)
[✓] Story 9.0: Frontend - Display habits list
Commit: 0483d73
Date: 2026-02-10
Implementation:
- Added CSS for habit cards (.habit-card, .habit-icon, .habit-info, .habit-streak)
- Added CSS for loading state with spinner animation
- Added CSS for error state with retry button
- Enhanced HTML with loading, error, and empty states (all with IDs)
- Implemented loadHabits() async function to fetch from /api/habits
- Habits sorted by streak descending (highest first)
- Loading state shown while fetching (loadingState.classList.add('active'))
- Error state shown on fetch failure with retry button
- Empty state shown when habits array is empty
- Habits list shown when habits exist
- Created createHabitCard(habit) function to render habit cards
- Daily habits show calendar icon (lucide), weekly show clock icon
- Each habit displays: name, frequency label, streak with 🔥 emoji
- Streak displayed as: "{number} 🔥" in accent color
- XSS protection via escapeHtml() function (uses textContent)
- Lucide icons reinitialized after rendering (lucide.createIcons())
- loadHabits() called on DOMContentLoaded (automatic page load)
- Habits data includes streak and checkedToday from API
Tests:
- Created dashboard/test_habits_display.py
- Tests for loading state structure (element, class, icon, message)
- Tests for error state structure (element, class, icon, message, retry button)
- Tests for empty state ID attribute
- Tests for habits list container existence
- Tests for loadHabits function implementation and API fetch
- Tests for sorting by streak descending (b.streak - a.streak)
- Tests for frequency icons (calendar for daily, clock for weekly)
- Tests for streak display with flame emoji 🔥
- Tests for state management (show/hide logic)
- Tests for error handling (catch block)
- Tests for createHabitCard function existence
- Tests for page load trigger (DOMContentLoaded listener)
- Tests for habit card CSS styling
- Tests for Lucide icons reinitialization after rendering
- Tests for XSS protection (escapeHtml function)
- All 15 tests pass ✓
- All previous tests (schema, API, HTML, modal) still pass ✓
Files modified:
- dashboard/habits.html (added habit cards CSS, loading/error states HTML, loadHabits implementation)
- dashboard/test_habits_display.py (created)
[✓] Story 10.0: Frontend - Check habit interaction
Commit: 775f171
Date: 2026-02-10
Implementation:
- Added circular checkbox button to each habit card
- Checkbox positioned at start of card (before icon)
- Checkbox styled with border-radius: 50% for circular shape
- Checkbox shows check icon when checkedToday is true
- Checkbox has 'checked' and 'disabled' classes when already done today
- Clicking checkbox calls checkHabit(habitId, element) function
- checkHabit performs optimistic UI update (checks immediately)
- API call to POST /api/habits/{id}/check executed after UI update
- On success: streak element updated with response data, shows toast
- On error: checkbox reverts to unchecked state, shows error toast
- Checkbox is non-clickable (disabled) when already checked today
- Streak updates dynamically using id="streak-{habitId}" element
- Check icon reinitialized with lucide.createIcons() after adding
- Hover state for unchecked checkboxes (border-color change)
- All CSS uses CSS variables for theming consistency
Tests:
- Created dashboard/test_habits_check_ui.py
- Tests for checkbox CSS (circular shape, checked/disabled states, hover)
- Tests for checkbox inclusion in createHabitCard function
- Tests for checkedToday state reflection in UI
- Tests for checkHabit function existence and signature
- Tests for API call to POST /api/habits/{id}/check
- Tests for optimistic UI update (classList.add before fetch)
- Tests for error handling and revert logic
- Tests for disabled state when already checked
- Tests for streak update from response data
- Tests for check icon display and lucide reinitialization
- All 10 tests pass ✓
- All previous tests (schema, API endpoints, HTML, modal, display) still pass ✓
Files modified:
- dashboard/habits.html (added checkbox CSS and checkHabit function)
- dashboard/test_habits_check_ui.py (created)
[✓] Story 11.0: Frontend - Create habit from form
Commit: 4933847
Date: 2026-02-10
Implementation:
- Enhanced createHabit() async function with complete form submission flow
- Added loading state: button disabled during submission with "Se creează..." text
- Button disabled immediately on submit (before API call)
- Original button text stored and restored on error
- Input field cleared after successful creation (nameInput.value = '')
- Success flow: closes modal, shows success toast, reloads habits list
- Error flow: button re-enabled, modal stays open, shows error toast
- Both API errors (response.ok check) and network errors (catch block) handled
- Error messages displayed to user via toast notifications
- Modal stays open on error so user can retry without re-entering data
- All existing form validation preserved (empty name check, trim validation)
- Enter key submission still works with loading state integration
Tests:
- Created dashboard/test_habits_form_submit.py with 9 comprehensive tests
- Tests for form submission API call (POST /api/habits with name and frequency)
- Tests for loading state (button disabled, text changed to "Se creează...")
- Tests for success behavior (modal closes, list refreshes, input cleared)
- Tests for error behavior (modal stays open, button re-enabled, error shown)
- Tests for input field clearing after successful creation
- Tests for preservation of existing form validation logic
- Tests for modal reset when opened (form cleared)
- Tests for Enter key submission integration
- Tests for all 6 acceptance criteria in summary test
- All 9 tests pass ✓
- All previous tests (schema, API endpoints, HTML, modal, display, check) still pass ✓
Files modified:
- dashboard/habits.html (enhanced createHabit function)
- dashboard/test_habits_form_submit.py (created)
- dashboard/habits.json (reset to empty for testing)
[✓] Story 12.0: Frontend - Habit card styling
Commit: c1d4ed1
Date: 2026-02-10
Implementation:
- Enhanced habit card styling to match dashboard aesthetic
- Changed card border-radius from --radius-md to --radius-lg for smoother appearance
- Changed streak font-size from --text-lg to --text-xl for prominent display
- Added green background tint (rgba(34, 197, 94, 0.1)) for checked habit cards
- Added 'checked' CSS class to habit-card when checkedToday is true
- Implemented pulse animation on checkbox hover for unchecked habits
- Animation scales checkbox subtly (1.0 to 1.05) with 1.5s ease-in-out timing
- Styled frequency badge as dashboard tag with inline-block, bg-elevated, border, padding
- Updated JavaScript createHabitCard to add 'checked' class to card element
- Updated JavaScript checkHabit to add 'checked' class on successful check
- Updated error rollback to remove 'checked' class if check fails
- Added mobile responsiveness with @media (max-width: 768px) query
- Mobile styles: full width cards, reduced padding, smaller icons (36px, 28px)
- All CSS uses CSS variables for theming consistency
Tests:
- Created dashboard/test_habits_card_styling.py with 10 comprehensive tests
- Tests for file existence
- Tests for card using --bg-surface with --border (acceptance criteria 1)
- Tests for --radius-lg border radius on cards (acceptance criteria 6)
- Tests for streak using --text-xl font size (acceptance criteria 2)
- Tests for checked habit green background tint (acceptance criteria 3)
- Tests for pulse animation on unchecked checkbox hover (acceptance criteria 4)
- Tests for frequency badge dashboard tag styling (acceptance criteria 5)
- Tests for mobile responsiveness with full width cards (acceptance criteria 7)
- Tests for checked class in createHabitCard function
- Summary test verifying all 7 acceptance criteria
- All 10 tests pass ✓ (acceptance criteria 8)
- All previous tests (schema, API, HTML, modal, display, check, form) still pass ✓
Files modified:
- dashboard/habits.html (updated CSS and JavaScript for styling enhancements)
- dashboard/test_habits_card_styling.py (created)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
NEXT STEPS:
- Continue with remaining 3 stories
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[✓] Story 13.0: Frontend - Add to dashboard navigation
Commit: 1d56fe3
Date: 2026-02-10
Implementation:
- Added Habits link to index.html navigation (5th nav item)
- Link points to /echo/habits.html with flame icon (lucide)
- Changed habits.html Habits icon from "target" to "flame" for consistency
- Navigation structure matches existing pattern (nav-item class)
- Dashboard link already existed in habits.html (links back properly)
- Active state styling uses same pattern as other nav items
- Mobile navigation supported via shared swipe-nav.js
- All 5 navigation items now present on both pages
- Flame icon (🔥 lucide) used consistently across both pages
Tests:
- Created dashboard/test_habits_navigation.py with 9 comprehensive tests
- Tests for file existence
- Tests for index.html Habits link to /echo/habits.html (AC1, AC2)
- Tests for flame icon usage in index.html (AC3)
- Tests for habits.html link back to dashboard (AC4)
- Tests for flame icon usage in habits.html (AC3)
- Tests for active state styling consistency (AC5)
- Tests for mobile navigation support via swipe-nav.js (AC6)
- Tests for navigation completeness (all 5 items on both pages)
- Summary test verifying all 7 acceptance criteria
- All 9 tests pass ✓ (AC7)
- All previous tests (schema, API endpoints, HTML, modal, display, check, form, styling) still pass ✓ (except schema test expects empty habits.json)
Files modified:
- dashboard/index.html (added Habits nav link with flame icon)
- dashboard/habits.html (changed icon from target to flame)
- dashboard/test_habits_navigation.py (created)
[✓] Story 14.0: Frontend - Responsive mobile design
Commit: 0011664
Date: 2026-02-10
Implementation:
- Enhanced mobile responsiveness for habit tracker
- Modal is now full-screen on mobile (< 768px): 100% width/height, no border-radius
- Touch targets increased to 44x44px for checkboxes (from 28px)
- All buttons have min-height: 44px on mobile (add-habit-btn, .btn, .radio-label)
- Form input uses autocapitalize="words" for mobile-optimized keyboard
- Form input uses autocomplete="off" to prevent autofill issues
- Habit cards already stack vertically via flex-direction: column
- Cards are 100% width on mobile for optimal space usage
- Swipe navigation already enabled via swipe-nav.js inclusion
- Responsive padding adjustments for .main on mobile
- Icon sizes adjusted for mobile (habit-icon: 36px, checkbox icons: 20px)
- All interactive elements meet WCAG touch target guidelines (44x44px minimum)
Tests:
- Created dashboard/test_habits_mobile.py with 9 comprehensive tests
- Tests for mobile media query existence (@media max-width: 768px)
- Tests for modal full-screen on mobile (100% width/height, 100vh, no border-radius) [AC1]
- Tests for habit cards stacking vertically (flex-direction: column, 100% width) [AC2]
- Tests for touch targets >= 44x44px (checkbox: 44px, buttons: min-height 44px) [AC3]
- Tests for mobile-optimized keyboards (autocapitalize="words", autocomplete="off") [AC4]
- Tests for swipe navigation (swipe-nav.js, viewport meta tag) [AC5]
- Tests for all button sizing (add-habit-btn, .btn, .radio-label with min-height)
- Tests for responsive layout structure (.main padding adjustment)
- Summary test verifying all 6 acceptance criteria [AC6]
- All 9 tests pass ✓
- All previous tests (HTML structure, modal, display, check, form, styling, navigation) still pass ✓
Files modified:
- dashboard/habits.html (enhanced mobile CSS, added input attributes)
- dashboard/test_habits_mobile.py (created)
[✓] Story 15.0: Backend - Delete habit endpoint
Commit: 0f9c0de
Date: 2026-02-10
Implementation:
- Added do_DELETE method to api.py for handling DELETE requests
- Route: DELETE /api/habits/{id} deletes habit by ID
- Extracts habit ID from URL path (/api/habits/{id})
- Returns 404 if habit not found in habits.json
- Removes habit from habits array using list.pop(index)
- Updates lastUpdated timestamp after deletion
- Returns 200 with success message, including deleted habit ID
- Graceful error handling for missing/corrupt habits.json (returns 404)
- Follows existing API patterns (similar to handle_habits_check)
- Error responses include descriptive error messages
Tests:
- Created dashboard/test_habits_delete.py with 6 comprehensive tests
- Tests for habit removal from habits.json file (AC1)
- Tests for 200 status with success message response (AC2)
- Tests for 404 when habit not found (AC3)
- Tests for lastUpdated timestamp update (AC4)
- Tests for edge cases: deleting already deleted habit, invalid paths
- Tests for graceful handling when habits.json is missing
- All 6 tests pass ✓ (AC5)
- All previous tests (schema, GET, POST, streak, check) still pass ✓
Files modified:
- dashboard/api.py (added do_DELETE method and handle_habits_delete)
- dashboard/test_habits_delete.py (created)
[✓] Story 16.0: Frontend - Delete habit with confirmation
Commit: 46dc3a5
Date: 2026-02-10
Implementation:
- Added delete button with trash icon (lucide trash-2) to each habit card
- Delete button styled with 32x32px size, border, hover state with red color
- Hover state changes border and background to danger color (rgba(239, 68, 68, 0.1))
- Created delete confirmation modal (id='deleteModal') with modal-overlay pattern
- Confirmation modal shows message: "Ștergi obișnuința {name}?" with habit name
- Modal includes Cancel button (btn-secondary) and Delete button (btn-danger)
- Delete button uses destructive red styling (.btn-danger class)
- Added showDeleteModal(habitId, habitName) function to display confirmation
- Added hideDeleteModal() function to close modal
- Added confirmDelete() async function to execute DELETE API call
- Delete button disabled during deletion with loading text "Se șterge..."
- On successful delete: hides modal, shows success toast, reloads habits list
- On error: shows error toast, re-enables delete button, keeps modal open
- Habit name properly escaped for XSS protection when passed to modal
- All styling uses CSS variables for theme consistency
Tests:
- Created dashboard/test_habits_delete_ui.py with 10 comprehensive tests
- Tests for delete button CSS styling (size, border, hover, danger color) [AC1]
- Tests for trash-2 icon inclusion in habit cards [AC1]
- Tests for confirmation modal structure with Romanian message [AC2]
- Tests for Cancel and Delete buttons with correct handlers [AC3]
- Tests for btn-danger destructive red styling [AC4]
- Tests for DELETE API call to /api/habits/{id} endpoint [AC5]
- Tests for loadHabits() call after successful deletion (list refresh) [AC5]
- Tests for error handling with toast notification [AC6]
- Tests for modal show/hide functions and active class toggle
- Summary test verifying all 7 acceptance criteria [AC7]
- All 10 tests pass ✓
- All previous tests (schema, API endpoints, HTML, modal, display, check, form, styling, navigation, mobile) still pass ✓
Files modified:
- dashboard/habits.html (added delete button, modal, CSS, and JavaScript functions)
- dashboard/test_habits_delete_ui.py (created)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
NEXT STEPS:
- Continue with remaining 1 story (17.0)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[✓] Story 17.0: Integration - End-to-end habit lifecycle test
Commit: bf215f7
Date: 2026-02-10
Implementation:
- Created dashboard/test_habits_integration.py with comprehensive integration tests
- Main test: test_complete_habit_lifecycle() covers full habit flow
- Test creates daily habit 'Bazin' via POST /api/habits
- Checks habit today via POST /api/habits/{id}/check (streak = 1)
- Simulates yesterday's check by manipulating habits.json file
- Verifies streak calculation is correct (streak = 2 for consecutive days)
- Deletes habit successfully via DELETE /api/habits/{id}
- Verifies habit no longer exists after deletion
- Additional test: test_broken_streak() validates gap detection (streak = 0)
- Additional test: test_weekly_habit_streak() validates weekly habit streaks
- Tests use HTTP test server on port 8765 in background thread
- Comprehensive validation of all API endpoints working together
- Proper setup/teardown with habits.json reset before/after tests
Tests:
- Created dashboard/test_habits_integration.py
- Main integration test passes all 5 steps (create, check, simulate, verify, delete)
- Tests for daily habit creation and checking (AC1, AC2)
- Tests for simulating yesterday's check and streak = 2 (AC3, AC4)
- Tests for habit deletion after lifecycle (AC5)
- Additional tests for broken streaks (gap > 1 day returns 0)
- Additional tests for weekly habit streak calculation (2 consecutive weeks)
- All tests pass ✓ (AC6)
- All previous tests (schema, API endpoints, HTML, modal, display, check, form, styling, navigation, mobile, delete) still pass ✓
Files modified:
- dashboard/test_habits_integration.py (created)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
FEATURE COMPLETE! 🎉
All 17 stories completed successfully:
- Data schema and backend API (7 stories)
- Frontend UI and interactions (10 stories)
- Comprehensive integration tests
The Habit Tracker feature is now fully implemented and tested.
Users can create habits (daily/weekly), track completions, view streaks,
and delete habits. The feature includes responsive design, proper error handling,
and full integration with the dashboard navigation.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

View File

@@ -313,17 +313,33 @@ def check_page(page, saved_versions, saved_hashes):
log(f"OK: {page_id}")
return None
def update_dashboard_status(has_changes, changes_count):
def update_dashboard_status(has_changes, changes_count, changes_list=None):
"""Actualizează status.json pentru dashboard"""
try:
status = load_json(DASHBOARD_STATUS, {})
status['anaf'] = {
anaf_status = {
'ok': not has_changes,
'status': 'MODIFICĂRI' if has_changes else 'OK',
'message': f'{changes_count} modificări detectate' if has_changes else 'Nicio modificare detectată',
'lastCheck': datetime.now().strftime('%d %b %Y, %H:%M'),
'changesCount': changes_count
}
# Adaugă detaliile modificărilor pentru dashboard
if has_changes and changes_list:
anaf_status['changes'] = []
for change in changes_list:
change_detail = {
'name': change.get('name', ''),
'url': change.get('url', ''),
'summary': []
}
# Ia primele 3 modificări ca rezumat
if change.get('changes'):
change_detail['summary'] = change['changes'][:3]
anaf_status['changes'].append(change_detail)
status['anaf'] = anaf_status
save_json(DASHBOARD_STATUS, status)
except Exception as e:
log(f"ERROR updating dashboard status: {e}")
@@ -345,7 +361,7 @@ def main():
save_json(HASHES_FILE, saved_hashes)
# Update dashboard status
update_dashboard_status(len(all_changes) > 0, len(all_changes))
update_dashboard_status(len(all_changes) > 0, len(all_changes), all_changes)
log("=== Monitor complete ===")