spa hardening, offline logging, rate limit fixes

server:
- backup exporter, auth extractors, error shapes, CONTEXT (prior work)
- spa assets served outside the rate limit via route_layer
- requests_per_second went to per_second(), which takes an interval not a
  rate: 50 meant one request per 50s once burst was spent. now converted
  properly. 15/s, burst 60

spa fixes:
- account delete cleared snake_case token keys that were never written
- refresh interceptor could retry forever
- date ranges used local day boundaries stamped +00:00
- "all" period trend plotted one page; calendar days fabricated mood 3
- chart grid invisible: hsl(var(--border)) against rgba tokens
- blob url leak, orphaned media on failed save, devtools in prod bundle
- pt-safe/safe-area-pb classes never existed

spa features:
- offline outbox: entries queue to IndexedDB, replay with backoff, only
  server refusals count against an entry
- drafts persist, quick-log sheet, diary infinite scroll + filters
- route error boundary, stale-chunk recovery, no service worker in dev

a11y + perf:
- mood picker is a radiogroup, activity picker keyboard-operable,
  text alternatives for colour/emoji, locale week start
- dark glass over the bright photo: worst case 1.4:1 -> 4.9-9.6:1
- initial payload 1095->769kB raw, 306->230kB gzip; 38 unused components
  and 5 deps dropped; fonts 218->133kB

53 tests added (43 spa, 10 server)
This commit is contained in:
2026-08-28 14:59:21 +02:00
parent 23d052278a
commit bf148902ab
395 changed files with 13972 additions and 10635 deletions

View File

@@ -3,14 +3,14 @@ use chrono::{DateTime, Utc};
use crate::provider::ProviderName;
use crate::user::UserId;
use super::{ApiTokenId, TokenDigest, TokenScope};
use super::{ApiTokenId, TokenDigest, TokenScope, TokenScopes};
pub struct ApiTokenData {
pub id: ApiTokenId,
pub user_id: UserId,
pub name: ProviderName,
pub digest: TokenDigest,
pub scope: TokenScope,
pub scopes: TokenScopes,
pub created_at: DateTime<Utc>,
pub last_used_at: Option<DateTime<Utc>>,
}
@@ -21,19 +21,24 @@ pub struct ApiToken {
user_id: UserId,
name: ProviderName,
digest: TokenDigest,
scope: TokenScope,
scopes: TokenScopes,
created_at: DateTime<Utc>,
last_used_at: Option<DateTime<Utc>>,
}
impl ApiToken {
pub fn new(user_id: UserId, name: ProviderName, digest: TokenDigest) -> Self {
pub fn new(
user_id: UserId,
name: ProviderName,
digest: TokenDigest,
scopes: TokenScopes,
) -> Self {
Self {
id: ApiTokenId::generate(),
user_id,
name,
digest,
scope: TokenScope::WriteMetrics,
scopes,
created_at: Utc::now(),
last_used_at: None,
}
@@ -45,7 +50,7 @@ impl ApiToken {
user_id: data.user_id,
name: data.name,
digest: data.digest,
scope: data.scope,
scopes: data.scopes,
created_at: data.created_at,
last_used_at: data.last_used_at,
}
@@ -69,8 +74,12 @@ impl ApiToken {
&self.digest
}
pub fn scope(&self) -> TokenScope {
self.scope
pub fn scopes(&self) -> &TokenScopes {
&self.scopes
}
pub fn allows(&self, scope: TokenScope) -> bool {
self.scopes.allows(scope)
}
pub fn created_at(&self) -> &DateTime<Utc> {

View File

@@ -3,9 +3,11 @@ mod api_token_id;
mod minted_api_token;
mod token_digest;
mod token_scope;
mod token_scopes;
pub use api_token::{ApiToken, ApiTokenData};
pub use api_token_id::ApiTokenId;
pub use minted_api_token::MintedApiToken;
pub use token_digest::TokenDigest;
pub use token_scope::TokenScope;
pub use token_scopes::TokenScopes;

View File

@@ -1,21 +1,59 @@
const READ_JOURNAL: &str = "readJournal";
const WRITE_JOURNAL: &str = "writeJournal";
const WRITE_METRICS: &str = "writeMetrics";
const READ_PROFILE: &str = "readProfile";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum TokenScope {
ReadJournal,
WriteJournal,
WriteMetrics,
ReadProfile,
}
impl TokenScope {
pub const ALL: [TokenScope; 4] = [
Self::ReadJournal,
Self::WriteJournal,
Self::WriteMetrics,
Self::ReadProfile,
];
pub fn name(&self) -> &'static str {
match self {
Self::ReadJournal => READ_JOURNAL,
Self::WriteJournal => WRITE_JOURNAL,
Self::WriteMetrics => WRITE_METRICS,
Self::ReadProfile => READ_PROFILE,
}
}
pub fn describes(&self) -> &'static str {
match self {
Self::ReadJournal => {
"read entries, activities, the calendar, statistics, correlations, cycle records and daily metrics"
}
Self::WriteJournal => {
"create, change and delete entries, activities, cycle records, reminders and media"
}
Self::WriteMetrics => "write daily metrics, attributed to this token's name",
Self::ReadProfile => "read the account profile, its preferences and its connections",
}
}
pub fn from_name(name: &str) -> Option<Self> {
match name {
READ_JOURNAL => Some(Self::ReadJournal),
WRITE_JOURNAL => Some(Self::WriteJournal),
WRITE_METRICS => Some(Self::WriteMetrics),
READ_PROFILE => Some(Self::ReadProfile),
_ => None,
}
}
}
impl std::fmt::Display for TokenScope {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.name())
}
}

View File

@@ -0,0 +1,62 @@
use std::collections::BTreeSet;
use crate::errors::DomainError;
use super::TokenScope;
const SEPARATOR: char = ' ';
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TokenScopes(BTreeSet<TokenScope>);
impl TokenScopes {
pub fn new(wanted: impl IntoIterator<Item = TokenScope>) -> Result<Self, DomainError> {
let held: BTreeSet<TokenScope> = wanted.into_iter().collect();
if held.is_empty() {
return Err(DomainError::InvalidInput(
"a token that grants nothing is not worth minting: name at least one scope".into(),
));
}
Ok(Self(held))
}
pub fn from_names<'a>(names: impl IntoIterator<Item = &'a str>) -> Result<Self, DomainError> {
let mut wanted = BTreeSet::new();
for name in names {
let scope = TokenScope::from_name(name).ok_or_else(|| {
DomainError::InvalidInput(format!("{name} is not a scope this server grants"))
})?;
wanted.insert(scope);
}
Self::new(wanted)
}
pub fn from_persistence(stored: &str) -> Option<Self> {
let names: Vec<&str> = stored.split(SEPARATOR).filter(|s| !s.is_empty()).collect();
Self::from_names(names).ok()
}
pub fn allows(&self, scope: TokenScope) -> bool {
self.0.contains(&scope)
}
pub fn names(&self) -> Vec<&'static str> {
self.0.iter().map(TokenScope::name).collect()
}
pub fn to_persistence(&self) -> String {
self.names().join(&SEPARATOR.to_string())
}
}
impl std::fmt::Display for TokenScopes {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.to_persistence())
}
}

