Skip to content

Commit 2bc8232

Browse files
committed
Fixed statistics filter to be per book per day instead of just per day
1 parent 5935527 commit 2bc8232

File tree

5 files changed

+146
-129
lines changed

5 files changed

+146
-129
lines changed

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "koshelf"
3-
version = "1.2.0"
3+
version = "1.2.1"
44
description = "Transform your KOReader library into a beautiful reading dashboard with statistics."
55
repository = "https://github.com/paviro/KOShelf"
66
license = "EUPL-1.2 license"

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -208,9 +208,9 @@ KoShelf can operate in several modes:
208208
- `--heatmap-scale-max`: Maximum value for heatmap color intensity scaling (e.g., "auto", "1h", "1h30m", "45min"). Values above this will still be shown but use the highest color intensity. Default is "auto" for automatic scaling
209209
- `--timezone`: Timezone to interpret timestamps (IANA name, e.g., `Australia/Sydney`); defaults to system local
210210
- `--day-start-time`: Logical day start time as `HH:MM` (default: `00:00`)
211-
- `--min-pages-per-day`: Minimum pages read per day to be counted in statistics (optional)
212-
- `--min-time-per-day`: Minimum reading time per day to be counted in statistics (e.g., "15m", "1h") (optional)
213-
> **Note:** If both `--min-pages-per-day` and `--min-time-per-day` are provided, a day is counted if **either** condition is met.
211+
- `--min-pages-per-day`: Minimum pages read per book per day to be counted in statistics (optional)
212+
- `--min-time-per-day`: Minimum reading time per book per day to be counted in statistics (e.g., "15m", "1h") (optional)
213+
> **Note:** If both `--min-pages-per-day` and `--min-time-per-day` are provided, a book's data for a day is counted if **either** condition is met for that book on that day. These filters apply **per book per day**, meaning each book must individually meet the threshold for each day to be included in statistics.
214214
- `--github`: Print GitHub repository URL
215215

216216
### Example

src/statistics.rs

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -332,39 +332,40 @@ impl StatisticsCalculator {
332332

333333
(total_completions, books_completed, most_completions)
334334
}
335-
/// Filter statistics to exclude days that don't meet the minimum requirements
335+
/// Filter statistics to exclude days that don't meet the minimum requirements per book
336336
pub fn filter_stats(
337337
stats_data: &mut StatisticsData,
338338
time_config: &TimeConfig,
339339
min_pages: Option<u32>,
340340
min_time: Option<u32>
341341
) {
342-
// 1. Aggregate daily totals
343-
let mut daily_pages: HashMap<String, i64> = HashMap::new();
344-
let mut daily_time: HashMap<String, i64> = HashMap::new();
342+
// 1. Aggregate daily totals per book per day
343+
let mut daily_book_pages: HashMap<(i64, String), i64> = HashMap::new();
344+
let mut daily_book_time: HashMap<(i64, String), i64> = HashMap::new();
345345

346346
for stat in &stats_data.page_stats {
347347
if stat.duration <= 0 { continue; }
348348

349349
let date = time_config.date_for_timestamp(stat.start_time);
350350
let date_str = date.format("%Y-%m-%d").to_string();
351+
let key = (stat.id_book, date_str);
351352

352-
*daily_pages.entry(date_str.clone()).or_insert(0) += 1;
353-
*daily_time.entry(date_str).or_insert(0) += stat.duration;
353+
*daily_book_pages.entry(key.clone()).or_insert(0) += 1;
354+
*daily_book_time.entry(key).or_insert(0) += stat.duration;
354355
}
355356

356-
// 2. Identify valid dates
357-
let mut valid_dates: HashMap<String, bool> = HashMap::new();
357+
// 2. Identify valid (book, date) combinations
358+
let mut valid_book_dates: HashMap<(i64, String), bool> = HashMap::new();
358359

359-
// Collect all unique dates
360-
let mut all_dates: Vec<String> = daily_pages.keys().cloned().collect();
361-
all_dates.extend(daily_time.keys().cloned());
362-
all_dates.sort();
363-
all_dates.dedup();
360+
// Collect all unique (book, date) combinations
361+
let mut all_book_dates: Vec<(i64, String)> = daily_book_pages.keys().cloned().collect();
362+
all_book_dates.extend(daily_book_time.keys().cloned());
363+
all_book_dates.sort();
364+
all_book_dates.dedup();
364365

365-
for date in all_dates {
366-
let pages = *daily_pages.get(&date).unwrap_or(&0);
367-
let time = *daily_time.get(&date).unwrap_or(&0);
366+
for book_date in all_book_dates {
367+
let pages = *daily_book_pages.get(&book_date).unwrap_or(&0);
368+
let time = *daily_book_time.get(&book_date).unwrap_or(&0);
368369

369370
let pages_ok = min_pages.map_or(false, |min| pages >= min as i64);
370371
let time_ok = min_time.map_or(false, |min| time >= min as i64);
@@ -378,15 +379,16 @@ impl StatisticsCalculator {
378379
time_ok
379380
};
380381

381-
valid_dates.insert(date, is_valid);
382+
valid_book_dates.insert(book_date, is_valid);
382383
}
383384

384-
// 3. Filter page_stats
385+
// 3. Filter page_stats based on per-book-per-day validity
385386
stats_data.page_stats.retain(|stat| {
386387
if stat.duration <= 0 { return false; }
387388
let date = time_config.date_for_timestamp(stat.start_time);
388389
let date_str = date.format("%Y-%m-%d").to_string();
389-
*valid_dates.get(&date_str).unwrap_or(&false)
390+
let key = (stat.id_book, date_str);
391+
*valid_book_dates.get(&key).unwrap_or(&false)
390392
});
391393
}
392394
}

