use crate::{ChronoError, Encoding, RenderZone};
use jiff::civil::Date;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WeekStart {
Monday,
Sunday,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct CalMonth {
pub year: i16,
pub month: i8,
pub zone_label: String,
pub weeks: Vec<Vec<Option<usize>>>,
pub days: Vec<CalDay>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Artifact {
pub kind: String,
pub name: String,
pub at_utc: String,
pub citation: String,
}
struct Rollover {
name: &'static str,
unix_second: i64,
citation: &'static str,
}
const ROLLOVERS: &[Rollover] = &[
Rollover {
name: "unix_i32",
unix_second: i32::MAX as i64,
citation: "POSIX time_t, 32-bit signed (Year 2038)",
},
Rollover {
name: "unix_u32",
unix_second: u32::MAX as i64,
citation: "time_t, 32-bit unsigned",
},
];
#[cfg(feature = "lunisolar")]
#[derive(Debug, Clone, serde::Serialize)]
pub struct ChineseDate {
pub lunar_year: i32,
pub lunar_month: u8,
pub lunar_day: u8,
pub is_leap_month: bool,
pub year_pillar: String,
pub day_pillar: String,
pub solar_term: String,
pub solar_longitude_deg: f64,
}
#[cfg(feature = "altcal")]
#[derive(Debug, Clone, serde::Serialize)]
pub struct HebrewDate {
pub year: i32,
pub month: u8,
pub day: u8,
pub month_code: String,
}
#[cfg(feature = "altcal")]
#[derive(Debug, Clone, serde::Serialize)]
pub struct IslamicDate {
pub year: i32,
pub month: u8,
pub day: u8,
}
#[cfg(feature = "lunisolar")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Hemisphere {
North,
South,
}
#[cfg(feature = "lunisolar")]
#[must_use]
pub fn season_for(solar_longitude_deg: f64, hemisphere: Hemisphere) -> &'static str {
let north = ["spring", "summer", "autumn", "winter"];
let idx = (solar_longitude_deg.rem_euclid(360.0) / 90.0).floor() as usize % 4;
match hemisphere {
Hemisphere::North => north[idx],
Hemisphere::South => north[(idx + 2) % 4],
}
}
#[cfg(feature = "lunisolar")]
#[derive(Debug, Clone, serde::Serialize)]
pub struct SeasonMarker {
pub solar_longitude_deg: f64,
pub instant_utc: String,
pub term_name: String,
}
#[cfg(feature = "lunisolar")]
#[must_use]
pub fn season_markers(year: i16) -> Vec<SeasonMarker> {
const CARDINALS: [(f64, u32); 4] = [(0.0, 2), (90.0, 5), (180.0, 8), (270.0, 11)];
let mut out = Vec::with_capacity(4);
for (lon, start_month) in CARDINALS {
if let Some(jd_ut) = stem_branch::find_solar_term_moment(lon, i32::from(year), start_month)
{
#[allow(clippy::cast_possible_truncation)]
let unix = ((jd_ut - 2_440_587.5) * 86_400.0).round() as i64;
if let Ok(ts) = jiff::Timestamp::from_second(unix) {
out.push(SeasonMarker {
solar_longitude_deg: lon,
instant_utc: ts.to_string(),
term_name: stem_branch::solar_term_for_longitude(lon).to_string(),
});
}
}
}
out
}
#[cfg(feature = "lunisolar")]
pub const PHASE_NAMES: [&str; 8] = [
"New Moon",
"Waxing Crescent",
"First Quarter",
"Waxing Gibbous",
"Full Moon",
"Waning Gibbous",
"Last Quarter",
"Waning Crescent",
];
#[cfg(feature = "lunisolar")]
#[derive(Debug, Clone, serde::Serialize)]
pub struct MoonInfo {
pub phase_index: u8,
pub phase_name: String,
pub elongation_deg: f64,
pub phase_angle_deg: f64,
pub illuminated_fraction: f64,
pub waxing: bool,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct DstTransition {
pub kind: String,
pub at_utc: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct CalDay {
pub date: String,
pub weekday: String,
pub iso_year: i16,
pub iso_week: i8,
pub iso_weekday: i8,
pub day_of_year: i16,
pub days_in_year: i16,
pub jdn: i64,
pub mjd: i64,
pub unix_utc_midnight: i64,
pub offset_start_seconds: i32,
pub offset_end_seconds: i32,
pub wall_day_seconds: i64,
pub dst_transition: Option<DstTransition>,
pub artifacts: Vec<Artifact>,
#[cfg(feature = "lunisolar")]
pub alt_chinese: Option<ChineseDate>,
#[cfg(feature = "lunisolar")]
pub moon: Option<MoonInfo>,
#[cfg(feature = "lunisolar")]
pub solar_longitude_deg: Option<f64>,
#[cfg(feature = "altcal")]
pub alt_hebrew: Option<HebrewDate>,
#[cfg(feature = "altcal")]
pub alt_islamic: Option<IslamicDate>,
#[cfg(feature = "leap")]
pub leap_second: i8,
#[cfg(feature = "leap")]
pub utc_day_seconds: i64,
#[cfg(feature = "leap")]
pub in_leap_smear_window: bool,
#[cfg(feature = "leap")]
pub gps_week: i64,
}
const JDN_UNIX_EPOCH: i64 = 2_440_588;
const MJD_OFFSET: i64 = 2_400_001;
fn zone_to_tz(zone: &RenderZone) -> jiff::tz::TimeZone {
match zone {
RenderZone::Utc => jiff::tz::TimeZone::UTC,
RenderZone::Fixed(offset) => offset.to_time_zone(),
RenderZone::Named(tz) => tz.clone(),
}
}
fn artifacts_on(date: Date) -> Vec<Artifact> {
let mut out = Vec::new();
for f in crate::registry::FORMATS.iter() {
let epoch_ns = match f.encoding {
Encoding::LinearInt { epoch_ns, .. }
| Encoding::LinearFloat { epoch_ns, .. }
| Encoding::Embedded { epoch_ns, .. } => epoch_ns,
Encoding::Packed(_) => continue, };
let Ok(ts) = jiff::Timestamp::from_nanosecond(epoch_ns) else {
continue;
};
if ts.to_zoned(jiff::tz::TimeZone::UTC).date() == date {
out.push(Artifact {
kind: "epoch".to_string(),
name: f.id.to_string(),
at_utc: ts.to_string(),
citation: f.citation.to_string(),
});
}
}
for r in ROLLOVERS {
let ts = jiff::Timestamp::from_second(r.unix_second);
if let Ok(ts) = ts {
if ts.to_zoned(jiff::tz::TimeZone::UTC).date() == date {
out.push(Artifact {
kind: "rollover".to_string(),
name: r.name.to_string(),
at_utc: ts.to_string(),
citation: r.citation.to_string(),
});
}
}
}
out
}
fn first_instant(date: Date, tz: &jiff::tz::TimeZone) -> Result<jiff::Timestamp, ChronoError> {
date.at(0, 0, 0, 0)
.to_zoned(tz.clone())
.map(|z| z.timestamp())
.map_err(|e| ChronoError::Render(e.to_string()))
}
#[cfg(feature = "lunisolar")]
fn chinese_on(date: Date, zone: &RenderZone) -> Option<ChineseDate> {
let tz = zone_to_tz(zone);
let noon = date
.at(12, 0, 0, 0)
.to_zoned(tz)
.ok()?
.timestamp()
.as_nanosecond();
let r = crate::lunisolar::render(crate::PosixNs(noon), zone, None).ok()?;
Some(ChineseDate {
lunar_year: r.lunar_year,
lunar_month: r.lunar_month,
lunar_day: r.lunar_day,
is_leap_month: r.is_leap_month,
year_pillar: r.year_pillar,
day_pillar: r.day_pillar,
solar_term: r.solar_term,
solar_longitude_deg: r.solar_longitude_deg,
})
}
#[cfg(feature = "altcal")]
fn altcal_on(date: Date) -> (Option<HebrewDate>, Option<IslamicDate>) {
let Ok(iso) = icu_calendar::Date::try_new_iso(
i32::from(date.year()),
date.month() as u8,
date.day() as u8,
) else {
return (None, None);
};
let h = iso.to_calendar(icu_calendar::cal::Hebrew);
let hebrew = HebrewDate {
year: h.era_year().year,
month: h.month().ordinal,
day: h.day_of_month().0,
month_code: h.month().to_input().code().to_string(),
};
let cal = icu_calendar::cal::Hijri::new_tabular(
icu_calendar::cal::HijriTabularLeapYears::TypeII,
icu_calendar::cal::HijriTabularEpoch::Friday,
);
let i = iso.to_calendar(cal);
let islamic = IslamicDate {
year: i.era_year().year,
month: i.month().ordinal,
day: i.day_of_month().0,
};
(Some(hebrew), Some(islamic))
}
#[cfg(feature = "lunisolar")]
fn moon_on(date: Date, zone: &RenderZone) -> Option<MoonInfo> {
let tz = zone_to_tz(zone);
let noon_ns = date
.at(12, 0, 0, 0)
.to_zoned(tz)
.ok()?
.timestamp()
.as_nanosecond();
#[allow(clippy::cast_precision_loss)]
let jd_ut = noon_ns as f64 / 1e9 / 86_400.0 + 2_440_587.5;
let jde_tt = jd_ut + stem_branch::delta_t_for_year(f64::from(date.year())) / 86_400.0;
let p = stem_branch::moon_phase(jde_tt);
let phase_index = (((p.elongation_deg + 22.5) / 45.0).floor() as i64).rem_euclid(8) as u8;
Some(MoonInfo {
phase_index,
phase_name: PHASE_NAMES[phase_index as usize].to_string(),
elongation_deg: p.elongation_deg,
phase_angle_deg: p.phase_angle_deg,
illuminated_fraction: p.illuminated_fraction,
waxing: p.waxing,
})
}
#[cfg(feature = "lunisolar")]
fn solar_longitude_on(date: Date, zone: &RenderZone) -> Option<f64> {
let tz = zone_to_tz(zone);
let noon_ns = date
.at(12, 0, 0, 0)
.to_zoned(tz)
.ok()?
.timestamp()
.as_nanosecond();
#[allow(clippy::cast_precision_loss)]
let jd_ut = noon_ns as f64 / 1e9 / 86_400.0 + 2_440_587.5;
let jde_tt = jd_ut + stem_branch::delta_t_for_year(f64::from(date.year())) / 86_400.0;
Some(
stem_branch::solar_ecliptic_state(jde_tt)
.apparent_longitude_degrees
.rem_euclid(360.0),
)
}
pub fn build_day(date: Date, zone: &RenderZone) -> Result<CalDay, ChronoError> {
let unix_utc_midnight = date
.at(0, 0, 0, 0)
.to_zoned(jiff::tz::TimeZone::UTC)
.map_err(|e| ChronoError::Render(e.to_string()))?
.timestamp()
.as_second();
let jdn = JDN_UNIX_EPOCH + unix_utc_midnight.div_euclid(86_400);
let iwd = date.iso_week_date();
let iso_weekday = date.weekday().to_monday_one_offset();
let weekday = match iso_weekday {
1 => "monday",
2 => "tuesday",
3 => "wednesday",
4 => "thursday",
5 => "friday",
6 => "saturday",
_ => "sunday",
};
let tz = zone_to_tz(zone);
let tomorrow = date
.tomorrow()
.map_err(|e| ChronoError::Render(e.to_string()))?;
let start = first_instant(date, &tz)?;
let next = first_instant(tomorrow, &tz)?;
let offset_start_seconds = tz.to_offset(start).seconds();
let offset_end_seconds = tz.to_offset(next).seconds();
let wall_day_seconds = next.as_second() - start.as_second();
let dst_transition = tz.following(start).next().and_then(|t| {
(t.timestamp() < next).then(|| {
let after = t.offset().seconds();
DstTransition {
kind: if after > offset_start_seconds {
"gap"
} else {
"fold"
}
.to_string(),
at_utc: t.timestamp().to_string(),
}
})
});
#[cfg(feature = "altcal")]
let (alt_hebrew, alt_islamic) = altcal_on(date);
Ok(CalDay {
date: date.to_string(),
weekday: weekday.to_string(),
iso_year: iwd.year(),
iso_week: iwd.week(),
iso_weekday,
day_of_year: date.day_of_year(),
days_in_year: date.days_in_year(),
jdn,
mjd: jdn - MJD_OFFSET,
unix_utc_midnight,
offset_start_seconds,
offset_end_seconds,
wall_day_seconds,
dst_transition,
artifacts: artifacts_on(date),
#[cfg(feature = "lunisolar")]
alt_chinese: chinese_on(date, zone),
#[cfg(feature = "lunisolar")]
moon: moon_on(date, zone),
#[cfg(feature = "lunisolar")]
solar_longitude_deg: solar_longitude_on(date, zone),
#[cfg(feature = "altcal")]
alt_hebrew,
#[cfg(feature = "altcal")]
alt_islamic,
#[cfg(feature = "leap")]
leap_second: crate::leap::leap_seconds_on_utc_day(unix_utc_midnight),
#[cfg(feature = "leap")]
utc_day_seconds: 86_400
+ i64::from(crate::leap::leap_seconds_on_utc_day(unix_utc_midnight)),
#[cfg(feature = "leap")]
in_leap_smear_window: crate::leap::within_leap_smear_window(unix_utc_midnight + 43_200),
#[cfg(feature = "leap")]
gps_week: crate::leap::gps_week(unix_utc_midnight),
})
}
fn zone_label(zone: &RenderZone) -> String {
match zone {
RenderZone::Utc => "UTC".to_string(),
RenderZone::Fixed(o) => o.to_string(),
RenderZone::Named(tz) => tz.iana_name().unwrap_or("local").to_string(),
}
}
pub fn build_month(
year: i16,
month: i8,
zone: &RenderZone,
week_start: WeekStart,
) -> Result<CalMonth, ChronoError> {
let first = Date::new(year, month, 1).map_err(|e| ChronoError::Render(e.to_string()))?;
let n = first.days_in_month();
let mut days = Vec::with_capacity(n as usize);
for d in 1..=n {
let date = Date::new(year, month, d).map_err(|e| ChronoError::Render(e.to_string()))?;
days.push(build_day(date, zone)?);
}
let lead = match week_start {
WeekStart::Monday => i32::from(first.weekday().to_monday_zero_offset()),
WeekStart::Sunday => i32::from(first.weekday().to_sunday_zero_offset()),
};
let mut cells: Vec<Option<usize>> = (0..lead).map(|_| None).collect();
cells.extend((0..days.len()).map(Some));
while !cells.len().is_multiple_of(7) {
cells.push(None);
}
let weeks: Vec<Vec<Option<usize>>> = cells.chunks(7).map(<[_]>::to_vec).collect();
Ok(CalMonth {
year,
month,
zone_label: zone_label(zone),
weeks,
days,
})
}