2026-05-30 18:35:40 +00:00
|
|
|
//! Date primitives for the diary subsystem.
|
2026-05-11 21:04:55 +00:00
|
|
|
//!
|
2026-05-30 18:35:40 +00:00
|
|
|
//! This crate deliberately doesn't pull in `chrono` or `time`. The diary feature
|
2026-05-11 21:04:55 +00:00
|
|
|
//! only needs `YYYY-MM-DD` parsing/formatting and ±1 day arithmetic, which
|
|
|
|
|
//! is small enough that the dependency cost outweighs the convenience.
|
|
|
|
|
//!
|
|
|
|
|
//! Date math uses Howard Hinnant's days-from-civil algorithm
|
|
|
|
|
//! (<https://howardhinnant.github.io/date_algorithms.html>) — proleptic
|
|
|
|
|
//! Gregorian, valid for any year representable in `i32`.
|
|
|
|
|
|
|
|
|
|
use std::cmp::Ordering;
|
|
|
|
|
use std::fmt;
|
|
|
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
|
|
|
|
|
|
/// A calendar day, decoupled from any time zone or wall-clock context.
|
|
|
|
|
///
|
|
|
|
|
/// Validation: `parse` and `from_ymd` reject impossible dates (month 0,
|
|
|
|
|
/// day 0, day > days-in-month, etc.). The struct fields are public so
|
|
|
|
|
/// callers can read them, but constructing one with bogus values bypasses
|
|
|
|
|
/// validation — use the constructors.
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
|
|
|
pub struct DiaryDate {
|
|
|
|
|
pub year: i32,
|
|
|
|
|
pub month: u8,
|
|
|
|
|
pub day: u8,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl DiaryDate {
|
|
|
|
|
/// Build a date from its components, validating the calendar.
|
|
|
|
|
pub fn from_ymd(year: i32, month: u8, day: u8) -> Option<Self> {
|
|
|
|
|
if !(1..=12).contains(&month) {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
if day < 1 || day > days_in_month(year, month) {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
Some(Self { year, month, day })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Parse a strict `YYYY-MM-DD` string. Rejects any deviation — extra
|
|
|
|
|
/// whitespace, missing zero-padding, alternate separators, etc.
|
|
|
|
|
pub fn parse(s: &str) -> Option<Self> {
|
|
|
|
|
let b = s.as_bytes();
|
|
|
|
|
if b.len() != 10 || b[4] != b'-' || b[7] != b'-' {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
let y = parse_digits(&b[0..4])?;
|
|
|
|
|
let m = parse_digits(&b[5..7])? as u8;
|
|
|
|
|
let d = parse_digits(&b[8..10])? as u8;
|
|
|
|
|
Self::from_ymd(y as i32, m, d)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Format as `YYYY-MM-DD`. Years outside 0..=9999 still render
|
|
|
|
|
/// numerically; the diary use case never produces those.
|
|
|
|
|
pub fn format(&self) -> String {
|
|
|
|
|
format!("{:04}-{:02}-{:02}", self.year, self.month, self.day)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 18:35:40 +00:00
|
|
|
/// UTC "today" computed from `SystemTime::now()`. The diary
|
2026-05-11 21:04:55 +00:00
|
|
|
/// commands intentionally use UTC — local-time semantics depend on a
|
|
|
|
|
/// timezone DB we don't ship and would surprise users crossing DST
|
|
|
|
|
/// boundaries.
|
|
|
|
|
pub fn today_utc() -> Self {
|
|
|
|
|
let secs = SystemTime::now()
|
|
|
|
|
.duration_since(UNIX_EPOCH)
|
|
|
|
|
.map(|d| d.as_secs())
|
|
|
|
|
.unwrap_or(0);
|
|
|
|
|
let days = (secs / 86_400) as i64;
|
|
|
|
|
from_days(days)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn next_day(&self) -> Self {
|
|
|
|
|
from_days(to_days(self.year, self.month, self.day) + 1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn prev_day(&self) -> Self {
|
|
|
|
|
from_days(to_days(self.year, self.month, self.day) - 1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Internal — exposed for tests that want to verify ordering against
|
|
|
|
|
/// the days-from-epoch representation.
|
|
|
|
|
pub fn to_days_epoch(&self) -> i64 {
|
|
|
|
|
to_days(self.year, self.month, self.day)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl PartialOrd for DiaryDate {
|
|
|
|
|
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
|
|
|
|
Some(self.cmp(other))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Ord for DiaryDate {
|
|
|
|
|
fn cmp(&self, other: &Self) -> Ordering {
|
|
|
|
|
self.to_days_epoch().cmp(&other.to_days_epoch())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl fmt::Display for DiaryDate {
|
|
|
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
|
|
|
write!(f, "{}", self.format())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parse_digits(b: &[u8]) -> Option<u32> {
|
|
|
|
|
if b.is_empty() {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
let mut n: u32 = 0;
|
|
|
|
|
for ch in b {
|
|
|
|
|
if !ch.is_ascii_digit() {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
n = n.checked_mul(10)?.checked_add((*ch - b'0') as u32)?;
|
|
|
|
|
}
|
|
|
|
|
Some(n)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn is_leap(year: i32) -> bool {
|
|
|
|
|
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn days_in_month(year: i32, month: u8) -> u8 {
|
|
|
|
|
match month {
|
|
|
|
|
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
|
|
|
|
|
4 | 6 | 9 | 11 => 30,
|
|
|
|
|
2 => {
|
|
|
|
|
if is_leap(year) {
|
|
|
|
|
29
|
|
|
|
|
} else {
|
|
|
|
|
28
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
_ => 0,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Howard Hinnant's `days_from_civil`: 1970-01-01 → 0.
|
|
|
|
|
fn to_days(year: i32, month: u8, day: u8) -> i64 {
|
|
|
|
|
let y = year as i64 - if (month as i64) <= 2 { 1 } else { 0 };
|
|
|
|
|
let era = y.div_euclid(400);
|
|
|
|
|
let yoe = y.rem_euclid(400);
|
|
|
|
|
let m = month as i64;
|
|
|
|
|
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + day as i64 - 1;
|
|
|
|
|
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
|
|
|
|
era * 146_097 + doe - 719_468
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 22:09:14 +00:00
|
|
|
// ============================================================
|
|
|
|
|
// Diary frequency / period
|
|
|
|
|
// ============================================================
|
|
|
|
|
//
|
|
|
|
|
// `DiaryFrequency` is the user-configured cadence (mirrors vimwiki's
|
|
|
|
|
// `diary_frequency` setting). `DiaryPeriod` is the *concrete* diary
|
|
|
|
|
// entry — a specific day, ISO week, calendar month, or year — that
|
|
|
|
|
// the LSP commands compute and the renderer addresses.
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
|
|
|
pub enum DiaryFrequency {
|
|
|
|
|
Daily,
|
|
|
|
|
Weekly,
|
|
|
|
|
Monthly,
|
|
|
|
|
Yearly,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl DiaryFrequency {
|
|
|
|
|
/// Parse the string form used in `WikiConfig.diary_frequency`.
|
|
|
|
|
/// Falls back to `Daily` for unrecognised values — vimwiki's
|
|
|
|
|
/// permissive behaviour for stale configs.
|
|
|
|
|
pub fn parse(s: &str) -> Self {
|
|
|
|
|
match s.trim().to_ascii_lowercase().as_str() {
|
|
|
|
|
"weekly" | "week" => Self::Weekly,
|
|
|
|
|
"monthly" | "month" => Self::Monthly,
|
|
|
|
|
"yearly" | "year" => Self::Yearly,
|
|
|
|
|
_ => Self::Daily,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn as_str(self) -> &'static str {
|
|
|
|
|
match self {
|
|
|
|
|
Self::Daily => "daily",
|
|
|
|
|
Self::Weekly => "weekly",
|
|
|
|
|
Self::Monthly => "monthly",
|
|
|
|
|
Self::Yearly => "yearly",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A specific diary entry, identified by its calendar period. The four
|
|
|
|
|
/// variants share the same `format` / `parse` / `next` / `prev` /
|
|
|
|
|
/// `today_utc` surface so the LSP dispatcher can stay frequency-agnostic.
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
|
|
|
pub enum DiaryPeriod {
|
|
|
|
|
Day(DiaryDate),
|
|
|
|
|
/// ISO 8601 week — year/week pair, week ∈ 1..=53.
|
|
|
|
|
Week {
|
|
|
|
|
iso_year: i32,
|
|
|
|
|
iso_week: u8,
|
|
|
|
|
},
|
|
|
|
|
Month {
|
|
|
|
|
year: i32,
|
|
|
|
|
month: u8,
|
|
|
|
|
},
|
|
|
|
|
Year {
|
|
|
|
|
year: i32,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl DiaryPeriod {
|
|
|
|
|
/// "Now" for the given frequency, in UTC. See `DiaryDate::today_utc`
|
|
|
|
|
/// for the timezone rationale.
|
|
|
|
|
pub fn today_utc(freq: DiaryFrequency) -> Self {
|
|
|
|
|
let today = DiaryDate::today_utc();
|
|
|
|
|
match freq {
|
|
|
|
|
DiaryFrequency::Daily => Self::Day(today),
|
|
|
|
|
DiaryFrequency::Weekly => Self::week_containing(today),
|
|
|
|
|
DiaryFrequency::Monthly => Self::Month {
|
|
|
|
|
year: today.year,
|
|
|
|
|
month: today.month,
|
|
|
|
|
},
|
|
|
|
|
DiaryFrequency::Yearly => Self::Year { year: today.year },
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// File-stem format — also the format vimwiki uses for diary link
|
|
|
|
|
/// targets (`[[diary:2026-W19]]`, `[[diary:2026-05]]`, etc.).
|
|
|
|
|
///
|
|
|
|
|
/// Day(2026-05-12) → `2026-05-12`
|
|
|
|
|
/// Week(2026, 19) → `2026-W19`
|
|
|
|
|
/// Month(2026, 5) → `2026-05`
|
|
|
|
|
/// Year(2026) → `2026`
|
|
|
|
|
pub fn format(&self) -> String {
|
|
|
|
|
match self {
|
|
|
|
|
Self::Day(d) => d.format(),
|
|
|
|
|
Self::Week { iso_year, iso_week } => format!("{iso_year:04}-W{iso_week:02}"),
|
|
|
|
|
Self::Month { year, month } => format!("{year:04}-{month:02}"),
|
|
|
|
|
Self::Year { year } => format!("{year:04}"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Inverse of `format` — recognise any of the four stems. Returns
|
|
|
|
|
/// `None` for inputs that don't match any flavour.
|
|
|
|
|
pub fn parse(s: &str) -> Option<Self> {
|
|
|
|
|
// Day: `YYYY-MM-DD`
|
|
|
|
|
if let Some(d) = DiaryDate::parse(s) {
|
|
|
|
|
return Some(Self::Day(d));
|
|
|
|
|
}
|
|
|
|
|
let b = s.as_bytes();
|
|
|
|
|
// Week: `YYYY-Www`
|
|
|
|
|
if b.len() == 8 && b[4] == b'-' && (b[5] == b'W' || b[5] == b'w') {
|
|
|
|
|
let y = parse_digits(&b[0..4])? as i32;
|
|
|
|
|
let w = parse_digits(&b[6..8])? as u8;
|
|
|
|
|
if (1..=53).contains(&w) {
|
|
|
|
|
return Some(Self::Week {
|
|
|
|
|
iso_year: y,
|
|
|
|
|
iso_week: w,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Month: `YYYY-MM`
|
|
|
|
|
if b.len() == 7 && b[4] == b'-' {
|
|
|
|
|
let y = parse_digits(&b[0..4])? as i32;
|
|
|
|
|
let m = parse_digits(&b[5..7])? as u8;
|
|
|
|
|
if (1..=12).contains(&m) {
|
|
|
|
|
return Some(Self::Month { year: y, month: m });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Year: `YYYY`
|
|
|
|
|
if b.len() == 4 {
|
|
|
|
|
let y = parse_digits(&b[0..4])? as i32;
|
|
|
|
|
return Some(Self::Year { year: y });
|
|
|
|
|
}
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn next(&self) -> Self {
|
|
|
|
|
match *self {
|
|
|
|
|
Self::Day(d) => Self::Day(d.next_day()),
|
|
|
|
|
Self::Week { iso_year, iso_week } => {
|
|
|
|
|
// Step forward one week: take the Monday of this ISO week,
|
|
|
|
|
// add 7 days, recompute the (year, week) it falls in.
|
|
|
|
|
let mon = monday_of_iso_week(iso_year, iso_week);
|
|
|
|
|
let next_mon = from_days(to_days(mon.year, mon.month, mon.day) + 7);
|
|
|
|
|
let (iso_year, iso_week) = iso_year_week(&next_mon);
|
|
|
|
|
Self::Week { iso_year, iso_week }
|
|
|
|
|
}
|
|
|
|
|
Self::Month { year, month } => {
|
|
|
|
|
if month == 12 {
|
|
|
|
|
Self::Month {
|
|
|
|
|
year: year + 1,
|
|
|
|
|
month: 1,
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
Self::Month {
|
|
|
|
|
year,
|
|
|
|
|
month: month + 1,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Self::Year { year } => Self::Year { year: year + 1 },
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn prev(&self) -> Self {
|
|
|
|
|
match *self {
|
|
|
|
|
Self::Day(d) => Self::Day(d.prev_day()),
|
|
|
|
|
Self::Week { iso_year, iso_week } => {
|
|
|
|
|
let mon = monday_of_iso_week(iso_year, iso_week);
|
|
|
|
|
let prev_mon = from_days(to_days(mon.year, mon.month, mon.day) - 7);
|
|
|
|
|
let (iso_year, iso_week) = iso_year_week(&prev_mon);
|
|
|
|
|
Self::Week { iso_year, iso_week }
|
|
|
|
|
}
|
|
|
|
|
Self::Month { year, month } => {
|
|
|
|
|
if month == 1 {
|
|
|
|
|
Self::Month {
|
|
|
|
|
year: year - 1,
|
|
|
|
|
month: 12,
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
Self::Month {
|
|
|
|
|
year,
|
|
|
|
|
month: month - 1,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Self::Year { year } => Self::Year { year: year - 1 },
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Compute the ISO week containing the given calendar date.
|
|
|
|
|
pub fn week_containing(d: DiaryDate) -> Self {
|
|
|
|
|
let (iso_year, iso_week) = iso_year_week(&d);
|
|
|
|
|
Self::Week { iso_year, iso_week }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// First calendar day of this period — useful for chronological
|
|
|
|
|
/// sorting across mixed-frequency entries.
|
|
|
|
|
///
|
|
|
|
|
/// Day(d) → d
|
|
|
|
|
/// Week(y,w) → Monday of that ISO week
|
|
|
|
|
/// Month(y,m) → 1st of that month
|
|
|
|
|
/// Year(y) → Jan 1st of that year
|
|
|
|
|
pub fn first_day(&self) -> DiaryDate {
|
|
|
|
|
match *self {
|
|
|
|
|
Self::Day(d) => d,
|
|
|
|
|
Self::Week { iso_year, iso_week } => monday_of_iso_week(iso_year, iso_week),
|
|
|
|
|
Self::Month { year, month } => {
|
|
|
|
|
DiaryDate::from_ymd(year, month, 1).expect("validated month")
|
|
|
|
|
}
|
|
|
|
|
Self::Year { year } => DiaryDate::from_ymd(year, 1, 1).expect("Jan 1st always valid"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl fmt::Display for DiaryPeriod {
|
|
|
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
|
|
|
write!(f, "{}", self.format())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 17:06:09 +00:00
|
|
|
/// How a *weekly* diary entry is named.
|
|
|
|
|
///
|
|
|
|
|
/// `Iso` — ISO-week label `YYYY-Www` (nuwiki's original scheme; the week
|
|
|
|
|
/// always begins on Monday, `week_start` is ignored).
|
|
|
|
|
/// `Date` — the week-start day's calendar date `YYYY-MM-DD`, matching
|
|
|
|
|
/// upstream vimwiki; honours `week_start`.
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
pub enum WeeklyStyle {
|
|
|
|
|
Iso,
|
|
|
|
|
Date,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl WeeklyStyle {
|
|
|
|
|
/// Parse the config string. `date`/`vimwiki` → `Date`; anything else
|
|
|
|
|
/// (including `iso`/`week`/empty) → `Iso`.
|
|
|
|
|
pub fn parse(s: &str) -> Self {
|
|
|
|
|
match s.trim().to_ascii_lowercase().as_str() {
|
|
|
|
|
"date" | "vimwiki" | "day" => Self::Date,
|
|
|
|
|
_ => Self::Iso,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Day a week begins on, in ISO numbering (Monday = 1 … Sunday = 7).
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
pub struct WeekStart(u8);
|
|
|
|
|
|
|
|
|
|
impl WeekStart {
|
|
|
|
|
/// Parse a weekday name (`monday`..`sunday`); unknown → Monday.
|
|
|
|
|
pub fn parse(s: &str) -> Self {
|
|
|
|
|
let n = match s.trim().to_ascii_lowercase().as_str() {
|
|
|
|
|
"tuesday" => 2,
|
|
|
|
|
"wednesday" => 3,
|
|
|
|
|
"thursday" => 4,
|
|
|
|
|
"friday" => 5,
|
|
|
|
|
"saturday" => 6,
|
|
|
|
|
"sunday" => 7,
|
|
|
|
|
_ => 1, // monday (default)
|
|
|
|
|
};
|
|
|
|
|
Self(n)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn monday() -> Self {
|
|
|
|
|
Self(1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn iso(self) -> i64 {
|
|
|
|
|
self.0 as i64
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Diary navigation policy: frequency plus, for weekly diaries, the naming
|
|
|
|
|
/// scheme and week-start day. Built from a wiki's `diary_*` config so the
|
|
|
|
|
/// LSP commands can compute today / next / prev without re-deriving the
|
|
|
|
|
/// rules. `Daily`/`Monthly`/`Yearly` ignore the weekly fields.
|
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
|
|
|
pub struct DiaryCalendar {
|
|
|
|
|
pub freq: DiaryFrequency,
|
|
|
|
|
pub weekly_style: WeeklyStyle,
|
|
|
|
|
pub week_start: WeekStart,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl DiaryCalendar {
|
|
|
|
|
pub fn new(freq: DiaryFrequency, weekly_style: WeeklyStyle, week_start: WeekStart) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
freq,
|
|
|
|
|
weekly_style,
|
|
|
|
|
week_start,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn is_weekly_date(&self) -> bool {
|
|
|
|
|
self.freq == DiaryFrequency::Weekly && self.weekly_style == WeeklyStyle::Date
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// "Now" as a diary period for this calendar. Date-mode weekly snaps
|
|
|
|
|
/// today back to the most recent week-start day (a plain `Day`, so the
|
|
|
|
|
/// file is `YYYY-MM-DD` like upstream); everything else defers to
|
|
|
|
|
/// [`DiaryPeriod::today_utc`].
|
|
|
|
|
pub fn today(&self) -> DiaryPeriod {
|
|
|
|
|
if self.is_weekly_date() {
|
|
|
|
|
DiaryPeriod::Day(self.week_start_of(DiaryDate::today_utc()))
|
|
|
|
|
} else {
|
|
|
|
|
DiaryPeriod::today_utc(self.freq)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The period one cadence-step after `p`.
|
|
|
|
|
pub fn next(&self, p: DiaryPeriod) -> DiaryPeriod {
|
|
|
|
|
self.step(p, 1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The period one cadence-step before `p`.
|
|
|
|
|
pub fn prev(&self, p: DiaryPeriod) -> DiaryPeriod {
|
|
|
|
|
self.step(p, -1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn step(&self, p: DiaryPeriod, dir: i64) -> DiaryPeriod {
|
|
|
|
|
// Date-mode weekly: the period is a `Day` on a week-start; a step is
|
|
|
|
|
// ±7 days (re-snapped so an off-week-start pivot still lands cleanly).
|
|
|
|
|
if self.is_weekly_date() {
|
|
|
|
|
if let DiaryPeriod::Day(d) = p {
|
|
|
|
|
let start = self.week_start_of(d);
|
|
|
|
|
return DiaryPeriod::Day(add_days(start, 7 * dir));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if dir >= 0 {
|
|
|
|
|
p.next()
|
|
|
|
|
} else {
|
|
|
|
|
p.prev()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The most recent `week_start` weekday on or before `d`.
|
|
|
|
|
fn week_start_of(&self, d: DiaryDate) -> DiaryDate {
|
|
|
|
|
let wd = iso_weekday(&d) as i64; // 1..=7
|
|
|
|
|
let back = (wd - self.week_start.iso()).rem_euclid(7);
|
|
|
|
|
add_days(d, -back)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Shift a calendar date by `delta` days (may be negative).
|
|
|
|
|
fn add_days(d: DiaryDate, delta: i64) -> DiaryDate {
|
|
|
|
|
from_days(to_days(d.year, d.month, d.day) + delta)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 22:09:14 +00:00
|
|
|
/// Day-of-week with Monday=1 … Sunday=7 (ISO 8601).
|
|
|
|
|
fn iso_weekday(d: &DiaryDate) -> u8 {
|
|
|
|
|
// Zeller-style via days-from-epoch: 1970-01-01 was a Thursday.
|
|
|
|
|
let days = to_days(d.year, d.month, d.day);
|
|
|
|
|
// (days + 3) mod 7 puts Monday at 0 … Sunday at 6; shift to 1..=7.
|
|
|
|
|
let r = (days + 3).rem_euclid(7);
|
|
|
|
|
(r as u8) + 1
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// ISO 8601 `(year, week)` for a calendar date. The ISO year may differ
|
|
|
|
|
/// from the calendar year for early January / late December dates.
|
|
|
|
|
fn iso_year_week(d: &DiaryDate) -> (i32, u8) {
|
|
|
|
|
// Standard algorithm: shift to the Thursday of the same ISO week,
|
|
|
|
|
// then ISO year = thursday.year, ISO week = ordinal_day(thursday) / 7 + 1.
|
|
|
|
|
let weekday = iso_weekday(d) as i64; // 1..=7
|
|
|
|
|
let days = to_days(d.year, d.month, d.day);
|
|
|
|
|
let thursday = from_days(days + 4 - weekday);
|
|
|
|
|
let jan1 = to_days(thursday.year, 1, 1);
|
|
|
|
|
let ordinal = to_days(thursday.year, thursday.month, thursday.day) - jan1 + 1;
|
|
|
|
|
let week = ((ordinal - 1) / 7 + 1) as u8;
|
|
|
|
|
(thursday.year, week)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Date of the Monday that opens the given ISO `(year, week)`.
|
|
|
|
|
fn monday_of_iso_week(iso_year: i32, iso_week: u8) -> DiaryDate {
|
|
|
|
|
// Find Jan 4th of iso_year — always in ISO week 1 by definition.
|
|
|
|
|
let jan4 = DiaryDate {
|
|
|
|
|
year: iso_year,
|
|
|
|
|
month: 1,
|
|
|
|
|
day: 4,
|
|
|
|
|
};
|
|
|
|
|
let jan4_weekday = iso_weekday(&jan4) as i64; // 1..=7
|
|
|
|
|
let week1_monday_days = to_days(jan4.year, jan4.month, jan4.day) - (jan4_weekday - 1);
|
|
|
|
|
let target = week1_monday_days + (iso_week as i64 - 1) * 7;
|
|
|
|
|
from_days(target)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 21:04:55 +00:00
|
|
|
/// Inverse of [`to_days`].
|
|
|
|
|
fn from_days(days: i64) -> DiaryDate {
|
|
|
|
|
let z = days + 719_468;
|
|
|
|
|
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
|
|
|
|
let doe = (z - era * 146_097) as u64;
|
|
|
|
|
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
|
|
|
|
let y = yoe as i64 + era * 400;
|
|
|
|
|
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
|
|
|
|
let mp = (5 * doy + 2) / 153;
|
|
|
|
|
let d = (doy - (153 * mp + 2) / 5 + 1) as u8;
|
|
|
|
|
let m_int = if mp < 10 { mp + 3 } else { mp - 9 };
|
|
|
|
|
let year = y + if m_int <= 2 { 1 } else { 0 };
|
|
|
|
|
DiaryDate {
|
|
|
|
|
year: year as i32,
|
|
|
|
|
month: m_int as u8,
|
|
|
|
|
day: d,
|
|
|
|
|
}
|
|
|
|
|
}
|