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)
16 KiB
k-mood
A personal mood tracking journal. Users log how they feel throughout the day, tag entries with activities, and review trends over time. Multi-user, self-hosted, single server with multiple clients.
Language
Core
MoodEntry: A single mood record — the aggregate root. Carries exactly one Mood, the instant it was logged, and nothing else. Belongs to exactly one User. Everything else attaches as EntryDimensions. Multiple MoodEntries per Date are allowed. Avoid: Log, journal entry, record, mood log
EntryDimension: An optional aspect of a MoodEntry, stored independently of it — Content, Activities, photos, voice memos, weather, location, song. Each kind is self-contained: it owns its own type, its own storage, and its own validation, and knows nothing about the others. A MoodEntry is complete without any of them. Avoid: Facet, attribute, extra, metadata, attachment
Mood: One of five discrete states representing how the user feels, mapped to a 1–5 ordinal scale: Awful (1), Bad (2), Meh (3), Good (4), Rad (5). The ordering is a domain truth — Rad is better than Good. Required on every MoodEntry. Avoid: Feeling, emotion, state, score
Content: Optional markdown text attached to a MoodEntry. Serves both quick annotations ("went for a walk") and longer journal-style writing. No domain-level length limit — the application layer enforces configurable maximums. Avoid: Note, quick note, description, journal entry
Activity: A named item from a user's personal catalog, tagged onto MoodEntries. Covers heterogeneous concepts (social contexts like "friends", health actions like "exercise", sleep indicators like "good sleep") under one umbrella term. Each Activity belongs to exactly one User. Activities are archivable — an archived Activity stays on every entry already wearing it, and those entries stay editable, but it cannot be added to an entry that does not already carry it. Unarchiving makes it taggable again. Avoid: Tag, label, habit, tracker
Category: A display-only grouping label stored as an optional string on an Activity (e.g., "social", "health", "sleep"). The domain stores it but never interprets it — no domain logic references categories. Exists purely for UI organization across multiple clients. Avoid: Group, section, type
People
User: A registered account identified by username and email. Has a role (Admin or User) and a Timezone. Owns their own Activity catalog, MoodEntries, and Reminders. Avoid: Account, member, profile
Timezone: The IANA zone a User lives their days in. It is what turns an instant into a Date, so anything day-shaped — the calendar, a streak, a DailyMetric — is unanswerable without it. Clients set it from the platform; a User who somehow has none is told to set one rather than being given a silently wrong answer. Avoid: TZ, offset, locale, region
Scheduling
Reminder:
A per-user notification schedule. Each Reminder defines an Option<Time> for each day of the week — Some(20:00) means remind at 8 PM, None means skip that day. Can be enabled or disabled. A User can have multiple Reminders (e.g., one for morning check-in, one for evening). Remembers the last DueOccurrence it sent, so a schedule is answered once however often it is asked. The domain defines when to remind; clients decide the message and delivery mechanism.
Avoid: Notification, alert, alarm, push
DueOccurrence: One scheduled firing of one Reminder, identified by the instant its wall clock time resolves to in the User's Timezone. What a Reminder records as sent — not the moment of sending, so asking again about the same occurrence answers no. Reached only once its instant has passed and while it is still inside a configured grace, so a worker that starts late does not deliver a morning that is over. Avoid: Trigger, firing, due time, schedule slot
Measurement
DailyMetric: A single measured or self-reported value for one User on one Date — a MetricKind plus its value. Not an aggregate; uniqueness of (User, Date, MetricKind) is a structural guarantee, not an invariant. Independent of MoodEntry: a Date may carry DailyMetrics with no MoodEntries and vice versa. Avoid: Health data, daily record, day log, stat, data point
Source: Where a DailyMetric's value came from — either Manual, meaning the User stated it, or a Provider, meaning something else did. A DailyMetric sourced Manual is never overwritten by an import; a manual write always supersedes a Provider's. Avoid: Origin, author, integration
Provider: An external system that supplies data. Identified only by name — the domain knows a Provider exists and what it claimed, never how it is reached, authenticated, or parsed. Which Providers exist is a deployment concern, not a domain one. Avoid: Integration, service, source, connector
ApiToken: A named, revocable, non-expiring credential a User mints for a client that cannot hold a session. Grants the TokenScopes it was minted with and nothing else, and never the account itself — no token can change a password, mint another token, or restore a backup. Its name is the Provider its metric writes are attributed to, so two tokens of one account cannot share a name. The value is shown once at mint time and held only as a digest. Avoid: API key, secret, personal access token, integration key
TokenScope: One named thing an ApiToken may do — read the journal, write the journal, write DailyMetrics, read the profile. Named after what a client does, not after the routes it calls, so a route's requirement is a statement about intent. A token holds a non-empty set of them; a name this build does not know is refused rather than ignored, because a typo must never mint a narrower token than the one asked for. Avoid: Permission, grant, role, capability
ProviderConnection: A User's standing authorization to a Provider, held as an opaque credential the domain never inspects. Each User supplies their own, and holds at most one per Provider — connecting again replaces it. The domain knows only that a User is or is not connected to a named Provider. Avoid: Account link, integration, credentials, connection
MetricKind: The closed set of things a DailyMetric can measure: steps, sleep minutes, awake minutes, resting heart rate, HRV, exercise minutes, screen time minutes, alcoholic drinks. Each MetricKind owns its unit and its valid range — the unit is never carried alongside the value. Membership is earned: a MetricKind must have real day-to-day variance, be measured well enough to trust, and not restate one already present. Extending the set is the only way to support a new measurement. Avoid: Metric type, field, measurement name
Date: The calendar day a DailyMetric belongs to, resolved by converting an instant into the User's Timezone. The single day boundary in the system — MoodEntries group onto the same Date for calendar and analytics. Avoid: Day, logged date, local date
EntrySelection: What a client is asking to read, as one value: the account, and optionally a range of instants, a Mood, an Activity, and an instant to return only what changed after. Combining narrowings is the point — every filter is the same kind of thing, so a page of one mood within one month is a selection rather than an endpoint nobody wrote. Counting and reading use the same selection, so a total can never describe a different question from the page beneath it. Avoid: Filter, criteria, search, query params
Page: One window onto an EntrySelection, with the total for the whole selection beside it. The total is what makes paging honest: without it a client can only fetch until a short page arrives and hope. A page larger than the server serves cannot be constructed, so an unbounded read is not something a client can ask for by accident. Avoid: Batch, chunk, result set, slice
DateSpan: A run of consecutive Dates, inclusive of both ends. What day-keyed data is read by, and distinct from the range of instants that MoodEntries are deleted by — a span of days has no time of day, so nothing about it depends on an offset. Avoid: Date range, period, window, interval
RejectedMetric: A reading that could not be used, kept where the account holder can read it — what arrived, from which Provider, on which Date, and why it was refused. Receives both readings that arrive broken from an importer and stored rows a later build can no longer read. Nothing in it was stored as a DailyMetric; it is a record of absence, not of data. Only the most recent are kept. Avoid: Error log, failed import, invalid metric, audit trail
CompleteBackup: Everything an account knows, written so that nothing is lost if it has to be restored — every MoodEntry with every EntryDimension, every DailyMetric, every CycleStart, the Activity catalogue, Reminders, UserPreferences and all media. Restores through its own path, never through the importer that reads foreign formats. Carries no credentials and no RejectedMetrics. Avoid: Export, dump, archive, snapshot
ShareableExtract: A readable document of mood, Content and Activities, for handing to another person. Carries nothing else — no Location, no Song, no DailyMetric, no CycleStart, no media — and cannot be restored from. Distinct from a CompleteBackup by intent, not by configuration: a single artifact with options would produce files that look like backups and are not. Avoid: Partial export, filtered export, share link
Ambient
Weather: The conditions at the place and instant a MoodEntry was logged — a Condition and a temperature in celsius — expressed in one canonical vocabulary so that any two MoodEntries' Weather are comparable. Always attributed to a Provider, never to the User: Weather is observed, not stated, so a User editing an entry cannot remove it and absence from an edit is not a request to delete it. Resolved after the entry is saved, never during it. Avoid: Forecast, conditions, temperature
Condition: The closed vocabulary Weather is expressed in — clear, cloudy, fog, drizzle, rain, snow, thunderstorm. The domain's words, not a Provider's: every Provider's own codes are mapped onto these at the boundary, because correlating across inconsistent labels means nothing. Deliberately coarse; a Provider distinguishing light from heavy rain reports rain. Avoid: Weather code, description, icon, summary
Location: The coordinates a MoodEntry was logged at. Supplied by the client, optional, and the prerequisite for Weather. Avoid: GPS, position, place, geo
MediaRef: A photo or voice memo named by its kind together with its id. What ownership of a blob is recorded against, so one record serves both kinds and two objects sharing a uuid across kinds stay distinct. The blob store itself knows nothing of Users; who may delete a photo is a fact held beside it, never inferred from whichever MoodEntry happens to wear it. Avoid: Attachment id, file, asset, blob key
CycleStart: A Date on which a User's menstrual period began. Recorded once per cycle, never per day. Cycle day for any Date is derived from the most recent preceding CycleStart and is never stored — correcting a CycleStart therefore corrects every Date that depends on it. Recorded only while the User has cycle tracking on; turning it off hides every derived cycle day without forgetting what was recorded. Avoid: Period, cycle day, menstrual day
CyclePosition: Where a Date sits in its cycle — the cycle day, counting from one, and the progress through the cycle from 0 at its start to 1 at its end. Both are derived. Progress is measured against the next CycleStart where one exists and against the median observed length where the cycle is still running, so a late cycle sits at its end rather than beyond it. A Date more than ninety days after the last CycleStart has no position: that is a missed record, not a long cycle. Avoid: Cycle phase, day number, cycle stage
UserPreferences: The optional features a User has turned on. Separate from User, which carries identity and credentials — a preference is not a fact about who someone is. Absent until something is turned on, and every preference is off when absent. Avoid: Settings, options, config, flags
Song: What a User was listening to when a MoodEntry was logged, held as its own title, artist, and album rather than as a pointer elsewhere — a MoodEntry stays readable with nothing else reachable. Carries an external recording identity when one is known, which is what makes two MoodEntries about the same recording comparable. Captured at the moment of logging only; a MoodEntry never acquires a Song later. Avoid: Track, music, now playing, recording
MoonPhase: The lunar phase on a given Date. Derived from the Date on read and never stored — it is a function of the calendar, not an observation, so there is nothing to persist and no value that can go stale. Avoid: Lunar phase, moon
Background work
Job: A record that some background work is wanted, carrying its kind, the thing it is about, its status, how many times it has been attempted and why the last attempt failed. Never the source of truth: no Job may be enqueued unless a query over stored data can independently rediscover the same work, so losing one costs promptness and never data. A Job that has used every attempt stops being retried and stays visible instead of vanishing. Avoid: Task, message, event, queue item
Sweep: The query that rediscovers work from stored data — "songs with no recording identity" — and enqueues whatever it finds. What makes the queue safe to lose. Every Job kind owes one, and work that cannot be expressed as such a query does not belong on the queue. Avoid: Scan, reconciler, cron, catch-up
Analysis
DayMood: The mean of every MoodEntry mood on one Date. The single mood value used wherever a Date needs one — analysis and calendar alike. A Date with no MoodEntries has no DayMood. Avoid: Dominant mood, average mood, daily mood, overall mood
CorrelationInput: Anything a CorrelationStrategy can score against DayMood — every MetricKind, every Activity, plus MoonPhase, which is not a MetricKind because it is never stored. Each input's series has one shape: a MetricKind and MoonPhase are continuous, an Activity is presence, and a Strategy scores one shape, so an incompatible pairing cannot be constructed. Extending the set is deliberate: each input added costs an expected false positive across the whole result set. Avoid: Variable, factor, feature, predictor
Agreement: How many of the Strategies that can score an input point the same way, out of how many apply. The headline for a result, in place of any single coefficient. An Activity has one applicable Strategy, so its agreement is one of one — which is a statement about the measurement, not a claim of corroboration. Avoid: Consensus, confidence, score, robustness
CorrelationStrategy: A named method for scoring the relationship between a MetricKind and DayMood — Pearson, Spearman, Kendall, mean difference. Several are computed over the same data and every one returns the same shape, so they are directly comparable. Agreement among Strategies is the signal; no single Strategy's value is authoritative. Avoid: Algorithm, correlation method, analyzer, engine
Adjustment: A correction applied across a whole set of CorrelationStrategy results to account for how many were tested at once. Operates on a set, never on a single result, and is therefore never itself a CorrelationStrategy. It marks results; it never hides them. Benjamini-Hochberg, controlling false discovery rate, at a configured threshold. A marked result held up once the number of comparisons in its Family was accounted for — a second axis of robustness beside Agreement, never a verdict. Avoid: Correction, p-value adjustment, filter, significance
Family: The set of results one Adjustment is computed over. A Family is a question, not a screen: MetricKinds form one, Activities another, because how many Activities a User keeps has nothing to do with whether their sleep tracks their mood. Adjustment is computed per Family within a single CorrelationStrategy. Avoid: Group, test set, batch, comparison set