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
@@ -150,7 +150,7 @@
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 @@
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 ---
@@ -252,6 +252,7 @@
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 }
@@ -96,6 +96,12 @@
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 @@
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 }
@@ -285,6 +285,7 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
260 288 deleteGoal,
261 289 navigateToDay,
262 290 showDaySummary,
291 + complete,
263 292 };
264 293
265 294 })();