Draw the date histogram over the collection you are looking at
Build and test / Desktop (Linux) (push) Failing after 57m25s
Build and test / Layer separation (push) Successful in 33s
Traceability / Requirement traces (push) Failing after 27s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Build and test / Android (aarch64) (push) Failing after 9m45s

The timeline counted the whole library whatever the grid was showing, so
opening a collection left a fortnight in Arosa as one column of a
fifteen-year axis — an axis describing photographs that were not on
screen.

Scope the buckets and the span to the same collection and rating filter
the grid uses. `timeline_range` counts `images` alone and cannot express
the membership join, so the scoped query lives beside the other scoped
readers in the UI and shares their descendants-of-scope rule.

`catalog_span` now delegates to the same scoped reader. Zoom and scrub
measured the full library while the bars were scoped, so a scrub could
land on an instant the collection did not contain and send the view
somewhere the user had not asked to go.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 22:15:59 +02:00
co-authored by Claude Opus 5
parent b0206cbc7a
commit 02d629922f
3 changed files with 116 additions and 30 deletions
+94
View File
@@ -2630,6 +2630,100 @@ pub fn total_images_scoped(
Ok(n as usize)
}
/// The SQL restricting a query to `scope` and its descendants, with the bound
/// parameters to go with it.
///
/// Shared by the span and the histogram so the two cannot drift: an axis drawn
/// over one set of images and bars counted over another puts the bars in the
/// wrong place.
fn scope_clause(
catalog: &Catalog,
scope: Option<dr_types::CollectionId>,
) -> Result<(String, Vec<rusqlite::types::Value>), dr_catalog::CatalogError> {
let Some(scope) = scope else {
return Ok((String::new(), Vec::new()));
};
let ids = dr_catalog::collections::descendants(catalog.connection(), scope)?;
let placeholders = std::iter::repeat_n("?", ids.len())
.collect::<Vec<_>>()
.join(",");
Ok((
format!(
" AND i.id IN (SELECT image_id FROM collection_members
WHERE collection_id IN ({placeholders}))"
),
ids.iter()
.map(|c| rusqlite::types::Value::Integer(c.0 as i64))
.collect(),
))
}
/// Earliest and latest capture time within `scope`, honouring the filter.
///
/// The timeline's extent. Taken over the same images the histogram counts, so
/// opening a collection shows that collection's years rather than the whole
/// library's — the axis was previously spanning everything, which left a
/// collection's bars crushed into a sliver of it.
pub fn span_scoped(
catalog: &Catalog,
scope: Option<dr_types::CollectionId>,
filter: &RatingFilter,
) -> Option<(i64, i64)> {
let (clause, params) = scope_clause(catalog, scope).ok()?;
let rated = filter.sql();
let sql = format!(
"SELECT min(i.captured_at), max(i.captured_at) FROM images i
WHERE {VISIBLE}{rated} AND i.captured_at IS NOT NULL{clause}"
);
catalog
.connection()
.query_row(&sql, rusqlite::params_from_iter(params.iter()), |r| {
Ok((r.get::<_, Option<i64>>(0)?, r.get::<_, Option<i64>>(1)?))
})
.ok()
.and_then(|(lo, hi)| Some((lo?, hi?)))
}
/// Histogram buckets within `scope` and a date range.
///
/// `Catalog::timeline_range` counts the whole library; a collection needs the
/// membership join, and the rating filter applied too, or the bars disagree
/// with the grid beneath them.
pub fn timeline_scoped(
catalog: &Catalog,
scope: Option<dr_types::CollectionId>,
filter: &RatingFilter,
granularity: dr_catalog::Granularity,
from: i64,
to: i64,
) -> Result<Vec<dr_catalog::TimeBucket>, dr_catalog::CatalogError> {
let (clause, mut params) = scope_clause(catalog, scope)?;
let rated = filter.sql();
let sql = format!(
"SELECT min(i.captured_at) AS start, count(*) AS n FROM images i
WHERE {VISIBLE}{rated} AND i.captured_at IS NOT NULL{clause}
AND i.captured_at >= ? AND i.captured_at <= ?
GROUP BY strftime('{}', i.captured_at
+ coalesce(i.captured_offset, 0) * 60, 'unixepoch')
ORDER BY start ASC",
granularity.strftime()
);
params.push(rusqlite::types::Value::Integer(from));
params.push(rusqlite::types::Value::Integer(to));
let conn = catalog.connection();
let mut stmt = conn.prepare(&sql)?;
let rows = stmt
.query_map(rusqlite::params_from_iter(params.iter()), |r| {
Ok(dr_catalog::TimeBucket {
start: r.get(0)?,
count: r.get::<_, i64>(1)? as u32,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
}
/// Total images in the catalog, honouring the rating filter.
fn total_images_filtered(
catalog: &Catalog,