max / goingson
- Co-Authored-By
- Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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 | })(); |