changes
All checks were successful
CI / ci (push) Successful in 19m38s

This commit is contained in:
2026-08-26 20:55:30 +02:00
parent a557c183e9
commit 23d052278a
523 changed files with 24448 additions and 2005 deletions

View File

@@ -8,6 +8,7 @@ thiserror.workspace = true
async-trait.workspace = true
uuid.workspace = true
chrono.workspace = true
chrono-tz.workspace = true
serde.workspace = true
email_address.workspace = true
tracing.workspace = true

View File

@@ -0,0 +1,89 @@
use chrono::{DateTime, Utc};
use crate::provider::ProviderName;
use crate::user::UserId;
use super::{ApiTokenId, TokenDigest, TokenScope};
pub struct ApiTokenData {
pub id: ApiTokenId,
pub user_id: UserId,
pub name: ProviderName,
pub digest: TokenDigest,
pub scope: TokenScope,
pub created_at: DateTime<Utc>,
pub last_used_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone)]
pub struct ApiToken {
id: ApiTokenId,
user_id: UserId,
name: ProviderName,
digest: TokenDigest,
scope: TokenScope,
created_at: DateTime<Utc>,
last_used_at: Option<DateTime<Utc>>,
}
impl ApiToken {
pub fn new(user_id: UserId, name: ProviderName, digest: TokenDigest) -> Self {
Self {
id: ApiTokenId::generate(),
user_id,
name,
digest,
scope: TokenScope::WriteMetrics,
created_at: Utc::now(),
last_used_at: None,
}
}
pub fn from_persistence(data: ApiTokenData) -> Self {
Self {
id: data.id,
user_id: data.user_id,
name: data.name,
digest: data.digest,
scope: data.scope,
created_at: data.created_at,
last_used_at: data.last_used_at,
}
}
}
impl ApiToken {
pub fn id(&self) -> &ApiTokenId {
&self.id
}
pub fn user_id(&self) -> &UserId {
&self.user_id
}
pub fn name(&self) -> &ProviderName {
&self.name
}
pub fn digest(&self) -> &TokenDigest {
&self.digest
}
pub fn scope(&self) -> TokenScope {
self.scope
}
pub fn created_at(&self) -> &DateTime<Utc> {
&self.created_at
}
pub fn last_used_at(&self) -> Option<&DateTime<Utc>> {
self.last_used_at.as_ref()
}
}
impl ApiToken {
pub fn mark_used(&mut self) {
self.last_used_at = Some(Utc::now());
}
}

View File

@@ -0,0 +1,3 @@
use crate::macros::uuid_id;
uuid_id!(ApiTokenId);

View File

@@ -0,0 +1,29 @@
use super::ApiToken;
pub struct MintedApiToken {
token: ApiToken,
secret: String,
}
impl MintedApiToken {
pub fn new(token: ApiToken, secret: String) -> Self {
Self { token, secret }
}
pub fn token(&self) -> &ApiToken {
&self.token
}
pub fn secret(&self) -> &str {
&self.secret
}
}
impl std::fmt::Debug for MintedApiToken {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MintedApiToken")
.field("token", &self.token)
.field("secret", &"<shown once, never logged>")
.finish()
}
}

View File

@@ -0,0 +1,11 @@
mod api_token;
mod api_token_id;
mod minted_api_token;
mod token_digest;
mod token_scope;
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;

View File

@@ -0,0 +1,18 @@
#[derive(Clone, PartialEq, Eq, Hash)]
pub struct TokenDigest(String);
impl TokenDigest {
pub fn from_persistence(digest: String) -> Self {
Self(digest)
}
pub fn value(&self) -> &str {
&self.0
}
}
impl std::fmt::Debug for TokenDigest {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("TokenDigest(<redacted>)")
}
}

View File

@@ -0,0 +1,21 @@
const WRITE_METRICS: &str = "writeMetrics";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum TokenScope {
WriteMetrics,
}
impl TokenScope {
pub fn name(&self) -> &'static str {
match self {
Self::WriteMetrics => WRITE_METRICS,
}
}
pub fn from_name(name: &str) -> Option<Self> {
match name {
WRITE_METRICS => Some(Self::WriteMetrics),
_ => None,
}
}
}

View File

@@ -0,0 +1,76 @@
use std::collections::BTreeMap;
use super::{CorrelationStrategy, Family, PValue};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Tested {
pub family: Family,
pub strategy: CorrelationStrategy,
pub p_value: PValue,
}
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct Adjustment {
false_discovery_rate: f64,
}
impl Adjustment {
pub fn controlling_false_discovery_at(false_discovery_rate: f64) -> Self {
Self {
false_discovery_rate,
}
}
pub fn holds_up_across(&self, tested: &[Tested]) -> Vec<bool> {
let mut groups: BTreeMap<(Family, CorrelationStrategy), Vec<usize>> = BTreeMap::new();
for (index, entry) in tested.iter().enumerate() {
groups
.entry((entry.family, entry.strategy))
.or_default()
.push(index);
}
let mut held = vec![false; tested.len()];
for places in groups.values() {
let p_values: Vec<PValue> = places.iter().map(|index| tested[*index].p_value).collect();
for (place, holds) in places.iter().zip(self.holds_up(&p_values)) {
held[*place] = holds;
}
}
held
}
pub fn holds_up(&self, p_values: &[PValue]) -> Vec<bool> {
let tested = p_values.len();
let mut by_size: Vec<usize> = (0..tested).collect();
by_size.sort_by(|left, right| p_values[*left].value().total_cmp(&p_values[*right].value()));
let largest_passing_rank = by_size
.iter()
.enumerate()
.filter(|(position, index)| {
p_values[**index].value() <= self.threshold_at(position + 1, tested)
})
.map(|(position, _)| position + 1)
.max();
let Some(rank) = largest_passing_rank else {
return vec![false; tested];
};
let mut held = vec![false; tested];
for index in &by_size[..rank] {
held[*index] = true;
}
held
}
fn threshold_at(&self, rank: usize, tested: usize) -> f64 {
rank as f64 / tested as f64 * self.false_discovery_rate
}
}

View File

@@ -0,0 +1,29 @@
use super::Coefficient;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Agreement {
agreeing: usize,
applicable: usize,
}
impl Agreement {
pub fn of(scores: &[Coefficient], applicable: usize) -> Self {
let positive = scores.iter().filter(|score| score.value() > 0.0).count();
let negative = scores.iter().filter(|score| score.value() < 0.0).count();
Self {
agreeing: positive.max(negative),
applicable,
}
}
}
impl Agreement {
pub fn agreeing(&self) -> usize {
self.agreeing
}
pub fn applicable(&self) -> usize {
self.applicable
}
}

View File

@@ -0,0 +1,29 @@
use crate::errors::DomainError;
const STRONGEST_NEGATIVE: f64 = -1.0;
const STRONGEST_POSITIVE: f64 = 1.0;
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct Coefficient(f64);
impl Coefficient {
pub fn new(value: f64) -> Result<Self, DomainError> {
if !value.is_finite() {
return Err(DomainError::InvalidInput(
"a correlation coefficient must be a finite number".into(),
));
}
if !(STRONGEST_NEGATIVE..=STRONGEST_POSITIVE).contains(&value) {
return Err(DomainError::InvalidInput(format!(
"a correlation coefficient must be between {STRONGEST_NEGATIVE} and {STRONGEST_POSITIVE}, got {value}"
)));
}
Ok(Self(value))
}
pub fn value(&self) -> f64 {
self.0
}
}

View File

@@ -0,0 +1,48 @@
use crate::activity::ActivityId;
use crate::metric::MetricKind;
use super::{Family, SeriesShape};
const MOON_PHASE: &str = "moonPhase";
const ACTIVITY: &str = "activity";
const CYCLE_PROGRESS: &str = "cycleProgress";
const TEMPERATURE: &str = "temperature";
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum CorrelationInput {
Metric(MetricKind),
MoonPhase,
CycleProgress,
Temperature,
Activity(ActivityId),
}
impl CorrelationInput {
pub fn name(&self) -> &'static str {
match self {
Self::Metric(kind) => kind.name(),
Self::MoonPhase => MOON_PHASE,
Self::CycleProgress => CYCLE_PROGRESS,
Self::Temperature => TEMPERATURE,
Self::Activity(_) => ACTIVITY,
}
}
pub fn family(&self) -> Family {
match self {
Self::Metric(_) | Self::MoonPhase | Self::CycleProgress | Self::Temperature => {
Family::Measurements
}
Self::Activity(_) => Family::Activities,
}
}
pub fn series(&self) -> SeriesShape {
match self {
Self::Metric(_) | Self::MoonPhase | Self::CycleProgress | Self::Temperature => {
SeriesShape::Continuous
}
Self::Activity(_) => SeriesShape::Presence,
}
}
}

View File

@@ -0,0 +1,113 @@
use super::PValue;
use super::kendall::kendall_tau_b;
use super::mean_difference::mean_difference;
use super::pearson::pearson;
use super::ranks::average_ranks;
use super::welch::welch_standard_score;
use super::{Coefficient, CorrelationInput, Observation, SeriesShape};
const PEARSON: &str = "pearson";
const SPEARMAN: &str = "spearman";
const KENDALL: &str = "kendall";
const MEAN_DIFFERENCE: &str = "meanDifference";
const FEWEST_OBSERVATIONS: usize = 2;
const FEWEST_FOR_A_P_VALUE: usize = 4;
const SPEARMAN_VARIANCE_INFLATION: f64 = 1.06;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum CorrelationStrategy {
Pearson,
Spearman,
Kendall,
MeanDifference,
}
impl CorrelationStrategy {
pub const ALL: [CorrelationStrategy; 4] = [
Self::Pearson,
Self::Spearman,
Self::Kendall,
Self::MeanDifference,
];
pub fn name(&self) -> &'static str {
match self {
Self::Pearson => PEARSON,
Self::Spearman => SPEARMAN,
Self::Kendall => KENDALL,
Self::MeanDifference => MEAN_DIFFERENCE,
}
}
pub fn scores(&self) -> SeriesShape {
match self {
Self::Pearson | Self::Spearman | Self::Kendall => SeriesShape::Continuous,
Self::MeanDifference => SeriesShape::Presence,
}
}
pub fn can_score(&self, input: &CorrelationInput) -> bool {
self.scores() == input.series()
}
pub fn significance(&self, observations: &[Observation]) -> Option<PValue> {
if observations.len() < FEWEST_FOR_A_P_VALUE {
return None;
}
self.standard_score(observations)
.map(PValue::from_standard_score)
}
fn standard_score(&self, observations: &[Observation]) -> Option<f64> {
let (values, moods) = series(observations);
let count = observations.len() as f64;
match self {
Self::Pearson => {
let scored = pearson(&values, &moods)?;
Some(fisher(scored, count, 1.0))
}
Self::Spearman => {
let scored = pearson(&average_ranks(&values), &average_ranks(&moods))?;
Some(fisher(scored, count, SPEARMAN_VARIANCE_INFLATION))
}
Self::Kendall => {
let scored = kendall_tau_b(&values, &moods)?;
Some(scored * (9.0 * count * (count - 1.0) / (2.0 * (2.0 * count + 5.0))).sqrt())
}
Self::MeanDifference => welch_standard_score(&values, &moods),
}
}
pub fn score(&self, observations: &[Observation]) -> Option<Coefficient> {
if observations.len() < FEWEST_OBSERVATIONS {
return None;
}
let (values, moods) = series(observations);
let scored = match self {
Self::Pearson => pearson(&values, &moods),
Self::Spearman => pearson(&average_ranks(&values), &average_ranks(&moods)),
Self::Kendall => kendall_tau_b(&values, &moods),
Self::MeanDifference => mean_difference(&values, &moods),
}?;
Coefficient::new(scored.clamp(-1.0, 1.0)).ok()
}
}
fn series(observations: &[Observation]) -> (Vec<f64>, Vec<f64>) {
let values = observations.iter().map(Observation::value).collect();
let moods = observations
.iter()
.map(|observation| observation.day_mood().value())
.collect();
(values, moods)
}
fn fisher(scored: f64, count: f64, variance_inflation: f64) -> f64 {
scored.atanh() * ((count - 3.0) / variance_inflation).sqrt()
}

