feat: US-011 - Frontend - Skip, lives display, and delete confirmation
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user