From e99481030c2fa280186fdfa31b4db88a84ae2858 Mon Sep 17 00:00:00 2001 From: Carson Date: Fri, 27 Feb 2026 14:16:18 -0600 Subject: [PATCH 1/2] fix: use utcFormat instead of timeFormat for temporal axis labels The Vega-Lite labelExpr for temporal scales (date, datetime, time) was using timeFormat() to compare axis tick values against ISO date keys from the RENAMING clause. timeFormat formats in the browser's local timezone, but ggsql writes all temporal data as UTC ISO strings (e.g., "2024-01-01" represents midnight UTC). In non-UTC timezones, this causes every label comparison to fail. For example, in US Central Time (UTC-6), timeFormat formats "2024-01-01" (midnight UTC) as "2023-12-31" (6 PM local), so the comparison against the key "2024-01-01" never matches. The labelExpr falls through to datum.label (Vega-Lite's default formatter), which shows time-of-day labels like "06 PM" / "07 PM" instead of the expected date labels. The fix is to use utcFormat(), which formats in UTC and matches the ISO date keys that ggsql generates. Co-Authored-By: Claude Opus 4.6 --- src/writer/vegalite/encoding.rs | 78 +++++++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 4 deletions(-) diff --git a/src/writer/vegalite/encoding.rs b/src/writer/vegalite/encoding.rs index 38aa2a64..57099a9c 100644 --- a/src/writer/vegalite/encoding.rs +++ b/src/writer/vegalite/encoding.rs @@ -24,10 +24,10 @@ use super::{POINTS_TO_AREA, POINTS_TO_PIXELS}; /// - Example: `"datum.label == 'A' ? 'Alpha' : datum.label == 'B' ? 'Beta' : datum.label"` /// /// For temporal scales: -/// - Uses `timeFormat(datum.value, 'fmt')` for comparisons +/// - Uses `utcFormat(datum.value, 'fmt')` for comparisons (UTC to match our ISO date strings) /// - This is necessary because `datum.label` contains Vega-Lite's formatted label (e.g., "Jan 1, 2024") /// but our label_mapping keys are ISO format strings (e.g., "2024-01-01") -/// - Example: `"timeFormat(datum.value, '%Y-%m-%d') == '2024-01-01' ? 'Q1 Start' : datum.label"` +/// - Example: `"utcFormat(datum.value, '%Y-%m-%d') == '2024-01-01' ? 'Q1 Start' : datum.label"` /// /// For threshold scales (binned legends): /// - The `null_key` parameter specifies which key should use `datum.label == null` instead of @@ -43,8 +43,11 @@ pub(super) fn build_label_expr( } // Build the comparison expression based on whether this is temporal + // Use utcFormat (not timeFormat) because ggsql writes ISO date strings as UTC. + // timeFormat uses the browser's local timezone, causing comparison mismatches + // (e.g., "2024-01-01" UTC midnight becomes "2023-12-31" in US timezones). let comparison_expr = match time_format { - Some(fmt) => format!("timeFormat(datum.value, '{}')", fmt), + Some(fmt) => format!("utcFormat(datum.value, '{}')", fmt), None => "datum.label".to_string(), }; @@ -668,7 +671,7 @@ fn apply_label_mapping_to_encoding( return; } - // For temporal scales, use timeFormat() to compare against ISO keys + // For temporal scales, use utcFormat() to compare against ISO keys let time_format = scale .transform .as_ref() @@ -939,3 +942,70 @@ pub(super) fn build_detail_encoding(partition_by: &[String]) -> Option { Some(json!(details)) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_label_expr_temporal_uses_utc_format() { + // Temporal label comparisons must use utcFormat (not timeFormat) because + // ggsql writes ISO date strings as UTC. timeFormat uses the browser's + // local timezone, causing comparisons to fail in non-UTC timezones + // (e.g., "2024-01-01" midnight UTC becomes "2023-12-31" in US Central). + let mut mappings = HashMap::new(); + mappings.insert("2024-01-01".to_string(), Some("Jan 2024".to_string())); + + let expr = build_label_expr(&mappings, Some("%Y-%m-%d"), None); + + assert!( + expr.contains("utcFormat("), + "temporal labelExpr should use utcFormat, got: {expr}" + ); + assert!( + !expr.contains("timeFormat("), + "temporal labelExpr must not use timeFormat (local tz), got: {expr}" + ); + assert!( + expr.contains("utcFormat(datum.value, '%Y-%m-%d') == '2024-01-01' ? 'Jan 2024'"), + "expected correct comparison expression, got: {expr}" + ); + } + + #[test] + fn test_build_label_expr_non_temporal_uses_datum_label() { + let mut mappings = HashMap::new(); + mappings.insert("A".to_string(), Some("Alpha".to_string())); + + let expr = build_label_expr(&mappings, None, None); + + assert!( + expr.contains("datum.label == 'A'"), + "non-temporal should use datum.label, got: {expr}" + ); + assert!( + !expr.contains("utcFormat("), + "non-temporal should not use utcFormat, got: {expr}" + ); + } + + #[test] + fn test_build_label_expr_fallback() { + let mappings = HashMap::new(); + let expr = build_label_expr(&mappings, Some("%Y-%m-%d"), None); + assert_eq!(expr, "datum.label", "empty mappings should fall back to datum.label"); + } + + #[test] + fn test_build_label_expr_null_suppression() { + let mut mappings = HashMap::new(); + mappings.insert("2024-06-01".to_string(), None); // suppress label + + let expr = build_label_expr(&mappings, Some("%Y-%m-%d"), None); + + assert!( + expr.contains("? ''"), + "None mapping should suppress label (empty string), got: {expr}" + ); + } +} From ebe6ef0ffb872fd58e2cd93b5646cd5e64d89928 Mon Sep 17 00:00:00 2001 From: Carson Date: Mon, 2 Mar 2026 09:58:37 -0600 Subject: [PATCH 2/2] cargo fmt --- src/writer/vegalite/encoding.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/writer/vegalite/encoding.rs b/src/writer/vegalite/encoding.rs index 57099a9c..b12153a7 100644 --- a/src/writer/vegalite/encoding.rs +++ b/src/writer/vegalite/encoding.rs @@ -993,7 +993,10 @@ mod tests { fn test_build_label_expr_fallback() { let mappings = HashMap::new(); let expr = build_label_expr(&mappings, Some("%Y-%m-%d"), None); - assert_eq!(expr, "datum.label", "empty mappings should fall back to datum.label"); + assert_eq!( + expr, "datum.label", + "empty mappings should fall back to datum.label" + ); } #[test]