Feature: Habit Tracker with Streak Calculation #1

Closed
Marius wants to merge 26 commits from feature/habit-tracker into master
13 changed files with 3181 additions and 0 deletions
Showing only changes of commit 0483d73ef8 - Show all commits

View File

@@ -77,13 +77,118 @@
height: 18px;
}
/* Habits list (for future use) */
/* 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;
}
/* 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;
@@ -241,8 +346,21 @@
<!-- 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">
<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()">
@@ -397,10 +515,95 @@
}, 3000);
}
// Load habits (placeholder for future API integration)
// Load habits from API
async function loadHabits() {
// Will be implemented in next story
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';
// Create card HTML
card.innerHTML = `
<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>${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;
}
// Load habits on page load
document.addEventListener('DOMContentLoaded', () => {
loadHabits();
});
</script>
</body>
</html>

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)