LR-VIEW-PHOTOGRID: group the photo/square grid by date with day/month/year headers

Catalog date runs (stacks never split), Sort.group (auto/day/month/year/none) in the sort
menus, library.groups query, sticky headers, header click selects the group; layout unit
tests and a headless test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 12:52:07 -04:00
co-authored by Claude Opus 5.5
parent cec012d032
commit 89b8aa3f77
13 changed files with 570 additions and 65 deletions
+23 -3
View File
@@ -1,7 +1,7 @@
//! Library commands: view source, filter/sort, selection, ratings/flags/labels, rotate, delete,
//! metadata, albums, import.
use lightcraft_catalog::{Album, AlbumId, ColorLabel, Flag, Op, PhotoId, Sort, SortKey};
use lightcraft_catalog::{Album, AlbumId, ColorLabel, Flag, GroupBy, Op, PhotoId, Sort, SortKey};
use serde_json::{Value, json};
use super::{CommandSpec, always, bad, bool_or, cmd, has_active, has_selection, ok, str_param};
@@ -130,17 +130,37 @@ pub fn specs() -> Vec<CommandSpec> {
"Sort",
["View", "Sort"],
None,
"{key: captureDate|importDate|editDate|fileName|rating|fileSize, ascending?: bool}",
"{key?: captureDate|importDate|editDate|fileName|rating|fileSize, ascending?: bool, group?: auto|none|day|month|year}",
always,
|s, p| {
let key: SortKey = match p.get("key") {
Some(k) => serde_json::from_value(k.clone()).map_err(|e| bad("library.sort", e.to_string()))?,
None => s.sort.key,
};
s.sort = Sort { key, ascending: bool_or(p, "ascending", s.sort.ascending) };
let group = match str_param(p, "group") {
Some(g) => GroupBy::parse(g).ok_or_else(|| bad("library.sort", "group must be auto|none|day|month|year"))?,
None => s.sort.group,
};
s.sort = Sort { key, ascending: bool_or(p, "ascending", s.sort.ascending), group };
ok()
}
),
cmd!(
query "library.groups",
"Date Groups",
[],
None,
"{by?: day|month|year (default: the sort's grouping; auto = day)} → [{key, label, start, count}] date headers of the grid",
always,
|s, p| {
let by = match str_param(p, "by") {
Some(g) => GroupBy::parse(g).ok_or_else(|| bad("library.groups", "by must be auto|none|day|month|year"))?,
None => s.sort.group,
};
let vis = s.visible_cloned();
Ok(serde_json::to_value(s.catalog.date_runs(&vis, s.sort.key, by)).unwrap_or_default())
}
),
// ---- selection
cmd!("library.select", "Select Photos", [], None, "{ids: [id], active?: id, mode?: replace|add|toggle|range}", always, |s, p| {
let ids = ids_param(p).unwrap_or_default();
+2 -13
View File
@@ -274,20 +274,9 @@ pub fn system_clock() -> String {
}
}
/// Unix seconds → `YYYY-MM-DDTHH:MM:SS` (UTC), proleptic Gregorian (H. Hinnant's algorithm).
/// Unix seconds → `YYYY-MM-DDTHH:MM:SS` (UTC), proleptic Gregorian.
pub fn civil(secs: i64) -> String {
let days = secs.div_euclid(86_400);
let rem = secs.rem_euclid(86_400);
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + i64::from(m <= 2);
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}", rem / 3600, rem % 3600 / 60, rem % 60)
lightcraft_catalog::dates::civil(secs)
}
impl Session {
+2
View File
@@ -340,6 +340,8 @@ mod tests_color;
#[cfg(test)]
mod tests_import;
#[cfg(test)]
mod tests_libops;
#[cfg(test)]
mod tests_library;
#[cfg(test)]
mod tests_merge;
+34
View File
@@ -0,0 +1,34 @@
//! Library management commands: date groups, keyword rename/delete/merge, batch rename, capture
//! time, label names, import review.
use serde_json::json;
use crate::Session;
/// Date headers follow the grid order and the sort's grouping.
#[test]
fn date_groups_follow_sort_and_grouping() {
let mut s = Session::with_demo();
let vis = s.visible_cloned();
let g = s.execute("library.groups", &json!({})).unwrap();
let groups = g.as_array().unwrap();
assert!(groups.len() > 3, "{g}");
let total: u64 = groups.iter().map(|x| x["count"].as_u64().unwrap()).sum();
assert_eq!(total as usize, vis.len());
// contiguous runs in grid order, labelled with weekday and date
let mut next = 0;
for x in groups {
assert_eq!(x["start"].as_u64().unwrap(), next);
next += x["count"].as_u64().unwrap();
assert!(x["label"].as_str().unwrap().contains(", "), "{x}");
}
let months = s.execute("library.groups", &json!({"by": "month"})).unwrap();
assert!(months.as_array().unwrap().len() < groups.len());
assert_eq!(months[0]["key"].as_str().unwrap().len(), 7);
s.execute("library.sort", &json!({"group": "none"})).unwrap();
assert_eq!(s.execute("library.groups", &json!({})).unwrap(), json!([]));
s.execute("library.sort", &json!({"group": "year", "key": "fileName"})).unwrap();
assert_eq!(s.execute("library.groups", &json!({})).unwrap(), json!([]), "no date headers when sorting by name");
assert_eq!(s.sort.group, lightcraft_catalog::GroupBy::Year);
assert!(s.execute("library.sort", &json!({"group": "week"})).is_err());
}