View File

@@ -0,0 +1,78 @@
use super::{PhotoId, VoiceMemoId};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum MediaKind {
Photo,
VoiceMemo,
}
impl MediaKind {
pub fn name(&self) -> &'static str {
match self {
Self::Photo => "photo",
Self::VoiceMemo => "voice_memo",
}
}
pub fn from_name(name: &str) -> Option<Self> {
match name {
"photo" => Some(Self::Photo),
"voice_memo" => Some(Self::VoiceMemo),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct MediaRef {
kind: MediaKind,
id: uuid::Uuid,
}
impl MediaRef {
pub fn to_photo(&self) -> Option<PhotoId> {
match self.kind {
MediaKind::Photo => Some(PhotoId::from_uuid(self.id)),
MediaKind::VoiceMemo => None,
}
}
pub fn to_voice_memo(&self) -> Option<VoiceMemoId> {
match self.kind {
MediaKind::VoiceMemo => Some(VoiceMemoId::from_uuid(self.id)),
MediaKind::Photo => None,
}
}
pub fn kind(&self) -> MediaKind {
self.kind
}
pub fn id(&self) -> uuid::Uuid {
self.id
}
}
impl From<&PhotoId> for MediaRef {
fn from(id: &PhotoId) -> Self {
Self {
kind: MediaKind::Photo,
id: id.value(),
}
}
}
impl From<&VoiceMemoId> for MediaRef {
fn from(id: &VoiceMemoId) -> Self {
Self {
kind: MediaKind::VoiceMemo,
id: id.value(),
}
}
}
impl std::fmt::Display for MediaRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} {}", self.kind.name(), self.id)
}
}

View File

@@ -1,9 +1,11 @@
mod content_type;
mod media_ref;
mod media_upload;
mod photo_id;
mod voice_memo_id;
pub use content_type::ContentType;
pub use media_ref::{MediaKind, MediaRef};
pub use media_upload::MediaUpload;
pub use photo_id::PhotoId;
pub use voice_memo_id::VoiceMemoId;

View File

@@ -0,0 +1,69 @@
use chrono::{DateTime, Utc};
use crate::activity::ActivityId;
use crate::user::UserId;
use super::{DateRange, Mood};
#[derive(Debug, Clone)]
pub struct EntrySelection {
user_id: UserId,
logged_within: Option<DateRange>,
mood: Option<Mood>,
tagged_with: Option<ActivityId>,
changed_since: Option<DateTime<Utc>>,
}
impl EntrySelection {
pub fn everything_of(user_id: UserId) -> Self {
Self {
user_id,
logged_within: None,
mood: None,
tagged_with: None,
changed_since: None,
}
}
pub fn logged_within(mut self, range: Option<DateRange>) -> Self {
self.logged_within = range;
self
}
pub fn of_mood(mut self, mood: Option<Mood>) -> Self {
self.mood = mood;
self
}
pub fn tagged_with(mut self, activity_id: Option<ActivityId>) -> Self {
self.tagged_with = activity_id;
self
}
pub fn changed_since(mut self, instant: Option<DateTime<Utc>>) -> Self {
self.changed_since = instant;
self
}
}
impl EntrySelection {
pub fn user_id(&self) -> &UserId {
&self.user_id
}
pub fn range(&self) -> Option<&DateRange> {
self.logged_within.as_ref()
}
pub fn mood(&self) -> Option<Mood> {
self.mood
}
pub fn activity(&self) -> Option<&ActivityId> {
self.tagged_with.as_ref()
}
pub fn since(&self) -> Option<DateTime<Utc>> {
self.changed_since
}
}

View File

@@ -3,15 +3,19 @@ mod date;
mod date_range;
mod date_span;
mod day_mood;
mod entry_selection;
mod mood;
mod mood_entry;
mod mood_entry_id;
mod pagination;
pub use content::Content;
pub use date::Date;
pub use date_range::DateRange;
pub use date_span::DateSpan;
pub use day_mood::DayMood;
pub use entry_selection::EntrySelection;
pub use mood::Mood;
pub use mood_entry::{MoodEntry, MoodEntryData};
pub use mood_entry_id::MoodEntryId;
pub use pagination::{Page, Pagination};

View File

