feat: 10.0 - Frontend - Check habit interaction
This commit is contained in:
@@ -144,6 +144,46 @@
|
|||||||
flex-shrink: 0;
|
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 */
|
||||||
.loading-state {
|
.loading-state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -575,8 +615,16 @@
|
|||||||
// Determine icon based on frequency
|
// Determine icon based on frequency
|
||||||
const iconName = habit.frequency === 'daily' ? 'calendar' : 'clock';
|
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
|
// Create card HTML
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
|
<div class="${checkboxClass}" data-habit-id="${habit.id}" onclick="checkHabit('${habit.id}', this)">
|
||||||
|
${checkIcon}
|
||||||
|
</div>
|
||||||
<div class="habit-icon">
|
<div class="habit-icon">
|
||||||
<i data-lucide="${iconName}"></i>
|
<i data-lucide="${iconName}"></i>
|
||||||
</div>
|
</div>
|
||||||
@@ -585,7 +633,7 @@
|
|||||||
<div class="habit-frequency">${habit.frequency === 'daily' ? 'Zilnic' : 'Săptămânal'}</div>
|
<div class="habit-frequency">${habit.frequency === 'daily' ? 'Zilnic' : 'Săptămânal'}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="habit-streak">
|
<div class="habit-streak">
|
||||||
<span>${habit.streak || 0}</span>
|
<span id="streak-${habit.id}">${habit.streak || 0}</span>
|
||||||
<span>🔥</span>
|
<span>🔥</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -600,6 +648,58 @@
|
|||||||
return div.innerHTML;
|
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
|
// Load habits on page load
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
loadHabits();
|
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