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 month_pillar: String,
pub day_pillar: String,
pub hour_pillar: String,
pub solar_term: String,
pub days_into_term: u32,
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 = "altcal")]
#[derive(Debug, Clone, serde::Serialize)]
pub struct ExtraCal {
pub key: String,
pub name: String,
pub year: i32,
pub month: u8,
pub day: u8,
pub formatted: String,
pub year_label: String,
pub month_label: String,
}
#[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")]
const SOUTHERN_ZONES: &[&str] = &[
"Africa/Blantyre",
"Africa/Brazzaville",
"Africa/Bujumbura",
"Africa/Dar_es_Salaam",
"Africa/Gaborone",
"Africa/Harare",
"Africa/Johannesburg",
"Africa/Kigali",
"Africa/Kinshasa",
"Africa/Luanda",
"Africa/Lubumbashi",
"Africa/Lusaka",
"Africa/Maputo",
"Africa/Maseru",
"Africa/Mbabane",
"Africa/Nairobi",
"Africa/Windhoek",
"America/Araguaina",
"America/Argentina/Buenos_Aires",
"America/Argentina/Catamarca",
"America/Argentina/Cordoba",
"America/Argentina/Jujuy",
"America/Argentina/La_Rioja",
"America/Argentina/Mendoza",
"America/Argentina/Rio_Gallegos",
"America/Argentina/Salta",
"America/Argentina/San_Juan",
"America/Argentina/San_Luis",
"America/Argentina/Tucuman",
"America/Argentina/Ushuaia",
"America/Asuncion",
"America/Bahia",
"America/Belem",
"America/Campo_Grande",
"America/Coyhaique",
"America/Cuiaba",
"America/Eirunepe",
"America/Fortaleza",
"America/Guayaquil",
"America/La_Paz",
"America/Lima",
"America/Maceio",
"America/Manaus",
"America/Montevideo",
"America/Noronha",
"America/Porto_Velho",
"America/Punta_Arenas",
"America/Recife",
"America/Rio_Branco",
"America/Santarem",
"America/Santiago",
"America/Sao_Paulo",
"Antarctica/Casey",
"Antarctica/Davis",
"Antarctica/DumontDUrville",
"Antarctica/Macquarie",
"Antarctica/Mawson",
"Antarctica/McMurdo",
"Antarctica/Palmer",
"Antarctica/Rothera",
"Antarctica/Syowa",
"Antarctica/Troll",
"Antarctica/Vostok",
"Asia/Dili",
"Asia/Jakarta",
"Asia/Jayapura",
"Asia/Makassar",
"Atlantic/South_Georgia",
"Atlantic/St_Helena",
"Atlantic/Stanley",
"Australia/Adelaide",
"Australia/Brisbane",
"Australia/Broken_Hill",
"Australia/Darwin",
"Australia/Eucla",
"Australia/Hobart",
"Australia/Lindeman",
"Australia/Lord_Howe",
"Australia/Melbourne",
"Australia/Perth",
"Australia/Sydney",
"Indian/Antananarivo",
"Indian/Chagos",
"Indian/Christmas",
"Indian/Cocos",
"Indian/Comoro",
"Indian/Kerguelen",
"Indian/Mahe",
"Indian/Mauritius",
"Indian/Mayotte",
"Indian/Reunion",
"Pacific/Apia",
"Pacific/Auckland",
"Pacific/Bougainville",
"Pacific/Chatham",
"Pacific/Easter",
"Pacific/Efate",
"Pacific/Fakaofo",
"Pacific/Fiji",
"Pacific/Funafuti",
"Pacific/Gambier",
"Pacific/Guadalcanal",
"Pacific/Kanton",
"Pacific/Marquesas",
"Pacific/Niue",
"Pacific/Norfolk",
"Pacific/Noumea",
"Pacific/Pago_Pago",
"Pacific/Pitcairn",
"Pacific/Port_Moresby",
"Pacific/Rarotonga",
"Pacific/Tahiti",
"Pacific/Tongatapu",
"Pacific/Wallis",
];
#[cfg(feature = "lunisolar")]
#[must_use]
pub fn hemisphere_for(zone: &RenderZone) -> Hemisphere {
if let RenderZone::Named(tz) = zone {
if tz.iana_name().is_some_and(|n| SOUTHERN_ZONES.contains(&n)) {
return Hemisphere::South;
}
}
Hemisphere::North
}
#[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 = "lunisolar")]
pub season: Option<String>,
#[cfg(feature = "lunisolar")]
pub southern_hemisphere: bool,
#[cfg(feature = "altcal")]
pub extra_calendars: Vec<ExtraCal>,
#[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_at(ref_ns: i128, zone: &RenderZone) -> Option<ChineseDate> {
let r = crate::lunisolar::render(crate::PosixNs(ref_ns), 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,
month_pillar: r.month_pillar,
day_pillar: r.day_pillar,
hour_pillar: r.hour_pillar,
solar_term: r.solar_term,
days_into_term: r.days_into_term,
solar_longitude_deg: r.solar_longitude_deg,
})
}
#[cfg(feature = "altcal")]
fn icu_iso(date: Date) -> Option<icu_calendar::Date<icu_calendar::cal::Iso>> {
icu_calendar::Date::try_new_iso(i32::from(date.year()), date.month() as u8, date.day() as u8)
.ok()
}
#[cfg(feature = "altcal")]
#[must_use]
pub fn hebrew_date(date: Date) -> Option<HebrewDate> {
let h = icu_iso(date)?.to_calendar(icu_calendar::cal::Hebrew);
Some(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(),
})
}
#[cfg(feature = "altcal")]
#[must_use]
pub fn islamic_date(date: Date) -> Option<IslamicDate> {
let cal = icu_calendar::cal::Hijri::new_tabular(
icu_calendar::cal::HijriTabularLeapYears::TypeII,
icu_calendar::cal::HijriTabularEpoch::Friday,
);
let i = icu_iso(date)?.to_calendar(cal);
Some(IslamicDate {
year: i.era_year().year,
month: i.month().ordinal,
day: i.day_of_month().0,
})
}
#[cfg(feature = "altcal")]
#[must_use]
pub fn extra_calendars(date: Date) -> Vec<ExtraCal> {
use crate::calfmt::{
greg_month_abbr, hebrew_month, islamic_month, japanese_era, persian_month,
};
let Some(iso) = icu_iso(date) else {
return Vec::new();
};
let mut out = Vec::with_capacity(6);
let r = iso.to_calendar(icu_calendar::cal::Roc);
let (ry, rm, rd) = (r.era_year().year, r.month().ordinal, r.day_of_month().0);
out.push(ExtraCal {
key: "roc".to_string(),
name: "中華民國 Republic of China".to_string(),
year: ry,
month: rm,
day: rd,
formatted: format!("{ry}年{rm}月{rd}日"),
year_label: format!("{ry}年"),
month_label: format!("{ry}年{rm}月"),
});
let j = iso.to_calendar(icu_calendar::cal::Japanese::new());
let jey = j.era_year();
let (jy, jm, jd) = (jey.year, j.month().ordinal, j.day_of_month().0);
out.push(ExtraCal {
key: "japanese".to_string(),
name: "和暦 Japanese".to_string(),
year: jy,
month: jm,
day: jd,
formatted: format!("{}{jy}年{jm}月{jd}日", japanese_era(jey.era.as_str())),
year_label: format!("{}{jy}年", japanese_era(jey.era.as_str())),
month_label: format!("{}{jy}年{jm}月", japanese_era(jey.era.as_str())),
});
let b = iso.to_calendar(icu_calendar::cal::Buddhist);
let (by, bm, bd) = (b.era_year().year, b.month().ordinal, b.day_of_month().0);
out.push(ExtraCal {
key: "buddhist".to_string(),
name: "बौद्ध संवत् Buddhist".to_string(),
year: by,
month: bm,
day: bd,
formatted: format!("{bd} {} {by} BE", greg_month_abbr(bm)),
year_label: format!("{by} BE"),
month_label: format!("{} {by} BE", greg_month_abbr(bm)),
});
if let Some(h) = hebrew_date(date) {
out.push(ExtraCal {
key: "hebrew".to_string(),
name: "לוח עברי Hebrew".to_string(),
year: h.year,
month: h.month,
day: h.day,
formatted: format!("{} {} {}", h.day, hebrew_month(&h.month_code), h.year),
year_label: h.year.to_string(),
month_label: format!("{} {}", hebrew_month(&h.month_code), h.year),
});
}
if let Some(i) = islamic_date(date) {
out.push(ExtraCal {
key: "islamic".to_string(),
name: "هجري Islamic".to_string(),
year: i.year,
month: i.month,
day: i.day,
formatted: format!("{} {} {}", i.day, islamic_month(i.month), i.year),
year_label: i.year.to_string(),
month_label: format!("{} {}", islamic_month(i.month), i.year),
});
}
let p = iso.to_calendar(icu_calendar::cal::Persian);
let (py, pm, pd) = (p.era_year().year, p.month().ordinal, p.day_of_month().0);
out.push(ExtraCal {
key: "persian".to_string(),
name: "خورشیدی Persian".to_string(),
year: py,
month: pm,
day: pd,
formatted: format!("{pd} {} {py}", persian_month(pm)),
year_label: py.to_string(),
month_label: format!("{} {}", persian_month(pm), py),
});
out
}
#[cfg(feature = "altcal")]
#[must_use]
pub fn extra_calendars_at(instant: crate::PosixNs, zone: &RenderZone) -> Vec<ExtraCal> {
let Ok(ts) = jiff::Timestamp::from_nanosecond(instant.0) else {
return Vec::new();
};
extra_calendars(ts.to_zoned(zone_to_tz(zone)).date())
}
#[cfg(feature = "lunisolar")]
fn jde_tt_of(ref_ns: i128, year: i16) -> f64 {
#[allow(clippy::cast_precision_loss)]
let jd_ut = ref_ns as f64 / 1e9 / 86_400.0 + 2_440_587.5;
jd_ut + stem_branch::delta_t_for_year(f64::from(year)) / 86_400.0
}
#[cfg(feature = "lunisolar")]
fn moon_at(ref_ns: i128, year: i16) -> MoonInfo {
let p = stem_branch::moon_phase(jde_tt_of(ref_ns, year));
let phase_index = (((p.elongation_deg + 22.5) / 45.0).floor() as i64).rem_euclid(8) as u8;
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_at(ref_ns: i128, year: i16) -> f64 {
stem_branch::solar_ecliptic_state(jde_tt_of(ref_ns, year))
.apparent_longitude_degrees
.rem_euclid(360.0)
}
pub fn build_day(date: Date, zone: &RenderZone) -> Result<CalDay, ChronoError> {
build_day_at(date.at(12, 0, 0, 0), zone)
}
pub fn build_day_at(dt: jiff::civil::DateTime, zone: &RenderZone) -> Result<CalDay, ChronoError> {
let date = dt.date();
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 = "lunisolar")]
let ref_ns: i128 = dt.to_zoned(tz.clone()).map_or_else(
|_| i128::from(unix_utc_midnight + 43_200) * 1_000_000_000,
|z| z.timestamp().as_nanosecond(),
);
#[cfg(feature = "lunisolar")]
let solar_lon = solar_longitude_at(ref_ns, date.year());
#[cfg(feature = "lunisolar")]
let hemisphere = hemisphere_for(zone);
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_at(ref_ns, zone),
#[cfg(feature = "lunisolar")]
moon: Some(moon_at(ref_ns, date.year())),
#[cfg(feature = "lunisolar")]
solar_longitude_deg: Some(solar_lon),
#[cfg(feature = "lunisolar")]
season: Some(season_for(solar_lon, hemisphere).to_string()),
#[cfg(feature = "lunisolar")]
southern_hemisphere: hemisphere == Hemisphere::South,
#[cfg(feature = "altcal")]
extra_calendars: extra_calendars(date),
#[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,
})
}