View File

@@ -0,0 +1,5 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Family {
Measurements,
Activities,
}

View File

@@ -0,0 +1,63 @@
pub fn kendall_tau_b(left: &[f64], right: &[f64]) -> Option<f64> {
let mut concordant = 0i64;
let mut discordant = 0i64;
for first in 0..left.len() {
for second in (first + 1)..left.len() {
let agreement =
direction(left[first] - left[second]) * direction(right[first] - right[second]);
match agreement {
1 => concordant += 1,
-1 => discordant += 1,
_ => continue,
}
}
}
let pairs = pair_count(left.len());
let denominator = ((pairs - tied_pairs(left)) * (pairs - tied_pairs(right))).sqrt();
if denominator == 0.0 {
return None;
}
Some((concordant - discordant) as f64 / denominator)
}
fn direction(difference: f64) -> i64 {
if difference > 0.0 {
return 1;
}
if difference < 0.0 {
return -1;
}
0
}
fn pair_count(len: usize) -> f64 {
let len = len as f64;
len * (len - 1.0) / 2.0
}
fn tied_pairs(values: &[f64]) -> f64 {
let mut sorted = values.to_vec();
sorted.sort_by(|left, right| left.total_cmp(right));
let mut tied = 0.0;
let mut start = 0;
while start < sorted.len() {
let mut end = start;
while end + 1 < sorted.len() && sorted[end + 1] == sorted[start] {
end += 1;
}
tied += pair_count(end - start + 1);
start = end + 1;
}
tied
}

View File

@@ -0,0 +1,33 @@
use crate::entry::Mood;
const PRESENT: f64 = 1.0;
pub fn mean_difference(values: &[f64], moods: &[f64]) -> Option<f64> {
let present: Vec<f64> = pick(values, moods, |value| value == PRESENT);
let absent: Vec<f64> = pick(values, moods, |value| value != PRESENT);
let difference = mean(&present)? - mean(&absent)?;
Some(difference / mood_span())
}
fn pick(values: &[f64], moods: &[f64], wanted: impl Fn(f64) -> bool) -> Vec<f64> {
values
.iter()
.zip(moods)
.filter(|(value, _)| wanted(**value))
.map(|(_, mood)| *mood)
.collect()
}
fn mean(moods: &[f64]) -> Option<f64> {
if moods.is_empty() {
return None;
}
Some(moods.iter().sum::<f64>() / moods.len() as f64)
}
fn mood_span() -> f64 {
f64::from(Mood::Rad.value() - Mood::Awful.value())
}

View File

@@ -0,0 +1,25 @@
mod adjustment;
mod agreement;
mod coefficient;
mod correlation_input;
mod correlation_strategy;
mod family;
mod kendall;
mod mean_difference;
mod normal;
mod observation;
mod p_value;
mod pearson;
mod ranks;
mod series_shape;
mod welch;
pub use adjustment::{Adjustment, Tested};
pub use agreement::Agreement;
pub use coefficient::Coefficient;
pub use correlation_input::CorrelationInput;
pub use correlation_strategy::CorrelationStrategy;
pub use family::Family;
pub use observation::Observation;
pub use p_value::PValue;
pub use series_shape::SeriesShape;

View File

@@ -0,0 +1,23 @@
const ERF_A1: f64 = 0.254_829_592;
const ERF_A2: f64 = -0.284_496_736;
const ERF_A3: f64 = 1.421_413_741;
const ERF_A4: f64 = -1.453_152_027;
const ERF_A5: f64 = 1.061_405_429;
const ERF_P: f64 = 0.327_591_1;
pub fn two_sided_tail(standard_score: f64) -> f64 {
let beyond = 1.0 - error_function(standard_score.abs() / std::f64::consts::SQRT_2);
beyond.clamp(0.0, 1.0)
}
fn error_function(x: f64) -> f64 {
if x < 0.0 {
return -error_function(-x);
}
let t = 1.0 / (1.0 + ERF_P * x);
let series = t * (ERF_A1 + t * (ERF_A2 + t * (ERF_A3 + t * (ERF_A4 + t * ERF_A5))));
1.0 - series * (-x * x).exp()
}

View File

@@ -0,0 +1,21 @@
use crate::entry::DayMood;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Observation {
value: f64,
day_mood: DayMood,
}
impl Observation {
pub fn new(value: f64, day_mood: DayMood) -> Self {
Self { value, day_mood }
}
pub fn value(&self) -> f64 {
self.value
}
pub fn day_mood(&self) -> DayMood {
self.day_mood
}
}

View File

@@ -0,0 +1,29 @@
use crate::errors::DomainError;
use super::normal::two_sided_tail;
const CERTAIN: f64 = 1.0;
const IMPOSSIBLE: f64 = 0.0;
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct PValue(f64);
impl PValue {
pub fn new(value: f64) -> Result<Self, DomainError> {
if !value.is_finite() || !(IMPOSSIBLE..=CERTAIN).contains(&value) {
return Err(DomainError::InvalidInput(format!(
"a p-value must be between {IMPOSSIBLE} and {CERTAIN}, got {value}"
)));
}
Ok(Self(value))
}
pub fn from_standard_score(standard_score: f64) -> Self {
Self(two_sided_tail(standard_score))
}
pub fn value(&self) -> f64 {
self.0
}
}

View File

@@ -0,0 +1,20 @@
pub fn pearson(left: &[f64], right: &[f64]) -> Option<f64> {
let count = left.len() as f64;
let left_mean = left.iter().sum::<f64>() / count;
let right_mean = right.iter().sum::<f64>() / count;
let covariance: f64 = left
.iter()
.zip(right)
.map(|(l, r)| (l - left_mean) * (r - right_mean))
.sum();
let left_variance: f64 = left.iter().map(|l| (l - left_mean).powi(2)).sum();
let right_variance: f64 = right.iter().map(|r| (r - right_mean).powi(2)).sum();
if left_variance == 0.0 || right_variance == 0.0 {
return None;
}
Some(covariance / (left_variance * right_variance).sqrt())
}

View File

@@ -0,0 +1,30 @@
pub fn average_ranks(values: &[f64]) -> Vec<f64> {
let mut order: Vec<usize> = (0..values.len()).collect();
order.sort_by(|left, right| values[*left].total_cmp(&values[*right]));
let mut ranks = vec![0.0; values.len()];
let mut start = 0;
while start < order.len() {
let mut end = start;
while end + 1 < order.len() && values[order[end + 1]] == values[order[start]] {
end += 1;
}
let shared = shared_rank(start, end);
for position in &order[start..=end] {
ranks[*position] = shared;
}
start = end + 1;
}
ranks
}
fn shared_rank(start: usize, end: usize) -> f64 {
let first = start as f64 + 1.0;
let last = end as f64 + 1.0;
(first + last) / 2.0
}

View File

@@ -0,0 +1,5 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum SeriesShape {
Continuous,
Presence,
}

View File

@@ -0,0 +1,51 @@
const PRESENT: f64 = 1.0;
const FEWEST_FOR_A_SPREAD: usize = 2;
pub fn welch_standard_score(values: &[f64], moods: &[f64]) -> Option<f64> {
let present = group(values, moods, |value| value == PRESENT);
let absent = group(values, moods, |value| value != PRESENT);
if present.len() < FEWEST_FOR_A_SPREAD || absent.len() < FEWEST_FOR_A_SPREAD {
return None;
}
let spread = (variance(&present)? / present.len() as f64
+ variance(&absent)? / absent.len() as f64)
.sqrt();
if spread == 0.0 {
return None;
}
Some((mean(&present)? - mean(&absent)?) / spread)
}
fn group(values: &[f64], moods: &[f64], wanted: impl Fn(f64) -> bool) -> Vec<f64> {
values
.iter()
.zip(moods)
.filter(|(value, _)| wanted(**value))
.map(|(_, mood)| *mood)
.collect()
}
fn mean(moods: &[f64]) -> Option<f64> {
if moods.is_empty() {
return None;
}
Some(moods.iter().sum::<f64>() / moods.len() as f64)
}
fn variance(moods: &[f64]) -> Option<f64> {
let average = mean(moods)?;
let degrees_of_freedom = moods.len() as f64 - 1.0;
Some(
moods
.iter()
.map(|mood| (mood - average).powi(2))
.sum::<f64>()
/ degrees_of_freedom,
)
}

View File

@@ -0,0 +1,63 @@
use crate::entry::Date;
use super::{CycleDay, CyclePosition};
const ASSUMED_CYCLE_LENGTH: i64 = 28;
pub struct CycleCalendar {
starts: Vec<Date>,
}
impl CycleCalendar {
pub fn new(starts: Vec<Date>) -> Self {
let mut starts = starts;
starts.sort();
starts.dedup();
Self { starts }
}
}
impl CycleCalendar {
pub fn starts(&self) -> &[Date] {
&self.starts
}
pub fn position_on(&self, date: &Date) -> Option<CyclePosition> {
let start = self.start_of_the_cycle_containing(date)?;
let elapsed = date.days_since(start);
let day = CycleDay::new(u16::try_from(elapsed + 1).ok()?).ok()?;
let length = self.length_of_the_cycle_from(start) as f64;
Some(CyclePosition::new(day, elapsed as f64 / length))
}
pub fn usual_length(&self) -> i64 {
let mut observed: Vec<i64> = self
.starts
.windows(2)
.map(|pair| pair[1].days_since(&pair[0]))
.collect();
if observed.is_empty() {
return ASSUMED_CYCLE_LENGTH;
}
observed.sort_unstable();
observed[observed.len() / 2]
}
fn start_of_the_cycle_containing(&self, date: &Date) -> Option<&Date> {
self.starts.iter().rev().find(|start| *start <= date)
}
fn length_of_the_cycle_from(&self, start: &Date) -> i64 {
self.starts
.iter()
.find(|next| *next > start)
.map(|next| next.days_since(start))
.unwrap_or_else(|| self.usual_length())
}
}

View File

@@ -0,0 +1,23 @@
use crate::errors::DomainError;
const FIRST_DAY: u16 = 1;
const LONGEST_PLAUSIBLE_CYCLE: u16 = 90;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct CycleDay(u16);
impl CycleDay {
pub fn new(day: u16) -> Result<Self, DomainError> {
if !(FIRST_DAY..=LONGEST_PLAUSIBLE_CYCLE).contains(&day) {
return Err(DomainError::InvalidInput(format!(
"a cycle day must be between {FIRST_DAY} and {LONGEST_PLAUSIBLE_CYCLE}, got {day}"
)));
}
Ok(Self(day))
}
pub fn value(&self) -> u16 {
self.0
}
}

View File

