feat: Focus mode, estimates/burndown+budget alerts, recurring blocks, saved filters, and rate overrides

Add Pomodoro focus mode with session summaries
Model: FocusSession; API: /api/focus-sessions/; UI: Focus modal on timer page
Add estimates vs actuals with burndown and budget alerts
Project fields: estimated_hours, budget_amount, budget_threshold_percent
API: /api/projects/<id>/burndown; Charts in project view and project report
Implement recurring time blocks/templates
Model: RecurringBlock; API CRUD: /api/recurring-blocks; CLI: flask generate_recurring
Add tagging and saved filters across views
Model: SavedFilter; /api/entries supports tag and saved_filter_id
Support billable rate overrides per project/member
Model: RateOverride; invoicing uses effective rate resolution
Also:
Migration: 016_add_focus_recurring_rates_filters_and_project_budget.py
Integrations and UI updates in projects view, timer page, and reports
Docs updated (startup, invoice, task mgmt) and README feature list
Added basic tests for new features
This commit is contained in:
Dries Peeters
2025-10-06 13:34:56 +02:00
parent 99e6584c04
commit b6c0a79ffc
18 changed files with 974 additions and 10 deletions

View File

@@ -18,6 +18,9 @@
<button type="button" id="openStartTimerBtn" class="btn-header btn-primary" data-bs-toggle="modal" data-bs-target="#startTimerModal">
<i class="fas fa-play"></i>{{ _('Start Timer') }}
</button>
<button type="button" id="openFocusBtn" class="btn-header btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#focusModal">
<i class="fas fa-hourglass-start"></i>{{ _('Focus Mode') }}
</button>
</div>
</div>
</div>
@@ -153,6 +156,46 @@
</div>
</div>
</div>
<!-- Focus Mode Modal -->
<div class="modal fade" id="focusModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fas fa-hourglass-half me-2 text-primary"></i>{{ _('Focus Mode') }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row g-3">
<div class="col-6">
<label class="form-label">{{ _('Pomodoro (min)') }}</label>
<input type="number" class="form-control" id="pomodoroLen" value="25" min="1">
</div>
<div class="col-6">
<label class="form-label">{{ _('Short Break (min)') }}</label>
<input type="number" class="form-control" id="shortBreakLen" value="5" min="1">
</div>
<div class="col-6">
<label class="form-label">{{ _('Long Break (min)') }}</label>
<input type="number" class="form-control" id="longBreakLen" value="15" min="1">
</div>
<div class="col-6">
<label class="form-label">{{ _('Long Break Every') }}</label>
<input type="number" class="form-control" id="longBreakEvery" value="4" min="1">
</div>
</div>
<div class="form-check mt-3">
<input class="form-check-input" type="checkbox" id="linkActiveTimer" checked>
<label class="form-check-label" for="linkActiveTimer">{{ _('Link to active timer if running') }}</label>
</div>
<div class="mt-3 small text-muted" id="focusSummary"></div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" data-bs-dismiss="modal">{{ _('Close') }}</button>
<button class="btn btn-primary" id="startFocusSessionBtn"><i class="fas fa-play me-2"></i>{{ _('Start Focus') }}</button>
</div>
</div>
</div>
</div>
<!-- Edit Timer Modal -->
<div class="modal fade" id="editTimerModal" tabindex="-1">
@@ -751,5 +794,38 @@ document.addEventListener('DOMContentLoaded', function() {
});
});
});
// Focus: session lifecycle
document.getElementById('startFocusSessionBtn').addEventListener('click', async function(){
const payload = {
pomodoro_length: Number(document.getElementById('pomodoroLen').value || 25),
short_break_length: Number(document.getElementById('shortBreakLen').value || 5),
long_break_length: Number(document.getElementById('longBreakLen').value || 15),
long_break_interval: Number(document.getElementById('longBreakEvery').value || 4),
link_active_timer: document.getElementById('linkActiveTimer').checked
};
const res = await fetch('/api/focus-sessions/start', { method:'POST', headers:{ 'Content-Type':'application/json' }, credentials:'same-origin', body: JSON.stringify(payload) });
const json = await res.json();
if (!json.success) { showToast(json.error || '{{ _('Failed to start focus session') }}', 'danger'); return; }
const session = json.session; window.__focusSessionId = session.id;
showToast('{{ _('Focus session started') }}', 'success');
bootstrap.Modal.getInstance(document.getElementById('focusModal')).hide();
// Simple countdown display under timer
const summary = document.getElementById('focusSummary');
if (summary) summary.textContent = '';
});
// When modal hidden, if session running we do nothing; finishing handled manually
});
// Optional: expose a finish function to be called by UI elsewhere
async function finishFocusSession(cyclesCompleted = 0, interruptions = 0, notes = ''){
if (!window.__focusSessionId) return;
try {
const res = await fetch('/api/focus-sessions/finish', { method:'POST', headers:{ 'Content-Type':'application/json' }, credentials:'same-origin', body: JSON.stringify({ session_id: window.__focusSessionId, cycles_completed: cyclesCompleted, interruptions: interruptions, notes }) });
const json = await res.json();
if (!json.success) throw new Error(json.error || 'fail');
showToast('{{ _('Focus session saved') }}', 'success');
} catch(e) { showToast('{{ _('Failed to save focus session') }}', 'danger'); }
finally { window.__focusSessionId = null; }
}
</script>
{% endblock %}