max / goingson
11 files changed,
+310 insertions,
-8 deletions
| @@ -96,6 +96,12 @@ pub trait TaskRepository: Send + Sync { | |||
| 96 | 96 | /// The caller is responsible for handling recurrence and milestone auto-completion. | |
| 97 | 97 | async fn complete(&self, id: TaskId, user_id: UserId) -> Result<Option<Task>>; | |
| 98 | 98 | ||
| 99 | + | /// Atomically completes a task and creates the next recurring instance. | |
| 100 | + | /// Returns (completed_task, next_task). If the task has no recurrence, | |
| 101 | + | /// next_task is None. The entire operation is wrapped in a transaction | |
| 102 | + | /// so a crash cannot break the recurrence chain. | |
| 103 | + | async fn complete_recurring(&self, id: TaskId, user_id: UserId, next: Option<NewTask>) -> Result<(Option<Task>, Option<Task>)>; | |
| 104 | + | ||
| 99 | 105 | /// Counts non-deleted, non-completed tasks in a milestone. | |
| 100 | 106 | async fn count_incomplete_by_milestone(&self, milestone_id: MilestoneId, user_id: UserId) -> Result<i64>; | |
| 101 | 107 | ||
| @@ -198,6 +204,9 @@ pub trait TaskRepository: Send + Sync { | |||
| 198 | 204 | /// Lists all time sessions for a task. | |
| 199 | 205 | async fn list_time_sessions(&self, task_id: TaskId, user_id: UserId) -> Result<Vec<TimeSession>>; | |
| 200 | 206 | ||
| 207 | + | /// Logs a manual time entry (retroactive, no live timer). | |
| 208 | + | async fn log_manual_time(&self, task_id: TaskId, user_id: UserId, minutes: i32, date: DateTime<Utc>) -> Result<TimeSession>; | |
| 209 | + | ||
| 201 | 210 | /// Gets aggregated time tracking summary grouped by project and date. | |
| 202 | 211 | async fn get_time_summary(&self, user_id: UserId, start: DateTime<Utc>, end: DateTime<Utc>) -> Result<Vec<TimeTrackingSummary>>; | |
| 203 | 212 | } |
| @@ -574,6 +574,79 @@ impl TaskRepository for SqliteTaskRepository { | |||
| 574 | 574 | } | |
| 575 | 575 | ||
| 576 | 576 | #[tracing::instrument(skip_all)] | |
| 577 | + | async fn complete_recurring(&self, id: TaskId, user_id: UserId, next: Option<NewTask>) -> Result<(Option<Task>, Option<Task>)> { | |
| 578 | + | let task = match get_task_by_id(&self.pool, id, user_id).await? { | |
| 579 | + | Some(t) => t, | |
| 580 | + | None => return Ok((None, None)), | |
| 581 | + | }; | |
| 582 | + | ||
| 583 | + | if task.status == TaskStatus::Completed { | |
| 584 | + | return Ok((None, None)); | |
| 585 | + | } | |
| 586 | + | ||
| 587 | + | let mut tx = self.pool.begin().await.map_err(CoreError::database)?; | |
| 588 | + | ||
| 589 | + | // Mark complete | |
| 590 | + | let now = format_datetime_now(); | |
| 591 | + | sqlx::query("UPDATE tasks SET status = 'Completed', completed_at = ? WHERE id = ? AND user_id = ?") | |
| 592 | + | .bind(&now) | |
| 593 | + | .bind(id.to_string()) | |
| 594 | + | .bind(user_id.to_string()) | |
| 595 | + | .execute(&mut *tx) | |
| 596 | + | .await | |
| 597 | + | .map_err(CoreError::database)?; | |
| 598 | + | ||
| 599 | + | // Create next recurring instance if provided | |
| 600 | + | let next_id = if let Some(new_task) = &next { | |
| 601 | + | let nid = TaskId::new(); | |
| 602 | + | let due_str = format_datetime_opt(new_task.due); | |
| 603 | + | let scheduled_start_str = format_datetime_opt(new_task.scheduled_start); | |
| 604 | + | let tags_json = serde_json::to_string(&new_task.tags).unwrap_or_else(|_| "[]".to_string()); | |
| 605 | + | ||
| 606 | + | sqlx::query( | |
| 607 | + | r#" | |
| 608 | + | INSERT INTO tasks (id, user_id, project_id, contact_id, milestone_id, description, priority, due, tags, recurrence, urgency, source_email_id, scheduled_start, scheduled_duration, estimated_minutes, created_at) | |
| 609 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| 610 | + | "#, | |
| 611 | + | ) | |
| 612 | + | .bind(nid.to_string()) | |
| 613 | + | .bind(user_id.to_string()) | |
| 614 | + | .bind(new_task.project_id.map(|p| p.to_string())) | |
| 615 | + | .bind(new_task.contact_id.map(|c| c.to_string())) | |
| 616 | + | .bind(new_task.milestone_id.map(|m| m.to_string())) | |
| 617 | + | .bind(&new_task.description) | |
| 618 | + | .bind(new_task.priority.db_value()) | |
| 619 | + | .bind(&due_str) | |
| 620 | + | .bind(&tags_json) | |
| 621 | + | .bind(new_task.recurrence.db_value()) | |
| 622 | + | .bind(new_task.urgency) | |
| 623 | + | .bind(new_task.source_email_id.map(|e| e.to_string())) | |
| 624 | + | .bind(&scheduled_start_str) | |
| 625 | + | .bind(new_task.scheduled_duration) | |
| 626 | + | .bind(new_task.estimated_minutes) | |
| 627 | + | .bind(&now) | |
| 628 | + | .execute(&mut *tx) | |
| 629 | + | .await | |
| 630 | + | .map_err(CoreError::database)?; | |
| 631 | + | ||
| 632 | + | Some(nid) | |
| 633 | + | } else { | |
| 634 | + | None | |
| 635 | + | }; | |
| 636 | + | ||
| 637 | + | tx.commit().await.map_err(CoreError::database)?; | |
| 638 | + | ||
| 639 | + | // Fetch the completed task and new task (outside transaction, committed) | |
| 640 | + | let completed = get_task_by_id(&self.pool, id, user_id).await?; | |
| 641 | + | let next_task = match next_id { | |
| 642 | + | Some(nid) => get_task_by_id(&self.pool, nid, user_id).await?, | |
| 643 | + | None => None, | |
| 644 | + | }; | |
| 645 | + | ||
| 646 | + | Ok((completed, next_task)) | |
| 647 | + | } | |
| 648 | + | ||
| 649 | + | #[tracing::instrument(skip_all)] | |
| 577 | 650 | async fn count_incomplete_by_milestone(&self, milestone_id: MilestoneId, user_id: UserId) -> Result<i64> { | |
| 578 | 651 | let (count,): (i64,) = sqlx::query_as( | |
| 579 | 652 | "SELECT COUNT(*) FROM tasks WHERE milestone_id = ? AND user_id = ? AND status != 'Deleted' AND status != 'Completed'" | |
| @@ -770,6 +843,11 @@ impl TaskRepository for SqliteTaskRepository { | |||
| 770 | 843 | } | |
| 771 | 844 | ||
| 772 | 845 | #[tracing::instrument(skip_all)] | |
| 846 | + | async fn log_manual_time(&self, task_id: TaskId, user_id: UserId, minutes: i32, date: DateTime<Utc>) -> Result<TimeSession> { | |
| 847 | + | time_session_repo::log_manual_time(&self.pool, task_id, user_id, minutes, date).await | |
| 848 | + | } | |
| 849 | + | ||
| 850 | + | #[tracing::instrument(skip_all)] | |
| 773 | 851 | async fn get_time_summary(&self, user_id: UserId, start: DateTime<Utc>, end: DateTime<Utc>) -> Result<Vec<TimeTrackingSummary>> { | |
| 774 | 852 | time_session_repo::get_time_summary(&self.pool, user_id, start, end).await | |
| 775 | 853 | } |
| @@ -315,3 +315,61 @@ pub(crate) async fn get_time_summary( | |||
| 315 | 315 | }) | |
| 316 | 316 | }).collect() | |
| 317 | 317 | } | |
| 318 | + | ||
| 319 | + | /// Log a manual time entry (completed session, no live timer). | |
| 320 | + | pub(crate) async fn log_manual_time( | |
| 321 | + | pool: &SqlitePool, | |
| 322 | + | task_id: TaskId, | |
| 323 | + | user_id: UserId, | |
| 324 | + | minutes: i32, | |
| 325 | + | date: DateTime<Utc>, | |
| 326 | + | ) -> Result<TimeSession> { | |
| 327 | + | use chrono::Duration; | |
| 328 | + | ||
| 329 | + | let id = TimeSessionId::new(); | |
| 330 | + | let started_at = date; | |
| 331 | + | let ended_at = date + Duration::minutes(minutes as i64); | |
| 332 | + | let now = Utc::now(); | |
| 333 | + | ||
| 334 | + | let started_str = format_datetime(&started_at); | |
| 335 | + | let ended_str = format_datetime(&ended_at); | |
| 336 | + | let created_str = format_datetime(&now); | |
| 337 | + | ||
| 338 | + | sqlx::query( | |
| 339 | + | "INSERT INTO time_sessions (id, task_id, user_id, started_at, ended_at, duration_minutes, created_at) | |
| 340 | + | VALUES (?, ?, ?, ?, ?, ?, ?)" | |
| 341 | + | ) | |
| 342 | + | .bind(id.to_string()) | |
| 343 | + | .bind(task_id.to_string()) | |
| 344 | + | .bind(user_id.to_string()) | |
| 345 | + | .bind(&started_str) | |
| 346 | + | .bind(&ended_str) | |
| 347 | + | .bind(minutes) | |
| 348 | + | .bind(&created_str) | |
| 349 | + | .execute(pool) | |
| 350 | + | .await | |
| 351 | + | .map_err(CoreError::database)?; | |
| 352 | + | ||
| 353 | + | // Update task's cached actual_minutes | |
| 354 | + | sqlx::query( | |
| 355 | + | "UPDATE tasks SET actual_minutes = COALESCE(actual_minutes, 0) + ?, updated_at = ? | |
| 356 | + | WHERE id = ? AND user_id = ?" | |
| 357 | + | ) | |
| 358 | + | .bind(minutes) | |
| 359 | + | .bind(&created_str) | |
| 360 | + | .bind(task_id.to_string()) | |
| 361 | + | .bind(user_id.to_string()) | |
| 362 | + | .execute(pool) | |
| 363 | + | .await | |
| 364 | + | .map_err(CoreError::database)?; | |
| 365 | + | ||
| 366 | + | Ok(TimeSession { | |
| 367 | + | id, | |
| 368 | + | task_id, | |
| 369 | + | user_id, | |
| 370 | + | started_at, | |
| 371 | + | ended_at: Some(ended_at), | |
| 372 | + | duration_minutes: Some(minutes), | |
| 373 | + | created_at: now, | |
| 374 | + | }) | |
| 375 | + | } |
| @@ -150,7 +150,7 @@ Audit run: `/use-fuzz GoingsOn`. Overall grade: B+. Items already tracked elsewh | |||
| 150 | 150 | - [x] Add introductory content for first monthly review (title hidden by CSS, no explanation) | |
| 151 | 151 | - [x] Add brief descriptions to differentiate Start Task / Schedule Time / Track Time / Focus Mode | |
| 152 | 152 | - [x] Add 2-3 sentence explanation in sync setup panel (what SyncKit is, what syncs, E2E encryption) | |
| 153 | - | - [ ] IMAP auto-detect server settings from email domain (Gmail, Fastmail, Outlook, Yahoo, iCloud) | |
| 153 | + | - [x] IMAP auto-detect server settings from email domain (Gmail, Fastmail, Outlook, Yahoo, iCloud) | |
| 154 | 154 | - [x] Rename "Pri" column header to "Priority" (only abbreviated header in task table) | |
| 155 | 155 | - [ ] Add frontend error message mapper — humanize backend error codes for toasts | |
| 156 | 156 | ||
| @@ -164,12 +164,12 @@ Audit run: `/use-fuzz GoingsOn`. Overall grade: B+. Items already tracked elsewh | |||
| 164 | 164 | - [ ] Recurring events — table stakes for any calendar feature | |
| 165 | 165 | - [ ] Calendar month/week grid view — even a basic one (events currently list-only) | |
| 166 | 166 | - [ ] Time tracking reports — per-project breakdown, estimated-vs-actual, weekly/monthly summaries | |
| 167 | - | - [ ] Manual time entry — log time retroactively, not just live timer | |
| 167 | + | - [x] Manual time entry — log time retroactively, not just live timer | |
| 168 | 168 | - [ ] Contacts export to vCard — import exists but no export (asymmetric) | |
| 169 | 169 | - [ ] Bulk operations for contacts (tag, delete) and events (delete) | |
| 170 | 170 | - [x] Bulk "Set Project" and "Set Priority" in task bulk actions bar | |
| 171 | 171 | - [ ] Daily review notes: persist to SQLite + sync (currently localStorage only, lost on reinstall) | |
| 172 | - | - [ ] Monthly review: add explicit "Complete Review" action (weekly has it, monthly does not) | |
| 172 | + | - [x] Monthly review: add explicit "Complete Review" action (weekly has it, monthly does not) | |
| 173 | 173 | - [ ] Workload guardrails in day planner — warn when scheduled hours exceed target | |
| 174 | 174 | ||
| 175 | 175 | --- |
| @@ -285,6 +285,7 @@ const api = { | |||
| 285 | 285 | discardTimer: (taskId) => invoke('discard_timer', { taskId }), | |
| 286 | 286 | getActive: () => invoke('get_active_timer'), | |
| 287 | 287 | listSessions: (taskId) => invoke('list_time_sessions', { taskId }), | |
| 288 | + | logManual: (taskId, minutes, date) => invoke('log_manual_time', { input: { taskId, minutes, date } }), | |
| 288 | 289 | getSummary: (start, end) => invoke('get_time_summary', { input: { start, end } }), | |
| 289 | 290 | }, | |
| 290 | 291 |
| @@ -125,6 +125,63 @@ | |||
| 125 | 125 | `; | |
| 126 | 126 | } | |
| 127 | 127 | ||
| 128 | + | // ============ IMAP/SMTP Auto-Detect ============ | |
| 129 | + | ||
| 130 | + | /** | |
| 131 | + | * Well-known email provider server settings. | |
| 132 | + | * Key is the email domain; value has imap, smtp, archive defaults. | |
| 133 | + | */ | |
| 134 | + | const PROVIDER_SETTINGS = { | |
| 135 | + | 'gmail.com': { imap: 'imap.gmail.com', imapPort: 993, smtp: 'smtp.gmail.com', smtpPort: 587, archive: '[Gmail]/All Mail' }, | |
| 136 | + | 'googlemail.com': { imap: 'imap.gmail.com', imapPort: 993, smtp: 'smtp.gmail.com', smtpPort: 587, archive: '[Gmail]/All Mail' }, | |
| 137 | + | 'fastmail.com': { imap: 'imap.fastmail.com', imapPort: 993, smtp: 'smtp.fastmail.com', smtpPort: 587, archive: 'Archive' }, | |
| 138 | + | 'outlook.com': { imap: 'outlook.office365.com', imapPort: 993, smtp: 'smtp.office365.com', smtpPort: 587, archive: 'Archive' }, | |
| 139 | + | 'hotmail.com': { imap: 'outlook.office365.com', imapPort: 993, smtp: 'smtp.office365.com', smtpPort: 587, archive: 'Archive' }, | |
| 140 | + | 'live.com': { imap: 'outlook.office365.com', imapPort: 993, smtp: 'smtp.office365.com', smtpPort: 587, archive: 'Archive' }, | |
| 141 | + | 'yahoo.com': { imap: 'imap.mail.yahoo.com', imapPort: 993, smtp: 'smtp.mail.yahoo.com', smtpPort: 587, archive: 'Archive' }, | |
| 142 | + | 'icloud.com': { imap: 'imap.mail.me.com', imapPort: 993, smtp: 'smtp.mail.me.com', smtpPort: 587, archive: 'Archive' }, | |
| 143 | + | 'me.com': { imap: 'imap.mail.me.com', imapPort: 993, smtp: 'smtp.mail.me.com', smtpPort: 587, archive: 'Archive' }, | |
| 144 | + | 'mac.com': { imap: 'imap.mail.me.com', imapPort: 993, smtp: 'smtp.mail.me.com', smtpPort: 587, archive: 'Archive' }, | |
| 145 | + | 'protonmail.com': { imap: 'imap.protonmail.ch', imapPort: 993, smtp: 'smtp.protonmail.ch', smtpPort: 587, archive: 'Archive' }, | |
| 146 | + | 'proton.me': { imap: 'imap.protonmail.ch', imapPort: 993, smtp: 'smtp.protonmail.ch', smtpPort: 587, archive: 'Archive' }, | |
| 147 | + | 'zoho.com': { imap: 'imap.zoho.com', imapPort: 993, smtp: 'smtp.zoho.com', smtpPort: 587, archive: 'Archive' }, | |
| 148 | + | 'aol.com': { imap: 'imap.aol.com', imapPort: 993, smtp: 'smtp.aol.com', smtpPort: 587, archive: 'Archive' }, | |
| 149 | + | }; | |
| 150 | + | ||
| 151 | + | /** | |
| 152 | + | * Attach auto-detect behavior to an email input field. | |
| 153 | + | * When the user types a recognized domain, auto-fills server fields. | |
| 154 | + | * @param {string} idPrefix - The form field ID prefix (e.g. 'acct') | |
| 155 | + | */ | |
| 156 | + | function attachAutoDetect(idPrefix) { | |
| 157 | + | const emailEl = document.getElementById(`${idPrefix}-email`); | |
| 158 | + | if (!emailEl) return; | |
| 159 | + | ||
| 160 | + | emailEl.addEventListener('change', () => { | |
| 161 | + | const email = emailEl.value.trim(); | |
| 162 | + | const domain = email.split('@')[1]?.toLowerCase(); | |
| 163 | + | if (!domain) return; | |
| 164 | + | ||
| 165 | + | const settings = PROVIDER_SETTINGS[domain]; | |
| 166 | + | if (!settings) return; | |
| 167 | + | ||
| 168 | + | const imapEl = document.getElementById(`${idPrefix}-imap-server`); | |
| 169 | + | const imapPortEl = document.getElementById(`${idPrefix}-imap-port`); | |
| 170 | + | const smtpEl = document.getElementById(`${idPrefix}-smtp-server`); | |
| 171 | + | const smtpPortEl = document.getElementById(`${idPrefix}-smtp-port`); | |
| 172 | + | const usernameEl = document.getElementById(`${idPrefix}-username`); | |
| 173 | + | const archiveEl = document.getElementById(`${idPrefix}-archive-folder`); | |
| 174 | + | ||
| 175 | + | // Only auto-fill if fields are empty or at defaults | |
| 176 | + | if (imapEl && !imapEl.value) imapEl.value = settings.imap; | |
| 177 | + | if (imapPortEl && (imapPortEl.value === '993' || !imapPortEl.value)) imapPortEl.value = settings.imapPort; | |
| 178 | + | if (smtpEl && !smtpEl.value) smtpEl.value = settings.smtp; | |
| 179 | + | if (smtpPortEl && (smtpPortEl.value === '587' || !smtpPortEl.value)) smtpPortEl.value = settings.smtpPort; | |
| 180 | + | if (usernameEl && !usernameEl.value) usernameEl.value = email; | |
| 181 | + | if (archiveEl && (archiveEl.value === 'Archive' || !archiveEl.value)) archiveEl.value = settings.archive; | |
| 182 | + | }); | |
| 183 | + | } | |
| 184 | + | ||
| 128 | 185 | // ============ Email Accounts ============ | |
| 129 | 186 | ||
| 130 | 187 | async function loadAccounts() { | |
| @@ -246,6 +303,8 @@ | |||
| 246 | 303 | ||
| 247 | 304 | const content = `${oauthButtons}${formHtml}`; | |
| 248 | 305 | GoingsOn.ui.openModal('Add Email Account', content); | |
| 306 | + | // Attach auto-detect for known providers after modal DOM is ready | |
| 307 | + | setTimeout(() => attachAutoDetect('acct'), 0); | |
| 249 | 308 | } | |
| 250 | 309 | ||
| 251 | 310 | async function createAccount(e) { | |
| @@ -461,7 +520,6 @@ ${esc(result.debugInfo.split(' | ').join('\n'))} | |||
| 461 | 520 | pendingOAuthState = { | |
| 462 | 521 | state: result.state, | |
| 463 | 522 | provider: result.provider, | |
| 464 | - | codeVerifier: result.codeVerifier, | |
| 465 | 523 | port: result.port, | |
| 466 | 524 | }; | |
| 467 | 525 | ||
| @@ -574,9 +632,6 @@ ${esc(result.debugInfo.split(' | ').join('\n'))} | |||
| 574 | 632 | const result = await GoingsOn.api.oauth.complete({ | |
| 575 | 633 | code, | |
| 576 | 634 | state, | |
| 577 | - | provider: pendingOAuthState.provider, | |
| 578 | - | codeVerifier: pendingOAuthState.codeVerifier, | |
| 579 | - | port: pendingOAuthState.port, | |
| 580 | 635 | }); | |
| 581 | 636 | ||
| 582 | 637 | pendingOAuthState = null; | |
| @@ -603,7 +658,6 @@ ${esc(result.debugInfo.split(' | ').join('\n'))} | |||
| 603 | 658 | pendingOAuthState = { | |
| 604 | 659 | state: result.state, | |
| 605 | 660 | provider: result.provider, | |
| 606 | - | codeVerifier: result.codeVerifier, | |
| 607 | 661 | port: result.port, | |
| 608 | 662 | accountId: accountId, // For updating existing account | |
| 609 | 663 | }; |
| @@ -191,6 +191,7 @@ function renderEmptyGoalSlot(month, position) { | |||
| 191 | 191 | function renderReflection(r) { | |
| 192 | 192 | const highlight = r.reflection?.highlightText || ''; | |
| 193 | 193 | const change = r.reflection?.changeText || ''; | |
| 194 | + | const isCompleted = !!(r.reflection && (highlight || change)); | |
| 194 | 195 | ||
| 195 | 196 | let html = '<div class="review-card month-reflection-card">'; | |
| 196 | 197 | html += '<h3 class="review-card-title">Monthly Reflection</h3>'; | |
| @@ -200,6 +201,11 @@ function renderReflection(r) { | |||
| 200 | 201 | html += `<label class="month-reflection-label" for="monthly-change">What would you change?</label>`; | |
| 201 | 202 | html += `<textarea id="monthly-change" class="month-reflection-textarea" rows="3" placeholder="Something to improve next month...">${esc(change)}</textarea>`; | |
| 202 | 203 | html += '</div>'; | |
| 204 | + | html += `<div style="margin-top: 1rem; text-align: right;"> | |
| 205 | + | <button class="btn ${isCompleted ? 'btn-secondary' : 'btn-primary'}" onclick="GoingsOn.monthlyReview.complete()"> | |
| 206 | + | ${isCompleted ? 'Review Completed' : 'Complete Review'} | |
| 207 | + | </button> | |
| 208 | + | </div>`; | |
| 203 | 209 | html += '</div>'; | |
| 204 | 210 | return html; | |
| 205 | 211 | } |
| @@ -70,6 +70,9 @@ function render(r) { | |||
| 70 | 70 | ||
| 71 | 71 | let html = ''; | |
| 72 | 72 | ||
| 73 | + | // Intro text | |
| 74 | + | html += '<p class="review-intro" style="color: var(--text-secondary); font-size: 0.875rem; margin: 0 0 1rem;">See your month at a glance: activity patterns, goal progress, and reflections to carry forward.</p>'; | |
| 75 | + | ||
| 73 | 76 | // Visualizer: heat map calendar | |
| 74 | 77 | html += R.renderHeatMap(r); | |
| 75 | 78 | ||
| @@ -248,6 +251,31 @@ async function showDaySummary(dateStr) { | |||
| 248 | 251 | } | |
| 249 | 252 | } | |
| 250 | 253 | ||
| 254 | + | // ============ Complete Review ============ | |
| 255 | + | ||
| 256 | + | /** | |
| 257 | + | * Explicitly save the monthly reflection and mark the review as complete. | |
| 258 | + | */ | |
| 259 | + | async function complete() { | |
| 260 | + | const highlightEl = document.getElementById('monthly-highlight'); | |
| 261 | + | const changeEl = document.getElementById('monthly-change'); | |
| 262 | + | const highlight = highlightEl?.value?.trim() || ''; | |
| 263 | + | const change = changeEl?.value?.trim() || ''; | |
| 264 | + | ||
| 265 | + | if (!highlight && !change) { | |
| 266 | + | GoingsOn.ui.showToast('Write at least one reflection before completing', 'error'); | |
| 267 | + | return; | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | try { | |
| 271 | + | await GoingsOn.api.monthlyReview.saveReflection(currentMonth, highlight, change); | |
| 272 | + | GoingsOn.ui.showToast('Monthly review completed!', 'success'); | |
| 273 | + | await load(); | |
| 274 | + | } catch (err) { | |
| 275 | + | GoingsOn.ui.showToast('Failed to complete review', 'error'); | |
| 276 | + | } | |
| 277 | + | } | |
| 278 | + | ||
| 251 | 279 | // ============ Exports ============ | |
| 252 | 280 | ||
| 253 | 281 | GoingsOn.monthlyReview = { | |
| @@ -260,6 +288,7 @@ GoingsOn.monthlyReview = { | |||
| 260 | 288 | deleteGoal, | |
| 261 | 289 | navigateToDay, | |
| 262 | 290 | showDaySummary, | |
| 291 | + | complete, | |
| 263 | 292 | }; | |
| 264 | 293 | ||
| 265 | 294 | })(); |
| @@ -273,6 +273,7 @@ async function loadTimerView() { | |||
| 273 | 273 | <div class="timer-task-actions"> | |
| 274 | 274 | <button class="btn btn-sm btn-primary" onclick="GoingsOn.timeTracking.trackFromTimerView('${escAttr(task.id)}')"${disabled} title="Start open-ended timer">Track</button> | |
| 275 | 275 | <button class="btn btn-sm btn-secondary" onclick="GoingsOn.timeTracking.focusFromTimerView('${escAttr(task.id)}')"${disabled} title="Start ${focusWorkMinutes}/${focusBreakMinutes} focus session">Focus</button> | |
| 276 | + | <button class="btn btn-sm btn-ghost" onclick="GoingsOn.timeTracking.openLogTimeModal('${escAttr(task.id)}')" title="Log time retroactively">Log</button> | |
| 276 | 277 | </div> | |
| 277 | 278 | </div>`; | |
| 278 | 279 | } | |
| @@ -356,6 +357,51 @@ function init() { | |||
| 356 | 357 | checkActive(); | |
| 357 | 358 | } | |
| 358 | 359 | ||
| 360 | + | // ============ Manual Time Entry ============ | |
| 361 | + | ||
| 362 | + | function openLogTimeModal(taskId) { | |
| 363 | + | const content = ` | |
| 364 | + | <form id="log-time-form" onsubmit="GoingsOn.timeTracking.submitLogTime(event, '${GoingsOn.utils.escapeAttr(taskId)}')"> | |
| 365 | + | <div class="form-group"> | |
| 366 | + | <label class="form-label" for="log-time-minutes">Duration (minutes)</label> | |
| 367 | + | <input type="number" class="form-input" id="log-time-minutes" name="minutes" required min="1" max="1440" placeholder="30" autofocus> | |
| 368 | + | </div> | |
| 369 | + | <div class="form-group"> | |
| 370 | + | <label class="form-label" for="log-time-date">Date</label> | |
| 371 | + | <input type="date" class="form-input" id="log-time-date" name="date" value="${new Date().toISOString().split('T')[0]}"> | |
| 372 | + | </div> | |
| 373 | + | <div class="form-actions"> | |
| 374 | + | <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal()">Cancel</button> | |
| 375 | + | <button type="submit" class="btn btn-primary">Log Time</button> | |
| 376 | + | </div> | |
| 377 | + | </form> | |
| 378 | + | `; | |
| 379 | + | GoingsOn.ui.openModal('Log Time', content); | |
| 380 | + | } | |
| 381 | + | ||
| 382 | + | async function submitLogTime(e, taskId) { | |
| 383 | + | e.preventDefault(); | |
| 384 | + | const form = e.target; | |
| 385 | + | const minutes = parseInt(form.minutes.value, 10); | |
| 386 | + | const dateStr = form.date.value; | |
| 387 | + | ||
| 388 | + | if (!minutes || minutes < 1) return; | |
| 389 | + | ||
| 390 | + | // Convert date to UTC datetime (noon on selected day) | |
| 391 | + | const date = new Date(dateStr + 'T12:00:00Z').toISOString(); | |
| 392 | + | ||
| 393 | + | try { | |
| 394 | + | await GoingsOn.api.timeTracking.logManual(taskId, minutes, date); | |
| 395 | + | const display = minutes >= 60 ? `${Math.floor(minutes / 60)}h ${minutes % 60}m` : `${minutes}m`; | |
| 396 | + | GoingsOn.ui.showToast(`Logged ${display}`, 'success'); | |
| 397 | + | GoingsOn.ui.closeModal(); | |
| 398 | + | await loadTimerView(); | |
| 399 | + | if (GoingsOn.tasks?.load) GoingsOn.tasks.load(); | |
| 400 | + | } catch (err) { | |
| 401 | + | GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to log time'), 'error'); | |
| 402 | + | } | |
| 403 | + | } | |
| 404 | + | ||
| 359 | 405 | // ============ Namespace ============ | |
| 360 | 406 | ||
| 361 | 407 | GoingsOn.timeTracking = { | |
| @@ -370,6 +416,8 @@ GoingsOn.timeTracking = { | |||
| 370 | 416 | stopAndRefreshTimerView, | |
| 371 | 417 | discardAndRefreshTimerView, | |
| 372 | 418 | updateFocusSplit, | |
| 419 | + | openLogTimeModal, | |
| 420 | + | submitLogTime, | |
| 373 | 421 | }; | |
| 374 | 422 | ||
| 375 | 423 | })(); |
| @@ -29,6 +29,14 @@ pub struct TimeSummaryInput { | |||
| 29 | 29 | pub end: DateTime<Utc>, | |
| 30 | 30 | } | |
| 31 | 31 | ||
| 32 | + | #[derive(Debug, Deserialize)] | |
| 33 | + | #[serde(rename_all = "camelCase")] | |
| 34 | + | pub struct LogManualTimeInput { | |
| 35 | + | pub task_id: TaskId, | |
| 36 | + | pub minutes: i32, | |
| 37 | + | pub date: DateTime<Utc>, | |
| 38 | + | } | |
| 39 | + | ||
| 32 | 40 | // ============ Commands ============ | |
| 33 | 41 | ||
| 34 | 42 | /// Starts a timer on a task. | |
| @@ -97,6 +105,16 @@ pub async fn list_time_sessions( | |||
| 97 | 105 | Ok(state.tasks.list_time_sessions(task_id, DESKTOP_USER_ID).await?) | |
| 98 | 106 | } | |
| 99 | 107 | ||
| 108 | + | /// Logs a manual time entry (retroactive, no live timer). | |
| 109 | + | #[tauri::command] | |
| 110 | + | #[instrument(skip_all)] | |
| 111 | + | pub async fn log_manual_time( | |
| 112 | + | state: State<'_, Arc<AppState>>, | |
| 113 | + | input: LogManualTimeInput, | |
| 114 | + | ) -> Result<TimeSession, ApiError> { | |
| 115 | + | Ok(state.tasks.log_manual_time(input.task_id, DESKTOP_USER_ID, input.minutes, input.date).await?) | |
| 116 | + | } | |
| 117 | + | ||
| 100 | 118 | /// Gets time tracking summary grouped by project and date. | |
| 101 | 119 | #[tauri::command] | |
| 102 | 120 | #[instrument(skip_all)] |
| @@ -252,6 +252,7 @@ pub fn build_mobile_app() -> tauri::Builder<tauri::Wry> { | |||
| 252 | 252 | commands::discard_timer, | |
| 253 | 253 | commands::get_active_timer, | |
| 254 | 254 | commands::list_time_sessions, | |
| 255 | + | commands::log_manual_time, | |
| 255 | 256 | commands::get_time_summary, | |
| 256 | 257 | ]) | |
| 257 | 258 | } |