Skip to main content

max / goingson

UX: monthly review complete, IMAP auto-detect, manual time entry - Monthly review: add "Complete Review" button (saves reflection + toast) - IMAP auto-detect: fill server settings when email domain is recognized (Gmail, Fastmail, Outlook, Yahoo, iCloud, Proton, Zoho, AOL) - Manual time entry: new "Log" button in Timer view, opens form for retroactive time logging with duration + date picker (new log_manual_time command + repository method + sqlite impl) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-03 13:22 UTC
Commit: 9d731a7d9e2091fb50ef183922d06c5bbf5f8b40
Parent: 2d4b9e1
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 }