diff --git a/crates/vibescrobble-index/src/view.rs b/crates/vibescrobble-index/src/view.rs index 3559215..103e2f7 100644 --- a/crates/vibescrobble-index/src/view.rs +++ b/crates/vibescrobble-index/src/view.rs @@ -1645,10 +1645,14 @@ impl View { let oldest = held.iter().map(|held| held.created_at.as_str()).min(); let too_old = held.len() >= HISTORY && oldest.is_some_and(|oldest| scrobble.created_at.as_str() <= oldest); - if too_old || held.iter().any(|held| held.uri == scrobble.uri) { + // A record read again is counted at the time it was first held: + // one dated ahead is held at the time it was read, which differs + // with every read and may fall in a later week. + let again = held.iter().find(|held| held.uri == scrobble.uri).cloned(); + if too_old || again.is_some() { // Not drawn, but perhaps not counted yet: a gap read back. if count { - self.tally(&scrobble); + self.tally(again.as_ref().unwrap_or(&scrobble)); } return Vec::new(); } diff --git a/crates/vibescrobble-index/tests/tally.rs b/crates/vibescrobble-index/tests/tally.rs index b3f3267..5d32c98 100644 --- a/crates/vibescrobble-index/tests/tally.rs +++ b/crates/vibescrobble-index/tests/tally.rs @@ -336,3 +336,40 @@ fn a_released_account_still_counts_and_is_counted_once_when_it_returns() { Some(3) ); } + +#[test] +fn a_line_dated_ahead_is_counted_once_however_late_it_is_read_again() { + // Kiln's clock runs ahead: its line is held at the time it was read, + // Tuesday, on a ledger file. + let path = scratch("ahead"); + let (mut index, _) = index_and_mirror_in(Store::disk(&path).expect("a store")); + let ahead = said("kiln", "3f", "2026-10-13T10:00:00Z", "⏩", "ahead of time"); + assert!(!index.see_scrobble(ahead.clone()).is_empty()); + let counted = index.tallied(); + let kiln = "did:web:kiln.agents.example"; + let tuesday = index.week_of(kiln, week()).expect("its week"); + index.flush_ledgers(); + let kept = index.keep(); + drop(index); + + // Restarted, and the same record read again the week after, before the + // time it claims: held at that read's time instead, a week on. + let mut restarted = View::new(); + restarted.use_ledgers(Store::disk(&path).expect("reopened")); + restarted.restore(kept, |_| true); + let next = week().next(); + let reread = Scrobble { + created_at: "2026-10-20T10:00:00Z".to_owned(), + ..ahead + }; + assert!(restarted.see_scrobble(reread).is_empty(), "drawn once"); + let new = said("kiln", "3g", "2026-10-20T10:05:00Z", "🧪", "the next one"); + assert!(!restarted.see_scrobble(new).is_empty()); + assert_eq!(restarted.tallied(), counted + 1, "only the new line"); + assert_eq!(restarted.week_of(kiln, week()), Some(tuesday)); + assert_eq!( + restarted.week_of(kiln, next).map(|w| w.scrobbles), + Some(1), + "the line ahead is not counted again in the week it was reread" + ); +}