feat: US-010 - Frontend - Check-in interaction (click and long-press)

This commit is contained in:
Echo
2026-02-10 16:54:09 +00:00
parent f838958bf2
commit 5ed8680164
2 changed files with 605 additions and 5 deletions

View File

@@ -522,6 +522,74 @@
.toast.error svg {
color: var(--error);
}
/* Pulse animation */
@keyframes pulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
.pulse {
animation: pulse 0.5s ease-in-out;
}
/* Check-in detail modal */
.checkin-modal {
max-width: 400px;
}
.rating-stars {
display: flex;
gap: var(--space-2);
justify-content: center;
margin: var(--space-3) 0;
}
.rating-star {
font-size: 32px;
cursor: pointer;
transition: all var(--transition-base);
opacity: 0.3;
}
.rating-star.active {
opacity: 1;
}
.rating-star:hover {
transform: scale(1.2);
opacity: 0.8;
}
.mood-buttons {
display: flex;
gap: var(--space-3);
justify-content: center;
margin: var(--space-3) 0;
}
.mood-btn {
font-size: 32px;
cursor: pointer;
padding: var(--space-2);
border: 2px solid transparent;
border-radius: var(--radius-md);
transition: all var(--transition-base);
background: none;
}
.mood-btn:hover {
transform: scale(1.1);
}
.mood-btn.selected {
border-color: var(--accent);
background: var(--accent-muted);
}
</style>
</head>
<body>
@@ -659,6 +727,53 @@
</div>
</div>
<!-- Check-in Detail Modal -->
<div id="checkinModal" class="modal-overlay">
<div class="modal checkin-modal">
<div class="modal-header">
<h2 class="modal-title">Check-In Details</h2>
<button class="modal-close" onclick="closeCheckinModal()">
<i data-lucide="x"></i>
</button>
</div>
<div class="modal-body">
<!-- Note -->
<div class="form-field">
<label class="form-label" for="checkinNote">Note (optional)</label>
<textarea id="checkinNote" class="form-textarea" placeholder="How did it go?"></textarea>
</div>
<!-- Rating -->
<div class="form-field">
<label class="form-label">Rating (optional)</label>
<div class="rating-stars" id="ratingStars">
<span class="rating-star" data-rating="1" onclick="selectRating(1)"></span>
<span class="rating-star" data-rating="2" onclick="selectRating(2)"></span>
<span class="rating-star" data-rating="3" onclick="selectRating(3)"></span>
<span class="rating-star" data-rating="4" onclick="selectRating(4)"></span>
<span class="rating-star" data-rating="5" onclick="selectRating(5)"></span>
</div>
</div>
<!-- Mood -->
<div class="form-field">
<label class="form-label">Mood (optional)</label>
<div class="mood-buttons">
<button class="mood-btn" data-mood="happy" onclick="selectMood('happy')">😊</button>
<button class="mood-btn" data-mood="neutral" onclick="selectMood('neutral')">😐</button>
<button class="mood-btn" data-mood="sad" onclick="selectMood('sad')">😞</button>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn" onclick="closeCheckinModal()">Cancel</button>
<button type="submit" class="btn btn-primary" id="submitCheckinBtn" onclick="submitCheckInDetail()">
Done!
</button>
</div>
</div>
</div>
<script>
// Theme management
function initTheme() {
@@ -722,6 +837,26 @@
const habitsHtml = habits.map(habit => renderHabitCard(habit)).join('');
container.innerHTML = `<div class="habits-grid">${habitsHtml}</div>`;
lucide.createIcons();
// Attach event handlers to check-in buttons
habits.forEach(habit => {
if (!isCheckedToday(habit)) {
const btn = document.getElementById(`checkin-btn-${habit.id}`);
if (btn) {
// Right-click to open detail modal
btn.addEventListener('contextmenu', (e) => handleCheckInButtonPress(habit.id, e, true));
// Mouse/touch events for long-press detection
btn.addEventListener('mousedown', (e) => handleCheckInButtonPress(habit.id, e, true));
btn.addEventListener('mouseup', (e) => handleCheckInButtonRelease(habit.id, e));
btn.addEventListener('mouseleave', () => handleCheckInButtonCancel());
btn.addEventListener('touchstart', (e) => handleCheckInButtonPress(habit.id, e, false));
btn.addEventListener('touchend', (e) => handleCheckInButtonRelease(habit.id, e));
btn.addEventListener('touchcancel', () => handleCheckInButtonCancel());
}
}
});
}
// Render single habit card
@@ -757,7 +892,7 @@
<button
class="habit-card-check-btn"
onclick="checkInHabit('${habit.id}')"
id="checkin-btn-${habit.id}"
${isDoneToday ? 'disabled' : ''}
>
${isDoneToday ? '✓ Done today' : 'Check In'}
@@ -897,6 +1032,11 @@
if (e.target === modal) {
closeHabitModal();
}
const checkinModal = document.getElementById('checkinModal');
if (e.target === checkinModal) {
closeCheckinModal();
}
});
// Initialize color picker
@@ -1201,9 +1341,209 @@
alert('Delete functionality - coming in next story!');
}
// Check in habit (placeholder)
async function checkInHabit(habitId) {
alert('Check-in functionality - coming in next story!');
// Check-in state
let checkInHabitId = null;
let checkInRating = null;
let checkInMood = null;
let longPressTimer = null;
// Check in habit (simple click)
async function checkInHabit(habitId, event) {
// Prevent simple check-in if this was triggered during long-press detection
if (event && event.type === 'mousedown') {
return; // Let the long-press handler deal with it
}
try {
const response = await fetch(`/echo/api/habits/${habitId}/check`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({})
});
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 streak
showToast(`Habit checked! 🔥 Streak: ${updatedHabit.streak?.current || 0}`, 'success');
// Refresh habits list
await loadHabits();
// Add pulse animation to the updated card
setTimeout(() => {
const card = event?.target?.closest('.habit-card');
if (card) {
card.classList.add('pulse');
setTimeout(() => card.classList.remove('pulse'), 500);
}
}, 100);
} catch (error) {
console.error('Failed to check in:', error);
showToast('Failed to check in: ' + error.message, 'error');
}
}
// Show check-in detail modal
function showCheckInDetailModal(habitId) {
checkInHabitId = habitId;
checkInRating = null;
checkInMood = null;
// Reset form
document.getElementById('checkinNote').value = '';
// Reset rating stars
document.querySelectorAll('.rating-star').forEach(star => {
star.classList.remove('active');
});
// Reset mood buttons
document.querySelectorAll('.mood-btn').forEach(btn => {
btn.classList.remove('selected');
});
// Show modal
const modal = document.getElementById('checkinModal');
modal.classList.add('active');
lucide.createIcons();
}
// Close check-in detail modal
function closeCheckinModal() {
const modal = document.getElementById('checkinModal');
modal.classList.remove('active');
checkInHabitId = null;
checkInRating = null;
checkInMood = null;
}
// Select rating
function selectRating(rating) {
checkInRating = rating;
// Update star display
document.querySelectorAll('.rating-star').forEach((star, index) => {
if (index < rating) {
star.classList.add('active');
} else {
star.classList.remove('active');
}
});
}
// Select mood
function selectMood(mood) {
checkInMood = mood;
// Update mood button display
document.querySelectorAll('.mood-btn').forEach(btn => {
if (btn.dataset.mood === mood) {
btn.classList.add('selected');
} else {
btn.classList.remove('selected');
}
});
}
// Submit check-in with details
async function submitCheckInDetail() {
if (!checkInHabitId) {
showToast('No habit selected', 'error');
return;
}
const submitBtn = document.getElementById('submitCheckinBtn');
submitBtn.disabled = true;
try {
const note = document.getElementById('checkinNote').value.trim();
// Build request body with optional fields
const body = {};
if (note) body.note = note;
if (checkInRating) body.rating = checkInRating;
if (checkInMood) body.mood = checkInMood;
const response = await fetch(`/echo/api/habits/${checkInHabitId}/check`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
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 streak
showToast(`Habit checked! 🔥 Streak: ${updatedHabit.streak?.current || 0}`, 'success');
// Close modal
closeCheckinModal();
// Refresh habits list
await loadHabits();
} catch (error) {
console.error('Failed to check in:', error);
showToast('Failed to check in: ' + error.message, 'error');
} finally {
submitBtn.disabled = false;
}
}
// Handle long-press for check-in detail modal
function handleCheckInButtonPress(habitId, event, isMouseEvent) {
// Prevent default context menu on right-click
if (event.type === 'contextmenu') {
event.preventDefault();
showCheckInDetailModal(habitId);
return;
}
// For touch/mouse press, start long-press timer
if (event.type === 'mousedown' || event.type === 'touchstart') {
event.preventDefault();
longPressTimer = setTimeout(() => {
showCheckInDetailModal(habitId);
longPressTimer = null;
}, 500); // 500ms for long-press
}
}
// Handle release of check-in button
function handleCheckInButtonRelease(habitId, event) {
if (event.type === 'mouseup' || event.type === 'touchend') {
// If long-press timer is still running, it was a short press
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
// Perform simple check-in
checkInHabit(habitId, event);
}
}
}
// Cancel long-press if moved away
function handleCheckInButtonCancel() {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
}
// Show error message

