feat: US-007 - Frontend - Habit card component

This commit is contained in:
Echo
2026-02-10 16:28:08 +00:00
parent f889e69b54
commit b99133de79
2 changed files with 437 additions and 11 deletions

View File

@@ -79,13 +79,17 @@
opacity: 0.7; opacity: 0.7;
} }
/* Habit card (placeholder for next story) */ /* Habit card */
.habit-card { .habit-card {
background: var(--bg-surface); background: var(--bg-surface);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
border-left: 4px solid var(--accent);
padding: var(--space-4); padding: var(--space-4);
transition: all var(--transition-base); transition: all var(--transition-base);
display: flex;
flex-direction: column;
gap: var(--space-3);
} }
.habit-card:hover { .habit-card:hover {
@@ -94,17 +98,157 @@
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
} }
.habit-name { .habit-card-header {
display: flex;
align-items: center;
gap: var(--space-2);
}
.habit-card-icon {
width: 20px;
height: 20px;
color: var(--text-primary);
flex-shrink: 0;
}
.habit-card-name {
flex: 1;
font-size: var(--text-base); font-size: var(--text-base);
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
margin-bottom: var(--space-2);
} }
.habit-meta { .habit-card-actions {
display: flex;
gap: var(--space-2);
}
.habit-card-action-btn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: var(--space-1);
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
transition: all var(--transition-base);
}
.habit-card-action-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.habit-card-action-btn svg {
width: 16px;
height: 16px;
}
.habit-card-streaks {
display: flex;
gap: var(--space-4);
font-size: var(--text-sm); font-size: var(--text-sm);
color: var(--text-muted); color: var(--text-muted);
} }
.habit-card-streak {
display: flex;
align-items: center;
gap: var(--space-1);
}
.habit-card-check-btn {
width: 100%;
padding: var(--space-3);
border: 2px solid var(--accent);
background: var(--accent);
color: white;
border-radius: var(--radius-md);
font-size: var(--text-base);
font-weight: 600;
cursor: pointer;
transition: all var(--transition-base);
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
}
.habit-card-check-btn:hover:not(:disabled) {
background: var(--accent-hover);
transform: scale(1.02);
}
.habit-card-check-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
background: var(--bg-muted);
border-color: var(--border);
color: var(--text-muted);
}
.habit-card-last-check {
font-size: var(--text-sm);
color: var(--text-muted);
text-align: center;
}
.habit-card-lives {
display: flex;
justify-content: center;
gap: var(--space-1);
font-size: var(--text-lg);
}
.habit-card-completion {
font-size: var(--text-sm);
color: var(--text-muted);
text-align: center;
}
.habit-card-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: var(--space-2);
border-top: 1px solid var(--border);
}
.habit-card-category {
font-size: var(--text-xs);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
background: var(--bg-muted);
color: var(--text-muted);
}
.habit-card-priority {
font-size: var(--text-xs);
color: var(--text-muted);
display: flex;
align-items: center;
gap: var(--space-1);
}
.priority-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
}
.priority-high {
background: var(--error);
}
.priority-medium {
background: var(--warning);
}
.priority-low {
background: var(--success);
}
</style> </style>
</head> </head>
<body> <body>
@@ -222,24 +366,134 @@
lucide.createIcons(); lucide.createIcons();
} }
// Render single habit card (placeholder - full card in next story) // Render single habit card
function renderHabitCard(habit) { function renderHabitCard(habit) {
const isDoneToday = isCheckedToday(habit);
const lastCheckInfo = getLastCheckInfo(habit);
const livesHtml = renderLives(habit.lives || 3);
const completionRate = habit.completion_rate_30d || 0;
return ` return `
<div class="habit-card"> <div class="habit-card" style="border-left-color: ${habit.color}">
<div class="habit-name">${escapeHtml(habit.name)}</div> <div class="habit-card-header">
<div class="habit-meta"> <i data-lucide="${habit.icon || 'circle'}" class="habit-card-icon"></i>
Frequency: ${habit.frequency.type} <span class="habit-card-name">${escapeHtml(habit.name)}</span>
${habit.category ? ` · ${habit.category}` : ''} <div class="habit-card-actions">
<button class="habit-card-action-btn" onclick="showEditHabitModal('${habit.id}')" title="Edit">
<i data-lucide="settings"></i>
</button>
<button class="habit-card-action-btn" onclick="deleteHabit('${habit.id}')" title="Delete">
<i data-lucide="trash-2"></i>
</button>
</div>
</div>
<div class="habit-card-streaks">
<div class="habit-card-streak">
🔥 ${habit.streak?.current || 0}
</div>
<div class="habit-card-streak">
🏆 ${habit.streak?.best || 0}
</div>
</div>
<button
class="habit-card-check-btn"
onclick="checkInHabit('${habit.id}')"
${isDoneToday ? 'disabled' : ''}
>
${isDoneToday ? '✓ Done today' : 'Check In'}
</button>
<div class="habit-card-last-check">${lastCheckInfo}</div>
<div class="habit-card-lives">${livesHtml}</div>
<div class="habit-card-completion">${completionRate}% (30d)</div>
<div class="habit-card-footer">
<span class="habit-card-category">${escapeHtml(habit.category || 'General')}</span>
<span class="habit-card-priority">
<span class="priority-indicator priority-${getPriorityLevel(habit.priority || 3)}"></span>
P${habit.priority || 3}
</span>
</div> </div>
</div> </div>
`; `;
} }
// Check if habit was checked today
function isCheckedToday(habit) {
if (!habit.completions || habit.completions.length === 0) {
return false;
}
const today = new Date().toISOString().split('T')[0];
return habit.completions.some(c => c.date === today);
}
// Get last check-in info text
function getLastCheckInfo(habit) {
if (!habit.completions || habit.completions.length === 0) {
return 'Last: Never';
}
const lastCompletion = habit.completions[habit.completions.length - 1];
const lastDate = new Date(lastCompletion.date);
const today = new Date();
today.setHours(0, 0, 0, 0);
lastDate.setHours(0, 0, 0, 0);
const diffDays = Math.floor((today - lastDate) / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return 'Last: Today';
} else if (diffDays === 1) {
return 'Last: Yesterday';
} else {
return `Last: ${diffDays} days ago`;
}
}
// Render lives as hearts
function renderLives(lives) {
const totalLives = 3;
let html = '';
for (let i = 0; i < totalLives; i++) {
html += i < lives ? '❤️' : '🖤';
}
return html;
}
// Get priority level string
function getPriorityLevel(priority) {
if (priority === 1) return 'high';
if (priority === 2) return 'medium';
return 'low';
}
// Show add habit modal (placeholder - full modal in next stories) // Show add habit modal (placeholder - full modal in next stories)
function showAddHabitModal() { function showAddHabitModal() {
alert('Add Habit modal - coming in next story!'); alert('Add Habit modal - coming in next story!');
} }
// Show edit habit modal (placeholder)
function showEditHabitModal(habitId) {
alert('Edit Habit modal - coming in next story!');
}
// Delete habit (placeholder)
async function deleteHabit(habitId) {
if (!confirm('Are you sure you want to delete this habit?')) {
return;
}
alert('Delete functionality - coming in next story!');
}
// Check in habit (placeholder)
async function checkInHabit(habitId) {
alert('Check-in functionality - coming in next story!');
}
// Show error message // Show error message
function showError(message) { function showError(message) {
const container = document.getElementById('habitsContainer'); const container = document.getElementById('habitsContainer');

View File

@@ -1,6 +1,7 @@
""" """
Test suite for Habits frontend page structure and navigation Test suite for Habits frontend page structure and navigation
Story US-006: Frontend - Page structure, layout, and navigation link Story US-006: Frontend - Page structure, layout, and navigation link
Story US-007: Frontend - Habit card component
""" """
import sys import sys
@@ -149,9 +150,168 @@ def test_typecheck():
print("✓ Test 10: HTML structure is well-formed") print("✓ Test 10: HTML structure is well-formed")
def test_card_colored_border():
"""Test 11: Habit card has colored left border matching habit.color"""
habits_path = Path(__file__).parent.parent / 'habits.html'
content = habits_path.read_text()
assert 'border-left-color' in content or 'borderLeftColor' in content, \
"Card should have colored left border"
assert 'habit.color' in content, "Card should use habit.color for border"
print("✓ Test 11: Card has colored left border")
def test_card_header_icons():
"""Test 12: Card header shows icon, name, settings, and delete"""
habits_path = Path(__file__).parent.parent / 'habits.html'
content = habits_path.read_text()
# Check for icon display
assert 'habit.icon' in content or 'habit-card-icon' in content, \
"Card should display habit icon"
# Check for name display
assert 'habit.name' in content or 'habit-card-name' in content, \
"Card should display habit name"
# Check for settings (gear) icon
assert 'settings' in content.lower(), "Card should have settings icon"
# Check for delete (trash) icon
assert 'trash' in content.lower(), "Card should have delete icon"
print("✓ Test 12: Card header has icon, name, settings, and delete")
def test_card_streak_display():
"""Test 13: Streak displays with fire emoji for current and trophy for best"""
habits_path = Path(__file__).parent.parent / 'habits.html'
content = habits_path.read_text()
assert '🔥' in content, "Card should have fire emoji for current streak"
assert '🏆' in content, "Card should have trophy emoji for best streak"
assert 'habit.streak' in content or 'streak?.current' in content or 'streak.current' in content, \
"Card should display streak.current"
assert 'streak?.best' in content or 'streak.best' in content, \
"Card should display streak.best"
print("✓ Test 13: Streak display with fire and trophy emojis")
def test_card_checkin_button():
"""Test 14: Check-in button is large and centered"""
habits_path = Path(__file__).parent.parent / 'habits.html'
content = habits_path.read_text()
assert 'habit-card-check-btn' in content or 'check-btn' in content or 'checkin' in content.lower(), \
"Card should have check-in button"
assert 'Check In' in content or 'Check in' in content, \
"Button should have 'Check In' text"
# Check for button styling (large/centered)
assert 'width: 100%' in content or 'width:100%' in content, \
"Check-in button should be full-width"
print("✓ Test 14: Check-in button is large and centered")
def test_card_checkin_disabled_when_done():
"""Test 15: Check-in button disabled when already checked today"""
habits_path = Path(__file__).parent.parent / 'habits.html'
content = habits_path.read_text()
assert 'disabled' in content, "Button should have disabled state"
assert 'Done today' in content or 'Done' in content, \
"Button should show 'Done today' when disabled"
assert 'isCheckedToday' in content or 'isDoneToday' in content, \
"Should have function to check if habit is done today"
print("✓ Test 15: Check-in button disabled when done today")
def test_card_lives_display():
"""Test 16: Lives display shows filled and empty hearts (total 3)"""
habits_path = Path(__file__).parent.parent / 'habits.html'
content = habits_path.read_text()
assert '❤️' in content or '' in content, "Card should have filled heart emoji"
assert '🖤' in content or '' in content, "Card should have empty heart emoji"
assert 'habit.lives' in content or 'renderLives' in content, \
"Card should display lives"
# Check for lives rendering function
assert 'renderLives' in content or 'lives' in content.lower(), \
"Should have lives rendering logic"
print("✓ Test 16: Lives display with hearts")
def test_card_completion_rate():
"""Test 17: Completion rate percentage is displayed"""
habits_path = Path(__file__).parent.parent / 'habits.html'
content = habits_path.read_text()
assert 'completion_rate' in content or 'completion' in content, \
"Card should display completion rate"
assert '(30d)' in content or '30d' in content, \
"Completion rate should show 30-day period"
assert '%' in content, "Completion rate should show percentage"
print("✓ Test 17: Completion rate displayed")
def test_card_footer_category_priority():
"""Test 18: Footer shows category badge and priority"""
habits_path = Path(__file__).parent.parent / 'habits.html'
content = habits_path.read_text()
assert 'habit.category' in content or 'habit-card-category' in content, \
"Card should display category"
assert 'habit.priority' in content or 'priority' in content.lower(), \
"Card should display priority"
assert 'habit-card-footer' in content or 'footer' in content.lower(), \
"Card should have footer section"
print("✓ Test 18: Footer shows category and priority")
def test_card_lucide_createicons():
"""Test 19: lucide.createIcons() is called after rendering cards"""
habits_path = Path(__file__).parent.parent / 'habits.html'
content = habits_path.read_text()
# Check that createIcons is called after rendering
render_pos = content.find('renderHabits')
if render_pos != -1:
after_render = content[render_pos:]
assert 'lucide.createIcons()' in after_render, \
"lucide.createIcons() should be called after rendering"
print("✓ Test 19: lucide.createIcons() called after rendering")
def test_card_common_css_variables():
"""Test 20: Card uses common.css variables for styling"""
habits_path = Path(__file__).parent.parent / 'habits.html'
content = habits_path.read_text()
# Check for common.css variable usage
assert '--bg-surface' in content or '--text-primary' in content or '--border' in content, \
"Card should use common.css variables"
assert 'var(--' in content, "Should use CSS variables"
print("✓ Test 20: Card uses common.css variables")
def test_typecheck_us007():
"""Test 21: Typecheck passes for US-007"""
habits_path = Path(__file__).parent.parent / 'habits.html'
assert habits_path.exists(), "habits.html should exist"
# Check that all functions are properly defined
content = habits_path.read_text()
assert 'function renderHabitCard(' in content, "renderHabitCard function should be defined"
assert 'function isCheckedToday(' in content, "isCheckedToday function should be defined"
assert 'function getLastCheckInfo(' in content, "getLastCheckInfo function should be defined"
assert 'function renderLives(' in content, "renderLives function should be defined"
assert 'function getPriorityLevel(' in content, "getPriorityLevel function should be defined"
print("✓ Test 21: Typecheck passes (all functions defined)")
def run_all_tests(): def run_all_tests():
"""Run all tests in sequence""" """Run all tests in sequence"""
tests = [ tests = [
# US-006 tests
test_habits_html_exists, test_habits_html_exists,
test_habits_html_structure, test_habits_html_structure,
test_page_has_header, test_page_has_header,
@@ -162,9 +322,21 @@ def run_all_tests():
test_habit_card_rendering, test_habit_card_rendering,
test_no_console_errors_structure, test_no_console_errors_structure,
test_typecheck, test_typecheck,
# US-007 tests
test_card_colored_border,
test_card_header_icons,
test_card_streak_display,
test_card_checkin_button,
test_card_checkin_disabled_when_done,
test_card_lives_display,
test_card_completion_rate,
test_card_footer_category_priority,
test_card_lucide_createicons,
test_card_common_css_variables,
test_typecheck_us007,
] ]
print(f"\nRunning {len(tests)} frontend tests for US-006...\n") print(f"\nRunning {len(tests)} frontend tests for US-006 and US-007...\n")
failed = [] failed = []
for test in tests: for test in tests: