feat: US-011 - Frontend - Skip, lives display, and delete confirmation

This commit is contained in:
Echo
2026-02-10 17:07:25 +00:00
parent 5ed8680164
commit 8897de25ed
2 changed files with 262 additions and 5 deletions

View File

@@ -198,10 +198,37 @@
.habit-card-lives {
display: flex;
justify-content: center;
gap: var(--space-1);
align-items: center;
gap: var(--space-2);
font-size: var(--text-lg);
}
.habit-card-lives-hearts {
display: flex;
gap: var(--space-1);
}
.habit-card-skip-btn {
background: none;
border: none;
color: var(--text-muted);
font-size: var(--text-xs);
cursor: pointer;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
transition: all var(--transition-base);
}
.habit-card-skip-btn:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--text-primary);
}
.habit-card-skip-btn:disabled {
cursor: not-allowed;
opacity: 0.3;
}
.habit-card-completion {
font-size: var(--text-sm);
color: var(--text-muted);
@@ -811,6 +838,8 @@
throw new Error(`HTTP ${response.status}`);
}
habits = await response.json();
// Cache habits for delete confirmation
localStorage.setItem('habitsCache', JSON.stringify(habits));
renderHabits();
} catch (error) {
console.error('Failed to load habits:', error);
@@ -900,7 +929,17 @@
<div class="habit-card-last-check">${lastCheckInfo}</div>
<div class="habit-card-lives">${livesHtml}</div>
<div class="habit-card-lives">
<div class="habit-card-lives-hearts">${livesHtml}</div>
<button
class="habit-card-skip-btn"
onclick="skipHabitDay('${habit.id}', '${escapeHtml(habit.name)}')"
${habit.lives === 0 ? 'disabled' : ''}
title="${habit.lives === 0 ? 'No lives left' : 'Skip today and use 1 life'}"
>
Skip day
</button>
</div>
<div class="habit-card-completion">${completionRate}% (30d)</div>
@@ -1335,10 +1374,66 @@
// Delete habit (placeholder)
async function deleteHabit(habitId) {
if (!confirm('Are you sure you want to delete this habit?')) {
// Find habit to get its name for confirmation
const habits = JSON.parse(localStorage.getItem('habitsCache') || '[]');
const habit = habits.find(h => h.id === habitId);
const habitName = habit ? habit.name : 'this habit';
if (!confirm(`Delete ${habitName}? This cannot be undone.`)) {
return;
}
alert('Delete functionality - coming in next story!');
try {
const response = await fetch(`/echo/api/habits/${habitId}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
// Show success toast
showToast('Habit deleted', 'success');
// Refresh habits list (will remove card from DOM)
await loadHabits();
} catch (error) {
console.error('Failed to delete habit:', error);
showToast(`Failed to delete habit: ${error.message}`, 'error');
}
}
// Skip a habit day using a life
async function skipHabitDay(habitId, habitName) {
if (!confirm('Use 1 life to skip today?')) {
return;
}
try {
const response = await fetch(`/echo/api/habits/${habitId}/skip`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || `HTTP ${response.status}`);
}
const updatedHabit = await response.json();
// Show success toast with remaining lives
const remainingLives = updatedHabit.lives;
showToast(`Day skipped. ${remainingLives} ${remainingLives === 1 ? 'life' : 'lives'} remaining.`, 'success');
// Refresh habits list
await loadHabits();
} catch (error) {
console.error('Failed to skip habit day:', error);
showToast(`Failed to skip: ${error.message}`, 'error');
}
}
// Check-in state

View File

@@ -5,6 +5,7 @@ Story US-007: Frontend - Habit card component
Story US-008: Frontend - Create habit modal with all options
Story US-009: Frontend - Edit habit modal
Story US-010: Frontend - Check-in interaction (click and long-press)
Story US-011: Frontend - Skip, lives display, and delete confirmation
"""
import sys
@@ -996,6 +997,156 @@ def test_typecheck_us010():
print("✓ Test 55: Typecheck passes (all check-in functions and variables defined)")
# ========== US-011 Tests: Skip, lives display, and delete confirmation ==========
def test_skip_button_visible():
"""Test 56: Skip button visible next to lives hearts on each card"""
html_path = Path(__file__).parent.parent / 'habits.html'
content = html_path.read_text()
# Check skip button CSS class exists
assert '.habit-card-skip-btn' in content, "Skip button CSS class should exist"
# Check skip button is in renderHabitCard
assert 'Skip day' in content, "Skip button text should be 'Skip day'"
assert 'onclick="skipHabitDay' in content, "Skip button should call skipHabitDay function"
# Check lives display structure has both hearts and button
assert 'habit-card-lives-hearts' in content, "Lives hearts should be wrapped in separate div"
print("✓ Test 56: Skip button visible next to lives hearts on each card")
def test_skip_confirmation_dialog():
"""Test 57: Clicking skip shows confirmation dialog"""
html_path = Path(__file__).parent.parent / 'habits.html'
content = html_path.read_text()
# Check skipHabitDay function exists
assert 'async function skipHabitDay' in content, "skipHabitDay function should exist"
# Check confirmation dialog
assert 'Use 1 life to skip today?' in content, "Skip confirmation dialog should ask 'Use 1 life to skip today?'"
assert 'confirm(' in content, "Should use confirm dialog"
print("✓ Test 57: Clicking skip shows confirmation dialog")
def test_skip_sends_post_and_refreshes():
"""Test 58: Confirming skip sends POST /echo/api/habits/{id}/skip and refreshes card"""
html_path = Path(__file__).parent.parent / 'habits.html'
content = html_path.read_text()
# Check API endpoint
assert "/echo/api/habits/${habitId}/skip" in content, "Should POST to /echo/api/habits/{id}/skip"
assert "method: 'POST'" in content, "Should use POST method"
# Check refresh
assert 'await loadHabits()' in content, "Should refresh habits list after skip"
print("✓ Test 58: Confirming skip sends POST /echo/api/habits/{id}/skip and refreshes card")
def test_skip_button_disabled_when_no_lives():
"""Test 59: Skip button disabled when lives is 0 with tooltip 'No lives left'"""
html_path = Path(__file__).parent.parent / 'habits.html'
content = html_path.read_text()
# Check disabled condition
assert "habit.lives === 0 ? 'disabled' : ''" in content, "Skip button should be disabled when lives is 0"
# Check tooltip
assert 'No lives left' in content, "Tooltip should say 'No lives left' when disabled"
print("✓ Test 59: Skip button disabled when lives is 0 with tooltip 'No lives left'")
def test_skip_toast_message():
"""Test 60: Toast shows 'Day skipped. X lives remaining.' after skip"""
html_path = Path(__file__).parent.parent / 'habits.html'
content = html_path.read_text()
# Check toast message format
assert 'Day skipped' in content, "Toast should include 'Day skipped'"
assert 'remaining' in content, "Toast should show remaining lives"
# Check life/lives plural handling
assert "remainingLives === 1 ? 'life' : 'lives'" in content, "Should handle singular/plural for lives"
print("✓ Test 60: Toast shows 'Day skipped. X lives remaining.' after skip")
def test_delete_confirmation_with_habit_name():
"""Test 61: Clicking trash icon shows delete confirmation dialog with habit name"""
html_path = Path(__file__).parent.parent / 'habits.html'
content = html_path.read_text()
# Check delete function exists
assert 'async function deleteHabit' in content, "deleteHabit function should exist"
# Check confirmation uses habit name
assert 'Delete ${habitName}?' in content or 'Delete ' in content, "Delete confirmation should include habit name"
assert 'This cannot be undone' in content, "Delete confirmation should warn 'This cannot be undone'"
print("✓ Test 61: Clicking trash icon shows delete confirmation dialog with habit name")
def test_delete_sends_delete_request():
"""Test 62: Confirming delete sends DELETE /echo/api/habits/{id} and removes card"""
html_path = Path(__file__).parent.parent / 'habits.html'
content = html_path.read_text()
# Check DELETE request
assert "method: 'DELETE'" in content, "Should use DELETE method"
assert "/echo/api/habits/${habitId}" in content or "/echo/api/habits/" in content, "Should DELETE to /echo/api/habits/{id}"
# Check refresh (removes card from DOM via re-render)
assert 'await loadHabits()' in content, "Should refresh habits list after delete"
print("✓ Test 62: Confirming delete sends DELETE /echo/api/habits/{id} and removes card")
def test_delete_toast_message():
"""Test 63: Toast shows 'Habit deleted' after deletion"""
html_path = Path(__file__).parent.parent / 'habits.html'
content = html_path.read_text()
# Check success toast
assert 'Habit deleted' in content, "Toast should say 'Habit deleted'"
print("✓ Test 63: Toast shows 'Habit deleted' after deletion")
def test_skip_delete_no_console_errors():
"""Test 64: No obvious console error sources in skip and delete code"""
html_path = Path(__file__).parent.parent / 'habits.html'
content = html_path.read_text()
# Check error handling in skipHabitDay
skip_func = content[content.find('async function skipHabitDay'):content.find('async function skipHabitDay') + 1500]
assert 'try' in skip_func and 'catch' in skip_func, "skipHabitDay should have try/catch"
assert 'console.error' in skip_func, "skipHabitDay should log errors"
# Check error handling in deleteHabit
delete_func = content[content.find('async function deleteHabit'):content.find('async function deleteHabit') + 1500]
assert 'try' in delete_func and 'catch' in delete_func, "deleteHabit should have try/catch"
assert 'console.error' in delete_func, "deleteHabit should log errors"
print("✓ Test 64: No obvious console error sources in skip and delete code")
def test_typecheck_us011():
"""Test 65: Typecheck passes (all skip and delete functions defined)"""
html_path = Path(__file__).parent.parent / 'habits.html'
content = html_path.read_text()
# Check function definitions
functions = [
'skipHabitDay',
'deleteHabit',
]
for func in functions:
assert f"function {func}" in content, \
f"{func} should be defined"
# Check that habits are cached for delete
assert 'localStorage.setItem' in content, "Should cache habits in localStorage"
assert 'habitsCache' in content, "Should use 'habitsCache' key"
print("✓ Test 65: Typecheck passes (all skip and delete functions defined)")
def run_all_tests():
"""Run all tests in sequence"""
tests = [
@@ -1059,9 +1210,20 @@ def run_all_tests():
test_checkin_prevent_context_menu,
test_checkin_event_listeners_attached,
test_typecheck_us010,
# US-011 tests
test_skip_button_visible,
test_skip_confirmation_dialog,
test_skip_sends_post_and_refreshes,
test_skip_button_disabled_when_no_lives,
test_skip_toast_message,
test_delete_confirmation_with_habit_name,
test_delete_sends_delete_request,
test_delete_toast_message,
test_skip_delete_no_console_errors,
test_typecheck_us011,
]
print(f"\nRunning {len(tests)} frontend tests for US-006, US-007, US-008, US-009, and US-010...\n")
print(f"\nRunning {len(tests)} frontend tests for US-006, US-007, US-008, US-009, US-010, and US-011...\n")
failed = []
for test in tests: