use chrono::{DateTime, Local, NaiveDate};
use vissue_core::views::IssueRec;
use vissue_core::LogEntry;
use anyhow::{Context, Result};
const QUIET_SHOWN: usize = 3;
const QUIET_DAYS: i64 = 1;
const TITLE_SHOWN: usize = 48;
const ELLIPSIS: &str = "...";
#[must_use]
pub fn beside_sitting(issue: &str, seat: &str) -> String {
let Some(spot) = crate::issue_spot::find(issue) else {
return String::new();
};
render(spot.recs.as_slice(), seat, Some(QUIET_SHOWN))
}
#[must_use]
pub fn beside_inbox(seat: &str) -> String {
let Ok(recs) = load_tracker() else {
return String::new();
};
render(&recs, seat, Some(QUIET_SHOWN))
}
pub fn list_mine() -> Result<String> {
let recs = load_tracker().context("claims --mine: the tracker could not be read")?;
Ok(render(&recs, &crate::seat_name(), None))
}
fn load_tracker() -> Result<Vec<IssueRec>> {
let layout = vissue_core::Layout::resolve(None, None)?;
layout.require_tracker()?;
Ok(vissue_core::catalog::load_recs(&layout)?)
}
fn render(recs: &[IssueRec], seat: &str, limit: Option<usize>) -> String {
let today = Local::now().date_naive();
let rows = quiet_rows(recs, seat, today);
if rows.is_empty() {
return if limit.is_none() {
"no quiet claims\n".to_string()
} else {
String::new()
};
}
let show = limit.unwrap_or(rows.len()).min(rows.len());
let mut out = String::new();
for row in rows.iter().take(show) {
out.push_str(&format!(
"{} {} {} ljos finish {} / ljos note {} \"...\"\n",
row.id,
short_title(&row.title),
row.age,
row.id,
row.id,
));
}
if let Some(limit) = limit {
let rest = rows.len().saturating_sub(limit);
if rest > 0 {
out.push_str(&format!("+{rest} more (ljos claims --mine)\n"));
}
}
out
}
struct Quiet {
id: String,
title: String,
age: String,
day: Option<NaiveDate>,
}
fn quiet_rows(recs: &[IssueRec], seat: &str, today: NaiveDate) -> Vec<Quiet> {
let want = crate::canonical_seat_name(seat);
if want.is_empty() {
return Vec::new();
}
let mut rows: Vec<Quiet> = recs
.iter()
.filter(|rec| held_live(&rec.heading, &want))
.filter_map(|rec| {
let day = last_touch(rec);
quiet_for(day, today).then(|| Quiet {
id: rec.heading.id.clone(),
title: rec.heading.title.clone(),
age: age_words(day, today),
day,
})
})
.collect();
rows.sort_by(|a, b| a.day.cmp(&b.day).then(a.id.cmp(&b.id)));
rows
}
fn quiet_for(day: Option<NaiveDate>, today: NaiveDate) -> bool {
match day {
Some(day) => (today - day).num_days() >= QUIET_DAYS,
None => true,
}
}
fn held_live(heading: &vissue_core::IssueHeading, seat: &str) -> bool {
matches!(heading.state.as_str(), "STARTED" | "BLOCKED")
&& heading
.claimed_by()
.is_some_and(|who| crate::canonical_seat_name(who) == seat)
}
fn last_touch(rec: &IssueRec) -> Option<NaiveDate> {
let mut best = rec
.heading
.claimed_at()
.and_then(vissue_core::model::parse_stamp_date);
for entry in &rec.heading.logbook {
if !counts_as_touch(entry) {
continue;
}
let Some(day) = vissue_core::model::parse_stamp_date(&entry.timestamp) else {
continue;
};
best = Some(best.map_or(day, |have| have.max(day)));
}
if let Some(day) = deed_file_day(rec) {
best = Some(best.map_or(day, |have| have.max(day)));
}
best
}
fn counts_as_touch(entry: &LogEntry) -> bool {
if entry.is_bookkeeping() {
return false;
}
if matches!(entry.to_state.as_deref(), Some("DONE" | "CANCELLED")) {
return true;
}
if entry
.note
.as_deref()
.is_some_and(|note| !note.trim().is_empty())
{
return true;
}
let Some(text) = crate::logbook_text(entry) else {
return false;
};
let lower = text.to_ascii_lowercase();
lower.starts_with("finish") || lower.contains("deed")
}
fn deed_file_day(rec: &IssueRec) -> Option<NaiveDate> {
if rec.heading.deeds().is_empty() {
return None;
}
let name = rec.path.file_name()?.to_str()?;
if !name.contains(rec.heading.id.as_str()) {
return None;
}
let modified = rec.path.metadata().ok()?.modified().ok()?;
let stamped: DateTime<Local> = DateTime::from(modified);
Some(stamped.date_naive())
}
fn age_words(day: Option<NaiveDate>, today: NaiveDate) -> String {
let Some(day) = day else {
return "undated".to_string();
};
let then = day.format("%Y-%m-%d").to_string();
let now = today.format("%Y-%m-%d").to_string();
let age = crate::age_of(Some(&then), &now);
if age.is_empty() {
"undated".to_string()
} else {
age
}
}
fn short_title(title: &str) -> String {
let flat = title.split_whitespace().collect::<Vec<_>>().join(" ");
if flat.chars().count() <= TITLE_SHOWN {
return flat;
}
let keep = TITLE_SHOWN.saturating_sub(ELLIPSIS.chars().count());
let mut cut: String = flat.chars().take(keep).collect();
cut.push_str(ELLIPSIS);
cut
}