Skip to content

Commit 6083634

Browse files
committed
Added yearly summary to recap & small overall UI improvements
1 parent b3d4776 commit 6083634

File tree

12 files changed

+433
-125
lines changed

12 files changed

+433
-125
lines changed

assets/calendar.js

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -125,29 +125,29 @@ async function fetchMonthData(targetMonth = null) {
125125
const now = new Date();
126126
targetMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
127127
}
128-
128+
129129
// Check if month data is already cached
130130
if (monthlyDataCache.has(targetMonth)) {
131131
return monthlyDataCache.get(targetMonth);
132132
}
133-
133+
134134
// Check if this month has data available
135135
if (availableMonths.length > 0 && !availableMonths.includes(targetMonth)) {
136136
console.info(`No calendar data available for ${targetMonth}`);
137137
return { events: [], books: {} }; // Return empty data instead of null
138138
}
139-
139+
140140
const response = await fetch(`/assets/json/calendar/${targetMonth}.json`);
141141
if (!response.ok) {
142142
console.error(`Failed to load calendar data for ${targetMonth}:`, response.status);
143143
return { events: [], books: {} };
144144
}
145-
145+
146146
const calendarData = await response.json();
147-
147+
148148
// Cache the loaded data
149149
monthlyDataCache.set(targetMonth, calendarData);
150-
150+
151151
return calendarData;
152152
} catch (error) {
153153
console.error(`Error loading calendar data for ${targetMonth}:`, error);
@@ -213,14 +213,14 @@ function initializeEventCalendar(events) {
213213
datesSet: info => {
214214
const viewTitle = info.view.title;
215215
const currentMonthDate = info.view.currentStart;
216-
216+
217217
updateCalendarTitleDirect(viewTitle);
218218
updateMonthlyStats(currentMonthDate);
219-
219+
220220
// Load data for the new month if it's different from current data
221221
const newMonth = `${currentMonthDate.getFullYear()}-${String(currentMonthDate.getMonth() + 1).padStart(2, '0')}`;
222222
updateDisplayedMonth(newMonth);
223-
223+
224224
// Scroll current day into view if needed
225225
setTimeout(() => scrollCurrentDayIntoView(), 100);
226226
}
@@ -245,15 +245,15 @@ function updateCalendarTitle(currentDate) {
245245
// Update the calendar title directly with the provided title string
246246
function updateCalendarTitleDirect(title) {
247247
if (!title) return;
248-
248+
249249
// Update mobile h1 title
250-
const mobileTitle = document.querySelector('.md\\:hidden h1');
250+
const mobileTitle = document.querySelector('.lg\\:hidden h1');
251251
if (mobileTitle) {
252252
mobileTitle.textContent = title;
253253
}
254-
254+
255255
// Update desktop h2 title
256-
const desktopTitle = document.querySelector('.hidden.md\\:block');
256+
const desktopTitle = document.querySelector('.hidden.lg\\:block');
257257
if (desktopTitle) {
258258
desktopTitle.textContent = title;
259259
}
@@ -421,25 +421,25 @@ function updateMonthlyStats(currentDate) {
421421
function scrollCurrentDayIntoView() {
422422
const calendarContainer = document.querySelector('.calendar-container');
423423
const todayCell = document.querySelector('.ec-today');
424-
424+
425425
if (!calendarContainer || !todayCell) return;
426-
426+
427427
// Get the container's scroll width and visible width
428428
const containerRect = calendarContainer.getBoundingClientRect();
429429
const todayRect = todayCell.getBoundingClientRect();
430-
430+
431431
// Calculate if today is outside the visible area
432432
const containerLeft = containerRect.left;
433433
const containerRight = containerRect.right;
434434
const todayLeft = todayRect.left;
435435
const todayRight = todayRect.right;
436-
436+
437437
// If today is outside the visible area, scroll to center it
438438
if (todayLeft < containerLeft || todayRight > containerRight) {
439439
const todayCenter = todayLeft + (todayRect.width / 2);
440440
const containerCenter = containerLeft + (containerRect.width / 2);
441441
const scrollOffset = todayCenter - containerCenter;
442-
442+
443443
calendarContainer.scrollBy({
444444
left: scrollOffset,
445445
behavior: 'smooth'

assets/input.css

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,17 @@
307307
transform: translateY(-50%);
308308
}
309309
}
310+
/* Yearly summary dot stays aligned with the title, not centered */
311+
.recap-dot-top {
312+
top: 0.5rem;
313+
transform: none;
314+
}
315+
@screen md {
316+
.recap-dot-top {
317+
top: 0.75rem;
318+
transform: none;
319+
}
320+
}
310321

311322
/* Recap cover max height: slightly larger on mobile, full on md+ */
312323
.recap-cover-max {

src/models/recap.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,21 @@ pub struct MonthRecap {
3636
pub hours_read_display: String, // formatted e.g. "12h 30m"
3737
pub items: Vec<RecapItem>, // enriched completion entries (sorted by end date)
3838
}
39+
40+
/// Aggregated yearly statistics for the recap header
41+
#[derive(Debug, Clone, Serialize)]
42+
pub struct YearlySummary {
43+
pub total_books: usize,
44+
pub total_time_seconds: i64,
45+
pub total_time_days: i64,
46+
pub total_time_hours: i64,
47+
pub longest_session_hours: i64,
48+
pub longest_session_minutes: i64,
49+
pub average_session_hours: i64,
50+
pub average_session_minutes: i64,
51+
pub active_days: usize,
52+
pub active_days_percentage: f64,
53+
pub longest_streak: i64,
54+
pub best_month_name: Option<String>,
55+
pub best_month_time_display: Option<String>,
56+
}

src/site_generator.rs

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ use futures::future;
1515
use crate::calendar::CalendarGenerator;
1616
use crate::time_config::TimeConfig;
1717
use std::collections::{BTreeMap, HashMap};
18+
use chrono::Datelike;
1819

1920
pub struct SiteGenerator {
2021
output_dir: PathBuf,
@@ -863,13 +864,140 @@ impl SiteGenerator {
863864
// Latest year href for sidebar
864865
let latest_href = format!("/recap/{}/", years[0]);
865866

867+
// ------------------------------------------------------------------
868+
// Calculate Yearly Summary Stats
869+
// ------------------------------------------------------------------
870+
// 1. Basic sums from monthly data
871+
let mut total_books = 0;
872+
let mut total_time_seconds = 0;
873+
874+
for m in &monthly_vec {
875+
total_books += m.books_finished;
876+
total_time_seconds += m.hours_read_seconds;
877+
}
878+
879+
// 2. Session stats from page_stats (filtered by year)
880+
// Filter page_stats for the current year
881+
let year_page_stats: Vec<crate::models::PageStat> = stats_data.page_stats.iter()
882+
.filter(|ps| {
883+
if let Some(dt) = chrono::DateTime::from_timestamp(ps.start_time, 0) {
884+
dt.year() == *year
885+
} else {
886+
false
887+
}
888+
})
889+
.cloned()
890+
.collect();
891+
892+
// Use session_calculator to aggregate stats into valid sessions (handling gaps)
893+
let all_sessions = crate::session_calculator::aggregate_session_durations(&year_page_stats);
894+
895+
let session_count = all_sessions.len() as i64;
896+
let longest_session_duration = all_sessions.iter().max().copied().unwrap_or(0);
897+
let average_session_duration = if session_count > 0 {
898+
all_sessions.iter().sum::<i64>() / session_count
899+
} else {
900+
0
901+
};
902+
903+
// 3. Calculate active reading days for this year
904+
let year_str = format!("{}", year);
905+
let active_days: usize = reading_stats.daily_activity.iter()
906+
.filter(|day| day.date.starts_with(&year_str) && day.read_time > 0)
907+
.count();
908+
909+
// Calculate percentage based on days in the year (365 or 366 for leap years)
910+
let days_in_year = if chrono::NaiveDate::from_ymd_opt(*year, 12, 31).map_or(false, |d| d.ordinal() == 366) {
911+
366.0
912+
} else {
913+
365.0
914+
};
915+
let active_days_percentage = (active_days as f64 / days_in_year * 100.0).round();
916+
917+
// 4. Calculate longest streak within this year
918+
let mut year_reading_dates: Vec<chrono::NaiveDate> = reading_stats.daily_activity.iter()
919+
.filter(|day| day.date.starts_with(&year_str) && day.read_time > 0)
920+
.filter_map(|day| chrono::NaiveDate::parse_from_str(&day.date, "%Y-%m-%d").ok())
921+
.collect();
922+
year_reading_dates.sort();
923+
year_reading_dates.dedup();
924+
925+
let longest_streak = if year_reading_dates.is_empty() {
926+
0
927+
} else {
928+
let mut max_streak = 1i64;
929+
let mut current_streak = 1i64;
930+
for i in 1..year_reading_dates.len() {
931+
let diff = (year_reading_dates[i] - year_reading_dates[i - 1]).num_days();
932+
if diff == 1 {
933+
current_streak += 1;
934+
max_streak = max_streak.max(current_streak);
935+
} else {
936+
current_streak = 1;
937+
}
938+
}
939+
max_streak
940+
};
941+
942+
// 5. Find best month (highest reading time) in this year
943+
let best_month: Option<(String, i64)> = month_hours.iter()
944+
.filter(|(ym, _)| ym.starts_with(&year_str))
945+
.max_by_key(|(_, secs)| *secs)
946+
.map(|(ym, secs)| (ym.clone(), *secs));
947+
948+
let (best_month_name, best_month_time_display) = if let Some((ym, secs)) = best_month {
949+
if secs > 0 {
950+
let month_name = if let Ok(date) = chrono::NaiveDate::parse_from_str(&format!("{}-01", ym), "%Y-%m-%d") {
951+
Some(date.format("%B").to_string())
952+
} else {
953+
None
954+
};
955+
(month_name, Some(format_duration(secs)))
956+
} else {
957+
(None, None)
958+
}
959+
} else {
960+
(None, None)
961+
};
962+
963+
// Calculate days and hours from total time (drop minutes)
964+
let total_minutes = total_time_seconds / 60;
965+
let total_time_days = total_minutes / (24 * 60);
966+
let total_time_hours = (total_minutes % (24 * 60)) / 60;
967+
968+
// Calculate hours and minutes for session stats
969+
let longest_session_total_mins = longest_session_duration / 60;
970+
let longest_session_hours = longest_session_total_mins / 60;
971+
let longest_session_minutes = longest_session_total_mins % 60;
972+
973+
let avg_session_total_mins = average_session_duration / 60;
974+
let average_session_hours = avg_session_total_mins / 60;
975+
let average_session_minutes = avg_session_total_mins % 60;
976+
977+
let summary = crate::models::YearlySummary {
978+
total_books,
979+
total_time_seconds,
980+
total_time_days,
981+
total_time_hours,
982+
longest_session_hours,
983+
longest_session_minutes,
984+
average_session_hours,
985+
average_session_minutes,
986+
active_days,
987+
active_days_percentage,
988+
longest_streak,
989+
best_month_name,
990+
best_month_time_display,
991+
};
992+
866993
let template = crate::templates::RecapTemplate {
867994
site_title: self.site_title.clone(),
868995
year: *year,
869996
available_years: years.clone(),
870997
prev_year,
871998
next_year,
872999
monthly: monthly_vec,
1000+
summary,
8731001
version: self.get_version(),
8741002
last_updated: self.get_last_updated(),
8751003
navbar_items: self.create_navbar_items_with_recap("recap", Some(latest_href.as_str())),

src/templates.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ pub struct RecapTemplate {
3131
pub prev_year: Option<i32>,
3232
pub next_year: Option<i32>,
3333
pub monthly: Vec<MonthRecap>,
34+
pub summary: YearlySummary,
3435
pub version: String,
3536
pub last_updated: String,
3637
pub navbar_items: Vec<NavItem>,

templates/book_details/book_details.html

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,26 +11,26 @@
1111
{% include "sidebar.html" %}
1212

1313
<!-- Header - Fixed at top -->
14-
<header class="fixed top-0 left-0 right-0 md:left-64 bg-white/90 dark:bg-dark-850/75 backdrop-blur-sm border-b border-gray-200/50 dark:border-dark-700/50 px-4 md:px-6 h-[70px] md:h-[80px] z-40 flex items-center justify-between">
14+
<header class="fixed top-0 left-0 right-0 lg:left-64 bg-white/90 dark:bg-dark-850/75 backdrop-blur-sm border-b border-gray-200/50 dark:border-dark-700/50 px-4 md:px-6 h-[70px] md:h-[80px] z-40 flex items-center justify-between">
1515
<div class="flex items-center h-full min-w-0 flex-1">
1616
<!-- Mobile Title & Back Button -->
17-
<div class="md:hidden flex items-center space-x-3 min-w-0 flex-1">
17+
<div class="lg:hidden flex items-center space-x-3 min-w-0 flex-1">
1818
<button onclick="if (typeof smartBack === 'function') { smartBack(document.referrer); }" class="flex items-center space-x-2 text-primary-400 hover:text-primary-300 transition-colors cursor-pointer flex-shrink-0">
1919
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
2020
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
2121
</svg>
2222
</button>
2323
<div class="h-8 w-px bg-gray-200 dark:bg-dark-700 mx-3 md:mx-6"></div>
2424
<div class="min-w-0 flex-1">
25-
<h1 class="text-lg font-bold text-gray-900 dark:text-white truncate">{{ book.epub_info.title }}</h1>
25+
<h1 class="text-lg md:text-2xl font-bold text-gray-900 dark:text-white truncate">{{ book.epub_info.title }}</h1>
2626
{% if !book.epub_info.authors.is_empty() %}
2727
<p class="text-xs text-gray-500 dark:text-dark-300 truncate">by {{ book.epub_info.authors[0] }}</p>
2828
{% endif %}
2929
</div>
3030
</div>
3131

3232
<!-- Desktop Title -->
33-
<div class="hidden md:block min-w-0 flex-1">
33+
<div class="hidden lg:block min-w-0 flex-1">
3434
<h1 class="text-2xl font-bold text-gray-900 dark:text-white truncate">{{ book.epub_info.title }}</h1>
3535
{% if !book.epub_info.authors.is_empty() %}
3636
<p class="text-sm text-gray-500 dark:text-dark-300 truncate">by {{ book.epub_info.authors[0] }}</p>
@@ -58,7 +58,7 @@ <h1 class="text-2xl font-bold text-gray-900 dark:text-white truncate">{{ book.ep
5858
</div>
5959
</header>
6060

61-
<div class="min-h-full md:ml-64">
61+
<div class="min-h-full lg:ml-64">
6262
<!-- Main Content -->
6363
<main class="pt-[88px] md:pt-24 pb-28 md:pb-6 px-4 md:px-6 space-y-6 md:space-y-8">
6464
<!-- Book Header -->

templates/book_list/book_list.html

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,25 +11,20 @@
1111
{% include "sidebar.html" %}
1212

1313
<!-- Header - Fixed at top -->
14-
<header class="fixed top-0 left-0 right-0 md:left-64 bg-white/90 dark:bg-dark-850/95 backdrop-blur-sm border-b border-gray-200/50 dark:border-dark-700/50 px-4 md:px-6 h-[70px] md:h-[80px] z-40">
14+
<header class="fixed top-0 left-0 right-0 lg:left-64 bg-white/90 dark:bg-dark-850/95 backdrop-blur-sm border-b border-gray-200/50 dark:border-dark-700/50 px-4 md:px-6 h-[70px] md:h-[80px] z-40">
1515
<div class="flex items-center justify-between h-full">
1616
<!-- Mobile Logo/Title (hidden when search is active) -->
17-
<div id="mobileTitle" class="md:hidden flex items-center space-x-3">
18-
<div class="w-6 h-6 bg-gradient-to-br from-primary-400 to-primary-600 rounded-lg flex items-center justify-center">
19-
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
20-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.746 0 3.332.477 4.5 1.253v13C19.832 18.477 18.246 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path>
21-
</svg>
22-
</div>
23-
<h1 class="text-lg font-bold text-gray-900 dark:text-white truncate">Books</h1>
17+
<div id="mobileTitle" class="lg:hidden flex items-center">
18+
<h1 class="text-lg md:text-2xl font-bold text-gray-900 dark:text-white truncate">Books</h1>
2419
</div>
2520

2621
<!-- Mobile Search Input (hidden by default, shown when search is active) -->
27-
<div id="mobileSearchContainer" class="md:hidden flex-1 mr-3 hidden">
22+
<div id="mobileSearchContainer" class="lg:hidden flex-1 mr-3 hidden">
2823
<input type="text" placeholder="Search book, author, series..." class="w-full bg-gray-100/50 dark:bg-dark-800/50 border border-gray-300/50 dark:border-dark-700/50 rounded-lg px-4 py-2 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-dark-400 focus:outline-none focus:ring-2 focus:ring-primary-500/50 shadow-sm text-sm backdrop-blur-sm" id="mobileSearchInput">
2924
</div>
3025

3126
<!-- Desktop Page Title -->
32-
<h2 class="hidden md:block text-2xl font-bold text-gray-900 dark:text-white">Books</h2>
27+
<h2 class="hidden lg:block text-2xl font-bold text-gray-900 dark:text-white">Books</h2>
3328

3429
<div class="flex items-center space-x-3 md:space-x-4">
3530
<!-- Desktop search input -->
@@ -79,7 +74,7 @@ <h2 class="hidden md:block text-2xl font-bold text-gray-900 dark:text-white">Boo
7974

8075

8176

82-
<div class="min-h-full md:ml-64">
77+
<div class="min-h-full lg:ml-64">
8378
<!-- Main Content -->
8479
<main class="pt-[88px] md:pt-24 pb-28 md:pb-6 px-4 md:px-6">
8580
<!-- Book Sections using reusable component -->

templates/bottom_navbar.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{% if navbar_items.len() > 1 %}
2-
<nav class="md:hidden fixed bottom-4 left-8 right-8 z-50">
2+
<nav class="lg:hidden fixed bottom-4 left-8 right-8 z-50">
33
<div class="bg-white/75 dark:bg-dark-850/75 backdrop-blur-sm border border-gray-200/50 dark:border-dark-700/50 rounded-2xl px-2 py-1.5 shadow-2xl">
44
<div class="flex items-center justify-around">
55
{% for nav_item in navbar_items %}

0 commit comments

Comments
 (0)