@@ -0,0 +1,24 @@
use super::CycleDay;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CyclePosition {
day: CycleDay,
progress: f64,
}
impl CyclePosition {
pub fn new(day: CycleDay, progress: f64) -> Self {
Self {
day,
progress: progress.clamp(0.0, 1.0),
}
}
pub fn day(&self) -> CycleDay {
self.day
}
pub fn progress(&self) -> f64 {
self.progress
}
}

View File

@@ -0,0 +1,14 @@
use crate::entry::Date;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CycleStartRestore(Date);
impl CycleStartRestore {
pub fn on(date: Date) -> Self {
Self(date)
}
pub fn date(&self) -> Date {
self.0
}
}

View File

@@ -0,0 +1,9 @@
mod cycle_calendar;
mod cycle_day;
mod cycle_position;
mod cycle_start_restore;
pub use cycle_calendar::CycleCalendar;
pub use cycle_day::CycleDay;
pub use cycle_position::CyclePosition;
pub use cycle_start_restore::CycleStartRestore;

View File

@@ -0,0 +1,44 @@
use crate::activity::ActivityId;
use crate::attachment::{PhotoId, VoiceMemoId};
use crate::entry::{Content, MoodEntry};
use crate::location::Coordinates;
use crate::song::Song;
use crate::weather::Weather;
use super::DimensionValue;
use super::lookup;
pub struct ComposedEntry {
pub entry: MoodEntry,
pub dimensions: Vec<DimensionValue>,
}
impl ComposedEntry {
pub fn content(&self) -> Option<&Content> {
lookup::content_in(&self.dimensions)
}
pub fn activities(&self) -> &[ActivityId] {
lookup::activities_in(&self.dimensions)
}
pub fn photos(&self) -> &[PhotoId] {
lookup::photos_in(&self.dimensions)
}
pub fn voice_memos(&self) -> &[VoiceMemoId] {
lookup::voice_memos_in(&self.dimensions)
}
pub fn location(&self) -> Option<&Coordinates> {
lookup::location_in(&self.dimensions)
}
pub fn song(&self) -> Option<&Song> {
lookup::song_in(&self.dimensions)
}
pub fn weather(&self) -> Option<&Weather> {
lookup::weather_in(&self.dimensions)
}
}

View File

@@ -0,0 +1,10 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum DimensionKind {
Content,
Activities,
Photos,
VoiceMemos,
Location,
Song,
Weather,
}

View File

@@ -0,0 +1,39 @@
use crate::activity::ActivityId;
use crate::attachment::{PhotoId, VoiceMemoId};
use crate::entry::Content;
use crate::location::Coordinates;
use crate::song::Song;
use crate::weather::Weather;
use super::DimensionKind;
#[derive(Debug, Clone, PartialEq)]
pub enum DimensionValue {
Content(Content),
Activities(Vec<ActivityId>),
Photos(Vec<PhotoId>),
VoiceMemos(Vec<VoiceMemoId>),
Location(Coordinates),
Song(Song),
Weather(Weather),
}
impl DimensionValue {
pub fn activities(mut ids: Vec<ActivityId>) -> Self {
ids.sort();
ids.dedup();
Self::Activities(ids)
}
pub fn kind(&self) -> DimensionKind {
match self {
Self::Content(_) => DimensionKind::Content,
Self::Activities(_) => DimensionKind::Activities,
Self::Photos(_) => DimensionKind::Photos,
Self::VoiceMemos(_) => DimensionKind::VoiceMemos,
Self::Location(_) => DimensionKind::Location,
Self::Song(_) => DimensionKind::Song,
Self::Weather(_) => DimensionKind::Weather,
}
}
}

View File

@@ -0,0 +1,61 @@
use crate::activity::ActivityId;
use crate::attachment::{PhotoId, VoiceMemoId};
use crate::entry::Content;
use crate::location::Coordinates;
use crate::song::Song;
use crate::weather::Weather;
use super::{DimensionKind, DimensionValue};
pub fn content_in(values: &[DimensionValue]) -> Option<&Content> {
match find(values, DimensionKind::Content) {
Some(DimensionValue::Content(content)) => Some(content),
_ => None,
}
}
pub fn activities_in(values: &[DimensionValue]) -> &[ActivityId] {
match find(values, DimensionKind::Activities) {
Some(DimensionValue::Activities(ids)) => ids,
_ => &[],
}
}
pub fn photos_in(values: &[DimensionValue]) -> &[PhotoId] {
match find(values, DimensionKind::Photos) {
Some(DimensionValue::Photos(ids)) => ids,
_ => &[],
}
}
pub fn voice_memos_in(values: &[DimensionValue]) -> &[VoiceMemoId] {
match find(values, DimensionKind::VoiceMemos) {
Some(DimensionValue::VoiceMemos(ids)) => ids,
_ => &[],
}
}
pub fn location_in(values: &[DimensionValue]) -> Option<&Coordinates> {
match find(values, DimensionKind::Location) {
Some(DimensionValue::Location(coordinates)) => Some(coordinates),
_ => None,
}
}
pub fn song_in(values: &[DimensionValue]) -> Option<&Song> {
match find(values, DimensionKind::Song) {
Some(DimensionValue::Song(song)) => Some(song),
_ => None,
}
}
pub fn weather_in(values: &[DimensionValue]) -> Option<&Weather> {
match find(values, DimensionKind::Weather) {
Some(DimensionValue::Weather(weather)) => Some(weather),
_ => None,
}
}
fn find(values: &[DimensionValue], kind: DimensionKind) -> Option<&DimensionValue> {
values.iter().find(|value| value.kind() == kind)
}

View File

@@ -0,0 +1,8 @@
mod composed_entry;
mod dimension_kind;
mod dimension_value;
pub mod lookup;
pub use composed_entry::ComposedEntry;
pub use dimension_kind::DimensionKind;
pub use dimension_value::DimensionValue;

View File

@@ -0,0 +1,40 @@
use chrono::{DateTime, FixedOffset, NaiveDate};
use crate::user::Timezone;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Date(NaiveDate);
impl Date {
pub fn from_instant(instant: &DateTime<FixedOffset>, timezone: &Timezone) -> Self {
Self(instant.with_timezone(&timezone.resolve()).date_naive())
}
pub fn from_persistence(date: NaiveDate) -> Self {
Self(date)
}
}
impl Date {
pub fn value(&self) -> NaiveDate {
self.0
}
pub fn previous(&self) -> Self {
Self(self.0.pred_opt().unwrap_or(self.0))
}
pub fn next(&self) -> Self {
Self(self.0.succ_opt().unwrap_or(self.0))
}
pub fn days_since(&self, other: &Date) -> i64 {
(self.0 - other.0).num_days()
}
}
impl std::fmt::Display for Date {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}

View File

@@ -0,0 +1,35 @@
use crate::errors::DomainError;
use super::Date;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DateSpan {
start: Date,
end: Date,
}
impl DateSpan {
pub fn new(start: Date, end: Date) -> Result<Self, DomainError> {
if start > end {
return Err(DomainError::InvalidInput(
"date span start must be on or before its end".into(),
));
}
Ok(Self { start, end })
}
}
impl DateSpan {
pub fn start(&self) -> &Date {
&self.start
}
pub fn end(&self) -> &Date {
&self.end
}
pub fn contains(&self, date: &Date) -> bool {
self.start <= *date && *date <= self.end
}
}

View File

@@ -0,0 +1,26 @@
use super::Mood;
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct DayMood(f64);
impl DayMood {
pub fn of(moods: &[Mood]) -> Option<Self> {
if moods.is_empty() {
return None;
}
let sum: u32 = moods.iter().map(|mood| u32::from(mood.value())).sum();
Some(Self(f64::from(sum) / moods.len() as f64))
}
}
impl DayMood {
pub fn value(&self) -> f64 {
self.0
}
pub fn rounded(&self) -> Mood {
Mood::try_from(self.0.round() as u8).unwrap_or(Mood::Meh)
}
}

View File

@@ -1,11 +1,17 @@
mod content;
mod date;
mod date_range;
mod date_span;
mod day_mood;
mod mood;
mod mood_entry;
mod mood_entry_id;
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 mood::Mood;
pub use mood_entry::{MoodEntry, MoodEntryData};
pub use mood_entry_id::MoodEntryId;

View File

