use chrono::{DateTime, Datelike, Duration, NaiveDate, Timelike, Utc};
use chrono_tz::Tz;
use rusqlite::{Connection, params};
use crate::error::{Error, Result};
pub const DEFAULT_TIMEZONE: &str = "America/Los_Angeles";
#[derive(Debug, Clone, PartialEq)]
pub struct LocalKeys {
pub date_key: String,
pub time_key: String,
}
pub fn configured_timezone(conn: &Connection) -> Result<Tz> {
let name: String = conn
.query_row(
"SELECT value FROM config WHERE key = 'timezone'",
[],
|row| row.get(0),
)
.unwrap_or_else(|_| DEFAULT_TIMEZONE.to_string());
name.parse::<Tz>()
.map_err(|_| Error::Config(format!("invalid timezone '{name}'")))
}
pub fn local_date_for(instant: DateTime<Utc>, timezone: Tz) -> NaiveDate {
instant.with_timezone(&timezone).date_naive()
}
pub fn today_as_of(conn: &Connection, instant: DateTime<Utc>) -> Result<NaiveDate> {
Ok(local_date_for(instant, configured_timezone(conn)?))
}
pub fn today(conn: &Connection) -> Result<NaiveDate> {
today_as_of(conn, Utc::now())
}
pub fn today_or_default(conn: &Connection) -> NaiveDate {
let timezone = configured_timezone(conn).unwrap_or_else(|_| {
DEFAULT_TIMEZONE
.parse::<Tz>()
.expect("DEFAULT_TIMEZONE is a valid IANA zone")
});
local_date_for(Utc::now(), timezone)
}
pub fn last_complete_day_as_of(conn: &Connection, instant: DateTime<Utc>) -> Result<NaiveDate> {
Ok(today_as_of(conn, instant)? - Duration::days(1))
}
pub fn last_complete_day(conn: &Connection) -> Result<NaiveDate> {
last_complete_day_as_of(conn, Utc::now())
}
pub fn configured_core_hours(conn: &Connection) -> (u32, u32) {
let read = |key: &str, default: u32| -> u32 {
conn.query_row("SELECT value FROM config WHERE key = ?1", [key], |row| {
row.get::<_, String>(0)
})
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or(default)
};
(read("core_hours_start", 9), read("core_hours_end", 17))
}
pub fn parse_utc(timestamp: &str) -> Result<DateTime<Utc>> {
timestamp
.parse::<DateTime<Utc>>()
.map_err(|error| Error::InvalidArgument(format!("bad timestamp '{timestamp}': {error}")))
}
pub fn derive_local_keys(timestamp_utc: &DateTime<Utc>, timezone: Tz) -> LocalKeys {
let local = timestamp_utc.with_timezone(&timezone);
LocalKeys {
date_key: local.format("%Y-%m-%d").to_string(),
time_key: format!("{:02}:00", local.hour()),
}
}
fn day_of_half(date: NaiveDate) -> i64 {
let half_start_month = if date.month() <= 6 { 1 } else { 7 };
let half_start = NaiveDate::from_ymd_opt(date.year(), half_start_month, 1).unwrap();
(date - half_start).num_days() + 1
}
fn day_of_quarter(date: NaiveDate) -> i64 {
let quarter_start_month = ((date.month() - 1) / 3) * 3 + 1;
let quarter_start = NaiveDate::from_ymd_opt(date.year(), quarter_start_month, 1).unwrap();
(date - quarter_start).num_days() + 1
}
pub fn ensure_date_row(conn: &Connection, date_key: &str) -> Result<()> {
let date = NaiveDate::parse_from_str(date_key, "%Y-%m-%d")
.map_err(|error| Error::InvalidArgument(format!("bad date key '{date_key}': {error}")))?;
let year = date.year();
let quarter = ((date.month() - 1) / 3 + 1) as i64;
let half = if date.month() <= 6 { 1 } else { 2 };
let iso_week = date.iso_week();
let day_of_week = date.weekday().number_from_monday() as i64;
let quarter_start_month = ((date.month() - 1) / 3) * 3 + 1;
conn.execute(
"INSERT OR IGNORE INTO dim_date (
date_key, year, quarter, month, day_of_month, day_of_week, is_weekend,
week_of_year, week_key, month_key, quarter_key, year_key, half_key,
day_of_quarter, day_of_year, day_of_half, week_of_quarter,
month_of_quarter, month_of_half
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)",
params![
date_key,
year,
quarter,
date.month() as i64,
date.day() as i64,
day_of_week,
(day_of_week >= 6) as i64,
iso_week.week() as i64,
format!("{}-W{:02}", iso_week.year(), iso_week.week()),
format!("{year}-{:02}", date.month()),
format!("{year}-Q{quarter}"),
format!("{year}"),
format!("{year}-H{half}"),
day_of_quarter(date),
date.ordinal() as i64,
day_of_half(date),
(day_of_quarter(date) - 1) / 7 + 1,
(date.month() as i64 - quarter_start_month as i64) + 1,
if half == 1 { date.month() as i64 } else { date.month() as i64 - 6 },
],
)?;
Ok(())
}
pub fn ensure_time_row(conn: &Connection, time_key: &str, core_hours: (u32, u32)) -> Result<()> {
let hour: u32 = time_key
.split(':')
.next()
.and_then(|text| text.parse().ok())
.ok_or_else(|| Error::InvalidArgument(format!("bad time key '{time_key}'")))?;
let hour_12 = match hour % 12 {
0 => 12,
other => other,
};
let am_pm = if hour < 12 { "AM" } else { "PM" };
let bucket = match hour {
0..=5 => "Night",
6..=11 => "Morning",
12..=17 => "Afternoon",
_ => "Evening",
};
let is_core = hour >= core_hours.0 && hour < core_hours.1;
conn.execute(
"INSERT OR IGNORE INTO dim_time (time_key, hour, hour_12, am_pm, time_bucket, is_core_hours)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![time_key, hour as i64, hour_12 as i64, am_pm, bucket, is_core as i64],
)?;
Ok(())
}
pub fn ensure_keys_for_timestamp(
conn: &Connection,
timestamp: &str,
timezone: Tz,
core_hours: (u32, u32),
) -> Result<LocalKeys> {
let utc = parse_utc(timestamp)?;
let keys = derive_local_keys(&utc, timezone);
ensure_date_row(conn, &keys.date_key)?;
ensure_time_row(conn, &keys.time_key, core_hours)?;
Ok(keys)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
use chrono_tz::America::Los_Angeles;
#[test]
fn derives_local_keys_across_dst() {
let winter = Utc.with_ymd_and_hms(2026, 1, 15, 10, 0, 0).unwrap();
let keys = derive_local_keys(&winter, Los_Angeles);
assert_eq!(keys.date_key, "2026-01-15");
assert_eq!(keys.time_key, "02:00");
let summer = Utc.with_ymd_and_hms(2026, 7, 15, 10, 0, 0).unwrap();
let keys = derive_local_keys(&summer, Los_Angeles);
assert_eq!(keys.time_key, "03:00");
}
#[test]
fn date_rollover_at_local_midnight() {
let timestamp = Utc.with_ymd_and_hms(2026, 1, 2, 6, 0, 0).unwrap();
let keys = derive_local_keys(×tamp, Los_Angeles);
assert_eq!(keys.date_key, "2026-01-01");
assert_eq!(keys.time_key, "22:00");
}
fn set_timezone(conn: &Connection, name: &str) {
conn.execute(
"INSERT INTO config (key, value) VALUES ('timezone', ?1)
ON CONFLICT (key) DO UPDATE SET value = excluded.value",
[name],
)
.unwrap();
}
#[test]
fn today_uses_the_configured_zone_not_utc() {
let warehouse = crate::GithubDW::open_in_memory().unwrap();
let conn = warehouse.connection();
set_timezone(conn, "America/Los_Angeles");
let instant = Utc.with_ymd_and_hms(2026, 7, 1, 1, 0, 0).unwrap();
assert_eq!(
today_as_of(conn, instant).unwrap(),
NaiveDate::from_ymd_opt(2026, 6, 30).unwrap()
);
assert_ne!(today_as_of(conn, instant).unwrap(), instant.date_naive());
let winter = Utc.with_ymd_and_hms(2026, 1, 1, 1, 0, 0).unwrap();
assert_eq!(
today_as_of(conn, winter).unwrap(),
NaiveDate::from_ymd_opt(2025, 12, 31).unwrap()
);
}
#[test]
fn today_is_config_driven_in_both_directions() {
let east = crate::GithubDW::open_in_memory().unwrap();
set_timezone(east.connection(), "Pacific/Kiritimati"); let west = crate::GithubDW::open_in_memory().unwrap();
set_timezone(west.connection(), "Etc/GMT+12");
let instant = Utc.with_ymd_and_hms(2026, 3, 10, 12, 0, 0).unwrap();
let east_today = today_as_of(east.connection(), instant).unwrap();
let west_today = today_as_of(west.connection(), instant).unwrap();
assert_eq!(east_today, NaiveDate::from_ymd_opt(2026, 3, 11).unwrap());
assert_eq!(west_today, NaiveDate::from_ymd_opt(2026, 3, 10).unwrap());
assert_ne!(east_today, west_today, "the zone comes from config");
}
#[test]
fn last_complete_day_trails_local_today() {
let warehouse = crate::GithubDW::open_in_memory().unwrap();
let conn = warehouse.connection();
set_timezone(conn, "America/Los_Angeles");
let instant = Utc.with_ymd_and_hms(2026, 8, 16, 16, 31, 57).unwrap();
assert_eq!(
last_complete_day_as_of(conn, instant).unwrap(),
NaiveDate::from_ymd_opt(2026, 8, 15).unwrap()
);
let evening = Utc.with_ymd_and_hms(2026, 8, 17, 3, 0, 0).unwrap();
assert_eq!(
last_complete_day_as_of(conn, evening).unwrap(),
NaiveDate::from_ymd_opt(2026, 8, 15).unwrap()
);
}
#[test]
fn week_key_uses_the_iso_week_year() {
let warehouse = crate::GithubDW::open_in_memory().unwrap();
let conn = warehouse.connection();
ensure_date_row(conn, "2024-12-30").unwrap();
let (week_key, year_key): (String, String) = conn
.query_row(
"SELECT week_key, year_key FROM dim_date WHERE date_key = '2024-12-30'",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.unwrap();
assert_eq!(week_key, "2025-W01");
assert_eq!(year_key, "2024", "calendar year is unaffected");
}
}