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

@@ -1,5 +1,6 @@
pub mod dimension;
pub mod errors;
pub mod mappers;
pub mod params;
pub mod requests;
pub mod responses;

View File

@@ -7,7 +7,12 @@ impl From<ApiToken> for ApiTokenResponse {
Self {
id: token.id().value().to_string(),
name: token.name().value().to_string(),
scope: token.scope().name().to_string(),
scopes: token
.scopes()
.names()
.iter()
.map(|n| n.to_string())
.collect(),
created_at: *token.created_at(),
last_used_at: token.last_used_at().copied(),
}

View File

@@ -8,7 +8,7 @@ impl From<CalendarDay> for CalendarDayResponse {
date: day.date.value(),
day_mood: day.day_mood.map(|mood| mood.value()),
mood: day.day_mood.map(|mood| mood.rounded().value()),
mood_label: day.day_mood.map(|mood| format!("{:?}", mood.rounded())),
mood_label: day.day_mood.map(|mood| mood.rounded().label().to_string()),
cycle_day: day.cycle_day.map(|cycle| cycle.value()),
entries: day.entries.into_iter().map(EntryResponse::from).collect(),
}

View File

@@ -1,18 +1,18 @@
use chrono::Utc;
use config::EntryConfig;
use domain::activity::ActivityId;
use domain::dimension::{ComposedEntry, DimensionValue};
use domain::entry::{DateRange, Mood, MoodEntryId};
use domain::entry::{DateRange, EntrySelection, Mood, MoodEntryId, Pagination};
use domain::user::UserId;
use application::entry::commands::{CreateEntryCommand, UpdateEntryCommand};
use application::entry::queries::{
FilterByActivityQuery, FilterByMoodQuery, ListEntriesQuery, MoodStatsQuery,
};
use application::entry::queries::{ListEntriesQuery, MoodStatsQuery};
use crate::dimension::DimensionPayload;
use crate::errors::ApiValidationError;
use crate::requests::{CreateEntryRequest, DateRangeParams, ListEntriesParams, UpdateEntryRequest};
use crate::params::{DateRangeParams, ListEntriesParams};
use crate::requests::{CreateEntryRequest, UpdateEntryRequest};
use crate::responses::EntryResponse;
use super::shared::{parse_datetime, validate_content_length};
@@ -45,9 +45,13 @@ impl UpdateEntryRequest {
entry_id: MoodEntryId,
config: &EntryConfig,
) -> Result<UpdateEntryCommand, ApiValidationError> {
let mood = Mood::try_from(self.mood)?;
let mood = self.mood.map(Mood::try_from).transpose()?;
let logged_at = self.logged_at.as_deref().map(parse_datetime).transpose()?;
let dimensions = parse_dimensions(self.dimensions, config)?;
let dimensions = match self.dimensions {
Some(named) => Some(parse_dimensions(Some(named), config)?),
None => None,
};
Ok(UpdateEntryCommand {
entry_id,
@@ -59,7 +63,11 @@ impl UpdateEntryRequest {
}
impl ListEntriesParams {
pub fn into_query(self, user_id: UserId) -> Result<ListEntriesQuery, ApiValidationError> {
pub fn into_query(
self,
user_id: UserId,
config: &EntryConfig,
) -> Result<ListEntriesQuery, ApiValidationError> {
let range = match (self.from, self.to) {
(Some(from), Some(to)) => Some(DateRange::new(
parse_datetime(&from)?,
@@ -67,15 +75,43 @@ impl ListEntriesParams {
)?),
_ => None,
};
Ok(ListEntriesQuery {
user_id,
range,
limit: self.limit,
offset: self.offset,
})
let activity = self
.activity
.as_deref()
.map(parse_activity_id)
.transpose()?;
let updated_since = self
.updated_since
.as_deref()
.map(parse_datetime)
.transpose()?
.map(|instant| instant.with_timezone(&Utc));
let selection = EntrySelection::everything_of(user_id)
.logged_within(range)
.of_mood(self.mood.map(Mood::try_from).transpose()?)
.tagged_with(activity)
.changed_since(updated_since);
let page = Pagination::new(
self.limit.unwrap_or(config.max_entries_per_page),
self.offset.unwrap_or(0),
config.max_entries_per_page,
)?;
Ok(ListEntriesQuery { selection, page })
}
}
fn parse_activity_id(named: &str) -> Result<ActivityId, ApiValidationError> {
named
.parse()
.map(ActivityId::from_uuid)
.map_err(|_| ApiValidationError::Invalid(format!("{named} is not an activity id")))
}
impl DateRangeParams {
pub fn into_date_range(self) -> Result<DateRange, ApiValidationError> {
let from = parse_datetime(&self.from)?;
@@ -87,27 +123,6 @@ impl DateRangeParams {
let range = Some(self.into_date_range()?);
Ok(MoodStatsQuery { user_id, range })
}
pub fn into_mood_filter(
self,
user_id: UserId,
mood: u8,
) -> Result<FilterByMoodQuery, ApiValidationError> {
let mood = Mood::try_from(mood)?;
Ok(FilterByMoodQuery { user_id, mood })
}
pub fn into_activity_filter(
self,
user_id: UserId,
activity_id: &str,
) -> Result<FilterByActivityQuery, ApiValidationError> {
let activity_id = super::shared::parse_uuid(activity_id)?.into();
Ok(FilterByActivityQuery {
user_id,
activity_id,
})
}
}
impl From<ComposedEntry> for EntryResponse {
@@ -118,7 +133,7 @@ impl From<ComposedEntry> for EntryResponse {
id: entry.id().value(),
user_id: entry.user_id().value(),
mood: entry.mood().value(),
mood_label: format!("{:?}", entry.mood()),
mood_label: entry.mood().label().to_string(),
logged_at: *entry.logged_at(),
photo_urls: composed
.photos()

View File

@@ -0,0 +1,19 @@
use domain::job::{Job, JobSubject};
use crate::responses::ExhaustedJobResponse;
impl From<Job> for ExhaustedJobResponse {
fn from(job: Job) -> Self {
let JobSubject::Entry(entry_id) = job.subject();
Self {
id: job.id().value().to_string(),
kind: job.kind().name().to_string(),
subject: entry_id.value().to_string(),
attempts: job.attempts(),
last_error: job.last_error().map(|reason| reason.to_string()),
enqueued_at: *job.enqueued_at(),
updated_at: *job.updated_at(),
}
}
}

View File

@@ -1,6 +1,6 @@
use domain::attachment::{PhotoId, VoiceMemoId};
use domain::attachment::{MediaKind, MediaRef, PhotoId, VoiceMemoId};
use crate::responses::MediaIdResponse;
use crate::responses::{MediaIdResponse, OwnedMediaResponse};
impl From<PhotoId> for MediaIdResponse {
fn from(id: PhotoId) -> Self {
@@ -13,3 +13,18 @@ impl From<VoiceMemoId> for MediaIdResponse {
Self { id: id.value() }
}
}
impl From<MediaRef> for OwnedMediaResponse {
fn from(media: MediaRef) -> Self {
let path = match media.kind() {
MediaKind::Photo => "photos",
MediaKind::VoiceMemo => "voice-memos",
};
Self {
url: format!("/api/v1/media/{path}/{}", media.id()),
id: media.id().to_string(),
kind: media.kind().name().to_string(),
}
}
}

View File

@@ -3,7 +3,8 @@ use domain::entry::{Date, DateSpan};
use domain::metric::{DailyMetric, MetricKind, MetricValue};
use crate::errors::ApiValidationError;
use crate::requests::{DateSpanParams, MetricPayload};
use crate::params::DateSpanParams;
use crate::requests::MetricPayload;
use crate::responses::DailyMetricResponse;
impl From<DailyMetric> for DailyMetricResponse {

View File

@@ -6,9 +6,11 @@ mod correlation;
mod cycle;
mod entry;
mod import_metrics;
mod job;
mod media;
mod metric;
mod provider;
mod push;
mod reminder;
mod restore;
pub mod shared;

View File

@@ -0,0 +1,13 @@
use domain::push::PushSubscription;
use crate::responses::PushSubscriptionResponse;
impl From<PushSubscription> for PushSubscriptionResponse {
fn from(subscription: PushSubscription) -> Self {
Self {
id: subscription.id().value().to_string(),
endpoint: subscription.endpoint().to_string(),
created_at: *subscription.created_at(),
}
}
}

View File

@@ -11,6 +11,7 @@ impl From<RestoreOutcome> for RestoreOutcomeResponse {
activities: outcome.activities,
reminders: outcome.reminders,
media: outcome.media,
skipped: outcome.skipped,
unreadable: outcome.unreadable,
}
}

View File

@@ -1,6 +1,6 @@
use application::entry::use_cases::get_mood_stats::MoodStats;
use crate::responses::{MoodFrequency, MoodStatsResponse};
use crate::responses::{MoodFrequencyResponse, MoodStatsResponse};
impl From<MoodStats> for MoodStatsResponse {
fn from(stats: MoodStats) -> Self {
@@ -9,14 +9,16 @@ impl From<MoodStats> for MoodStatsResponse {
frequency: stats
.frequency
.into_iter()
.map(|(mood, count)| MoodFrequency {
.map(|(mood, count)| MoodFrequencyResponse {
mood: mood.value(),
mood_label: format!("{mood:?}"),
mood_label: mood.label().to_string(),
count,
})
.collect(),
current_streak: stats.current_streak,
total_entries: stats.total_entries,
first_logged_on: stats.first_logged_on.map(|date| date.to_string()),
last_logged_on: stats.last_logged_on.map(|date| date.to_string()),
}
}
}

View File

@@ -0,0 +1,5 @@
#[derive(Debug, Default, serde::Deserialize, utoipa::ToSchema, utoipa::IntoParams)]
#[serde(rename_all = "camelCase")]
pub struct ListActivitiesParams {
pub include_archived: Option<bool>,
}

View File

@@ -0,0 +1,18 @@
#[derive(Debug, serde::Deserialize, utoipa::ToSchema, utoipa::IntoParams)]
#[serde(rename_all = "camelCase")]
pub struct ListEntriesParams {
pub from: Option<String>,
pub to: Option<String>,
pub mood: Option<u8>,
pub activity: Option<String>,
pub updated_since: Option<String>,
pub limit: Option<i64>,
pub offset: Option<i64>,
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema, utoipa::IntoParams)]
#[serde(rename_all = "camelCase")]
pub struct DateRangeParams {
pub from: String,
pub to: String,
}

View File

@@ -0,0 +1,6 @@
#[derive(Debug, serde::Deserialize, utoipa::ToSchema, utoipa::IntoParams)]
#[serde(rename_all = "camelCase")]
pub struct DateSpanParams {
pub from: String,
pub to: String,
}

View File

@@ -0,0 +1,7 @@
mod activity;
mod entry;
mod metric;
pub use activity::ListActivitiesParams;
pub use entry::{DateRangeParams, ListEntriesParams};
pub use metric::DateSpanParams;

View File

@@ -2,4 +2,5 @@
#[serde(rename_all = "camelCase")]
pub struct MintApiTokenRequest {
pub name: String,
pub scopes: Vec<String>,
}

View File

@@ -1,33 +1,25 @@
use crate::dimension::DimensionPayload;
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct CreateEntryRequest {
pub mood: u8,
pub logged_at: Option<String>,
pub dimensions: Option<Vec<crate::dimension::DimensionPayload>>,
pub dimensions: Option<Vec<DimensionPayload>>,
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct CreateEntriesRequest {
pub entries: Vec<CreateEntryRequest>,
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct UpdateEntryRequest {
pub mood: u8,
pub mood: Option<u8>,
pub logged_at: Option<String>,
pub dimensions: Option<Vec<crate::dimension::DimensionPayload>>,
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema, utoipa::IntoParams)]
#[serde(rename_all = "camelCase")]
pub struct ListEntriesParams {
pub from: Option<String>,
pub to: Option<String>,
pub limit: Option<i64>,
pub offset: Option<i64>,
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema, utoipa::IntoParams)]
#[serde(rename_all = "camelCase")]
pub struct DateRangeParams {
pub from: String,
pub to: String,
pub dimensions: Option<Vec<DimensionPayload>>,
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]

View File

@@ -10,10 +10,3 @@ pub struct MetricPayload {
pub kind: String,
pub value: Option<i64>,
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema, utoipa::IntoParams)]
#[serde(rename_all = "camelCase")]
pub struct DateSpanParams {
pub from: String,
pub to: String,
}

View File

@@ -13,11 +13,10 @@ pub use activity::{CreateActivityRequest, RenameActivityRequest, SetCategoryRequ
pub use api_token::MintApiTokenRequest;
pub use cycle::SetPreferencesRequest;
pub use entry::{
CreateEntryRequest, DateRangeParams, ListEntriesParams, ReplaceActivityRequest,
UpdateEntryRequest,
CreateEntriesRequest, CreateEntryRequest, ReplaceActivityRequest, UpdateEntryRequest,
};
pub use import::{ImportDailyMetricsRequest, ImportedDayPayload, ImportedMetricPayload};
pub use metric::{DateSpanParams, MetricPayload, SetDailyMetricsRequest};
pub use metric::{MetricPayload, SetDailyMetricsRequest};
pub use provider::ConnectProviderRequest;
pub use push::{PushSubscribeRequest, PushUnsubscribeRequest};
pub use reminder::{CreateReminderRequest, UpdateReminderRequest};

View File

@@ -25,8 +25,9 @@ pub struct PushUnsubscribeRequest {
}
impl PushUnsubscribeRequest {
pub fn into_command(self) -> UnsubscribePushCommand {
pub fn into_command(self, user_id: UserId) -> UnsubscribePushCommand {
UnsubscribePushCommand {
user_id,
endpoint: self.endpoint,
}
}

View File

@@ -5,7 +5,7 @@ use chrono::{DateTime, Utc};
pub struct ApiTokenResponse {
pub id: String,
pub name: String,
pub scope: String,
pub scopes: Vec<String>,
pub created_at: DateTime<Utc>,
pub last_used_at: Option<DateTime<Utc>>,
}

View File

@@ -0,0 +1,26 @@
use chrono::{DateTime, Utc};
use super::UserResponse;
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct SignedInResponse {
pub access_token: String,
pub refresh_token: String,
pub expires_at: DateTime<Utc>,
pub user: UserResponse,
}
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct RefreshedResponse {
pub access_token: String,
pub refresh_token: String,
pub expires_at: DateTime<Utc>,
}
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct VapidKeyResponse {
pub public_key: String,
}

View File

@@ -1,21 +0,0 @@
use uuid::Uuid;
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct BulkActionResponse {
pub affected_count: u64,
}
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ImportResultResponse {
pub imported: u64,
pub skipped: u64,
pub errors: Vec<String>,
}
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct MediaIdResponse {
pub id: Uuid,
}

View File

@@ -0,0 +1,5 @@
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct BulkActionResponse {
pub affected_count: u64,
}

View File

@@ -0,0 +1,23 @@
#[derive(Debug, serde::Serialize, serde::Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ErrorResponse {
pub error: ErrorDetailResponse,
}
#[derive(Debug, serde::Serialize, serde::Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ErrorDetailResponse {
pub code: String,
pub message: String,
}
impl ErrorResponse {
pub fn new(code: &str, message: impl Into<String>) -> Self {
Self {
error: ErrorDetailResponse {
code: code.to_string(),
message: message.into(),
},
}
}
}

View File

@@ -0,0 +1,7 @@
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ImportResultResponse {
pub imported: u64,
pub skipped: u64,
pub errors: Vec<String>,
}

View File

@@ -0,0 +1,13 @@
use chrono::{DateTime, Utc};
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ExhaustedJobResponse {
pub id: String,
pub kind: String,
pub subject: String,
pub attempts: u32,
pub last_error: Option<String>,
pub enqueued_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}

View File

@@ -0,0 +1,13 @@
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct MediaIdResponse {
pub id: uuid::Uuid,
}
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct OwnedMediaResponse {
pub id: String,
pub kind: String,
pub url: String,
}

View File

@@ -1,31 +1,49 @@
mod activity;
mod api_token;
mod bulk;
mod auth;
mod bulk_action;
mod calendar;
mod correlation;
mod cycle;
mod entry;
mod error;
mod import;
mod import_result;
mod job;
mod media;
mod metric;
mod page;
mod provider;
mod push;
mod reminder;
mod restore;
mod server;
mod stats;
mod user;
pub use activity::ActivityResponse;
pub use api_token::{ApiTokenResponse, MintedApiTokenResponse};
pub use bulk::{BulkActionResponse, ImportResultResponse, MediaIdResponse};
pub use auth::{RefreshedResponse, SignedInResponse, VapidKeyResponse};
pub use bulk_action::BulkActionResponse;
pub use calendar::CalendarDayResponse;
pub use correlation::{
AgreementResponse, CorrelationInputResponse, CorrelationRowResponse, StrategyScoreResponse,
};
pub use cycle::{CyclePositionResponse, CycleViewResponse, PreferencesResponse};
pub use entry::EntryResponse;
pub use error::{ErrorDetailResponse, ErrorResponse};
pub use import::{ImportOutcomeResponse, RejectedMetricResponse, RejectionResponse};
pub use import_result::ImportResultResponse;
pub use job::ExhaustedJobResponse;
pub use media::{MediaIdResponse, OwnedMediaResponse};
pub use metric::DailyMetricResponse;
pub use page::EntryPageResponse;
pub use provider::ProviderConnectionResponse;
pub use push::PushSubscriptionResponse;
pub use reminder::{DayScheduleResponse, ReminderResponse};
pub use restore::RestoreOutcomeResponse;
pub use stats::{MoodFrequency, MoodStatsResponse};
pub use server::{
MetricKindResponse, MoodResponse, ServerInfoResponse, ServerLimitsResponse, TokenScopeResponse,
};
pub use stats::{MoodFrequencyResponse, MoodStatsResponse};
pub use user::UserResponse;

View File

@@ -0,0 +1,11 @@
use super::EntryResponse;
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct EntryPageResponse {
pub items: Vec<EntryResponse>,
pub total: u64,
pub limit: i64,
pub offset: i64,
pub has_more: bool,
}

View File

@@ -0,0 +1,9 @@
use chrono::{DateTime, Utc};
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct PushSubscriptionResponse {
pub id: String,
pub endpoint: String,
pub created_at: DateTime<Utc>,
}

View File

@@ -7,5 +7,6 @@ pub struct RestoreOutcomeResponse {
pub activities: usize,
pub reminders: usize,
pub media: usize,
pub skipped: usize,
pub unreadable: Vec<String>,
}

View File

@@ -0,0 +1,50 @@
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ServerInfoResponse {
pub version: String,
pub registration_open: bool,
pub push_enabled: bool,
pub weather_enabled: bool,
pub now_playing_provider: Option<String>,
pub access_token_ttl_seconds: u64,
pub limits: ServerLimitsResponse,
pub token_scopes: Vec<TokenScopeResponse>,
pub metric_kinds: Vec<MetricKindResponse>,
pub moods: Vec<MoodResponse>,
pub weather_conditions: Vec<String>,
}
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ServerLimitsResponse {
pub max_body_size: usize,
pub max_content_length: usize,
pub max_photos: usize,
pub max_voice_memos: usize,
pub max_activities_per_entry: usize,
pub max_entries_per_page: i64,
pub max_import_days: usize,
}
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct TokenScopeResponse {
pub name: String,
pub describes: String,
}
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct MetricKindResponse {
pub name: String,
pub unit: String,
pub minimum: i64,
pub maximum: i64,
}
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct MoodResponse {
pub value: u8,
pub label: String,
}

View File

@@ -1,6 +1,6 @@
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct MoodFrequency {
pub struct MoodFrequencyResponse {
pub mood: u8,
pub mood_label: String,
pub count: usize,
@@ -10,7 +10,9 @@ pub struct MoodFrequency {
#[serde(rename_all = "camelCase")]
pub struct MoodStatsResponse {
pub average: Option<f64>,
pub frequency: Vec<MoodFrequency>,
pub frequency: Vec<MoodFrequencyResponse>,
pub current_streak: usize,
pub total_entries: usize,
pub first_logged_on: Option<String>,
pub last_logged_on: Option<String>,
}