Skip to main content

max / goingson

v0.3.1: Time tracking, file attachments, external sync, email attachments Major features: - Time tracking with focus timer, time sessions model, and time summary UI - File attachments system: model, repository, blob sync, Tauri commands, frontend UI - External sync: iCal and vCard import support with dedicated command layer - Email attachment extraction pipeline via IMAP Supporting changes: - Migrations 035-038 (time_tracking, attachments, external_sync, sync_trigger_fixes) - Sync service: blob sync, improved pull/push/apply, scheduler enhancements - OAuth credential improvements, expanded search and task repos - Frontend: new JS modules (focus-timer, time-tracking, time-summary, attachments, import-external) - Weekly review enhancements, day planning updates, project render improvements - Android target scaffolding, Apple project config updates - Cleaned up todo docs and removed completed archive Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-13 22:18 UTC
Commit: 7c3777de50846fe489174f0ada2cb83f437e4ebe
Parent: 2aa7cd2
190 files changed, +11771 insertions, -1508 deletions
M Cargo.lock +23
@@ -1905,13 +1905,16 @@ dependencies = [
1905 1905 "goingson-core",
1906 1906 "goingson-db-sqlite",
1907 1907 "goingson-plugin-runtime",
1908 + "ical",
1908 1909 "icalendar",
1909 1910 "keyring",
1910 1911 "lettre",
1912 + "libsqlite3-sys",
1911 1913 "mailparse",
1912 1914 "notify",
1913 1915 "notify-debouncer-mini",
1914 1916 "open",
1917 + "openssl",
1915 1918 "parking_lot",
1916 1919 "rand 0.8.5",
1917 1920 "reqwest 0.12.28",
@@ -2314,6 +2317,15 @@ dependencies = [
2314 2317 ]
2315 2318
2316 2319 [[package]]
2320 + name = "ical"
2321 + version = "0.11.0"
2322 + source = "registry+https://github.com/rust-lang/crates.io-index"
2323 + checksum = "9b7cab7543a8b7729a19e2c04309f902861293dcdae6558dfbeb634454d279f6"
2324 + dependencies = [
2325 + "thiserror 1.0.69",
2326 + ]
2327 +
2328 + [[package]]
2317 2329 name = "icalendar"
2318 2330 version = "0.16.17"
2319 2331 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3508,6 +3520,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
3508 3520 checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
3509 3521
3510 3522 [[package]]
3523 + name = "openssl-src"
3524 + version = "300.5.5+3.5.5"
3525 + source = "registry+https://github.com/rust-lang/crates.io-index"
3526 + checksum = "3f1787d533e03597a7934fd0a765f0d28e94ecc5fb7789f8053b1e699a56f709"
3527 + dependencies = [
3528 + "cc",
3529 + ]
3530 +
3531 + [[package]]
3511 3532 name = "openssl-sys"
3512 3533 version = "0.9.112"
3513 3534 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3515,6 +3536,7 @@ checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb"
3515 3536 dependencies = [
3516 3537 "cc",
3517 3538 "libc",
3539 + "openssl-src",
3518 3540 "pkg-config",
3519 3541 "vcpkg",
3520 3542 ]
@@ -5591,6 +5613,7 @@ dependencies = [
5591 5613 "serde_json",
5592 5614 "thiserror 2.0.18",
5593 5615 "tokio",
5616 + "tokio-stream",
5594 5617 "tracing",
5595 5618 "unicode-normalization",
5596 5619 "urlencoding",
M Cargo.toml +4 -1
@@ -10,7 +10,7 @@ default-members = ["src-tauri"]
10 10 resolver = "2"
11 11
12 12 [workspace.package]
13 - version = "0.3.0"
13 + version = "0.3.1"
14 14 edition = "2021"
15 15 license-file = "LICENSE"
16 16
@@ -74,6 +74,9 @@ toml = "0.8"
74 74 strum = "0.26"
75 75 strum_macros = "0.26"
76 76
77 + # iCalendar parsing
78 + ical = "0.11"
79 +
77 80 # CSV
78 81 csv = "1.3"
79 82
@@ -23,6 +23,8 @@ pub struct Contact {
23 23 pub tags: Vec<String>,
24 24 pub birthday: Option<NaiveDate>,
25 25 pub timezone: Option<String>,
26 + pub external_source: Option<String>,
27 + pub external_id: Option<String>,
26 28 pub emails: Vec<ContactEmail>,
27 29 pub phones: Vec<ContactPhone>,
28 30 pub social_handles: Vec<SocialHandle>,
@@ -197,6 +199,8 @@ mod tests {
197 199 tags: vec![],
198 200 birthday: None,
199 201 timezone: None,
202 + external_source: None,
203 + external_id: None,
200 204 emails: vec![],
201 205 phones: vec![],
202 206 social_handles: vec![],
@@ -29,6 +29,7 @@ pub struct FetchedEmail {
29 29 pub source_folder: String,
30 30 pub imap_uid: Option<i64>,
31 31 pub is_archived: bool,
32 + pub attachment_meta: Option<String>,
32 33 }
33 34
34 35 /// Result of processing a batch of fetched emails.
@@ -44,8 +45,8 @@ pub struct SyncProcessResult {
44 45 ///
45 46 /// This is the shared logic for both IMAP and JMAP sync paths:
46 47 /// 1. Batch-check which message IDs already exist
47 - /// 2. For each new email, save it with tracking metadata
48 - /// 3. If it's a reply to a message we're waiting on, clear the waiting status
48 + /// 2. Batch-insert all new emails (single transaction, no post-insert SELECTs)
49 + /// 3. For replies to messages we're waiting on, clear the waiting status
49 50 pub async fn process_fetched_emails(
50 51 email_repo: &dyn EmailRepository,
51 52 user_id: UserId,
@@ -64,8 +65,10 @@ pub async fn process_fetched_emails(
64 65 .await
65 66 .unwrap_or_default();
66 67
67 - // For each email: (1) skip if already exists, (2) insert with tracking
68 - // metadata, (3) if it's a reply to a message we're waiting on, clear waiting.
68 + // Collect new emails for batch insert, track reply-to IDs for waiting-status clearing
69 + let mut new_emails = Vec::new();
70 + let mut reply_to_ids: Vec<String> = Vec::new();
71 +
69 72 for email in emails {
70 73 if let Some(ref msg_id) = email.message_id {
71 74 if existing_ids.contains(msg_id) {
@@ -77,9 +80,12 @@ pub async fn process_fetched_emails(
77 80 // otherwise fall back to message_id (starts a new thread). This means
78 81 // the first email in a thread has thread_id == message_id.
79 82 let thread_id = email.in_reply_to.clone().or_else(|| email.message_id.clone());
80 - let in_reply_to_for_waiting = email.in_reply_to.clone();
81 83
82 - let new_email = NewEmailWithTracking {
84 + if let Some(ref reply_to) = email.in_reply_to {
85 + reply_to_ids.push(reply_to.clone());
86 + }
87 +
88 + new_emails.push(NewEmailWithTracking {
83 89 project_id: None,
84 90 from_address: email.from,
85 91 to_address: email.to,
@@ -96,19 +102,19 @@ pub async fn process_fetched_emails(
96 102 source_folder: Some(email.source_folder),
97 103 email_account_id: Some(account_id),
98 104 is_outgoing: false,
99 - };
105 + attachment_meta: email.attachment_meta,
106 + });
107 + }
108 +
109 + // Batch insert — single transaction, no post-insert SELECTs
110 + result.emails_saved = email_repo.create_with_tracking_batch(user_id, new_emails).await?;
100 111
101 - if email_repo.create_with_tracking(user_id, new_email).await.is_ok() {
102 - result.emails_saved += 1;
103 -
104 - // Clear waiting status if this is a reply to a message we're waiting on
105 - if let Some(ref reply_to_msg_id) = in_reply_to_for_waiting {
106 - if let Ok(Some(original)) = email_repo.get_by_message_id(user_id, reply_to_msg_id).await {
107 - if original.waiting_for_response {
108 - let _ = email_repo.clear_waiting(original.id, user_id).await;
109 - result.waiting_cleared += 1;
110 - }
111 - }
112 + // Clear waiting status for emails that received replies
113 + for reply_to_msg_id in &reply_to_ids {
114 + if let Ok(Some(original)) = email_repo.get_by_message_id(user_id, reply_to_msg_id).await {
115 + if original.waiting_for_response {
116 + let _ = email_repo.clear_waiting(original.id, user_id).await;
117 + result.waiting_cleared += 1;
112 118 }
113 119 }
114 120 }
@@ -142,10 +148,12 @@ mod tests {
142 148 source_folder: "INBOX".to_string(),
143 149 imap_uid: Some(42),
144 150 is_archived: false,
151 + attachment_meta: None,
145 152 };
146 153 assert_eq!(email.from, "sender@example.com");
147 154 assert_eq!(email.imap_uid, Some(42));
148 155 assert!(!email.is_read);
149 156 assert!(!email.is_archived);
157 + assert!(email.attachment_meta.is_none());
150 158 }
151 159 }
@@ -127,11 +127,14 @@ define_uuid_id!(
127 127 // Sub-entity IDs
128 128 define_uuid_id!(
129 129 AnnotationId,
130 + AttachmentId,
130 131 SubtaskId,
132 + TimeSessionId,
131 133 ContactEmailId,
132 134 ContactPhoneId,
133 135 SocialHandleId,
134 136 CustomFieldId,
137 + SyncAccountId,
135 138 );
136 139
137 140 #[cfg(test)]
@@ -55,20 +55,23 @@ pub use contact::{
55 55 };
56 56 pub use error::CoreError;
57 57 pub use id_types::{
58 - AnnotationId, ContactEmailId, ContactId, ContactPhoneId, CustomFieldId, EmailAccountId,
59 - EmailId, EventId, LlmSettingsId, MilestoneId, MonthlyGoalId, MonthlyReflectionId,
60 - ProjectId, SavedViewId, SocialHandleId,
61 - SubtaskId, TaskId, UserId, WeeklyReviewId,
58 + AnnotationId, AttachmentId, ContactEmailId, ContactId, ContactPhoneId, CustomFieldId,
59 + EmailAccountId, EmailId, EventId, LlmSettingsId, MilestoneId, MonthlyGoalId,
60 + MonthlyReflectionId, ProjectId, SavedViewId, SocialHandleId,
61 + SubtaskId, SyncAccountId, TaskId, TimeSessionId, UserId, WeeklyReviewId,
62 62 };
63 63 pub use models::{
64 - Annotation, BackupSettings, BlockType, CssClass, DbValue, Email, EmailAccount, EmailAuthType,
65 - EmailThread, Event, LlmContext, LlmProviderType, LlmSettings, Milestone, MilestoneStatus,
66 - MonthlyGoal, MonthlyGoalStatus, MonthlyReflection,
67 - NewBackupSettings, NewEmail, NewEmailWithTracking, NewEvent, NewEventBuilder, NewLlmSettings,
68 - NewMilestone, NewProject, NewSavedView, NewTask, NewTaskBuilder, Priority, Project,
69 - ParseableEnum, ProjectStatus, ProjectType, Recurrence, SavedView, SortDirection, SortField, Subtask, Task,
70 - TaskFilterQuery, TaskSortColumn, TaskStatus, UpdateEvent, UpdateProject, UpdateTask, User,
64 + Annotation, Attachment, BackupSettings, BlockType, CssClass, DbValue, Email, EmailAccount,
65 + EmailAuthType, EmailThread, Event, LlmContext, LlmProviderType, LlmSettings, Milestone,
66 + MilestoneStatus, MonthlyGoal, MonthlyGoalStatus, MonthlyReflection,
67 + AttachmentMeta, NewAttachment, NewBackupSettings, NewEmail, NewEmailWithTracking, NewEvent, NewEventBuilder,
68 + NewLlmSettings, NewMilestone, NewProject, NewSavedView, NewTask, NewTaskBuilder, Priority,
69 + Project, ParseableEnum, ProjectStatus, ProjectType, Recurrence, SavedView, SortDirection,
70 + SyncAccount,
71 + SortField, Subtask, Task, TaskFilterQuery, TaskSortColumn, TaskStatus, TimeSession,
72 + TimeTrackingSummary, UpdateEvent, UpdateProject, UpdateTask, User,
71 73 ViewFilters, ViewType, WeeklyReview,
74 + format_file_size, mime_from_extension,
72 75 };
73 76 pub use parser::{parse_quick_add, parse_quick_add_with_warnings, ParsedTask, ParseResult};
74 77 pub use day_planning::{Conflict, TimelineItem, detect_conflicts};
@@ -0,0 +1,182 @@
1 + //! File attachment domain types.
2 +
3 + use chrono::{DateTime, Utc};
4 + use serde::{Deserialize, Serialize};
5 + use crate::id_types::{AttachmentId, EmailId, ProjectId, TaskId, UserId};
6 +
7 + /// A file attachment linked to a task or project.
8 + #[derive(Debug, Clone, Serialize, Deserialize)]
9 + #[serde(rename_all = "camelCase")]
10 + pub struct Attachment {
11 + pub id: AttachmentId,
12 + pub user_id: UserId,
13 + pub task_id: Option<TaskId>,
14 + pub project_id: Option<ProjectId>,
15 + pub filename: String,
16 + pub file_size: i64,
17 + pub mime_type: String,
18 + pub blob_hash: String,
19 + pub source_email_id: Option<EmailId>,
20 + pub created_at: DateTime<Utc>,
21 + }
22 +
23 + /// Data for creating a new attachment.
24 + #[derive(Debug, Clone)]
25 + pub struct NewAttachment {
26 + pub task_id: Option<TaskId>,
27 + pub project_id: Option<ProjectId>,
28 + pub filename: String,
29 + pub file_size: i64,
30 + pub mime_type: String,
31 + pub blob_hash: String,
32 + pub source_email_id: Option<EmailId>,
33 + }
34 +
35 + /// Metadata for an email attachment, stored as JSON in emails.attachment_meta.
36 + #[derive(Debug, Clone, Serialize, Deserialize)]
37 + pub struct AttachmentMeta {
38 + pub filename: String,
39 + pub mime_type: String,
40 + pub size: usize,
41 + pub blob_hash: String,
42 + }
43 +
44 + /// Detect MIME type from file extension.
45 + pub fn mime_from_extension(filename: &str) -> &'static str {
46 + let ext = filename.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
47 + match ext.as_str() {
48 + "pdf" => "application/pdf",
49 + "doc" | "docx" => "application/msword",
50 + "xls" | "xlsx" => "application/vnd.ms-excel",
51 + "ppt" | "pptx" => "application/vnd.ms-powerpoint",
52 + "zip" => "application/zip",
53 + "gz" | "gzip" => "application/gzip",
54 + "tar" => "application/x-tar",
55 + "json" => "application/json",
56 + "xml" => "application/xml",
57 + "csv" => "text/csv",
58 + "txt" | "md" | "markdown" => "text/plain",
59 + "html" | "htm" => "text/html",
60 + "css" => "text/css",
61 + "js" => "text/javascript",
62 + "png" => "image/png",
63 + "jpg" | "jpeg" => "image/jpeg",
64 + "gif" => "image/gif",
65 + "svg" => "image/svg+xml",
66 + "webp" => "image/webp",
67 + "ico" => "image/x-icon",
68 + "mp3" => "audio/mpeg",
69 + "wav" => "audio/wav",
70 + "ogg" => "audio/ogg",
71 + "flac" => "audio/flac",
72 + "aac" => "audio/aac",
73 + "mp4" => "video/mp4",
74 + "webm" => "video/webm",
75 + "mov" => "video/quicktime",
76 + "avi" => "video/x-msvideo",
77 + "rs" => "text/x-rust",
78 + "py" => "text/x-python",
79 + "toml" => "application/toml",
80 + "yaml" | "yml" => "application/yaml",
81 + _ => "application/octet-stream",
82 + }
83 + }
84 +
85 + /// Format a byte count as a human-readable string.
86 + pub fn format_file_size(bytes: i64) -> String {
87 + if bytes < 1024 {
88 + format!("{} B", bytes)
89 + } else if bytes < 1024 * 1024 {
90 + format!("{:.1} KB", bytes as f64 / 1024.0)
91 + } else if bytes < 1024 * 1024 * 1024 {
92 + format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
93 + } else {
94 + format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
95 + }
96 + }
97 +
98 + #[cfg(test)]
99 + mod tests {
100 + use super::*;
101 +
102 + #[test]
103 + fn mime_detection_common_types() {
104 + assert_eq!(mime_from_extension("report.pdf"), "application/pdf");
105 + assert_eq!(mime_from_extension("photo.jpg"), "image/jpeg");
106 + assert_eq!(mime_from_extension("photo.JPEG"), "image/jpeg");
107 + assert_eq!(mime_from_extension("song.mp3"), "audio/mpeg");
108 + assert_eq!(mime_from_extension("video.mp4"), "video/mp4");
109 + assert_eq!(mime_from_extension("notes.txt"), "text/plain");
110 + assert_eq!(mime_from_extension("data.csv"), "text/csv");
111 + }
112 +
113 + #[test]
114 + fn mime_detection_unknown_extension() {
115 + assert_eq!(mime_from_extension("file.xyz"), "application/octet-stream");
116 + assert_eq!(mime_from_extension("noext"), "application/octet-stream");
117 + }
118 +
119 + #[test]
120 + fn file_size_formatting() {
121 + assert_eq!(format_file_size(0), "0 B");
122 + assert_eq!(format_file_size(512), "512 B");
123 + assert_eq!(format_file_size(1024), "1.0 KB");
124 + assert_eq!(format_file_size(1536), "1.5 KB");
125 + assert_eq!(format_file_size(1048576), "1.0 MB");
126 + assert_eq!(format_file_size(1572864), "1.5 MB");
127 + assert_eq!(format_file_size(1073741824), "1.0 GB");
128 + }
129 +
130 + #[test]
131 + fn mime_from_dotfile() {
132 + assert_eq!(mime_from_extension(".gitignore"), "application/octet-stream");
133 + }
134 +
135 + #[test]
136 + fn attachment_meta_serialization_roundtrip() {
137 + let meta = AttachmentMeta {
138 + filename: "report.pdf".into(),
139 + mime_type: "application/pdf".into(),
140 + size: 12345,
141 + blob_hash: "abc123def456".into(),
142 + };
143 + let json = serde_json::to_string(&meta).unwrap();
144 + let deserialized: AttachmentMeta = serde_json::from_str(&json).unwrap();
145 + assert_eq!(deserialized.filename, "report.pdf");
146 + assert_eq!(deserialized.mime_type, "application/pdf");
147 + assert_eq!(deserialized.size, 12345);
148 + assert_eq!(deserialized.blob_hash, "abc123def456");
149 + }
150 +
151 + #[test]
152 + fn attachment_meta_empty_list() {
153 + let metas: Vec<AttachmentMeta> = vec![];
154 + let json = serde_json::to_string(&metas).unwrap();
155 + assert_eq!(json, "[]");
156 + let deserialized: Vec<AttachmentMeta> = serde_json::from_str(&json).unwrap();
157 + assert!(deserialized.is_empty());
158 + }
159 +
160 + #[test]
161 + fn attachment_meta_multiple() {
162 + let metas = vec![
163 + AttachmentMeta {
164 + filename: "doc.pdf".into(),
165 + mime_type: "application/pdf".into(),
166 + size: 1000,
167 + blob_hash: "hash1".into(),
168 + },
169 + AttachmentMeta {
170 + filename: "image.png".into(),
171 + mime_type: "image/png".into(),
172 + size: 2000,
173 + blob_hash: "hash2".into(),
174 + },
175 + ];
176 + let json = serde_json::to_string(&metas).unwrap();
177 + let deserialized: Vec<AttachmentMeta> = serde_json::from_str(&json).unwrap();
178 + assert_eq!(deserialized.len(), 2);
179 + assert_eq!(deserialized[0].filename, "doc.pdf");
180 + assert_eq!(deserialized[1].filename, "image.png");
181 + }
182 + }
@@ -57,6 +57,9 @@ pub struct Email {
57 57 /// IMAP folder name (internal).
58 58 #[serde(skip_serializing)]
59 59 pub source_folder: Option<String>,
60 + /// JSON-serialized attachment metadata from IMAP sync.
61 + #[serde(skip_serializing)]
62 + pub attachment_meta: Option<String>,
60 63 /// If snoozed, when to resurface.
61 64 pub snoozed_until: Option<DateTime<Utc>>,
62 65 /// Whether waiting for a reply.
@@ -183,6 +186,7 @@ mod tests {
183 186 is_outgoing: false,
184 187 imap_uid: None,
185 188 source_folder: None,
189 + attachment_meta: None,
186 190 snoozed_until: None,
187 191 waiting_for_response: false,
188 192 waiting_since: None,
@@ -359,4 +363,5 @@ pub struct NewEmailWithTracking {
359 363 pub is_outgoing: bool,
360 364 pub imap_uid: Option<i64>,
361 365 pub source_folder: Option<String>,
366 + pub attachment_meta: Option<String>,
362 367 }
@@ -51,6 +51,12 @@ pub struct Event {
51 51 pub recurrence_parent_id: Option<EventId>,
52 52 /// If this is a time block, the block type.
53 53 pub block_type: Option<BlockType>,
54 + /// External sync source (e.g., "vcf", "ics", "google", "apple").
55 + pub external_source: Option<String>,
56 + /// External ID for dedup (e.g., UID from .ics, provider-specific ID).
57 + pub external_id: Option<String>,
58 + /// Whether this event is read-only (synced from external calendar).
59 + pub is_read_only: bool,
54 60 }
55 61
56 62 impl Event {
@@ -1,5 +1,6 @@
1 1 //! Domain models for the GoingsOn application.
2 2
3 + mod attachment;
3 4 mod backup;
4 5 mod email;
5 6 mod email_account;
@@ -9,11 +10,14 @@ mod milestone;
9 10 mod project;
10 11 mod saved_view;
11 12 mod shared;
13 + mod sync_account;
12 14 mod task;
15 + mod time_session;
13 16 mod user;
14 17 mod monthly_review;
15 18 mod weekly_review;
16 19
20 + pub use attachment::*;
17 21 pub use backup::*;
18 22 pub use email::*;
19 23 pub use email_account::*;
@@ -24,6 +28,8 @@ pub use monthly_review::*;
24 28 pub use project::*;
25 29 pub use saved_view::*;
26 30 pub use shared::*;
31 + pub use sync_account::*;
27 32 pub use task::*;
33 + pub use time_session::*;
28 34 pub use user::*;
29 35 pub use weekly_review::*;
@@ -33,7 +33,7 @@ impl std::str::FromStr for MonthlyGoalStatus {
33 33 "active" => Ok(Self::Active),
34 34 "done" => Ok(Self::Done),
35 35 "abandoned" => Ok(Self::Abandoned),
36 - _ => Err(crate::CoreError::parse(&format!("Invalid goal status: {s}"))),
36 + _ => Err(crate::CoreError::parse(format!("Invalid goal status: {s}"))),
37 37 }
38 38 }
39 39 }
@@ -0,0 +1,25 @@
1 + //! Sync account model for external calendar/contact provider connections.
2 +
3 + use chrono::{DateTime, Utc};
4 + use serde::{Deserialize, Serialize};
5 + use crate::id_types::{SyncAccountId, UserId};
6 +
7 + /// A configured sync account for an external provider (Google, Apple, Microsoft).
8 + #[derive(Debug, Clone, Serialize, Deserialize)]
9 + #[serde(rename_all = "camelCase")]
10 + pub struct SyncAccount {
11 + pub id: SyncAccountId,
12 + #[serde(skip_serializing)]
13 + pub user_id: UserId,
14 + pub provider: String,
15 + pub account_name: String,
16 + pub email: Option<String>,
17 + pub sync_calendars: bool,
18 + pub sync_contacts: bool,
19 + pub calendar_ids: Vec<String>,
20 + pub last_calendar_sync: Option<DateTime<Utc>>,
21 + pub last_contact_sync: Option<DateTime<Utc>>,
22 + pub sync_interval_minutes: i32,
23 + pub enabled: bool,
24 + pub created_at: DateTime<Utc>,
25 + }
@@ -14,6 +14,7 @@ use crate::constants::{
14 14 DAYS_THRESHOLD_SHORT_FORMAT, URGENCY_HIGH_THRESHOLD, URGENCY_MEDIUM_THRESHOLD,
15 15 };
16 16 use crate::id_types::{TaskId, ProjectId, MilestoneId, ContactId, EmailId, AnnotationId, SubtaskId};
17 + use super::time_session::TimeSession;
17 18 use super::shared::{CssClass, DbValue, ParseableEnum, Recurrence, SortDirection};
18 19
19 20 // ============ Task Types ============
@@ -227,6 +228,13 @@ pub struct Task {
227 228 pub annotations: Vec<Annotation>,
228 229 /// Checklist items.
229 230 pub subtasks: Vec<Subtask>,
231 + /// Estimated duration in minutes (user-provided).
232 + pub estimated_minutes: Option<i32>,
233 + /// Cached total actual tracked minutes across all sessions.
234 + pub actual_minutes: i32,
235 + /// Currently active time session, if any (populated on fetch).
236 + #[serde(skip_serializing_if = "Option::is_none")]
237 + pub active_session: Option<TimeSession>,
230 238 /// When the task was created.
231 239 pub created_at: DateTime<Utc>,
232 240 /// When the task was completed (set on status transition to Completed).
@@ -374,6 +382,29 @@ impl Task {
374 382 pub fn is_focused(&self) -> bool {
375 383 self.is_focus
376 384 }
385 +
386 + /// Returns time progress as a percentage (0-100), or None if no estimate.
387 + pub fn time_progress(&self) -> Option<u8> {
388 + self.estimated_minutes.map(|est| {
389 + if est <= 0 {
390 + return 0;
391 + }
392 + ((self.actual_minutes as f64 / est as f64) * 100.0).round().min(255.0) as u8
393 + })
394 + }
395 +
396 + /// Returns true if actual tracked time exceeds the estimate.
397 + pub fn is_over_estimate(&self) -> bool {
398 + match self.estimated_minutes {
399 + Some(est) if est > 0 => self.actual_minutes > est,
400 + _ => false,
401 + }
402 + }
403 +
404 + /// Returns true if a timer is currently running on this task.
405 + pub fn has_active_timer(&self) -> bool {
406 + self.active_session.is_some()
407 + }
377 408 }
378 409
379 410 /// Column to sort tasks by.
@@ -461,6 +492,8 @@ pub struct NewTask {
461 492 pub scheduled_start: Option<DateTime<Utc>>,
462 493 /// Scheduled duration in minutes for time-blocking.
463 494 pub scheduled_duration: Option<i32>,
495 + /// Estimated duration in minutes.
496 + pub estimated_minutes: Option<i32>,
464 497 }
465 498
466 499 impl NewTask {
@@ -498,6 +531,7 @@ pub struct NewTaskBuilder {
498 531 source_email_id: Option<EmailId>,
499 532 scheduled_start: Option<DateTime<Utc>>,
500 533 scheduled_duration: Option<i32>,
534 + estimated_minutes: Option<i32>,
501 535 }
502 536
503 537 impl NewTaskBuilder {
@@ -516,6 +550,7 @@ impl NewTaskBuilder {
516 550 source_email_id: None,
517 551 scheduled_start: None,
518 552 scheduled_duration: None,
553 + estimated_minutes: None,
519 554 }
520 555 }
521 556
@@ -591,6 +626,12 @@ impl NewTaskBuilder {
591 626 self
592 627 }
593 628
629 + /// Sets the estimated duration in minutes.
630 + pub fn estimated_minutes(mut self, minutes: i32) -> Self {
631 + self.estimated_minutes = Some(minutes);
632 + self
633 + }
634 +
594 635 /// Builds the [`NewTask`].
595 636 pub fn build(self) -> NewTask {
596 637 NewTask {
@@ -606,10 +647,21 @@ impl NewTaskBuilder {
606 647 source_email_id: self.source_email_id,
607 648 scheduled_start: self.scheduled_start,
608 649 scheduled_duration: self.scheduled_duration,
650 + estimated_minutes: self.estimated_minutes,
609 651 }
610 652 }
611 653 }
612 654
655 + /// Lightweight context for task update logic — avoids fetching annotations, subtasks, sessions.
656 + #[derive(Debug, Clone)]
657 + pub struct TaskUpdateContext {
658 + pub created_at: DateTime<Utc>,
659 + pub status: TaskStatus,
660 + pub completed_at: Option<DateTime<Utc>>,
661 + pub scheduled_start: Option<DateTime<Utc>>,
662 + pub scheduled_duration: Option<i32>,
663 + }
664 +
613 665 /// Data for updating an existing task.
614 666 #[derive(Debug, Clone, Serialize, Deserialize)]
615 667 pub struct UpdateTask {
@@ -637,4 +689,6 @@ pub struct UpdateTask {
637 689 pub scheduled_start: Option<DateTime<Utc>>,
638 690 /// Scheduled duration in minutes for time-blocking.
639 691 pub scheduled_duration: Option<i32>,
692 + /// Estimated duration in minutes.
693 + pub estimated_minutes: Option<i32>,
640 694 }
@@ -0,0 +1,93 @@
1 + //! Time tracking session types.
2 + //!
3 + //! A `TimeSession` records a period of work on a task. Sessions are created
4 + //! when a timer is started and closed when it's stopped. At most one session
5 + //! per user can be active (ended_at IS NULL) at any time.
6 +
7 + use chrono::{DateTime, Utc};
8 + use serde::{Deserialize, Serialize};
9 + use crate::id_types::{TaskId, TimeSessionId, UserId, ProjectId};
10 +
11 + /// A single time tracking session on a task.
12 + #[derive(Debug, Clone, Serialize, Deserialize)]
13 + #[serde(rename_all = "camelCase")]
14 + pub struct TimeSession {
15 + pub id: TimeSessionId,
16 + pub task_id: TaskId,
17 + pub user_id: UserId,
18 + pub started_at: DateTime<Utc>,
19 + pub ended_at: Option<DateTime<Utc>>,
20 + pub duration_minutes: Option<i32>,
21 + pub created_at: DateTime<Utc>,
22 + }
23 +
24 + impl TimeSession {
25 + /// Returns true if this session is still running (no end time).
26 + pub fn is_active(&self) -> bool {
27 + self.ended_at.is_none()
28 + }
29 +
30 + /// Returns elapsed minutes from start to now (if active) or to ended_at.
31 + pub fn elapsed_minutes(&self) -> i32 {
32 + let end = self.ended_at.unwrap_or_else(Utc::now);
33 + let diff = end.signed_duration_since(self.started_at);
34 + diff.num_minutes().max(0) as i32
35 + }
36 + }
37 +
38 + /// Aggregated time tracking data grouped by project and date.
39 + #[derive(Debug, Clone, Serialize, Deserialize)]
40 + #[serde(rename_all = "camelCase")]
41 + pub struct TimeTrackingSummary {
42 + pub project_id: Option<ProjectId>,
43 + pub project_name: Option<String>,
44 + pub date: String,
45 + pub total_minutes: i32,
46 + pub session_count: i32,
47 + }
48 +
49 + #[cfg(test)]
50 + mod tests {
51 + use super::*;
52 +
53 + fn make_session(started: DateTime<Utc>, ended: Option<DateTime<Utc>>) -> TimeSession {
54 + TimeSession {
55 + id: TimeSessionId::new(),
56 + task_id: TaskId::new(),
57 + user_id: UserId::new(),
58 + started_at: started,
59 + ended_at: ended,
60 + duration_minutes: ended.map(|e| (e - started).num_minutes() as i32),
61 + created_at: started,
62 + }
63 + }
64 +
65 + #[test]
66 + fn is_active_when_no_end() {
67 + let session = make_session(Utc::now(), None);
68 + assert!(session.is_active());
69 + }
70 +
71 + #[test]
72 + fn is_not_active_when_ended() {
73 + let start = Utc::now() - chrono::Duration::minutes(30);
74 + let end = Utc::now();
75 + let session = make_session(start, Some(end));
76 + assert!(!session.is_active());
77 + }
78 +
79 + #[test]
80 + fn elapsed_minutes_for_ended_session() {
81 + let start = Utc::now() - chrono::Duration::minutes(45);
82 + let end = Utc::now();
83 + let session = make_session(start, Some(end));
84 + assert_eq!(session.elapsed_minutes(), 45);
85 + }
86 +
87 + #[test]
88 + fn elapsed_minutes_active_session_positive() {
89 + let start = Utc::now() - chrono::Duration::minutes(10);
90 + let session = make_session(start, None);
91 + assert!(session.elapsed_minutes() >= 9);
92 + }
93 + }
@@ -158,7 +158,7 @@ pub fn compute_monthly_review(input: MonthlyReviewInput) -> MonthlyReviewData {
158 158
159 159 // Calendar grid layout
160 160 let first_day_offset = month_start.weekday().num_days_from_monday();
161 - let week_count = (first_day_offset + days_in_month + 6) / 7;
161 + let week_count = (first_day_offset + days_in_month).div_ceil(7);
162 162
163 163 // Build per-day data
164 164 let days: Vec<MonthDayData> = (0..days_in_month)
@@ -216,8 +216,7 @@ pub fn compute_monthly_review(input: MonthlyReviewInput) -> MonthlyReviewData {
216 216 .filter(|d| d.completed_count > 0)
217 217 .map(|d| d.date.clone());
218 218 let quietest_day = past_days.iter()
219 - .filter(|d| d.completed_count == 0 && !d.is_vacation)
220 - .next()
219 + .find(|d| d.completed_count == 0 && !d.is_vacation)
221 220 .or_else(|| past_days.iter().min_by_key(|d| d.completed_count))
222 221 .map(|d| d.date.clone());
223 222
@@ -344,7 +343,7 @@ fn compute_patterns(
344 343 let mut days_counted_by_dow = [0i32; 7];
345 344
346 345 for day in days.iter().filter(|d| d.is_past || d.is_today) {
347 - if let Some(date) = chrono::NaiveDate::parse_from_str(&day.date, "%Y-%m-%d").ok() {
346 + if let Ok(date) = chrono::NaiveDate::parse_from_str(&day.date, "%Y-%m-%d") {
348 347 let dow = date.weekday().num_days_from_monday() as usize;
349 348 completions_by_dow[dow] += day.completed_count;
350 349 days_counted_by_dow[dow] += 1;
@@ -7,9 +7,9 @@ use async_trait::async_trait;
7 7 use chrono::{DateTime, NaiveDate, Utc};
8 8 use std::collections::HashSet;
9 9 use crate::id_types::{
10 - AnnotationId, ContactEmailId, ContactId, ContactPhoneId, CustomFieldId, EmailAccountId,
11 - EmailId, EventId, MilestoneId, ProjectId, SavedViewId, SocialHandleId, SubtaskId, TaskId,
12 - UserId,
10 + AnnotationId, AttachmentId, ContactEmailId, ContactId, ContactPhoneId, CustomFieldId,
11 + EmailAccountId, EmailId, EventId, MilestoneId, ProjectId, SavedViewId, SocialHandleId,
12 + SubtaskId, SyncAccountId, TaskId, UserId,
13 13 };
14 14 use uuid::Uuid;
15 15
@@ -19,9 +19,10 @@ use crate::contact::{
19 19 };
20 20 use crate::error::CoreError;
21 21 use crate::models::{
22 - Annotation, Email, EmailAccount, EmailAuthType, EmailThread, Event, LlmSettings, NewEmail,
23 - NewEmailWithTracking, NewEvent, NewLlmSettings, NewProject, NewSavedView, NewTask, Project,
24 - SavedView, Subtask, Task, TaskFilterQuery, UpdateTask, User,
22 + Annotation, Attachment, Email, EmailAccount, EmailAuthType, EmailThread, Event, LlmSettings,
23 + NewAttachment, NewEmail, NewEmailWithTracking, NewEvent, NewLlmSettings, NewProject,
24 + NewSavedView, NewTask, Project, SavedView, Subtask, Task, TaskFilterQuery, TimeSession,
25 + TimeTrackingSummary, UpdateTask, User,
25 26 };
26 27
27 28 /// Convenience type alias for repository operation results.
@@ -75,6 +76,10 @@ pub trait TaskRepository: Send + Sync {
75 76 /// Retrieves a task by ID.
76 77 async fn get_by_id(&self, id: TaskId, user_id: UserId) -> Result<Option<Task>>;
77 78
79 + /// Lightweight fetch of fields needed for update logic (avoids annotation/subtask/session sub-queries).
80 + /// Returns (created_at, status, completed_at, scheduled_start, scheduled_duration).
81 + async fn get_update_context(&self, id: TaskId, user_id: UserId) -> Result<Option<crate::models::TaskUpdateContext>>;
82 +
78 83 /// Creates a new task.
79 84 async fn create(&self, user_id: UserId, task: NewTask) -> Result<Task>;
80 85
@@ -175,6 +180,26 @@ pub trait TaskRepository: Send + Sync {
175 180
176 181 /// Lists high-priority pending tasks for focus selection.
177 182 async fn list_available_for_focus(&self, user_id: UserId, limit: i64) -> Result<Vec<Task>>;
183 +
184 + // ---- Time Tracking ----
185 +
186 + /// Starts a timer on a task. Fails if any session is already active for the user.
187 + async fn start_timer(&self, task_id: TaskId, user_id: UserId) -> Result<TimeSession>;
188 +
189 + /// Stops the active timer on a task, updating duration and actual_minutes cache.
190 + async fn stop_timer(&self, task_id: TaskId, user_id: UserId) -> Result<Option<TimeSession>>;
191 +
192 + /// Discards the active timer without updating actual_minutes.
193 + async fn discard_timer(&self, task_id: TaskId, user_id: UserId) -> Result<bool>;
194 +
195 + /// Gets the currently active timer for a user (at most one).
196 + async fn get_active_timer(&self, user_id: UserId) -> Result<Option<(TimeSession, String)>>;
197 +
198 + /// Lists all time sessions for a task.
199 + async fn list_time_sessions(&self, task_id: TaskId, user_id: UserId) -> Result<Vec<TimeSession>>;
200 +
201 + /// Gets aggregated time tracking summary grouped by project and date.
202 + async fn get_time_summary(&self, user_id: UserId, start: DateTime<Utc>, end: DateTime<Utc>) -> Result<Vec<TimeTrackingSummary>>;
178 203 }
179 204
180 205 /// Repository for calendar event operations.
@@ -220,6 +245,9 @@ pub trait EventRepository: Send + Sync {
220 245
221 246 /// Lists events within a date range (for weekly review), ordered by `start_time ASC`.
222 247 async fn list_between(&self, user_id: UserId, start: DateTime<Utc>, end: DateTime<Utc>) -> Result<Vec<Event>>;
248 +
249 + /// Finds an event by external source and ID (for dedup during import).
250 + async fn find_by_external_id(&self, source: &str, ext_id: &str, user_id: UserId) -> Result<Option<Event>>;
223 251 }
224 252
225 253 /// Repository for email message operations.
@@ -250,6 +278,10 @@ pub trait EmailRepository: Send + Sync {
250 278 /// Creates an email with follow-up tracking fields.
251 279 async fn create_with_tracking(&self, user_id: UserId, email: NewEmailWithTracking) -> Result<Email>;
252 280
281 + /// Batch-inserts emails with tracking in a single transaction, skipping post-insert SELECTs.
282 + /// Returns the count of successfully inserted emails.
283 + async fn create_with_tracking_batch(&self, user_id: UserId, emails: Vec<NewEmailWithTracking>) -> Result<usize>;
284 +
253 285 /// Deletes an email.
254 286 async fn delete(&self, id: EmailId, user_id: UserId) -> Result<bool>;
255 287
@@ -810,6 +842,9 @@ pub trait ContactRepository: Send + Sync {
810 842 /// Finds a contact by email address.
811 843 async fn find_by_email(&self, user_id: UserId, email: &str) -> Result<Option<Contact>>;
812 844
845 + /// Finds a contact by external source and ID (for dedup during import).
846 + async fn find_by_external_id(&self, source: &str, ext_id: &str, user_id: UserId) -> Result<Option<Contact>>;
847 +
813 848 /// Adds an email address to a contact.
814 849 async fn add_email(&self, contact_id: ContactId, user_id: UserId, email: NewContactEmail) -> Result<ContactEmail>;
815 850
@@ -834,3 +869,47 @@ pub trait ContactRepository: Send + Sync {
834 869 /// Removes a custom field from a contact.
835 870 async fn remove_custom_field(&self, field_id: CustomFieldId, user_id: UserId) -> Result<bool>;
836 871 }
872 +
873 + /// Repository for file attachment operations.
874 + #[async_trait]
875 + pub trait AttachmentRepository: Send + Sync {
876 + /// Creates a new attachment record.
877 + async fn create(&self, user_id: UserId, attachment: NewAttachment) -> Result<Attachment>;
878 +
879 + /// Lists attachments for a task.
880 + async fn list_for_task(&self, task_id: TaskId, user_id: UserId) -> Result<Vec<Attachment>>;
881 +
882 + /// Lists attachments for a project.
883 + async fn list_for_project(&self, project_id: ProjectId, user_id: UserId) -> Result<Vec<Attachment>>;
884 +
885 + /// Retrieves an attachment by ID.
886 + async fn get_by_id(&self, id: AttachmentId, user_id: UserId) -> Result<Option<Attachment>>;
887 +
888 + /// Deletes an attachment record, returning `true` if deleted.
889 + async fn delete(&self, id: AttachmentId, user_id: UserId) -> Result<bool>;
890 +
891 + /// Lists all attachments sharing a blob hash (for dedup checks).
892 + async fn list_by_blob_hash(&self, blob_hash: &str, user_id: UserId) -> Result<Vec<Attachment>>;
893 +
894 + /// Lists all distinct blob hashes for a user (for blob sync).
895 + async fn list_all_blob_hashes(&self, user_id: UserId) -> Result<Vec<String>>;
896 + }
897 +
898 + /// Repository for sync account CRUD operations.
899 + #[async_trait]
900 + pub trait SyncAccountRepository: Send + Sync {
901 + /// Lists all sync accounts for a user.
902 + async fn list_all(&self, user_id: UserId) -> Result<Vec<crate::models::SyncAccount>>;
903 +
904 + /// Retrieves a sync account by ID.
905 + async fn get_by_id(&self, id: SyncAccountId, user_id: UserId) -> Result<Option<crate::models::SyncAccount>>;
906 +
907 + /// Creates a new sync account.
908 + async fn create(&self, user_id: UserId, provider: &str, account_name: &str, email: Option<&str>) -> Result<crate::models::SyncAccount>;
909 +
910 + /// Updates a sync account.
911 + async fn update(&self, id: SyncAccountId, user_id: UserId, account_name: &str, sync_calendars: bool, sync_contacts: bool, enabled: bool) -> Result<Option<crate::models::SyncAccount>>;
912 +
913 + /// Deletes a sync account.
914 + async fn delete(&self, id: SyncAccountId, user_id: UserId) -> Result<bool>;
915 + }
@@ -203,6 +203,7 @@ mod tests {
203 203 scheduled_duration: Some(60),
204 204 contact_id: None,
205 205 milestone_id: None,
206 + estimated_minutes: None,
206 207 };
207 208 assert!(task.validate().is_ok());
208 209 }
@@ -222,6 +223,7 @@ mod tests {
222 223 scheduled_duration: None,
223 224 contact_id: None,
224 225 milestone_id: None,
226 + estimated_minutes: None,
225 227 };
226 228 let err = task.validate().unwrap_err();
227 229 assert!(matches!(err, CoreError::Validation { field: "description", .. }));
@@ -242,6 +244,7 @@ mod tests {
242 244 scheduled_duration: Some(-30),
243 245 contact_id: None,
244 246 milestone_id: None,
247 + estimated_minutes: None,
245 248 };
246 249 let err = task.validate().unwrap_err();
247 250 assert!(matches!(err, CoreError::Validation { field: "scheduled_duration", .. }));
@@ -321,6 +324,7 @@ mod tests {
321 324 scheduled_duration: None,
322 325 contact_id: None,
323 326 milestone_id: None,
327 + estimated_minutes: None,
324 328 };
325 329 let err = task.validate().unwrap_err();
326 330 assert!(matches!(err, CoreError::Validation { field: "tags", .. }));
@@ -341,6 +345,7 @@ mod tests {
341 345 scheduled_duration: None,
342 346 contact_id: None,
343 347 milestone_id: None,
348 + estimated_minutes: None,
344 349 };
345 350 let err = task.validate().unwrap_err();
346 351 assert!(matches!(err, CoreError::Validation { field: "tags", .. }));
@@ -361,6 +366,7 @@ mod tests {
361 366 scheduled_duration: Some(MAX_SCHEDULED_DURATION_MINUTES + 1),
362 367 contact_id: None,
363 368 milestone_id: None,
369 + estimated_minutes: None,
364 370 };
365 371 let err = task.validate().unwrap_err();
366 372 assert!(matches!(err, CoreError::Validation { field: "scheduled_duration", .. }));
@@ -393,6 +399,7 @@ mod tests {
393 399 scheduled_duration: None,
394 400 contact_id: None,
395 401 milestone_id: None,
402 + estimated_minutes: None,
396 403 };
397 404 let err = task.validate().unwrap_err();
398 405 assert!(matches!(err, CoreError::Validation { field: "description", .. }));
@@ -453,6 +460,7 @@ mod tests {
453 460 urgency: 7.0,
454 461 scheduled_start: None,
455 462 scheduled_duration: None,
463 + estimated_minutes: None,
456 464 };
457 465 assert!(task.validate().is_ok());
458 466 }
@@ -482,6 +490,7 @@ mod tests {
482 490 scheduled_duration: None,
483 491 contact_id: None,
484 492 milestone_id: None,
493 + estimated_minutes: None,
485 494 };
486 495 assert!(task.validate().is_err());
487 496
@@ -279,6 +279,7 @@ pub fn compute_weekly_review(input: WeeklyReviewInput) -> WeeklyReviewData {
279 279 }
280 280
281 281 /// Builds timeline data for each day of the review week (Mon–Sun).
282 + #[allow(clippy::too_many_arguments)]
282 283 pub fn build_timeline_days(
283 284 week_start: NaiveDate,
284 285 today: NaiveDate,
@@ -477,6 +478,9 @@ mod tests {
477 478 completed_at: Some(completed_at),
478 479 is_focus: false,
479 480 focus_set_at: None,
481 + estimated_minutes: None,
482 + actual_minutes: 0,
483 + active_session: None,
480 484 }
481 485 }
482 486
@@ -628,6 +632,9 @@ mod tests {
628 632 recurrence: Recurrence::None,
629 633 recurrence_parent_id: None,
630 634 block_type: None,
635 + external_id: None,
636 + external_source: None,
637 + is_read_only: false,
631 638 }
632 639 }
633 640
@@ -12,6 +12,7 @@ async-trait = { workspace = true }
12 12 argon2 = { workspace = true, features = ["std"] }
13 13 serde_json = { workspace = true }
14 14 tracing = { workspace = true }
15 + tokio = { workspace = true, features = ["macros"] }
15 16
16 17 [dev-dependencies]
17 18 tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
@@ -45,6 +45,7 @@ pub async fn run_migrations(pool: &SqlitePool) -> Result<(), sqlx::migrate::Migr
45 45 }
46 46
47 47 pub use repository::{
48 + SqliteAttachmentRepository,
48 49 SqliteBackupSettingsRepository,
49 50 SqliteContactRepository,
50 51 SqliteProjectRepository,
@@ -60,5 +61,6 @@ pub use repository::{
60 61 SqliteMilestoneRepository,
61 62 SqliteMonthlyReviewRepository,
62 63 SqliteSavedViewRepository,
64 + SqliteSyncAccountRepository,
63 65 SqliteWeeklyReviewRepository,
64 66 };
M docs/todo/todo.md +31 -103