1use crate::db::db::Db;
9use anyhow::Result;
10use chrono::{Duration, Local, NaiveDateTime};
11use rusqlite::{OptionalExtension, params};
12
13pub const FRESH_BADGE_HOURS: i64 = 24;
15
16#[derive(Debug, Clone)]
18pub struct JiraInboxItem {
19 pub issue_key: String,
20 pub issue_id: String,
21 pub summary: String,
22 pub status_id: Option<String>,
23 pub status_name: String,
25 pub priority: Option<String>,
26 pub priority_rank: i32,
27 pub sort_value: Option<f64>,
29 pub url: String,
30 pub first_seen: NaiveDateTime,
31 pub last_seen: NaiveDateTime,
32 pub notified: bool,
33 pub pinned: bool,
34 pub dismissed: bool,
35 pub raw_updated: Option<String>,
36 pub gone_at: Option<NaiveDateTime>,
38 pub last_change: Option<String>,
40 pub changed_at: Option<NaiveDateTime>,
41 pub taken_at: Option<NaiveDateTime>,
46}
47
48#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
50pub struct InboxCounts {
51 pub total: i64,
53 pub taken: i64,
55 pub fresh: i64,
57}
58
59impl InboxCounts {
60 pub fn is_empty(&self) -> bool {
62 self.total == 0
63 }
64}
65
66impl JiraInboxItem {
67 pub fn badge(&self, now: NaiveDateTime) -> Option<String> {
73 let fresh = |t: NaiveDateTime| now.signed_duration_since(t) < Duration::hours(FRESH_BADGE_HOURS);
74 if self.gone_at.is_some() {
75 return Some("gone".to_string());
76 }
77 if self.taken_at.is_some() {
80 return Some("taken".to_string());
81 }
82 if fresh(self.first_seen) {
83 return Some("NEW".to_string());
84 }
85 match (&self.last_change, self.changed_at) {
86 (Some(change), Some(at)) if fresh(at) => Some(change.clone()),
87 _ => None,
88 }
89 }
90}
91
92#[derive(Debug, Clone)]
94pub struct JiraInboxUpsert {
95 pub issue_key: String,
96 pub issue_id: String,
97 pub summary: String,
98 pub status_id: Option<String>,
99 pub status_name: String,
101 pub priority: Option<String>,
102 pub priority_rank: i32,
103 pub sort_value: Option<f64>,
104 pub url: String,
105 pub raw_updated: Option<String>,
106}
107
108#[derive(Debug, Clone)]
110pub struct ChangedIssue {
111 pub issue_key: String,
112 pub change: String,
114 pub dismissed: bool,
115}
116
117#[derive(Debug, Default)]
119pub struct UpsertBatchResult {
120 pub new_keys: Vec<String>,
121 pub updated: usize,
122 pub changed: Vec<ChangedIssue>,
123}
124
125pub struct JiraInbox {
127 db: Db,
128}
129
130impl JiraInbox {
131 pub fn new() -> Result<Self> {
132 Ok(Self { db: Db::new()? })
133 }
134
135 pub fn upsert_batch(&self, items: &[JiraInboxUpsert]) -> Result<UpsertBatchResult> {
141 let now = Local::now().naive_local();
142 let mut result = UpsertBatchResult::default();
143
144 for item in items {
145 let existing: Option<ExistingRow> = self
146 .db
147 .conn
148 .query_row(
149 "SELECT status_id, priority, priority_rank, sort_value, gone_at, dismissed
150 FROM jira_inbox WHERE issue_key = ?1",
151 params![item.issue_key],
152 |row| {
153 Ok(ExistingRow {
154 status_id: row.get(0)?,
155 priority: row.get(1)?,
156 priority_rank: row.get(2)?,
157 sort_value: row.get(3)?,
158 gone_at: row.get(4)?,
159 dismissed: row.get::<_, i32>(5)? != 0,
160 })
161 },
162 )
163 .optional()?;
164
165 if let Some(old) = existing {
166 self.db.conn.execute(
167 "UPDATE jira_inbox SET
168 issue_id = ?1,
169 summary = ?2,
170 status_id = ?3,
171 priority = ?4,
172 priority_rank = ?5,
173 sort_value = ?6,
174 url = ?7,
175 last_seen = ?8,
176 raw_updated = ?9,
177 gone_at = NULL
178 WHERE issue_key = ?10",
179 params![
180 item.issue_id,
181 item.summary,
182 item.status_id,
183 item.priority,
184 item.priority_rank,
185 item.sort_value,
186 item.url,
187 now,
188 item.raw_updated,
189 item.issue_key,
190 ],
191 )?;
192 result.updated += 1;
193
194 let change = describe_change(&old, item);
195 if let Some(change) = change {
196 self.db.conn.execute(
197 "UPDATE jira_inbox SET last_change = ?1, changed_at = ?2 WHERE issue_key = ?3",
198 params![change, now, item.issue_key],
199 )?;
200 result.changed.push(ChangedIssue {
201 issue_key: item.issue_key.clone(),
202 change,
203 dismissed: old.dismissed,
204 });
205 }
206 } else {
207 self.db.conn.execute(
208 "INSERT INTO jira_inbox (
209 issue_key, issue_id, summary, status_id, priority, priority_rank,
210 sort_value, url, first_seen, last_seen, notified, pinned, dismissed, raw_updated
211 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 0, 0, 0, ?11)",
212 params![
213 item.issue_key,
214 item.issue_id,
215 item.summary,
216 item.status_id,
217 item.priority,
218 item.priority_rank,
219 item.sort_value,
220 item.url,
221 now,
222 now,
223 item.raw_updated,
224 ],
225 )?;
226 result.new_keys.push(item.issue_key.clone());
227 }
228 }
229
230 Ok(result)
231 }
232
233 pub fn mark_gone(&self, present_keys: &[String]) -> Result<Vec<String>> {
239 let now = Local::now().naive_local();
240 let placeholders = vec!["?"; present_keys.len()].join(", ");
241 let not_in = if present_keys.is_empty() {
242 String::new()
243 } else {
244 format!(" AND issue_key NOT IN ({placeholders})")
245 };
246
247 let select = format!("SELECT issue_key FROM jira_inbox WHERE gone_at IS NULL AND dismissed = 0{not_in}");
248 let mut stmt = self.db.conn.prepare(&select)?;
249 let newly_gone: Vec<String> = stmt
250 .query_map(rusqlite::params_from_iter(present_keys.iter()), |row| row.get(0))?
251 .collect::<Result<Vec<_>, _>>()?;
252 drop(stmt);
253
254 let update = format!("UPDATE jira_inbox SET gone_at = ?1 WHERE gone_at IS NULL{not_in}");
255 let mut update_params: Vec<&dyn rusqlite::types::ToSql> = vec![&now];
256 for key in present_keys {
257 update_params.push(key);
258 }
259 self.db.conn.execute(&update, &update_params[..])?;
260
261 Ok(newly_gone)
262 }
263
264 pub fn list_active(&self, include_gone: bool) -> Result<Vec<JiraInboxItem>> {
269 let gone_filter = if include_gone { "" } else { " AND i.gone_at IS NULL" };
270 let query = format!(
271 "SELECT i.issue_key, i.issue_id, i.summary, i.status_id, COALESCE(s.name, ''),
272 i.priority, i.priority_rank, i.sort_value, i.url,
273 i.first_seen, i.last_seen, i.notified, i.pinned, i.dismissed, i.raw_updated,
274 i.gone_at, i.last_change, i.changed_at, i.taken_at
275 FROM jira_inbox i
276 LEFT JOIN jira_statuses s ON s.id = i.status_id
277 WHERE i.dismissed = 0{gone_filter}
278 ORDER BY i.gone_at IS NOT NULL, i.pinned DESC, i.sort_value IS NULL, i.sort_value DESC,
279 i.priority_rank ASC, i.last_seen DESC"
280 );
281 let mut stmt = self.db.conn.prepare(&query)?;
282
283 let rows = stmt.query_map([], map_row)?;
284 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
285 }
286
287 pub fn get_by_key(&self, key: &str) -> Result<Option<JiraInboxItem>> {
288 self.db
289 .conn
290 .query_row(
291 "SELECT i.issue_key, i.issue_id, i.summary, i.status_id, COALESCE(s.name, ''),
292 i.priority, i.priority_rank, i.sort_value, i.url,
293 i.first_seen, i.last_seen, i.notified, i.pinned, i.dismissed, i.raw_updated,
294 i.gone_at, i.last_change, i.changed_at, i.taken_at
295 FROM jira_inbox i
296 LEFT JOIN jira_statuses s ON s.id = i.status_id
297 WHERE i.issue_key = ?1",
298 params![key],
299 map_row,
300 )
301 .optional()
302 .map_err(Into::into)
303 }
304
305 pub fn set_pinned(&self, key: &str, pinned: bool) -> Result<bool> {
306 let n = self
307 .db
308 .conn
309 .execute("UPDATE jira_inbox SET pinned = ?1 WHERE issue_key = ?2", params![pinned as i32, key])?;
310 Ok(n > 0)
311 }
312
313 pub fn set_dismissed(&self, key: &str, dismissed: bool) -> Result<bool> {
314 let n = self
315 .db
316 .conn
317 .execute("UPDATE jira_inbox SET dismissed = ?1 WHERE issue_key = ?2", params![dismissed as i32, key])?;
318 Ok(n > 0)
319 }
320
321 pub fn counts(&self) -> Result<InboxCounts> {
326 let now = Local::now().naive_local();
327 let fresh_since = now - Duration::hours(FRESH_BADGE_HOURS);
328 self.db
329 .conn
330 .query_row(
331 "SELECT COUNT(*),
332 COUNT(taken_at),
333 SUM(CASE WHEN taken_at IS NULL AND first_seen >= ?1 THEN 1 ELSE 0 END)
334 FROM jira_inbox
335 WHERE dismissed = 0 AND gone_at IS NULL",
336 params![fresh_since],
337 |row| {
338 Ok(InboxCounts {
339 total: row.get(0)?,
340 taken: row.get(1)?,
341 fresh: row.get::<_, Option<i64>>(2)?.unwrap_or(0),
342 })
343 },
344 )
345 .map_err(Into::into)
346 }
347
348 pub fn set_taken(&self, key: &str, taken: bool) -> Result<bool> {
353 let value = taken.then(|| Local::now().naive_local());
354 let n = self
355 .db
356 .conn
357 .execute("UPDATE jira_inbox SET taken_at = ?1 WHERE issue_key = ?2", params![value, key])?;
358 Ok(n > 0)
359 }
360
361 pub fn mark_notified(&self, keys: &[String]) -> Result<()> {
362 for key in keys {
363 self.db.conn.execute("UPDATE jira_inbox SET notified = 1 WHERE issue_key = ?1", params![key])?;
364 }
365 Ok(())
366 }
367
368 pub fn list_unnotified_new(&self, keys: &[String]) -> Result<Vec<JiraInboxItem>> {
370 let mut items = Vec::new();
371 for key in keys {
372 if let Some(item) = self.get_by_key(key)?
373 && !item.notified
374 && !item.dismissed
375 {
376 items.push(item);
377 }
378 }
379 Ok(items)
380 }
381}
382
383struct ExistingRow {
385 status_id: Option<String>,
386 priority: Option<String>,
387 priority_rank: i32,
388 sort_value: Option<f64>,
389 gone_at: Option<NaiveDateTime>,
390 dismissed: bool,
391}
392
393fn describe_change(old: &ExistingRow, new: &JiraInboxUpsert) -> Option<String> {
395 let mut parts = Vec::new();
396
397 if old.gone_at.is_some() {
398 parts.push("back".to_string());
399 }
400
401 if old.status_id != new.status_id {
402 let name = if new.status_name.is_empty() {
403 new.status_id.as_deref().unwrap_or("—")
404 } else {
405 &new.status_name
406 };
407 parts.push(format!("status→{name}"));
408 }
409
410 if old.priority_rank != new.priority_rank || old.priority != new.priority {
411 let name = new.priority.as_deref().unwrap_or("—");
412 let arrow = if new.priority_rank < old.priority_rank {
414 "↑prio"
415 } else if new.priority_rank > old.priority_rank {
416 "↓prio"
417 } else {
418 "prio→"
419 };
420 parts.push(format!("{arrow} {name}"));
421 }
422
423 let score_changed = match (old.sort_value, new.sort_value) {
424 (Some(a), Some(b)) => (a - b).abs() > f64::EPSILON,
425 (None, None) => false,
426 _ => true,
427 };
428 if score_changed {
429 let fmt = |v: Option<f64>| v.map(fmt_score).unwrap_or_else(|| "—".to_string());
430 parts.push(format!("score {}→{}", fmt(old.sort_value), fmt(new.sort_value)));
431 }
432
433 if parts.is_empty() { None } else { Some(parts.join(", ")) }
434}
435
436fn fmt_score(v: f64) -> String {
438 if v.fract() == 0.0 { format!("{}", v as i64) } else { format!("{v}") }
439}
440
441fn map_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<JiraInboxItem> {
442 Ok(JiraInboxItem {
443 issue_key: row.get(0)?,
444 issue_id: row.get(1)?,
445 summary: row.get(2)?,
446 status_id: row.get(3)?,
447 status_name: row.get(4)?,
448 priority: row.get(5)?,
449 priority_rank: row.get(6)?,
450 sort_value: row.get(7)?,
451 url: row.get(8)?,
452 first_seen: row.get(9)?,
453 last_seen: row.get(10)?,
454 notified: row.get::<_, i32>(11)? != 0,
455 pinned: row.get::<_, i32>(12)? != 0,
456 dismissed: row.get::<_, i32>(13)? != 0,
457 raw_updated: row.get(14)?,
458 gone_at: row.get(15)?,
459 last_change: row.get(16)?,
460 changed_at: row.get(17)?,
461 taken_at: row.get(18)?,
462 })
463}