Feature: Habit Tracker with Streak Calculation #1
@@ -144,6 +144,46 @@
|
||||
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;
|
||||
@@ -575,8 +615,16 @@
|
||||
// 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>
|
||||
@@ -585,7 +633,7 @@
|
||||
<div class="habit-frequency">${habit.frequency === 'daily' ? 'Zilnic' : 'Săptămânal'}</div>
|
||||
</div>
|
||||
<div class="habit-streak">
|
||||
<span>${habit.streak || 0}</span>
|
||||
<span id="streak-${habit.id}">${habit.streak || 0}</span>
|
||||
<span>🔥</span>
|
||||
</div>
|
||||
`;
|
||||
@@ -600,6 +648,58 @@
|
||||
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();
|
||||
|
||||
222
dashboard/test_habits_check_ui.py
Normal file
222
dashboard/test_habits_check_ui.py
Normal file
@@ -0,0 +1,222 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for Story 10.0: Frontend - Check habit interaction
|
||||
"""
|
||||
|
||||
import sys
|
||||
import re
|
||||
|
||||
def load_html():
|
||||
"""Load habits.html content"""
|
||||
try:
|
||||
with open('dashboard/habits.html', 'r', encoding='utf-8') as f:
|
||||
return f.read()
|
||||
except FileNotFoundError:
|
||||
print("ERROR: dashboard/habits.html not found")
|
||||
sys.exit(1)
|
||||
|
||||
def test_checkbox_css_exists():
|
||||
"""Test that checkbox CSS styles are defined"""
|
||||
html = load_html()
|
||||
|
||||
# Check for checkbox class
|
||||
assert '.habit-checkbox' in html, "Missing .habit-checkbox CSS class"
|
||||
|
||||
# Check for circular shape (border-radius: 50%)
|
||||
assert 'border-radius: 50%' in html, "Checkbox should be circular (border-radius: 50%)"
|
||||
|
||||
# Check for checked state
|
||||
assert '.habit-checkbox.checked' in html, "Missing .habit-checkbox.checked CSS"
|
||||
|
||||
# Check for disabled state
|
||||
assert '.habit-checkbox.disabled' in html, "Missing .habit-checkbox.disabled CSS"
|
||||
|
||||
# Check for hover state
|
||||
assert '.habit-checkbox:hover' in html, "Missing .habit-checkbox:hover CSS"
|
||||
|
||||
print("✓ Checkbox CSS styles exist")
|
||||
|
||||
def test_checkbox_in_habit_card():
|
||||
"""Test that createHabitCard includes checkbox"""
|
||||
html = load_html()
|
||||
|
||||
# Check that createHabitCard creates a checkbox element
|
||||
assert 'habit-checkbox' in html, "createHabitCard should include checkbox element"
|
||||
|
||||
# Check for data-habit-id attribute
|
||||
assert 'data-habit-id' in html, "Checkbox should have data-habit-id attribute"
|
||||
|
||||
# Check for onclick handler
|
||||
assert 'onclick="checkHabit' in html, "Checkbox should have onclick='checkHabit' handler"
|
||||
|
||||
print("✓ Checkbox is included in habit card")
|
||||
|
||||
def test_checkbox_checked_state():
|
||||
"""Test that checkbox uses checkedToday to determine state"""
|
||||
html = load_html()
|
||||
|
||||
# Look for logic that checks habit.checkedToday
|
||||
assert 'checkedToday' in html, "Should check habit.checkedToday property"
|
||||
|
||||
# Check for conditional checked class
|
||||
assert 'checked' in html, "Should add 'checked' class when checkedToday is true"
|
||||
|
||||
# Check for check icon
|
||||
assert 'data-lucide="check"' in html, "Should show check icon when checked"
|
||||
|
||||
print("✓ Checkbox state reflects checkedToday")
|
||||
|
||||
def test_check_habit_function_exists():
|
||||
"""Test that checkHabit function is defined"""
|
||||
html = load_html()
|
||||
|
||||
# Check for function definition
|
||||
assert 'function checkHabit' in html or 'async function checkHabit' in html, \
|
||||
"checkHabit function should be defined"
|
||||
|
||||
# Check for parameters
|
||||
assert re.search(r'function checkHabit\s*\(\s*habitId', html) or \
|
||||
re.search(r'async function checkHabit\s*\(\s*habitId', html), \
|
||||
"checkHabit should accept habitId parameter"
|
||||
|
||||
print("✓ checkHabit function exists")
|
||||
|
||||
def test_check_habit_api_call():
|
||||
"""Test that checkHabit calls POST /api/habits/{id}/check"""
|
||||
html = load_html()
|
||||
|
||||
# Check for fetch call
|
||||
assert 'fetch(' in html, "checkHabit should use fetch API"
|
||||
|
||||
# Check for POST method
|
||||
assert "'POST'" in html or '"POST"' in html, "checkHabit should use POST method"
|
||||
|
||||
# Check for /api/habits/ endpoint
|
||||
assert '/api/habits/' in html, "Should call /api/habits/{id}/check endpoint"
|
||||
|
||||
# Check for /check path
|
||||
assert '/check' in html, "Should call endpoint with /check path"
|
||||
|
||||
print("✓ checkHabit calls POST /api/habits/{id}/check")
|
||||
|
||||
def test_optimistic_ui_update():
|
||||
"""Test that checkbox updates immediately (optimistic)"""
|
||||
html = load_html()
|
||||
|
||||
# Check for classList.add before fetch
|
||||
# The pattern should be: add 'checked' class, then fetch
|
||||
checkHabitFunc = html[html.find('async function checkHabit'):html.find('async function checkHabit') + 2000]
|
||||
|
||||
# Check for immediate classList modification
|
||||
assert 'classList.add' in checkHabitFunc, "Should add class immediately (optimistic update)"
|
||||
assert "'checked'" in checkHabitFunc or '"checked"' in checkHabitFunc, \
|
||||
"Should add 'checked' class optimistically"
|
||||
|
||||
print("✓ Optimistic UI update implemented")
|
||||
|
||||
def test_error_handling_revert():
|
||||
"""Test that checkbox reverts on error"""
|
||||
html = load_html()
|
||||
|
||||
# Check for catch block
|
||||
assert 'catch' in html, "checkHabit should have error handling (catch)"
|
||||
|
||||
# Check for classList.remove in error handler
|
||||
checkHabitFunc = html[html.find('async function checkHabit'):]
|
||||
|
||||
# Find the catch block
|
||||
if 'catch' in checkHabitFunc:
|
||||
catchBlock = checkHabitFunc[checkHabitFunc.find('catch'):]
|
||||
|
||||
# Check for revert logic
|
||||
assert 'classList.remove' in catchBlock, "Should revert checkbox on error"
|
||||
assert "'checked'" in catchBlock or '"checked"' in catchBlock, \
|
||||
"Should remove 'checked' class on error"
|
||||
|
||||
print("✓ Error handling reverts checkbox")
|
||||
|
||||
def test_disabled_when_checked():
|
||||
"""Test that checkbox is disabled when already checked"""
|
||||
html = load_html()
|
||||
|
||||
# Check for disabled class on checked habits
|
||||
assert 'disabled' in html, "Should add 'disabled' class to checked habits"
|
||||
|
||||
# Check for early return if disabled
|
||||
checkHabitFunc = html[html.find('async function checkHabit'):html.find('async function checkHabit') + 1000]
|
||||
assert 'disabled' in checkHabitFunc, "Should check if checkbox is disabled"
|
||||
assert 'return' in checkHabitFunc, "Should return early if disabled"
|
||||
|
||||
print("✓ Checkbox disabled when already checked")
|
||||
|
||||
def test_streak_updates():
|
||||
"""Test that streak updates after successful check"""
|
||||
html = load_html()
|
||||
|
||||
# Check for streak element ID
|
||||
assert 'streak-' in html, "Should use ID for streak element (e.g., streak-${habit.id})"
|
||||
|
||||
# Check for getElementById to update streak
|
||||
checkHabitFunc = html[html.find('async function checkHabit'):]
|
||||
assert 'getElementById' in checkHabitFunc or 'getElementById' in html, \
|
||||
"Should get streak element by ID to update it"
|
||||
|
||||
# Check that response data is used to update streak
|
||||
assert '.streak' in checkHabitFunc or 'streak' in checkHabitFunc, \
|
||||
"Should update streak from response data"
|
||||
|
||||
print("✓ Streak updates after successful check")
|
||||
|
||||
def test_check_icon_display():
|
||||
"""Test that check icon is shown when checked"""
|
||||
html = load_html()
|
||||
|
||||
# Check for lucide check icon
|
||||
assert 'data-lucide="check"' in html, "Should use lucide check icon"
|
||||
|
||||
# Check that icon is created/shown after checking
|
||||
checkHabitFunc = html[html.find('async function checkHabit'):html.find('async function checkHabit') + 1500]
|
||||
assert 'lucide.createIcons()' in checkHabitFunc, \
|
||||
"Should reinitialize lucide icons after adding check icon"
|
||||
|
||||
print("✓ Check icon displays correctly")
|
||||
|
||||
def main():
|
||||
"""Run all tests"""
|
||||
print("Running Story 10.0 Frontend Check Interaction Tests...\n")
|
||||
|
||||
tests = [
|
||||
test_checkbox_css_exists,
|
||||
test_checkbox_in_habit_card,
|
||||
test_checkbox_checked_state,
|
||||
test_check_habit_function_exists,
|
||||
test_check_habit_api_call,
|
||||
test_optimistic_ui_update,
|
||||
test_error_handling_revert,
|
||||
test_disabled_when_checked,
|
||||
test_streak_updates,
|
||||
test_check_icon_display,
|
||||
]
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
for test in tests:
|
||||
try:
|
||||
test()
|
||||
passed += 1
|
||||
except AssertionError as e:
|
||||
print(f"✗ {test.__name__}: {e}")
|
||||
failed += 1
|
||||
except Exception as e:
|
||||
print(f"✗ {test.__name__}: Unexpected error: {e}")
|
||||
failed += 1
|
||||
|
||||
print(f"\n{'='*50}")
|
||||
print(f"Results: {passed} passed, {failed} failed")
|
||||
print(f"{'='*50}")
|
||||
|
||||
return 0 if failed == 0 else 1
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user