@@ -11,10 +11,28 @@ pub enum Mood {
Rad = 5,
}
const AWFUL: &str = "Awful";
const BAD: &str = "Bad";
const MEH: &str = "Meh";
const GOOD: &str = "Good";
const RAD: &str = "Rad";
impl Mood {
pub const ALL: [Mood; 5] = [Self::Awful, Self::Bad, Self::Meh, Self::Good, Self::Rad];
pub fn value(&self) -> u8 {
*self as u8
}
pub fn label(&self) -> &'static str {
match self {
Self::Awful => AWFUL,
Self::Bad => BAD,
Self::Meh => MEH,
Self::Good => GOOD,
Self::Rad => RAD,
}
}
}
impl TryFrom<u8> for Mood {

View File

@@ -0,0 +1,86 @@
use crate::errors::DomainError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Pagination {
limit: i64,
offset: i64,
}
impl Pagination {
pub fn new(limit: i64, offset: i64, most_per_page: i64) -> Result<Self, DomainError> {
if limit < 1 {
return Err(DomainError::InvalidInput(
"a page of nothing is not a page: ask for at least one".into(),
));
}
if limit > most_per_page {
return Err(DomainError::InvalidInput(format!(
"this server serves at most {most_per_page} entries per page, and {limit} were asked for"
)));
}
if offset < 0 {
return Err(DomainError::InvalidInput(
"a page cannot start before the beginning".into(),
));
}
Ok(Self { limit, offset })
}
pub fn limit(&self) -> i64 {
self.limit
}
pub fn offset(&self) -> i64 {
self.offset
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Page<T> {
items: Vec<T>,
total: u64,
at: Pagination,
}
impl<T> Page<T> {
pub fn new(items: Vec<T>, total: u64, at: Pagination) -> Self {
Self { items, total, at }
}
pub fn items(&self) -> &[T] {
&self.items
}
pub fn into_items(self) -> Vec<T> {
self.items
}
pub fn total(&self) -> u64 {
self.total
}
pub fn limit(&self) -> i64 {
self.at.limit()
}
pub fn offset(&self) -> i64 {
self.at.offset()
}
pub fn more_after_this(&self) -> bool {
let seen = self.at.offset().saturating_add(self.items.len() as i64);
(seen as u64) < self.total
}
pub fn map<U>(self, transform: impl FnMut(T) -> U) -> Page<U> {
Page {
items: self.items.into_iter().map(transform).collect(),
total: self.total,
at: self.at,
}
}
}

View File

@@ -48,11 +48,16 @@ macro_rules! bounded_metric {
pub struct $name($inner);
impl $name {
pub const MINIMUM: $inner = $min;
pub const MAXIMUM: $inner = $max;
pub fn new(value: $inner) -> Result<Self, crate::errors::DomainError> {
if !($min..=$max).contains(&value) {
if !(Self::MINIMUM..=Self::MAXIMUM).contains(&value) {
return Err(crate::errors::DomainError::InvalidInput(format!(
concat!($label, " must be between {} and {}, got {}"),
$min, $max, value
Self::MINIMUM,
Self::MAXIMUM,
value
)));
}

View File

@@ -7,6 +7,12 @@ const EXERCISE_MINUTES: &str = "exerciseMinutes";
const SCREEN_TIME_MINUTES: &str = "screenTimeMinutes";
const ALCOHOLIC_DRINKS: &str = "alcoholicDrinks";
const STEPS_UNIT: &str = "steps";
const MINUTES_UNIT: &str = "minutes";
const BEATS_PER_MINUTE_UNIT: &str = "bpm";
const MILLISECONDS_UNIT: &str = "milliseconds";
const DRINKS_UNIT: &str = "drinks";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum MetricKind {
Steps,
@@ -44,6 +50,47 @@ impl MetricKind {
}
}
pub fn unit(&self) -> &'static str {
match self {
Self::Steps => STEPS_UNIT,
Self::SleepMinutes | Self::AwakeMinutes => MINUTES_UNIT,
Self::RestingHeartRate => BEATS_PER_MINUTE_UNIT,
Self::Hrv => MILLISECONDS_UNIT,
Self::ExerciseMinutes | Self::ScreenTimeMinutes => MINUTES_UNIT,
Self::AlcoholicDrinks => DRINKS_UNIT,
}
}
pub fn bounds(&self) -> (i64, i64) {
use crate::metric::{
AlcoholicDrinks, AwakeMinutes, ExerciseMinutes, Hrv, RestingHeartRate,
ScreenTimeMinutes, SleepMinutes, Steps,
};
match self {
Self::Steps => (Steps::MINIMUM.into(), Steps::MAXIMUM.into()),
Self::SleepMinutes => (SleepMinutes::MINIMUM.into(), SleepMinutes::MAXIMUM.into()),
Self::AwakeMinutes => (AwakeMinutes::MINIMUM.into(), AwakeMinutes::MAXIMUM.into()),
Self::RestingHeartRate => (
RestingHeartRate::MINIMUM.into(),
RestingHeartRate::MAXIMUM.into(),
),
Self::Hrv => (Hrv::MINIMUM.into(), Hrv::MAXIMUM.into()),
Self::ExerciseMinutes => (
ExerciseMinutes::MINIMUM.into(),
ExerciseMinutes::MAXIMUM.into(),
),
Self::ScreenTimeMinutes => (
ScreenTimeMinutes::MINIMUM.into(),
ScreenTimeMinutes::MAXIMUM.into(),
),
Self::AlcoholicDrinks => (
AlcoholicDrinks::MINIMUM.into(),
AlcoholicDrinks::MAXIMUM.into(),
),
}
}
pub fn from_name(name: &str) -> Option<Self> {
match name {
STEPS => Some(Self::Steps),

View File

@@ -1,5 +1,5 @@
use crate::activity::ActivityId;
use crate::entry::{DateRange, Mood, MoodEntry, MoodEntryId};
use crate::entry::{DateRange, EntrySelection, MoodEntry, MoodEntryId, Pagination};
use crate::errors::DomainError;
use crate::user::UserId;
@@ -27,28 +27,21 @@ pub trait MoodEntryCommandPort: Send + Sync {
#[async_trait::async_trait]
pub trait MoodEntryQueryPort: Send + Sync {
async fn find_by_id(&self, id: &MoodEntryId) -> Result<Option<MoodEntry>, DomainError>;
async fn find_by_user(
async fn find_all_by_user(&self, user_id: &UserId) -> Result<Vec<MoodEntry>, DomainError>;
async fn select(
&self,
user_id: &UserId,
limit: Option<i64>,
offset: Option<i64>,
selection: &EntrySelection,
page: Pagination,
) -> Result<Vec<MoodEntry>, DomainError>;
async fn count(&self, selection: &EntrySelection) -> Result<u64, DomainError>;
async fn find_by_date_range(
&self,
user_id: &UserId,
range: &DateRange,
) -> Result<Vec<MoodEntry>, DomainError>;
async fn find_by_mood(
&self,
user_id: &UserId,
mood: Mood,
) -> Result<Vec<MoodEntry>, DomainError>;
async fn find_by_activity(
&self,
user_id: &UserId,
activity_id: &ActivityId,
) -> Result<Vec<MoodEntry>, DomainError>;
async fn count_tagged_with(&self, activity_id: &ActivityId) -> Result<u64, DomainError>;
}

View File

@@ -1,4 +1,5 @@
use crate::activity::Activity;
use crate::attachment::ContentType;
use crate::cycle::CycleStartRestore;
use crate::dimension::ComposedEntry;
use crate::errors::DomainError;
@@ -9,6 +10,7 @@ use crate::user::UserPreferences;
pub struct MediaBlob {
pub id: String,
pub data: Vec<u8>,
pub content_type: ContentType,
}
pub struct BackupMedia {

View File

@@ -51,6 +51,11 @@ pub trait WeatherBacklogQueryPort: Send + Sync {
&self,
most: usize,
) -> Result<Vec<UnwatchedPlace>, DomainError>;
async fn find_place_without_weather(
&self,
entry_id: &crate::entry::MoodEntryId,
) -> Result<Option<UnwatchedPlace>, DomainError>;
}
#[async_trait::async_trait]
@@ -60,6 +65,11 @@ pub trait RecordingBackfillQueryPort: Send + Sync {
most: usize,
) -> Result<Vec<UnidentifiedSong>, DomainError>;
async fn find_song_without_a_recording(
&self,
entry_id: &crate::entry::MoodEntryId,
) -> Result<Option<UnidentifiedSong>, DomainError>;
async fn record_identity(
&self,
entry_id: &crate::entry::MoodEntryId,

View File

@@ -1,5 +1,6 @@
use crate::attachment::{ContentType, MediaUpload, PhotoId, VoiceMemoId};
use crate::attachment::{ContentType, MediaRef, MediaUpload, PhotoId, VoiceMemoId};
use crate::errors::DomainError;
use crate::user::UserId;
pub struct MediaFile {
pub data: Vec<u8>,
@@ -15,3 +16,12 @@ pub trait MediaStoragePort: Send + Sync {
async fn delete_photo(&self, id: &PhotoId) -> Result<(), DomainError>;
async fn delete_voice_memo(&self, id: &VoiceMemoId) -> Result<(), DomainError>;
}
#[async_trait::async_trait]
pub trait MediaOwnershipPort: Send + Sync {
async fn remember(&self, owner: &UserId, media: MediaRef) -> Result<(), DomainError>;
async fn owner_of(&self, media: MediaRef) -> Result<Option<UserId>, DomainError>;
async fn owned_by(&self, owner: &UserId) -> Result<Vec<MediaRef>, DomainError>;
async fn forget(&self, media: MediaRef) -> Result<(), DomainError>;
async fn forget_all_by_user(&self, owner: &UserId) -> Result<(), DomainError>;
}

View File

@@ -39,7 +39,7 @@ pub use job::{
JobQueueCommandPort, JobQueueQueryPort, RecordingBackfillQueryPort, UnidentifiedSong,
UnwatchedPlace, WeatherBacklogQueryPort, WeatherLookupPort,
};
pub use media::{MediaFile, MediaStoragePort};
pub use media::{MediaFile, MediaOwnershipPort, MediaStoragePort};
pub use metric::{DailyMetricCommandPort, DailyMetricQueryPort};
pub use music::{NowPlayingPort, RecordingLookupPort};
pub use provider::{ProviderConnectionCommandPort, ProviderConnectionQueryPort};
@@ -47,7 +47,7 @@ pub use push::{PushSubscriptionCommandPort, PushSubscriptionQueryPort};
pub use rejection::{RejectionCommandPort, RejectionQueryPort};
pub use reminder::{ReminderCommandPort, ReminderQueryPort, ReminderSenderPort};
pub use restore::{
BackupReaderPort, RestorableActivity, RestorableContents, RestorableEntry, RestorableMetric,
RestorableReminder,
BackupReaderPort, RestorableActivity, RestorableContents, RestorableEntry, RestorableMedia,
RestorableMetric, RestorableReminder,
};
pub use user::{UserCommandPort, UserQueryPort};

View File

@@ -6,7 +6,8 @@ use crate::user::UserId;
pub trait PushSubscriptionCommandPort: Send + Sync {
async fn save(&self, subscription: &PushSubscription) -> Result<(), DomainError>;
async fn delete(&self, id: &PushSubscriptionId) -> Result<(), DomainError>;
async fn delete_by_endpoint(&self, endpoint: &str) -> Result<(), DomainError>;
async fn delete_by_endpoint(&self, user_id: &UserId, endpoint: &str)
-> Result<(), DomainError>;
async fn delete_all_by_user(&self, user_id: &UserId) -> Result<(), DomainError>;
}

View File

@@ -1,3 +1,4 @@
use crate::attachment::ContentType;
use crate::errors::DomainError;
pub struct RestorableEntry {
@@ -32,8 +33,14 @@ pub struct RestorableContents {
pub activities: Vec<RestorableActivity>,
pub reminders: Vec<RestorableReminder>,
pub tracks_cycle: bool,
pub photos: Vec<(String, Vec<u8>)>,
pub voice_memos: Vec<(String, Vec<u8>)>,
pub photos: Vec<RestorableMedia>,
pub voice_memos: Vec<RestorableMedia>,
}
pub struct RestorableMedia {
pub id: String,
pub data: Vec<u8>,
pub content_type: Option<ContentType>,
}
#[async_trait::async_trait]

View File

@@ -45,6 +45,12 @@ impl PushSubscription {
}
}
pub fn renew(&mut self, user_id: UserId, p256dh: String, auth: String) {
self.user_id = user_id;
self.p256dh = p256dh;
self.auth = auth;
}
pub fn id(&self) -> &PushSubscriptionId {
&self.id
}

View File

@@ -0,0 +1,62 @@
use chrono::{DateTime, Datelike, Duration, NaiveDate, NaiveTime, TimeDelta, Utc};
use crate::user::Timezone;
use super::DaySchedule;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DueOccurrence {
scheduled_for: DateTime<Utc>,
}
impl DueOccurrence {
pub fn scheduled_for(&self) -> DateTime<Utc> {
self.scheduled_for
}
}
pub fn occurrence_reached(
schedule: &DaySchedule,
last_sent_at: Option<DateTime<Utc>>,
now: DateTime<Utc>,
timezone: &Timezone,
grace: Duration,
) -> Option<DueOccurrence> {
let local_today = now.with_timezone(&timezone.resolve()).date_naive();
[local_today, local_today.pred_opt()?]
.into_iter()
.filter_map(|local_date| scheduled_instant(schedule, local_date, timezone))
.filter(|scheduled| reached_but_not_stale(*scheduled, now, grace))
.filter(|scheduled| not_already_sent(*scheduled, last_sent_at))
.max()
.map(|scheduled_for| DueOccurrence { scheduled_for })
}
fn scheduled_instant(
schedule: &DaySchedule,
local_date: NaiveDate,
timezone: &Timezone,
) -> Option<DateTime<Utc>> {
let time = schedule.time_for(local_date.weekday())?;
placed_in(local_date, time, timezone)
}
fn placed_in(local_date: NaiveDate, time: NaiveTime, timezone: &Timezone) -> Option<DateTime<Utc>> {
local_date
.and_time(time)
.and_local_timezone(timezone.resolve())
.earliest()
.map(|placed| placed.with_timezone(&Utc))
}
fn reached_but_not_stale(scheduled: DateTime<Utc>, now: DateTime<Utc>, grace: Duration) -> bool {
let since = now.signed_duration_since(scheduled);
since >= TimeDelta::zero() && since < grace
}
fn not_already_sent(scheduled: DateTime<Utc>, last_sent_at: Option<DateTime<Utc>>) -> bool {
last_sent_at.is_none_or(|sent| sent < scheduled)
}

View File

@@ -1,7 +1,9 @@
mod day_schedule;
mod due_occurrence;
mod reminder;
mod reminder_id;
pub use day_schedule::DaySchedule;
pub use due_occurrence::DueOccurrence;
pub use reminder::Reminder;
pub use reminder_id::ReminderId;

View File

@@ -1,7 +1,8 @@
use chrono::{DateTime, Utc};
use chrono::{DateTime, Duration, Utc};
use crate::user::UserId;
use crate::user::{Timezone, UserId};
use super::due_occurrence::{DueOccurrence, occurrence_reached};
use super::{DaySchedule, ReminderId};
#[derive(Debug, Clone)]
@@ -11,6 +12,7 @@ pub struct Reminder {
schedule: DaySchedule,
enabled: bool,
created_at: DateTime<Utc>,
last_sent_at: Option<DateTime<Utc>>,
}
impl Reminder {
@@ -21,6 +23,7 @@ impl Reminder {
schedule,
enabled: true,
created_at: Utc::now(),
last_sent_at: None,
}
}
@@ -30,6 +33,7 @@ impl Reminder {
schedule: DaySchedule,
enabled: bool,
created_at: DateTime<Utc>,
last_sent_at: Option<DateTime<Utc>>,
) -> Self {
Self {
id,
@@ -37,6 +41,7 @@ impl Reminder {
schedule,
enabled,
created_at,
last_sent_at,
}
}
}
@@ -61,6 +66,23 @@ impl Reminder {
pub fn created_at(&self) -> &DateTime<Utc> {
&self.created_at
}
pub fn last_sent_at(&self) -> Option<DateTime<Utc>> {
self.last_sent_at
}
pub fn occurrence_reached(
&self,
now: DateTime<Utc>,
timezone: &Timezone,
grace: Duration,
) -> Option<DueOccurrence> {
if !self.enabled {
return None;
}
occurrence_reached(&self.schedule, self.last_sent_at, now, timezone, grace)
}
}
impl Reminder {
@@ -75,4 +97,8 @@ impl Reminder {
pub fn disable(&mut self) {
self.enabled = false;
}
pub fn mark_sent(&mut self, occurrence: DueOccurrence) {
self.last_sent_at = Some(occurrence.scheduled_for());
}
}

View File

@@ -0,0 +1,54 @@
use std::collections::HashMap;
use std::sync::Arc;
use crate::dimension::{DimensionKind, DimensionValue, lookup};
use crate::entry::MoodEntryId;
use crate::errors::DomainError;
use super::store::InMemoryStore;
pub struct InMemoryActivityDimension {
store: Arc<InMemoryStore>,
}
impl InMemoryActivityDimension {
pub fn sharing(store: Arc<InMemoryStore>) -> Self {
Self { store }
}
}
#[async_trait::async_trait]
impl crate::ports::EntryDimensionPort for InMemoryActivityDimension {
async fn load(
&self,
entry_ids: &[MoodEntryId],
) -> Result<HashMap<MoodEntryId, DimensionValue>, DomainError> {
let tagged = self.store.entry_activities.read().unwrap();
Ok(entry_ids
.iter()
.filter_map(|id| tagged.get(id).map(|ids| (id.clone(), ids.clone())))
.filter(|(_, ids)| !ids.is_empty())
.map(|(id, ids)| (id, DimensionValue::activities(ids)))
.collect())
}
async fn save(
&self,
entry_id: &MoodEntryId,
values: &[DimensionValue],
) -> Result<(), DomainError> {
let mut tagged = self.store.entry_activities.write().unwrap();
let names_activities = values
.iter()
.any(|value| value.kind() == DimensionKind::Activities);
match names_activities {
true => tagged.insert(entry_id.clone(), lookup::activities_in(values).to_vec()),
false => tagged.remove(entry_id),
};
Ok(())
}
}

View File

@@ -5,9 +5,11 @@ use crate::attachment::{ContentType, MediaUpload, PhotoId, VoiceMemoId};
use crate::errors::DomainError;
use crate::ports::MediaFile;
type Blob = (Vec<u8>, ContentType);
pub struct FakeMediaStorage {
photos: RwLock<HashMap<PhotoId, Vec<u8>>>,
voice_memos: RwLock<HashMap<VoiceMemoId, Vec<u8>>>,
photos: RwLock<HashMap<PhotoId, Blob>>,
voice_memos: RwLock<HashMap<VoiceMemoId, Blob>>,
deletions_attempted: RwLock<Vec<String>>,
refusing_to_delete: bool,
}
@@ -30,17 +32,49 @@ impl FakeMediaStorage {
}
pub fn put_photo(&self, id: &PhotoId, bytes: &[u8]) {
self.put_photo_as(
id,
bytes,
ContentType::from_persistence("image/jpeg".into()),
);
}
pub fn put_photo_as(&self, id: &PhotoId, bytes: &[u8], content_type: ContentType) {
self.photos
.write()
.unwrap()
.insert(id.clone(), bytes.to_vec());
.insert(id.clone(), (bytes.to_vec(), content_type));
}
pub fn put_voice_memo(&self, id: &VoiceMemoId, bytes: &[u8]) {
self.put_voice_memo_as(
id,
bytes,
ContentType::from_persistence("audio/webm".into()),
);
}
pub fn put_voice_memo_as(&self, id: &VoiceMemoId, bytes: &[u8], content_type: ContentType) {
self.voice_memos
.write()
.unwrap()
.insert(id.clone(), bytes.to_vec());
.insert(id.clone(), (bytes.to_vec(), content_type));
}
pub fn type_of_photo(&self, id: &PhotoId) -> Option<ContentType> {
self.photos
.read()
.unwrap()
.get(id)
.map(|(_, content_type)| content_type.clone())
}
pub fn type_of_voice_memo(&self, id: &VoiceMemoId) -> Option<ContentType> {
self.voice_memos
.read()
.unwrap()
.get(id)
.map(|(_, content_type)| content_type.clone())
}
pub fn holds_photo(&self, id: &PhotoId) -> bool {
@@ -70,23 +104,28 @@ impl Default for FakeMediaStorage {
impl crate::ports::MediaStoragePort for FakeMediaStorage {
async fn store_photo(&self, upload: MediaUpload) -> Result<PhotoId, DomainError> {
let id = PhotoId::generate();
self.put_photo(&id, upload.data());
self.put_photo_as(&id, upload.data(), upload.content_type().clone());
Ok(id)
}
async fn store_voice_memo(&self, upload: MediaUpload) -> Result<VoiceMemoId, DomainError> {
let id = VoiceMemoId::generate();
self.put_voice_memo(&id, upload.data());
self.put_voice_memo_as(&id, upload.data(), upload.content_type().clone());
Ok(id)
}
async fn get_photo(&self, id: &PhotoId) -> Result<Option<MediaFile>, DomainError> {
Ok(self.photos.read().unwrap().get(id).map(|bytes| MediaFile {
data: bytes.clone(),
content_type: ContentType::from_persistence("image/jpeg".into()),
}))
Ok(self
.photos
.read()
.unwrap()
.get(id)
.map(|(data, content_type)| MediaFile {
data: data.clone(),
content_type: content_type.clone(),
}))
}
async fn get_voice_memo(&self, id: &VoiceMemoId) -> Result<Option<MediaFile>, DomainError> {
@@ -95,9 +134,9 @@ impl crate::ports::MediaStoragePort for FakeMediaStorage {
.read()
.unwrap()
.get(id)
.map(|bytes| MediaFile {
data: bytes.clone(),
content_type: ContentType::from_persistence("audio/webm".into()),
.map(|(data, content_type)| MediaFile {
data: data.clone(),
content_type: content_type.clone(),
}))
}

View File

@@ -1,3 +1,4 @@
mod activity_dimension;
mod api_token_secret;
mod cipher;
mod dimension_store;
@@ -13,6 +14,7 @@ mod store_cycle;
mod store_entry;
mod store_infra;
mod store_job;
mod store_media;
mod store_metric;
mod store_provider;
mod store_rejection;
@@ -20,6 +22,7 @@ mod store_reminder;
mod store_user;
mod weather;
pub use activity_dimension::InMemoryActivityDimension;
pub use api_token_secret::FakeApiTokenSecret;
pub use cipher::FakeCredentialCipher;
pub use dimension_store::InMemoryDimensionStore;

View File

@@ -45,6 +45,7 @@ pub struct InMemoryStore {
pub(super) dependent_dimensions: RwLock<Vec<std::sync::Arc<InMemoryDimensionStore>>>,
pub(super) events: RwLock<Vec<EventEnvelope>>,
pub(super) sent_reminders: RwLock<Vec<UserId>>,
pub(super) media_owners: RwLock<HashMap<crate::attachment::MediaRef, UserId>>,
}
impl InMemoryStore {
@@ -71,6 +72,7 @@ impl InMemoryStore {
dependent_dimensions: RwLock::new(Vec::new()),
events: RwLock::new(Vec::new()),
sent_reminders: RwLock::new(Vec::new()),
media_owners: RwLock::new(HashMap::new()),
}
}

View File

@@ -1,5 +1,5 @@
use crate::activity::ActivityId;
use crate::entry::{DateRange, Mood, MoodEntry, MoodEntryId};
use crate::entry::{DateRange, EntrySelection, MoodEntry, MoodEntryId, Pagination};
use crate::errors::DomainError;
use crate::user::UserId;
@@ -84,26 +84,28 @@ impl crate::ports::MoodEntryQueryPort for InMemoryStore {
Ok(self.entries.read().unwrap().get(id).cloned())
}
async fn find_by_user(
async fn find_all_by_user(&self, user_id: &UserId) -> Result<Vec<MoodEntry>, DomainError> {
Ok(self.newest_first(|entry| entry.user_id() == user_id))
}
async fn select(
&self,
user_id: &UserId,
limit: Option<i64>,
offset: Option<i64>,
selection: &EntrySelection,
page: Pagination,
) -> Result<Vec<MoodEntry>, DomainError> {
let mut entries: Vec<_> = self
.entries
.read()
.unwrap()
.values()
.filter(|e| e.user_id() == user_id)
.cloned()
.collect();
let matching = self.newest_first(|entry| self.matches(entry, selection));
let offset = offset.unwrap_or(0) as usize;
let limit = limit.unwrap_or(i64::MAX) as usize;
entries = entries.into_iter().skip(offset).take(limit).collect();
Ok(matching
.into_iter()
.skip(page.offset() as usize)
.take(page.limit() as usize)
.collect())
}
Ok(entries)
async fn count(&self, selection: &EntrySelection) -> Result<u64, DomainError> {
Ok(self
.newest_first(|entry| self.matches(entry, selection))
.len() as u64)
}
async fn find_by_date_range(
@@ -125,41 +127,69 @@ impl crate::ports::MoodEntryQueryPort for InMemoryStore {
.collect())
}
async fn find_by_mood(
&self,
user_id: &UserId,
mood: Mood,
) -> Result<Vec<MoodEntry>, DomainError> {
Ok(self
.entries
async fn count_tagged_with(&self, activity_id: &ActivityId) -> Result<u64, DomainError> {
let tagged = self
.entry_activities
.read()
.unwrap()
.values()
.filter(|e| e.user_id() == user_id && e.mood() == mood)
.cloned()
.collect())
}
.filter(|ids| ids.contains(activity_id))
.count();
async fn find_by_activity(
&self,
user_id: &UserId,
activity_id: &ActivityId,
) -> Result<Vec<MoodEntry>, DomainError> {
Ok(self
.entries
.read()
.unwrap()
.values()
.filter(|e| {
e.user_id() == user_id
&& self
.entry_activities
.read()
.unwrap()
.get(e.id())
.is_some_and(|ids| ids.contains(activity_id))
})
.cloned()
.collect())
Ok(tagged as u64)
}
}
impl InMemoryStore {
fn newest_first(&self, wanted: impl Fn(&MoodEntry) -> bool) -> Vec<MoodEntry> {
let mut found: Vec<MoodEntry> = self
.entries
.read()
.unwrap()
.values()
.filter(|entry| wanted(entry))
.cloned()
.collect();
found.sort_by(|left, right| right.logged_at().cmp(left.logged_at()));
found
}
fn matches(&self, entry: &MoodEntry, selection: &EntrySelection) -> bool {
if entry.user_id() != selection.user_id() {
return false;
}
if let Some(range) = selection.range()
&& (entry.logged_at() < range.start() || entry.logged_at() > range.end())
{
return false;
}
if let Some(mood) = selection.mood()
&& entry.mood() != mood
{
return false;
}
if let Some(activity_id) = selection.activity()
&& !self
.entry_activities
.read()
.unwrap()
.get(entry.id())
.is_some_and(|ids| ids.contains(activity_id))
{
return false;
}
if let Some(since) = selection.since()
&& *entry.updated_at() <= since
{
return false;
}
true
}
}

View File

@@ -49,45 +49,54 @@ impl crate::ports::MediaStoragePort for InMemoryStore {
}
}
impl InMemoryStore {
fn forget_what_the_user_logged(&self, user_id: &UserId) {
self.entries
.write()
.unwrap()
.retain(|_, entry| entry.user_id() != user_id);
self.activities
.write()
.unwrap()
.retain(|_, activity| activity.user_id() != user_id);
self.reminders
.write()
.unwrap()
.retain(|_, reminder| reminder.user_id() != user_id);
self.daily_metrics
.write()
.unwrap()
.retain(|metric| metric.user_id() != user_id);
self.cycle_starts
.write()
.unwrap()
.retain(|(held, _)| held != user_id);
self.rejections
.write()
.unwrap()
.retain(|rejected| rejected.user_id() != user_id);
self.media_owners
.write()
.unwrap()
.retain(|_, owner| owner != user_id);
}
}
#[async_trait::async_trait]
impl crate::ports::CascadeDeletePort for InMemoryStore {
async fn delete_all_user_data(&self, user_id: &UserId) -> Result<(), DomainError> {
self.cascade_to_dimensions(&self.entry_ids_of(user_id));
self.entries
.write()
.unwrap()
.retain(|_, e| e.user_id() != user_id);
self.activities
.write()
.unwrap()
.retain(|_, a| a.user_id() != user_id);
self.reminders
.write()
.unwrap()
.retain(|_, r| r.user_id() != user_id);
self.daily_metrics
.write()
.unwrap()
.retain(|m| m.user_id() != user_id);
self.forget_what_the_user_logged(user_id);
Ok(())
}
async fn delete_user_account(&self, user_id: &UserId) -> Result<(), DomainError> {
self.cascade_to_dimensions(&self.entry_ids_of(user_id));
self.entries
.write()
.unwrap()
.retain(|_, e| e.user_id() != user_id);
self.activities
.write()
.unwrap()
.retain(|_, a| a.user_id() != user_id);
self.reminders
.write()
.unwrap()
.retain(|_, r| r.user_id() != user_id);
self.forget_what_the_user_logged(user_id);
self.refresh_sessions
.write()
.unwrap()
@@ -96,11 +105,16 @@ impl crate::ports::CascadeDeletePort for InMemoryStore {
.write()
.unwrap()
.retain(|_, s| s.user_id() != user_id);
self.daily_metrics
self.provider_connections
.write()
.unwrap()
.retain(|m| m.user_id() != user_id);
.retain(|c| c.user_id() != user_id);
self.api_tokens
.write()
.unwrap()
.retain(|t| t.user_id() != user_id);
self.users.write().unwrap().remove(user_id);
Ok(())
}
@@ -147,10 +161,13 @@ impl InMemoryStore {
#[async_trait::async_trait]
impl crate::ports::PushSubscriptionCommandPort for InMemoryStore {
async fn save(&self, subscription: &PushSubscription) -> Result<(), DomainError> {
self.push_subscriptions
.write()
.unwrap()
.insert(subscription.id().clone(), subscription.clone());
let mut held = self.push_subscriptions.write().unwrap();
held.retain(|id, existing| {
id == subscription.id() || existing.endpoint() != subscription.endpoint()
});
held.insert(subscription.id().clone(), subscription.clone());
Ok(())
}
@@ -159,11 +176,15 @@ impl crate::ports::PushSubscriptionCommandPort for InMemoryStore {
Ok(())
}
async fn delete_by_endpoint(&self, endpoint: &str) -> Result<(), DomainError> {
async fn delete_by_endpoint(
&self,
user_id: &UserId,
endpoint: &str,
) -> Result<(), DomainError> {
self.push_subscriptions
.write()
.unwrap()
.retain(|_, s| s.endpoint() != endpoint);
.retain(|_, s| s.user_id() != user_id || s.endpoint() != endpoint);
Ok(())
}

View File

@@ -172,6 +172,15 @@ impl crate::ports::RecordingBackfillQueryPort for InMemoryStore {
.collect())
}
async fn find_song_without_a_recording(
&self,
entry_id: &crate::entry::MoodEntryId,
) -> Result<Option<UnidentifiedSong>, DomainError> {
let waiting = self.find_songs_without_a_recording(usize::MAX).await?;
Ok(waiting.into_iter().find(|song| &song.entry_id == entry_id))
}
async fn record_identity(
&self,
entry_id: &crate::entry::MoodEntryId,
@@ -241,4 +250,15 @@ impl crate::ports::WeatherBacklogQueryPort for InMemoryStore {
})
.collect())
}
async fn find_place_without_weather(
&self,
entry_id: &crate::entry::MoodEntryId,
) -> Result<Option<crate::ports::UnwatchedPlace>, DomainError> {
let waiting = self.find_places_without_weather(usize::MAX).await?;
Ok(waiting
.into_iter()
.find(|place| &place.entry_id == entry_id))
}
}

View File

@@ -0,0 +1,47 @@
use crate::attachment::MediaRef;
use crate::errors::DomainError;
use crate::user::UserId;
use super::store::InMemoryStore;
#[async_trait::async_trait]
impl crate::ports::MediaOwnershipPort for InMemoryStore {
async fn remember(&self, owner: &UserId, media: MediaRef) -> Result<(), DomainError> {
self.media_owners
.write()
.unwrap()
.insert(media, owner.clone());
Ok(())
}
async fn owner_of(&self, media: MediaRef) -> Result<Option<UserId>, DomainError> {
Ok(self.media_owners.read().unwrap().get(&media).cloned())
}
async fn owned_by(&self, owner: &UserId) -> Result<Vec<MediaRef>, DomainError> {
Ok(self
.media_owners
.read()
.unwrap()
.iter()
.filter(|(_, held)| *held == owner)
.map(|(media, _)| *media)
.collect())
}
async fn forget(&self, media: MediaRef) -> Result<(), DomainError> {
self.media_owners.write().unwrap().remove(&media);
Ok(())
}
async fn forget_all_by_user(&self, owner: &UserId) -> Result<(), DomainError> {
self.media_owners
.write()
.unwrap()
.retain(|_, held| held != owner);
Ok(())
}
}

View File

@@ -4,6 +4,6 @@ mod fakes;
pub use factories::*;
pub use fakes::{
FakeApiTokenSecret, FakeCredentialCipher, FakeMediaStorage, FakeNowPlaying, FakePasswordHasher,
FakeRecordingLookup, FakeWeatherLookup, InMemoryDimensionStore, InMemoryStore, OneTokenStore,
RefusingApiTokenStore, RefusingRejectionTrace,
FakeRecordingLookup, FakeWeatherLookup, InMemoryActivityDimension, InMemoryDimensionStore,
InMemoryStore, OneTokenStore, RefusingApiTokenStore, RefusingRejectionTrace,
};

View File

@@ -1,12 +1,17 @@
use domain::api_token::{ApiToken, MintedApiToken, TokenDigest, TokenScope};
use domain::api_token::{ApiToken, MintedApiToken, TokenDigest, TokenScope, TokenScopes};
use domain::provider::ProviderName;
use domain::user::UserId;
fn a_token() -> ApiToken {
token_granting([TokenScope::WriteMetrics])
}
fn token_granting(scopes: impl IntoIterator<Item = TokenScope>) -> ApiToken {
ApiToken::new(
UserId::generate(),
ProviderName::new("iphone-shortcuts").unwrap(),
TokenDigest::from_persistence("digest-of-the-secret".into()),
TokenScopes::new(scopes).unwrap(),
)
}
@@ -15,7 +20,7 @@ fn a_fresh_token_has_never_been_used() {
let token = a_token();
assert!(token.last_used_at().is_none());
assert_eq!(token.scope(), TokenScope::WriteMetrics);
assert!(token.allows(TokenScope::WriteMetrics));
}
#[test]
@@ -36,13 +41,58 @@ fn the_name_a_token_carries_is_the_provider_its_writes_are_attributed_to() {
#[test]
fn every_scope_survives_a_round_trip_through_its_name() {
assert_eq!(
TokenScope::from_name(TokenScope::WriteMetrics.name()),
Some(TokenScope::WriteMetrics)
);
for scope in TokenScope::ALL {
assert_eq!(TokenScope::from_name(scope.name()), Some(scope));
}
assert_eq!(TokenScope::from_name("read-everything"), None);
}
#[test]
fn a_token_grants_only_what_it_was_minted_for() {
let token = token_granting([TokenScope::ReadJournal]);
assert!(token.allows(TokenScope::ReadJournal));
assert!(!token.allows(TokenScope::WriteJournal));
assert!(!token.allows(TokenScope::WriteMetrics));
assert!(!token.allows(TokenScope::ReadProfile));
}
#[test]
fn a_token_that_grants_nothing_cannot_be_minted() {
assert!(TokenScopes::new([]).is_err());
assert!(TokenScopes::from_names(Vec::<&str>::new()).is_err());
}
#[test]
fn an_unknown_scope_name_is_refused_rather_than_ignored() {
let refused = TokenScopes::from_names(["readJournal", "readEverything"]);
assert!(
refused.is_err(),
"a typo must not quietly mint a narrower token than asked for"
);
}
#[test]
fn scopes_survive_a_round_trip_through_persistence() {
let held = TokenScopes::new([TokenScope::ReadJournal, TokenScope::WriteJournal]).unwrap();
assert_eq!(
TokenScopes::from_persistence(&held.to_persistence()),
Some(held)
);
}
#[test]
fn a_stored_scope_this_build_cannot_read_makes_the_whole_set_unreadable() {
assert_eq!(
TokenScopes::from_persistence("readJournal writeEverything"),
None
);
assert_eq!(TokenScopes::from_persistence(""), None);
}
#[test]
fn a_minted_token_keeps_the_secret_out_of_its_debug_output() {
let minted = MintedApiToken::new(a_token(), "kmood_supersecretvalue".into());

View File

@@ -1,2 +1,5 @@
#[path = "reminder/due_occurrence_test.rs"]
mod due_occurrence_test;
#[path = "reminder/reminder_test.rs"]
mod reminder_test;

View File

@@ -0,0 +1,169 @@
use chrono::{DateTime, Duration, NaiveTime, TimeZone, Utc, Weekday};
use domain::reminder::{DaySchedule, Reminder};
use domain::user::{Timezone, UserId};
const GRACE: Duration = Duration::minutes(30);
fn warsaw() -> Timezone {
Timezone::new("Europe/Warsaw").unwrap()
}
fn at(time: &str) -> NaiveTime {
NaiveTime::parse_from_str(time, "%H:%M").unwrap()
}
fn instant(text: &str) -> DateTime<Utc> {
DateTime::parse_from_rfc3339(text)
.unwrap()
.with_timezone(&Utc)
}
fn every_day_at(time: &str) -> Reminder {
Reminder::new(UserId::generate(), DaySchedule::every_day_at(at(time)))
}
#[test]
fn a_reminder_is_due_once_its_local_time_has_arrived() {
let reminder = every_day_at("20:00");
let just_after_eight_in_warsaw = instant("2026-08-27T18:01:00Z");
assert!(
reminder
.occurrence_reached(just_after_eight_in_warsaw, &warsaw(), GRACE)
.is_some()
);
}
#[test]
fn a_reminder_is_not_due_before_its_local_time() {
let reminder = every_day_at("20:00");
let half_past_seven_in_warsaw = instant("2026-08-27T17:30:00Z");
assert!(
reminder
.occurrence_reached(half_past_seven_in_warsaw, &warsaw(), GRACE)
.is_none()
);
}
#[test]
fn the_same_occurrence_is_never_due_twice() {
let mut reminder = every_day_at("20:00");
let first_sweep = instant("2026-08-27T18:01:00Z");
let occurrence = reminder
.occurrence_reached(first_sweep, &warsaw(), GRACE)
.expect("the reminder should be due on the first sweep");
reminder.mark_sent(occurrence);
for minute in 2..=25 {
let later = instant(&format!("2026-08-27T18:{minute:02}:00Z"));
assert!(
reminder
.occurrence_reached(later, &warsaw(), GRACE)
.is_none(),
"a reminder already sent must not be sent again at 18:{minute:02}"
);
}
}
#[test]
fn the_next_days_occurrence_is_due_again() {
let mut reminder = every_day_at("20:00");
let today = instant("2026-08-27T18:01:00Z");
let occurrence = reminder
.occurrence_reached(today, &warsaw(), GRACE)
.unwrap();
reminder.mark_sent(occurrence);
let tomorrow = instant("2026-08-28T18:01:00Z");
assert!(
reminder
.occurrence_reached(tomorrow, &warsaw(), GRACE)
.is_some(),
"a new day is a new occurrence"
);
}
#[test]
fn an_occurrence_late_yesterday_is_still_reachable_after_local_midnight() {
let schedule = {
let mut schedule = DaySchedule::new();
schedule.set_time(Weekday::Thu, Some(at("23:50")));
schedule
};
let reminder = Reminder::new(UserId::generate(), schedule);
let ten_past_midnight_on_friday = instant("2026-08-27T22:10:00Z");
assert!(
reminder
.occurrence_reached(ten_past_midnight_on_friday, &warsaw(), GRACE)
.is_some(),
"a reminder scheduled just before midnight must survive the day boundary"
);
}
#[test]
fn an_occurrence_older_than_the_grace_window_is_not_resurrected() {
let reminder = every_day_at("08:00");
let late_that_evening = instant("2026-08-27T19:00:00Z");
assert!(
reminder
.occurrence_reached(late_that_evening, &warsaw(), GRACE)
.is_none(),
"a worker starting late must not fire this morning's reminder"
);
}
#[test]
fn a_disabled_reminder_is_never_due() {
let mut reminder = every_day_at("20:00");
reminder.disable();
assert!(
reminder
.occurrence_reached(instant("2026-08-27T18:01:00Z"), &warsaw(), GRACE)
.is_none()
);
}
#[test]
fn a_day_with_no_time_set_is_never_due() {
let schedule = {
let mut schedule = DaySchedule::new();
schedule.set_time(Weekday::Mon, Some(at("20:00")));
schedule
};
let reminder = Reminder::new(UserId::generate(), schedule);
let a_thursday = instant("2026-08-27T18:01:00Z");
assert!(
reminder
.occurrence_reached(a_thursday, &warsaw(), GRACE)
.is_none()
);
}
#[test]
fn the_scheduled_instant_is_resolved_in_the_users_own_zone() {
let reminder = every_day_at("20:00");
let warsaw_evening = instant("2026-08-27T18:01:00Z");
let occurrence = reminder
.occurrence_reached(warsaw_evening, &warsaw(), GRACE)
.unwrap();
assert_eq!(
occurrence.scheduled_for(),
Utc.with_ymd_and_hms(2026, 8, 27, 18, 0, 0).unwrap(),
"8 PM in Warsaw in August is 18:00Z"
);
}