src/tests/statistics.rs

Lines changed: 120 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -3,133 +3,148 @@ use crate::statistics::StatisticsCalculator;
33
use crate::time_config::TimeConfig;
44
use std::collections::HashMap;
55

6-
fn create_mock_stats() -> StatisticsData {
7-
// Create 3 days of data:
8-
// Day 1 (2023-01-01): 5 pages, 10 mins (600s)
9-
// Day 2 (2023-01-02): 20 pages, 5 mins (300s)
10-
// Day 3 (2023-01-03): 2 pages, 60 mins (3600s)
11-
6+
#[test]
7+
fn test_filter_stats_per_book_per_day() {
8+
// Comprehensive test for per-book-per-day filtering
9+
// Tests multiple scenarios:
10+
// - Day 1: Book 1 (10 pages, 600s) passes pages threshold, Book 2 (5 pages, 300s) fails both
11+
// - Day 2: Book 1 (2 pages, 3600s) passes time threshold, Book 2 (15 pages, 200s) passes pages threshold
12+
// - Day 3: Book 1 (3 pages, 100s) fails both, Book 2 (4 pages, 500s) fails both
13+
1214
let day1_ts = 1672531200; // 2023-01-01 00:00:00 UTC
1315
let day2_ts = 1672617600; // 2023-01-02 00:00:00 UTC
1416
let day3_ts = 1672704000; // 2023-01-03 00:00:00 UTC
15-
1617
let mut page_stats = Vec::new();
17-
18-
// Day 1: 5 pages, 10 mins
19-
for i in 0..5 {
18+
19+
// Day 1: Book 1 - 10 pages, 600s (passes pages threshold)
20+
for i in 0..10 {
2021
page_stats.push(PageStat {
2122
id_book: 1,
2223
page: i,
23-
start_time: day1_ts + i * 60, // 1 min per page roughly
24-
duration: 120, // 2 mins per page -> total 10 mins
24+
start_time: day1_ts + i * 60,
25+
duration: 60,
2526
});
2627
}
27-
28-
// Day 2: 20 pages, 5 mins
29-
for i in 0..20 {
28+
29+
// Day 1: Book 2 - 5 pages, 300s (fails both thresholds)
30+
for i in 0..5 {
3031
page_stats.push(PageStat {
31-
id_book: 1,
32+
id_book: 2,
3233
page: i,
33-
start_time: day2_ts + i * 10,
34-
duration: 15, // 15s per page -> total 300s (5 mins)
34+
start_time: day1_ts + i * 60,
35+
duration: 60,
3536
});
3637
}
37-
38-
// Day 3: 2 pages, 60 mins
38+
39+
// Day 2: Book 1 - 2 pages, 3600s (passes time threshold)
3940
for i in 0..2 {
4041
page_stats.push(PageStat {
4142
id_book: 1,
42-
page: i,
43-
start_time: day3_ts + i * 1800,
44-
duration: 1800, // 30 mins per page -> total 60 mins
43+
page: i + 10,
44+
start_time: day2_ts + i * 1800,
45+
duration: 1800,
4546
});
4647
}
47-
48-
StatisticsData {
49-
books: vec![StatBook {
50-
id: 1,
51-
title: "Test Book".to_string(),
52-
authors: "Author".to_string(),
53-
notes: None,
54-
last_open: None,
55-
highlights: None,
56-
pages: None,
57-
md5: "abc".to_string(),
58-
total_read_time: None,
59-
total_read_pages: None,
60-
completions: None,
61-
}],
62-
page_stats,
63-
stats_by_md5: HashMap::new(),
48+
49+
// Day 2: Book 2 - 15 pages, 200s (passes pages threshold)
50+
for i in 0..15 {
51+
page_stats.push(PageStat {
52+
id_book: 2,
53+
page: i + 5,
54+
start_time: day2_ts + i * 10,
55+
duration: 13,
56+
});
6457
}
65-
}
66-
67-
#[test]
68-
fn test_filter_stats_min_pages() {
69-
let mut data = create_mock_stats();
70-
let time_config = TimeConfig::new(None, 0); // UTC by default
71-
72-
// Filter: min 10 pages
73-
// Should keep Day 2 (20 pages), remove Day 1 (5) and Day 3 (2)
74-
StatisticsCalculator::filter_stats(&mut data, &time_config, Some(10), None);
75-
76-
let remaining_days: Vec<i64> = data.page_stats.iter().map(|s| s.start_time).collect();
77-
assert!(!remaining_days.is_empty());
78-
79-
for ts in remaining_days {
80-
// All timestamps should be from Day 2
81-
assert!(ts >= 1672617600 && ts < 1672704000);
58+
59+
// Day 3: Book 1 - 3 pages, 100s (fails both)
60+
for i in 0..3 {
61+
page_stats.push(PageStat {
62+
id_book: 1,
63+
page: i + 12,
64+
start_time: day3_ts + i * 30,
65+
duration: 33,
66+
});
8267
}
83-
}
84-
85-
#[test]
86-
fn test_filter_stats_min_time() {
87-
let mut data = create_mock_stats();
88-
let time_config = TimeConfig::new(None, 0);
89-
90-
// Filter: min 30 mins (1800s)
91-
// Should keep Day 3 (60 mins), remove Day 1 (10 mins) and Day 2 (5 mins)
92-
StatisticsCalculator::filter_stats(&mut data, &time_config, None, Some(1800));
93-
94-
let remaining_days: Vec<i64> = data.page_stats.iter().map(|s| s.start_time).collect();
95-
assert!(!remaining_days.is_empty());
96-
97-
for ts in remaining_days {
98-
// All timestamps should be from Day 3
99-
assert!(ts >= 1672704000);
68+
69+
// Day 3: Book 2 - 4 pages, 500s (fails both)
70+
for i in 0..4 {
71+
page_stats.push(PageStat {
72+
id_book: 2,
73+
page: i + 20,
74+
start_time: day3_ts + i * 100,
75+
duration: 125,
76+
});
10077
}
101-
}
102-
103-
#[test]
104-
fn test_filter_stats_or_logic() {
105-
let mut data = create_mock_stats();
78+
79+
let mut data = StatisticsData {
80+
books: vec![
81+
StatBook {
82+
id: 1,
83+
title: "Book 1".to_string(),
84+
authors: "Author 1".to_string(),
85+
notes: None,
86+
last_open: None,
87+
highlights: None,
88+
pages: None,
89+
md5: "abc".to_string(),
90+
total_read_time: None,
91+
total_read_pages: None,
92+
completions: None,
93+
},
94+
StatBook {
95+
id: 2,
96+
title: "Book 2".to_string(),
97+
authors: "Author 2".to_string(),
98+
notes: None,
99+
last_open: None,
100+
highlights: None,
101+
pages: None,
102+
md5: "def".to_string(),
103+
total_read_time: None,
104+
total_read_pages: None,
105+
completions: None,
106+
},
107+
],
108+
page_stats,
109+
stats_by_md5: HashMap::new(),
110+
};
111+
106112
let time_config = TimeConfig::new(None, 0);
107-
108-
// Filter: min 10 pages OR min 30 mins
109-
// Should keep Day 2 (20 pages) AND Day 3 (60 mins)
110-
// Should remove Day 1 (5 pages, 10 mins)
111-
StatisticsCalculator::filter_stats(&mut data, &time_config, Some(10), Some(1800));
112-
113-
let remaining_days: Vec<i64> = data.page_stats.iter().map(|s| s.start_time).collect();
114-
assert!(!remaining_days.is_empty());
115-
116-
let mut has_day2 = false;
117-
let mut has_day3 = false;
118-
let mut has_day1 = false;
119-
120-
for ts in remaining_days {
121-
if ts >= 1672531200 && ts < 1672617600 {
122-
has_day1 = true;
123-
}
124-
if ts >= 1672617600 && ts < 1672704000 {
125-
has_day2 = true;
126-
}
127-
if ts >= 1672704000 {
128-
has_day3 = true;
113+
// Filter: min 8 pages OR min 1800s (30 mins)
114+
StatisticsCalculator::filter_stats(&mut data, &time_config, Some(8), Some(1800));
115+
116+
// Expected results:
117+
// Day 1: Book 1 kept (10 pages), Book 2 filtered (5 pages, 300s)
118+
// Day 2: Book 1 kept (3600s), Book 2 kept (15 pages)
119+
// Day 3: Both filtered
120+
// Total: 10 + 2 + 15 = 27 pages
121+
122+
assert_eq!(data.page_stats.len(), 27, "Should have 27 pages remaining");
123+
124+
let mut book1_day1 = 0;
125+
let mut book1_day2 = 0;
126+
let mut book1_day3 = 0;
127+
let mut book2_day1 = 0;
128+
let mut book2_day2 = 0;
129+
let mut book2_day3 = 0;
130+
131+
for stat in &data.page_stats {
132+
match (stat.id_book, stat.start_time) {
133+
(1, ts) if ts >= day1_ts && ts < day2_ts => book1_day1 += 1,
134+
(1, ts) if ts >= day2_ts && ts < day3_ts => book1_day2 += 1,
135+
(1, ts) if ts >= day3_ts => book1_day3 += 1,
136+
(2, ts) if ts >= day1_ts && ts < day2_ts => book2_day1 += 1,
137+
(2, ts) if ts >= day2_ts && ts < day3_ts => book2_day2 += 1,
138+
(2, ts) if ts >= day3_ts => book2_day3 += 1,
139+
_ => {}
129140
}
130141
}
131-
132-
assert!(!has_day1, "Day 1 should be removed");
133-
assert!(has_day2, "Day 2 should be kept (pages)");
134-
assert!(has_day3, "Day 3 should be kept (time)");
142+
143+
assert_eq!(book1_day1, 10, "Book 1 Day 1: 10 pages (passes pages threshold)");
144+
assert_eq!(book1_day2, 2, "Book 1 Day 2: 2 pages (passes time threshold)");
145+
assert_eq!(book1_day3, 0, "Book 1 Day 3: filtered (fails both)");
146+
assert_eq!(book2_day1, 0, "Book 2 Day 1: filtered (fails both)");
147+
assert_eq!(book2_day2, 15, "Book 2 Day 2: 15 pages (passes pages threshold)");
148+
assert_eq!(book2_day3, 0, "Book 2 Day 3: filtered (fails both)");
135149
}
150+

0 commit comments

Comments
 (0)