@@ -1,20 +1,14 @@
use chrono::{DateTime, FixedOffset, Utc};
use crate::activity::ActivityId;
use crate::attachment::{PhotoId, VoiceMemoId};
use crate::user::UserId;
use super::{Content, Mood, MoodEntryId};
use super::{Mood, MoodEntryId};
pub struct MoodEntryData {
pub id: MoodEntryId,
pub user_id: UserId,
pub mood: Mood,
pub logged_at: DateTime<FixedOffset>,
pub activities: Vec<ActivityId>,
pub content: Option<Content>,
pub photos: Vec<PhotoId>,
pub voice_memos: Vec<VoiceMemoId>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
@@ -25,10 +19,6 @@ pub struct MoodEntry {
user_id: UserId,
mood: Mood,
logged_at: DateTime<FixedOffset>,
activities: Vec<ActivityId>,
content: Option<Content>,
photos: Vec<PhotoId>,
voice_memos: Vec<VoiceMemoId>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
@@ -41,10 +31,6 @@ impl MoodEntry {
user_id,
mood,
logged_at,
activities: Vec::new(),
content: None,
photos: Vec::new(),
voice_memos: Vec::new(),
created_at: now,
updated_at: now,
}
@@ -56,10 +42,6 @@ impl MoodEntry {
user_id: data.user_id,
mood: data.mood,
logged_at: data.logged_at,
activities: data.activities,
content: data.content,
photos: data.photos,
voice_memos: data.voice_memos,
created_at: data.created_at,
updated_at: data.updated_at,
}
@@ -83,22 +65,6 @@ impl MoodEntry {
&self.logged_at
}
pub fn activities(&self) -> &[ActivityId] {
&self.activities
}
pub fn content(&self) -> Option<&Content> {
self.content.as_ref()
}
pub fn photos(&self) -> &[PhotoId] {
&self.photos
}
pub fn voice_memos(&self) -> &[VoiceMemoId] {
&self.voice_memos
}
pub fn created_at(&self) -> &DateTime<Utc> {
&self.created_at
}
@@ -119,28 +85,6 @@ impl MoodEntry {
self.touch();
}
pub fn set_content(&mut self, content: Option<Content>) {
self.content = content;
self.touch();
}
pub fn set_activities(&mut self, mut activities: Vec<ActivityId>) {
activities.sort();
activities.dedup();
self.activities = activities;
self.touch();
}
pub fn set_photos(&mut self, photos: Vec<PhotoId>) {
self.photos = photos;
self.touch();
}
pub fn set_voice_memos(&mut self, voice_memos: Vec<VoiceMemoId>) {
self.voice_memos = voice_memos;
self.touch();
}
fn touch(&mut self) {
self.updated_at = Utc::now();
}

View File

@@ -0,0 +1,101 @@
use chrono::{DateTime, Utc};
use super::{JobId, JobKind, JobStatus, JobSubject};
pub struct JobData {
pub id: JobId,
pub kind: JobKind,
pub subject: JobSubject,
pub status: JobStatus,
pub attempts: u32,
pub last_error: Option<String>,
pub enqueued_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct Job {
id: JobId,
kind: JobKind,
subject: JobSubject,
status: JobStatus,
attempts: u32,
last_error: Option<String>,
enqueued_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
impl Job {
pub fn pending(kind: JobKind, subject: JobSubject) -> Self {
let now = Utc::now();
Self {
id: JobId::generate(),
kind,
subject,
status: JobStatus::Pending,
attempts: 0,
last_error: None,
enqueued_at: now,
updated_at: now,
}
}
pub fn from_persistence(data: JobData) -> Self {
Self {
id: data.id,
kind: data.kind,
subject: data.subject,
status: data.status,
attempts: data.attempts,
last_error: data.last_error,
enqueued_at: data.enqueued_at,
updated_at: data.updated_at,
}
}
}
impl Job {
pub fn id(&self) -> &JobId {
&self.id
}
pub fn kind(&self) -> JobKind {
self.kind
}
pub fn subject(&self) -> &JobSubject {
&self.subject
}
pub fn status(&self) -> JobStatus {
self.status
}
pub fn attempts(&self) -> u32 {
self.attempts
}
pub fn last_error(&self) -> Option<&str> {
self.last_error.as_deref()
}
pub fn enqueued_at(&self) -> &DateTime<Utc> {
&self.enqueued_at
}
pub fn updated_at(&self) -> &DateTime<Utc> {
&self.updated_at
}
pub fn is_worth_another_attempt(&self, most_attempts: u32) -> bool {
self.attempts < most_attempts
}
}
impl Job {
pub fn attempted(&mut self) {
self.attempts += 1;
self.updated_at = Utc::now();
}
}

View File

@@ -0,0 +1,3 @@
use crate::macros::uuid_id;
uuid_id!(JobId);

View File

@@ -0,0 +1,27 @@
const BACKFILL_RECORDING_IDENTITY: &str = "backfillRecordingIdentity";
const OBSERVE_WEATHER: &str = "observeWeather";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum JobKind {
BackfillRecordingIdentity,
ObserveWeather,
}
impl JobKind {
pub const ALL: [JobKind; 2] = [Self::BackfillRecordingIdentity, Self::ObserveWeather];
pub fn name(&self) -> &'static str {
match self {
Self::BackfillRecordingIdentity => BACKFILL_RECORDING_IDENTITY,
Self::ObserveWeather => OBSERVE_WEATHER,
}
}
pub fn from_name(name: &str) -> Option<Self> {
match name {
BACKFILL_RECORDING_IDENTITY => Some(Self::BackfillRecordingIdentity),
OBSERVE_WEATHER => Some(Self::ObserveWeather),
_ => None,
}
}
}

View File

@@ -0,0 +1,29 @@
const PENDING: &str = "pending";
const RUNNING: &str = "running";
const EXHAUSTED: &str = "exhausted";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum JobStatus {
Pending,
Running,
Exhausted,
}
impl JobStatus {
pub fn name(&self) -> &'static str {
match self {
Self::Pending => PENDING,
Self::Running => RUNNING,
Self::Exhausted => EXHAUSTED,
}
}
pub fn from_name(name: &str) -> Option<Self> {
match name {
PENDING => Some(Self::Pending),
RUNNING => Some(Self::Running),
EXHAUSTED => Some(Self::Exhausted),
_ => None,
}
}
}

View File

@@ -0,0 +1,21 @@
use crate::entry::MoodEntryId;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum JobSubject {
Entry(MoodEntryId),
}
impl JobSubject {
pub fn key(&self) -> String {
match self {
Self::Entry(id) => id.value().to_string(),
}
}
pub fn from_key(key: &str) -> Option<Self> {
key.parse()
.ok()
.map(MoodEntryId::from_uuid)
.map(Self::Entry)
}
}

View File

@@ -0,0 +1,11 @@
mod job;
mod job_id;
mod job_kind;
mod job_status;
mod job_subject;
pub use job::{Job, JobData};
pub use job_id::JobId;
pub use job_kind::JobKind;
pub use job_status::JobStatus;
pub use job_subject::JobSubject;

View File

@@ -1,16 +1,28 @@
#![allow(clippy::module_inception)]
pub mod activity;
pub mod api_token;
pub mod attachment;
pub mod auth;
pub mod correlation;
pub mod cycle;
pub mod dimension;
pub mod entry;
pub mod errors;
pub mod events;
pub mod job;
pub mod location;
pub mod metric;
pub mod moon;
pub mod ports;
pub mod provider;
pub mod push;
pub mod rejection;
pub mod reminder;
pub mod services;
pub mod song;
pub mod user;
pub mod weather;
mod macros;

View File

@@ -0,0 +1,33 @@
use crate::errors::DomainError;
use super::{Latitude, Longitude};
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Coordinates {
latitude: Latitude,
longitude: Longitude,
}
impl Coordinates {
pub fn new(latitude: f64, longitude: f64) -> Result<Self, DomainError> {
Ok(Self {
latitude: Latitude::new(latitude)?,
longitude: Longitude::new(longitude)?,
})
}
pub fn from_persistence(latitude: f64, longitude: f64) -> Self {
Self {
latitude: Latitude::from_persistence(latitude),
longitude: Longitude::from_persistence(longitude),
}
}
pub fn latitude(&self) -> Latitude {
self.latitude
}
pub fn longitude(&self) -> Longitude {
self.longitude
}
}

View File

@@ -0,0 +1,31 @@
use crate::errors::DomainError;
const MIN_LATITUDE: f64 = -90.0;
const MAX_LATITUDE: f64 = 90.0;
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Latitude(f64);
impl Latitude {
pub fn new(degrees: f64) -> Result<Self, DomainError> {
if !degrees.is_finite() {
return Err(DomainError::InvalidInput(
"latitude must be a finite number".into(),
));
}
if !(MIN_LATITUDE..=MAX_LATITUDE).contains(&degrees) {
return Err(DomainError::InvalidInput(format!(
"latitude must be between {MIN_LATITUDE} and {MAX_LATITUDE}, got {degrees}"
)));
}
Ok(Self(degrees))
}
pub fn from_persistence(degrees: f64) -> Self {
Self(degrees)
}
pub fn value(&self) -> f64 {
self.0
}
}

View File

@@ -0,0 +1,31 @@
use crate::errors::DomainError;
const MIN_LONGITUDE: f64 = -180.0;
const MAX_LONGITUDE: f64 = 180.0;
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Longitude(f64);
impl Longitude {
pub fn new(degrees: f64) -> Result<Self, DomainError> {
if !degrees.is_finite() {
return Err(DomainError::InvalidInput(
"longitude must be a finite number".into(),
));
}
if !(MIN_LONGITUDE..=MAX_LONGITUDE).contains(&degrees) {
return Err(DomainError::InvalidInput(format!(
"longitude must be between {MIN_LONGITUDE} and {MAX_LONGITUDE}, got {degrees}"
)));
}
Ok(Self(degrees))
}
pub fn from_persistence(degrees: f64) -> Self {
Self(degrees)
}
pub fn value(&self) -> f64 {
self.0
}
}

View File

@@ -0,0 +1,7 @@
mod coordinates;
mod latitude;
mod longitude;
pub use coordinates::Coordinates;
pub use latitude::Latitude;
pub use longitude::Longitude;

View File

@@ -41,3 +41,33 @@ macro_rules! uuid_id {
}
pub(crate) use uuid_id;
macro_rules! bounded_metric {
($name:ident, $inner:ty, $min:expr, $max:expr, $label:literal) => {
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct $name($inner);
impl $name {
pub fn new(value: $inner) -> Result<Self, crate::errors::DomainError> {
if !($min..=$max).contains(&value) {
return Err(crate::errors::DomainError::InvalidInput(format!(
concat!($label, " must be between {} and {}, got {}"),
$min, $max, value
)));
}
Ok(Self(value))
}
pub fn from_persistence(value: $inner) -> Self {
Self(value)
}
pub fn value(&self) -> $inner {
self.0
}
}
};
}
pub(crate) use bounded_metric;

View File

@@ -0,0 +1,12 @@
use crate::macros::bounded_metric;
const MIN_ALCOHOLIC_DRINKS: u16 = 0;
const MAX_ALCOHOLIC_DRINKS: u16 = 60;
bounded_metric!(
AlcoholicDrinks,
u16,
MIN_ALCOHOLIC_DRINKS,
MAX_ALCOHOLIC_DRINKS,
"alcoholic drinks"
);

View File

@@ -0,0 +1,12 @@
use crate::macros::bounded_metric;
const MIN_AWAKE_MINUTES: u16 = 0;
const MAX_AWAKE_MINUTES: u16 = 1_440;
bounded_metric!(
AwakeMinutes,
u16,
MIN_AWAKE_MINUTES,
MAX_AWAKE_MINUTES,
"awake minutes"
);

View File

@@ -0,0 +1,49 @@
use crate::entry::Date;
use crate::user::UserId;
use super::{MetricKind, MetricValue, Source};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DailyMetric {
user_id: UserId,
date: Date,
value: MetricValue,
source: Source,
}
impl DailyMetric {
pub fn new(user_id: UserId, date: Date, value: MetricValue, source: Source) -> Self {
Self {
user_id,
date,
value,
source,
}
}
}
impl DailyMetric {
pub fn user_id(&self) -> &UserId {
&self.user_id
}
pub fn date(&self) -> &Date {
&self.date
}
pub fn value(&self) -> &MetricValue {
&self.value
}
pub fn source(&self) -> &Source {
&self.source
}
pub fn kind(&self) -> MetricKind {
self.value.kind()
}
pub fn supersedes(&self, stored: &DailyMetric) -> bool {
stored.source.is_superseded_by(&self.source)
}
}

View File

@@ -0,0 +1,12 @@
use crate::macros::bounded_metric;
const MIN_EXERCISE_MINUTES: u16 = 0;
const MAX_EXERCISE_MINUTES: u16 = 1_440;
bounded_metric!(
ExerciseMinutes,
u16,
MIN_EXERCISE_MINUTES,
MAX_EXERCISE_MINUTES,
"exercise minutes"
);

View File

@@ -0,0 +1,6 @@
use crate::macros::bounded_metric;
const MIN_HRV: u16 = 1;
const MAX_HRV: u16 = 300;
bounded_metric!(Hrv, u16, MIN_HRV, MAX_HRV, "heart rate variability");

View File

@@ -0,0 +1,60 @@
const STEPS: &str = "steps";
const SLEEP_MINUTES: &str = "sleepMinutes";
const AWAKE_MINUTES: &str = "awakeMinutes";
const RESTING_HEART_RATE: &str = "restingHeartRate";
const HRV: &str = "hrv";
const EXERCISE_MINUTES: &str = "exerciseMinutes";
const SCREEN_TIME_MINUTES: &str = "screenTimeMinutes";
const ALCOHOLIC_DRINKS: &str = "alcoholicDrinks";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum MetricKind {
Steps,
SleepMinutes,
AwakeMinutes,
RestingHeartRate,
Hrv,
ExerciseMinutes,
ScreenTimeMinutes,
AlcoholicDrinks,
}
impl MetricKind {
pub const ALL: [MetricKind; 8] = [
Self::Steps,
Self::SleepMinutes,
Self::AwakeMinutes,
Self::RestingHeartRate,
Self::Hrv,
Self::ExerciseMinutes,
Self::ScreenTimeMinutes,
Self::AlcoholicDrinks,
];
pub fn name(&self) -> &'static str {
match self {
Self::Steps => STEPS,
Self::SleepMinutes => SLEEP_MINUTES,
Self::AwakeMinutes => AWAKE_MINUTES,
Self::RestingHeartRate => RESTING_HEART_RATE,
Self::Hrv => HRV,
Self::ExerciseMinutes => EXERCISE_MINUTES,
Self::ScreenTimeMinutes => SCREEN_TIME_MINUTES,
Self::AlcoholicDrinks => ALCOHOLIC_DRINKS,
}
}
pub fn from_name(name: &str) -> Option<Self> {
match name {
STEPS => Some(Self::Steps),
SLEEP_MINUTES => Some(Self::SleepMinutes),
AWAKE_MINUTES => Some(Self::AwakeMinutes),
RESTING_HEART_RATE => Some(Self::RestingHeartRate),
HRV => Some(Self::Hrv),
EXERCISE_MINUTES => Some(Self::ExerciseMinutes),
SCREEN_TIME_MINUTES => Some(Self::ScreenTimeMinutes),
ALCOHOLIC_DRINKS => Some(Self::AlcoholicDrinks),
_ => None,
}
}
}

View File

@@ -0,0 +1,79 @@
use crate::errors::DomainError;
use super::{
AlcoholicDrinks, AwakeMinutes, ExerciseMinutes, Hrv, MetricKind, RestingHeartRate,
ScreenTimeMinutes, SleepMinutes, Steps,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MetricValue {
Steps(Steps),
SleepMinutes(SleepMinutes),
AwakeMinutes(AwakeMinutes),
RestingHeartRate(RestingHeartRate),
Hrv(Hrv),
ExerciseMinutes(ExerciseMinutes),
ScreenTimeMinutes(ScreenTimeMinutes),
AlcoholicDrinks(AlcoholicDrinks),
}
impl MetricValue {
pub fn of_kind(kind: MetricKind, count: i64) -> Result<Self, DomainError> {
match kind {
MetricKind::Steps => Ok(Self::Steps(Steps::new(narrow(count, kind)?)?)),
MetricKind::SleepMinutes => {
Ok(Self::SleepMinutes(SleepMinutes::new(narrow(count, kind)?)?))
}
MetricKind::AwakeMinutes => {
Ok(Self::AwakeMinutes(AwakeMinutes::new(narrow(count, kind)?)?))
}
MetricKind::RestingHeartRate => Ok(Self::RestingHeartRate(RestingHeartRate::new(
narrow(count, kind)?,
)?)),
MetricKind::Hrv => Ok(Self::Hrv(Hrv::new(narrow(count, kind)?)?)),
MetricKind::ExerciseMinutes => Ok(Self::ExerciseMinutes(ExerciseMinutes::new(
narrow(count, kind)?,
)?)),
MetricKind::ScreenTimeMinutes => Ok(Self::ScreenTimeMinutes(ScreenTimeMinutes::new(
narrow(count, kind)?,
)?)),
MetricKind::AlcoholicDrinks => Ok(Self::AlcoholicDrinks(AlcoholicDrinks::new(
narrow(count, kind)?,
)?)),
}
}
}
impl MetricValue {
pub fn kind(&self) -> MetricKind {
match self {
Self::Steps(_) => MetricKind::Steps,
Self::SleepMinutes(_) => MetricKind::SleepMinutes,
Self::AwakeMinutes(_) => MetricKind::AwakeMinutes,
Self::RestingHeartRate(_) => MetricKind::RestingHeartRate,
Self::Hrv(_) => MetricKind::Hrv,
Self::ExerciseMinutes(_) => MetricKind::ExerciseMinutes,
Self::ScreenTimeMinutes(_) => MetricKind::ScreenTimeMinutes,
Self::AlcoholicDrinks(_) => MetricKind::AlcoholicDrinks,
}
}
pub fn count(&self) -> i64 {
match self {
Self::Steps(steps) => i64::from(steps.value()),
Self::SleepMinutes(minutes) => i64::from(minutes.value()),
Self::AwakeMinutes(minutes) => i64::from(minutes.value()),
Self::RestingHeartRate(beats) => i64::from(beats.value()),
Self::Hrv(milliseconds) => i64::from(milliseconds.value()),
Self::ExerciseMinutes(minutes) => i64::from(minutes.value()),
Self::ScreenTimeMinutes(minutes) => i64::from(minutes.value()),
Self::AlcoholicDrinks(drinks) => i64::from(drinks.value()),
}
}
}
fn narrow<T: TryFrom<i64>>(count: i64, kind: MetricKind) -> Result<T, DomainError> {
T::try_from(count).map_err(|_| {
DomainError::InvalidInput(format!("{} cannot hold the value {count}", kind.name()))
})
}

View File

@@ -0,0 +1,25 @@
mod alcoholic_drinks;
mod awake_minutes;
mod daily_metric;
mod exercise_minutes;
mod hrv;
mod metric_kind;
mod metric_value;
mod resting_heart_rate;
mod screen_time_minutes;
mod sleep_minutes;
mod source;
mod steps;
pub use alcoholic_drinks::AlcoholicDrinks;
pub use awake_minutes::AwakeMinutes;
pub use daily_metric::DailyMetric;
pub use exercise_minutes::ExerciseMinutes;
pub use hrv::Hrv;
pub use metric_kind::MetricKind;
pub use metric_value::MetricValue;
pub use resting_heart_rate::RestingHeartRate;
pub use screen_time_minutes::ScreenTimeMinutes;
pub use sleep_minutes::SleepMinutes;
pub use source::Source;
pub use steps::Steps;

View File

@@ -0,0 +1,12 @@
use crate::macros::bounded_metric;
const MIN_RESTING_HEART_RATE: u16 = 25;
const MAX_RESTING_HEART_RATE: u16 = 120;
bounded_metric!(
RestingHeartRate,
u16,
MIN_RESTING_HEART_RATE,
MAX_RESTING_HEART_RATE,
"resting heart rate"
);

View File

@@ -0,0 +1,12 @@
use crate::macros::bounded_metric;
const MIN_SCREEN_TIME_MINUTES: u16 = 0;
const MAX_SCREEN_TIME_MINUTES: u16 = 1_440;
bounded_metric!(
ScreenTimeMinutes,
u16,
MIN_SCREEN_TIME_MINUTES,
MAX_SCREEN_TIME_MINUTES,
"screen time minutes"
);

View File

@@ -0,0 +1,12 @@
use crate::macros::bounded_metric;
const MIN_SLEEP_MINUTES: u16 = 0;
const MAX_SLEEP_MINUTES: u16 = 1_440;
bounded_metric!(
SleepMinutes,
u16,
MIN_SLEEP_MINUTES,
MAX_SLEEP_MINUTES,
"sleep minutes"
);

View File

@@ -0,0 +1,24 @@
use crate::provider::ProviderName;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Source {
Manual,
Provider(ProviderName),
}
impl Source {
pub fn is_manual(&self) -> bool {
matches!(self, Self::Manual)
}
pub fn is_superseded_by(&self, incoming: &Source) -> bool {
incoming.is_manual() || !self.is_manual()
}
pub fn provider(&self) -> Option<&ProviderName> {
match self {
Self::Manual => None,
Self::Provider(name) => Some(name),
}
}
}

View File

@@ -0,0 +1,12 @@
use crate::macros::bounded_metric;
const MIN_STEPS_PER_DAY: u32 = 0;
const MAX_STEPS_PER_DAY: u32 = 200_000;
bounded_metric!(
Steps,
u32,
MIN_STEPS_PER_DAY,
MAX_STEPS_PER_DAY,
"step count"
);

View File

@@ -0,0 +1,3 @@
mod moon_phase;
pub use moon_phase::MoonPhase;

View File

@@ -0,0 +1,46 @@
use chrono::NaiveDate;
use crate::entry::Date;
const SYNODIC_MONTH_IN_DAYS: f64 = 29.530_588_853;
const NAMED_PHASES: [&str; 8] = [
"New",
"Waxing Crescent",
"First Quarter",
"Waxing Gibbous",
"Full",
"Waning Gibbous",
"Last Quarter",
"Waning Crescent",
];
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct MoonPhase(f64);
impl MoonPhase {
pub fn on(date: &Date) -> Self {
let days = (date.value() - known_new_moon()).num_days() as f64;
let through_the_cycle = days.rem_euclid(SYNODIC_MONTH_IN_DAYS) / SYNODIC_MONTH_IN_DAYS;
Self(through_the_cycle)
}
}
impl MoonPhase {
pub fn illumination(&self) -> f64 {
let lit = (1.0 - (std::f64::consts::TAU * self.0).cos()) / 2.0;
lit.clamp(0.0, 1.0)
}
pub fn name(&self) -> &'static str {
let segments = NAMED_PHASES.len() as f64;
let segment = (self.0 * segments).round() as usize % NAMED_PHASES.len();
NAMED_PHASES[segment]
}
}
fn known_new_moon() -> NaiveDate {
NaiveDate::from_ymd_opt(2000, 1, 6).unwrap_or_default()
}

View File

@@ -0,0 +1,21 @@
use crate::api_token::{ApiToken, ApiTokenId, TokenDigest};
use crate::errors::DomainError;
use crate::user::UserId;
pub trait ApiTokenSecretPort: Send + Sync {
fn mint(&self) -> String;
fn digest(&self, secret: &str) -> TokenDigest;
}
#[async_trait::async_trait]
pub trait ApiTokenCommandPort: Send + Sync {
async fn save(&self, token: &ApiToken) -> Result<(), DomainError>;
async fn revoke(&self, user_id: &UserId, id: &ApiTokenId) -> Result<(), DomainError>;
async fn mark_used(&self, id: &ApiTokenId) -> Result<(), DomainError>;
}
#[async_trait::async_trait]
pub trait ApiTokenQueryPort: Send + Sync {
async fn find_by_digest(&self, digest: &TokenDigest) -> Result<Option<ApiToken>, DomainError>;
async fn find_by_user(&self, user_id: &UserId) -> Result<Vec<ApiToken>, DomainError>;
}

View File

@@ -0,0 +1,24 @@
use crate::entry::Date;
use crate::errors::DomainError;
use crate::user::{UserId, UserPreferences};
#[async_trait::async_trait]
pub trait CycleStartCommandPort: Send + Sync {
async fn record(&self, user_id: &UserId, date: &Date) -> Result<(), DomainError>;
async fn forget(&self, user_id: &UserId, date: &Date) -> Result<(), DomainError>;
}
#[async_trait::async_trait]
pub trait CycleStartQueryPort: Send + Sync {
async fn find_by_user(&self, user_id: &UserId) -> Result<Vec<Date>, DomainError>;
}
#[async_trait::async_trait]
pub trait UserPreferencesCommandPort: Send + Sync {
async fn save(&self, preferences: &UserPreferences) -> Result<(), DomainError>;
}
#[async_trait::async_trait]
pub trait UserPreferencesQueryPort: Send + Sync {
async fn find_by_user(&self, user_id: &UserId) -> Result<Option<UserPreferences>, DomainError>;
}

View File

@@ -0,0 +1,19 @@
use std::collections::HashMap;
use crate::dimension::DimensionValue;
use crate::entry::MoodEntryId;
use crate::errors::DomainError;
#[async_trait::async_trait]
pub trait EntryDimensionPort: Send + Sync {
async fn load(
&self,
entry_ids: &[MoodEntryId],
) -> Result<HashMap<MoodEntryId, DimensionValue>, DomainError>;
async fn save(
&self,
entry_id: &MoodEntryId,
values: &[DimensionValue],
) -> Result<(), DomainError>;
}

View File

@@ -1,24 +1,44 @@
use crate::activity::Activity;
use crate::entry::MoodEntry;
use crate::cycle::CycleStartRestore;
use crate::dimension::ComposedEntry;
use crate::errors::DomainError;
use crate::metric::DailyMetric;
use crate::reminder::Reminder;
use crate::user::UserPreferences;
pub struct MediaBlob {
pub id: String,
pub data: Vec<u8>,
}
pub struct UserExport {
pub entries: Vec<MoodEntry>,
pub activities: Vec<Activity>,
pub reminders: Vec<Reminder>,
pub struct BackupMedia {
pub photos: Vec<MediaBlob>,
pub voice_memos: Vec<MediaBlob>,
}
pub struct UserBackup {
pub entries: Vec<ComposedEntry>,
pub metrics: Vec<DailyMetric>,
pub cycle_starts: Vec<CycleStartRestore>,
pub activities: Vec<Activity>,
pub reminders: Vec<Reminder>,
pub preferences: UserPreferences,
pub media: BackupMedia,
}
pub struct SharedExtract {
pub entries: Vec<ComposedEntry>,
pub activities: Vec<Activity>,
}
#[async_trait::async_trait]
pub trait ExportPort: Send + Sync {
async fn export_user_data(&self, data: &UserExport) -> Result<Vec<u8>, DomainError>;
pub trait BackupWriterPort: Send + Sync {
async fn write(&self, backup: &UserBackup) -> Result<Vec<u8>, DomainError>;
}
#[async_trait::async_trait]
pub trait ExtractWriterPort: Send + Sync {
async fn write(&self, extract: &SharedExtract) -> Result<Vec<u8>, DomainError>;
}
#[async_trait::async_trait]
@@ -26,6 +46,7 @@ pub trait ImportSourcePort: Send + Sync {
async fn read_entries(&self, data: &[u8]) -> Result<Vec<ImportedRow>, DomainError>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImportedRow {
pub mood: u8,
pub date: String,

View File

@@ -0,0 +1,81 @@
use crate::errors::DomainError;
use crate::job::{Job, JobId, JobKind, JobSubject};
use crate::user::UserId;
#[async_trait::async_trait]
pub trait JobQueueCommandPort: Send + Sync {
async fn enqueue(&self, kind: JobKind, subject: &JobSubject) -> Result<bool, DomainError>;
async fn claim(&self, kind: JobKind, most: usize) -> Result<Vec<Job>, DomainError>;
async fn finish(&self, id: &JobId) -> Result<(), DomainError>;
async fn release(&self, id: &JobId, reason: &str) -> Result<(), DomainError>;
async fn exhaust(&self, id: &JobId, reason: &str) -> Result<(), DomainError>;
async fn reclaim_stalled(&self, stalled_after_seconds: i64) -> Result<u64, DomainError>;
}
#[async_trait::async_trait]
pub trait JobQueueQueryPort: Send + Sync {
async fn find_exhausted(&self, most: usize) -> Result<Vec<Job>, DomainError>;
}
#[async_trait::async_trait]
pub trait WeatherLookupPort: Send + Sync {
fn provider(&self) -> &crate::provider::ProviderName;
async fn observed_at(
&self,
coordinates: &crate::location::Coordinates,
instant: &chrono::DateTime<chrono::FixedOffset>,
) -> Result<Option<crate::weather::Weather>, DomainError>;
}
pub struct UnwatchedPlace {
pub entry_id: crate::entry::MoodEntryId,
pub coordinates: crate::location::Coordinates,
pub logged_at: chrono::DateTime<chrono::FixedOffset>,
}
impl UnwatchedPlace {
pub fn subject(&self) -> JobSubject {
JobSubject::Entry(self.entry_id.clone())
}
}
#[async_trait::async_trait]
pub trait WeatherBacklogQueryPort: Send + Sync {
async fn find_places_without_weather(
&self,
most: usize,
) -> Result<Vec<UnwatchedPlace>, DomainError>;
}
#[async_trait::async_trait]
pub trait RecordingBackfillQueryPort: Send + Sync {
async fn find_songs_without_a_recording(
&self,
most: usize,
) -> Result<Vec<UnidentifiedSong>, DomainError>;
async fn record_identity(
&self,
entry_id: &crate::entry::MoodEntryId,
recording_id: &crate::song::RecordingId,
) -> Result<(), DomainError>;
}
pub struct UnidentifiedSong {
pub entry_id: crate::entry::MoodEntryId,
pub user_id: UserId,
pub title: String,
pub artist: String,
}
impl UnidentifiedSong {
pub fn subject(&self) -> JobSubject {
JobSubject::Entry(self.entry_id.clone())
}
}

View File

@@ -0,0 +1,25 @@
use crate::entry::{Date, DateSpan};
use crate::errors::DomainError;
use crate::metric::{DailyMetric, MetricKind};
use crate::user::UserId;
#[async_trait::async_trait]
pub trait DailyMetricCommandPort: Send + Sync {
async fn save(&self, metrics: &[DailyMetric]) -> Result<usize, DomainError>;
async fn delete(
&self,
user_id: &UserId,
date: &Date,
kinds: &[MetricKind],
) -> Result<(), DomainError>;
}
#[async_trait::async_trait]
pub trait DailyMetricQueryPort: Send + Sync {
async fn find_by_span(
&self,
user_id: &UserId,
span: &DateSpan,
) -> Result<Vec<DailyMetric>, DomainError>;
}

View File

@@ -1,23 +1,53 @@
mod activity;
mod api_token;
mod auth;
mod cascade;
mod cycle;
mod dimension;
mod entry;
mod event;
mod import_export;
mod job;
mod media;
mod metric;
mod music;
mod provider;
mod push;
mod rejection;
mod reminder;
mod restore;
mod user;
pub use activity::{ActivityCommandPort, ActivityQueryPort};
pub use api_token::{ApiTokenCommandPort, ApiTokenQueryPort, ApiTokenSecretPort};
pub use auth::{
AuthServicePort, PasswordHasherPort, RefreshSessionCommandPort, RefreshSessionQueryPort,
};
pub use cascade::CascadeDeletePort;
pub use cycle::{
CycleStartCommandPort, CycleStartQueryPort, UserPreferencesCommandPort,
UserPreferencesQueryPort,
};
pub use dimension::EntryDimensionPort;
pub use entry::{MoodEntryCommandPort, MoodEntryQueryPort};
pub use event::EventPublisherPort;
pub use import_export::{ExportPort, ImportSourcePort, ImportedRow, MediaBlob, UserExport};
pub use import_export::{
BackupMedia, BackupWriterPort, ExtractWriterPort, ImportSourcePort, ImportedRow, MediaBlob,
SharedExtract, UserBackup,
};
pub use job::{
JobQueueCommandPort, JobQueueQueryPort, RecordingBackfillQueryPort, UnidentifiedSong,
UnwatchedPlace, WeatherBacklogQueryPort, WeatherLookupPort,
};
pub use media::{MediaFile, MediaStoragePort};
pub use metric::{DailyMetricCommandPort, DailyMetricQueryPort};
pub use music::{NowPlayingPort, RecordingLookupPort};
pub use provider::{ProviderConnectionCommandPort, ProviderConnectionQueryPort};
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,
};
pub use user::{UserCommandPort, UserQueryPort};

View File

@@ -0,0 +1,18 @@
use crate::errors::DomainError;
use crate::song::{RecordingId, Song};
#[async_trait::async_trait]
pub trait NowPlayingPort: Send + Sync {
fn provider(&self) -> &str;
async fn now_playing(&self, credential: &[u8]) -> Result<Option<Song>, DomainError>;
}
#[async_trait::async_trait]
pub trait RecordingLookupPort: Send + Sync {
async fn find_recording(
&self,
title: &str,
artist: &str,
) -> Result<Option<RecordingId>, DomainError>;
}

View File

@@ -0,0 +1,21 @@
use crate::errors::DomainError;
use crate::provider::{ProviderConnection, ProviderName};
use crate::user::UserId;
#[async_trait::async_trait]
pub trait ProviderConnectionCommandPort: Send + Sync {
async fn save(&self, connection: &ProviderConnection) -> Result<(), DomainError>;
async fn delete(&self, user_id: &UserId, provider: &ProviderName) -> Result<(), DomainError>;
}
#[async_trait::async_trait]
pub trait ProviderConnectionQueryPort: Send + Sync {
async fn find_by_user(&self, user_id: &UserId) -> Result<Vec<ProviderConnection>, DomainError>;
async fn find_by_user_and_provider(
&self,
user_id: &UserId,
provider: &ProviderName,
) -> Result<Option<ProviderConnection>, DomainError>;
}

View File

@@ -0,0 +1,16 @@
use crate::errors::DomainError;
use crate::rejection::RejectedMetric;
use crate::user::UserId;
#[async_trait::async_trait]
pub trait RejectionCommandPort: Send + Sync {
async fn record(&self, rejections: &[RejectedMetric]) -> Result<(), DomainError>;
}
#[async_trait::async_trait]
pub trait RejectionQueryPort: Send + Sync {
async fn find_recent_by_user(
&self,
user_id: &UserId,
) -> Result<Vec<RejectedMetric>, DomainError>;
}

View File

@@ -0,0 +1,42 @@
use crate::errors::DomainError;
pub struct RestorableEntry {
pub mood: u8,
pub logged_at: String,
pub dimensions: Vec<crate::dimension::DimensionValue>,
}
pub struct RestorableMetric {
pub date: String,
pub kind: String,
pub value: i64,
pub provider: Option<String>,
}
pub struct RestorableActivity {
pub id: String,
pub name: String,
pub category: Option<String>,
pub archived: bool,
}
pub struct RestorableReminder {
pub enabled: bool,
pub times: [Option<String>; 7],
}
pub struct RestorableContents {
pub entries: Vec<RestorableEntry>,
pub metrics: Vec<RestorableMetric>,
pub cycle_starts: Vec<String>,
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>)>,
}
#[async_trait::async_trait]
pub trait BackupReaderPort: Send + Sync {
async fn read(&self, data: &[u8]) -> Result<RestorableContents, DomainError>;
}

View File

@@ -0,0 +1,26 @@
use crate::errors::DomainError;
#[derive(Clone, PartialEq, Eq)]
pub struct EncryptedCredential(Vec<u8>);
impl EncryptedCredential {
pub fn from_persistence(ciphertext: Vec<u8>) -> Self {
Self(ciphertext)
}
pub fn value(&self) -> &[u8] {
&self.0
}
}
impl std::fmt::Debug for EncryptedCredential {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "EncryptedCredential(<redacted {} bytes>)", self.0.len())
}
}
#[async_trait::async_trait]
pub trait CredentialCipher: Send + Sync {
fn encrypt(&self, plaintext: &[u8]) -> Result<EncryptedCredential, DomainError>;
fn decrypt(&self, credential: &EncryptedCredential) -> Result<Vec<u8>, DomainError>;
}

View File

@@ -0,0 +1,9 @@
mod encrypted_credential;
mod provider_connection;
mod provider_connection_id;
mod provider_name;
pub use encrypted_credential::{CredentialCipher, EncryptedCredential};
pub use provider_connection::{ProviderConnection, ProviderConnectionData};
pub use provider_connection_id::ProviderConnectionId;
pub use provider_name::ProviderName;

View File

@@ -0,0 +1,82 @@
use chrono::{DateTime, Utc};
use crate::user::UserId;
use super::{EncryptedCredential, ProviderConnectionId, ProviderName};
pub struct ProviderConnectionData {
pub id: ProviderConnectionId,
pub user_id: UserId,
pub provider: ProviderName,
pub credential: EncryptedCredential,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct ProviderConnection {
id: ProviderConnectionId,
user_id: UserId,
provider: ProviderName,
credential: EncryptedCredential,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
impl ProviderConnection {
pub fn new(user_id: UserId, provider: ProviderName, credential: EncryptedCredential) -> Self {
let now = Utc::now();
Self {
id: ProviderConnectionId::generate(),
user_id,
provider,
credential,
created_at: now,
updated_at: now,
}
}
pub fn from_persistence(data: ProviderConnectionData) -> Self {
Self {
id: data.id,
user_id: data.user_id,
provider: data.provider,
credential: data.credential,
created_at: data.created_at,
updated_at: data.updated_at,
}
}
}
impl ProviderConnection {
pub fn id(&self) -> &ProviderConnectionId {
&self.id
}
pub fn user_id(&self) -> &UserId {
&self.user_id
}
pub fn provider(&self) -> &ProviderName {
&self.provider
}
pub fn credential(&self) -> &EncryptedCredential {
&self.credential
}
pub fn created_at(&self) -> &DateTime<Utc> {
&self.created_at
}
pub fn updated_at(&self) -> &DateTime<Utc> {
&self.updated_at
}
}
impl ProviderConnection {
pub fn replace_credential(&mut self, credential: EncryptedCredential) {
self.credential = credential;
self.updated_at = Utc::now();
}
}

View File

@@ -0,0 +1 @@
crate::macros::uuid_id!(ProviderConnectionId);

View File

@@ -0,0 +1,34 @@
use crate::errors::DomainError;
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct ProviderName(String);
impl ProviderName {
pub fn new(name: impl Into<String>) -> Result<Self, DomainError> {
let normalised = name.into().trim().to_lowercase();
if normalised.is_empty() {
return Err(DomainError::InvalidInput(
"provider name cannot be empty".into(),
));
}
if !normalised
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-')
{
return Err(DomainError::InvalidInput(
"provider name must contain only letters, digits and hyphens".into(),
));
}
Ok(Self(normalised))
}
pub fn from_persistence(name: String) -> Self {
Self(name)
}
pub fn value(&self) -> &str {
&self.0
}
}

View File

@@ -0,0 +1,9 @@
mod rejected_metric;
mod rejection_detail;
mod rejection_id;
mod rejection_origin;
pub use rejected_metric::{RejectedMetric, RejectedMetricData};
pub use rejection_detail::RejectionDetail;
pub use rejection_id::RejectionId;
pub use rejection_origin::RejectionOrigin;

View File

@@ -0,0 +1,79 @@
use chrono::{DateTime, Utc};
use crate::user::UserId;
use super::{RejectionDetail, RejectionId, RejectionOrigin};
pub struct RejectedMetricData {
pub id: RejectionId,
pub user_id: UserId,
pub origin: RejectionOrigin,
pub detail: RejectionDetail,
pub reason: String,
pub recorded_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct RejectedMetric {
id: RejectionId,
user_id: UserId,
origin: RejectionOrigin,
detail: RejectionDetail,
reason: String,
recorded_at: DateTime<Utc>,
}
impl RejectedMetric {
pub fn new(
user_id: UserId,
origin: RejectionOrigin,
detail: RejectionDetail,
reason: impl Into<String>,
) -> Self {
Self {
id: RejectionId::generate(),
user_id,
origin,
detail,
reason: reason.into(),
recorded_at: Utc::now(),
}
}
pub fn from_persistence(data: RejectedMetricData) -> Self {
Self {
id: data.id,
user_id: data.user_id,
origin: data.origin,
detail: data.detail,
reason: data.reason,
recorded_at: data.recorded_at,
}
}
}
impl RejectedMetric {
pub fn id(&self) -> &RejectionId {
&self.id
}
pub fn user_id(&self) -> &UserId {
&self.user_id
}
pub fn origin(&self) -> RejectionOrigin {
self.origin
}
pub fn detail(&self) -> &RejectionDetail {
&self.detail
}
pub fn reason(&self) -> &str {
&self.reason
}
pub fn recorded_at(&self) -> &DateTime<Utc> {
&self.recorded_at
}
}

View File

@@ -0,0 +1,44 @@
use crate::entry::Date;
use crate::provider::ProviderName;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RejectionDetail {
provider: Option<ProviderName>,
date: Option<Date>,
kind: String,
value: Option<i64>,
}
impl RejectionDetail {
pub fn new(
provider: Option<ProviderName>,
date: Option<Date>,
kind: impl Into<String>,
value: Option<i64>,
) -> Self {
Self {
provider,
date,
kind: kind.into(),
value,
}
}
}
impl RejectionDetail {
pub fn provider(&self) -> Option<&ProviderName> {
self.provider.as_ref()
}
pub fn date(&self) -> Option<&Date> {
self.date.as_ref()
}
pub fn kind(&self) -> &str {
&self.kind
}
pub fn value(&self) -> Option<i64> {
self.value
}
}

View File

@@ -0,0 +1,3 @@
use crate::macros::uuid_id;
uuid_id!(RejectionId);

View File

@@ -0,0 +1,25 @@
const IMPORT: &str = "import";
const STORED_ROW: &str = "storedRow";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum RejectionOrigin {
Import,
StoredRow,
}
impl RejectionOrigin {
pub fn name(&self) -> &'static str {
match self {
Self::Import => IMPORT,
Self::StoredRow => STORED_ROW,
}
}
pub fn from_name(name: &str) -> Option<Self> {
match name {
IMPORT => Some(Self::Import),
STORED_ROW => Some(Self::StoredRow),
_ => None,
}
}
}

View File

@@ -1,7 +1,4 @@
use chrono::Utc;
use crate::activity::ActivityId;
use crate::entry::{Mood, MoodEntry};
use crate::entry::{Date, Mood, MoodEntry};
pub struct MoodAnalyzerService;
@@ -32,64 +29,28 @@ impl MoodAnalyzerService {
.collect()
}
#[tracing::instrument(skip(entries), fields(entry_count = entries.len()))]
pub fn current_streak(entries: &[MoodEntry]) -> usize {
if entries.is_empty() {
return 0;
}
let mut dates: Vec<_> = entries.iter().map(|e| e.logged_at().date_naive()).collect();
#[tracing::instrument(skip(dates), fields(date_count = dates.len()))]
pub fn current_streak(dates: &[Date], today: Date) -> usize {
let mut dates = dates.to_vec();
dates.sort();
dates.dedup();
let today = Utc::now().date_naive();
let Some(most_recent) = dates.last() else {
return 0;
};
let last_date = *dates.last().unwrap();
let diff_to_today = (today - last_date).num_days();
if diff_to_today > 1 {
if today.days_since(most_recent) > 1 {
return 0;
}
let mut streak = 1;
for window in dates.windows(2).rev() {
let diff = window[1] - window[0];
if diff.num_days() == 1 {
streak += 1;
} else {
if window[1].days_since(&window[0]) != 1 {
break;
}
streak += 1;
}
streak
}
#[tracing::instrument(skip(entries), fields(entry_count = entries.len()))]
pub fn activity_mood_correlation(
entries: &[MoodEntry],
activity_id: &ActivityId,
) -> Option<f64> {
let with: Vec<_> = entries
.iter()
.filter(|e| e.activities().contains(activity_id))
.collect();
let without: Vec<_> = entries
.iter()
.filter(|e| !e.activities().contains(activity_id))
.collect();
let avg_with = Self::average_mood_from_refs(&with)?;
let avg_without = Self::average_mood_from_refs(&without)?;
Some(avg_with - avg_without)
}
fn average_mood_from_refs(entries: &[&MoodEntry]) -> Option<f64> {
if entries.is_empty() {
return None;
}
let sum: u32 = entries.iter().map(|e| e.mood().value() as u32).sum();
Some(sum as f64 / entries.len() as f64)
}
}

View File

@@ -0,0 +1,24 @@
use crate::errors::DomainError;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct AlbumName(String);
impl AlbumName {
pub fn new(name: impl Into<String>) -> Result<Self, DomainError> {
let trimmed = name.into().trim().to_string();
if trimmed.is_empty() {
return Err(DomainError::InvalidInput(
"album name cannot be empty".into(),
));
}
Ok(Self(trimmed))
}
pub fn from_persistence(name: String) -> Self {
Self(name)
}
pub fn value(&self) -> &str {
&self.0
}
}

View File

@@ -0,0 +1,24 @@
use crate::errors::DomainError;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ArtistName(String);
impl ArtistName {
pub fn new(name: impl Into<String>) -> Result<Self, DomainError> {
let trimmed = name.into().trim().to_string();
if trimmed.is_empty() {
return Err(DomainError::InvalidInput(
"artist name cannot be empty".into(),
));
}
Ok(Self(trimmed))
}
pub fn from_persistence(name: String) -> Self {
Self(name)
}
pub fn value(&self) -> &str {
&self.0
}
}

View File

@@ -0,0 +1,11 @@
mod album_name;
mod artist_name;
mod recording_id;
mod song;
mod song_title;
pub use album_name::AlbumName;
pub use artist_name::ArtistName;
pub use recording_id::RecordingId;
pub use song::Song;
pub use song_title::SongTitle;

View File

@@ -0,0 +1,21 @@
use crate::errors::DomainError;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct RecordingId(uuid::Uuid);
impl RecordingId {
pub fn new(id: &str) -> Result<Self, DomainError> {
id.trim()
.parse::<uuid::Uuid>()
.map(Self)
.map_err(|_| DomainError::InvalidInput("recording id must be a UUID".into()))
}
pub fn from_uuid(id: uuid::Uuid) -> Self {
Self(id)
}
pub fn value(&self) -> uuid::Uuid {
self.0
}
}

View File

@@ -0,0 +1,59 @@
use crate::errors::DomainError;
use super::{AlbumName, ArtistName, RecordingId, SongTitle};
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Song {
title: SongTitle,
artist: ArtistName,
album: Option<AlbumName>,
recording_id: Option<RecordingId>,
}
impl Song {
pub fn new(
title: impl Into<String>,
artist: impl Into<String>,
album: Option<String>,
recording_id: Option<RecordingId>,
) -> Result<Self, DomainError> {
Ok(Self {
title: SongTitle::new(title)?,
artist: ArtistName::new(artist)?,
album: album.and_then(|name| AlbumName::new(name).ok()),
recording_id,
})
}
pub fn from_persistence(
title: SongTitle,
artist: ArtistName,
album: Option<AlbumName>,
recording_id: Option<RecordingId>,
) -> Self {
Self {
title,
artist,
album,
recording_id,
}
}
}
impl Song {
pub fn title(&self) -> &SongTitle {
&self.title
}
pub fn artist(&self) -> &ArtistName {
&self.artist
}
pub fn album(&self) -> Option<&AlbumName> {
self.album.as_ref()
}
pub fn recording_id(&self) -> Option<&RecordingId> {
self.recording_id.as_ref()
}
}

View File

@@ -0,0 +1,24 @@
use crate::errors::DomainError;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct SongTitle(String);
impl SongTitle {
pub fn new(title: impl Into<String>) -> Result<Self, DomainError> {
let trimmed = title.into().trim().to_string();
if trimmed.is_empty() {
return Err(DomainError::InvalidInput(
"song title cannot be empty".into(),
));
}
Ok(Self(trimmed))
}
pub fn from_persistence(title: String) -> Self {
Self(title)
}
pub fn value(&self) -> &str {
&self.0
}
}

View File

@@ -0,0 +1,33 @@
use std::sync::atomic::{AtomicUsize, Ordering};
use crate::api_token::TokenDigest;
pub struct FakeApiTokenSecret {
minted: AtomicUsize,
}
impl FakeApiTokenSecret {
pub fn new() -> Self {
Self {
minted: AtomicUsize::new(0),
}
}
}
impl Default for FakeApiTokenSecret {
fn default() -> Self {
Self::new()
}
}
impl crate::ports::ApiTokenSecretPort for FakeApiTokenSecret {
fn mint(&self) -> String {
let number = self.minted.fetch_add(1, Ordering::SeqCst);
format!("kmood_secret_{number}")
}
fn digest(&self, secret: &str) -> TokenDigest {
TokenDigest::from_persistence(format!("digest-of-{secret}"))
}
}

View File

@@ -0,0 +1,18 @@
use crate::errors::DomainError;
use crate::provider::{CredentialCipher, EncryptedCredential};
const MASK: u8 = 0x5a;
pub struct FakeCredentialCipher;
impl CredentialCipher for FakeCredentialCipher {
fn encrypt(&self, plaintext: &[u8]) -> Result<EncryptedCredential, DomainError> {
Ok(EncryptedCredential::from_persistence(
plaintext.iter().map(|byte| byte ^ MASK).collect(),
))
}
fn decrypt(&self, credential: &EncryptedCredential) -> Result<Vec<u8>, DomainError> {
Ok(credential.value().iter().map(|byte| byte ^ MASK).collect())
}
}

View File

@@ -0,0 +1,63 @@
use std::collections::HashMap;
use std::sync::RwLock;
use crate::dimension::{DimensionKind, DimensionValue};
use crate::entry::MoodEntryId;
use crate::errors::DomainError;
pub struct InMemoryDimensionStore {
kind: DimensionKind,
values: RwLock<HashMap<MoodEntryId, DimensionValue>>,
}
impl InMemoryDimensionStore {
pub fn new(kind: DimensionKind) -> Self {
Self {
kind,
values: RwLock::new(HashMap::new()),
}
}
pub fn forget(&self, entry_id: &MoodEntryId) {
self.values.write().unwrap().remove(entry_id);
}
pub fn holds_count(&self) -> usize {
self.values.read().unwrap().len()
}
pub fn holds(&self, entry_id: &MoodEntryId) -> bool {
self.values.read().unwrap().contains_key(entry_id)
}
pub fn put(&self, entry_id: &MoodEntryId, value: DimensionValue) {
self.values.write().unwrap().insert(entry_id.clone(), value);
}
}
#[async_trait::async_trait]
impl crate::ports::EntryDimensionPort for InMemoryDimensionStore {
async fn load(
&self,
entry_ids: &[MoodEntryId],
) -> Result<HashMap<MoodEntryId, DimensionValue>, DomainError> {
let stored = self.values.read().unwrap();
Ok(entry_ids
.iter()
.filter_map(|id| stored.get(id).map(|value| (id.clone(), value.clone())))
.collect())
}
async fn save(
&self,
entry_id: &MoodEntryId,
values: &[DimensionValue],
) -> Result<(), DomainError> {
let mut stored = self.values.write().unwrap();
match values.iter().find(|value| value.kind() == self.kind) {
Some(value) => stored.insert(entry_id.clone(), value.clone()),
None => stored.remove(entry_id),
};
Ok(())
}
}

View File

@@ -0,0 +1,137 @@
use std::collections::HashMap;
use std::sync::RwLock;
use crate::attachment::{ContentType, MediaUpload, PhotoId, VoiceMemoId};
use crate::errors::DomainError;
use crate::ports::MediaFile;
pub struct FakeMediaStorage {
photos: RwLock<HashMap<PhotoId, Vec<u8>>>,
voice_memos: RwLock<HashMap<VoiceMemoId, Vec<u8>>>,
deletions_attempted: RwLock<Vec<String>>,
refusing_to_delete: bool,
}
impl FakeMediaStorage {
pub fn holding_nothing() -> Self {
Self {
photos: RwLock::new(HashMap::new()),
voice_memos: RwLock::new(HashMap::new()),
deletions_attempted: RwLock::new(Vec::new()),
refusing_to_delete: false,
}
}
pub fn refusing_to_delete() -> Self {
Self {
refusing_to_delete: true,
..Self::holding_nothing()
}
}
pub fn put_photo(&self, id: &PhotoId, bytes: &[u8]) {
self.photos
.write()
.unwrap()
.insert(id.clone(), bytes.to_vec());
}
pub fn put_voice_memo(&self, id: &VoiceMemoId, bytes: &[u8]) {
self.voice_memos
.write()
.unwrap()
.insert(id.clone(), bytes.to_vec());
}
pub fn holds_photo(&self, id: &PhotoId) -> bool {
self.photos.read().unwrap().contains_key(id)
}
pub fn holds_voice_memo(&self, id: &VoiceMemoId) -> bool {
self.voice_memos.read().unwrap().contains_key(id)
}
pub fn blobs_held(&self) -> usize {
self.photos.read().unwrap().len() + self.voice_memos.read().unwrap().len()
}
pub fn deletions_attempted(&self) -> Vec<String> {
self.deletions_attempted.read().unwrap().clone()
}
}
impl Default for FakeMediaStorage {
fn default() -> Self {
Self::holding_nothing()
}
}
#[async_trait::async_trait]
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());
Ok(id)
}
async fn store_voice_memo(&self, upload: MediaUpload) -> Result<VoiceMemoId, DomainError> {
let id = VoiceMemoId::generate();
self.put_voice_memo(&id, upload.data());
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()),
}))
}
async fn get_voice_memo(&self, id: &VoiceMemoId) -> Result<Option<MediaFile>, DomainError> {
Ok(self
.voice_memos
.read()
.unwrap()
.get(id)
.map(|bytes| MediaFile {
data: bytes.clone(),
content_type: ContentType::from_persistence("audio/webm".into()),
}))
}
async fn delete_photo(&self, id: &PhotoId) -> Result<(), DomainError> {
self.deletions_attempted
.write()
.unwrap()
.push(id.value().to_string());
if self.refusing_to_delete {
return Err(DomainError::InvalidInput(
"the blob store refused to delete this photo".into(),
));
}
self.photos.write().unwrap().remove(id);
Ok(())
}
async fn delete_voice_memo(&self, id: &VoiceMemoId) -> Result<(), DomainError> {
self.deletions_attempted
.write()
.unwrap()
.push(id.value().to_string());
if self.refusing_to_delete {
return Err(DomainError::InvalidInput(
"the blob store refused to delete this voice memo".into(),
));
}
self.voice_memos.write().unwrap().remove(id);
Ok(())
}
}

