Compare commits

...

15 Commits

15 changed files with 3908 additions and 0 deletions

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

726
dashboard/habits.html Normal file
View File

@@ -0,0 +1,726 @@
<!DOCTYPE html>
<html lang="ro">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/svg+xml" href="/echo/favicon.svg">
<title>Echo · Habit Tracker</title>
<link rel="stylesheet" href="/echo/common.css">
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
<script src="/echo/swipe-nav.js"></script>
<style>
.main {
max-width: 900px;
margin: 0 auto;
padding: var(--space-5);
}
.page-header {
margin-bottom: var(--space-5);
}
.page-title {
font-size: var(--text-xl);
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--space-1);
}
.page-subtitle {
font-size: var(--text-sm);
color: var(--text-muted);
}
/* Empty state */
.empty-state {
text-align: center;
padding: var(--space-6) var(--space-4);
color: var(--text-muted);
}
.empty-state svg {
width: 64px;
height: 64px;
margin-bottom: var(--space-3);
opacity: 0.5;
color: var(--text-muted);
}
.empty-state-message {
font-size: var(--text-base);
margin-bottom: var(--space-4);
}
/* Add habit button */
.add-habit-btn {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-5);
background: var(--accent);
color: white;
border: none;
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
}
.add-habit-btn:hover {
filter: brightness(1.1);
transform: translateY(-1px);
}
.add-habit-btn svg {
width: 18px;
height: 18px;
}
/* Habits list */
.habits-list {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.habit-card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-4);
display: flex;
align-items: center;
gap: var(--space-3);
transition: all var(--transition-fast);
}
.habit-card:hover {
border-color: var(--accent);
transform: translateY(-1px);
}
.habit-icon {
width: 40px;
height: 40px;
border-radius: var(--radius-md);
background: var(--bg-elevated);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.habit-icon svg {
width: 20px;
height: 20px;
color: var(--accent);
}
.habit-info {
flex: 1;
}
.habit-name {
font-size: var(--text-base);
font-weight: 500;
color: var(--text-primary);
margin-bottom: var(--space-1);
}
.habit-frequency {
font-size: var(--text-xs);
color: var(--text-muted);
text-transform: capitalize;
}
.habit-streak {
display: flex;
align-items: center;
gap: var(--space-1);
font-size: var(--text-lg);
font-weight: 600;
color: var(--accent);
flex-shrink: 0;
}
/* Habit checkbox */
.habit-checkbox {
width: 32px;
height: 32px;
border-radius: 50%;
border: 2px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all var(--transition-fast);
flex-shrink: 0;
background: var(--bg-base);
}
.habit-checkbox:hover:not(.disabled) {
border-color: var(--accent);
background: var(--accent-light, rgba(99, 102, 241, 0.1));
}
.habit-checkbox.checked {
background: var(--accent);
border-color: var(--accent);
}
.habit-checkbox.checked svg {
color: white;
}
.habit-checkbox.disabled {
cursor: not-allowed;
opacity: 0.6;
}
.habit-checkbox svg {
width: 18px;
height: 18px;
color: var(--accent);
}
/* Loading state */
.loading-state {
text-align: center;
padding: var(--space-6) var(--space-4);
color: var(--text-muted);
display: none;
}
.loading-state.active {
display: block;
}
.loading-state svg {
width: 48px;
height: 48px;
margin-bottom: var(--space-3);
opacity: 0.5;
color: var(--text-muted);
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Error state */
.error-state {
text-align: center;
padding: var(--space-6) var(--space-4);
color: var(--text-danger);
display: none;
}
.error-state.active {
display: block;
}
.error-state svg {
width: 48px;
height: 48px;
margin-bottom: var(--space-3);
color: var(--text-danger);
}
/* Modal */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal-overlay.active {
display: flex;
}
.modal {
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--space-5);
width: 90%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
}
.modal-title {
font-size: var(--text-lg);
font-weight: 600;
margin-bottom: var(--space-4);
}
.form-group {
margin-bottom: var(--space-4);
}
.form-label {
display: block;
font-size: var(--text-sm);
font-weight: 500;
margin-bottom: var(--space-1);
color: var(--text-secondary);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
margin-top: var(--space-5);
}
/* Radio group */
.radio-group {
display: flex;
gap: var(--space-3);
}
.radio-option {
flex: 1;
}
.radio-option input[type="radio"] {
display: none;
}
.radio-label {
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-3);
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
font-size: var(--text-sm);
font-weight: 500;
color: var(--text-secondary);
}
.radio-option input[type="radio"]:checked + .radio-label {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.radio-label:hover {
border-color: var(--accent);
}
/* Toast */
.toast {
position: fixed;
bottom: var(--space-5);
left: 50%;
transform: translateX(-50%) translateY(100px);
background: var(--bg-elevated);
border: 1px solid var(--border);
padding: var(--space-3) var(--space-5);
border-radius: var(--radius-md);
font-size: var(--text-sm);
opacity: 0;
transition: all var(--transition-base);
z-index: 1001;
}
.toast.show {
transform: translateX(-50%) translateY(0);
opacity: 1;
}
</style>
</head>
<body>
<header class="header">
<a href="/echo/index.html" class="logo">
<i data-lucide="circle-dot"></i>
Echo
</a>
<nav class="nav">
<a href="/echo/index.html" class="nav-item">
<i data-lucide="layout-dashboard"></i>
<span>Dashboard</span>
</a>
<a href="/echo/workspace.html" class="nav-item">
<i data-lucide="code"></i>
<span>Workspace</span>
</a>
<a href="/echo/notes.html" class="nav-item">
<i data-lucide="file-text"></i>
<span>KB</span>
</a>
<a href="/echo/files.html" class="nav-item">
<i data-lucide="folder"></i>
<span>Files</span>
</a>
<a href="/echo/habits.html" class="nav-item active">
<i data-lucide="target"></i>
<span>Habits</span>
</a>
<button class="theme-toggle" onclick="toggleTheme()" title="Schimbă tema">
<i data-lucide="sun" id="themeIcon"></i>
</button>
</nav>
</header>
<main class="main">
<div class="page-header">
<h1 class="page-title">Habit Tracker</h1>
<p class="page-subtitle">Urmărește-ți obiceiurile zilnice și săptămânale</p>
</div>
<!-- Habits container -->
<div id="habitsContainer">
<!-- Loading state -->
<div class="loading-state" id="loadingState">
<i data-lucide="loader"></i>
<p>Se încarcă obiceiurile...</p>
</div>
<!-- Error state -->
<div class="error-state" id="errorState">
<i data-lucide="alert-circle"></i>
<p>Eroare la încărcarea obiceiurilor</p>
<button class="btn btn-secondary" onclick="loadHabits()">Încearcă din nou</button>
</div>
<!-- Empty state -->
<div class="empty-state" id="emptyState" style="display: none;">
<i data-lucide="target"></i>
<p class="empty-state-message">Nicio obișnuință încă. Creează prima!</p>
<button class="add-habit-btn" onclick="showAddHabitModal()">
<i data-lucide="plus"></i>
<span>Adaugă obișnuință</span>
</button>
</div>
<!-- Habits list (hidden initially) -->
<div class="habits-list" id="habitsList" style="display: none;">
<!-- Habits will be populated here by JavaScript -->
</div>
</div>
</main>
<!-- Add Habit Modal -->
<div class="modal-overlay" id="habitModal">
<div class="modal">
<h2 class="modal-title">Obișnuință nouă</h2>
<div class="form-group">
<label class="form-label">Nume *</label>
<input type="text" class="input" id="habitName" placeholder="ex: Bazin, Sală, Meditație...">
</div>
<div class="form-group">
<label class="form-label">Frecvență</label>
<div class="radio-group">
<div class="radio-option">
<input type="radio" name="frequency" id="freqDaily" value="daily" checked>
<label for="freqDaily" class="radio-label">Zilnic</label>
</div>
<div class="radio-option">
<input type="radio" name="frequency" id="freqWeekly" value="weekly">
<label for="freqWeekly" class="radio-label">Săptămânal</label>
</div>
</div>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="hideHabitModal()">Anulează</button>
<button class="btn btn-primary" id="habitCreateBtn" onclick="createHabit()" disabled>Creează</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
// Theme management
function initTheme() {
const saved = localStorage.getItem('theme') || 'dark';
document.documentElement.setAttribute('data-theme', saved);
updateThemeIcon(saved);
}
function toggleTheme() {
const current = document.documentElement.getAttribute('data-theme') || 'dark';
const next = current === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
updateThemeIcon(next);
}
function updateThemeIcon(theme) {
const icon = document.getElementById('themeIcon');
if (icon) {
icon.setAttribute('data-lucide', theme === 'dark' ? 'sun' : 'moon');
lucide.createIcons();
}
}
// Initialize theme and icons
initTheme();
lucide.createIcons();
// Modal functions
function showAddHabitModal() {
const modal = document.getElementById('habitModal');
const nameInput = document.getElementById('habitName');
const createBtn = document.getElementById('habitCreateBtn');
// Reset form
nameInput.value = '';
document.getElementById('freqDaily').checked = true;
createBtn.disabled = true;
// Show modal
modal.classList.add('active');
nameInput.focus();
}
function hideHabitModal() {
const modal = document.getElementById('habitModal');
modal.classList.remove('active');
}
// Form validation - enable/disable Create button based on name input
document.addEventListener('DOMContentLoaded', () => {
const nameInput = document.getElementById('habitName');
const createBtn = document.getElementById('habitCreateBtn');
nameInput.addEventListener('input', () => {
const name = nameInput.value.trim();
createBtn.disabled = name.length === 0;
});
// Allow Enter key to submit if button is enabled
nameInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter' && !createBtn.disabled) {
createHabit();
}
});
});
// Create habit
async function createHabit() {
const nameInput = document.getElementById('habitName');
const createBtn = document.getElementById('habitCreateBtn');
const name = nameInput.value.trim();
const frequency = document.querySelector('input[name="frequency"]:checked').value;
if (!name) {
showToast('Te rog introdu un nume pentru obișnuință');
return;
}
// Disable button during submission (loading state)
createBtn.disabled = true;
const originalText = createBtn.textContent;
createBtn.textContent = 'Se creează...';
try {
const response = await fetch('/api/habits', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, frequency })
});
if (response.ok) {
// Clear input field after successful creation
nameInput.value = '';
hideHabitModal();
showToast('Obișnuință creată cu succes!');
loadHabits();
} else {
const error = await response.text();
showToast('Eroare la crearea obișnuinței: ' + error);
// Re-enable button on error (modal stays open)
createBtn.disabled = false;
createBtn.textContent = originalText;
}
} catch (error) {
showToast('Eroare la conectarea cu serverul');
console.error(error);
// Re-enable button on error (modal stays open)
createBtn.disabled = false;
createBtn.textContent = originalText;
}
}
// Toast notification
function showToast(message) {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.classList.add('show');
setTimeout(() => {
toast.classList.remove('show');
}, 3000);
}
// Load habits from API
async function loadHabits() {
const loadingState = document.getElementById('loadingState');
const errorState = document.getElementById('errorState');
const emptyState = document.getElementById('emptyState');
const habitsList = document.getElementById('habitsList');
// Show loading state
loadingState.classList.add('active');
errorState.classList.remove('active');
emptyState.style.display = 'none';
habitsList.style.display = 'none';
try {
const response = await fetch('/api/habits');
if (!response.ok) {
throw new Error('Failed to fetch habits');
}
const data = await response.json();
const habits = data.habits || [];
// Hide loading state
loadingState.classList.remove('active');
// Sort habits by streak descending (highest first)
habits.sort((a, b) => (b.streak || 0) - (a.streak || 0));
if (habits.length === 0) {
// Show empty state
emptyState.style.display = 'block';
} else {
// Render habits list
habitsList.innerHTML = '';
habits.forEach(habit => {
const card = createHabitCard(habit);
habitsList.appendChild(card);
});
habitsList.style.display = 'flex';
}
// Re-initialize Lucide icons
lucide.createIcons();
} catch (error) {
console.error('Error loading habits:', error);
loadingState.classList.remove('active');
errorState.classList.add('active');
}
}
// Create habit card element
function createHabitCard(habit) {
const card = document.createElement('div');
card.className = 'habit-card';
// Determine icon based on frequency
const iconName = habit.frequency === 'daily' ? 'calendar' : 'clock';
// Checkbox state
const isChecked = habit.checkedToday || false;
const checkboxClass = isChecked ? 'habit-checkbox checked disabled' : 'habit-checkbox';
const checkIcon = isChecked ? '<i data-lucide="check"></i>' : '';
// Create card HTML
card.innerHTML = `
<div class="${checkboxClass}" data-habit-id="${habit.id}" onclick="checkHabit('${habit.id}', this)">
${checkIcon}
</div>
<div class="habit-icon">
<i data-lucide="${iconName}"></i>
</div>
<div class="habit-info">
<div class="habit-name">${escapeHtml(habit.name)}</div>
<div class="habit-frequency">${habit.frequency === 'daily' ? 'Zilnic' : 'Săptămânal'}</div>
</div>
<div class="habit-streak">
<span id="streak-${habit.id}">${habit.streak || 0}</span>
<span>🔥</span>
</div>
`;
return card;
}
// Escape HTML to prevent XSS
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Check habit (mark as done for today)
async function checkHabit(habitId, checkboxElement) {
// Don't allow checking if already checked
if (checkboxElement.classList.contains('disabled')) {
return;
}
// Optimistic UI update
checkboxElement.classList.add('checked', 'disabled');
checkboxElement.innerHTML = '<i data-lucide="check"></i>';
lucide.createIcons();
// Store original state for rollback
const originalCheckbox = checkboxElement.cloneNode(true);
const streakElement = document.getElementById(`streak-${habitId}`);
const originalStreak = streakElement ? streakElement.textContent : '0';
try {
const response = await fetch(`/api/habits/${habitId}/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
if (!response.ok) {
throw new Error('Failed to check habit');
}
const data = await response.json();
// Update streak with server value
if (streakElement && data.habit && data.habit.streak !== undefined) {
streakElement.textContent = data.habit.streak;
}
showToast('Obișnuință bifată! 🎉');
} catch (error) {
console.error('Error checking habit:', error);
// Revert checkbox on error
checkboxElement.classList.remove('checked', 'disabled');
checkboxElement.innerHTML = '';
// Revert streak
if (streakElement) {
streakElement.textContent = originalStreak;
}
showToast('Eroare la bifarea obișnuinței. Încearcă din nou.');
}
}
// Load habits on page load
document.addEventListener('DOMContentLoaded', () => {
loadHabits();
});
</script>
</body>
</html>

4
dashboard/habits.json Normal file
View File

@@ -0,0 +1,4 @@
{
"habits": [],
"lastUpdated": "2026-02-10T12:39:00Z"
}

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

460
progress.txt Normal file
View File

@@ -0,0 +1,460 @@
=== HABIT TRACKER FEATURE PROGRESS ===
Date: 2026-02-10
Branch: feature/habit-tracker
Repo: /home/moltbot/clawd
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
COMPLETED STORIES:
[✓] Story 1.0: Define habits.json data schema
Commit: ee8727a
Date: 2026-02-10
Implementation:
- Created dashboard/habits.json with proper schema
- Root structure: lastUpdated (ISO timestamp) + habits (array)
- Habit schema: id (string), name (string), frequency (daily/weekly),
createdAt (ISO), completions (array of ISO dates)
- Initial file contains empty habits array with current timestamp
- All JSON validation passes
Tests:
- Created dashboard/test_habits_schema.py
- Tests for file existence, valid JSON, root structure
- Tests for lastUpdated ISO format validation
- Tests for habits array type
- Tests for complete habit schema (all required fields + types)
- Tests for initial empty state
- All tests pass ✓
Files modified:
- dashboard/habits.json (created)
- dashboard/test_habits_schema.py (created)
[✓] Story 2.0: Backend API - GET /api/habits
Commit: fc5ebf2
Date: 2026-02-10
Implementation:
- Added GET /api/habits endpoint to dashboard/api.py
- Endpoint returns habits array and lastUpdated timestamp
- Graceful error handling: returns empty array if file missing/corrupt
- Follows existing API patterns (similar to /api/git, /api/status)
- Returns 200 status for all valid requests
Tests:
- Created dashboard/test_habits_api.py
- Tests for endpoint existence (returns 200)
- Tests for valid JSON response
- Tests for response structure (habits array + lastUpdated)
- Tests for ISO timestamp validation
- Tests for empty file handling (returns [], not error)
- Tests for habits with data
- All 6 tests pass ✓
Files modified:
- dashboard/api.py (added handle_habits_get method + route)
- dashboard/test_habits_api.py (created)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CODEBASE PATTERNS:
1. JSON Data Files
- Location: dashboard/*.json
- Pattern: Similar structure to tasks.json, todos.json, issues.json
- All use ISO timestamps for dates
- Root objects contain metadata + data arrays
2. Testing Approach
- Python test files in dashboard/ directory
- Test naming: test_*.py
- Comprehensive validation: existence, JSON validity, schema, types
- Run tests from repo root with: python3 dashboard/test_*.py
3. Build Validation
- Command: python3 -m py_compile dashboard/api.py
- Validates Python syntax without executing
4. Utility Functions in api.py
- Standalone utility functions placed before TaskBoardHandler class
- Documented with docstrings (Args, Returns, Rules/behavior)
- ISO timestamp parsing pattern: datetime.fromisoformat(ts.replace('Z', '+00:00'))
- Convert to date only when time doesn't matter: dt.date()
- Use try/except for robust parsing with sensible defaults (return 0, [], etc.)
[✓] Story 3.0: Backend API - POST /api/habits (create habit)
Commit: 3a09e6c
Date: 2026-02-10
Implementation:
- Added POST /api/habits endpoint to dashboard/api.py
- Accepts {name, frequency} in request body
- Returns 400 for missing name or empty name (after trim)
- Returns 400 for invalid frequency (must be 'daily' or 'weekly')
- Generates unique id following format 'habit-{millisecond_timestamp}'
- Sets createdAt to current ISO timestamp
- Initializes completions array as empty
- Persists new habit to habits.json
- Updates lastUpdated timestamp in habits.json
- Returns 201 status with created habit object
- Graceful handling of missing/corrupt habits.json file
Tests:
- Created dashboard/test_habits_post.py
- Tests for successful habit creation (returns 201 + full habit object)
- Tests for habit persistence to habits.json
- Tests for correct id format (habit-{timestamp})
- Tests for missing name validation (400)
- Tests for empty name validation (400)
- Tests for invalid frequency validation (400)
- Tests for missing frequency validation (400)
- Tests for creating multiple habits (unique IDs)
- Tests for lastUpdated timestamp update
- All 9 tests pass ✓
Files modified:
- dashboard/api.py (added handle_habits_post method + route)
- dashboard/test_habits_post.py (created)
[✓] Story 4.0: Backend API - Streak calculation utility
Commit: 3927b7c
Date: 2026-02-10
Implementation:
- Created calculate_streak(completions, frequency) utility function
- Added to dashboard/api.py as standalone function (before TaskBoardHandler class)
- Accepts completions array (ISO timestamps) and frequency ('daily' or 'weekly')
- Returns integer streak count (days for daily, weeks for weekly)
- Daily habits: counts consecutive days without gaps
- Weekly habits: counts consecutive weeks (7-day periods)
- Returns 0 for no completions, invalid dates, or broken streaks
- Edge case: today's completion counts even if streak was 0 yesterday
- Edge case: multiple completions same day/week count as one period
- Robust date parsing with error handling for invalid ISO timestamps
Tests:
- Created dashboard/test_habits_streak.py
- Tests for no completions (returns 0)
- Tests for daily single completion (today and yesterday)
- Tests for daily consecutive days (5 days streak)
- Tests for daily broken streak (gap detection)
- Tests for daily old completion (>1 day ago returns 0)
- Tests for weekly single completion (this week)
- Tests for weekly consecutive weeks (4 weeks streak)
- Tests for weekly broken streak (missing week)
- Tests for weekly old completion (>7 days ago returns 0)
- Tests for multiple completions same day (deduplicated)
- Tests for today counting despite yesterday missing
- Tests for invalid date format handling
- Tests for weekly multiple in same week (deduplicated)
- All 14 tests pass ✓
- All previous tests (schema, GET, POST) still pass ✓
Files modified:
- dashboard/api.py (added calculate_streak function)
- dashboard/test_habits_streak.py (created)
[✓] Story 5.0: Backend API - POST /api/habits/{id}/check
Commit: ca4ee77
Date: 2026-02-10
Implementation:
- Added POST /api/habits/{id}/check endpoint to dashboard/api.py
- Extracts habit ID from URL path (/api/habits/{id}/check)
- Adds today's date (YYYY-MM-DD) to completions array
- Returns 400 if habit already checked today
- Returns 404 if habit ID not found
- Sorts completions chronologically (oldest first) after adding
- Uses ISO date format YYYY-MM-DD (not full timestamps)
- Calculates and returns streak using calculate_streak utility
- Returns 200 with updated habit object including streak
- Streak is calculated but not persisted (only in response)
- Updates lastUpdated timestamp in habits.json
- Graceful error handling for missing/corrupt files
Tests:
- Created dashboard/test_habits_check.py
- Tests for successful habit check (returns 200 + updated habit)
- Tests for already checked validation (400 error)
- Tests for habit not found (404 error)
- Tests for persistence to habits.json
- Tests for chronological sorting of completions
- Tests for streak calculation in response
- Tests for weekly habit checking
- Tests for ISO date format (YYYY-MM-DD, no time)
- All 8 tests pass ✓
- All previous tests (schema, GET, POST, streak) still pass ✓
Files modified:
- dashboard/api.py (added handle_habits_check method + route in do_POST)
- dashboard/test_habits_check.py (created)
- dashboard/habits.json (reset to empty for testing)
[✓] Story 6.0: Backend API - GET /api/habits with streaks
Commit: c84135d
Date: 2026-02-10
Implementation:
- Enhanced GET /api/habits endpoint to include calculated streaks
- Each habit in response now includes 'streak' field (integer)
- Each habit in response now includes 'checkedToday' field (boolean)
- Streak is calculated using the calculate_streak utility function
- checkedToday checks if today's date (YYYY-MM-DD) is in completions array
- All original habit fields are preserved in response
- Get today's date once and reuse for all habits (efficient)
- Enhanced habits array built by looping through each habit and adding fields
- Updated docstring to reflect new functionality
Tests:
- Created dashboard/test_habits_get_enhanced.py
- Tests for streak field inclusion in response
- Tests for checkedToday boolean field inclusion
- Tests for correct streak calculation (daily and weekly habits)
- Tests for broken streaks (should return 0)
- Tests for empty habits array handling
- Tests for preservation of original habit fields
- All 5 tests pass ✓
- All previous tests (schema, GET, POST, streak, check) still pass ✓
Files modified:
- dashboard/api.py (enhanced handle_habits_get method)
- dashboard/test_habits_get_enhanced.py (created)
[✓] Story 7.0: Frontend - Create habits.html page structure
Commit: dd0bf24
Date: 2026-02-10
Implementation:
- Created dashboard/habits.html with basic layout matching dashboard style
- Uses common.css and swipe-nav.js for consistent styling and navigation
- Added navigation bar with 5 items (Dashboard, Workspace, KB, Files, Habits)
- Habits nav item has 'active' class to indicate current page
- Page header with title "Habit Tracker" and subtitle
- Empty state section with lucide 'target' icon
- Empty state message: "Nicio obișnuință încă. Creează prima!"
- Add habit button with lucide 'plus' icon and text "Adaugă obișnuință"
- Theme toggle functionality (dark/light mode) matching dashboard
- Placeholder JavaScript functions for future API integration
- HTML5 compliant structure with lang="ro" attribute
Tests:
- Created dashboard/test_habits_html.py
- Tests for file existence
- Tests for valid HTML5 structure (DOCTYPE, required tags, lang attribute)
- Tests for common.css and swipe-nav.js inclusion
- Tests for navigation bar with correct items and active state
- Tests for page title "Habit Tracker" in both <title> and <h1>
- Tests for empty state message with exact text
- Tests for add habit button with lucide plus icon
- All 7 tests pass ✓
- All previous tests (schema, API endpoints) still pass ✓
Files modified:
- dashboard/habits.html (created)
- dashboard/test_habits_html.py (created)
- dashboard/habits.json (reset to empty for testing)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CODEBASE PATTERNS UPDATE:
5. Frontend HTML Pages
- Location: dashboard/*.html
- Common structure: DOCTYPE html, lang="ro", UTF-8 charset
- Shared resources: common.css, swipe-nav.js, lucide icons CDN
- Navigation pattern: header.header > logo + nav.nav > nav-item links
- Active nav item has 'active' class
- Theme toggle button in nav with onclick="toggleTheme()"
- Main content in <main class="main"> with max-width container
- Page header pattern: .page-header > .page-title + .page-subtitle
- Empty states: .empty-state with centered icon, message, and action button
- Icons: use lucide via data-lucide attribute, initialize with lucide.createIcons()
[✓] Story 8.0: Frontend - Create habit form modal
Commit: 97af2ae
Date: 2026-02-10
Implementation:
- Added modal HTML structure to habits.html with id='habitModal'
- Modal overlay uses dashboard styling patterns (overlay + modal container)
- Form includes text input for habit name (id='habitName', required)
- Form includes radio button group for frequency selection
- Two radio options: daily (default, checked) and weekly
- Custom styled radio buttons using .radio-group and .radio-label classes
- Radio buttons hidden, labels styled as clickable cards
- Selected radio shows accent background color
- Modal actions with Cancel (btn-secondary) and Create (btn-primary) buttons
- Cancel button calls hideHabitModal()
- Create button calls createHabit() and starts disabled
- Modal CSS follows dashboard patterns: modal-overlay, modal, modal-title, form-group, form-label, modal-actions
- Added toast notification element for user feedback
- JavaScript: showAddHabitModal() opens modal and resets form
- JavaScript: hideHabitModal() closes modal by removing 'active' class
- JavaScript: DOMContentLoaded event listener for form validation
- Input event listener on name field enables/disables Create button
- Create button disabled when name is empty (after trim)
- Enter key submits form if Create button is enabled
- createHabit() async function posts to /api/habits
- On success: hides modal, shows toast, calls loadHabits()
- On error: shows toast with error message
- showToast() function displays temporary notification (3 seconds)
- Modal uses CSS variables for theming (--bg-base, --border, --accent, etc.)
- Responsive design with max-width: 500px and 90% width
Tests:
- Created dashboard/test_habits_modal.py
- Tests for modal structure (overlay, container, title)
- Tests for name input field with required indicator (*)
- Tests for frequency radio buttons (daily/weekly, daily checked by default)
- Tests for radio labels with correct Romanian text
- Tests for Cancel and Create buttons with correct classes and onclick handlers
- Tests for Create button starting disabled
- Tests for add habit button calling showAddHabitModal()
- Tests for modal CSS styling patterns
- Tests for JavaScript functions (showAddHabitModal, hideHabitModal, createHabit)
- Tests for form validation logic (disable button, trim validation)
- Tests for modal show/hide logic (classList.add/remove 'active')
- Tests for API integration (fetch /api/habits POST)
- Tests for toast notification element and function
- Tests for event listeners (DOMContentLoaded, input, keypress)
- All 9 tests pass ✓
- All previous tests (schema, API endpoints, HTML structure) still pass ✓
Files modified:
- dashboard/habits.html (added modal HTML, CSS, and JavaScript)
- dashboard/test_habits_modal.py (created)
[✓] Story 9.0: Frontend - Display habits list
Commit: 0483d73
Date: 2026-02-10
Implementation:
- Added CSS for habit cards (.habit-card, .habit-icon, .habit-info, .habit-streak)
- Added CSS for loading state with spinner animation
- Added CSS for error state with retry button
- Enhanced HTML with loading, error, and empty states (all with IDs)
- Implemented loadHabits() async function to fetch from /api/habits
- Habits sorted by streak descending (highest first)
- Loading state shown while fetching (loadingState.classList.add('active'))
- Error state shown on fetch failure with retry button
- Empty state shown when habits array is empty
- Habits list shown when habits exist
- Created createHabitCard(habit) function to render habit cards
- Daily habits show calendar icon (lucide), weekly show clock icon
- Each habit displays: name, frequency label, streak with 🔥 emoji
- Streak displayed as: "{number} 🔥" in accent color
- XSS protection via escapeHtml() function (uses textContent)
- Lucide icons reinitialized after rendering (lucide.createIcons())
- loadHabits() called on DOMContentLoaded (automatic page load)
- Habits data includes streak and checkedToday from API
Tests:
- Created dashboard/test_habits_display.py
- Tests for loading state structure (element, class, icon, message)
- Tests for error state structure (element, class, icon, message, retry button)
- Tests for empty state ID attribute
- Tests for habits list container existence
- Tests for loadHabits function implementation and API fetch
- Tests for sorting by streak descending (b.streak - a.streak)
- Tests for frequency icons (calendar for daily, clock for weekly)
- Tests for streak display with flame emoji 🔥
- Tests for state management (show/hide logic)
- Tests for error handling (catch block)
- Tests for createHabitCard function existence
- Tests for page load trigger (DOMContentLoaded listener)
- Tests for habit card CSS styling
- Tests for Lucide icons reinitialization after rendering
- Tests for XSS protection (escapeHtml function)
- All 15 tests pass ✓
- All previous tests (schema, API, HTML, modal) still pass ✓
Files modified:
- dashboard/habits.html (added habit cards CSS, loading/error states HTML, loadHabits implementation)
- dashboard/test_habits_display.py (created)
[✓] Story 10.0: Frontend - Check habit interaction
Commit: 775f171
Date: 2026-02-10
Implementation:
- Added circular checkbox button to each habit card
- Checkbox positioned at start of card (before icon)
- Checkbox styled with border-radius: 50% for circular shape
- Checkbox shows check icon when checkedToday is true
- Checkbox has 'checked' and 'disabled' classes when already done today
- Clicking checkbox calls checkHabit(habitId, element) function
- checkHabit performs optimistic UI update (checks immediately)
- API call to POST /api/habits/{id}/check executed after UI update
- On success: streak element updated with response data, shows toast
- On error: checkbox reverts to unchecked state, shows error toast
- Checkbox is non-clickable (disabled) when already checked today
- Streak updates dynamically using id="streak-{habitId}" element
- Check icon reinitialized with lucide.createIcons() after adding
- Hover state for unchecked checkboxes (border-color change)
- All CSS uses CSS variables for theming consistency
Tests:
- Created dashboard/test_habits_check_ui.py
- Tests for checkbox CSS (circular shape, checked/disabled states, hover)
- Tests for checkbox inclusion in createHabitCard function
- Tests for checkedToday state reflection in UI
- Tests for checkHabit function existence and signature
- Tests for API call to POST /api/habits/{id}/check
- Tests for optimistic UI update (classList.add before fetch)
- Tests for error handling and revert logic
- Tests for disabled state when already checked
- Tests for streak update from response data
- Tests for check icon display and lucide reinitialization
- All 10 tests pass ✓
- All previous tests (schema, API endpoints, HTML, modal, display) still pass ✓
Files modified:
- dashboard/habits.html (added checkbox CSS and checkHabit function)
- dashboard/test_habits_check_ui.py (created)
[✓] Story 11.0: Frontend - Create habit from form
Commit: 4933847
Date: 2026-02-10
Implementation:
- Enhanced createHabit() async function with complete form submission flow
- Added loading state: button disabled during submission with "Se creează..." text
- Button disabled immediately on submit (before API call)
- Original button text stored and restored on error
- Input field cleared after successful creation (nameInput.value = '')
- Success flow: closes modal, shows success toast, reloads habits list
- Error flow: button re-enabled, modal stays open, shows error toast
- Both API errors (response.ok check) and network errors (catch block) handled
- Error messages displayed to user via toast notifications
- Modal stays open on error so user can retry without re-entering data
- All existing form validation preserved (empty name check, trim validation)
- Enter key submission still works with loading state integration
Tests:
- Created dashboard/test_habits_form_submit.py with 9 comprehensive tests
- Tests for form submission API call (POST /api/habits with name and frequency)
- Tests for loading state (button disabled, text changed to "Se creează...")
- Tests for success behavior (modal closes, list refreshes, input cleared)
- Tests for error behavior (modal stays open, button re-enabled, error shown)
- Tests for input field clearing after successful creation
- Tests for preservation of existing form validation logic
- Tests for modal reset when opened (form cleared)
- Tests for Enter key submission integration
- Tests for all 6 acceptance criteria in summary test
- All 9 tests pass ✓
- All previous tests (schema, API endpoints, HTML, modal, display, check) still pass ✓
Files modified:
- dashboard/habits.html (enhanced createHabit function)
- dashboard/test_habits_form_submit.py (created)
- dashboard/habits.json (reset to empty for testing)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
NEXT STEPS:
- Continue with remaining 7 stories
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━