Skip to main content

max / goingson

6.8 KB · 208 lines History Blame Raw
1 //! SQLite implementation of the WeeklyReviewRepository.
2 //!
3 //! Provides weekly review tracking functionality including:
4 //! - Getting reviews for specific weeks
5 //! - Creating/updating reviews
6 //! - Checking if current week is completed
7
8 use async_trait::async_trait;
9 use chrono::{Datelike, NaiveDate, Utc};
10 use sqlx::SqlitePool;
11 use goingson_core::{CoreError, Result, UserId, WeeklyReview, WeeklyReviewId, WeeklyReviewRepository};
12
13 use crate::utils::{format_datetime, parse_datetime, parse_uuid};
14
15 /// SQLite-backed implementation of [`WeeklyReviewRepository`].
16 ///
17 /// Tracks weekly review completion with notes. Reviews are keyed by
18 /// the Monday of each ISO week.
19 pub struct SqliteWeeklyReviewRepository {
20 pool: SqlitePool,
21 }
22
23 impl SqliteWeeklyReviewRepository {
24 /// Creates a new repository instance with the given connection pool.
25 #[tracing::instrument(skip_all)]
26 pub fn new(pool: SqlitePool) -> Self {
27 Self { pool }
28 }
29 }
30
31 /// Gets the Monday of the current ISO week.
32 fn current_week_start() -> NaiveDate {
33 let today = Utc::now().date_naive();
34 // NaiveDate::week returns the ISO week, which starts on Monday
35 let days_from_monday = today.weekday().num_days_from_monday();
36 today - chrono::Duration::days(days_from_monday as i64)
37 }
38
39 #[derive(sqlx::FromRow)]
40 struct WeeklyReviewRow {
41 id: String,
42 user_id: String,
43 week_start_date: String,
44 completed_at: String,
45 notes: String,
46 vacation_days: String,
47 }
48
49 /// Parse comma-separated day indices into Vec<u8>.
50 fn parse_vacation_days(s: &str) -> Vec<u8> {
51 if s.is_empty() {
52 return Vec::new();
53 }
54 s.split(',')
55 .filter_map(|d| d.trim().parse::<u8>().ok())
56 .filter(|&d| d <= 6)
57 .collect()
58 }
59
60 /// Serialize Vec<u8> into comma-separated string.
61 fn serialize_vacation_days(days: &[u8]) -> String {
62 days.iter()
63 .filter(|&&d| d <= 6)
64 .map(|d| d.to_string())
65 .collect::<Vec<_>>()
66 .join(",")
67 }
68
69 impl TryFrom<WeeklyReviewRow> for WeeklyReview {
70 type Error = CoreError;
71
72 fn try_from(row: WeeklyReviewRow) -> Result<Self> {
73 Ok(WeeklyReview {
74 id: parse_uuid(&row.id)?.into(),
75 user_id: parse_uuid(&row.user_id)?.into(),
76 week_start_date: NaiveDate::parse_from_str(&row.week_start_date, "%Y-%m-%d")
77 .map_err(|_| CoreError::parse("Invalid date"))?,
78 completed_at: parse_datetime(&row.completed_at)?,
79 notes: row.notes,
80 vacation_days: parse_vacation_days(&row.vacation_days),
81 })
82 }
83 }
84
85 #[async_trait]
86 impl WeeklyReviewRepository for SqliteWeeklyReviewRepository {
87 #[tracing::instrument(skip_all)]
88 async fn get_for_week(&self, user_id: UserId, week_start: NaiveDate) -> Result<Option<WeeklyReview>> {
89 let user_id_str = user_id.to_string();
90 let week_start_str = week_start.format("%Y-%m-%d").to_string();
91
92 let row: Option<WeeklyReviewRow> = sqlx::query_as(
93 "SELECT id, user_id, week_start_date, completed_at, notes, vacation_days
94 FROM weekly_reviews
95 WHERE user_id = ? AND week_start_date = ?"
96 )
97 .bind(&user_id_str)
98 .bind(&week_start_str)
99 .fetch_optional(&self.pool)
100 .await
101 .map_err(CoreError::database)?;
102
103 row.map(WeeklyReview::try_from).transpose()
104 }
105
106 #[tracing::instrument(skip_all)]
107 async fn upsert(&self, user_id: UserId, week_start: NaiveDate, notes: &str) -> Result<WeeklyReview> {
108 let user_id_str = user_id.to_string();
109 let week_start_str = week_start.format("%Y-%m-%d").to_string();
110 let now = Utc::now();
111 let completed_at_str = format_datetime(&now);
112
113 // Try to get existing review
114 let existing = self.get_for_week(user_id, week_start).await?;
115 let existing_vacation_days = existing.as_ref().map(|r| r.vacation_days.clone()).unwrap_or_default();
116
117 let id = if let Some(existing) = existing {
118 // Update existing
119 sqlx::query(
120 "UPDATE weekly_reviews SET notes = ?, completed_at = ? WHERE id = ?"
121 )
122 .bind(notes)
123 .bind(&completed_at_str)
124 .bind(existing.id.to_string())
125 .execute(&self.pool)
126 .await
127 .map_err(CoreError::database)?;
128
129 existing.id
130 } else {
131 // Insert new
132 let id = WeeklyReviewId::new();
133 sqlx::query(
134 "INSERT INTO weekly_reviews (id, user_id, week_start_date, completed_at, notes)
135 VALUES (?, ?, ?, ?, ?)"
136 )
137 .bind(id.to_string())
138 .bind(&user_id_str)
139 .bind(&week_start_str)
140 .bind(&completed_at_str)
141 .bind(notes)
142 .execute(&self.pool)
143 .await
144 .map_err(CoreError::database)?;
145
146 id
147 };
148
149 Ok(WeeklyReview {
150 id,
151 user_id,
152 week_start_date: week_start,
153 completed_at: now,
154 notes: notes.to_string(),
155 vacation_days: existing_vacation_days,
156 })
157 }
158
159 #[tracing::instrument(skip_all)]
160 async fn is_current_week_completed(&self, user_id: UserId) -> Result<bool> {
161 let week_start = current_week_start();
162 let review = self.get_for_week(user_id, week_start).await?;
163 Ok(review.is_some())
164 }
165
166 #[tracing::instrument(skip_all)]
167 async fn set_vacation_days(&self, user_id: UserId, week_start: NaiveDate, days: &[u8]) -> Result<()> {
168 let user_id_str = user_id.to_string();
169 let week_start_str = week_start.format("%Y-%m-%d").to_string();
170 let vacation_str = serialize_vacation_days(days);
171
172 // Check if a review row already exists for this week
173 let existing = self.get_for_week(user_id, week_start).await?;
174
175 if existing.is_some() {
176 // Update existing row
177 sqlx::query(
178 "UPDATE weekly_reviews SET vacation_days = ? WHERE user_id = ? AND week_start_date = ?"
179 )
180 .bind(&vacation_str)
181 .bind(&user_id_str)
182 .bind(&week_start_str)
183 .execute(&self.pool)
184 .await
185 .map_err(CoreError::database)?;
186 } else {
187 // Insert a new row (review not yet completed, just vacation days)
188 let id = WeeklyReviewId::new();
189 let now = format_datetime(&Utc::now());
190
191 sqlx::query(
192 "INSERT INTO weekly_reviews (id, user_id, week_start_date, completed_at, notes, vacation_days)
193 VALUES (?, ?, ?, ?, '', ?)"
194 )
195 .bind(id.to_string())
196 .bind(&user_id_str)
197 .bind(&week_start_str)
198 .bind(&now)
199 .bind(&vacation_str)
200 .execute(&self.pool)
201 .await
202 .map_err(CoreError::database)?;
203 }
204
205 Ok(())
206 }
207 }
208