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