View File

@@ -4,6 +4,7 @@ Story US-006: Frontend - Page structure, layout, and navigation link
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)
"""
import sys
@@ -748,6 +749,253 @@ def test_typecheck_us009():
print("✓ Test 44: Typecheck passes (edit modal fully implemented)")
def test_checkin_simple_click():
"""Test 45: Simple click on check-in button sends POST request"""
habits_path = Path(__file__).parent.parent / 'habits.html'
content = habits_path.read_text()
# Check that checkInHabit function exists and does POST
assert 'function checkInHabit' in content or 'async function checkInHabit' in content, \
"checkInHabit function should be defined"
checkin_start = content.find('function checkInHabit')
checkin_end = content.find('\n }', checkin_start + 500)
checkin_func = content[checkin_start:checkin_end]
assert "fetch(`/echo/api/habits/${habitId}/check`" in checkin_func or \
'fetch(`/echo/api/habits/${habitId}/check`' in checkin_func, \
"Should POST to /echo/api/habits/{id}/check"
assert "method: 'POST'" in checkin_func, "Should use POST method"
print("✓ Test 45: Simple click sends POST to check-in endpoint")
def test_checkin_detail_modal_structure():
"""Test 46: Check-in detail modal exists with note, rating, and mood fields"""
habits_path = Path(__file__).parent.parent / 'habits.html'
content = habits_path.read_text()
# Check modal exists
assert 'id="checkinModal"' in content, "Should have check-in detail modal"
assert 'Check-In Details' in content or 'Check-in Details' in content, \
"Modal should have title 'Check-In Details'"
# Check for note textarea
assert 'id="checkinNote"' in content, "Should have note textarea"
assert '<textarea' in content, "Should have textarea element"
# Check for rating stars
assert 'rating-star' in content, "Should have rating star elements"
assert 'selectRating' in content, "Should have selectRating function"
# Check for mood buttons
assert 'mood-btn' in content, "Should have mood button elements"
assert 'selectMood' in content, "Should have selectMood function"
assert '😊' in content and '😐' in content and '😞' in content, \
"Should have happy, neutral, and sad emojis"
print("✓ Test 46: Check-in detail modal has note, rating, and mood fields")
def test_checkin_long_press_handler():
"""Test 47: Long-press (mobile) and right-click (desktop) handlers exist"""
habits_path = Path(__file__).parent.parent / 'habits.html'
content = habits_path.read_text()
# Check for long-press handling functions
assert 'handleCheckInButtonPress' in content, \
"Should have handleCheckInButtonPress function"
assert 'handleCheckInButtonRelease' in content, \
"Should have handleCheckInButtonRelease function"
# Check for contextmenu event (right-click)
assert "contextmenu" in content, "Should handle contextmenu event for right-click"
# Check for touch/mouse events
assert "mousedown" in content, "Should handle mousedown event"
assert "mouseup" in content, "Should handle mouseup event"
assert "touchstart" in content or "touch" in content, \
"Should handle touch events for mobile"
# Check for long-press timer
assert 'longPressTimer' in content, "Should track long-press timer"
assert '500' in content, "Should use 500ms delay for long-press"
print("✓ Test 47: Long-press and right-click handlers exist")
def test_checkin_detail_modal_functions():
"""Test 48: Modal functions (show, close, selectRating, selectMood) are defined"""
habits_path = Path(__file__).parent.parent / 'habits.html'
content = habits_path.read_text()
# Check modal control functions
assert 'function showCheckInDetailModal' in content, \
"Should have showCheckInDetailModal function"
assert 'function closeCheckinModal' in content, \
"Should have closeCheckinModal function"
# Check selection functions
assert 'function selectRating' in content, "Should have selectRating function"
assert 'function selectMood' in content, "Should have selectMood function"
# Check submit function
assert 'function submitCheckInDetail' in content or 'async function submitCheckInDetail' in content, \
"Should have submitCheckInDetail function"
print("✓ Test 48: Check-in modal functions are defined")
def test_checkin_detail_submit():
"""Test 49: Detail modal submit sends note, rating, and mood"""
habits_path = Path(__file__).parent.parent / 'habits.html'
content = habits_path.read_text()
# Find submitCheckInDetail function
submit_start = content.find('function submitCheckInDetail')
submit_end = content.find('\n }', submit_start + 1000)
submit_func = content[submit_start:submit_end]
# Check it builds body with optional fields
assert 'body.note' in submit_func or 'if (note)' in submit_func, \
"Should add note to body if provided"
assert 'body.rating' in submit_func or 'checkInRating' in submit_func, \
"Should add rating to body if provided"
assert 'body.mood' in submit_func or 'checkInMood' in submit_func, \
"Should add mood to body if provided"
# Check it POSTs to API
assert "fetch(`/echo/api/habits/" in submit_func, "Should POST to API"
assert "/check`" in submit_func, "Should POST to check endpoint"
print("✓ Test 49: Detail modal submit includes optional fields")
def test_checkin_toast_with_streak():
"""Test 50: Toast notification shows 'Habit checked! 🔥 Streak: X'"""
habits_path = Path(__file__).parent.parent / 'habits.html'
content = habits_path.read_text()
# Check for toast with streak message
assert 'Habit checked!' in content, "Should show 'Habit checked!' message"
assert '🔥' in content, "Should include fire emoji in streak message"
assert 'Streak:' in content, "Should show streak count"
# Check that streak value comes from API response
checkin_start = content.find('function checkInHabit')
checkin_area = content[checkin_start:checkin_start+2000]
assert 'updatedHabit' in checkin_area or 'await response.json()' in checkin_area, \
"Should get updated habit from response"
assert 'streak' in checkin_area, "Should access streak from response"
print("✓ Test 50: Toast shows 'Habit checked! 🔥 Streak: X'")
def test_checkin_button_disabled_after():
"""Test 51: Check-in button becomes disabled with 'Done today' after check-in"""
habits_path = Path(__file__).parent.parent / 'habits.html'
content = habits_path.read_text()
# Check that renderHabitCard uses isCheckedToday to disable button
assert 'isCheckedToday(habit)' in content, \
"Should check if habit is checked today"
# Check button uses disabled attribute based on condition
assert 'isDoneToday ? \'disabled\' : \'\'' in content or \
'${isDoneToday ? \'disabled\' : \'\'}' in content or \
'disabled' in content, \
"Button should have conditional disabled attribute"
# Check button text changes
assert '✓ Done today' in content or 'Done today' in content, \
"Button should show 'Done today' when disabled"
assert 'Check In' in content, "Button should show 'Check In' when enabled"
print("✓ Test 51: Button becomes disabled with 'Done today' after check-in")
def test_checkin_pulse_animation():
"""Test 52: Pulse animation plays on card after successful check-in"""
habits_path = Path(__file__).parent.parent / 'habits.html'
content = habits_path.read_text()
# Check for pulse animation CSS
assert '@keyframes pulse' in content, "Should define pulse animation"
assert 'transform: scale' in content, "Pulse should use scale transform"
assert '.pulse' in content, "Should have pulse CSS class"
# Check that checkInHabit adds pulse class
checkin_start = content.find('function checkInHabit')
checkin_area = content[checkin_start:checkin_start+2000]
assert 'pulse' in checkin_area or 'classList.add' in checkin_area, \
"Should add pulse class to card after check-in"
print("✓ Test 52: Pulse animation defined and applied")
def test_checkin_prevent_context_menu():
"""Test 53: Right-click prevents default context menu"""
habits_path = Path(__file__).parent.parent / 'habits.html'
content = habits_path.read_text()
# Check for preventDefault on contextmenu
press_handler_start = content.find('function handleCheckInButtonPress')
press_handler_end = content.find('\n }', press_handler_start + 1000)
press_handler = content[press_handler_start:press_handler_end]
assert 'contextmenu' in press_handler, "Should check for contextmenu event"
assert 'preventDefault()' in press_handler, "Should call preventDefault on right-click"
print("✓ Test 53: Right-click prevents default context menu")
def test_checkin_event_listeners_attached():
"""Test 54: Event listeners are attached to check-in buttons in renderHabits"""
habits_path = Path(__file__).parent.parent / 'habits.html'
content = habits_path.read_text()
# Find renderHabits function
render_start = content.find('function renderHabits()')
render_end = content.find('\n }', render_start + 2000)
render_func = content[render_start:render_end]
# Check that event listeners are attached
assert 'addEventListener' in render_func, \
"Should attach event listeners after rendering"
assert 'contextmenu' in render_func or 'contextmenu' in content[render_end:render_end+1000], \
"Should attach contextmenu listener"
assert 'mousedown' in render_func or 'mousedown' in content[render_end:render_end+1000], \
"Should attach mousedown listener"
# Check that we iterate through habits to attach listeners
assert 'habits.forEach' in render_func or 'for' in render_func, \
"Should iterate through habits to attach listeners"
print("✓ Test 54: Event listeners attached in renderHabits")
def test_typecheck_us010():
"""Test 55: Typecheck passes - all check-in functions defined"""
habits_path = Path(__file__).parent.parent / 'habits.html'
content = habits_path.read_text()
# Check all required functions are defined
required_functions = [
'checkInHabit',
'showCheckInDetailModal',
'closeCheckinModal',
'selectRating',
'selectMood',
'submitCheckInDetail',
'handleCheckInButtonPress',
'handleCheckInButtonRelease',
'handleCheckInButtonCancel'
]
for func in required_functions:
assert f'function {func}' in content or f'async function {func}' in content, \
f"{func} should be defined"
# Check state variables
assert 'let checkInHabitId' in content, "checkInHabitId should be declared"
assert 'let checkInRating' in content, "checkInRating should be declared"
assert 'let checkInMood' in content, "checkInMood should be declared"
assert 'let longPressTimer' in content, "longPressTimer should be declared"
print("✓ Test 55: Typecheck passes (all check-in functions and variables defined)")
def run_all_tests():
"""Run all tests in sequence"""
tests = [
@@ -799,9 +1047,21 @@ def run_all_tests():
test_edit_modal_close_resets_state,
test_edit_modal_no_console_errors,
test_typecheck_us009,
# US-010 tests
test_checkin_simple_click,
test_checkin_detail_modal_structure,
test_checkin_long_press_handler,
test_checkin_detail_modal_functions,
test_checkin_detail_submit,
test_checkin_toast_with_streak,
test_checkin_button_disabled_after,
test_checkin_pulse_animation,
test_checkin_prevent_context_menu,
test_checkin_event_listeners_attached,
test_typecheck_us010,
]
print(f"\nRunning {len(tests)} frontend tests for US-006, US-007, US-008, and US-009...\n")
print(f"\nRunning {len(tests)} frontend tests for US-006, US-007, US-008, US-009, and US-010...\n")
failed = []
for test in tests: