Compare commits
10 Commits
4acf5d6c9b
...
d36685c66b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d36685c66b | ||
|
|
bf215f768c | ||
|
|
3c3d6926b1 | ||
|
|
46dc3a5041 | ||
|
|
0f9c0de1a2 | ||
|
|
6837d6a925 | ||
|
|
0011664b58 | ||
|
|
1d56fe388e | ||
|
|
c7bea57cd3 | ||
|
|
c1d4ed1b03 |
1
antfarm
Submodule
1
antfarm
Submodule
Submodule antfarm added at 2fff211502
@@ -166,6 +166,12 @@ class TaskBoardHandler(SimpleHTTPRequestHandler):
|
|||||||
else:
|
else:
|
||||||
self.send_error(404)
|
self.send_error(404)
|
||||||
|
|
||||||
|
def do_DELETE(self):
|
||||||
|
if self.path.startswith('/api/habits/'):
|
||||||
|
self.handle_habits_delete()
|
||||||
|
else:
|
||||||
|
self.send_error(404)
|
||||||
|
|
||||||
def handle_git_commit(self):
|
def handle_git_commit(self):
|
||||||
"""Run git commit and push."""
|
"""Run git commit and push."""
|
||||||
try:
|
try:
|
||||||
@@ -988,6 +994,59 @@ class TaskBoardHandler(SimpleHTTPRequestHandler):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.send_json({'error': str(e)}, 500)
|
self.send_json({'error': str(e)}, 500)
|
||||||
|
|
||||||
|
def handle_habits_delete(self):
|
||||||
|
"""Delete a habit by ID."""
|
||||||
|
try:
|
||||||
|
# Extract habit ID from path: /api/habits/{id}
|
||||||
|
path_parts = self.path.split('/')
|
||||||
|
if len(path_parts) < 4:
|
||||||
|
self.send_json({'error': 'Invalid path'}, 400)
|
||||||
|
return
|
||||||
|
|
||||||
|
habit_id = path_parts[3] # /api/habits/{id} -> index 3 is id
|
||||||
|
|
||||||
|
# Read habits file
|
||||||
|
habits_file = KANBAN_DIR / 'habits.json'
|
||||||
|
if not habits_file.exists():
|
||||||
|
self.send_json({'error': 'Habit not found'}, 404)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
habits_data = json.loads(habits_file.read_text(encoding='utf-8'))
|
||||||
|
except (json.JSONDecodeError, IOError):
|
||||||
|
self.send_json({'error': 'Habit not found'}, 404)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Find the habit by ID
|
||||||
|
habit_index = None
|
||||||
|
for i, h in enumerate(habits_data.get('habits', [])):
|
||||||
|
if h.get('id') == habit_id:
|
||||||
|
habit_index = i
|
||||||
|
break
|
||||||
|
|
||||||
|
if habit_index is None:
|
||||||
|
self.send_json({'error': 'Habit not found'}, 404)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Remove the habit
|
||||||
|
deleted_habit = habits_data['habits'].pop(habit_index)
|
||||||
|
|
||||||
|
# Update lastUpdated timestamp
|
||||||
|
habits_data['lastUpdated'] = datetime.now().isoformat()
|
||||||
|
|
||||||
|
# Write back to file
|
||||||
|
habits_file.write_text(json.dumps(habits_data, indent=2), encoding='utf-8')
|
||||||
|
|
||||||
|
# Return 200 OK with success message
|
||||||
|
self.send_json({
|
||||||
|
'success': True,
|
||||||
|
'message': 'Habit deleted successfully',
|
||||||
|
'id': habit_id
|
||||||
|
}, 200)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.send_json({'error': str(e)}, 500)
|
||||||
|
|
||||||
def handle_files_get(self):
|
def handle_files_get(self):
|
||||||
"""List files or get file content."""
|
"""List files or get file content."""
|
||||||
from urllib.parse import urlparse, parse_qs
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
|||||||
@@ -87,7 +87,7 @@
|
|||||||
.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-md);
|
border-radius: var(--radius-lg);
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -100,6 +100,10 @@
|
|||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.habit-card.checked {
|
||||||
|
background: rgba(34, 197, 94, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
.habit-icon {
|
.habit-icon {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
@@ -129,8 +133,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.habit-frequency {
|
.habit-frequency {
|
||||||
|
display: inline-block;
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
color: var(--text-muted);
|
color: var(--text-secondary);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,12 +147,42 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
font-size: var(--text-lg);
|
font-size: var(--text-xl);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Delete button */
|
||||||
|
.habit-delete-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-base);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-delete-btn:hover {
|
||||||
|
border-color: var(--text-danger);
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-delete-btn svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-delete-btn:hover svg {
|
||||||
|
color: var(--text-danger);
|
||||||
|
}
|
||||||
|
|
||||||
/* Habit checkbox */
|
/* Habit checkbox */
|
||||||
.habit-checkbox {
|
.habit-checkbox {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
@@ -162,6 +201,12 @@
|
|||||||
.habit-checkbox:hover:not(.disabled) {
|
.habit-checkbox:hover:not(.disabled) {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
background: var(--accent-light, rgba(99, 102, 241, 0.1));
|
background: var(--accent-light, rgba(99, 102, 241, 0.1));
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.05); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.habit-checkbox.checked {
|
.habit-checkbox.checked {
|
||||||
@@ -343,6 +388,121 @@
|
|||||||
transform: translateX(-50%) translateY(0);
|
transform: translateX(-50%) translateY(0);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Delete confirmation modal */
|
||||||
|
.confirm-modal {
|
||||||
|
background: var(--bg-base);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-5);
|
||||||
|
width: 90%;
|
||||||
|
max-width: 400px;
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-modal-title {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-modal-message {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--text-danger);
|
||||||
|
color: white;
|
||||||
|
border: 1px solid var(--text-danger);
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: #dc2626;
|
||||||
|
border-color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile responsiveness */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.main {
|
||||||
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-card {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habits-list {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-icon svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Touch targets >= 44x44px for accessibility */
|
||||||
|
.habit-checkbox {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.habit-checkbox svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Full-screen modal on mobile */
|
||||||
|
.modal {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
max-height: 100vh;
|
||||||
|
border-radius: 0;
|
||||||
|
padding: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Larger touch targets for buttons */
|
||||||
|
.add-habit-btn {
|
||||||
|
padding: var(--space-4) var(--space-5);
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
min-height: 44px;
|
||||||
|
padding: var(--space-3) var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Larger radio buttons for touch */
|
||||||
|
.radio-label {
|
||||||
|
padding: var(--space-4);
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -369,7 +529,7 @@
|
|||||||
<span>Files</span>
|
<span>Files</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/echo/habits.html" class="nav-item active">
|
<a href="/echo/habits.html" class="nav-item active">
|
||||||
<i data-lucide="target"></i>
|
<i data-lucide="flame"></i>
|
||||||
<span>Habits</span>
|
<span>Habits</span>
|
||||||
</a>
|
</a>
|
||||||
<button class="theme-toggle" onclick="toggleTheme()" title="Schimbă tema">
|
<button class="theme-toggle" onclick="toggleTheme()" title="Schimbă tema">
|
||||||
@@ -422,7 +582,7 @@
|
|||||||
<h2 class="modal-title">Obișnuință nouă</h2>
|
<h2 class="modal-title">Obișnuință nouă</h2>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Nume *</label>
|
<label class="form-label">Nume *</label>
|
||||||
<input type="text" class="input" id="habitName" placeholder="ex: Bazin, Sală, Meditație...">
|
<input type="text" class="input" id="habitName" placeholder="ex: Bazin, Sală, Meditație..." autocapitalize="words" autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Frecvență</label>
|
<label class="form-label">Frecvență</label>
|
||||||
@@ -444,6 +604,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Modal -->
|
||||||
|
<div class="modal-overlay" id="deleteModal">
|
||||||
|
<div class="confirm-modal">
|
||||||
|
<h2 class="confirm-modal-title">Ștergi obișnuința?</h2>
|
||||||
|
<p class="confirm-modal-message" id="deleteModalMessage">
|
||||||
|
Ștergi obișnuința <strong id="deleteHabitName"></strong>?
|
||||||
|
</p>
|
||||||
|
<div class="confirm-modal-actions">
|
||||||
|
<button class="btn btn-secondary" onclick="hideDeleteModal()">Anulează</button>
|
||||||
|
<button class="btn btn-danger" id="confirmDeleteBtn" onclick="confirmDelete()">Șterge</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -627,13 +801,13 @@
|
|||||||
// Create habit card element
|
// Create habit card element
|
||||||
function createHabitCard(habit) {
|
function createHabitCard(habit) {
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'habit-card';
|
const isChecked = habit.checkedToday || false;
|
||||||
|
card.className = isChecked ? 'habit-card checked' : 'habit-card';
|
||||||
|
|
||||||
// Determine icon based on frequency
|
// Determine icon based on frequency
|
||||||
const iconName = habit.frequency === 'daily' ? 'calendar' : 'clock';
|
const iconName = habit.frequency === 'daily' ? 'calendar' : 'clock';
|
||||||
|
|
||||||
// Checkbox state
|
// Checkbox state
|
||||||
const isChecked = habit.checkedToday || false;
|
|
||||||
const checkboxClass = isChecked ? 'habit-checkbox checked disabled' : 'habit-checkbox';
|
const checkboxClass = isChecked ? 'habit-checkbox checked disabled' : 'habit-checkbox';
|
||||||
const checkIcon = isChecked ? '<i data-lucide="check"></i>' : '';
|
const checkIcon = isChecked ? '<i data-lucide="check"></i>' : '';
|
||||||
|
|
||||||
@@ -653,6 +827,9 @@
|
|||||||
<span id="streak-${habit.id}">${habit.streak || 0}</span>
|
<span id="streak-${habit.id}">${habit.streak || 0}</span>
|
||||||
<span>🔥</span>
|
<span>🔥</span>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="habit-delete-btn" onclick="showDeleteModal('${habit.id}', '${escapeHtml(habit.name).replace(/'/g, "'")}')">
|
||||||
|
<i data-lucide="trash-2"></i>
|
||||||
|
</button>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return card;
|
return card;
|
||||||
@@ -677,6 +854,12 @@
|
|||||||
checkboxElement.innerHTML = '<i data-lucide="check"></i>';
|
checkboxElement.innerHTML = '<i data-lucide="check"></i>';
|
||||||
lucide.createIcons();
|
lucide.createIcons();
|
||||||
|
|
||||||
|
// Add 'checked' class to parent card for green background
|
||||||
|
const card = checkboxElement.closest('.habit-card');
|
||||||
|
if (card) {
|
||||||
|
card.classList.add('checked');
|
||||||
|
}
|
||||||
|
|
||||||
// Store original state for rollback
|
// Store original state for rollback
|
||||||
const originalCheckbox = checkboxElement.cloneNode(true);
|
const originalCheckbox = checkboxElement.cloneNode(true);
|
||||||
const streakElement = document.getElementById(`streak-${habitId}`);
|
const streakElement = document.getElementById(`streak-${habitId}`);
|
||||||
@@ -708,6 +891,12 @@
|
|||||||
checkboxElement.classList.remove('checked', 'disabled');
|
checkboxElement.classList.remove('checked', 'disabled');
|
||||||
checkboxElement.innerHTML = '';
|
checkboxElement.innerHTML = '';
|
||||||
|
|
||||||
|
// Revert card background
|
||||||
|
const card = checkboxElement.closest('.habit-card');
|
||||||
|
if (card) {
|
||||||
|
card.classList.remove('checked');
|
||||||
|
}
|
||||||
|
|
||||||
// Revert streak
|
// Revert streak
|
||||||
if (streakElement) {
|
if (streakElement) {
|
||||||
streakElement.textContent = originalStreak;
|
streakElement.textContent = originalStreak;
|
||||||
@@ -717,6 +906,64 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete habit functions
|
||||||
|
let habitToDelete = null;
|
||||||
|
|
||||||
|
function showDeleteModal(habitId, habitName) {
|
||||||
|
habitToDelete = habitId;
|
||||||
|
const modal = document.getElementById('deleteModal');
|
||||||
|
const nameElement = document.getElementById('deleteHabitName');
|
||||||
|
|
||||||
|
// Decode HTML entities for display
|
||||||
|
const tempDiv = document.createElement('div');
|
||||||
|
tempDiv.innerHTML = habitName;
|
||||||
|
nameElement.textContent = tempDiv.textContent;
|
||||||
|
|
||||||
|
modal.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideDeleteModal() {
|
||||||
|
const modal = document.getElementById('deleteModal');
|
||||||
|
modal.classList.remove('active');
|
||||||
|
habitToDelete = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
if (!habitToDelete) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteBtn = document.getElementById('confirmDeleteBtn');
|
||||||
|
|
||||||
|
// Disable button during deletion
|
||||||
|
deleteBtn.disabled = true;
|
||||||
|
const originalText = deleteBtn.textContent;
|
||||||
|
deleteBtn.textContent = 'Se șterge...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/habits/${habitToDelete}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to delete habit');
|
||||||
|
}
|
||||||
|
|
||||||
|
hideDeleteModal();
|
||||||
|
showToast('Obișnuință ștearsă cu succes');
|
||||||
|
loadHabits();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting habit:', error);
|
||||||
|
showToast('Eroare la ștergerea obișnuinței. Încearcă din nou.');
|
||||||
|
|
||||||
|
// Re-enable button on error
|
||||||
|
deleteBtn.disabled = false;
|
||||||
|
deleteBtn.textContent = originalText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Load habits on page load
|
// Load habits on page load
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
loadHabits();
|
loadHabits();
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
{
|
{
|
||||||
"habits": [],
|
"habits": [
|
||||||
"lastUpdated": "2026-02-10T12:39:00Z"
|
{
|
||||||
}
|
"id": "habit-test1",
|
||||||
|
"name": "Test Habit",
|
||||||
|
"frequency": "daily",
|
||||||
|
"createdAt": "2026-02-01T10:00:00Z",
|
||||||
|
"completions": [
|
||||||
|
"2026-02-10"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lastUpdated": "2026-02-10T13:51:07.626599"
|
||||||
|
}
|
||||||
@@ -1075,6 +1075,10 @@
|
|||||||
<i data-lucide="folder"></i>
|
<i data-lucide="folder"></i>
|
||||||
<span>Files</span>
|
<span>Files</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/echo/habits.html" class="nav-item">
|
||||||
|
<i data-lucide="flame"></i>
|
||||||
|
<span>Habits</span>
|
||||||
|
</a>
|
||||||
<button class="theme-toggle" onclick="toggleTheme()" title="Schimbă tema">
|
<button class="theme-toggle" onclick="toggleTheme()" title="Schimbă tema">
|
||||||
<i data-lucide="sun" id="themeIcon"></i>
|
<i data-lucide="sun" id="themeIcon"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -1600,10 +1604,34 @@
|
|||||||
const msg = status.anaf.ok !== false ? 'Nicio modificare' : (status.anaf.message || 'Modificări!');
|
const msg = status.anaf.ok !== false ? 'Nicio modificare' : (status.anaf.message || 'Modificări!');
|
||||||
subtitle.textContent = `${msg} · ${lastCheck}`;
|
subtitle.textContent = `${msg} · ${lastCheck}`;
|
||||||
|
|
||||||
if (status.anaf.lastCheck) {
|
// Actualizează detaliile
|
||||||
document.getElementById('anafLastCheck').textContent =
|
const details = document.getElementById('anafDetails');
|
||||||
'Ultima verificare: ' + status.anaf.lastCheck;
|
let html = '';
|
||||||
|
|
||||||
|
// Adaugă detaliile modificărilor dacă există
|
||||||
|
if (status.anaf.changes && status.anaf.changes.length > 0) {
|
||||||
|
status.anaf.changes.forEach(change => {
|
||||||
|
const summaryText = change.summary && change.summary.length > 0
|
||||||
|
? ' - ' + change.summary.join(', ')
|
||||||
|
: '';
|
||||||
|
html += `
|
||||||
|
<div class="status-detail-item uncommitted">
|
||||||
|
<i data-lucide="alert-circle"></i>
|
||||||
|
<span><a href="${change.url}" target="_blank" style="color:var(--warning)">${change.name}</a>${summaryText}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
html = `
|
||||||
|
<div class="status-detail-item">
|
||||||
|
<i data-lucide="check-circle"></i>
|
||||||
|
<span>Toate paginile sunt la zi</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
details.innerHTML = html;
|
||||||
|
lucide.createIcons();
|
||||||
}
|
}
|
||||||
|
|
||||||
return status;
|
return status;
|
||||||
|
|||||||
@@ -13,7 +13,16 @@
|
|||||||
"ok": false,
|
"ok": false,
|
||||||
"status": "MODIFICĂRI",
|
"status": "MODIFICĂRI",
|
||||||
"message": "1 modificări detectate",
|
"message": "1 modificări detectate",
|
||||||
"lastCheck": "09 Feb 2026, 14:00",
|
"lastCheck": "10 Feb 2026, 12:39",
|
||||||
"changesCount": 1
|
"changesCount": 1,
|
||||||
|
"changes": [
|
||||||
|
{
|
||||||
|
"name": "Declarația 100 - Obligații de plată la bugetul de stat",
|
||||||
|
"url": "https://static.anaf.ro/static/10/Anaf/Declaratii_R/100.html",
|
||||||
|
"summary": [
|
||||||
|
"Soft A: 09.02.2026 → 10.02.2026"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
256
dashboard/test_habits_card_styling.py
Normal file
256
dashboard/test_habits_card_styling.py
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Tests for Story 12.0: Frontend - Habit card styling
|
||||||
|
Tests styling enhancements for habit cards
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_file_exists():
|
||||||
|
"""Test that habits.html exists"""
|
||||||
|
import os
|
||||||
|
assert os.path.exists('dashboard/habits.html'), "habits.html file should exist"
|
||||||
|
print("✓ habits.html exists")
|
||||||
|
|
||||||
|
def test_card_border_radius():
|
||||||
|
"""Test that cards use --radius-lg border radius"""
|
||||||
|
with open('dashboard/habits.html', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Check that habit-card has border-radius: var(--radius-lg)
|
||||||
|
assert 'border-radius: var(--radius-lg);' in content, "habit-card should use --radius-lg border radius"
|
||||||
|
|
||||||
|
# Check it's in the .habit-card CSS rule
|
||||||
|
habit_card_start = content.find('.habit-card {')
|
||||||
|
habit_card_end = content.find('}', habit_card_start)
|
||||||
|
habit_card_css = content[habit_card_start:habit_card_end]
|
||||||
|
assert 'border-radius: var(--radius-lg)' in habit_card_css, "habit-card should have --radius-lg in its CSS"
|
||||||
|
|
||||||
|
print("✓ Cards use --radius-lg border radius")
|
||||||
|
|
||||||
|
def test_streak_font_size():
|
||||||
|
"""Test that streak uses --text-xl font size"""
|
||||||
|
with open('dashboard/habits.html', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Find .habit-streak CSS rule
|
||||||
|
streak_start = content.find('.habit-streak {')
|
||||||
|
assert streak_start > 0, ".habit-streak CSS rule should exist"
|
||||||
|
|
||||||
|
streak_end = content.find('}', streak_start)
|
||||||
|
streak_css = content[streak_start:streak_end]
|
||||||
|
|
||||||
|
# Check for font-size: var(--text-xl)
|
||||||
|
assert 'font-size: var(--text-xl)' in streak_css, "Streak should use --text-xl font size"
|
||||||
|
|
||||||
|
print("✓ Streak displayed prominently with --text-xl font size")
|
||||||
|
|
||||||
|
def test_checked_habit_background():
|
||||||
|
"""Test that checked habits have subtle green background tint"""
|
||||||
|
with open('dashboard/habits.html', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Check for .habit-card.checked CSS rule
|
||||||
|
assert '.habit-card.checked' in content, "Should have .habit-card.checked CSS rule"
|
||||||
|
|
||||||
|
# Find the CSS rule
|
||||||
|
checked_start = content.find('.habit-card.checked {')
|
||||||
|
assert checked_start > 0, ".habit-card.checked CSS rule should exist"
|
||||||
|
|
||||||
|
checked_end = content.find('}', checked_start)
|
||||||
|
checked_css = content[checked_start:checked_end]
|
||||||
|
|
||||||
|
# Check for green background (using rgba with green color and low opacity)
|
||||||
|
assert 'background: rgba(34, 197, 94, 0.1)' in checked_css, "Checked cards should have green background tint"
|
||||||
|
|
||||||
|
# Check JavaScript adds 'checked' class to card
|
||||||
|
assert "card.classList.add('checked')" in content, "JavaScript should add 'checked' class to card"
|
||||||
|
|
||||||
|
print("✓ Checked habits have subtle green background tint")
|
||||||
|
|
||||||
|
def test_checkbox_pulse_animation():
|
||||||
|
"""Test that unchecked habits have subtle pulse animation on checkbox hover"""
|
||||||
|
with open('dashboard/habits.html', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Check for animation on hover (not disabled)
|
||||||
|
hover_start = content.find('.habit-checkbox:hover:not(.disabled) {')
|
||||||
|
assert hover_start > 0, "Should have hover rule for unchecked checkboxes"
|
||||||
|
|
||||||
|
hover_end = content.find('}', hover_start)
|
||||||
|
hover_css = content[hover_start:hover_end]
|
||||||
|
|
||||||
|
# Check for pulse animation
|
||||||
|
assert 'animation: pulse' in hover_css, "Unchecked checkboxes should have pulse animation on hover"
|
||||||
|
|
||||||
|
# Check for @keyframes pulse definition
|
||||||
|
assert '@keyframes pulse' in content, "Should have pulse keyframes definition"
|
||||||
|
|
||||||
|
# Verify pulse animation scales element
|
||||||
|
keyframes_start = content.find('@keyframes pulse {')
|
||||||
|
keyframes_end = content.find('}', keyframes_start)
|
||||||
|
keyframes_css = content[keyframes_start:keyframes_end]
|
||||||
|
assert 'scale(' in keyframes_css, "Pulse animation should scale the element"
|
||||||
|
|
||||||
|
print("✓ Unchecked habits have subtle pulse animation on checkbox hover")
|
||||||
|
|
||||||
|
def test_frequency_badge_styling():
|
||||||
|
"""Test that frequency badge uses dashboard tag styling"""
|
||||||
|
with open('dashboard/habits.html', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Find .habit-frequency CSS rule
|
||||||
|
freq_start = content.find('.habit-frequency {')
|
||||||
|
assert freq_start > 0, ".habit-frequency CSS rule should exist"
|
||||||
|
|
||||||
|
freq_end = content.find('}', freq_start)
|
||||||
|
freq_css = content[freq_start:freq_end]
|
||||||
|
|
||||||
|
# Check for tag-like styling
|
||||||
|
assert 'display: inline-block' in freq_css, "Frequency should be inline-block"
|
||||||
|
assert 'background: var(--bg-elevated)' in freq_css, "Frequency should use --bg-elevated"
|
||||||
|
assert 'border: 1px solid var(--border)' in freq_css, "Frequency should have border"
|
||||||
|
assert 'padding:' in freq_css, "Frequency should have padding"
|
||||||
|
assert 'border-radius:' in freq_css, "Frequency should have border-radius"
|
||||||
|
|
||||||
|
print("✓ Frequency badge uses dashboard tag styling")
|
||||||
|
|
||||||
|
def test_card_uses_css_variables():
|
||||||
|
"""Test that cards use --bg-surface with --border"""
|
||||||
|
with open('dashboard/habits.html', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Find .habit-card CSS rule
|
||||||
|
card_start = content.find('.habit-card {')
|
||||||
|
assert card_start > 0, ".habit-card CSS rule should exist"
|
||||||
|
|
||||||
|
card_end = content.find('}', card_start)
|
||||||
|
card_css = content[card_start:card_end]
|
||||||
|
|
||||||
|
# Check for CSS variables
|
||||||
|
assert 'background: var(--bg-surface)' in card_css, "Cards should use --bg-surface"
|
||||||
|
assert 'border: 1px solid var(--border)' in card_css, "Cards should use --border"
|
||||||
|
|
||||||
|
print("✓ Cards use --bg-surface with --border")
|
||||||
|
|
||||||
|
def test_mobile_responsiveness():
|
||||||
|
"""Test that cards are responsive on mobile (full width < 768px)"""
|
||||||
|
with open('dashboard/habits.html', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Check for media query
|
||||||
|
assert '@media (max-width: 768px)' in content, "Should have mobile media query"
|
||||||
|
|
||||||
|
# Find mobile media query
|
||||||
|
mobile_start = content.find('@media (max-width: 768px)')
|
||||||
|
assert mobile_start > 0, "Mobile media query should exist"
|
||||||
|
|
||||||
|
mobile_end = content.find('}', content.find('}', content.find('}', mobile_start) + 1) + 1)
|
||||||
|
mobile_css = content[mobile_start:mobile_end]
|
||||||
|
|
||||||
|
# Check for habit-card width
|
||||||
|
assert '.habit-card {' in mobile_css or 'habit-card' in mobile_css, "Mobile styles should target habit-card"
|
||||||
|
assert 'width: 100%' in mobile_css, "Cards should be full width on mobile"
|
||||||
|
|
||||||
|
# Check for reduced spacing
|
||||||
|
assert '.main {' in mobile_css, "Main container should have mobile styling"
|
||||||
|
|
||||||
|
print("✓ Responsive on mobile (full width < 768px)")
|
||||||
|
|
||||||
|
def test_checked_class_in_createHabitCard():
|
||||||
|
"""Test that createHabitCard adds 'checked' class to card"""
|
||||||
|
with open('dashboard/habits.html', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Find createHabitCard function
|
||||||
|
func_start = content.find('function createHabitCard(habit) {')
|
||||||
|
assert func_start > 0, "createHabitCard function should exist"
|
||||||
|
|
||||||
|
func_end = content.find('return card;', func_start)
|
||||||
|
func_code = content[func_start:func_end]
|
||||||
|
|
||||||
|
# Check for checked class logic
|
||||||
|
assert "isChecked ? 'habit-card checked' : 'habit-card'" in func_code, "Should add 'checked' class to card when habit is checked"
|
||||||
|
|
||||||
|
print("✓ createHabitCard adds 'checked' class when appropriate")
|
||||||
|
|
||||||
|
def test_all_acceptance_criteria():
|
||||||
|
"""Summary test: verify all 7 acceptance criteria are met"""
|
||||||
|
with open('dashboard/habits.html', 'r') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
criteria = []
|
||||||
|
|
||||||
|
# 1. Cards use --bg-surface with --border
|
||||||
|
card_start = content.find('.habit-card {')
|
||||||
|
card_end = content.find('}', card_start)
|
||||||
|
card_css = content[card_start:card_end]
|
||||||
|
if 'background: var(--bg-surface)' in card_css and 'border: 1px solid var(--border)' in card_css:
|
||||||
|
criteria.append("✓ Cards use --bg-surface with --border")
|
||||||
|
|
||||||
|
# 2. Streak displayed prominently with --text-xl
|
||||||
|
streak_start = content.find('.habit-streak {')
|
||||||
|
streak_end = content.find('}', streak_start)
|
||||||
|
streak_css = content[streak_start:streak_end]
|
||||||
|
if 'font-size: var(--text-xl)' in streak_css:
|
||||||
|
criteria.append("✓ Streak displayed prominently with --text-xl")
|
||||||
|
|
||||||
|
# 3. Checked habits have subtle green background tint
|
||||||
|
if '.habit-card.checked' in content and 'rgba(34, 197, 94, 0.1)' in content:
|
||||||
|
criteria.append("✓ Checked habits have subtle green background tint")
|
||||||
|
|
||||||
|
# 4. Unchecked habits have subtle pulse animation on checkbox hover
|
||||||
|
if 'animation: pulse' in content and '@keyframes pulse' in content:
|
||||||
|
criteria.append("✓ Unchecked habits have pulse animation on hover")
|
||||||
|
|
||||||
|
# 5. Frequency badge uses dashboard tag styling
|
||||||
|
freq_start = content.find('.habit-frequency {')
|
||||||
|
freq_end = content.find('}', freq_start)
|
||||||
|
freq_css = content[freq_start:freq_end]
|
||||||
|
if 'display: inline-block' in freq_css and 'background: var(--bg-elevated)' in freq_css:
|
||||||
|
criteria.append("✓ Frequency badge uses dashboard tag styling")
|
||||||
|
|
||||||
|
# 6. Cards have --radius-lg border radius
|
||||||
|
if 'border-radius: var(--radius-lg)' in card_css:
|
||||||
|
criteria.append("✓ Cards have --radius-lg border radius")
|
||||||
|
|
||||||
|
# 7. Responsive on mobile (full width < 768px)
|
||||||
|
if '@media (max-width: 768px)' in content and 'width: 100%' in content[content.find('@media (max-width: 768px)'):]:
|
||||||
|
criteria.append("✓ Responsive on mobile (full width < 768px)")
|
||||||
|
|
||||||
|
for criterion in criteria:
|
||||||
|
print(criterion)
|
||||||
|
|
||||||
|
assert len(criteria) == 7, f"Should meet all 7 acceptance criteria, met {len(criteria)}"
|
||||||
|
print(f"\n✓ All 7 acceptance criteria met!")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
import os
|
||||||
|
os.chdir('/home/moltbot/clawd')
|
||||||
|
|
||||||
|
tests = [
|
||||||
|
test_file_exists,
|
||||||
|
test_card_uses_css_variables,
|
||||||
|
test_card_border_radius,
|
||||||
|
test_streak_font_size,
|
||||||
|
test_checked_habit_background,
|
||||||
|
test_checkbox_pulse_animation,
|
||||||
|
test_frequency_badge_styling,
|
||||||
|
test_mobile_responsiveness,
|
||||||
|
test_checked_class_in_createHabitCard,
|
||||||
|
test_all_acceptance_criteria
|
||||||
|
]
|
||||||
|
|
||||||
|
print("Running tests for Story 12.0: Frontend - Habit card styling\n")
|
||||||
|
|
||||||
|
for test in tests:
|
||||||
|
try:
|
||||||
|
test()
|
||||||
|
except AssertionError as e:
|
||||||
|
print(f"✗ {test.__name__} failed: {e}")
|
||||||
|
exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ {test.__name__} error: {e}")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"All {len(tests)} tests passed! ✓")
|
||||||
|
print(f"{'='*60}")
|
||||||
203
dashboard/test_habits_delete.py
Normal file
203
dashboard/test_habits_delete.py
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Tests for Story 15.0: Backend - Delete habit endpoint
|
||||||
|
Tests the DELETE /api/habits/{id} endpoint functionality.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime
|
||||||
|
from http.server import HTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
from threading import Thread
|
||||||
|
from time import sleep
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
from urllib.error import HTTPError
|
||||||
|
|
||||||
|
# Add parent directory to path
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from dashboard.api import TaskBoardHandler
|
||||||
|
|
||||||
|
class TestHabitsDelete(unittest.TestCase):
|
||||||
|
"""Test DELETE /api/habits/{id} endpoint"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
"""Start test server"""
|
||||||
|
# Create temp habits.json
|
||||||
|
cls.temp_dir = Path(tempfile.mkdtemp())
|
||||||
|
cls.habits_file = cls.temp_dir / 'habits.json'
|
||||||
|
cls.habits_file.write_text(json.dumps({
|
||||||
|
'lastUpdated': datetime.now().isoformat(),
|
||||||
|
'habits': []
|
||||||
|
}))
|
||||||
|
|
||||||
|
# Monkey-patch KANBAN_DIR to use temp directory
|
||||||
|
import dashboard.api as api_module
|
||||||
|
cls.original_kanban_dir = api_module.KANBAN_DIR
|
||||||
|
api_module.KANBAN_DIR = cls.temp_dir
|
||||||
|
|
||||||
|
# Start server in background thread
|
||||||
|
cls.port = 9022
|
||||||
|
cls.server = HTTPServer(('127.0.0.1', cls.port), TaskBoardHandler)
|
||||||
|
cls.server_thread = Thread(target=cls.server.serve_forever, daemon=True)
|
||||||
|
cls.server_thread.start()
|
||||||
|
sleep(0.5) # Wait for server to start
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
"""Stop server and cleanup"""
|
||||||
|
cls.server.shutdown()
|
||||||
|
|
||||||
|
# Restore original KANBAN_DIR
|
||||||
|
import dashboard.api as api_module
|
||||||
|
api_module.KANBAN_DIR = cls.original_kanban_dir
|
||||||
|
|
||||||
|
# Cleanup temp directory
|
||||||
|
shutil.rmtree(cls.temp_dir)
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
"""Reset habits file before each test"""
|
||||||
|
self.habits_file.write_text(json.dumps({
|
||||||
|
'lastUpdated': datetime.now().isoformat(),
|
||||||
|
'habits': []
|
||||||
|
}))
|
||||||
|
|
||||||
|
def api_call(self, method, path, body=None):
|
||||||
|
"""Make API call and return (status, data)"""
|
||||||
|
url = f'http://127.0.0.1:{self.port}{path}'
|
||||||
|
headers = {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
|
if body:
|
||||||
|
data = json.dumps(body).encode('utf-8')
|
||||||
|
req = Request(url, data=data, headers=headers, method=method)
|
||||||
|
else:
|
||||||
|
req = Request(url, headers=headers, method=method)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urlopen(req) as response:
|
||||||
|
return response.status, json.loads(response.read().decode('utf-8'))
|
||||||
|
except HTTPError as e:
|
||||||
|
return e.code, json.loads(e.read().decode('utf-8'))
|
||||||
|
|
||||||
|
def test_01_delete_removes_habit_from_file(self):
|
||||||
|
"""AC1: DELETE /api/habits/{id} removes habit from habits.json"""
|
||||||
|
# Create two habits
|
||||||
|
_, habit1 = self.api_call('POST', '/api/habits', {'name': 'Habit 1', 'frequency': 'daily'})
|
||||||
|
_, habit2 = self.api_call('POST', '/api/habits', {'name': 'Habit 2', 'frequency': 'weekly'})
|
||||||
|
|
||||||
|
habit1_id = habit1['id']
|
||||||
|
habit2_id = habit2['id']
|
||||||
|
|
||||||
|
# Delete first habit
|
||||||
|
status, _ = self.api_call('DELETE', f'/api/habits/{habit1_id}')
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
|
||||||
|
# Verify it's removed from file
|
||||||
|
data = json.loads(self.habits_file.read_text())
|
||||||
|
remaining_ids = [h['id'] for h in data['habits']]
|
||||||
|
|
||||||
|
self.assertNotIn(habit1_id, remaining_ids, "Deleted habit still in file")
|
||||||
|
self.assertIn(habit2_id, remaining_ids, "Other habit was incorrectly deleted")
|
||||||
|
self.assertEqual(len(data['habits']), 1, "Should have exactly 1 habit remaining")
|
||||||
|
|
||||||
|
def test_02_returns_200_with_success_message(self):
|
||||||
|
"""AC2: Returns 200 with success message"""
|
||||||
|
# Create a habit
|
||||||
|
_, habit = self.api_call('POST', '/api/habits', {'name': 'Test Habit', 'frequency': 'daily'})
|
||||||
|
habit_id = habit['id']
|
||||||
|
|
||||||
|
# Delete it
|
||||||
|
status, response = self.api_call('DELETE', f'/api/habits/{habit_id}')
|
||||||
|
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertTrue(response.get('success'), "Response should have success=true")
|
||||||
|
self.assertIn('message', response, "Response should contain message field")
|
||||||
|
self.assertEqual(response.get('id'), habit_id, "Response should contain habit ID")
|
||||||
|
|
||||||
|
def test_03_returns_404_if_not_found(self):
|
||||||
|
"""AC3: Returns 404 if habit not found"""
|
||||||
|
status, response = self.api_call('DELETE', '/api/habits/nonexistent-id')
|
||||||
|
|
||||||
|
self.assertEqual(status, 404)
|
||||||
|
self.assertIn('error', response, "Response should contain error message")
|
||||||
|
|
||||||
|
def test_04_updates_lastUpdated_timestamp(self):
|
||||||
|
"""AC4: Updates lastUpdated timestamp"""
|
||||||
|
# Get initial timestamp
|
||||||
|
data_before = json.loads(self.habits_file.read_text())
|
||||||
|
timestamp_before = data_before['lastUpdated']
|
||||||
|
|
||||||
|
sleep(0.1) # Ensure timestamp difference
|
||||||
|
|
||||||
|
# Create and delete a habit
|
||||||
|
_, habit = self.api_call('POST', '/api/habits', {'name': 'Test', 'frequency': 'daily'})
|
||||||
|
self.api_call('DELETE', f'/api/habits/{habit["id"]}')
|
||||||
|
|
||||||
|
# Check timestamp was updated
|
||||||
|
data_after = json.loads(self.habits_file.read_text())
|
||||||
|
timestamp_after = data_after['lastUpdated']
|
||||||
|
|
||||||
|
self.assertNotEqual(timestamp_after, timestamp_before, "Timestamp should be updated")
|
||||||
|
|
||||||
|
# Verify it's a valid ISO timestamp
|
||||||
|
try:
|
||||||
|
datetime.fromisoformat(timestamp_after.replace('Z', '+00:00'))
|
||||||
|
except ValueError:
|
||||||
|
self.fail(f"Invalid ISO timestamp: {timestamp_after}")
|
||||||
|
|
||||||
|
def test_05_edge_cases(self):
|
||||||
|
"""AC5: Tests for delete endpoint edge cases"""
|
||||||
|
# Create a habit
|
||||||
|
_, habit = self.api_call('POST', '/api/habits', {'name': 'Test', 'frequency': 'daily'})
|
||||||
|
habit_id = habit['id']
|
||||||
|
|
||||||
|
# Delete it
|
||||||
|
status, _ = self.api_call('DELETE', f'/api/habits/{habit_id}')
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
|
||||||
|
# Try to delete again (should return 404)
|
||||||
|
status, _ = self.api_call('DELETE', f'/api/habits/{habit_id}')
|
||||||
|
self.assertEqual(status, 404, "Should return 404 for already deleted habit")
|
||||||
|
|
||||||
|
# Test invalid path (trailing slash)
|
||||||
|
status, _ = self.api_call('DELETE', '/api/habits/')
|
||||||
|
self.assertEqual(status, 404, "Should return 404 for invalid path")
|
||||||
|
|
||||||
|
def test_06_missing_file_handling(self):
|
||||||
|
"""Test graceful handling when habits.json is missing"""
|
||||||
|
# Remove file
|
||||||
|
self.habits_file.unlink()
|
||||||
|
|
||||||
|
# Try to delete
|
||||||
|
status, response = self.api_call('DELETE', '/api/habits/some-id')
|
||||||
|
|
||||||
|
self.assertEqual(status, 404)
|
||||||
|
self.assertIn('error', response)
|
||||||
|
|
||||||
|
# Restore file for cleanup
|
||||||
|
self.habits_file.write_text(json.dumps({
|
||||||
|
'lastUpdated': datetime.now().isoformat(),
|
||||||
|
'habits': []
|
||||||
|
}))
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
# Run tests
|
||||||
|
suite = unittest.TestLoader().loadTestsFromTestCase(TestHabitsDelete)
|
||||||
|
runner = unittest.TextTestRunner(verbosity=2)
|
||||||
|
result = runner.run(suite)
|
||||||
|
|
||||||
|
# Print summary
|
||||||
|
if result.wasSuccessful():
|
||||||
|
print("\n✅ All Story 15.0 acceptance criteria verified:")
|
||||||
|
print(" 1. DELETE /api/habits/{id} removes habit from habits.json ✓")
|
||||||
|
print(" 2. Returns 200 with success message ✓")
|
||||||
|
print(" 3. Returns 404 if habit not found ✓")
|
||||||
|
print(" 4. Updates lastUpdated timestamp ✓")
|
||||||
|
print(" 5. Tests for delete endpoint pass ✓")
|
||||||
|
|
||||||
|
sys.exit(0 if result.wasSuccessful() else 1)
|
||||||
274
dashboard/test_habits_delete_ui.py
Normal file
274
dashboard/test_habits_delete_ui.py
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test suite for Story 16.0: Frontend - Delete habit with confirmation
|
||||||
|
Tests the delete button and confirmation modal functionality.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
def test_file_exists():
|
||||||
|
"""Test that habits.html exists"""
|
||||||
|
try:
|
||||||
|
with open('dashboard/habits.html', 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
return True
|
||||||
|
except FileNotFoundError:
|
||||||
|
print("FAIL: habits.html not found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_button_css():
|
||||||
|
"""AC1: Tests delete button styling (trash icon button using lucide)"""
|
||||||
|
with open('dashboard/habits.html', 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Check for delete button CSS class
|
||||||
|
if '.habit-delete-btn' not in content:
|
||||||
|
print("FAIL: .habit-delete-btn CSS class not found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check for proper styling (size, border, hover state)
|
||||||
|
css_pattern = r'\.habit-delete-btn\s*\{[^}]*width:\s*32px[^}]*height:\s*32px'
|
||||||
|
if not re.search(css_pattern, content, re.DOTALL):
|
||||||
|
print("FAIL: Delete button sizing not found (32x32px)")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check for hover state with danger color
|
||||||
|
if '.habit-delete-btn:hover' not in content:
|
||||||
|
print("FAIL: Delete button hover state not found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if 'var(--text-danger)' not in content:
|
||||||
|
print("FAIL: Danger color not used for delete button")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_button_in_card():
|
||||||
|
"""AC1: Tests that habit card includes trash icon button"""
|
||||||
|
with open('dashboard/habits.html', 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Check for trash-2 icon (lucide) in createHabitCard
|
||||||
|
if 'trash-2' not in content:
|
||||||
|
print("FAIL: trash-2 icon not found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check for delete button in card HTML with onclick handler
|
||||||
|
pattern = r'habit-delete-btn.*onclick.*showDeleteModal'
|
||||||
|
if not re.search(pattern, content, re.DOTALL):
|
||||||
|
print("FAIL: Delete button with onclick handler not found in card")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def test_confirmation_modal_structure():
|
||||||
|
"""AC2: Tests confirmation modal 'Ștergi obișnuința {name}?'"""
|
||||||
|
with open('dashboard/habits.html', 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Check for delete modal element
|
||||||
|
if 'id="deleteModal"' not in content:
|
||||||
|
print("FAIL: deleteModal element not found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check for Romanian confirmation message
|
||||||
|
if 'Ștergi obișnuința' not in content:
|
||||||
|
print("FAIL: Romanian confirmation message not found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check for habit name placeholder
|
||||||
|
if 'id="deleteHabitName"' not in content:
|
||||||
|
print("FAIL: deleteHabitName element for dynamic habit name not found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def test_confirmation_buttons():
|
||||||
|
"""AC3 & AC4: Tests Cancel and Delete buttons with correct styling"""
|
||||||
|
with open('dashboard/habits.html', 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Check for Cancel button
|
||||||
|
if 'onclick="hideDeleteModal()"' not in content:
|
||||||
|
print("FAIL: Cancel button with hideDeleteModal() not found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check for Delete button
|
||||||
|
if 'onclick="confirmDelete()"' not in content:
|
||||||
|
print("FAIL: Delete button with confirmDelete() not found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# AC4: Check for destructive red styling (btn-danger class)
|
||||||
|
if '.btn-danger' not in content:
|
||||||
|
print("FAIL: .btn-danger CSS class not found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check that btn-danger uses danger color
|
||||||
|
css_pattern = r'\.btn-danger\s*\{[^}]*background:\s*var\(--text-danger\)'
|
||||||
|
if not re.search(css_pattern, content, re.DOTALL):
|
||||||
|
print("FAIL: btn-danger does not use danger color")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_api_call():
|
||||||
|
"""AC5: Tests DELETE API call and list removal on confirm"""
|
||||||
|
with open('dashboard/habits.html', 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Check for confirmDelete function
|
||||||
|
if 'async function confirmDelete()' not in content:
|
||||||
|
print("FAIL: confirmDelete async function not found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check for DELETE method call to API
|
||||||
|
pattern = r"method:\s*['\"]DELETE['\"]"
|
||||||
|
if not re.search(pattern, content):
|
||||||
|
print("FAIL: DELETE method not found in confirmDelete")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check for API endpoint with habitToDelete variable
|
||||||
|
pattern = r"/api/habits/\$\{habitToDelete\}"
|
||||||
|
if not re.search(pattern, content):
|
||||||
|
print("FAIL: DELETE endpoint /api/habits/{id} not found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check for loadHabits() call after successful deletion (removes from list)
|
||||||
|
if 'loadHabits()' not in content:
|
||||||
|
print("FAIL: loadHabits() not called after deletion (list won't refresh)")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_handling():
|
||||||
|
"""AC6: Tests error message display if delete fails"""
|
||||||
|
with open('dashboard/habits.html', 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Check for error handling in confirmDelete
|
||||||
|
pattern = r'catch\s*\(error\)\s*\{[^}]*showToast'
|
||||||
|
if not re.search(pattern, content, re.DOTALL):
|
||||||
|
print("FAIL: Error handling with showToast not found in confirmDelete")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check for error message
|
||||||
|
if 'Eroare la ștergerea obișnuinței' not in content:
|
||||||
|
print("FAIL: Delete error message not found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def test_modal_functions():
|
||||||
|
"""Tests showDeleteModal and hideDeleteModal functions"""
|
||||||
|
with open('dashboard/habits.html', 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Check for showDeleteModal function
|
||||||
|
if 'function showDeleteModal(' not in content:
|
||||||
|
print("FAIL: showDeleteModal function not found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check for hideDeleteModal function
|
||||||
|
if 'function hideDeleteModal(' not in content:
|
||||||
|
print("FAIL: hideDeleteModal function not found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check for habitToDelete variable tracking
|
||||||
|
if 'habitToDelete' not in content:
|
||||||
|
print("FAIL: habitToDelete tracking variable not found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def test_modal_show_hide_logic():
|
||||||
|
"""Tests modal active class toggle for show/hide"""
|
||||||
|
with open('dashboard/habits.html', 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Check for classList.add('active') in showDeleteModal
|
||||||
|
pattern = r'showDeleteModal[^}]*classList\.add\(["\']active["\']\)'
|
||||||
|
if not re.search(pattern, content, re.DOTALL):
|
||||||
|
print("FAIL: Modal show logic (classList.add('active')) not found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check for classList.remove('active') in hideDeleteModal
|
||||||
|
pattern = r'hideDeleteModal[^}]*classList\.remove\(["\']active["\']\)'
|
||||||
|
if not re.search(pattern, content, re.DOTALL):
|
||||||
|
print("FAIL: Modal hide logic (classList.remove('active')) not found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def test_acceptance_criteria_summary():
|
||||||
|
"""AC7: Summary test verifying all acceptance criteria"""
|
||||||
|
with open('dashboard/habits.html', 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
checks = {
|
||||||
|
'AC1: Trash icon button': 'trash-2' in content and '.habit-delete-btn' in content,
|
||||||
|
'AC2: Confirmation modal message': 'Ștergi obișnuința' in content and 'id="deleteHabitName"' in content,
|
||||||
|
'AC3: Cancel and Delete buttons': 'hideDeleteModal()' in content and 'confirmDelete()' in content,
|
||||||
|
'AC4: Red destructive style': '.btn-danger' in content and 'var(--text-danger)' in content,
|
||||||
|
'AC5: DELETE endpoint call': 'method:' in content and 'DELETE' in content and '/api/habits/' in content,
|
||||||
|
'AC6: Error handling': 'catch (error)' in content and 'Eroare la ștergerea' in content,
|
||||||
|
'AC7: Delete interaction tests pass': True # This test itself
|
||||||
|
}
|
||||||
|
|
||||||
|
all_passed = all(checks.values())
|
||||||
|
|
||||||
|
if not all_passed:
|
||||||
|
print("FAIL: Not all acceptance criteria met:")
|
||||||
|
for criterion, passed in checks.items():
|
||||||
|
if not passed:
|
||||||
|
print(f" ✗ {criterion}")
|
||||||
|
|
||||||
|
return all_passed
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
tests = [
|
||||||
|
("File exists", test_file_exists),
|
||||||
|
("Delete button CSS styling", test_delete_button_css),
|
||||||
|
("Delete button in habit card (trash icon)", test_delete_button_in_card),
|
||||||
|
("Confirmation modal structure", test_confirmation_modal_structure),
|
||||||
|
("Confirmation buttons (Cancel & Delete)", test_confirmation_buttons),
|
||||||
|
("DELETE API call on confirm", test_delete_api_call),
|
||||||
|
("Error handling for failed delete", test_error_handling),
|
||||||
|
("Modal show/hide functions", test_modal_functions),
|
||||||
|
("Modal active class toggle logic", test_modal_show_hide_logic),
|
||||||
|
("All acceptance criteria summary", test_acceptance_criteria_summary),
|
||||||
|
]
|
||||||
|
|
||||||
|
passed = 0
|
||||||
|
total = len(tests)
|
||||||
|
|
||||||
|
print("Running Story 16.0 tests (Frontend - Delete habit with confirmation)...\n")
|
||||||
|
|
||||||
|
for name, test_func in tests:
|
||||||
|
try:
|
||||||
|
result = test_func()
|
||||||
|
if result:
|
||||||
|
print(f"✓ {name}")
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
print(f"✗ {name}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ {name} (exception: {e})")
|
||||||
|
|
||||||
|
print(f"\n{passed}/{total} tests passed")
|
||||||
|
|
||||||
|
if passed == total:
|
||||||
|
print("✓ All tests passed!")
|
||||||
|
exit(0)
|
||||||
|
else:
|
||||||
|
print(f"✗ {total - passed} test(s) failed")
|
||||||
|
exit(1)
|
||||||
279
dashboard/test_habits_integration.py
Normal file
279
dashboard/test_habits_integration.py
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Integration test for complete habit lifecycle.
|
||||||
|
Tests: create, check multiple days, view streak, delete
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from http.server import HTTPServer
|
||||||
|
import threading
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
# Add parent directory to path for imports
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
from api import TaskBoardHandler
|
||||||
|
|
||||||
|
# Test configuration
|
||||||
|
PORT = 8765
|
||||||
|
BASE_URL = f"http://localhost:{PORT}"
|
||||||
|
HABITS_FILE = os.path.join(os.path.dirname(__file__), 'habits.json')
|
||||||
|
|
||||||
|
# Global server instance
|
||||||
|
server = None
|
||||||
|
server_thread = None
|
||||||
|
|
||||||
|
|
||||||
|
def setup_server():
|
||||||
|
"""Start test server in background thread"""
|
||||||
|
global server, server_thread
|
||||||
|
server = HTTPServer(('localhost', PORT), TaskBoardHandler)
|
||||||
|
server_thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
server_thread.start()
|
||||||
|
time.sleep(0.5) # Give server time to start
|
||||||
|
|
||||||
|
|
||||||
|
def teardown_server():
|
||||||
|
"""Stop test server"""
|
||||||
|
global server
|
||||||
|
if server:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
|
||||||
|
|
||||||
|
def reset_habits_file():
|
||||||
|
"""Reset habits.json to empty state"""
|
||||||
|
data = {
|
||||||
|
"lastUpdated": datetime.utcnow().isoformat() + 'Z',
|
||||||
|
"habits": []
|
||||||
|
}
|
||||||
|
with open(HABITS_FILE, 'w') as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def make_request(method, path, body=None):
|
||||||
|
"""Make HTTP request to test server"""
|
||||||
|
url = BASE_URL + path
|
||||||
|
headers = {'Content-Type': 'application/json'} if body else {}
|
||||||
|
data = json.dumps(body).encode('utf-8') if body else None
|
||||||
|
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req) as response:
|
||||||
|
response_data = response.read().decode('utf-8')
|
||||||
|
return response.status, json.loads(response_data) if response_data else None
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
response_data = e.read().decode('utf-8')
|
||||||
|
return e.code, json.loads(response_data) if response_data else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_today():
|
||||||
|
"""Get today's date in YYYY-MM-DD format"""
|
||||||
|
return datetime.utcnow().strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
|
||||||
|
def get_yesterday():
|
||||||
|
"""Get yesterday's date in YYYY-MM-DD format"""
|
||||||
|
return (datetime.utcnow() - timedelta(days=1)).strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
|
||||||
|
def test_complete_habit_lifecycle():
|
||||||
|
"""
|
||||||
|
Integration test: Complete habit flow from creation to deletion
|
||||||
|
"""
|
||||||
|
print("\n=== Integration Test: Complete Habit Lifecycle ===\n")
|
||||||
|
|
||||||
|
# Step 1: Create daily habit 'Bazin'
|
||||||
|
print("Step 1: Creating daily habit 'Bazin'...")
|
||||||
|
status, response = make_request('POST', '/api/habits', {
|
||||||
|
'name': 'Bazin',
|
||||||
|
'frequency': 'daily'
|
||||||
|
})
|
||||||
|
|
||||||
|
assert status == 201, f"Expected 201, got {status}"
|
||||||
|
assert response['name'] == 'Bazin', "Habit name mismatch"
|
||||||
|
assert response['frequency'] == 'daily', "Frequency mismatch"
|
||||||
|
assert 'id' in response, "Missing habit ID"
|
||||||
|
habit_id = response['id']
|
||||||
|
print(f"✓ Created habit: {habit_id}")
|
||||||
|
|
||||||
|
# Step 2: Check it today (streak should be 1)
|
||||||
|
print("\nStep 2: Checking habit today (expecting streak = 1)...")
|
||||||
|
status, response = make_request('POST', f'/api/habits/{habit_id}/check', {})
|
||||||
|
|
||||||
|
assert status == 200, f"Expected 200, got {status}"
|
||||||
|
assert response['streak'] == 1, f"Expected streak=1, got {response['streak']}"
|
||||||
|
assert get_today() in response['completions'], "Today's date not in completions"
|
||||||
|
print(f"✓ Checked today, streak = {response['streak']}")
|
||||||
|
|
||||||
|
# Step 3: Simulate checking yesterday (manually add to completions)
|
||||||
|
print("\nStep 3: Simulating yesterday's check (expecting streak = 2)...")
|
||||||
|
# Read current habits.json
|
||||||
|
with open(HABITS_FILE, 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
# Find the habit and add yesterday's date
|
||||||
|
for habit in data['habits']:
|
||||||
|
if habit['id'] == habit_id:
|
||||||
|
habit['completions'].append(get_yesterday())
|
||||||
|
habit['completions'].sort() # Keep chronological order
|
||||||
|
break
|
||||||
|
|
||||||
|
# Write back to file
|
||||||
|
data['lastUpdated'] = datetime.utcnow().isoformat() + 'Z'
|
||||||
|
with open(HABITS_FILE, 'w') as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
|
||||||
|
# Verify streak calculation by fetching habits
|
||||||
|
status, response = make_request('GET', '/api/habits', None)
|
||||||
|
assert status == 200, f"Expected 200, got {status}"
|
||||||
|
|
||||||
|
habit = next((h for h in response['habits'] if h['id'] == habit_id), None)
|
||||||
|
assert habit is not None, "Habit not found in response"
|
||||||
|
assert habit['streak'] == 2, f"Expected streak=2 after adding yesterday, got {habit['streak']}"
|
||||||
|
print(f"✓ Added yesterday's completion, streak = {habit['streak']}")
|
||||||
|
|
||||||
|
# Step 4: Verify streak calculation is correct
|
||||||
|
print("\nStep 4: Verifying streak calculation...")
|
||||||
|
assert len(habit['completions']) == 2, f"Expected 2 completions, got {len(habit['completions'])}"
|
||||||
|
assert get_yesterday() in habit['completions'], "Yesterday not in completions"
|
||||||
|
assert get_today() in habit['completions'], "Today not in completions"
|
||||||
|
assert habit['checkedToday'] == True, "checkedToday should be True"
|
||||||
|
print("✓ Streak calculation verified: 2 consecutive days")
|
||||||
|
|
||||||
|
# Step 5: Delete habit successfully
|
||||||
|
print("\nStep 5: Deleting habit...")
|
||||||
|
status, response = make_request('DELETE', f'/api/habits/{habit_id}', None)
|
||||||
|
|
||||||
|
assert status == 200, f"Expected 200, got {status}"
|
||||||
|
assert 'message' in response, "Missing success message"
|
||||||
|
print(f"✓ Deleted habit: {response['message']}")
|
||||||
|
|
||||||
|
# Verify habit is gone
|
||||||
|
status, response = make_request('GET', '/api/habits', None)
|
||||||
|
assert status == 200, f"Expected 200, got {status}"
|
||||||
|
|
||||||
|
habit = next((h for h in response['habits'] if h['id'] == habit_id), None)
|
||||||
|
assert habit is None, "Habit still exists after deletion"
|
||||||
|
print("✓ Verified habit no longer exists")
|
||||||
|
|
||||||
|
print("\n=== All Integration Tests Passed ✓ ===\n")
|
||||||
|
|
||||||
|
|
||||||
|
def test_broken_streak():
|
||||||
|
"""
|
||||||
|
Additional test: Verify broken streak returns 0 (with gap)
|
||||||
|
"""
|
||||||
|
print("\n=== Additional Test: Broken Streak (with gap) ===\n")
|
||||||
|
|
||||||
|
# Create habit
|
||||||
|
status, response = make_request('POST', '/api/habits', {
|
||||||
|
'name': 'Sală',
|
||||||
|
'frequency': 'daily'
|
||||||
|
})
|
||||||
|
assert status == 201
|
||||||
|
habit_id = response['id']
|
||||||
|
print(f"✓ Created habit: {habit_id}")
|
||||||
|
|
||||||
|
# Add check from 3 days ago (creating a gap)
|
||||||
|
three_days_ago = (datetime.utcnow() - timedelta(days=3)).strftime('%Y-%m-%d')
|
||||||
|
with open(HABITS_FILE, 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
for habit in data['habits']:
|
||||||
|
if habit['id'] == habit_id:
|
||||||
|
habit['completions'].append(three_days_ago)
|
||||||
|
break
|
||||||
|
|
||||||
|
data['lastUpdated'] = datetime.utcnow().isoformat() + 'Z'
|
||||||
|
with open(HABITS_FILE, 'w') as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
|
||||||
|
# Verify streak is 0 (>1 day gap means broken streak)
|
||||||
|
status, response = make_request('GET', '/api/habits', None)
|
||||||
|
habit = next((h for h in response['habits'] if h['id'] == habit_id), None)
|
||||||
|
assert habit['streak'] == 0, f"Expected streak=0 for broken streak, got {habit['streak']}"
|
||||||
|
print(f"✓ Broken streak (>1 day gap) correctly returns 0")
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
make_request('DELETE', f'/api/habits/{habit_id}', None)
|
||||||
|
print("✓ Cleanup complete")
|
||||||
|
|
||||||
|
|
||||||
|
def test_weekly_habit_streak():
|
||||||
|
"""
|
||||||
|
Additional test: Weekly habit streak calculation
|
||||||
|
"""
|
||||||
|
print("\n=== Additional Test: Weekly Habit Streak ===\n")
|
||||||
|
|
||||||
|
# Create weekly habit
|
||||||
|
status, response = make_request('POST', '/api/habits', {
|
||||||
|
'name': 'Yoga',
|
||||||
|
'frequency': 'weekly'
|
||||||
|
})
|
||||||
|
assert status == 201
|
||||||
|
habit_id = response['id']
|
||||||
|
print(f"✓ Created weekly habit: {habit_id}")
|
||||||
|
|
||||||
|
# Check today (streak = 1 week)
|
||||||
|
status, response = make_request('POST', f'/api/habits/{habit_id}/check', {})
|
||||||
|
assert status == 200
|
||||||
|
assert response['streak'] == 1, f"Expected streak=1 week, got {response['streak']}"
|
||||||
|
print(f"✓ Checked today, weekly streak = {response['streak']}")
|
||||||
|
|
||||||
|
# Add check from 8 days ago (last week)
|
||||||
|
eight_days_ago = (datetime.utcnow() - timedelta(days=8)).strftime('%Y-%m-%d')
|
||||||
|
with open(HABITS_FILE, 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
for habit in data['habits']:
|
||||||
|
if habit['id'] == habit_id:
|
||||||
|
habit['completions'].append(eight_days_ago)
|
||||||
|
habit['completions'].sort()
|
||||||
|
break
|
||||||
|
|
||||||
|
data['lastUpdated'] = datetime.utcnow().isoformat() + 'Z'
|
||||||
|
with open(HABITS_FILE, 'w') as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
|
||||||
|
# Verify streak is 2 weeks
|
||||||
|
status, response = make_request('GET', '/api/habits', None)
|
||||||
|
habit = next((h for h in response['habits'] if h['id'] == habit_id), None)
|
||||||
|
assert habit['streak'] == 2, f"Expected streak=2 weeks, got {habit['streak']}"
|
||||||
|
print(f"✓ Weekly streak calculation correct: {habit['streak']} weeks")
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
make_request('DELETE', f'/api/habits/{habit_id}', None)
|
||||||
|
print("✓ Cleanup complete")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
try:
|
||||||
|
# Setup
|
||||||
|
reset_habits_file()
|
||||||
|
setup_server()
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
test_complete_habit_lifecycle()
|
||||||
|
test_broken_streak()
|
||||||
|
test_weekly_habit_streak()
|
||||||
|
|
||||||
|
print("\n🎉 All Integration Tests Passed! 🎉\n")
|
||||||
|
|
||||||
|
except AssertionError as e:
|
||||||
|
print(f"\n❌ Test Failed: {e}\n")
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ Error: {e}\n")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
sys.exit(1)
|
||||||
|
finally:
|
||||||
|
# Cleanup
|
||||||
|
teardown_server()
|
||||||
|
reset_habits_file()
|
||||||
256
dashboard/test_habits_mobile.py
Normal file
256
dashboard/test_habits_mobile.py
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test suite for Story 14.0: Frontend - Responsive mobile design
|
||||||
|
Tests mobile responsiveness for habit tracker
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
def test_file_exists():
|
||||||
|
"""AC: Test file exists"""
|
||||||
|
path = Path(__file__).parent / 'habits.html'
|
||||||
|
assert path.exists(), "habits.html should exist"
|
||||||
|
print("✓ File exists")
|
||||||
|
|
||||||
|
def test_modal_fullscreen_mobile():
|
||||||
|
"""AC1: Modal is full-screen on mobile (< 768px)"""
|
||||||
|
path = Path(__file__).parent / 'habits.html'
|
||||||
|
content = path.read_text()
|
||||||
|
|
||||||
|
# Check for mobile media query
|
||||||
|
assert '@media (max-width: 768px)' in content, "Should have mobile media query"
|
||||||
|
|
||||||
|
# Find the mobile section by locating the media query and extracting content until the closing brace
|
||||||
|
media_start = content.find('@media (max-width: 768px)')
|
||||||
|
assert media_start != -1, "Should have mobile media query"
|
||||||
|
|
||||||
|
# Extract a reasonable chunk after the media query (enough to include all mobile styles)
|
||||||
|
mobile_chunk = content[media_start:media_start + 3000]
|
||||||
|
|
||||||
|
# Check for modal full-screen styles within mobile section
|
||||||
|
assert '.modal {' in mobile_chunk or '.modal{' in mobile_chunk, "Mobile section should include .modal styles"
|
||||||
|
assert 'width: 100%' in mobile_chunk, "Modal should have 100% width on mobile"
|
||||||
|
assert 'height: 100vh' in mobile_chunk, "Modal should have 100vh height on mobile"
|
||||||
|
assert 'max-height: 100vh' in mobile_chunk, "Modal should have 100vh max-height on mobile"
|
||||||
|
assert 'border-radius: 0' in mobile_chunk, "Modal should have no border-radius on mobile"
|
||||||
|
|
||||||
|
print("✓ Modal is full-screen on mobile")
|
||||||
|
|
||||||
|
def test_habit_cards_stack_vertically():
|
||||||
|
"""AC2: Habit cards stack vertically on mobile"""
|
||||||
|
path = Path(__file__).parent / 'habits.html'
|
||||||
|
content = path.read_text()
|
||||||
|
|
||||||
|
# Check for habits-list with flex-direction: column
|
||||||
|
assert '.habits-list' in content, "Should have .habits-list class"
|
||||||
|
|
||||||
|
# Extract habits-list styles
|
||||||
|
habits_list_match = re.search(r'\.habits-list\s*\{([^}]+)\}', content)
|
||||||
|
assert habits_list_match, "Should have .habits-list styles"
|
||||||
|
|
||||||
|
habits_list_styles = habits_list_match.group(1)
|
||||||
|
assert 'display: flex' in habits_list_styles or 'display:flex' in habits_list_styles, "habits-list should use flexbox"
|
||||||
|
assert 'flex-direction: column' in habits_list_styles or 'flex-direction:column' in habits_list_styles, "habits-list should stack vertically"
|
||||||
|
|
||||||
|
# Find the mobile section
|
||||||
|
media_start = content.find('@media (max-width: 768px)')
|
||||||
|
mobile_chunk = content[media_start:media_start + 3000]
|
||||||
|
|
||||||
|
# Verify cards are full width on mobile
|
||||||
|
assert '.habit-card {' in mobile_chunk or '.habit-card{' in mobile_chunk, "Should have .habit-card mobile styles"
|
||||||
|
assert 'width: 100%' in mobile_chunk, "Should have 100% width on mobile"
|
||||||
|
|
||||||
|
print("✓ Habit cards stack vertically on mobile")
|
||||||
|
|
||||||
|
def test_touch_targets_44px():
|
||||||
|
"""AC3: Touch targets >= 44x44px for checkbox"""
|
||||||
|
path = Path(__file__).parent / 'habits.html'
|
||||||
|
content = path.read_text()
|
||||||
|
|
||||||
|
# Find the mobile section
|
||||||
|
media_start = content.find('@media (max-width: 768px)')
|
||||||
|
assert media_start != -1, "Should have mobile media query"
|
||||||
|
|
||||||
|
mobile_chunk = content[media_start:media_start + 3000]
|
||||||
|
|
||||||
|
# Check for checkbox sizing in mobile section
|
||||||
|
assert '.habit-checkbox {' in mobile_chunk or '.habit-checkbox{' in mobile_chunk, "Should have .habit-checkbox styles in mobile section"
|
||||||
|
|
||||||
|
# Extract width and height values from the mobile checkbox section
|
||||||
|
checkbox_section_start = mobile_chunk.find('.habit-checkbox')
|
||||||
|
checkbox_section = mobile_chunk[checkbox_section_start:checkbox_section_start + 300]
|
||||||
|
|
||||||
|
width_match = re.search(r'width:\s*(\d+)px', checkbox_section)
|
||||||
|
height_match = re.search(r'height:\s*(\d+)px', checkbox_section)
|
||||||
|
|
||||||
|
assert width_match, "Checkbox should have width specified"
|
||||||
|
assert height_match, "Checkbox should have height specified"
|
||||||
|
|
||||||
|
width = int(width_match.group(1))
|
||||||
|
height = int(height_match.group(1))
|
||||||
|
|
||||||
|
# Verify touch target size (44x44px minimum for accessibility)
|
||||||
|
assert width >= 44, f"Checkbox width should be >= 44px (got {width}px)"
|
||||||
|
assert height >= 44, f"Checkbox height should be >= 44px (got {height}px)"
|
||||||
|
|
||||||
|
# Check for other touch targets (buttons)
|
||||||
|
assert 'min-height: 44px' in mobile_chunk, "Buttons should have min-height of 44px"
|
||||||
|
|
||||||
|
print("✓ Touch targets are >= 44x44px")
|
||||||
|
|
||||||
|
def test_mobile_optimized_keyboards():
|
||||||
|
"""AC4: Form inputs use mobile-optimized keyboards"""
|
||||||
|
path = Path(__file__).parent / 'habits.html'
|
||||||
|
content = path.read_text()
|
||||||
|
|
||||||
|
# Check for input field
|
||||||
|
assert 'id="habitName"' in content, "Should have habitName input field"
|
||||||
|
|
||||||
|
# Extract input element
|
||||||
|
input_match = re.search(r'<input[^>]+id="habitName"[^>]*>', content)
|
||||||
|
assert input_match, "Should have habitName input element"
|
||||||
|
|
||||||
|
input_element = input_match.group(0)
|
||||||
|
|
||||||
|
# Check for mobile-optimized attributes
|
||||||
|
# autocapitalize="words" for proper names
|
||||||
|
# autocomplete="off" to prevent autofill issues
|
||||||
|
assert 'autocapitalize="words"' in input_element or 'autocapitalize=\'words\'' in input_element, \
|
||||||
|
"Input should have autocapitalize='words' for mobile optimization"
|
||||||
|
assert 'autocomplete="off"' in input_element or 'autocomplete=\'off\'' in input_element, \
|
||||||
|
"Input should have autocomplete='off' to prevent autofill"
|
||||||
|
|
||||||
|
# Verify type="text" is present (appropriate for habit names)
|
||||||
|
assert 'type="text"' in input_element, "Input should have type='text'"
|
||||||
|
|
||||||
|
print("✓ Form inputs use mobile-optimized keyboards")
|
||||||
|
|
||||||
|
def test_swipe_navigation():
|
||||||
|
"""AC5: Swipe navigation works (via swipe-nav.js)"""
|
||||||
|
path = Path(__file__).parent / 'habits.html'
|
||||||
|
content = path.read_text()
|
||||||
|
|
||||||
|
# Check for swipe-nav.js inclusion
|
||||||
|
assert 'swipe-nav.js' in content, "Should include swipe-nav.js for mobile swipe navigation"
|
||||||
|
|
||||||
|
# Verify script tag
|
||||||
|
assert '<script src="/echo/swipe-nav.js"></script>' in content, \
|
||||||
|
"Should have proper script tag for swipe-nav.js"
|
||||||
|
|
||||||
|
# Check for viewport meta tag (required for proper mobile rendering)
|
||||||
|
assert '<meta name="viewport"' in content, "Should have viewport meta tag"
|
||||||
|
assert 'width=device-width' in content, "Viewport should include width=device-width"
|
||||||
|
assert 'initial-scale=1.0' in content, "Viewport should include initial-scale=1.0"
|
||||||
|
|
||||||
|
print("✓ Swipe navigation is enabled")
|
||||||
|
|
||||||
|
def test_mobile_button_sizing():
|
||||||
|
"""Additional test: Verify all interactive elements have proper mobile sizing"""
|
||||||
|
path = Path(__file__).parent / 'habits.html'
|
||||||
|
content = path.read_text()
|
||||||
|
|
||||||
|
# Find the mobile section
|
||||||
|
media_start = content.find('@media (max-width: 768px)')
|
||||||
|
mobile_chunk = content[media_start:media_start + 3000]
|
||||||
|
|
||||||
|
# Check for add-habit-btn sizing
|
||||||
|
assert '.add-habit-btn {' in mobile_chunk or '.add-habit-btn{' in mobile_chunk, "Should have .add-habit-btn mobile styles"
|
||||||
|
assert 'min-height: 44px' in mobile_chunk, "Add habit button should have min-height 44px"
|
||||||
|
|
||||||
|
# Check for generic .btn sizing
|
||||||
|
assert '.btn {' in mobile_chunk or '.btn{' in mobile_chunk, "Should have .btn mobile styles"
|
||||||
|
|
||||||
|
# Check for radio labels sizing
|
||||||
|
assert '.radio-label {' in mobile_chunk or '.radio-label{' in mobile_chunk, "Should have .radio-label mobile styles"
|
||||||
|
|
||||||
|
print("✓ All buttons and interactive elements have proper mobile sizing")
|
||||||
|
|
||||||
|
def test_responsive_layout_structure():
|
||||||
|
"""Additional test: Verify responsive layout structure"""
|
||||||
|
path = Path(__file__).parent / 'habits.html'
|
||||||
|
content = path.read_text()
|
||||||
|
|
||||||
|
# Find the mobile section
|
||||||
|
media_start = content.find('@media (max-width: 768px)')
|
||||||
|
mobile_chunk = content[media_start:media_start + 3000]
|
||||||
|
|
||||||
|
# Verify main padding is adjusted for mobile
|
||||||
|
assert '.main {' in mobile_chunk or '.main{' in mobile_chunk, "Should have .main mobile styles"
|
||||||
|
main_section_start = mobile_chunk.find('.main')
|
||||||
|
main_section = mobile_chunk[main_section_start:main_section_start + 200]
|
||||||
|
assert 'padding' in main_section, "Main should have adjusted padding on mobile"
|
||||||
|
|
||||||
|
print("✓ Responsive layout structure is correct")
|
||||||
|
|
||||||
|
def test_all_acceptance_criteria():
|
||||||
|
"""Summary test: Verify all 6 acceptance criteria are met"""
|
||||||
|
path = Path(__file__).parent / 'habits.html'
|
||||||
|
content = path.read_text()
|
||||||
|
|
||||||
|
# Find mobile section
|
||||||
|
media_start = content.find('@media (max-width: 768px)')
|
||||||
|
mobile_chunk = content[media_start:media_start + 3000]
|
||||||
|
|
||||||
|
# AC1: Modal is full-screen on mobile
|
||||||
|
assert '.modal {' in mobile_chunk or '.modal{' in mobile_chunk, "AC1: Modal styles in mobile section"
|
||||||
|
assert 'width: 100%' in mobile_chunk, "AC1: Modal full-screen width"
|
||||||
|
assert 'height: 100vh' in mobile_chunk, "AC1: Modal full-screen height"
|
||||||
|
|
||||||
|
# AC2: Habit cards stack vertically
|
||||||
|
habits_list_match = re.search(r'\.habits-list\s*\{([^}]+)\}', content)
|
||||||
|
assert habits_list_match and 'flex-direction: column' in habits_list_match.group(1), "AC2: Cards stack vertically"
|
||||||
|
|
||||||
|
# AC3: Touch targets >= 44x44px
|
||||||
|
checkbox_section_start = mobile_chunk.find('.habit-checkbox')
|
||||||
|
checkbox_section = mobile_chunk[checkbox_section_start:checkbox_section_start + 300]
|
||||||
|
assert 'width: 44px' in checkbox_section, "AC3: Touch targets 44px width"
|
||||||
|
assert 'height: 44px' in checkbox_section, "AC3: Touch targets 44px height"
|
||||||
|
|
||||||
|
# AC4: Mobile-optimized keyboards
|
||||||
|
input_match = re.search(r'<input[^>]+id="habitName"[^>]*>', content)
|
||||||
|
assert input_match and 'autocapitalize="words"' in input_match.group(0), "AC4: Mobile keyboards"
|
||||||
|
|
||||||
|
# AC5: Swipe navigation
|
||||||
|
assert 'swipe-nav.js' in content, "AC5: Swipe navigation"
|
||||||
|
|
||||||
|
# AC6: Tests pass (this test itself)
|
||||||
|
print("✓ All 6 acceptance criteria verified")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Run all tests"""
|
||||||
|
tests = [
|
||||||
|
test_file_exists,
|
||||||
|
test_modal_fullscreen_mobile,
|
||||||
|
test_habit_cards_stack_vertically,
|
||||||
|
test_touch_targets_44px,
|
||||||
|
test_mobile_optimized_keyboards,
|
||||||
|
test_swipe_navigation,
|
||||||
|
test_mobile_button_sizing,
|
||||||
|
test_responsive_layout_structure,
|
||||||
|
test_all_acceptance_criteria
|
||||||
|
]
|
||||||
|
|
||||||
|
print("Running Story 14.0 mobile responsiveness tests...\n")
|
||||||
|
|
||||||
|
failed = []
|
||||||
|
for test in tests:
|
||||||
|
try:
|
||||||
|
test()
|
||||||
|
except AssertionError as e:
|
||||||
|
failed.append((test.__name__, str(e)))
|
||||||
|
print(f"✗ {test.__name__}: {e}")
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
if failed:
|
||||||
|
print(f"FAILED: {len(failed)} test(s) failed")
|
||||||
|
for name, error in failed:
|
||||||
|
print(f" - {name}: {error}")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
print(f"SUCCESS: All {len(tests)} tests passed! ✓")
|
||||||
|
return True
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
import sys
|
||||||
|
sys.exit(0 if main() else 1)
|
||||||
235
dashboard/test_habits_navigation.py
Normal file
235
dashboard/test_habits_navigation.py
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test Suite for Story 13.0: Frontend - Add to dashboard navigation
|
||||||
|
Tests that Habit Tracker link is added to main navigation properly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_existence():
|
||||||
|
"""Test that both index.html and habits.html exist."""
|
||||||
|
assert os.path.exists('dashboard/index.html'), "index.html should exist"
|
||||||
|
assert os.path.exists('dashboard/habits.html'), "habits.html should exist"
|
||||||
|
print("✓ Both HTML files exist")
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_habits_link():
|
||||||
|
"""Test that index.html includes Habits link pointing to /echo/habits.html."""
|
||||||
|
with open('dashboard/index.html', 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Check for Habits link with correct href
|
||||||
|
assert 'href="/echo/habits.html"' in content, "index.html should have link to /echo/habits.html"
|
||||||
|
|
||||||
|
# Check that Habits link exists in navigation
|
||||||
|
habits_link_pattern = r'<a[^>]*href="/echo/habits\.html"[^>]*class="nav-item"[^>]*>.*?<span>Habits</span>'
|
||||||
|
assert re.search(habits_link_pattern, content, re.DOTALL), "Habits link should be in nav-item format"
|
||||||
|
|
||||||
|
print("✓ index.html includes Habits link to /echo/habits.html (AC1, AC2)")
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_flame_icon():
|
||||||
|
"""Test that index.html Habits link uses flame icon (lucide)."""
|
||||||
|
with open('dashboard/index.html', 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Find the Habits nav item
|
||||||
|
habits_section = re.search(
|
||||||
|
r'<a[^>]*href="/echo/habits\.html"[^>]*>.*?</a>',
|
||||||
|
content,
|
||||||
|
re.DOTALL
|
||||||
|
)
|
||||||
|
|
||||||
|
assert habits_section, "Habits link should exist"
|
||||||
|
habits_html = habits_section.group(0)
|
||||||
|
|
||||||
|
# Check for flame icon (lucide)
|
||||||
|
assert 'data-lucide="flame"' in habits_html, "Habits link should use lucide flame icon"
|
||||||
|
|
||||||
|
print("✓ index.html Habits link uses flame icon (AC3)")
|
||||||
|
|
||||||
|
|
||||||
|
def test_habits_back_to_dashboard():
|
||||||
|
"""Test that habits.html navigation includes link back to dashboard."""
|
||||||
|
with open('dashboard/habits.html', 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Check for Dashboard link
|
||||||
|
assert 'href="/echo/index.html"' in content, "habits.html should link back to dashboard"
|
||||||
|
|
||||||
|
# Check that Dashboard link exists in navigation
|
||||||
|
dashboard_link_pattern = r'<a[^>]*href="/echo/index\.html"[^>]*class="nav-item"[^>]*>.*?<span>Dashboard</span>'
|
||||||
|
assert re.search(dashboard_link_pattern, content, re.DOTALL), "Dashboard link should be in nav-item format"
|
||||||
|
|
||||||
|
print("✓ habits.html includes link back to dashboard (AC4)")
|
||||||
|
|
||||||
|
|
||||||
|
def test_habits_flame_icon():
|
||||||
|
"""Test that habits.html Habits link also uses flame icon."""
|
||||||
|
with open('dashboard/habits.html', 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Find the Habits nav item in habits.html
|
||||||
|
habits_section = re.search(
|
||||||
|
r'<a[^>]*href="/echo/habits\.html"[^>]*class="nav-item active"[^>]*>.*?</a>',
|
||||||
|
content,
|
||||||
|
re.DOTALL
|
||||||
|
)
|
||||||
|
|
||||||
|
assert habits_section, "Habits link should exist in habits.html with active class"
|
||||||
|
habits_html = habits_section.group(0)
|
||||||
|
|
||||||
|
# Check for flame icon (lucide)
|
||||||
|
assert 'data-lucide="flame"' in habits_html, "habits.html Habits link should use lucide flame icon"
|
||||||
|
|
||||||
|
print("✓ habits.html Habits link uses flame icon (AC3)")
|
||||||
|
|
||||||
|
|
||||||
|
def test_active_state_styling():
|
||||||
|
"""Test that active state styling matches other nav items."""
|
||||||
|
with open('dashboard/habits.html', 'r', encoding='utf-8') as f:
|
||||||
|
habits_content = f.read()
|
||||||
|
|
||||||
|
with open('dashboard/index.html', 'r', encoding='utf-8') as f:
|
||||||
|
index_content = f.read()
|
||||||
|
|
||||||
|
# Check that habits.html has 'active' class on Habits nav item
|
||||||
|
habits_active = re.search(
|
||||||
|
r'<a[^>]*href="/echo/habits\.html"[^>]*class="nav-item active"',
|
||||||
|
habits_content
|
||||||
|
)
|
||||||
|
assert habits_active, "Habits nav item should have 'active' class in habits.html"
|
||||||
|
|
||||||
|
# Check that index.html has 'active' class on Dashboard nav item (pattern to follow)
|
||||||
|
index_active = re.search(
|
||||||
|
r'<a[^>]*href="/echo/index\.html"[^>]*class="nav-item active"',
|
||||||
|
index_content
|
||||||
|
)
|
||||||
|
assert index_active, "Dashboard nav item should have 'active' class in index.html"
|
||||||
|
|
||||||
|
# Both should use the same pattern (nav-item active)
|
||||||
|
print("✓ Active state styling matches other nav items (AC5)")
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_navigation():
|
||||||
|
"""Test that mobile navigation is supported (shared nav structure)."""
|
||||||
|
with open('dashboard/index.html', 'r', encoding='utf-8') as f:
|
||||||
|
index_content = f.read()
|
||||||
|
|
||||||
|
with open('dashboard/habits.html', 'r', encoding='utf-8') as f:
|
||||||
|
habits_content = f.read()
|
||||||
|
|
||||||
|
# Check that both files include swipe-nav.js for mobile navigation
|
||||||
|
assert 'swipe-nav.js' in index_content, "index.html should include swipe-nav.js for mobile navigation"
|
||||||
|
assert 'swipe-nav.js' in habits_content, "habits.html should include swipe-nav.js for mobile navigation"
|
||||||
|
|
||||||
|
# Check that navigation uses the same class structure (nav-item)
|
||||||
|
# This ensures mobile navigation will work consistently
|
||||||
|
index_nav_items = len(re.findall(r'class="nav-item', index_content))
|
||||||
|
habits_nav_items = len(re.findall(r'class="nav-item', habits_content))
|
||||||
|
|
||||||
|
assert index_nav_items >= 5, "index.html should have at least 5 nav items (including Habits)"
|
||||||
|
assert habits_nav_items >= 5, "habits.html should have at least 5 nav items"
|
||||||
|
|
||||||
|
print("✓ Mobile navigation is supported (AC6)")
|
||||||
|
|
||||||
|
|
||||||
|
def test_navigation_completeness():
|
||||||
|
"""Test that navigation is complete on both pages."""
|
||||||
|
with open('dashboard/index.html', 'r', encoding='utf-8') as f:
|
||||||
|
index_content = f.read()
|
||||||
|
|
||||||
|
with open('dashboard/habits.html', 'r', encoding='utf-8') as f:
|
||||||
|
habits_content = f.read()
|
||||||
|
|
||||||
|
# Define expected navigation items
|
||||||
|
nav_items = [
|
||||||
|
('Dashboard', '/echo/index.html', 'layout-dashboard'),
|
||||||
|
('Workspace', '/echo/workspace.html', 'code'),
|
||||||
|
('KB', '/echo/notes.html', 'file-text'),
|
||||||
|
('Files', '/echo/files.html', 'folder'),
|
||||||
|
('Habits', '/echo/habits.html', 'flame')
|
||||||
|
]
|
||||||
|
|
||||||
|
# Check all items exist in both files
|
||||||
|
for label, href, icon in nav_items:
|
||||||
|
assert href in index_content, f"index.html should have link to {href}"
|
||||||
|
assert href in habits_content, f"habits.html should have link to {href}"
|
||||||
|
|
||||||
|
# Check flame icon specifically
|
||||||
|
assert 'data-lucide="flame"' in index_content, "index.html should have flame icon"
|
||||||
|
assert 'data-lucide="flame"' in habits_content, "habits.html should have flame icon"
|
||||||
|
|
||||||
|
print("✓ Navigation is complete on both pages with all 5 items")
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_acceptance_criteria():
|
||||||
|
"""Summary test: verify all 7 acceptance criteria are met."""
|
||||||
|
print("\n=== Testing All Acceptance Criteria ===")
|
||||||
|
|
||||||
|
with open('dashboard/index.html', 'r', encoding='utf-8') as f:
|
||||||
|
index_content = f.read()
|
||||||
|
|
||||||
|
with open('dashboard/habits.html', 'r', encoding='utf-8') as f:
|
||||||
|
habits_content = f.read()
|
||||||
|
|
||||||
|
# AC1: index.html navigation includes 'Habits' link
|
||||||
|
ac1 = 'href="/echo/habits.html"' in index_content and 'class="nav-item"' in index_content
|
||||||
|
print(f"AC1 - index.html has Habits link: {'✓' if ac1 else '✗'}")
|
||||||
|
|
||||||
|
# AC2: Link points to /echo/habits.html
|
||||||
|
ac2 = 'href="/echo/habits.html"' in index_content
|
||||||
|
print(f"AC2 - Link points to /echo/habits.html: {'✓' if ac2 else '✗'}")
|
||||||
|
|
||||||
|
# AC3: Uses flame icon (lucide)
|
||||||
|
ac3 = 'data-lucide="flame"' in index_content and 'data-lucide="flame"' in habits_content
|
||||||
|
print(f"AC3 - Uses flame icon: {'✓' if ac3 else '✗'}")
|
||||||
|
|
||||||
|
# AC4: habits.html navigation includes link back to dashboard
|
||||||
|
ac4 = 'href="/echo/index.html"' in habits_content
|
||||||
|
print(f"AC4 - habits.html links back to dashboard: {'✓' if ac4 else '✗'}")
|
||||||
|
|
||||||
|
# AC5: Active state styling matches
|
||||||
|
ac5_habits = bool(re.search(r'href="/echo/habits\.html"[^>]*class="nav-item active"', habits_content))
|
||||||
|
ac5_index = bool(re.search(r'href="/echo/index\.html"[^>]*class="nav-item active"', index_content))
|
||||||
|
ac5 = ac5_habits and ac5_index
|
||||||
|
print(f"AC5 - Active state styling matches: {'✓' if ac5 else '✗'}")
|
||||||
|
|
||||||
|
# AC6: Mobile navigation supported
|
||||||
|
ac6 = 'swipe-nav.js' in index_content and 'swipe-nav.js' in habits_content
|
||||||
|
print(f"AC6 - Mobile navigation supported: {'✓' if ac6 else '✗'}")
|
||||||
|
|
||||||
|
# AC7: Tests pass (this test itself)
|
||||||
|
ac7 = True
|
||||||
|
print(f"AC7 - Tests for navigation pass: {'✓' if ac7 else '✗'}")
|
||||||
|
|
||||||
|
assert all([ac1, ac2, ac3, ac4, ac5, ac6, ac7]), "All acceptance criteria should pass"
|
||||||
|
print("\n✓ All 7 acceptance criteria met!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
print("Running Story 13.0 Navigation Tests...\n")
|
||||||
|
|
||||||
|
try:
|
||||||
|
test_file_existence()
|
||||||
|
test_index_habits_link()
|
||||||
|
test_index_flame_icon()
|
||||||
|
test_habits_back_to_dashboard()
|
||||||
|
test_habits_flame_icon()
|
||||||
|
test_active_state_styling()
|
||||||
|
test_mobile_navigation()
|
||||||
|
test_navigation_completeness()
|
||||||
|
test_all_acceptance_criteria()
|
||||||
|
|
||||||
|
print("\n" + "="*50)
|
||||||
|
print("✓ ALL TESTS PASSED")
|
||||||
|
print("="*50)
|
||||||
|
|
||||||
|
except AssertionError as e:
|
||||||
|
print(f"\n✗ TEST FAILED: {e}")
|
||||||
|
exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n✗ ERROR: {e}")
|
||||||
|
exit(1)
|
||||||
21
memory/2026-02-10.md
Normal file
21
memory/2026-02-10.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# 2026-02-10
|
||||||
|
|
||||||
|
## Dashboard ANAF - Detalii Modificări
|
||||||
|
|
||||||
|
**Context:** Marius a cerut să vadă ce modificări detectează ANAF Monitor în dashboard, nu doar mesaj generic "Modificări detectate".
|
||||||
|
|
||||||
|
**Implementare:**
|
||||||
|
1. **monitor_v2.py** - modificat `update_dashboard_status()` să salveze detalii în `status.json`:
|
||||||
|
- Nume pagină modificată
|
||||||
|
- URL către pagina ANAF
|
||||||
|
- Rezumat modificări (ex: "Soft A: 09.02.2026 → 10.02.2026")
|
||||||
|
|
||||||
|
2. **dashboard/index.html** - modificat `loadAnafStatus()` să afișeze detaliile:
|
||||||
|
- Link-uri clickabile către paginile ANAF
|
||||||
|
- Lista modificărilor pentru fiecare pagină
|
||||||
|
- Expandabil în secțiunea ANAF Monitor
|
||||||
|
|
||||||
|
**Modificare reală detectată astăzi:**
|
||||||
|
- D100 (Declarația 100) - Soft A: 09.02.2026 → 10.02.2026
|
||||||
|
|
||||||
|
**Status:** Implementat, netestat în browser. Așteaptă commit.
|
||||||
@@ -1,5 +1,48 @@
|
|||||||
{
|
{
|
||||||
"notes": [
|
"notes": [
|
||||||
|
{
|
||||||
|
"file": "notes-data/tools/antfarm-workflow.md",
|
||||||
|
"title": "Antfarm Workflow - Echo",
|
||||||
|
"date": "2026-02-10",
|
||||||
|
"tags": [],
|
||||||
|
"domains": [],
|
||||||
|
"types": [],
|
||||||
|
"category": "tools",
|
||||||
|
"project": null,
|
||||||
|
"subdir": null,
|
||||||
|
"video": "",
|
||||||
|
"tldr": "**Update:** După ce învăț fluxul mai bine"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "memory/provocare-azi.md",
|
||||||
|
"title": "Provocarea Zilei - 2026-02-08",
|
||||||
|
"date": "2026-02-10",
|
||||||
|
"tags": [],
|
||||||
|
"domains": [],
|
||||||
|
"types": [
|
||||||
|
"memory"
|
||||||
|
],
|
||||||
|
"category": "memory",
|
||||||
|
"project": null,
|
||||||
|
"subdir": null,
|
||||||
|
"video": "",
|
||||||
|
"tldr": "- Monica Ion - Legea Fractalilor (Cele 7 Legi Universale)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "memory/2026-02-10.md",
|
||||||
|
"title": "2026-02-10",
|
||||||
|
"date": "2026-02-10",
|
||||||
|
"tags": [],
|
||||||
|
"domains": [],
|
||||||
|
"types": [
|
||||||
|
"memory"
|
||||||
|
],
|
||||||
|
"category": "memory",
|
||||||
|
"project": null,
|
||||||
|
"subdir": null,
|
||||||
|
"video": "",
|
||||||
|
"tldr": "**Status:** Aștept confirmare de la Marius să lansez `antfarm workflow run feature-dev`."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"file": "notes-data/coaching/2026-02-09-seara.md",
|
"file": "notes-data/coaching/2026-02-09-seara.md",
|
||||||
"title": "Gândul de Seară - Duminică, 9 Februarie 2026",
|
"title": "Gândul de Seară - Duminică, 9 Februarie 2026",
|
||||||
@@ -813,21 +856,6 @@
|
|||||||
"video": "https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/articole/monica-ion/cele-7-legi-universale.md",
|
"video": "https://moltbot.tailf7372d.ts.net/echo/files.html#memory/kb/articole/monica-ion/cele-7-legi-universale.md",
|
||||||
"tldr": "Cele 7 Legi Universale sunt principii fundamentale care explică cum funcționează mintea, de ce trăim viața așa cum o trăim și cum putem genera transformare reală. Fiecare lege este susținută de istori..."
|
"tldr": "Cele 7 Legi Universale sunt principii fundamentale care explică cum funcționează mintea, de ce trăim viața așa cum o trăim și cum putem genera transformare reală. Fiecare lege este susținută de istori..."
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"file": "memory/provocare-azi.md",
|
|
||||||
"title": "Provocarea Zilei - 2026-02-08",
|
|
||||||
"date": "2026-02-08",
|
|
||||||
"tags": [],
|
|
||||||
"domains": [],
|
|
||||||
"types": [
|
|
||||||
"memory"
|
|
||||||
],
|
|
||||||
"category": "memory",
|
|
||||||
"project": null,
|
|
||||||
"subdir": null,
|
|
||||||
"video": "",
|
|
||||||
"tldr": "- Monica Ion - Legea Fractalilor (Cele 7 Legi Universale)"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"file": "memory/jurnal-motivatie.md",
|
"file": "memory/jurnal-motivatie.md",
|
||||||
"title": "Jurnal - Drumul spre regăsirea motivației",
|
"title": "Jurnal - Drumul spre regăsirea motivației",
|
||||||
@@ -3346,7 +3374,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"stats": {
|
"stats": {
|
||||||
"total": 194,
|
"total": 196,
|
||||||
"by_domain": {
|
"by_domain": {
|
||||||
"work": 59,
|
"work": 59,
|
||||||
"health": 34,
|
"health": 34,
|
||||||
@@ -3365,9 +3393,9 @@
|
|||||||
"projects": 85,
|
"projects": 85,
|
||||||
"reflectii": 3,
|
"reflectii": 3,
|
||||||
"retete": 1,
|
"retete": 1,
|
||||||
"tools": 5,
|
"tools": 6,
|
||||||
"youtube": 42,
|
"youtube": 42,
|
||||||
"memory": 16
|
"memory": 17
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"domains": [
|
"domains": [
|
||||||
|
|||||||
87
memory/kb/tools/antfarm-workflow.md
Normal file
87
memory/kb/tools/antfarm-workflow.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# Antfarm Workflow - Echo
|
||||||
|
|
||||||
|
**Instalat:** github.com/snarktank/antfarm
|
||||||
|
**CLI:** `antfarm` (în PATH, global)
|
||||||
|
**Dashboard:** https://moltbot.tailf7372d.ts.net:3333
|
||||||
|
**Docs:** ~/clawd/antfarm/README.md, ~/clawd/antfarm/docs/creating-workflows.md
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flux rapid (pentru Echo)
|
||||||
|
|
||||||
|
### 1. Primesc request de la Marius
|
||||||
|
**EX:** "Vreau să construiesc un Habit tracker în dashboard"
|
||||||
|
|
||||||
|
### 2. Lansez direct workflow-ul cu promptul lui Marius
|
||||||
|
```bash
|
||||||
|
cd ~/clawd
|
||||||
|
antfarm workflow run feature-dev "<prompt exact de la Marius>"
|
||||||
|
```
|
||||||
|
|
||||||
|
**NU:**
|
||||||
|
- ✗ Verific dacă e instalat (e instalat, permanent)
|
||||||
|
- ✗ Fac eu requirements/acceptance criteria (planner-ul face asta)
|
||||||
|
- ✗ Complicez task string-ul (simplitate = mai bine)
|
||||||
|
|
||||||
|
**DA:**
|
||||||
|
- ✓ Trimit prompt-ul EXACT cum îl primesc de la Marius
|
||||||
|
- ✓ Planner-ul descompune în stories automat
|
||||||
|
- ✓ Developer-ul decide tehnologii/structură
|
||||||
|
|
||||||
|
### 3. Monitorez progres
|
||||||
|
```bash
|
||||||
|
antfarm workflow status <run-id sau substring>
|
||||||
|
antfarm workflow runs # listă toate
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Raportez când e gata
|
||||||
|
Agenții lucrează autonom (polling 15 min). Raportez când:
|
||||||
|
- Stories finalizate
|
||||||
|
- Erori care necesită intervenție
|
||||||
|
- PR creat pentru review
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workflows disponibile
|
||||||
|
|
||||||
|
| Workflow | Când să-l folosesc |
|
||||||
|
|----------|-------------------|
|
||||||
|
| `feature-dev` | Features noi, refactoring, îmbunătățiri |
|
||||||
|
| `bug-fix` | Bug-uri cu pași de reproducere |
|
||||||
|
| `security-audit` | Audit securitate codebase |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Comenzi utile
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Status rapid
|
||||||
|
antfarm workflow status <query>
|
||||||
|
|
||||||
|
# Force trigger agent (skip 15min wait)
|
||||||
|
cron action=run jobId=antfarm/feature-dev/developer
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
antfarm logs 50
|
||||||
|
|
||||||
|
# Resume dacă failuit
|
||||||
|
antfarm workflow resume <run-id>
|
||||||
|
|
||||||
|
# Dashboard
|
||||||
|
antfarm dashboard status
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reguli importante
|
||||||
|
|
||||||
|
1. **Task string = prompt exact de la Marius** (nu complica)
|
||||||
|
2. **Planner face requirements** (nu tu)
|
||||||
|
3. **Agenții sunt autonomi** (polling 15 min, nu trebuie să-i controlezi)
|
||||||
|
4. **Monitor dashboard** (https://moltbot.tailf7372d.ts.net:3333)
|
||||||
|
5. **Raportează doar când e relevant** (finalizare, erori, PR)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Creat:** 2026-02-10
|
||||||
|
**Update:** După ce învăț fluxul mai bine
|
||||||
249
progress.txt
249
progress.txt
@@ -273,6 +273,16 @@ CODEBASE PATTERNS UPDATE:
|
|||||||
- Empty states: .empty-state with centered icon, message, and action button
|
- Empty states: .empty-state with centered icon, message, and action button
|
||||||
- Icons: use lucide via data-lucide attribute, initialize with lucide.createIcons()
|
- Icons: use lucide via data-lucide attribute, initialize with lucide.createIcons()
|
||||||
|
|
||||||
|
6. Mobile Responsiveness
|
||||||
|
- Use @media (max-width: 768px) for mobile breakpoint
|
||||||
|
- Touch targets: minimum 44x44px for WCAG compliance (checkboxes, buttons)
|
||||||
|
- Modal pattern: full-screen on mobile (100% width/height, no border-radius)
|
||||||
|
- Input optimization: autocapitalize="words" for proper names, autocomplete="off" for sensitive fields
|
||||||
|
- Navigation: swipe-nav.js provides mobile swipe gestures
|
||||||
|
- Viewport: include <meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
- All buttons should have min-height: 44px on mobile for easy tapping
|
||||||
|
- Flexbox direction already handles vertical stacking (flex-direction: column)
|
||||||
|
|
||||||
[✓] Story 8.0: Frontend - Create habit form modal
|
[✓] Story 8.0: Frontend - Create habit form modal
|
||||||
Commit: 97af2ae
|
Commit: 97af2ae
|
||||||
Date: 2026-02-10
|
Date: 2026-02-10
|
||||||
@@ -452,9 +462,246 @@ CODEBASE PATTERNS UPDATE:
|
|||||||
- dashboard/test_habits_form_submit.py (created)
|
- dashboard/test_habits_form_submit.py (created)
|
||||||
- dashboard/habits.json (reset to empty for testing)
|
- dashboard/habits.json (reset to empty for testing)
|
||||||
|
|
||||||
|
[✓] Story 12.0: Frontend - Habit card styling
|
||||||
|
Commit: c1d4ed1
|
||||||
|
Date: 2026-02-10
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
- Enhanced habit card styling to match dashboard aesthetic
|
||||||
|
- Changed card border-radius from --radius-md to --radius-lg for smoother appearance
|
||||||
|
- Changed streak font-size from --text-lg to --text-xl for prominent display
|
||||||
|
- Added green background tint (rgba(34, 197, 94, 0.1)) for checked habit cards
|
||||||
|
- Added 'checked' CSS class to habit-card when checkedToday is true
|
||||||
|
- Implemented pulse animation on checkbox hover for unchecked habits
|
||||||
|
- Animation scales checkbox subtly (1.0 to 1.05) with 1.5s ease-in-out timing
|
||||||
|
- Styled frequency badge as dashboard tag with inline-block, bg-elevated, border, padding
|
||||||
|
- Updated JavaScript createHabitCard to add 'checked' class to card element
|
||||||
|
- Updated JavaScript checkHabit to add 'checked' class on successful check
|
||||||
|
- Updated error rollback to remove 'checked' class if check fails
|
||||||
|
- Added mobile responsiveness with @media (max-width: 768px) query
|
||||||
|
- Mobile styles: full width cards, reduced padding, smaller icons (36px, 28px)
|
||||||
|
- All CSS uses CSS variables for theming consistency
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
- Created dashboard/test_habits_card_styling.py with 10 comprehensive tests
|
||||||
|
- Tests for file existence
|
||||||
|
- Tests for card using --bg-surface with --border (acceptance criteria 1)
|
||||||
|
- Tests for --radius-lg border radius on cards (acceptance criteria 6)
|
||||||
|
- Tests for streak using --text-xl font size (acceptance criteria 2)
|
||||||
|
- Tests for checked habit green background tint (acceptance criteria 3)
|
||||||
|
- Tests for pulse animation on unchecked checkbox hover (acceptance criteria 4)
|
||||||
|
- Tests for frequency badge dashboard tag styling (acceptance criteria 5)
|
||||||
|
- Tests for mobile responsiveness with full width cards (acceptance criteria 7)
|
||||||
|
- Tests for checked class in createHabitCard function
|
||||||
|
- Summary test verifying all 7 acceptance criteria
|
||||||
|
- All 10 tests pass ✓ (acceptance criteria 8)
|
||||||
|
- All previous tests (schema, API, HTML, modal, display, check, form) still pass ✓
|
||||||
|
|
||||||
|
Files modified:
|
||||||
|
- dashboard/habits.html (updated CSS and JavaScript for styling enhancements)
|
||||||
|
- dashboard/test_habits_card_styling.py (created)
|
||||||
|
|
||||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
NEXT STEPS:
|
NEXT STEPS:
|
||||||
- Continue with remaining 7 stories
|
- Continue with remaining 3 stories
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
[✓] Story 13.0: Frontend - Add to dashboard navigation
|
||||||
|
Commit: 1d56fe3
|
||||||
|
Date: 2026-02-10
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
- Added Habits link to index.html navigation (5th nav item)
|
||||||
|
- Link points to /echo/habits.html with flame icon (lucide)
|
||||||
|
- Changed habits.html Habits icon from "target" to "flame" for consistency
|
||||||
|
- Navigation structure matches existing pattern (nav-item class)
|
||||||
|
- Dashboard link already existed in habits.html (links back properly)
|
||||||
|
- Active state styling uses same pattern as other nav items
|
||||||
|
- Mobile navigation supported via shared swipe-nav.js
|
||||||
|
- All 5 navigation items now present on both pages
|
||||||
|
- Flame icon (🔥 lucide) used consistently across both pages
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
- Created dashboard/test_habits_navigation.py with 9 comprehensive tests
|
||||||
|
- Tests for file existence
|
||||||
|
- Tests for index.html Habits link to /echo/habits.html (AC1, AC2)
|
||||||
|
- Tests for flame icon usage in index.html (AC3)
|
||||||
|
- Tests for habits.html link back to dashboard (AC4)
|
||||||
|
- Tests for flame icon usage in habits.html (AC3)
|
||||||
|
- Tests for active state styling consistency (AC5)
|
||||||
|
- Tests for mobile navigation support via swipe-nav.js (AC6)
|
||||||
|
- Tests for navigation completeness (all 5 items on both pages)
|
||||||
|
- Summary test verifying all 7 acceptance criteria
|
||||||
|
- All 9 tests pass ✓ (AC7)
|
||||||
|
- All previous tests (schema, API endpoints, HTML, modal, display, check, form, styling) still pass ✓ (except schema test expects empty habits.json)
|
||||||
|
|
||||||
|
Files modified:
|
||||||
|
- dashboard/index.html (added Habits nav link with flame icon)
|
||||||
|
- dashboard/habits.html (changed icon from target to flame)
|
||||||
|
- dashboard/test_habits_navigation.py (created)
|
||||||
|
|
||||||
|
|
||||||
|
[✓] Story 14.0: Frontend - Responsive mobile design
|
||||||
|
Commit: 0011664
|
||||||
|
Date: 2026-02-10
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
- Enhanced mobile responsiveness for habit tracker
|
||||||
|
- Modal is now full-screen on mobile (< 768px): 100% width/height, no border-radius
|
||||||
|
- Touch targets increased to 44x44px for checkboxes (from 28px)
|
||||||
|
- All buttons have min-height: 44px on mobile (add-habit-btn, .btn, .radio-label)
|
||||||
|
- Form input uses autocapitalize="words" for mobile-optimized keyboard
|
||||||
|
- Form input uses autocomplete="off" to prevent autofill issues
|
||||||
|
- Habit cards already stack vertically via flex-direction: column
|
||||||
|
- Cards are 100% width on mobile for optimal space usage
|
||||||
|
- Swipe navigation already enabled via swipe-nav.js inclusion
|
||||||
|
- Responsive padding adjustments for .main on mobile
|
||||||
|
- Icon sizes adjusted for mobile (habit-icon: 36px, checkbox icons: 20px)
|
||||||
|
- All interactive elements meet WCAG touch target guidelines (44x44px minimum)
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
- Created dashboard/test_habits_mobile.py with 9 comprehensive tests
|
||||||
|
- Tests for mobile media query existence (@media max-width: 768px)
|
||||||
|
- Tests for modal full-screen on mobile (100% width/height, 100vh, no border-radius) [AC1]
|
||||||
|
- Tests for habit cards stacking vertically (flex-direction: column, 100% width) [AC2]
|
||||||
|
- Tests for touch targets >= 44x44px (checkbox: 44px, buttons: min-height 44px) [AC3]
|
||||||
|
- Tests for mobile-optimized keyboards (autocapitalize="words", autocomplete="off") [AC4]
|
||||||
|
- Tests for swipe navigation (swipe-nav.js, viewport meta tag) [AC5]
|
||||||
|
- Tests for all button sizing (add-habit-btn, .btn, .radio-label with min-height)
|
||||||
|
- Tests for responsive layout structure (.main padding adjustment)
|
||||||
|
- Summary test verifying all 6 acceptance criteria [AC6]
|
||||||
|
- All 9 tests pass ✓
|
||||||
|
- All previous tests (HTML structure, modal, display, check, form, styling, navigation) still pass ✓
|
||||||
|
|
||||||
|
Files modified:
|
||||||
|
- dashboard/habits.html (enhanced mobile CSS, added input attributes)
|
||||||
|
- dashboard/test_habits_mobile.py (created)
|
||||||
|
|
||||||
|
[✓] Story 15.0: Backend - Delete habit endpoint
|
||||||
|
Commit: 0f9c0de
|
||||||
|
Date: 2026-02-10
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
- Added do_DELETE method to api.py for handling DELETE requests
|
||||||
|
- Route: DELETE /api/habits/{id} deletes habit by ID
|
||||||
|
- Extracts habit ID from URL path (/api/habits/{id})
|
||||||
|
- Returns 404 if habit not found in habits.json
|
||||||
|
- Removes habit from habits array using list.pop(index)
|
||||||
|
- Updates lastUpdated timestamp after deletion
|
||||||
|
- Returns 200 with success message, including deleted habit ID
|
||||||
|
- Graceful error handling for missing/corrupt habits.json (returns 404)
|
||||||
|
- Follows existing API patterns (similar to handle_habits_check)
|
||||||
|
- Error responses include descriptive error messages
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
- Created dashboard/test_habits_delete.py with 6 comprehensive tests
|
||||||
|
- Tests for habit removal from habits.json file (AC1)
|
||||||
|
- Tests for 200 status with success message response (AC2)
|
||||||
|
- Tests for 404 when habit not found (AC3)
|
||||||
|
- Tests for lastUpdated timestamp update (AC4)
|
||||||
|
- Tests for edge cases: deleting already deleted habit, invalid paths
|
||||||
|
- Tests for graceful handling when habits.json is missing
|
||||||
|
- All 6 tests pass ✓ (AC5)
|
||||||
|
- All previous tests (schema, GET, POST, streak, check) still pass ✓
|
||||||
|
|
||||||
|
Files modified:
|
||||||
|
- dashboard/api.py (added do_DELETE method and handle_habits_delete)
|
||||||
|
- dashboard/test_habits_delete.py (created)
|
||||||
|
|
||||||
|
[✓] Story 16.0: Frontend - Delete habit with confirmation
|
||||||
|
Commit: 46dc3a5
|
||||||
|
Date: 2026-02-10
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
- Added delete button with trash icon (lucide trash-2) to each habit card
|
||||||
|
- Delete button styled with 32x32px size, border, hover state with red color
|
||||||
|
- Hover state changes border and background to danger color (rgba(239, 68, 68, 0.1))
|
||||||
|
- Created delete confirmation modal (id='deleteModal') with modal-overlay pattern
|
||||||
|
- Confirmation modal shows message: "Ștergi obișnuința {name}?" with habit name
|
||||||
|
- Modal includes Cancel button (btn-secondary) and Delete button (btn-danger)
|
||||||
|
- Delete button uses destructive red styling (.btn-danger class)
|
||||||
|
- Added showDeleteModal(habitId, habitName) function to display confirmation
|
||||||
|
- Added hideDeleteModal() function to close modal
|
||||||
|
- Added confirmDelete() async function to execute DELETE API call
|
||||||
|
- Delete button disabled during deletion with loading text "Se șterge..."
|
||||||
|
- On successful delete: hides modal, shows success toast, reloads habits list
|
||||||
|
- On error: shows error toast, re-enables delete button, keeps modal open
|
||||||
|
- Habit name properly escaped for XSS protection when passed to modal
|
||||||
|
- All styling uses CSS variables for theme consistency
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
- Created dashboard/test_habits_delete_ui.py with 10 comprehensive tests
|
||||||
|
- Tests for delete button CSS styling (size, border, hover, danger color) [AC1]
|
||||||
|
- Tests for trash-2 icon inclusion in habit cards [AC1]
|
||||||
|
- Tests for confirmation modal structure with Romanian message [AC2]
|
||||||
|
- Tests for Cancel and Delete buttons with correct handlers [AC3]
|
||||||
|
- Tests for btn-danger destructive red styling [AC4]
|
||||||
|
- Tests for DELETE API call to /api/habits/{id} endpoint [AC5]
|
||||||
|
- Tests for loadHabits() call after successful deletion (list refresh) [AC5]
|
||||||
|
- Tests for error handling with toast notification [AC6]
|
||||||
|
- Tests for modal show/hide functions and active class toggle
|
||||||
|
- Summary test verifying all 7 acceptance criteria [AC7]
|
||||||
|
- All 10 tests pass ✓
|
||||||
|
- All previous tests (schema, API endpoints, HTML, modal, display, check, form, styling, navigation, mobile) still pass ✓
|
||||||
|
|
||||||
|
Files modified:
|
||||||
|
- dashboard/habits.html (added delete button, modal, CSS, and JavaScript functions)
|
||||||
|
- dashboard/test_habits_delete_ui.py (created)
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
NEXT STEPS:
|
||||||
|
- Continue with remaining 1 story (17.0)
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
[✓] Story 17.0: Integration - End-to-end habit lifecycle test
|
||||||
|
Commit: bf215f7
|
||||||
|
Date: 2026-02-10
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
- Created dashboard/test_habits_integration.py with comprehensive integration tests
|
||||||
|
- Main test: test_complete_habit_lifecycle() covers full habit flow
|
||||||
|
- Test creates daily habit 'Bazin' via POST /api/habits
|
||||||
|
- Checks habit today via POST /api/habits/{id}/check (streak = 1)
|
||||||
|
- Simulates yesterday's check by manipulating habits.json file
|
||||||
|
- Verifies streak calculation is correct (streak = 2 for consecutive days)
|
||||||
|
- Deletes habit successfully via DELETE /api/habits/{id}
|
||||||
|
- Verifies habit no longer exists after deletion
|
||||||
|
- Additional test: test_broken_streak() validates gap detection (streak = 0)
|
||||||
|
- Additional test: test_weekly_habit_streak() validates weekly habit streaks
|
||||||
|
- Tests use HTTP test server on port 8765 in background thread
|
||||||
|
- Comprehensive validation of all API endpoints working together
|
||||||
|
- Proper setup/teardown with habits.json reset before/after tests
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
- Created dashboard/test_habits_integration.py
|
||||||
|
- Main integration test passes all 5 steps (create, check, simulate, verify, delete)
|
||||||
|
- Tests for daily habit creation and checking (AC1, AC2)
|
||||||
|
- Tests for simulating yesterday's check and streak = 2 (AC3, AC4)
|
||||||
|
- Tests for habit deletion after lifecycle (AC5)
|
||||||
|
- Additional tests for broken streaks (gap > 1 day returns 0)
|
||||||
|
- Additional tests for weekly habit streak calculation (2 consecutive weeks)
|
||||||
|
- All tests pass ✓ (AC6)
|
||||||
|
- All previous tests (schema, API endpoints, HTML, modal, display, check, form, styling, navigation, mobile, delete) still pass ✓
|
||||||
|
|
||||||
|
Files modified:
|
||||||
|
- dashboard/test_habits_integration.py (created)
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
|
FEATURE COMPLETE! 🎉
|
||||||
|
|
||||||
|
All 17 stories completed successfully:
|
||||||
|
- Data schema and backend API (7 stories)
|
||||||
|
- Frontend UI and interactions (10 stories)
|
||||||
|
- Comprehensive integration tests
|
||||||
|
|
||||||
|
The Habit Tracker feature is now fully implemented and tested.
|
||||||
|
Users can create habits (daily/weekly), track completions, view streaks,
|
||||||
|
and delete habits. The feature includes responsive design, proper error handling,
|
||||||
|
and full integration with the dashboard navigation.
|
||||||
|
|
||||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|||||||
@@ -313,17 +313,33 @@ def check_page(page, saved_versions, saved_hashes):
|
|||||||
log(f"OK: {page_id}")
|
log(f"OK: {page_id}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def update_dashboard_status(has_changes, changes_count):
|
def update_dashboard_status(has_changes, changes_count, changes_list=None):
|
||||||
"""Actualizează status.json pentru dashboard"""
|
"""Actualizează status.json pentru dashboard"""
|
||||||
try:
|
try:
|
||||||
status = load_json(DASHBOARD_STATUS, {})
|
status = load_json(DASHBOARD_STATUS, {})
|
||||||
status['anaf'] = {
|
anaf_status = {
|
||||||
'ok': not has_changes,
|
'ok': not has_changes,
|
||||||
'status': 'MODIFICĂRI' if has_changes else 'OK',
|
'status': 'MODIFICĂRI' if has_changes else 'OK',
|
||||||
'message': f'{changes_count} modificări detectate' if has_changes else 'Nicio modificare detectată',
|
'message': f'{changes_count} modificări detectate' if has_changes else 'Nicio modificare detectată',
|
||||||
'lastCheck': datetime.now().strftime('%d %b %Y, %H:%M'),
|
'lastCheck': datetime.now().strftime('%d %b %Y, %H:%M'),
|
||||||
'changesCount': changes_count
|
'changesCount': changes_count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Adaugă detaliile modificărilor pentru dashboard
|
||||||
|
if has_changes and changes_list:
|
||||||
|
anaf_status['changes'] = []
|
||||||
|
for change in changes_list:
|
||||||
|
change_detail = {
|
||||||
|
'name': change.get('name', ''),
|
||||||
|
'url': change.get('url', ''),
|
||||||
|
'summary': []
|
||||||
|
}
|
||||||
|
# Ia primele 3 modificări ca rezumat
|
||||||
|
if change.get('changes'):
|
||||||
|
change_detail['summary'] = change['changes'][:3]
|
||||||
|
anaf_status['changes'].append(change_detail)
|
||||||
|
|
||||||
|
status['anaf'] = anaf_status
|
||||||
save_json(DASHBOARD_STATUS, status)
|
save_json(DASHBOARD_STATUS, status)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log(f"ERROR updating dashboard status: {e}")
|
log(f"ERROR updating dashboard status: {e}")
|
||||||
@@ -345,7 +361,7 @@ def main():
|
|||||||
save_json(HASHES_FILE, saved_hashes)
|
save_json(HASHES_FILE, saved_hashes)
|
||||||
|
|
||||||
# Update dashboard status
|
# Update dashboard status
|
||||||
update_dashboard_status(len(all_changes) > 0, len(all_changes))
|
update_dashboard_status(len(all_changes) > 0, len(all_changes), all_changes)
|
||||||
|
|
||||||
log("=== Monitor complete ===")
|
log("=== Monitor complete ===")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user