feat: always-on Insights — correlation analysis over viewing habits
Some checks failed
CI / Check / Test (push) Failing after 9m16s

Conservative, live insights (ADR-0010) derived from each user's diary:
rating lift by genre (first-time watches only), genre seasonality vs a
uniform 1/12 month baseline, medium weekend-lift, and watch-time rhythm.

- domain: WatchRow model, InsightsQuery port, pure insights_analyzer with
  all honesty thresholds (sample minimums, strength cutoffs, suppressions
  for import noise), unit-tested
- adapters: thin Sqlite/Postgres fetch twins, reusing WrapUp's genre fetch
- application: get_insights use case, InsightsGroup in Deps
- composition/server: wiring through Repositories/DatabaseOutput
- presentation: GET /api/v1/insights handler, typed serde-tagged DTOs,
  openapi doc
- spa: /insights route (shadcn cards), insights feature hook, profile
  entry link, en locale strings
- server test: end-to-end seed sqlite → HTTP → rating lift + rhythm

Also records ADR-0010, defines "Insight" in the glossary, and includes
pre-existing worktree comment-stripping cleanups.
This commit is contained in:
2026-10-12 19:29:54 +02:00
parent 4d221e60de
commit 020fbb891e
126 changed files with 1922 additions and 639 deletions

View File

