feat(rhema-code): add the colors setting with four palettes
The stored `colors` key names a palette — rhema (the default, Jadeite), aquamarine, black-and-white, or dracula — and the Appearance page of /settings cycles it live, like the theme and icon toggles beside it. Each palette carries a dark and a light appearance under the existing `theme` key; dracula's light side is derived, the official palette ships only dark. The appearance decision moves off token comparison: Theme now answers is_dark() from the ground/text luminance, so palettes beyond the original two get the right per-profile fallback tones.
This commit is contained in:
commit
81da26b955
159 files changed
+50628
No files matched your search
@@ -0,0 +1,161 @@
|
||||
//! The normalized event vocabulary shared by every calendar backend.
|
||||
//!
|
||||
//! Constraints the normalization pins (each adapter module documents how
|
||||
//! it meets them):
|
||||
//! - An event is one occurrence, never a series. Recurring masters are
|
||||
//! expanded (by the Google server with `singleEvents=true`, by the
|
||||
//! `ics` adapter client-side), so a consumer never sees a rule, only
|
||||
//! starts it can compare against its clock.
|
||||
//! - [`CalendarEvent::id`] is the dedup id a dispatcher keys on:
|
||||
//! `{provider}:{uid}@{unix start}`. Two polls of the same occurrence
|
||||
//! produce the same id; the id changes only when the occurrence
|
||||
//! itself is a different one. What a consumer does about the
|
||||
//! repetition (dispatch dedup, reminder slots) is the consumer's
|
||||
//! seam, not this crate's.
|
||||
//! - Times are instants, not wall-clock strings. A wire time with no
|
||||
//! offset reads as UTC, and an all-day date starts at its midnight
|
||||
//! UTC with [`CalendarEvent::all_day`] set, so every `start`
|
||||
//! compares against any clock without a timezone database on board.
|
||||
//! - [`CalendarEvent::end`] is exclusive, as every wire format spells
|
||||
//! it, and `None` when the row names no end.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use time::OffsetDateTime;
|
||||
use time::serde::rfc3339;
|
||||
|
||||
/// One occurrence on a calendar: a meeting, a birthday, a block of
|
||||
/// time. Everything a reminder needs to say, lifted from whichever
|
||||
/// dialect produced it.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CalendarEvent {
|
||||
/// Stable dedup id, `{provider}:{uid}@{unix start}` (e.g.
|
||||
/// `ics:c1f3a9@1792471200`,
|
||||
/// `google:_70ojsm0k6nojajk6@1792471200`): one occurrence, one id,
|
||||
/// across polls. A recurring series expands to one event per
|
||||
/// occurrence, each with its own.
|
||||
pub id: String,
|
||||
/// The title (`SUMMARY` / `summary`); empty when the row has none.
|
||||
pub title: String,
|
||||
/// When the occurrence starts.
|
||||
#[serde(with = "rfc3339")]
|
||||
pub start: OffsetDateTime,
|
||||
/// When it ends, exclusive; `None` when the row names no end.
|
||||
#[serde(with = "rfc3339::option")]
|
||||
pub end: Option<OffsetDateTime>,
|
||||
/// Whether this is an all-day occurrence: the wire carried a date
|
||||
/// with no time, and [`CalendarEvent::start`] is that date's
|
||||
/// midnight UTC.
|
||||
pub all_day: bool,
|
||||
/// Where it happens, when the row says.
|
||||
pub location: Option<String>,
|
||||
/// The row's notes, when it has any.
|
||||
pub description: Option<String>,
|
||||
/// Who is invited, bare addresses (`mailto:` stripped, Google's
|
||||
/// `email` fields).
|
||||
pub attendees: Vec<String>,
|
||||
/// The organizer's address, when the row names one.
|
||||
pub organizer: Option<String>,
|
||||
/// Human-facing URL of the event (`URL` / `htmlLink`).
|
||||
pub url: Option<String>,
|
||||
/// The untouched wire row: the adapter's parsed property map for
|
||||
/// ICS, the JSON object for Google. Parameters the vocabulary does
|
||||
/// not lift (`TZID`, for one) ride here for a consumer that can do
|
||||
/// more with them.
|
||||
pub raw: serde_json::Value,
|
||||
}
|
||||
|
||||
impl CalendarEvent {
|
||||
/// The event's duration: `end - start` when the row names an end,
|
||||
/// zero otherwise. All-day rows without an `DTEND` are a zero-width
|
||||
/// midnight: the wire says the day, the duration is not this
|
||||
/// crate's guess to widen.
|
||||
pub fn duration(&self) -> time::Duration {
|
||||
self.end
|
||||
.map(|end| end - self.start)
|
||||
.unwrap_or(time::Duration::ZERO)
|
||||
}
|
||||
}
|
||||
|
||||
/// The stretch of time a poll asks about, as starts: an event belongs
|
||||
/// to a window when its [`CalendarEvent::start`] falls inside
|
||||
/// `[from, to)`. Windows are absolute (instants, not cursors), which
|
||||
/// is why a calendar poll needs no cursor: re-asking the same window
|
||||
/// returns the same rows under the same ids.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Window {
|
||||
/// The earliest start included.
|
||||
pub from: OffsetDateTime,
|
||||
/// The latest start excluded.
|
||||
pub to: OffsetDateTime,
|
||||
}
|
||||
|
||||
impl Window {
|
||||
/// A window from `from` (inclusive) to `to` (exclusive).
|
||||
pub fn new(from: OffsetDateTime, to: OffsetDateTime) -> Self {
|
||||
Self { from, to }
|
||||
}
|
||||
|
||||
/// Whether `start` falls inside the window.
|
||||
pub fn starts_within(&self, start: OffsetDateTime) -> bool {
|
||||
start >= self.from && start < self.to
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn at(raw: &str) -> OffsetDateTime {
|
||||
OffsetDateTime::parse(raw, &time::format_description::well_known::Rfc3339)
|
||||
.expect("rfc3339 parses")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_bounds_by_start_inclusively_below_exclusively() {
|
||||
let window = Window::new(at("2026-10-20T00:00:00Z"), at("2026-10-21T00:00:00Z"));
|
||||
assert!(window.starts_within(at("2026-10-20T00:00:00Z")));
|
||||
assert!(window.starts_within(at("2026-10-20T23:59:59Z")));
|
||||
assert!(!window.starts_within(at("2026-10-19T23:59:59Z")));
|
||||
assert!(!window.starts_within(at("2026-10-21T00:00:00Z")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_event_round_trips_through_serde() {
|
||||
let event = CalendarEvent {
|
||||
id: "ics:abc@1".into(),
|
||||
title: "standup".into(),
|
||||
start: at("2026-10-20T09:00:00Z"),
|
||||
end: Some(at("2026-10-20T09:15:00Z")),
|
||||
all_day: false,
|
||||
location: None,
|
||||
description: None,
|
||||
attendees: vec!["a@example.net".into()],
|
||||
organizer: Some("b@example.net".into()),
|
||||
url: None,
|
||||
raw: serde_json::json!({ "SUMMARY": "standup" }),
|
||||
};
|
||||
let round: CalendarEvent =
|
||||
serde_json::from_value(serde_json::to_value(&event).expect("serializes"))
|
||||
.expect("deserializes");
|
||||
assert_eq!(round, event);
|
||||
assert_eq!(round.duration(), time::Duration::minutes(15));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_endless_event_has_no_duration() {
|
||||
let event = CalendarEvent {
|
||||
id: "ics:abc@1".into(),
|
||||
title: String::new(),
|
||||
start: at("2026-10-20T09:00:00Z"),
|
||||
end: None,
|
||||
all_day: true,
|
||||
location: None,
|
||||
description: None,
|
||||
attendees: Vec::new(),
|
||||
organizer: None,
|
||||
url: None,
|
||||
raw: serde_json::json!({}),
|
||||
};
|
||||
assert_eq!(event.duration(), time::Duration::ZERO);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user