From 9a141452a4b5f3574b77e90e16171332766aa554 Mon Sep 17 00:00:00 2001 From: Chris Moyer Date: Wed, 12 Aug 2026 20:46:08 -0400 Subject: [PATCH] feat(soup): forward-looking due-date buckets for grouped queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First step toward an Asana-style "My Tasks" view: group tasks into Today / Upcoming / Later / Backlog by reading a Date-typed property forward from the viewer's current day. Backend only — no caller requests the new field yet. Neither existing grouping mode can do this: - `GroupByField::Date` buckets `sort_ts` *backwards* (Today / Yesterday / Last week) to answer "what did I touch recently". - `GroupByField::Property` extracts values by expanding `values->'value'` with `jsonb_array_elements`, but `PropertyValue::Date` is a JSON *scalar*, so that path yields NULL and files every task under "Not Set". So `DueDateBucket` gets its own scalar-reading lateral join, and its own bucketer in `models_grouping::gtd_buckets`. Generic over the property rather than pinned to the system due date, so a custom date property groups the same way. Two decisions worth review: - **Boundaries are computed in Rust, not SQL.** `CURRENT_DATE` is the database server's date (UTC), which runs a day ahead of any viewer in the Americas during their evening and would file tomorrow morning's work under Today. The field carries an IANA `time_zone`; an unrecognized value degrades to UTC rather than failing the request. `horizon_days` is likewise a parameter, so changing the Upcoming window later is a request change, not a migration. - **The comparison is textual, not cast.** `(values->>'value')::timestamptz` is only *stable*, not immutable, so Postgres rejects an index on it. Z-suffixed RFC 3339 compares lexicographically in chronological order, keeping a plain B-tree usable. The boundary literal deliberately omits the trailing `Z`: `'.' < 'Z'`, so a value with fractional seconds would otherwise sort before a Z-suffixed boundary it should sort after. Both properties are covered by tests, including one that executes the bucketing in Postgres and asserts it agrees with the Rust implementation. The group-by expression is also now generated from a single pinned `now` (`group_select_expr_at`), since it is emitted three times per query and a request landing on midnight would otherwise partition against one set of boundaries and count against another. Both transports get the variant (REST `ApiGroupByField`, GraphQL `GraphqlGroupByField`); `static_assets/schema.graphql` regenerated. --- Cargo.lock | 2 + crates/graphql_soup/src/inputs.rs | 73 ++++-- crates/models_grouping/Cargo.toml | 1 + crates/models_grouping/src/field.rs | 28 ++- crates/models_grouping/src/gtd_buckets.rs | 229 ++++++++++++++++++ .../models_grouping/src/gtd_buckets/test.rs | 229 ++++++++++++++++++ crates/models_grouping/src/lib.rs | 2 + crates/soup/Cargo.toml | 1 + crates/soup/src/domain/models/grouping.rs | 8 +- crates/soup/src/inbound/axum_router.rs | 32 +++ .../outbound/pg_soup_repo/expanded/dynamic.rs | 9 +- .../src/outbound/pg_soup_repo/grouping.rs | 94 ++++++- .../outbound/pg_soup_repo/grouping/test.rs | 197 +++++++++++++++ static_assets/schema.graphql | 18 +- 14 files changed, 897 insertions(+), 26 deletions(-) create mode 100644 crates/models_grouping/src/gtd_buckets.rs create mode 100644 crates/models_grouping/src/gtd_buckets/test.rs diff --git a/Cargo.lock b/Cargo.lock index 178df37956c..1af65644180 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9644,6 +9644,7 @@ name = "models_grouping" version = "0.1.0" dependencies = [ "chrono", + "chrono-tz", "serde", "uuid", "workspace-hack", @@ -14036,6 +14037,7 @@ dependencies = [ "call", "channels", "chrono", + "chrono-tz", "cool_asserts", "cowlike", "crm", diff --git a/crates/graphql_soup/src/inputs.rs b/crates/graphql_soup/src/inputs.rs index 38598740fe0..df35830d9ac 100644 --- a/crates/graphql_soup/src/inputs.rs +++ b/crates/graphql_soup/src/inputs.rs @@ -204,10 +204,17 @@ impl GroupedSoupContinuationInput { struct GraphqlGroupByInput { /// The kind of grouping to perform. field: GraphqlGroupByField, - /// Property definition to group by when `field` is `PROPERTY`. + /// Property definition to group by when `field` is `PROPERTY` or + /// `DUE_DATE_BUCKET`. property_definition_id: Option, /// Optional property entity type restriction. entity_type: Option, + /// IANA timezone the due-date day boundaries are computed in. Only valid + /// with `DUE_DATE_BUCKET`; unset falls back to UTC. + time_zone: Option, + /// Days after today counted as Upcoming. Only valid with + /// `DUE_DATE_BUCKET`; unset defaults to 7. + horizon_days: Option, } impl GraphqlGroupByInput { @@ -220,40 +227,63 @@ impl GraphqlGroupByInput { } GraphqlGroupByField::Project => self.without_property_options(GroupByField::Project), GraphqlGroupByField::Property => { - let property_definition_id = self.property_definition_id.ok_or_else(|| { - async_graphql::Error::new( - "propertyDefinitionId is required when grouping by PROPERTY", - ) - })?; - let property_definition_id = - parse_id(property_definition_id, "propertyDefinitionId")?; - let entity_type = self - .entity_type - .map(PropertyEntityType::try_from) - .transpose() - .map_err(|_| { - async_graphql::Error::new( - "CALL_RECORD is not supported for property grouping", - ) - })? - .map(|entity_type| entity_type.to_string()); + let (property_definition_id, entity_type) = self.property_target("PROPERTY")?; Ok(GroupByField::Property { property_definition_id, entity_type, }) } + GraphqlGroupByField::DueDateBucket => { + let time_zone = self.time_zone.clone(); + let horizon_days = self.horizon_days; + let (property_definition_id, entity_type) = + self.property_target("DUE_DATE_BUCKET")?; + + Ok(GroupByField::DueDateBucket { + property_definition_id, + entity_type, + time_zone, + horizon_days, + }) + } } } - /// Reject property-only options for non-property grouping modes. + /// Extract the property definition and entity type both property-backed + /// grouping modes need. + fn property_target(self, mode: &str) -> async_graphql::Result<(uuid::Uuid, Option)> { + let property_definition_id = self.property_definition_id.ok_or_else(|| { + async_graphql::Error::new(format!( + "propertyDefinitionId is required when grouping by {mode}" + )) + })?; + let property_definition_id = parse_id(property_definition_id, "propertyDefinitionId")?; + let entity_type = self + .entity_type + .map(PropertyEntityType::try_from) + .transpose() + .map_err(|_| { + async_graphql::Error::new("CALL_RECORD is not supported for property grouping") + })? + .map(|entity_type| entity_type.to_string()); + + Ok((property_definition_id, entity_type)) + } + + /// Reject property-only options for grouping modes that read no property. fn without_property_options( self, group_by: GroupByField, ) -> async_graphql::Result { if self.property_definition_id.is_some() || self.entity_type.is_some() { return Err(async_graphql::Error::new( - "propertyDefinitionId and entityType require PROPERTY grouping", + "propertyDefinitionId and entityType require PROPERTY or DUE_DATE_BUCKET grouping", + )); + } + if self.time_zone.is_some() || self.horizon_days.is_some() { + return Err(async_graphql::Error::new( + "timeZone and horizonDays require DUE_DATE_BUCKET grouping", )); } Ok(group_by) @@ -271,6 +301,9 @@ enum GraphqlGroupByField { Project, /// Group by a property value. Property, + /// Group into forward-looking due-date buckets: Today, Upcoming, Later, + /// Backlog. Reads a `Date`-typed property. + DueDateBucket, } impl SoupInput { diff --git a/crates/models_grouping/Cargo.toml b/crates/models_grouping/Cargo.toml index 0025a3e23d5..3f675a327c7 100644 --- a/crates/models_grouping/Cargo.toml +++ b/crates/models_grouping/Cargo.toml @@ -6,6 +6,7 @@ version = "0.1.0" [dependencies] chrono = { workspace = true } +chrono-tz = { workspace = true } serde = { workspace = true } uuid = { workspace = true } workspace-hack = { version = "0.1", path = "../workspace-hack" } diff --git a/crates/models_grouping/src/field.rs b/crates/models_grouping/src/field.rs index 25b1e56a6c0..1bfd13f6a8f 100644 --- a/crates/models_grouping/src/field.rs +++ b/crates/models_grouping/src/field.rs @@ -22,11 +22,37 @@ pub enum GroupByField { #[serde(skip_serializing_if = "Option::is_none")] entity_type: Option, }, + /// Forward-looking due-date buckets over a `Date`-typed property: + /// Today, Upcoming, Later, Backlog. + /// + /// Generic over the property rather than pinned to the system due date, so + /// a custom date property groups the same way. Distinct from + /// [`GroupByField::Date`], which buckets activity recency *backwards*, and + /// from [`GroupByField::Property`], whose value extraction expands JSON + /// arrays and so reads a scalar date as "not set". + DueDateBucket { + /// The `Date`-typed property definition UUID to read. + property_definition_id: Uuid, + /// Optional entity type scope for the property lookup + #[serde(skip_serializing_if = "Option::is_none")] + entity_type: Option, + /// IANA timezone the viewer's day boundaries are computed in. + /// Unset or unrecognized falls back to UTC. + #[serde(skip_serializing_if = "Option::is_none")] + time_zone: Option, + /// Days after today that count as Upcoming. Unset uses + /// [`crate::DEFAULT_HORIZON_DAYS`]. + #[serde(skip_serializing_if = "Option::is_none")] + horizon_days: Option, + }, } impl GroupByField { /// Returns true if this field requires a property join. pub fn requires_property_join(&self) -> bool { - matches!(self, GroupByField::Property { .. }) + matches!( + self, + GroupByField::Property { .. } | GroupByField::DueDateBucket { .. } + ) } } diff --git a/crates/models_grouping/src/gtd_buckets.rs b/crates/models_grouping/src/gtd_buckets.rs new file mode 100644 index 00000000000..7992a1b5793 --- /dev/null +++ b/crates/models_grouping/src/gtd_buckets.rs @@ -0,0 +1,229 @@ +//! Forward-looking due-date buckets: Today, Upcoming, Later, Backlog. +//! +//! Distinct from [`crate::date_buckets`], which buckets an entity's *activity* +//! timestamp backwards (Today / Yesterday / Last week) to answer "what did I +//! touch recently". These buckets read a **due date** forwards to answer "what +//! do I have to do", which is the Asana "My Tasks" model. +//! +//! # Where the boundaries come from +//! +//! The two boundaries — end of the viewer's today, and end of the upcoming +//! horizon — are computed **here, in Rust**, not in SQL. Two reasons: +//! +//! 1. `CURRENT_DATE` is the database server's date (UTC in every deployment). +//! A task due 23:00 local would land a bucket early or late for anyone west +//! of Greenwich, which is a visible bug rather than a rounding detail. +//! 2. The grouping expression is interpolated into the query as raw SQL (see +//! `soup::outbound::pg_soup_repo::grouping`), which has no room to bind a +//! timezone parameter. Formatting the boundaries ourselves keeps the +//! interpolated text a fixed `[0-9T:-]` shape with no injection surface. +//! +//! # Why the comparison is textual +//! +//! Property values live in `entity_properties.values` as JSONB, so a due date +//! reads out as `values->>'value'` — text. Casting it (`::timestamptz`) is +//! only *stable*, not immutable, so Postgres will not accept an index on the +//! cast expression; comparing the text directly keeps a plain B-tree usable. +//! +//! [`PropertyValue::Date`][pv] serializes from a `DateTime`, giving +//! Z-suffixed RFC 3339, and those strings compare lexicographically in +//! chronological order. One subtlety governs [`boundary_prefix`]: the boundary +//! text deliberately **omits the trailing `Z`**. Fractional seconds otherwise +//! invert the comparison at an exact boundary, because `'.' (0x2E) < 'Z' +//! (0x5A)`: +//! +//! ```text +//! "2026-08-13T00:00:00.000Z" < "2026-08-13T00:00:00Z" -- true, and WRONG +//! "2026-08-13T00:00:00.000Z" < "2026-08-13T00:00:00" -- false, correct +//! ``` +//! +//! Against the bare prefix, any value in that same second sorts *after* it +//! (same prefix, longer string), so midnight-exact due dates fall on the later +//! side of the boundary — which is what "due tomorrow" means. +//! +//! [pv]: https://docs.rs/models_properties + +use chrono::{DateTime, Days, NaiveDate, NaiveTime, TimeZone, Utc}; +use chrono_tz::Tz; + +/// Bucket keys. Stable identifiers — they appear in API responses, in +/// collapse state persisted by the client, and (from the placement work) in a +/// database column. +pub mod gtd_keys { + /// Due today or overdue. + pub const TODAY: &str = "today"; + /// Due within the horizon after today. + pub const UPCOMING: &str = "upcoming"; + /// Due beyond the horizon. + pub const LATER: &str = "later"; + /// No due date. + pub const BACKLOG: &str = "backlog"; +} + +/// Days after today that count as Upcoming. Matches Asana's default. +pub const DEFAULT_HORIZON_DAYS: u16 = 7; + +/// Urgency rank, ascending: lower is more urgent. +/// +/// This doubles as the group display order and as the ordering used to resolve +/// a manual placement against the date-derived bucket (the more urgent of the +/// two wins). Unknown keys rank last so a stale or hand-written key degrades +/// to the bottom of the list rather than the top. +pub fn gtd_bucket_rank(key: &str) -> i32 { + match key { + gtd_keys::TODAY => 0, + gtd_keys::UPCOMING => 1, + gtd_keys::LATER => 2, + _ => 3, + } +} + +/// Bucket key for a rank, inverse of [`gtd_bucket_rank`]. +pub fn gtd_bucket_for_rank(rank: i32) -> &'static str { + match rank { + 0 => gtd_keys::TODAY, + 1 => gtd_keys::UPCOMING, + 2 => gtd_keys::LATER, + _ => gtd_keys::BACKLOG, + } +} + +/// Display order for a bucket (lower = first). +pub fn gtd_bucket_order(key: &str) -> i32 { + gtd_bucket_rank(key) +} + +/// Human-readable label for a bucket. +pub fn gtd_bucket_label(key: &str) -> &'static str { + match key { + gtd_keys::TODAY => "Today", + gtd_keys::UPCOMING => "Upcoming", + gtd_keys::LATER => "Later", + _ => "Backlog", + } +} + +/// The instants separating the buckets, for one viewer at one moment. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct GtdBoundaries { + /// First instant that is no longer "today" for the viewer. + pub today_end: DateTime, + /// First instant that is no longer within the upcoming horizon. + pub horizon_end: DateTime, +} + +/// Resolve a local midnight to a UTC instant. +/// +/// Midnight is not guaranteed to exist or to be unique: a zone that shifts its +/// clock at midnight (Cuba, and Chile historically) either skips 00:00 or +/// repeats it. Prefer the earliest valid reading of the wall clock; when the +/// hour was skipped entirely, walk forward until the clock exists, which is the +/// first real instant of that local day. +fn local_midnight_utc(tz: Tz, date: NaiveDate) -> DateTime { + let midnight = date.and_time(NaiveTime::MIN); + + if let Some(resolved) = tz.from_local_datetime(&midnight).earliest() { + return resolved.with_timezone(&Utc); + } + + // Skipped hour: step through the local day for the first instant that maps. + for hour in 1..=23 { + let candidate = date + .and_hms_opt(hour, 0, 0) + .expect("hour in 1..=23 is valid"); + if let Some(resolved) = tz.from_local_datetime(&candidate).earliest() { + return resolved.with_timezone(&Utc); + } + } + + // No local time on this date resolves, which no real zone does. Treating + // the day as starting at UTC midnight keeps bucketing total. + Utc.from_utc_datetime(&midnight) +} + +/// Compute the bucket boundaries for a viewer in `tz` at `now`. +/// +/// `now` is taken as a parameter rather than read from the clock so the +/// boundaries are testable and so one request buckets every row against a +/// single consistent moment. +pub fn gtd_boundaries(now: DateTime, tz: Tz, horizon_days: u16) -> GtdBoundaries { + let local_today = now.with_timezone(&tz).date_naive(); + let tomorrow = local_today + .checked_add_days(Days::new(1)) + .unwrap_or(local_today); + let horizon = local_today + .checked_add_days(Days::new(1 + u64::from(horizon_days))) + .unwrap_or(tomorrow); + + GtdBoundaries { + today_end: local_midnight_utc(tz, tomorrow), + horizon_end: local_midnight_utc(tz, horizon), + } +} + +/// Format a boundary as the ISO-8601 prefix used for text comparison. +/// +/// Deliberately without a trailing `Z` — see the module documentation. Only +/// ever emits `[0-9T:-]`, which is what makes it safe to interpolate into SQL. +pub fn boundary_prefix(ts: DateTime) -> String { + ts.format("%Y-%m-%dT%H:%M:%S").to_string() +} + +/// Bucket a due date in Rust, mirroring [`gtd_bucket_sql_key`]. +/// +/// Used for the equivalence tests that keep the two implementations honest, +/// and by callers that already hold the value. +pub fn compute_gtd_bucket(due: Option>, boundaries: &GtdBoundaries) -> &'static str { + match due { + None => gtd_keys::BACKLOG, + Some(due) if due < boundaries.today_end => gtd_keys::TODAY, + Some(due) if due < boundaries.horizon_end => gtd_keys::UPCOMING, + Some(_) => gtd_keys::LATER, + } +} + +/// SQL `CASE` yielding the bucket key from a **text** due-date expression. +/// +/// `due_text_expr` must evaluate to RFC 3339 text or NULL (typically +/// `ep_due.due_text`, i.e. `values->>'value'`). +/// +/// # Example +/// ``` +/// use chrono::{TimeZone, Utc}; +/// use models_grouping::{gtd_boundaries, gtd_bucket_sql_key, DEFAULT_HORIZON_DAYS}; +/// +/// let now = Utc.with_ymd_and_hms(2026, 8, 12, 15, 0, 0).unwrap(); +/// let b = gtd_boundaries(now, chrono_tz::UTC, DEFAULT_HORIZON_DAYS); +/// let sql = gtd_bucket_sql_key("ep_due.due_text", &b); +/// assert!(sql.contains("'today'")); +/// assert!(sql.contains("2026-08-13T00:00:00")); +/// ``` +pub fn gtd_bucket_sql_key(due_text_expr: &str, boundaries: &GtdBoundaries) -> String { + let today_end = boundary_prefix(boundaries.today_end); + let horizon_end = boundary_prefix(boundaries.horizon_end); + format!( + r#"CASE + WHEN {due_text_expr} IS NULL THEN 'backlog' + WHEN {due_text_expr} < '{today_end}' THEN 'today' + WHEN {due_text_expr} < '{horizon_end}' THEN 'upcoming' + ELSE 'later' +END"# + ) +} + +/// SQL `CASE` yielding the bucket display order, matching [`gtd_bucket_order`]. +pub fn gtd_bucket_sql_order(due_text_expr: &str, boundaries: &GtdBoundaries) -> String { + let today_end = boundary_prefix(boundaries.today_end); + let horizon_end = boundary_prefix(boundaries.horizon_end); + format!( + r#"CASE + WHEN {due_text_expr} IS NULL THEN 3 + WHEN {due_text_expr} < '{today_end}' THEN 0 + WHEN {due_text_expr} < '{horizon_end}' THEN 1 + ELSE 2 +END"# + ) +} + +#[cfg(test)] +mod test; diff --git a/crates/models_grouping/src/gtd_buckets/test.rs b/crates/models_grouping/src/gtd_buckets/test.rs new file mode 100644 index 00000000000..27a04cf2294 --- /dev/null +++ b/crates/models_grouping/src/gtd_buckets/test.rs @@ -0,0 +1,229 @@ +use super::*; +use chrono::TimeZone; + +fn utc(y: i32, m: u32, d: u32, h: u32, min: u32) -> DateTime { + Utc.with_ymd_and_hms(y, m, d, h, min, 0).unwrap() +} + +#[test] +fn ranks_ascend_by_urgency_and_unknown_keys_sort_last() { + assert_eq!(gtd_bucket_rank(gtd_keys::TODAY), 0); + assert_eq!(gtd_bucket_rank(gtd_keys::UPCOMING), 1); + assert_eq!(gtd_bucket_rank(gtd_keys::LATER), 2); + assert_eq!(gtd_bucket_rank(gtd_keys::BACKLOG), 3); + assert_eq!(gtd_bucket_rank("something-else"), 3); +} + +#[test] +fn rank_round_trips_through_key() { + for key in [ + gtd_keys::TODAY, + gtd_keys::UPCOMING, + gtd_keys::LATER, + gtd_keys::BACKLOG, + ] { + assert_eq!(gtd_bucket_for_rank(gtd_bucket_rank(key)), key); + } +} + +#[test] +fn labels_cover_every_key() { + assert_eq!(gtd_bucket_label(gtd_keys::TODAY), "Today"); + assert_eq!(gtd_bucket_label(gtd_keys::UPCOMING), "Upcoming"); + assert_eq!(gtd_bucket_label(gtd_keys::LATER), "Later"); + assert_eq!(gtd_bucket_label(gtd_keys::BACKLOG), "Backlog"); + assert_eq!(gtd_bucket_label("unknown"), "Backlog"); +} + +#[test] +fn utc_boundaries_are_next_midnight_and_horizon() { + let b = gtd_boundaries(utc(2026, 8, 12, 15, 0), chrono_tz::UTC, 7); + + assert_eq!(b.today_end, utc(2026, 8, 13, 0, 0)); + assert_eq!(b.horizon_end, utc(2026, 8, 20, 0, 0)); +} + +/// The reason boundaries are computed in Rust rather than from `CURRENT_DATE`: +/// late evening in the Americas is already tomorrow in UTC, so the server's +/// notion of "today" runs a day ahead of the viewer's and swallows work that is +/// genuinely tomorrow's. +#[test] +fn boundaries_follow_the_viewer_not_the_server() { + // 2026-08-13T02:00Z == 2026-08-12T22:00 EDT: still the 12th in New York, + // already the 13th in UTC. + let now = utc(2026, 8, 13, 2, 0); + let ny = gtd_boundaries(now, chrono_tz::America::New_York, 7); + let utc_zone = gtd_boundaries(now, chrono_tz::UTC, 7); + + // The viewer's today ends at midnight EDT, four hours from now. + assert_eq!(ny.today_end, utc(2026, 8, 13, 4, 0)); + // The server's today has 22 hours left to run — a day out of step. + assert_eq!(utc_zone.today_end, utc(2026, 8, 14, 0, 0)); + + // Due 23:30 tonight EDT: today for the viewer, and both agree. + let tonight = utc(2026, 8, 13, 3, 30); + assert_eq!(compute_gtd_bucket(Some(tonight), &ny), gtd_keys::TODAY); + assert_eq!( + compute_gtd_bucket(Some(tonight), &utc_zone), + gtd_keys::TODAY + ); + + // Due 08:00 EDT tomorrow morning: Upcoming for the viewer, but a + // UTC-derived boundary files it under Today. This is the bug. + let tomorrow_morning = utc(2026, 8, 13, 12, 0); + assert_eq!( + compute_gtd_bucket(Some(tomorrow_morning), &ny), + gtd_keys::UPCOMING + ); + assert_eq!( + compute_gtd_bucket(Some(tomorrow_morning), &utc_zone), + gtd_keys::TODAY + ); +} + +#[test] +fn buckets_split_at_the_boundaries() { + let b = gtd_boundaries(utc(2026, 8, 12, 15, 0), chrono_tz::UTC, 7); + + assert_eq!(compute_gtd_bucket(None, &b), gtd_keys::BACKLOG); + // Overdue folds into Today rather than getting its own section. + assert_eq!( + compute_gtd_bucket(Some(utc(2026, 7, 1, 9, 0)), &b), + gtd_keys::TODAY + ); + assert_eq!( + compute_gtd_bucket(Some(utc(2026, 8, 12, 23, 59)), &b), + gtd_keys::TODAY + ); + // Exactly midnight belongs to the next day. + assert_eq!( + compute_gtd_bucket(Some(b.today_end), &b), + gtd_keys::UPCOMING + ); + assert_eq!( + compute_gtd_bucket(Some(utc(2026, 8, 19, 23, 59)), &b), + gtd_keys::UPCOMING + ); + assert_eq!(compute_gtd_bucket(Some(b.horizon_end), &b), gtd_keys::LATER); +} + +#[test] +fn a_zero_day_horizon_leaves_no_upcoming_bucket() { + let b = gtd_boundaries(utc(2026, 8, 12, 15, 0), chrono_tz::UTC, 0); + + assert_eq!(b.today_end, b.horizon_end); + assert_eq!( + compute_gtd_bucket(Some(utc(2026, 8, 13, 0, 1)), &b), + gtd_keys::LATER + ); +} + +/// The whole textual-comparison premise in one test: for RFC 3339 values as +/// `PropertyValue::Date` serializes them, comparing the raw string against a +/// `Z`-less boundary prefix must agree with comparing the parsed instants. +/// +/// This is what licenses `values->>'value' < ''` in SQL, so if the +/// serialization format ever changes this test is where it surfaces. +#[test] +fn text_comparison_agrees_with_instant_comparison() { + let b = gtd_boundaries(utc(2026, 8, 12, 15, 0), chrono_tz::UTC, 7); + let today_end_prefix = boundary_prefix(b.today_end); + + // Includes the cases that motivate dropping the trailing `Z`: values at the + // boundary second, with and without fractional digits. + let values = [ + "2026-08-12T00:00:00Z", + "2026-08-12T23:59:59Z", + "2026-08-12T23:59:59.999Z", + "2026-08-13T00:00:00Z", + "2026-08-13T00:00:00.000Z", + "2026-08-13T00:00:00.001Z", + "2026-08-13T00:00:01Z", + "2026-12-31T12:00:00.5Z", + "2025-01-01T00:00:00Z", + ]; + + for raw in values { + let parsed = DateTime::parse_from_rfc3339(raw) + .unwrap_or_else(|e| panic!("{raw} is not RFC 3339: {e}")) + .with_timezone(&Utc); + + assert_eq!( + raw < today_end_prefix.as_str(), + parsed < b.today_end, + "text and instant comparison disagree for {raw} against {today_end_prefix}" + ); + } +} + +/// A trailing `Z` on the boundary would invert the comparison for a value that +/// carries fractional seconds — the bug the prefix format exists to avoid. +#[test] +fn a_z_suffixed_boundary_would_misbucket_fractional_values() { + let b = gtd_boundaries(utc(2026, 8, 12, 15, 0), chrono_tz::UTC, 7); + let correct = boundary_prefix(b.today_end); + let naive = format!("{correct}Z"); + let midnight_with_fraction = "2026-08-13T00:00:00.000Z"; + + assert!(!(midnight_with_fraction < correct.as_str())); + assert!(midnight_with_fraction < naive.as_str()); +} + +#[test] +fn boundary_prefix_emits_only_sql_safe_characters() { + let prefix = boundary_prefix(utc(2026, 8, 13, 0, 0)); + + assert_eq!(prefix, "2026-08-13T00:00:00"); + assert!( + prefix + .chars() + .all(|c| c.is_ascii_digit() || matches!(c, '-' | ':' | 'T')), + "{prefix} would not be safe to interpolate" + ); +} + +/// Cuba shifts its clock at midnight, so 00:00 does not exist on the day DST +/// starts. Bucketing must still produce an instant on that local day rather +/// than panicking or silently falling back to UTC. +#[test] +fn a_skipped_local_midnight_resolves_to_the_first_real_instant() { + let havana = chrono_tz::America::Havana; + // Second Sunday in March, when Cuba springs forward at midnight. + let dst_start = NaiveDate::from_ymd_opt(2026, 3, 8).unwrap(); + + let resolved = local_midnight_utc(havana, dst_start); + + assert_eq!( + resolved.with_timezone(&havana).date_naive(), + dst_start, + "resolved instant should still fall on the requested local day" + ); +} + +#[test] +fn sql_key_embeds_both_boundaries_and_handles_null() { + let b = gtd_boundaries(utc(2026, 8, 12, 15, 0), chrono_tz::UTC, 7); + let sql = gtd_bucket_sql_key("ep_due.due_text", &b); + + assert!(sql.contains("ep_due.due_text IS NULL THEN 'backlog'")); + assert!(sql.contains("'2026-08-13T00:00:00'")); + assert!(sql.contains("'2026-08-20T00:00:00'")); + assert!(sql.contains("ELSE 'later'")); + assert!(!sql.contains("00:00:00Z"), "boundary must not carry a Z"); +} + +#[test] +fn sql_order_matches_the_rust_ranks() { + let b = gtd_boundaries(utc(2026, 8, 12, 15, 0), chrono_tz::UTC, 7); + let sql = gtd_bucket_sql_order("ep_due.due_text", &b); + + assert!(sql.contains("IS NULL THEN 3")); + assert!(sql.contains("< '2026-08-13T00:00:00' THEN 0")); + assert!(sql.contains("< '2026-08-20T00:00:00' THEN 1")); + assert!(sql.contains("ELSE 2")); + + assert_eq!(gtd_bucket_order(gtd_keys::BACKLOG), 3); + assert_eq!(gtd_bucket_order(gtd_keys::TODAY), 0); + assert_eq!(gtd_bucket_order(gtd_keys::UPCOMING), 1); + assert_eq!(gtd_bucket_order(gtd_keys::LATER), 2); +} diff --git a/crates/models_grouping/src/lib.rs b/crates/models_grouping/src/lib.rs index 7060ee73d31..8d6e1fee690 100644 --- a/crates/models_grouping/src/lib.rs +++ b/crates/models_grouping/src/lib.rs @@ -10,9 +10,11 @@ mod config; mod date_buckets; mod field; +mod gtd_buckets; mod meta; pub use config::*; pub use date_buckets::*; pub use field::*; +pub use gtd_buckets::*; pub use meta::*; diff --git a/crates/soup/Cargo.toml b/crates/soup/Cargo.toml index b6aff3aac43..8a7fc0870f7 100644 --- a/crates/soup/Cargo.toml +++ b/crates/soup/Cargo.toml @@ -54,6 +54,7 @@ channels = { path = "../channels", default-features = false, features = [ "list", ] } chrono = { workspace = true } +chrono-tz = { workspace = true } cowlike = { path = "../cowlike" } crm = { path = "../crm", default-features = false } document_sub_type = { path = "../document_sub_type" } diff --git a/crates/soup/src/domain/models/grouping.rs b/crates/soup/src/domain/models/grouping.rs index cacfee24dc6..202d6c39a46 100644 --- a/crates/soup/src/domain/models/grouping.rs +++ b/crates/soup/src/domain/models/grouping.rs @@ -6,7 +6,9 @@ mod test; use super::{EnrichedSoupItem, SoupPropertiesField}; use indexmap::IndexMap; use item_filters::ast::EntityFilterAst; -use models_grouping::{GroupByField, date_bucket_label, date_bucket_order}; +use models_grouping::{ + GroupByField, date_bucket_label, date_bucket_order, gtd_bucket_label, gtd_bucket_order, +}; use models_pagination::{ Base64Str, CursorVal, CursorWithValAndFilter, Identify, SimpleSortMethod, SortOn, }; @@ -26,6 +28,10 @@ fn resolve_group_label_and_order(key: &str, group_by: &GroupByField) -> (String, entity_type_labels::label(key).to_string(), Some(entity_type_labels::display_order(key)), ), + GroupByField::DueDateBucket { .. } => ( + gtd_bucket_label(key).to_string(), + Some(gtd_bucket_order(key)), + ), GroupByField::Project if key.is_empty() => ("No Project".to_string(), Some(i32::MAX)), GroupByField::Property { .. } if key.is_empty() => ("Not Set".to_string(), Some(i32::MAX)), _ => (key.to_string(), None), diff --git a/crates/soup/src/inbound/axum_router.rs b/crates/soup/src/inbound/axum_router.rs index af7f2b12134..24faf425bb4 100644 --- a/crates/soup/src/inbound/axum_router.rs +++ b/crates/soup/src/inbound/axum_router.rs @@ -291,6 +291,27 @@ pub enum ApiGroupByField { #[serde(skip_serializing_if = "Option::is_none")] entity_type: Option, }, + /// Forward-looking due-date buckets: Today, Upcoming, Later, Backlog. + /// + /// Reads a `Date`-typed property (typically the system due date). Unlike + /// `date`, which buckets activity recency backwards, these look forward + /// from the viewer's current day. + #[serde(rename = "due_date_bucket")] + DueDateBucket { + /// The `Date`-typed property definition UUID to bucket on + property_definition_id: Uuid, + /// Optional entity type filter for the property lookup + #[serde(skip_serializing_if = "Option::is_none")] + entity_type: Option, + /// IANA timezone the day boundaries are computed in (e.g. + /// `America/New_York`). Unset or unrecognized falls back to UTC, which + /// mis-buckets tasks due late in the viewer's evening. + #[serde(skip_serializing_if = "Option::is_none")] + time_zone: Option, + /// Days after today counted as Upcoming. Defaults to 7. + #[serde(skip_serializing_if = "Option::is_none")] + horizon_days: Option, + }, } impl From for GroupByField { @@ -306,6 +327,17 @@ impl From for GroupByField { property_definition_id, entity_type: entity_type.map(|et| PropertyEntityType::from(et).to_string()), }, + ApiGroupByField::DueDateBucket { + property_definition_id, + entity_type, + time_zone, + horizon_days, + } => GroupByField::DueDateBucket { + property_definition_id, + entity_type: entity_type.map(|et| PropertyEntityType::from(et).to_string()), + time_zone, + horizon_days, + }, } } } diff --git a/crates/soup/src/outbound/pg_soup_repo/expanded/dynamic.rs b/crates/soup/src/outbound/pg_soup_repo/expanded/dynamic.rs index 3b6bcd53892..4c9ae002e8d 100644 --- a/crates/soup/src/outbound/pg_soup_repo/expanded/dynamic.rs +++ b/crates/soup/src/outbound/pg_soup_repo/expanded/dynamic.rs @@ -31,7 +31,7 @@ use uuid::Uuid; use crate::domain::models::grouping::ItemGroupingInfo; use crate::outbound::pg_soup_repo::grouping::{ - GroupJoinClause, group_join_clause, group_select_expr, + GroupJoinClause, group_join_clause, group_select_expr_at, }; use crate::outbound::pg_soup_repo::type_err; use models_grouping::{GroupByField, GroupingConfig, date_bucket_sql_order}; @@ -1838,7 +1838,12 @@ fn build_grouped_items_cte( builder: &mut QueryBuilder<'_, Postgres>, grouping: &GroupingConfig, ) -> Option { - let select_expr = group_select_expr(&grouping.field); + // Pin the clock: due-date bucketing bakes day boundaries into the SQL, and + // the expression is emitted three times below (select, partition, filter). + // Generating them from one moment keeps a request that lands on midnight + // from partitioning against one set of boundaries and counting against + // another. + let select_expr = group_select_expr_at(&grouping.field, Utc::now()); builder.push("GroupedItems AS (SELECT t.*, ("); builder.push(&select_expr); diff --git a/crates/soup/src/outbound/pg_soup_repo/grouping.rs b/crates/soup/src/outbound/pg_soup_repo/grouping.rs index acca06a7565..f022ca40ed6 100644 --- a/crates/soup/src/outbound/pg_soup_repo/grouping.rs +++ b/crates/soup/src/outbound/pg_soup_repo/grouping.rs @@ -1,12 +1,56 @@ //! SQL grouping expressions for soup queries. -use models_grouping::{GroupByField, date_bucket_sql_key, date_bucket_sql_order}; +use chrono::{DateTime, Utc}; +use chrono_tz::Tz; +use models_grouping::{ + DEFAULT_HORIZON_DAYS, GroupByField, GtdBoundaries, date_bucket_sql_key, date_bucket_sql_order, + gtd_boundaries, gtd_bucket_sql_key, gtd_bucket_sql_order, +}; use std::borrow::Cow; +/// Alias of the lateral join that exposes a scalar date property as text. +const DUE_TEXT_EXPR: &str = "ep_due.due_text"; + +/// Resolve the bucket boundaries a [`GroupByField::DueDateBucket`] asks for. +/// +/// An unrecognized timezone degrades to UTC rather than failing the query: the +/// value comes from a client header, and a mistyped zone should mis-bucket by +/// hours, not return an error page. +fn due_date_boundaries( + time_zone: Option<&str>, + horizon_days: Option, + now: DateTime, +) -> GtdBoundaries { + let tz = time_zone + .and_then(|tz| tz.parse::().ok()) + .unwrap_or(chrono_tz::UTC); + + gtd_boundaries(now, tz, horizon_days.unwrap_or(DEFAULT_HORIZON_DAYS)) +} + /// Build the group select expression for a field. pub fn group_select_expr(field: &GroupByField) -> Cow<'static, str> { + group_select_expr_at(field, Utc::now()) +} + +/// [`group_select_expr`] against a fixed clock. +/// +/// Due-date bucketing bakes the day boundaries into the SQL, so the caller must +/// be able to pin `now` — both for tests and so every expression in one query +/// (the key appears three times: select, partition, and filter) is generated +/// from a single moment. Two calls straddling midnight would otherwise +/// partition on one set of boundaries and count on another. +pub fn group_select_expr_at(field: &GroupByField, now: DateTime) -> Cow<'static, str> { match field { GroupByField::Date => Cow::Owned(date_bucket_sql_key("sort_ts")), + GroupByField::DueDateBucket { + time_zone, + horizon_days, + .. + } => Cow::Owned(gtd_bucket_sql_key( + DUE_TEXT_EXPR, + &due_date_boundaries(time_zone.as_deref(), *horizon_days, now), + )), GroupByField::EntityType => Cow::Borrowed("item_type"), GroupByField::Project => Cow::Borrowed("COALESCE(project_id::text, '')"), GroupByField::Property { .. } => Cow::Borrowed( @@ -25,8 +69,21 @@ pub fn group_select_expr(field: &GroupByField) -> Cow<'static, str> { /// Build the group order expression for a field. pub fn group_order_expr(field: &GroupByField) -> Cow<'static, str> { + group_order_expr_at(field, Utc::now()) +} + +/// [`group_order_expr`] against a fixed clock. See [`group_select_expr_at`]. +pub fn group_order_expr_at(field: &GroupByField, now: DateTime) -> Cow<'static, str> { match field { GroupByField::Date => Cow::Owned(date_bucket_sql_order("sort_ts")), + GroupByField::DueDateBucket { + time_zone, + horizon_days, + .. + } => Cow::Owned(gtd_bucket_sql_order( + DUE_TEXT_EXPR, + &due_date_boundaries(time_zone.as_deref(), *horizon_days, now), + )), GroupByField::EntityType => Cow::Borrowed("item_type"), GroupByField::Project => Cow::Borrowed("project_id NULLS LAST"), GroupByField::Property { .. } => Cow::Borrowed( @@ -94,6 +151,41 @@ pub fn group_join_clause(field: &GroupByField) -> Option { entity_type_bind, }) } + // A `Date` property value is a JSON *scalar*, so the array-expanding + // lateral above would hand back NULL for every row. Read the value + // straight out as text instead — see `models_grouping::gtd_buckets` for + // why the comparison stays textual rather than casting to timestamptz. + // + // `LIMIT 1` is a safety net, not an expectation: a date property is + // single-valued, and duplicating rows here would double-count items in + // the group totals. + GroupByField::DueDateBucket { + property_definition_id, + entity_type, + .. + } => { + let (entity_type_filter, entity_type_bind) = match entity_type { + Some(et) => ("AND ep.entity_type = $10".to_string(), Some(et.clone())), + None => (String::new(), None), + }; + + Some(GroupJoinClause { + sql: format!( + "LEFT JOIN LATERAL ( + SELECT ep.values->>'value' AS due_text + FROM entity_properties ep + WHERE ep.entity_id = t.id::text + AND ep.entity_type = t.property_entity_type + AND ep.property_definition_id = '{}' + AND ep.values->>'type' = 'Date' + {} + LIMIT 1 + ) ep_due ON TRUE", + property_definition_id, entity_type_filter + ), + entity_type_bind, + }) + } _ => None, } } diff --git a/crates/soup/src/outbound/pg_soup_repo/grouping/test.rs b/crates/soup/src/outbound/pg_soup_repo/grouping/test.rs index 31c002107b2..c55878463f4 100644 --- a/crates/soup/src/outbound/pg_soup_repo/grouping/test.rs +++ b/crates/soup/src/outbound/pg_soup_repo/grouping/test.rs @@ -774,3 +774,200 @@ async fn grouped_soup_filters_calendar_events_by_notification_done( Ok(()) } + +const DUE_DATE_PROPERTY_ID: uuid::Uuid = uuid::uuid!("00000001-0000-0000-0000-000000000004"); + +/// A fixed moment so the generated boundaries are assertable. +fn fixed_now() -> chrono::DateTime { + use chrono::TimeZone; + chrono::Utc.with_ymd_and_hms(2026, 8, 12, 15, 0, 0).unwrap() +} + +fn due_date_field(time_zone: Option<&str>) -> GroupByField { + GroupByField::DueDateBucket { + property_definition_id: DUE_DATE_PROPERTY_ID, + entity_type: None, + time_zone: time_zone.map(str::to_string), + horizon_days: None, + } +} + +#[test] +fn due_date_select_bakes_in_the_viewer_day_boundaries() { + let expr = group_select_expr_at(&due_date_field(Some("UTC")), fixed_now()); + + assert!(expr.contains("'backlog'")); + assert!(expr.contains("'today'")); + assert!(expr.contains("'upcoming'")); + assert!(expr.contains("'later'")); + // End of today and end of the default 7-day horizon, as literals. + assert!(expr.contains("'2026-08-13T00:00:00'"), "{expr}"); + assert!(expr.contains("'2026-08-20T00:00:00'"), "{expr}"); +} + +#[test] +fn due_date_boundaries_shift_with_the_requested_timezone() { + let ny = group_select_expr_at(&due_date_field(Some("America/New_York")), fixed_now()); + + // 2026-08-12T15:00Z is 11:00 EDT, so the viewer's today ends at 04:00Z. + assert!(ny.contains("'2026-08-13T04:00:00'"), "{ny}"); +} + +/// The timezone arrives from a client header, so a bad value must degrade +/// rather than fail the request. +#[test] +fn an_unrecognized_timezone_falls_back_to_utc() { + let bogus = group_select_expr_at(&due_date_field(Some("Mars/Olympus_Mons")), fixed_now()); + let utc = group_select_expr_at(&due_date_field(None), fixed_now()); + + assert_eq!(bogus, utc); + assert!(utc.contains("'2026-08-13T00:00:00'")); +} + +#[test] +fn due_date_order_expr_matches_the_bucket_ranks() { + let expr = group_order_expr_at(&due_date_field(Some("UTC")), fixed_now()); + + assert!(expr.contains("IS NULL THEN 3")); + assert!(expr.contains("THEN 0")); + assert!(expr.contains("THEN 1")); + assert!(expr.contains("ELSE 2")); +} + +/// A `Date` property value is a JSON scalar, so this join must read it directly +/// rather than expanding it as an array the way property grouping does — that +/// path yields NULL for every row and files every task under Backlog. +#[test] +fn due_date_join_reads_the_scalar_value() { + let join = group_join_clause(&due_date_field(None)).unwrap(); + + assert!(join.sql.contains("ep_due")); + assert!(join.sql.contains("ep.values->>'value' AS due_text")); + assert!(join.sql.contains("ep.values->>'type' = 'Date'")); + assert!(join.sql.contains("LIMIT 1")); + assert!(join.sql.contains(&DUE_DATE_PROPERTY_ID.to_string())); + assert!( + !join.sql.contains("jsonb_array_elements"), + "scalar dates must not go through the array-expanding lateral" + ); + assert!(join.entity_type_bind.is_none()); +} + +#[test] +fn due_date_join_binds_an_entity_type_scope() { + let field = GroupByField::DueDateBucket { + property_definition_id: DUE_DATE_PROPERTY_ID, + entity_type: Some("TASK".to_string()), + time_zone: None, + horizon_days: None, + }; + + let join = group_join_clause(&field).unwrap(); + + assert!(join.sql.contains("AND ep.entity_type = $10")); + assert_eq!(join.entity_type_bind.as_deref(), Some("TASK")); +} + +/// Executes the bucketing against Postgres, which is the only way to confirm +/// that comparing `values->>'value'` as *text* against a Z-less boundary agrees +/// with comparing instants — the premise the whole approach rests on. +#[sqlx::test( + fixtures( + path = "../../../../../macro_db_client/fixtures", + scripts("mixed_items_expanded") + ), + migrator = "MACRO_DB_MIGRATIONS" +)] +async fn due_date_bucketing_agrees_with_postgres(pool: Pool) -> anyhow::Result<()> { + use models_grouping::{compute_gtd_bucket, gtd_boundaries}; + + const TASK_ID: &str = "11111111-0000-0000-0000-000000000000"; + + let now = fixed_now(); + let boundaries = gtd_boundaries(now, chrono_tz::UTC, 7); + let today_end = models_grouping::boundary_prefix(boundaries.today_end); + let horizon_end = models_grouping::boundary_prefix(boundaries.horizon_end); + + // The cases that make the textual comparison non-obvious: overdue, the last + // instant of today, midnight exactly (with and without fractional seconds), + // and the far side of the horizon. + let cases = [ + ("2026-07-01T09:00:00Z", "today"), + ("2026-08-12T23:59:59.999Z", "today"), + ("2026-08-13T00:00:00Z", "upcoming"), + ("2026-08-13T00:00:00.000Z", "upcoming"), + ("2026-08-19T23:59:59Z", "upcoming"), + ("2026-08-20T00:00:00Z", "later"), + ("2026-09-30T12:00:00.5Z", "later"), + ]; + + // Unchecked on purpose: a `query!` here would need a fresh `.sqlx` cache + // entry, and this test adds no new schema knowledge worth that. + sqlx::query( + r#" + INSERT INTO document_sub_type (document_id, sub_type) + VALUES ('11111111-0000-0000-0000-000000000000', 'task') + "#, + ) + .execute(&pool) + .await?; + + for (raw, expected) in cases { + sqlx::query( + r#" + INSERT INTO entity_properties + (id, entity_id, entity_type, property_definition_id, values) + VALUES ($1, $2, 'TASK', $3, jsonb_build_object('type', 'Date', 'value', $4::text)) + ON CONFLICT (id) DO UPDATE SET values = EXCLUDED.values + "#, + ) + .bind(uuid::uuid!("e0000000-0000-0000-0000-0000000000d0")) + .bind(TASK_ID) + .bind(DUE_DATE_PROPERTY_ID) + .bind(raw) + .execute(&pool) + .await?; + + // Ask Postgres to bucket it with exactly the expression the query uses. + let sql = format!( + "SELECT ({}) AS key", + models_grouping::gtd_bucket_sql_key("ep.values->>'value'", &boundaries) + ); + let key: String = sqlx::query_scalar(&format!( + "{sql} FROM entity_properties ep + WHERE ep.entity_id = $1 AND ep.property_definition_id = $2" + )) + .bind(TASK_ID) + .bind(DUE_DATE_PROPERTY_ID) + .fetch_one(&pool) + .await?; + + assert_eq!( + key, expected, + "postgres bucketed {raw} as {key} against [{today_end}, {horizon_end})" + ); + + // ...and Rust must reach the same answer for the same input. + let parsed = chrono::DateTime::parse_from_rfc3339(raw)?.with_timezone(&chrono::Utc); + assert_eq!( + compute_gtd_bucket(Some(parsed), &boundaries), + expected, + "rust disagreed with postgres for {raw}" + ); + } + + // A task with no due-date row at all lands in Backlog. + let missing: Option = sqlx::query_scalar( + "SELECT ep.values->>'value' FROM entity_properties ep + WHERE ep.entity_id = $1 AND ep.property_definition_id = $2", + ) + .bind("22222222-0000-0000-0000-000000000000") + .bind(DUE_DATE_PROPERTY_ID) + .fetch_optional(&pool) + .await? + .flatten(); + assert_eq!(missing, None); + assert_eq!(compute_gtd_bucket(None, &boundaries), "backlog"); + + Ok(()) +} diff --git a/static_assets/schema.graphql b/static_assets/schema.graphql index a0228ea1362..7ca16c3b587 100644 --- a/static_assets/schema.graphql +++ b/static_assets/schema.graphql @@ -2337,6 +2337,11 @@ enum GraphqlGroupByField { Group by a property value. """ PROPERTY + """ + Group into forward-looking due-date buckets: Today, Upcoming, Later, + Backlog. Reads a `Date`-typed property. + """ + DUE_DATE_BUCKET } """ @@ -2348,13 +2353,24 @@ input GraphqlGroupByInput { """ field: GraphqlGroupByField! """ - Property definition to group by when `field` is `PROPERTY`. + Property definition to group by when `field` is `PROPERTY` or + `DUE_DATE_BUCKET`. """ propertyDefinitionId: ID """ Optional property entity type restriction. """ entityType: GraphqlPropertyEntityType + """ + IANA timezone the due-date day boundaries are computed in. Only valid + with `DUE_DATE_BUCKET`; unset falls back to UTC. + """ + timeZone: String + """ + Days after today counted as Upcoming. Only valid with + `DUE_DATE_BUCKET`; unset defaults to 7. + """ + horizonDays: Int } """