@@ -39,10 +39,6 @@ pub struct FederatedProfile {
pub banner_url: Option<String>,
}
/// A domain-blocklist entry as presented to admin surfaces. Mirrors what the
/// federation adapter can supply; `blocked_at` is a pre-formatted string
/// because that is what the underlying store returns and every consumer
/// renders it verbatim.
#[derive(Debug, Clone)]
pub struct BlockedDomainInfo {
pub domain: String,
@@ -50,9 +46,6 @@ pub struct BlockedDomainInfo {
pub blocked_at: String,
}
/// A remote actor the local instance follows, reduced to the two fields any
/// consumer actually reads. Deliberately narrower than the federation
/// library's actor type — widening it is a decision, not an oversight.
#[derive(Debug, Clone)]
pub struct FollowedActorInfo {
pub url: String,

View File

@@ -0,0 +1,66 @@
use chrono::NaiveDateTime;
use uuid::Uuid;
/// One denormalized row per Review — everything the insight analyzer needs,
/// nothing it doesn't (ADR-0010). Lighter than `WrapUpMovieRow` on purpose:
/// WrapUp's row is a report-era artifact with poster paths, budgets and cast
/// that Insights must not inherit.
#[derive(Clone, Debug)]
pub struct WatchRow {
pub movie_id: Uuid,
pub rating: u8,
pub watched_at: NaiveDateTime,
pub watch_medium: Option<String>,
pub genres: Vec<String>,
}
/// A single conservative, baseline-relative statement about a user's viewing
/// behavior (see CONTEXT.md "Insight"). Every variant carries the numbers the
/// SPA needs to render the claim plus the `sample_count` behind it, so the UI
/// can show why a category is empty without another round trip.
///
/// Ordering contract: `build_insights` returns insights grouped by category in
/// a fixed order (rating lift, seasonality, medium timing, rhythm), strongest
/// first within each category, at most `MAX_PER_CATEGORY` per category.
#[derive(Clone, Debug, PartialEq)]
pub enum Insight {
/// The genre's average rating sits `delta` stars away from the user's own
/// average. First-time watches only — a rewatch is chosen because it is
/// already liked, so counting it would conflate choice with genre.
RatingLift {
genre: String,
genre_avg: f64,
user_avg: f64,
delta: f64,
sample_count: u32,
},
/// The genre's share of watches in one month of the year is `multiplier`
/// times the uniform 1/12 baseline.
Seasonality {
genre: String,
/// 1–12
month: u32,
multiplier: f64,
sample_count: u32,
},
/// The medium's weekend share of watches is `multiplier` times the user's
/// overall weekend share.
MediumTiming {
medium: String,
multiplier: f64,
weekend_share: f64,
baseline_share: f64,
sample_count: u32,
},
/// When watching actually happens: the 3-hour window and the weekdays that
/// carry the most real-timestamped watches.
Rhythm {
/// 0–23, start of the peak 3-hour window.
peak_hour_start: u32,
/// 0–23, exclusive end of the peak window (`start + 3`, mod 24).
peak_hour_end: u32,
/// Chrono weekday numbers, strongest first: Mon=1 … Sun=7.
top_days: Vec<u32>,
sample_count: u32,
},
}

View File

@@ -12,6 +12,7 @@ pub mod goal;
pub mod import;
pub mod import_profile;
pub mod import_session;
pub mod insights;
pub mod person;
pub mod remote_goal;
pub mod remote_watchlist;
@@ -24,6 +25,7 @@ pub mod wrapup;
pub use enrichment::*;
pub use federation::*;
pub use feed::*;
pub use insights::*;
pub use movie::*;
pub use review::*;
pub use stats::{

View File

@@ -12,7 +12,6 @@ impl ExternalPersonId {
&self.0
}
/// Parse the TMDb numeric ID from "tmdb:12345". Returns None for other formats.
pub fn tmdb_id(&self) -> Option<i64> {
self.0.strip_prefix("tmdb:").and_then(|s| s.parse().ok())
}

View File

@@ -28,7 +28,6 @@ pub struct PersistedReview {
pub watch_medium: Option<WatchMedium>,
}
/// Partial update for a review. `None` = unchanged, `Some(None)` = clear, `Some(Some(v))` = set.
#[derive(Clone, Debug, Default)]
pub struct ReviewEdit {
pub rating: Option<Rating>,
@@ -133,7 +132,6 @@ impl Review {
pub fn watch_medium(&self) -> Option<&WatchMedium> {
self.watch_medium.as_ref()
}
/// Returns [star1_filled, star2_filled, ..., star5_filled]
pub fn stars(&self) -> [bool; 5] {
let r = self.rating.value();
[r >= 1, r >= 2, r >= 3, r >= 4, r >= 5]

View File

@@ -42,13 +42,9 @@ pub struct PersonSearchHit {
pub name: String,
pub known_for_department: Option<String>,
pub profile_path: Option<String>,
/// Top movie titles this person is known for — populated at query time
/// by joining relational tables, never from the index.
pub known_for_titles: Vec<String>,
}
/// Document submitted to the search index.
/// Add a new variant here to make a new entity type searchable — the port never changes.
pub enum IndexableDocument {
Movie {
id: MovieId,
@@ -58,7 +54,6 @@ pub enum IndexableDocument {
Person {
id: PersonId,
person: Box<Person>,
// known_for_titles intentionally absent — no reads inside a command flow
},
}

View File

@@ -11,9 +11,6 @@ pub trait EventPublisher: Send + Sync {
}
pub trait EventConsumer: Send + Sync {
/// Returns a stream of event envelopes. Each envelope carries a domain event
/// and an ack handle — callers ack after successful dispatch, nack on failure.
/// Implementations decide transport (NATS, DB queue, in-memory channel).
fn consume(&self) -> futures::stream::BoxStream<'_, Result<EventEnvelope, DomainError>>;
}

View File

@@ -6,8 +6,6 @@ use crate::{
models::{BlockedDomainInfo, FollowedActorInfo},
};
/// Serves ActivityPub documents for content negotiation. Presentation calls
/// this when a peer sends `Accept: application/activity+json`.
#[async_trait]
pub trait ApDocumentPort: Send + Sync {
async fn actor_json(&self, user_id: &str) -> Result<String, DomainError>;
@@ -23,8 +21,6 @@ pub trait ApDocumentPort: Send + Sync {
) -> Result<String, DomainError>;
}
/// Instance-wide domain blocklist administration. Presentation calls this from
/// the admin API and the admin HTML pages; nothing outside presentation does.
#[async_trait]
pub trait InstanceBlocklistPort: Send + Sync {
async fn get_blocked_domains(&self) -> Result<Vec<BlockedDomainInfo>, DomainError>;
@@ -36,8 +32,6 @@ pub trait InstanceBlocklistPort: Send + Sync {
async fn remove_blocked_domain(&self, domain: &str) -> Result<(), DomainError>;
}
/// Pulling remote content in, and pushing local content out, after a follow
/// is established. Worker-side only — no HTTP handler calls this.
#[async_trait]
pub trait ApBackfillPort: Send + Sync {
async fn get_following(

View File

@@ -4,7 +4,6 @@ use crate::{errors::DomainError, value_objects::PosterUrl};
#[async_trait]
pub trait ObjectStorage: Send + Sync {
/// Stores `image_bytes` at `key` and returns the stored key.
async fn store(&self, key: &str, image_bytes: &[u8]) -> Result<String, DomainError>;
async fn get(&self, key: &str) -> Result<Vec<u8>, DomainError>;
async fn get_stream(

View File

@@ -0,0 +1,12 @@
use async_trait::async_trait;
use crate::{errors::DomainError, models::insights::WatchRow, value_objects::UserId};
/// Read side of Insights (ADR-0010): fetches one denormalized `WatchRow` per
/// review across the user's whole diary. All thresholds and baseline
/// comparisons live in the pure `insights_analyzer`, never in adapters —
/// SQL's job here is "rows in, rows out" and nothing else.
#[async_trait]
pub trait InsightsQuery: Send + Sync {
async fn get_watch_rows(&self, user_id: &UserId) -> Result<Vec<WatchRow>, DomainError>;
}

View File

@@ -12,7 +12,6 @@ pub trait MediaServerParser: Send + Sync {
-> Result<Option<ParsedPlaybackEvent>, DomainError>;
}
/// Write port — mutates watch events.
#[async_trait]
pub trait WatchEventCommand: Send + Sync {
async fn save(&self, event: &WatchEvent) -> Result<(), DomainError>;
@@ -32,7 +31,6 @@ pub trait WatchEventCommand: Send + Sync {
) -> Result<u64, DomainError>;
}
/// Read port — queries watch events. No mutations.
#[async_trait]
pub trait WatchEventQuery: Send + Sync {
async fn list_pending(&self, user_id: &UserId) -> Result<Vec<WatchEvent>, DomainError>;

View File

@@ -8,6 +8,7 @@ pub mod goals;
pub mod image_fetcher;
pub mod images;
pub mod import;
pub mod insights;
pub mod jobs;
pub mod media_server;
pub mod movie;
@@ -29,6 +30,7 @@ pub use goals::*;
pub use image_fetcher::*;
pub use images::*;
pub use import::*;
pub use insights::*;
pub use jobs::*;
pub use media_server::*;
pub use movie::*;

View File

@@ -9,14 +9,12 @@ use crate::{
value_objects::{ExternalMetadataId, MovieId, MovieTitle, PosterUrl, ReleaseYear},
};
/// Write port — mutates the movies table.
#[async_trait]
pub trait MovieCommand: Send + Sync {
async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError>;
async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError>;
}
/// Read port — queries movies. No mutations.
#[async_trait]
pub trait MovieQuery: Send + Sync {
async fn get_movie_by_external_id(
@@ -42,7 +40,6 @@ pub trait MovieQuery: Send + Sync {
page: &PageParams,
filter: &MovieFilter,
) -> Result<Paginated<MovieSummary>, DomainError>;
/// Returns all movies that have an external_metadata_id set. Used for deduplication.
async fn list_movies_with_external_id(&self) -> Result<Vec<Movie>, DomainError>;
}
@@ -62,14 +59,11 @@ pub trait MetadataClient: Send + Sync {
pub trait MovieProfileRepository: Send + Sync {
async fn upsert(&self, profile: &MovieProfile) -> Result<(), DomainError>;
async fn get_by_movie_id(&self, id: &MovieId) -> Result<Option<MovieProfile>, DomainError>;
/// Returns (movie_id, external_metadata_id) for movies with no profile or a stale one
/// (enriched_at older than 30 days).
async fn list_stale(&self) -> Result<Vec<(MovieId, String)>, DomainError>;
}
#[async_trait]
pub trait MovieEnrichmentClient: Send + Sync {
/// Resolves an external ID (TMDb or IMDb) and fetches the full movie profile.
async fn fetch_profile(
&self,
movie_id: MovieId,
@@ -79,9 +73,6 @@ pub trait MovieEnrichmentClient: Send + Sync {
#[async_trait]
pub trait MovieDeduplicator: Send + Sync {
/// Atomically re-points all foreign keys (reviews, watchlist entries, movie profiles)
/// from `old_id` to `canonical_id`, upserts the canonical movie record, then deletes
/// the old duplicate. Returns the number of rows re-pointed across all tables.
async fn merge_into_canonical(
&self,
old_id: &MovieId,

View File

@@ -7,7 +7,6 @@ use crate::{
// ── NoopRemoteWatchlistRepository ─────────────────────────────────────────────
/// Stub used when federation is disabled — every operation is a no-op.
pub struct NoopRemoteWatchlistRepository;
#[async_trait]
@@ -113,7 +112,6 @@ impl super::BlockQuery for NoopSocialQuery {
// ── NoopFederationAdminQuery ─────────────────────────────────────────────────
/// Stub used when federation is disabled — returns empty results.
pub struct NoopFederationAdminQuery;
#[async_trait]
@@ -127,7 +125,6 @@ impl super::FederationAdminQuery for NoopFederationAdminQuery {
// ── NoopApDocument ───────────────────────────────────────────────────────────
/// Stub used when federation is disabled — every operation is a no-op.
pub struct NoopApDocument;
#[async_trait]
@@ -153,7 +150,6 @@ impl super::ApDocumentPort for NoopApDocument {
// ── NoopInstanceBlocklist ────────────────────────────────────────────────────
/// Stub used when federation is disabled — every operation is a no-op.
pub struct NoopInstanceBlocklist;
#[async_trait]
@@ -173,7 +169,6 @@ impl super::InstanceBlocklistPort for NoopInstanceBlocklist {
// ── NoopApBackfill ───────────────────────────────────────────────────────────
/// Stub used when federation is disabled — every operation is a no-op.
pub struct NoopApBackfill;
#[async_trait]

View File

@@ -10,13 +10,9 @@ pub trait PersonEnrichmentClient: Send + Sync {
async fn fetch_details(&self, external_id: &str) -> Result<PersonEnrichmentData, DomainError>;
}
/// Write port — mutates the persons table. No reads.
#[async_trait]
pub trait PersonCommand: Send + Sync {
/// Upsert a batch of persons. Uses INSERT OR REPLACE (SQLite) / ON CONFLICT DO UPDATE (Postgres).
async fn upsert_batch(&self, persons: &[Person]) -> Result<(), DomainError>;
/// Insert a batch of missing persons from movie_cast/movie_crew into the persons table.
/// Returns (inserted_count, has_more).
async fn backfill_from_credits_batch(
&self,
batch_size: u32,
@@ -28,7 +24,6 @@ pub trait PersonCommand: Send + Sync {
) -> Result<(), DomainError>;
}
/// Read port — queries persons and credits. No mutations.
#[async_trait]
pub trait PersonQuery: Send + Sync {
async fn get_by_id(&self, id: &PersonId) -> Result<Option<Person>, DomainError>;
@@ -36,10 +31,7 @@ pub trait PersonQuery: Send + Sync {
&self,
id: &ExternalPersonId,
) -> Result<Option<Person>, DomainError>;
/// Returns the person's full cast and crew credit history across all indexed movies.
async fn get_credits(&self, id: &PersonId) -> Result<PersonCredits, DomainError>;
/// Returns persons who have no remaining entries in movie_cast or movie_crew.
/// Called after movie deletion to find index entries that can be pruned.
async fn list_orphaned_persons(&self) -> Result<Vec<PersonId>, DomainError>;
async fn list_page(&self, limit: u32, offset: u32) -> Result<Vec<Person>, DomainError>;
}

View File

@@ -5,17 +5,13 @@ use crate::{
models::{EntityType, IndexableDocument, SearchQuery, SearchResults},
};
/// Read port — executes search queries. No mutations.
#[async_trait]
pub trait SearchPort: Send + Sync {
async fn search(&self, query: &SearchQuery) -> Result<SearchResults, DomainError>;
}
/// Write port — manages the search index. No reads.
#[async_trait]
pub trait SearchCommand: Send + Sync {
/// Add or replace a document in the search index.
async fn index(&self, doc: IndexableDocument) -> Result<(), DomainError>;
/// Remove a document from the search index by entity type and internal ID string.
async fn remove(&self, entity_type: EntityType, id: &str) -> Result<(), DomainError>;
}

View File

@@ -127,35 +127,11 @@ pub trait LocalApContentQuery: Send + Sync {
) -> Result<Vec<DiaryEntry>, DomainError>;
}
/// Resolves a `FollowTarget` (a handle or an already-known identity) to the
/// `SocialIdentity` that should be dispatched on — local vs. remote.
///
/// Split out from `SocialCommand::follow` because the decision must happen
/// once, before dispatch, and both `LocalSocialService` and
/// `CompositeSocialAdapter` need to observe its result rather than each
/// re-deriving it (which would need `UserRepository` on the composite and
/// duplicate the local/remote fallthrough logic).
#[async_trait]
pub trait FollowTargetResolver: Send + Sync {
async fn resolve_target(&self, target: &FollowTarget) -> Result<SocialIdentity, DomainError>;
}
/// Performs the local follow write for a target whose `SocialIdentity` a caller
/// has *already* resolved via `FollowTargetResolver::resolve_target`.
///
/// Exists so `CompositeSocialAdapter` — which must call `resolve_target` first
/// to decide local vs. remote dispatch — doesn't then hand the raw
/// `FollowTarget` to `SocialCommand::follow` and pay for a second resolution of
/// the same handle. That second resolution wasn't just wasteful: on a
/// federation-ON deployment it could also change the answer (the target user
/// deleted between the two calls, e.g.), turning a local follow into a bogus
/// "federation is not enabled" error.
///
/// `LocalSocialService::follow` is `resolve_target_identity` then
/// `follow_resolved` — the self-follow guard and the two-sided write live only
/// in the latter, so a caller that already has the identity (the composite)
/// and one that doesn't (federation-off `SocialCommand::follow`, wired
/// directly) both end up running the exact same write path.
#[async_trait]
pub trait ResolvedFollow: Send + Sync {
async fn follow_resolved(
@@ -165,15 +141,6 @@ pub trait ResolvedFollow: Send + Sync {
) -> Result<(), DomainError>;
}
/// The subset of social behavior that needs no ActivityPub — everything a
/// single-instance deployment can do with only its own database.
///
/// A marker supertrait with a blanket impl, so any type implementing all five
/// parts is usable as `Arc<dyn LocalSocial>` without a separate registration.
/// Same shape as `k_ap::FollowRepository` over its five follow traits.
///
/// `CompositeSocialAdapter` takes one of these for its local branches, which is
/// what lets the `activitypub` crate stay unaware of `application`.
pub trait LocalSocial:
SocialCommand + FollowGraphQuery + BlockQuery + FollowTargetResolver + ResolvedFollow
{
@@ -333,8 +300,6 @@ mod local_social_tests {
}
}
/// The blanket impl must make any type implementing all five usable as
/// `Arc<dyn LocalSocial>`, and `LocalSocial` must stay object-safe.
#[test]
fn blanket_impl_yields_a_trait_object() {
let local: Arc<dyn LocalSocial> = Arc::new(Stub);

View File

@@ -11,13 +11,10 @@ use crate::{
#[async_trait]
pub trait WatchlistRepository: Send + Sync {
/// Add a new entry. Silently succeeds if the entry already exists.
async fn add(&self, entry: &WatchlistEntry) -> Result<(), DomainError>;
/// Remove an entry. Returns NotFound if the entry does not exist.
async fn remove(&self, user_id: &UserId, movie_id: &MovieId) -> Result<(), DomainError>;
/// Remove an entry if it exists. Never returns NotFound.
async fn remove_if_present(
&self,
user_id: &UserId,

View File

@@ -0,0 +1,343 @@
//! Conservative insight extraction over a user's whole diary (ADR-0010).
//!
//! Every threshold lives here, not in adapters, so the honesty rules are
//! unit-testable without a database. An insight that cannot clear its
//! thresholds is silently absent — an empty page is the correct rendering of
//! a small or noisy diary, not a bug to fix by lowering thresholds.
use std::collections::HashMap;
use chrono::{Datelike, Timelike, Weekday};
use crate::models::{Insight, WatchRow};
// ── Honesty thresholds ───────────────────────────────────────────────────────
/// First-time ratings a genre needs before its average means anything.
const MIN_GENRE_RATINGS: usize = 5;
/// First-time ratings overall before the user's own average is a stable baseline.
const MIN_BASELINE_RATINGS: usize = 10;
/// Minimum distance from the user's average before a rating lift is worth saying.
const MIN_RATING_DELTA: f64 = 0.3;
/// Rows a genre needs before month-of-year distribution is more than noise.
const MIN_GENRE_SEASON_ROWS: usize = 15;
/// Minimum lift over the uniform 1/12 month baseline.
const MIN_SEASON_MULTIPLIER: f64 = 1.5;
/// Rows a medium needs before its weekend share means anything.
const MIN_MEDIUM_ROWS: usize = 5;
/// Minimum lift over the user's own weekend share.
const MIN_MEDIUM_MULTIPLIER: f64 = 1.5;
/// More than this share of rows without a medium → the medium-timing category
/// would describe half a diary; skip it entirely.
const MAX_MISSING_MEDIUM_SHARE: f64 = 0.3;
/// Real-timestamped rows needed to say anything about rhythm.
const MIN_RHYTHM_ROWS: usize = 10;
/// More than this share of rows at exactly 00:00 → timestamps are import noise
/// (CSV imports carry no time); skip rhythm entirely.
const MAX_MIDNIGHT_SHARE: f64 = 0.5;
/// Insights shown per category — more than this and the page is a wall.
const MAX_PER_CATEGORY: usize = 3;
/// Width of the peak-hour window in the rhythm insight.
const RHYTHM_WINDOW_HOURS: u32 = 3;
pub fn build_insights(rows: &[WatchRow]) -> Vec<Insight> {
let mut insights = Vec::new();
insights.extend(rating_lifts(rows));
insights.extend(seasonalities(rows));
insights.extend(medium_timings(rows));
insights.extend(rhythm(rows));
insights
}
// ── Rating lift ──────────────────────────────────────────────────────────────
fn rating_lifts(rows: &[WatchRow]) -> Vec<Insight> {
let first_time = first_time_rows(rows);
if first_time.len() < MIN_BASELINE_RATINGS {
return vec![];
}
let user_avg = avg_rating(&first_time);
let mut by_genre: HashMap<&str, Vec<u8>> = HashMap::new();
for row in first_time {
for genre in &row.genres {
by_genre.entry(genre).or_default().push(row.rating);
}
}
let mut candidates: Vec<Insight> = by_genre
.into_iter()
.filter(|(_, ratings)| ratings.len() >= MIN_GENRE_RATINGS)
.filter_map(|(genre, ratings)| {
let genre_avg =
ratings.iter().map(|&r| f64::from(r)).sum::<f64>() / ratings.len() as f64;
let delta = round2(genre_avg - user_avg);
if delta.abs() < MIN_RATING_DELTA {
return None;
}
Some((
strength(delta.abs(), ratings.len()),
genre,
genre_avg,
delta,
ratings.len(),
))
})
.map(|(_, genre, genre_avg, delta, n)| Insight::RatingLift {
genre: genre.to_string(),
genre_avg: round2(genre_avg),
user_avg: round2(user_avg),
delta,
sample_count: n as u32,
})
.collect();
candidates.sort_by(strongest_first);
candidates.truncate(MAX_PER_CATEGORY);
candidates
}
/// Strongest first; ties broken by label ascending so output is deterministic.
fn strongest_first(a: &Insight, b: &Insight) -> std::cmp::Ordering {
strength_of(b)
.partial_cmp(&strength_of(a))
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| label_of(a).cmp(label_of(b)))
}
/// The earliest review of each movie; later reviews of the same movie are
/// rewatches (the same definition `ReviewHistory` uses).
fn first_time_rows(rows: &[WatchRow]) -> Vec<&WatchRow> {
let mut seen: HashMap<uuid::Uuid, &WatchRow> = HashMap::new();
for row in rows {
match seen.get(&row.movie_id) {
Some(existing) if existing.watched_at <= row.watched_at => {}
_ => {
seen.insert(row.movie_id, row);
}
}
}
let mut first: Vec<&WatchRow> = seen.into_values().collect();
first.sort_by_key(|r| r.watched_at);
first
}
fn avg_rating(rows: &[&WatchRow]) -> f64 {
rows.iter().map(|r| f64::from(r.rating)).sum::<f64>() / rows.len() as f64
}
// ── Seasonality ──────────────────────────────────────────────────────────────
fn seasonalities(rows: &[WatchRow]) -> Vec<Insight> {
let mut by_genre: HashMap<&str, Vec<u32>> = HashMap::new();
for row in rows {
for genre in &row.genres {
by_genre
.entry(genre)
.or_default()
.push(row.watched_at.month());
}
}
let mut candidates: Vec<Insight> = by_genre
.into_iter()
.filter(|(_, months)| months.len() >= MIN_GENRE_SEASON_ROWS)
.filter_map(|(genre, months)| {
let total = months.len() as f64;
let mut histogram = [0u32; 12];
for &m in &months {
histogram[(m - 1) as usize] += 1;
}
// Smallest month number wins ties — deterministic output.
let (peak_month, peak_count) = histogram
.into_iter()
.enumerate()
.max_by_key(|(i, c)| (*c, std::cmp::Reverse(*i)))
.map(|(i, c)| (i as u32 + 1, c))?;
// Baseline is a uniform year: each month expects total/12 watches.
let multiplier = round2(f64::from(peak_count) * 12.0 / total);
if multiplier < MIN_SEASON_MULTIPLIER {
return None;
}
Some((
strength(multiplier - 1.0, months.len()),
genre,
peak_month,
multiplier,
months.len(),
))
})
.map(|(_, genre, month, multiplier, n)| Insight::Seasonality {
genre: genre.to_string(),
month,
multiplier,
sample_count: n as u32,
})
.collect();
candidates.sort_by(strongest_first);
candidates.truncate(MAX_PER_CATEGORY);
candidates
}
// ── Medium timing ────────────────────────────────────────────────────────────
fn medium_timings(rows: &[WatchRow]) -> Vec<Insight> {
if rows.is_empty() {
return vec![];
}
let with_medium_count = rows.iter().filter(|r| r.watch_medium.is_some()).count();
let missing_share = 1.0 - with_medium_count as f64 / rows.len() as f64;
if missing_share > MAX_MISSING_MEDIUM_SHARE {
return vec![];
}
// (total watches, weekend watches) per medium.
let mut by_medium: HashMap<&str, (usize, usize)> = HashMap::new();
let mut weekend_total = 0usize;
for row in rows.iter().filter(|r| r.watch_medium.is_some()) {
let weekend = matches!(row.watched_at.weekday(), Weekday::Sat | Weekday::Sun);
weekend_total += usize::from(weekend);
let entry = by_medium
.entry(row.watch_medium.as_deref().expect("filtered above"))
.or_insert((0, 0));
entry.0 += 1;
entry.1 += usize::from(weekend);
}
let baseline_share = weekend_total as f64 / with_medium_count as f64;
// No weekend watches at all → no baseline to lift against.
if baseline_share == 0.0 {
return vec![];
}
let mut candidates: Vec<Insight> = by_medium
.into_iter()
.filter(|(_, (count, _))| *count >= MIN_MEDIUM_ROWS)
.filter_map(|(medium, (count, weekend))| {
let weekend_share = weekend as f64 / count as f64;
let multiplier = round2(weekend_share / baseline_share);
if multiplier < MIN_MEDIUM_MULTIPLIER {
return None;
}
Some((
strength(multiplier - 1.0, count),
medium,
weekend_share,
multiplier,
count,
))
})
.map(
|(_, medium, weekend_share, multiplier, n)| Insight::MediumTiming {
medium: medium.to_string(),
multiplier,
weekend_share: round2(weekend_share),
baseline_share: round2(baseline_share),
sample_count: n as u32,
},
)
.collect();
candidates.sort_by(strongest_first);
candidates.truncate(MAX_PER_CATEGORY);
candidates
}
// ── Rhythm ───────────────────────────────────────────────────────────────────
fn rhythm(rows: &[WatchRow]) -> Vec<Insight> {
let real: Vec<&WatchRow> = rows
.iter()
.filter(|r| !(r.watched_at.hour() == 0 && r.watched_at.minute() == 0))
.collect();
let midnight_share = 1.0 - real.len() as f64 / rows.len().max(1) as f64;
if rows.is_empty() || real.len() < MIN_RHYTHM_ROWS || midnight_share > MAX_MIDNIGHT_SHARE {
return vec![];
}
let mut hours = [0u32; 24];
let mut days = [0u32; 7]; // index 0 = Monday (chrono weekday number − 1)
for row in &real {
hours[row.watched_at.hour() as usize] += 1;
days[row.watched_at.weekday().number_from_monday() as usize - 1] += 1;
}
// Circular 3-hour window with the most watches; smallest start hour wins ties.
let mut best_start = 0usize;
let mut best_count = 0u32;
for start in 0..24 {
let count: u32 = (0..RHYTHM_WINDOW_HOURS)
.map(|o| hours[(start + o as usize) % 24])
.sum();
if count > best_count {
best_count = count;
best_start = start;
}
}
// Days carrying at least 60% of the busiest day's load, strongest first,
// capped at three — "mostly Tue–Thu" without the tail of noise.
let max_day = days.iter().copied().max().unwrap_or(0);
let mut top_days: Vec<(u32, u32)> = days
.into_iter()
.enumerate()
.filter(|(_, c)| *c > 0 && f64::from(*c) >= f64::from(max_day) * 0.6)
.map(|(i, c)| (i as u32 + 1, c))
.collect();
// Count descending, then day number ascending — deterministic.
top_days.sort_unstable_by_key(|&(day, count)| (std::cmp::Reverse(count), day));
let top_days: Vec<u32> = top_days.into_iter().map(|(day, _)| day).take(3).collect();
vec![Insight::Rhythm {
peak_hour_start: best_start as u32,
peak_hour_end: (best_start as u32 + RHYTHM_WINDOW_HOURS) % 24,
top_days,
sample_count: real.len() as u32,
}]
}
// ── Shared helpers ───────────────────────────────────────────────────────────
/// Strength = effect size scaled by evidence volume. A 1.6× lift on 60 rows
/// outranks 1.7× on 3; a raw lift would not.
fn strength(effect: f64, sample: usize) -> f64 {
effect * (1.0 + (sample as f64).ln())
}
fn strength_of(insight: &Insight) -> f64 {
match insight {
Insight::RatingLift {
delta,
sample_count,
..
} => strength(delta.abs(), *sample_count as usize),
Insight::Seasonality {
multiplier,
sample_count,
..
}
| Insight::MediumTiming {
multiplier,
sample_count,
..
} => strength(multiplier - 1.0, *sample_count as usize),
Insight::Rhythm { .. } => 0.0,
}
}
fn label_of(insight: &Insight) -> &str {
match insight {
Insight::RatingLift { genre, .. } | Insight::Seasonality { genre, .. } => genre,
Insight::MediumTiming { medium, .. } => medium,
Insight::Rhythm { .. } => "",
}
}
fn round2(v: f64) -> f64 {
(v * 100.0).round() / 100.0
}
#[cfg(test)]
#[path = "tests/insights_analyzer.rs"]
mod tests;

View File

@@ -1,2 +1,3 @@
pub mod insights_analyzer;
pub mod review_history;
pub mod wrapup_analyzer;

View File

@@ -0,0 +1,365 @@
use chrono::{Datelike, NaiveDate, Weekday};
use uuid::Uuid;
use crate::models::{Insight, WatchRow};
use super::super::insights_analyzer::build_insights;
fn watch(genres: &[&str], rating: u8, date: NaiveDate, hour: u32) -> WatchRow {
WatchRow {
movie_id: Uuid::new_v4(),
rating,
watched_at: date.and_hms_opt(hour, 30, 0).unwrap(),
watch_medium: Some("streaming".to_string()),
genres: genres.iter().map(|g| g.to_string()).collect(),
}
}
fn date(year: i32, month: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(year, month, day).unwrap()
}
fn kinds(insights: &[Insight]) -> Vec<&str> {
insights
.iter()
.map(|i| match i {
Insight::RatingLift { .. } => "rating_lift",
Insight::Seasonality { .. } => "seasonality",
Insight::MediumTiming { .. } => "medium_timing",
Insight::Rhythm { .. } => "rhythm",
})
.collect()
}
// ── Rating lift ──────────────────────────────────────────────────────────────
#[test]
fn rating_lift_compares_genre_to_personal_average() {
// 12 thrillers rated 5, 12 comedies rated 2 — user average 3.5.
let mut rows: Vec<WatchRow> = (0..12)
.map(|i| watch(&["Thriller"], 5, date(2024, 1, 1 + i), 20))
.collect();
rows.extend((0..12).map(|i| watch(&["Comedy"], 2, date(2024, 3, 1 + i), 20)));
let insights = build_insights(&rows);
let thrill = insights.iter().find_map(|i| match i {
Insight::RatingLift {
genre,
delta,
sample_count,
..
} if genre == "Thriller" => Some((*delta, *sample_count)),
_ => None,
});
let (delta, count) = thrill.expect("Thriller lift expected");
assert!((delta - 1.5).abs() < 1e-9, "delta was {delta}");
assert_eq!(count, 12);
let comedy = insights.iter().find_map(|i| match i {
Insight::RatingLift { genre, delta, .. } if genre == "Comedy" => Some(*delta),
_ => None,
});
assert!((comedy.expect("Comedy lift expected") + 1.5).abs() < 1e-9);
}
#[test]
fn rating_lift_ignores_rewatches() {
// 10 first-time dramas rated 3 (baseline), 5 thrillers rated 5…
let mut rows: Vec<WatchRow> = (0..10)
.map(|i| watch(&["Drama"], 3, date(2024, 1, 1 + i), 20))
.collect();
let mut thriller_ids: Vec<uuid::Uuid> = Vec::new();
for i in 0..5 {
let row = watch(&["Thriller"], 5, date(2024, 2, 1 + i), 20);
thriller_ids.push(row.movie_id);
rows.push(row);
}
// …and 3 rewatches of those same thrillers rated 1, which must not count.
for (i, id) in thriller_ids.iter().enumerate() {
let mut rewatch = watch(&["Thriller"], 1, date(2024, 6, 1 + i as u32), 20);
rewatch.movie_id = *id;
rows.push(rewatch);
}
let insights = build_insights(&rows);
let thrill = insights.iter().find_map(|i| match i {
Insight::RatingLift {
genre,
delta,
sample_count,
..
} if genre == "Thriller" => Some((*delta, *sample_count)),
_ => None,
});
let (delta, count) = thrill.expect("Thriller lift expected");
// 15 first-time rows: user_avg = (10*3 + 5*5)/15 = 55/15 ≈ 3.67,
// genre_avg = 5.0, delta ≈ 1.33.
assert!((delta - 1.33).abs() < 0.01, "delta was {delta}");
assert_eq!(count, 5, "rewatches must not join the genre sample");
}
#[test]
fn rating_lift_below_sample_threshold_is_absent() {
let mut rows: Vec<WatchRow> = (0..10)
.map(|i| watch(&["Drama"], 3, date(2024, 1, 1 + i), 20))
.collect();
// Only 4 thriller ratings — below MIN_GENRE_RATINGS, so no matter how
// high they are rated, the genre must not yield an insight.
rows.extend((0..4).map(|i| watch(&["Thriller"], 5, date(2024, 2, 1 + i), 20)));
let insights = build_insights(&rows);
assert!(
!insights
.iter()
.any(|i| matches!(i, Insight::RatingLift { genre, .. } if genre == "Thriller")),
"a genre with 4 ratings must not yield a lift"
);
}
#[test]
fn rating_lift_below_delta_threshold_is_absent() {
// 5 thrillers rated 4; dramas mix to put the personal average at 3.85,
// so the thriller delta is 0.15 < 0.3.
let mut rows: Vec<WatchRow> = (0..12)
.map(|i| watch(&["Drama"], 4, date(2024, 1, 1 + i % 28), 20))
.collect();
rows.extend((0..3).map(|i| watch(&["Drama"], 3, date(2024, 1, 13 + i), 20)));
rows.extend((0..5).map(|i| watch(&["Thriller"], 4, date(2024, 2, 1 + i), 20)));
let insights = build_insights(&rows);
assert!(!kinds(&insights).contains(&"rating_lift"));
}
// ── Seasonality ──────────────────────────────────────────────────────────────
#[test]
fn seasonality_detects_month_peak_against_uniform_baseline() {
// 16 horrors: 10 in October across two years, 6 scattered.
let mut rows: Vec<WatchRow> = Vec::new();
for i in 0..10u32 {
let year = if i < 5 { 2023 } else { 2024 };
rows.push(watch(&["Horror"], 4, date(year, 10, 1 + i % 27), 21));
}
for (m, d) in [(1u32, 3u32), (2, 7), (4, 11), (6, 15), (8, 19), (12, 21)] {
rows.push(watch(&["Horror"], 4, date(2024, m, d), 21));
}
// A perfectly spread genre (2 watches per month) must NOT appear.
rows.extend((0..24).map(|i| watch(&["Drama"], 3, date(2024, 1 + i % 12, 5), 21)));
let insights = build_insights(&rows);
let horror = insights.iter().find_map(|i| match i {
Insight::Seasonality {
genre,
month,
multiplier,
sample_count,
..
} if genre == "Horror" => Some((*month, *multiplier, *sample_count)),
_ => None,
});
let (month, multiplier, count) = horror.expect("Horror seasonality expected");
assert_eq!(month, 10);
// 10 of 16 watches in one month → (10/16)·12 = 7.5× the uniform baseline.
assert!(
(multiplier - 7.5).abs() < 1e-9,
"multiplier was {multiplier}"
);
assert_eq!(count, 16);
assert!(
!insights
.iter()
.any(|i| matches!(i, Insight::Seasonality { genre, .. } if genre == "Drama")),
"a spread-out genre must not yield a seasonality insight"
);
}
#[test]
fn seasonality_below_sample_threshold_is_absent() {
let rows: Vec<WatchRow> = (0..14)
.map(|i| watch(&["Horror"], 4, date(2024, 10, 1 + i), 21))
.collect();
assert!(!kinds(&build_insights(&rows)).contains(&"seasonality"));
}
// ── Medium timing ────────────────────────────────────────────────────────────
#[test]
fn medium_timing_detects_weekend_lift() {
let saturday = date(2024, 3, 9); // a Saturday
assert_eq!(saturday.weekday(), Weekday::Sat);
let friday = date(2024, 3, 8);
let mut rows: Vec<WatchRow> = Vec::new();
// 6 cinema watches, 5 of them on Saturdays.
let cinema_days = [
saturday,
saturday.with_day(16).unwrap(),
saturday.with_day(23).unwrap(),
saturday.with_day(30).unwrap(),
date(2024, 4, 6), // next month's first Saturday
friday,
];
for day in cinema_days {
let mut row = watch(&["Drama"], 4, day, 19);
row.watch_medium = Some("cinema".to_string());
rows.push(row);
}
// 10 streaming watches on weekdays only (two Mon–Fri weeks) to keep the
// baseline weekend share driven by cinema alone.
for i in 0..10u32 {
let mut row = watch(&["Drama"], 4, friday, 21);
row.watch_medium = Some("streaming".to_string());
row.watched_at = date(2024, 3, 4 + i % 5 + 7 * (i / 5))
.and_hms_opt(21, 0, 0)
.unwrap();
rows.push(row);
}
let insights = build_insights(&rows);
let cinema = insights.iter().find_map(|i| match i {
Insight::MediumTiming {
medium,
multiplier,
sample_count,
..
} if medium == "cinema" => Some((*multiplier, *sample_count)),
_ => None,
});
let (multiplier, count) = cinema.expect("cinema timing expected");
// Baseline weekend share = 5/16; cinema share = 5/6 → lift = (5/6)/(5/16) ≈ 2.67.
assert!(
(multiplier - 2.67).abs() < 0.01,
"multiplier was {multiplier}"
);
assert_eq!(count, 6);
}
#[test]
fn medium_timing_skipped_when_medium_mostly_missing() {
let mut rows: Vec<WatchRow> = Vec::new();
let first_saturday = date(2024, 3, 9);
for i in 0..6u32 {
let mut row = watch(
&["Drama"],
4,
first_saturday + chrono::Duration::weeks(i64::from(i)),
20,
);
row.watch_medium = Some("cinema".to_string());
rows.push(row);
}
for i in 0..10u32 {
let mut row = watch(&["Drama"], 4, date(2024, 3, 1 + i), 20);
row.watch_medium = None; // imports without a medium
rows.push(row);
}
assert!(!kinds(&build_insights(&rows)).contains(&"medium_timing"));
}
// ── Rhythm ───────────────────────────────────────────────────────────────────
#[test]
fn rhythm_finds_peak_window_and_days() {
// 12 watches at 22:30: five Tuesdays, four Wednesdays, three Thursdays.
let first_tuesday = date(2024, 3, 5);
assert_eq!(first_tuesday.weekday(), Weekday::Tue);
let mut rows: Vec<WatchRow> = Vec::new();
for day in 0..3u32 {
let count = 5 - day; // Tue ×5, Wed ×4, Thu ×3
for week in 0..count {
rows.push(watch(
&["Drama"],
4,
first_tuesday
+ chrono::Duration::days(i64::from(day))
+ chrono::Duration::weeks(i64::from(week)),
22,
));
}
}
let insights = build_insights(&rows);
let rhythm = insights.iter().find_map(|i| match i {
Insight::Rhythm {
peak_hour_start,
peak_hour_end,
top_days,
sample_count,
} => Some((
*peak_hour_start,
*peak_hour_end,
top_days.clone(),
*sample_count,
)),
_ => None,
});
let (start, end, days, count) = rhythm.expect("rhythm expected");
// All watches sit at hour 22; the earliest 3-hour window that covers
// them starts at 20 (20, 21, 22).
assert_eq!(start, 20);
assert_eq!(end, 23);
// Every day carries ≥60% of the busiest day (5): Tue(5) Wed(4) Thu(3).
assert_eq!(days, vec![2, 3, 4], "Tue=2, Wed=3, Thu=4");
assert_eq!(count, 12);
}
#[test]
fn rhythm_skipped_when_timestamps_are_import_noise() {
let mut rows: Vec<WatchRow> = Vec::new();
for i in 0..12u32 {
// Exactly midnight — the signature of a CSV import with no time data.
let row = WatchRow {
movie_id: Uuid::new_v4(),
rating: 4,
watched_at: date(2024, 1, 1 + i).and_hms_opt(0, 0, 0).unwrap(),
watch_medium: None,
genres: vec!["Drama".to_string()],
};
rows.push(row);
}
assert!(!kinds(&build_insights(&rows)).contains(&"rhythm"));
}
// ── Overall ──────────────────────────────────────────────────────────────────
#[test]
fn empty_diary_yields_nothing() {
assert!(build_insights(&[]).is_empty());
}
#[test]
fn small_diary_yields_nothing() {
let rows: Vec<WatchRow> = (0..4)
.map(|i| watch(&["Drama"], 4, date(2024, 1, 1 + i), 20))
.collect();
assert!(build_insights(&rows).is_empty());
}
#[test]
fn max_three_insights_per_category() {
// Six genres, all with a strong rating lift — only the three strongest survive.
let mut rows: Vec<WatchRow> = Vec::new();
let genres = [
("Thriller", 5u8),
("Horror", 5),
("Sci-Fi", 5),
("Comedy", 5),
("Romance", 5),
("Documentary", 5),
];
for (genre, rating) in genres {
rows.extend((0..5).map(|i| watch(&[genre], rating, date(2024, 1, 1 + i), 20)));
}
// Baseline dramas rated 1 make every lift large.
rows.extend((0..10).map(|i| watch(&["Drama"], 1, date(2024, 3, 1 + i), 20)));
let insights = build_insights(&rows);
let rating_lifts = insights
.iter()
.filter(|i| matches!(i, Insight::RatingLift { .. }))
.count();
assert_eq!(rating_lifts, 3);
}

View File

@@ -103,9 +103,6 @@ impl FakeDiaryQuery {
.insert(movie.id().value(), (movie, reviews));
}
/// Configures what `query_diary` returns. Without this, `query_diary` keeps
/// its original always-empty behavior — this is purely opt-in for tests that
/// need to prove entries actually flow through a use case.
pub fn set_diary_page(&self, page: Paginated<DiaryEntry>) {
*self.diary_page.lock().unwrap() = Some(page);
}

View File

@@ -347,9 +347,6 @@ impl WatchlistRepository for InMemoryWatchlistRepository {
// ── InMemoryRemoteWatchlistRepository ─────────────────────────────────────────
/// Unlike `NoopRemoteWatchlistRepository`, this actually stores what it's given —
/// needed by tests that must prove a federated-watchlist read returns real seeded
/// data, not just an empty default that happens to match by coincidence.
pub struct InMemoryRemoteWatchlistRepository {
store: Mutex<Vec<RemoteWatchlistEntry>>,
}
@@ -405,10 +402,6 @@ impl RemoteWatchlistRepository for InMemoryRemoteWatchlistRepository {
Ok(())
}
/// Real adapters derive `uuid` from the actor URL via a SQL-side hash — not
/// domain logic, so this fake doesn't replicate it. It simply returns
/// everything seeded, which is all callers in this workspace need: tests seed
/// exactly the entries for the owner under test.
async fn get_by_derived_uuid(
&self,
_uuid: Uuid,

View File

@@ -0,0 +1,52 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use crate::{
errors::DomainError, models::insights::WatchRow, ports::InsightsQuery, value_objects::UserId,
};
// ── PanicInsightsQuery ──────────────────────────────────────────────────────
pub struct PanicInsightsQuery;
#[async_trait]
impl InsightsQuery for PanicInsightsQuery {
async fn get_watch_rows(&self, _: &UserId) -> Result<Vec<WatchRow>, DomainError> {
panic!("PanicInsightsQuery called")
}
}
// ── InMemoryInsightsQuery ───────────────────────────────────────────────────
pub struct InMemoryInsightsQuery {
pub rows_by_user: Mutex<HashMap<uuid::Uuid, Vec<WatchRow>>>,
}
impl InMemoryInsightsQuery {
pub fn new() -> Arc<Self> {
Arc::new(Self {
rows_by_user: Mutex::new(HashMap::new()),
})
}
pub fn with_rows(user_id: uuid::Uuid, rows: Vec<WatchRow>) -> Arc<Self> {
let query = Self::new();
query.rows_by_user.lock().unwrap().insert(user_id, rows);
query
}
}
#[async_trait]
impl InsightsQuery for InMemoryInsightsQuery {
async fn get_watch_rows(&self, user_id: &UserId) -> Result<Vec<WatchRow>, DomainError> {
Ok(self
.rows_by_user
.lock()
.unwrap()
.get(&user_id.value())
.cloned()
.unwrap_or_default())
}
}

View File

@@ -1,11 +1,13 @@
mod fakes;
mod in_memory;
mod insights;
mod noops;
mod panics;
mod wrapup;
pub use fakes::*;
pub use in_memory::*;
pub use insights::*;
pub use noops::*;
pub use panics::*;
pub use wrapup::*;

View File

@@ -22,9 +22,6 @@ macro_rules! uuid_id {
uuid_id!(MovieId);
impl MovieId {
/// Derives a stable, deterministic UUID from an external metadata ID (e.g. `tmdb:12345`).
/// All instances that know a movie by the same external ID will produce the same MovieId,
/// enabling remote and local reviews to be linked to the same movie record.
pub fn from_external(external_id: &crate::value_objects::ExternalMetadataId) -> Self {
Self(Uuid::new_v5(
&Uuid::NAMESPACE_URL,
@@ -45,8 +42,6 @@ uuid_id!(GoalId);
uuid_id!(PersonId);
impl PersonId {
/// Deterministic UUIDv5 from an external person ID string.
/// "tmdb:12345" always maps to the same PersonId.
pub fn from_external(external_id: &crate::models::person::ExternalPersonId) -> Self {
Self(Uuid::new_v5(
&Uuid::NAMESPACE_URL,

View File

@@ -1,14 +1,11 @@
use super::{SocialIdentity, UserId};
/// This instance's own identity on the network. Owns every URL and handle
/// derivation that used to be spelled with a bare `base_url: &str`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct InstanceIdentity {
base_url: String,
}
impl InstanceIdentity {
/// Normalizes away a trailing slash so every derived URL has exactly one.
pub fn new(base_url: impl Into<String>) -> Self {
Self {
base_url: base_url.into().trim_end_matches('/').to_string(),
@@ -19,7 +16,6 @@ impl InstanceIdentity {
&self.base_url
}
/// Host and port, as it appears in a fediverse handle.
pub fn host(&self) -> &str {
self.base_url
.split("://")
@@ -47,7 +43,6 @@ impl InstanceIdentity {
}
}
/// Inverse of `actor_url_of`: decides whether an actor URL is ours.
pub fn identify(&self, actor_url: &str) -> SocialIdentity {
let prefix = format!("{}/users/", self.base_url);
if let Some(uuid_str) = actor_url.strip_prefix(&prefix)

View File

@@ -37,8 +37,6 @@ pub struct SocialActor {
pub avatar_url: Option<String>,
}
/// Both directions of the follow edge between a viewer and a target.
/// `None` means no edge at all; `Some(Pending)` means requested but not accepted.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub struct FollowRelation {
pub following: Option<FollowStatus>,

View File

@@ -25,8 +25,6 @@ impl Username {
const MIN_LENGTH: usize = 2;
const MAX_LENGTH: usize = 30;
/// Accepts 2–30 chars: lowercase letters, digits, underscores, hyphens.
/// Lowercases input automatically.
pub fn new(raw: String) -> Result<Self, DomainError> {
let s = raw.trim().to_lowercase();
if s.len() < Self::MIN_LENGTH || s.len() > Self::MAX_LENGTH {