View File

@@ -1,11 +1,31 @@
mod api_token_secret;
mod cipher;
mod dimension_store;
mod media_storage;
mod music;
mod password_hasher;
mod refusing;
mod store;
mod store_activity;
mod store_api_token;
mod store_auth;
mod store_cycle;
mod store_entry;
mod store_infra;
mod store_job;
mod store_metric;
mod store_provider;
mod store_rejection;
mod store_reminder;
mod store_user;
mod weather;
pub use api_token_secret::FakeApiTokenSecret;
pub use cipher::FakeCredentialCipher;
pub use dimension_store::InMemoryDimensionStore;
pub use media_storage::FakeMediaStorage;
pub use music::{FakeNowPlaying, FakeRecordingLookup};
pub use password_hasher::FakePasswordHasher;
pub use refusing::{OneTokenStore, RefusingApiTokenStore, RefusingRejectionTrace};
pub use store::InMemoryStore;
pub use weather::FakeWeatherLookup;

View File

@@ -0,0 +1,90 @@
use crate::errors::DomainError;
use crate::ports::{NowPlayingPort, RecordingLookupPort};
use crate::song::{RecordingId, Song};
pub struct FakeNowPlaying {
provider: String,
playing: Option<Song>,
failing_with: Option<String>,
}
impl FakeNowPlaying {
pub fn playing(provider: &str, song: Song) -> Self {
Self {
provider: provider.to_string(),
playing: Some(song),
failing_with: None,
}
}
pub fn silent(provider: &str) -> Self {
Self {
provider: provider.to_string(),
playing: None,
failing_with: None,
}
}
pub fn failing(provider: &str, reason: impl Into<String>) -> Self {
Self {
provider: provider.to_string(),
playing: None,
failing_with: Some(reason.into()),
}
}
}
#[async_trait::async_trait]
impl NowPlayingPort for FakeNowPlaying {
fn provider(&self) -> &str {
&self.provider
}
async fn now_playing(&self, _credential: &[u8]) -> Result<Option<Song>, DomainError> {
if let Some(reason) = &self.failing_with {
return Err(DomainError::InvalidInput(reason.clone()));
}
Ok(self.playing.clone())
}
}
pub struct FakeRecordingLookup {
found: Option<RecordingId>,
failing_with: Option<String>,
}
impl FakeRecordingLookup {
pub fn finding(found: Option<RecordingId>) -> Self {
Self {
found,
failing_with: None,
}
}
pub fn finding_nothing() -> Self {
Self::finding(None)
}
pub fn failing(reason: impl Into<String>) -> Self {
Self {
found: None,
failing_with: Some(reason.into()),
}
}
}
#[async_trait::async_trait]
impl RecordingLookupPort for FakeRecordingLookup {
async fn find_recording(
&self,
_title: &str,
_artist: &str,
) -> Result<Option<RecordingId>, DomainError> {
if let Some(reason) = &self.failing_with {
return Err(DomainError::InvalidInput(reason.clone()));
}
Ok(self.found.clone())
}
}

