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}
42
43impl JiraInboxItem {
44 pub fn badge(&self, now: NaiveDateTime) -> Option<String> {
50 let fresh = |t: NaiveDateTime| now.signed_duration_since(t) < Duration::hours(FRESH_BADGE_HOURS);
51 if self.gone_at.is_some() {
52 return Some("gone".to_string());
53 }
54 if fresh(self.first_seen) {
55 return Some("NEW".to_string());
56 }
57 match (&self.last_change, self.changed_at) {
58 (Some(change), Some(at)) if fresh(at) => Some(change.clone()),
59 _ => None,
60 }
61 }
62}
63
64#[derive(Debug, Clone)]
66pub struct JiraInboxUpsert {
67 pub issue_key: String,
68 pub issue_id: String,
69 pub summary: String,
70 pub status_id: Option<String>,
71 pub status_name: String,
73 pub priority: Option<String>,
74 pub priority_rank: i32,
75 pub sort_value: Option<f64>,
76 pub url: String,
77 pub raw_updated: Option<String>,
78}
79
80#[derive(Debug, Clone)]
82pub struct ChangedIssue {
83 pub issue_key: String,
84 pub change: String,
86 pub dismissed: bool,
87}
88
89#[derive(Debug, Default)]
91pub struct UpsertBatchResult {
92 pub new_keys: Vec<String>,
93 pub updated: usize,
94 pub changed: Vec<ChangedIssue>,
95}
96
97pub struct JiraInbox {
99 db: Db,
100}
101
102impl JiraInbox {
103 pub fn new() -> Result<Self> {
104 Ok(Self { db: Db::new()? })
105 }
106
107 pub fn upsert_batch(&self, items: &[JiraInboxUpsert]) -> Result<UpsertBatchResult> {
113 let now = Local::now().naive_local();
114 let mut result = UpsertBatchResult::default();
115
116 for item in items {
117 let existing: Option<ExistingRow> = self
118 .db
119 .conn
120 .query_row(
121 "SELECT status_id, priority, priority_rank, sort_value, gone_at, dismissed
122 FROM jira_inbox WHERE issue_key = ?1",
123 params![item.issue_key],
124 |row| {
125 Ok(ExistingRow {
126 status_id: row.get(0)?,
127 priority: row.get(1)?,
128 priority_rank: row.get(2)?,
129 sort_value: row.get(3)?,
130 gone_at: row.get(4)?,
131 dismissed: row.get::<_, i32>(5)? != 0,
132 })
133 },
134 )
135 .optional()?;
136
137 if let Some(old) = existing {
138 self.db.conn.execute(
139 "UPDATE jira_inbox SET
140 issue_id = ?1,
141 summary = ?2,
142 status_id = ?3,
143 priority = ?4,
144 priority_rank = ?5,
145 sort_value = ?6,
146 url = ?7,
147 last_seen = ?8,
148 raw_updated = ?9,
149 gone_at = NULL
150 WHERE issue_key = ?10",
151 params![
152 item.issue_id,
153 item.summary,
154 item.status_id,
155 item.priority,
156 item.priority_rank,
157 item.sort_value,
158 item.url,
159 now,
160 item.raw_updated,
161 item.issue_key,
162 ],
163 )?;
164 result.updated += 1;
165
166 let change = describe_change(&old, item);
167 if let Some(change) = change {
168 self.db.conn.execute(
169 "UPDATE jira_inbox SET last_change = ?1, changed_at = ?2 WHERE issue_key = ?3",
170 params![change, now, item.issue_key],
171 )?;
172 result.changed.push(ChangedIssue {
173 issue_key: item.issue_key.clone(),
174 change,
175 dismissed: old.dismissed,
176 });
177 }
178 } else {
179 self.db.conn.execute(
180 "INSERT INTO jira_inbox (
181 issue_key, issue_id, summary, status_id, priority, priority_rank,
182 sort_value, url, first_seen, last_seen, notified, pinned, dismissed, raw_updated
183 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 0, 0, 0, ?11)",
184 params![
185 item.issue_key,
186 item.issue_id,
187 item.summary,
188 item.status_id,
189 item.priority,
190 item.priority_rank,
191 item.sort_value,
192 item.url,
193 now,
194 now,
195 item.raw_updated,
196 ],
197 )?;
198 result.new_keys.push(item.issue_key.clone());
199 }
200 }
201
202 Ok(result)
203 }
204
205 pub fn mark_gone(&self, present_keys: &[String]) -> Result<Vec<String>> {
211 let now = Local::now().naive_local();
212 let placeholders = vec!["?"; present_keys.len()].join(", ");
213 let not_in = if present_keys.is_empty() {
214 String::new()
215 } else {
216 format!(" AND issue_key NOT IN ({placeholders})")
217 };
218
219 let select = format!("SELECT issue_key FROM jira_inbox WHERE gone_at IS NULL AND dismissed = 0{not_in}");
220 let mut stmt = self.db.conn.prepare(&select)?;
221 let newly_gone: Vec<String> = stmt
222 .query_map(rusqlite::params_from_iter(present_keys.iter()), |row| row.get(0))?
223 .collect::<Result<Vec<_>, _>>()?;
224 drop(stmt);
225
226 let update = format!("UPDATE jira_inbox SET gone_at = ?1 WHERE gone_at IS NULL{not_in}");
227 let mut update_params: Vec<&dyn rusqlite::types::ToSql> = vec![&now];
228 for key in present_keys {
229 update_params.push(key);
230 }
231 self.db.conn.execute(&update, &update_params[..])?;
232
233 Ok(newly_gone)
234 }
235
236 pub fn list_active(&self, include_gone: bool) -> Result<Vec<JiraInboxItem>> {
241 let gone_filter = if include_gone { "" } else { " AND i.gone_at IS NULL" };
242 let query = format!(
243 "SELECT i.issue_key, i.issue_id, i.summary, i.status_id, COALESCE(s.name, ''),
244 i.priority, i.priority_rank, i.sort_value, i.url,
245 i.first_seen, i.last_seen, i.notified, i.pinned, i.dismissed, i.raw_updated,
246 i.gone_at, i.last_change, i.changed_at
247 FROM jira_inbox i
248 LEFT JOIN jira_statuses s ON s.id = i.status_id
249 WHERE i.dismissed = 0{gone_filter}
250 ORDER BY i.gone_at IS NOT NULL, i.pinned DESC, i.sort_value IS NULL, i.sort_value DESC,
251 i.priority_rank ASC, i.last_seen DESC"
252 );
253 let mut stmt = self.db.conn.prepare(&query)?;
254
255 let rows = stmt.query_map([], map_row)?;
256 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
257 }
258
259 pub fn get_by_key(&self, key: &str) -> Result<Option<JiraInboxItem>> {
260 self.db
261 .conn
262 .query_row(
263 "SELECT i.issue_key, i.issue_id, i.summary, i.status_id, COALESCE(s.name, ''),
264 i.priority, i.priority_rank, i.sort_value, i.url,
265 i.first_seen, i.last_seen, i.notified, i.pinned, i.dismissed, i.raw_updated,
266 i.gone_at, i.last_change, i.changed_at
267 FROM jira_inbox i
268 LEFT JOIN jira_statuses s ON s.id = i.status_id
269 WHERE i.issue_key = ?1",
270 params![key],
271 map_row,
272 )
273 .optional()
274 .map_err(Into::into)
275 }
276
277 pub fn set_pinned(&self, key: &str, pinned: bool) -> Result<bool> {
278 let n = self
279 .db
280 .conn
281 .execute("UPDATE jira_inbox SET pinned = ?1 WHERE issue_key = ?2", params![pinned as i32, key])?;
282 Ok(n > 0)
283 }
284
285 pub fn set_dismissed(&self, key: &str, dismissed: bool) -> Result<bool> {
286 let n = self
287 .db
288 .conn
289 .execute("UPDATE jira_inbox SET dismissed = ?1 WHERE issue_key = ?2", params![dismissed as i32, key])?;
290 Ok(n > 0)
291 }
292
293 pub fn mark_notified(&self, keys: &[String]) -> Result<()> {
294 for key in keys {
295 self.db.conn.execute("UPDATE jira_inbox SET notified = 1 WHERE issue_key = ?1", params![key])?;
296 }
297 Ok(())
298 }
299
300 pub fn list_unnotified_new(&self, keys: &[String]) -> Result<Vec<JiraInboxItem>> {
302 let mut items = Vec::new();
303 for key in keys {
304 if let Some(item) = self.get_by_key(key)?
305 && !item.notified
306 && !item.dismissed
307 {
308 items.push(item);
309 }
310 }
311 Ok(items)
312 }
313}
314
315struct ExistingRow {
317 status_id: Option<String>,
318 priority: Option<String>,
319 priority_rank: i32,
320 sort_value: Option<f64>,
321 gone_at: Option<NaiveDateTime>,
322 dismissed: bool,
323}
324
325fn describe_change(old: &ExistingRow, new: &JiraInboxUpsert) -> Option<String> {
327 let mut parts = Vec::new();
328
329 if old.gone_at.is_some() {
330 parts.push("back".to_string());
331 }
332
333 if old.status_id != new.status_id {
334 let name = if new.status_name.is_empty() {
335 new.status_id.as_deref().unwrap_or("—")
336 } else {
337 &new.status_name
338 };
339 parts.push(format!("status→{name}"));
340 }
341
342 if old.priority_rank != new.priority_rank || old.priority != new.priority {
343 let name = new.priority.as_deref().unwrap_or("—");
344 let arrow = if new.priority_rank < old.priority_rank {
346 "↑prio"
347 } else if new.priority_rank > old.priority_rank {
348 "↓prio"
349 } else {
350 "prio→"
351 };
352 parts.push(format!("{arrow} {name}"));
353 }
354
355 let score_changed = match (old.sort_value, new.sort_value) {
356 (Some(a), Some(b)) => (a - b).abs() > f64::EPSILON,
357 (None, None) => false,
358 _ => true,
359 };
360 if score_changed {
361 let fmt = |v: Option<f64>| v.map(fmt_score).unwrap_or_else(|| "—".to_string());
362 parts.push(format!("score {}→{}", fmt(old.sort_value), fmt(new.sort_value)));
363 }
364
365 if parts.is_empty() { None } else { Some(parts.join(", ")) }
366}
367
368fn fmt_score(v: f64) -> String {
370 if v.fract() == 0.0 { format!("{}", v as i64) } else { format!("{v}") }
371}
372
373fn map_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<JiraInboxItem> {
374 Ok(JiraInboxItem {
375 issue_key: row.get(0)?,
376 issue_id: row.get(1)?,
377 summary: row.get(2)?,
378 status_id: row.get(3)?,
379 status_name: row.get(4)?,
380 priority: row.get(5)?,
381 priority_rank: row.get(6)?,
382 sort_value: row.get(7)?,
383 url: row.get(8)?,
384 first_seen: row.get(9)?,
385 last_seen: row.get(10)?,
386 notified: row.get::<_, i32>(11)? != 0,
387 pinned: row.get::<_, i32>(12)? != 0,
388 dismissed: row.get::<_, i32>(13)? != 0,
389 raw_updated: row.get(14)?,
390 gone_at: row.get(15)?,
391 last_change: row.get(16)?,
392 changed_at: row.get(17)?,
393 })
394}