//! `deariary_today` — the most recent auto-generated diary entry.
//!
//! deariary.com aggregates external tools (GitHub, Calendar, Slack, Linear, ...) into a
//! written entry each morning, covering roughly the previous 24 hours. This fetcher targets
//! whichever entry is freshest right now: usually today's, falling back to yesterday's when
//! today's hasn't been generated yet (e.g. early morning splash). Requires the deariary
//! Advanced plan and a Bearer token via `options.token` or `DEARIARY_TOKEN` env.
//!
//! Safety::Safe: host `api.deariary.com` is hardcoded.
use async_trait::async_trait;
use chrono::{Local, NaiveDate};
use serde::Deserialize;
use super::client::{
ApiEntry, cache_extra, cached_get_entries, cached_get_entry, entry_url, resolve_token,
};
use crate::fetcher::github::common::{cache_key, parse_options, payload};
use crate::fetcher::{FetchContext, FetchError, Fetcher, Safety};
use crate::options::OptionSchema;
use crate::payload::{
BadgeData, Body, EntriesData, Entry, LinkedLine, LinkedTextBlockData, MarkdownTextBlockData,
Payload, Status, TextBlockData, TextData,
};
use crate::render::Shape;
use crate::samples;
const SHAPES: &[Shape] = &[
Shape::TextBlock,
Shape::Text,
Shape::MarkdownTextBlock,
Shape::LinkedTextBlock,
Shape::Entries,
Shape::Badge,
];
const OPTION_SCHEMAS: &[OptionSchema] = &[OptionSchema {
name: "token",
type_hint: "string",
required: false,
default: None,
description: "Deariary API token (Bearer). Falls back to the `DEARIARY_TOKEN` env var.",
}];
pub struct DeariaryToday;
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct Options {
#[serde(default)]
token: Option<String>,
}
#[async_trait]
impl Fetcher for DeariaryToday {
fn name(&self) -> &str {
"deariary_today"
}
fn safety(&self) -> Safety {
Safety::Safe
}
fn description(&self) -> &'static str {
"The most recent auto-generated deariary.com entry — typically today's, falling back to yesterday's when today's hasn't generated yet. Aggregated from external tools (GitHub, Calendar, Slack, Linear, ...); requires the Advanced plan. `TextBlock` shows the body, `MarkdownTextBlock` preserves formatting for `text_markdown`, `LinkedTextBlock` is a one-line clickable headline that opens the entry on deariary.com, `Entries` summarises title / date / tags / sources, `Text` is a one-line headline, `Badge` is a status pill."
}
fn refresh_interval(&self) -> u64 {
60 * 5
}
fn shapes(&self) -> &[Shape] {
SHAPES
}
fn option_schemas(&self) -> &[OptionSchema] {
OPTION_SCHEMAS
}
fn cache_key(&self, ctx: &FetchContext) -> String {
let opts: Options = parse_options(ctx.options.as_ref()).unwrap_or_default();
let extra = cache_extra(opts.token.as_deref(), ctx.options.as_ref());
cache_key(self.name(), ctx, &extra)
}
fn sample_body(&self, shape: Shape) -> Option<Body> {
Some(match shape {
Shape::Text => samples::text("📔 My adventurous day"),
Shape::TextBlock => samples::text_block(&[
"Caught the morning train to the office and shipped the heatmap renderer.",
"Reviewed two pull requests with the team and wrote release notes.",
"Wrapped up with a calm dinner and a long walk along the river.",
]),
Shape::MarkdownTextBlock => samples::markdown(
"## My adventurous day\n\nCaught the morning train to the office and shipped the heatmap renderer.\n\n- 🐱 GitHub: 3 commits, 2 PRs reviewed\n- 📅 Calendar: 1:1 with manager",
),
Shape::LinkedTextBlock => samples::linked_text_block(&[(
"📔 My adventurous day",
Some("https://app.deariary.com/entries/2026/04/27"),
)]),
Shape::Entries => samples::entries(&[
("title", "My adventurous day"),
("date", "2026-04-27"),
("tags", "work, travel"),
("sources", "github, calendar"),
]),
Shape::Badge => Body::Badge(BadgeData {
status: Status::Ok,
label: "📔 today".into(),
}),
_ => return None,
})
}
async fn fetch(&self, ctx: &FetchContext) -> Result<Payload, FetchError> {
let opts: Options = parse_options(ctx.options.as_ref()).map_err(FetchError::Failed)?;
let token = resolve_token(opts.token.as_deref())?;
let today = Local::now().date_naive();
let entry = latest_entry(&token, today).await?;
let shape = ctx.shape.unwrap_or(Shape::TextBlock);
Ok(payload(render_body(entry.as_ref(), shape, today)))
}
}
/// Pulls the freshest entry, but only when it's recent enough to be honest about. The
/// fetcher's description promises "typically today's, falling back to yesterday's when
/// today's hasn't generated yet" — surfacing a 5-day-old entry as "today" would be a
/// silent lie. If the newest available entry isn't from today or yesterday, return
/// `Ok(None)` and let the empty-state placeholder render instead.
///
/// The list call is cached and shared with `deariary_recent`; the per-date lookup is
/// cached and shared with `deariary_on_this_day` — `/entries` list responses may strip
/// content for size, so we re-fetch the full body via the date endpoint.
async fn latest_entry(token: &str, today: NaiveDate) -> Result<Option<ApiEntry>, FetchError> {
let entries = cached_get_entries(token, None).await?;
let Some(latest) = entries.first() else {
return Ok(None);
};
if !is_today_or_yesterday(&latest.date, today) {
return Ok(None);
}
cached_get_entry(token, &latest.date).await
}
fn is_today_or_yesterday(raw: &str, today: NaiveDate) -> bool {
let Ok(date) = NaiveDate::parse_from_str(raw, "%Y-%m-%d") else {
return false;
};
let days_back = (today - date).num_days();
(0..=1).contains(&days_back)
}
fn render_body(entry: Option<&ApiEntry>, shape: Shape, today: NaiveDate) -> Body {
match entry {
None => empty_body(shape),
Some(e) => match shape {
Shape::Text => render_text(e),
Shape::MarkdownTextBlock => render_markdown(e),
Shape::LinkedTextBlock => render_linked(e),
Shape::Entries => render_entries(e),
Shape::Badge => render_badge(e, today),
_ => render_text_block(e),
},
}
}
fn render_text(e: &ApiEntry) -> Body {
Body::Text(TextData {
value: format!("📔 {}", e.title),
})
}
fn render_text_block(e: &ApiEntry) -> Body {
Body::TextBlock(TextBlockData {
lines: content_lines(e),
})
}
fn render_markdown(e: &ApiEntry) -> Body {
Body::MarkdownTextBlock(MarkdownTextBlockData {
value: e.content.clone().unwrap_or_default(),
})
}
/// Single-row clickable headline opening the entry on deariary.com. Multi-line content is
/// the job of `TextBlock` / `MarkdownTextBlock`; here the user just wants one line they can
/// click to keep reading.
fn render_linked(e: &ApiEntry) -> Body {
Body::LinkedTextBlock(LinkedTextBlockData {
items: vec![LinkedLine {
text: format!("📔 {}", e.title),
url: Some(entry_url(&e.date)),
}],
})
}
fn render_entries(e: &ApiEntry) -> Body {
let items = [
("title", Some(e.title.clone())),
("date", Some(e.date.clone())),
("tags", (!e.tags.is_empty()).then(|| e.tags.join(", "))),
(
"sources",
(!e.sources.is_empty()).then(|| e.sources.join(", ")),
),
]
.into_iter()
.filter_map(|(key, value)| {
value.map(|v| Entry {
key: key.into(),
value: Some(v),
status: None,
})
})
.collect();
Body::Entries(EntriesData { items })
}
fn render_badge(e: &ApiEntry, today: NaiveDate) -> Body {
Body::Badge(BadgeData {
status: Status::Ok,
label: format!("📔 {}", relative_day(&e.date, today)),
})
}
/// `today` / `yesterday` / falls back to the raw ISO date for older entries — keeps the badge
/// label honest when today's hasn't generated yet and the fetcher returns yesterday's instead.
fn relative_day(raw: &str, today: NaiveDate) -> String {
let Ok(date) = NaiveDate::parse_from_str(raw, "%Y-%m-%d") else {
return raw.to_string();
};
match (today - date).num_days() {
0 => "today".into(),
1 => "yesterday".into(),
_ => raw.to_string(),
}
}
fn content_lines(e: &ApiEntry) -> Vec<String> {
e.content
.as_deref()
.unwrap_or_default()
.lines()
.map(str::to_string)
.collect()
}
fn empty_body(shape: Shape) -> Body {
match shape {
Shape::Text => Body::Text(TextData {
value: String::new(),
}),
Shape::MarkdownTextBlock => Body::MarkdownTextBlock(MarkdownTextBlockData {
value: String::new(),
}),
Shape::LinkedTextBlock => Body::LinkedTextBlock(LinkedTextBlockData { items: vec![] }),
Shape::Entries => Body::Entries(EntriesData { items: vec![] }),
Shape::Badge => Body::Badge(BadgeData {
status: Status::Warn,
label: "📔 no entry yet".into(),
}),
_ => Body::TextBlock(TextBlockData { lines: vec![] }),
}
}
#[cfg(test)]
mod tests {
use std::{future::Future, time::Duration};
use super::*;
fn entry() -> ApiEntry {
ApiEntry {
date: "2026-04-27".into(),
title: "My Day".into(),
content: Some("# Hello\n\nWorld".into()),
tags: vec!["work".into(), "travel".into()],
sources: vec!["github".into(), "calendar".into()],
generated_at: Some("2026-04-27T08:00:00Z".into()),
word_count: Some(482),
}
}
fn ymd(y: i32, m: u32, d: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, d).unwrap()
}
fn ctx(options: Option<&str>, shape: Option<Shape>, format: Option<&str>) -> FetchContext {
FetchContext {
widget_id: "deariary-today".into(),
format: format.map(str::to_string),
timeout: Duration::from_secs(1),
file_format: None,
shape,
options: options.map(|raw| toml::from_str(raw).unwrap()),
timezone: None,
locale: None,
}
}
fn run_async<T>(future: impl Future<Output = T>) -> T {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(future)
}
#[test]
fn fetcher_catalog_surface_matches_contract() {
let fetcher = DeariaryToday;
assert_eq!(fetcher.name(), "deariary_today");
assert_eq!(fetcher.safety(), Safety::Safe);
assert_eq!(fetcher.default_shape(), Shape::TextBlock);
assert_eq!(fetcher.shapes(), SHAPES);
assert_eq!(fetcher.option_schemas().len(), 1);
assert_eq!(fetcher.option_schemas()[0].name, "token");
assert!(fetcher.description().contains("most recent auto-generated"));
}
#[test]
fn cache_key_changes_with_token_shape_and_format() {
let fetcher = DeariaryToday;
let base = fetcher.cache_key(&ctx(
Some("token = \"alpha\""),
Some(Shape::TextBlock),
Some("plain"),
));
let same = fetcher.cache_key(&ctx(
Some("token = \"alpha\""),
Some(Shape::TextBlock),
Some("plain"),
));
let different_token = fetcher.cache_key(&ctx(
Some("token = \"beta\""),
Some(Shape::TextBlock),
Some("plain"),
));
let different_shape = fetcher.cache_key(&ctx(
Some("token = \"alpha\""),
Some(Shape::Badge),
Some("plain"),
));
let different_format = fetcher.cache_key(&ctx(
Some("token = \"alpha\""),
Some(Shape::TextBlock),
Some("json"),
));
assert_eq!(base, same);
assert_ne!(base, different_token);
assert_ne!(base, different_shape);
assert_ne!(base, different_format);
assert!(
!fetcher
.cache_key(&ctx(Some("bogus = true"), None, None))
.is_empty()
);
}
#[test]
fn text_body_uses_title_only() {
assert!(matches!(
render_body(Some(&entry()), Shape::Text, ymd(2026, 4, 27)),
Body::Text(TextData { value }) if value.contains("My Day") && !value.contains("482")
));
}
#[test]
fn text_block_splits_content_into_lines() {
assert!(matches!(
render_body(Some(&entry()), Shape::TextBlock, ymd(2026, 4, 27)),
Body::TextBlock(TextBlockData { lines })
if lines
== vec![
"# Hello".to_string(),
String::new(),
"World".to_string(),
]
));
}
#[test]
fn linked_text_block_emits_one_clickable_headline() {
assert!(matches!(
render_body(Some(&entry()), Shape::LinkedTextBlock, ymd(2026, 4, 27)),
Body::LinkedTextBlock(LinkedTextBlockData { items })
if items.len() == 1
&& items[0].text.contains("My Day")
&& items[0].url.as_deref()
== Some("https://app.deariary.com/entries/2026/04/27")
));
}
#[test]
fn markdown_block_passes_content_through_verbatim() {
assert!(matches!(
render_body(Some(&entry()), Shape::MarkdownTextBlock, ymd(2026, 4, 27)),
Body::MarkdownTextBlock(MarkdownTextBlockData { value })
if value == "# Hello\n\nWorld"
));
}
#[test]
fn entries_includes_only_present_metadata_keys() {
let mut bare = entry();
bare.tags.clear();
bare.sources.clear();
assert!(matches!(
render_body(Some(&bare), Shape::Entries, ymd(2026, 4, 27)),
Body::Entries(EntriesData { items })
if items.iter().map(|item| item.key.as_str()).collect::<Vec<_>>()
== vec!["title", "date"]
));
}
#[test]
fn entries_joins_tags_and_sources_when_present() {
assert!(matches!(
render_body(Some(&entry()), Shape::Entries, ymd(2026, 4, 27)),
Body::Entries(EntriesData { items })
if items
.iter()
.find(|item| item.key == "tags")
.and_then(|item| item.value.as_deref())
== Some("work, travel")
));
}
#[test]
fn badge_label_says_today_when_entry_date_matches_today() {
assert!(matches!(
render_body(Some(&entry()), Shape::Badge, ymd(2026, 4, 27)),
Body::Badge(BadgeData { status: Status::Ok, label }) if label == "📔 today"
));
}
#[test]
fn badge_label_says_yesterday_when_today_has_not_generated_yet() {
assert!(matches!(
render_body(Some(&entry()), Shape::Badge, ymd(2026, 4, 28)),
Body::Badge(BadgeData { label, .. }) if label == "📔 yesterday"
));
}
#[test]
fn badge_label_falls_back_to_iso_date_for_older_entries() {
assert!(matches!(
render_body(Some(&entry()), Shape::Badge, ymd(2026, 5, 1)),
Body::Badge(BadgeData { label, .. }) if label == "📔 2026-04-27"
));
}
#[test]
fn freshness_gate_accepts_today_and_yesterday_only() {
// The fetcher's contract is "typically today's, falling back to yesterday's" — a
// 5-day-old entry must NOT be served as "today" or even "yesterday". Bound the
// gate to a 0/1-day delta and let `Ok(None)` fall through to the empty-state
// placeholder for older surrogates.
let today = ymd(2026, 4, 27);
assert!(is_today_or_yesterday("2026-04-27", today));
assert!(is_today_or_yesterday("2026-04-26", today));
assert!(!is_today_or_yesterday("2026-04-25", today));
assert!(!is_today_or_yesterday("2026-04-22", today));
// Future-dated entries (clock skew, time-zone weirdness on the API side) shouldn't
// be silently surfaced either — guard against them too.
assert!(!is_today_or_yesterday("2026-04-28", today));
// Unparseable dates fall through to false; the surrogate placeholder beats
// surfacing an entry the fetcher can't reason about.
assert!(!is_today_or_yesterday("not-a-date", today));
}
#[test]
fn missing_entry_emits_empty_text_block() {
assert!(matches!(
render_body(None, Shape::TextBlock, ymd(2026, 4, 27)),
Body::TextBlock(TextBlockData { lines }) if lines.is_empty()
));
}
#[test]
fn missing_entry_covers_other_empty_shapes() {
let today = ymd(2026, 4, 27);
assert!(matches!(
render_body(None, Shape::Text, today),
Body::Text(TextData { value }) if value.is_empty()
));
assert!(matches!(
render_body(None, Shape::MarkdownTextBlock, today),
Body::MarkdownTextBlock(MarkdownTextBlockData { value }) if value.is_empty()
));
assert!(matches!(
render_body(None, Shape::LinkedTextBlock, today),
Body::LinkedTextBlock(LinkedTextBlockData { items }) if items.is_empty()
));
assert!(matches!(
render_body(None, Shape::Entries, today),
Body::Entries(EntriesData { items }) if items.is_empty()
));
}
#[test]
fn missing_entry_emits_warn_badge() {
assert!(matches!(
render_body(None, Shape::Badge, ymd(2026, 4, 27)),
Body::Badge(BadgeData {
status: Status::Warn,
label,
}) if label.contains("no entry")
));
}
#[test]
fn relative_day_returns_raw_text_for_invalid_dates() {
assert_eq!(relative_day("not-a-date", ymd(2026, 4, 27)), "not-a-date");
}
#[test]
fn sample_body_provides_value_for_each_supported_shape() {
let f = DeariaryToday;
for s in SHAPES {
assert!(f.sample_body(*s).is_some(), "missing sample for {s:?}");
}
assert!(f.sample_body(Shape::Heatmap).is_none());
}
#[test]
fn options_reject_unknown_keys() {
let raw: toml::Value = toml::from_str("token = \"abc\"\nbogus = 1").unwrap();
assert!(raw.try_into::<Options>().is_err());
}
#[tokio::test]
async fn fetch_rejects_unknown_options() {
let fetcher = DeariaryToday;
let err = fetcher
.fetch(&ctx(
Some("token = \"abc\"\nbogus = true"),
Some(Shape::TextBlock),
None,
))
.await
.unwrap_err();
assert!(matches!(
err,
FetchError::Failed(msg) if msg.contains("unknown field `bogus`")
));
}
#[test]
fn fetch_requires_token_before_network() {
let _lock = crate::paths::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let previous = std::env::var("DEARIARY_TOKEN").ok();
unsafe { std::env::remove_var("DEARIARY_TOKEN") };
let fetcher = DeariaryToday;
let err = run_async(fetcher.fetch(&ctx(None, Some(Shape::TextBlock), None))).unwrap_err();
assert!(matches!(
err,
FetchError::Failed(msg)
if msg == "deariary token missing: set options.token or DEARIARY_TOKEN"
));
unsafe {
match previous {
Some(value) => std::env::set_var("DEARIARY_TOKEN", value),
None => std::env::remove_var("DEARIARY_TOKEN"),
}
}
}
}