View File

@@ -0,0 +1,55 @@
use crate::api_token::{ApiToken, ApiTokenId, TokenDigest};
use crate::errors::DomainError;
use crate::rejection::RejectedMetric;
use crate::user::UserId;
pub struct RefusingRejectionTrace;
#[async_trait::async_trait]
impl crate::ports::RejectionCommandPort for RefusingRejectionTrace {
async fn record(&self, _rejections: &[RejectedMetric]) -> Result<(), DomainError> {
Err(DomainError::InvalidInput(
"the rejection trace is unwritable".into(),
))
}
}
pub struct RefusingApiTokenStore;
#[async_trait::async_trait]
impl crate::ports::ApiTokenCommandPort for RefusingApiTokenStore {
async fn save(&self, _token: &ApiToken) -> Result<(), DomainError> {
Err(DomainError::InvalidInput("cannot write a token".into()))
}
async fn revoke(&self, _user_id: &UserId, _id: &ApiTokenId) -> Result<(), DomainError> {
Err(DomainError::InvalidInput("cannot revoke a token".into()))
}
async fn mark_used(&self, _id: &ApiTokenId) -> Result<(), DomainError> {
Err(DomainError::InvalidInput(
"cannot record that a token was used".into(),
))
}
}
pub struct OneTokenStore(pub ApiToken);
#[async_trait::async_trait]
impl crate::ports::ApiTokenQueryPort for OneTokenStore {
async fn find_by_digest(&self, digest: &TokenDigest) -> Result<Option<ApiToken>, DomainError> {
if self.0.digest() == digest {
return Ok(Some(self.0.clone()));
}
Ok(None)
}
async fn find_by_user(&self, user_id: &UserId) -> Result<Vec<ApiToken>, DomainError> {
if self.0.user_id() == user_id {
return Ok(vec![self.0.clone()]);
}
Ok(Vec::new())
}
}

Some files were not shown because too many files have changed in this diff Show More