# Hackatime architecture guide This is a map of current ownership and invariants, not a proposed design. Follow the linked source when behavior and this summary disagree. ## Sources of truth and derived state | Domain | Source of truth | Derived / disposable state | | --- | --- | --- | | Identity | `users`, `email_addresses`, provider IDs and encrypted provider tokens | Session cookie | | Coding activity | Non-deleted `heartbeats` | Durations, dashboard/profile payloads, rollups, leaderboards, streaks, Rails caches | | Heartbeat project identity | `heartbeats.project` | Project lists and project statistics | | Per-user project settings | `project_repo_mappings` keyed by project name | Discovery retry/coalescing cache keys | | Repository identity | Shared `repositories` row (`url`, host, owner, name) | Stars, languages, homepage, commit count, sync timestamps, imported commits | | Async work | Pending/scheduled `good_jobs` rows and their serialized arguments | Finished execution history, process records, cron history and enqueue cache keys, subject to retention policy | Do not write a rollup, duration, profile statistic, or cache as though it were primary data. Change its input or the derivation, invalidate it, and let it be rebuilt. ## 1. Rails request and Inertia/Svelte UI flow 1. Routes dispatch to Rails controllers. Browser controllers inherit [`ApplicationController`](app/controllers/application_controller.rb), which supplies session identity, lockout enforcement, no-store headers, error reporting, PaperTrail attribution, and the user's time zone. 2. Inertia controllers inherit [`InertiaController`](app/controllers/inertia_controller.rb). It shares layout props (navigation, flash, theme, CSRF token, footer and impersonation state) on every Inertia response. Controllers select a page with `render inertia: "Directory/Page", props: ...`; server props are the request's serialized boundary, not a second model layer. 3. [`inertia.ts`](app/javascript/entrypoints/inertia.ts) resolves that name to `app/javascript/pages/Directory/Page.svelte` and wraps pages in `AppLayout.svelte`. Inertia ``, `
` and `router` calls make later visits while Rails still owns routing, authorization, validation and writes. 4. Svelte pages own presentation and truly local/editable UI state. Keep domain calculations and authorization on the server. Shared props belong in `InertiaController`; page-specific props belong in the rendering controller. Client route strings come from **js_from_routes**, not controller props or hand-built URLs. Mark the route with `export: true` in [`config/routes.rb`](config/routes.rb), regenerate, and import the controller module from `app/javascript/api`. Call `.path()` with path/query parameters. The generated directory is gitignored. Only marked routes are exported; it intentionally avoids exposing every Rails route. Keep server-built URLs only when the client lacks required information (for example, request host), or for external links. API-only controllers may inherit `ActionController::API`; they are not part of the Inertia boundary. ## 2. Identity and authorization boundaries ### Browser identity [`ApplicationController#current_user`](app/controllers/application_controller.rb) is exactly `User.find_by(id: session[:user_id])`. HCA, Slack, and single-use email-link sign-in converge on a `User`; successful login resets the session before assigning that ID. Slack and GitHub callback state is consumed and compared with `secure_compare`; HCA currently does not use an OAuth state nonce. Continuation URLs must be local paths (not `//...`). See [`SessionsController`](app/controllers/sessions_controller.rb). [`EmailAddress`](app/models/email_address.rb) owns normalized, globally unique login addresses and their provenance. Provider/preserved addresses cannot be unlinked, and a user cannot remove their last address. Provider IDs and encrypted HCA/Slack/GitHub access tokens live on [`User`](app/models/user.rb); provider concerns own token exchange and remote profile synchronization. ### API identities * [`ApiKey`](app/models/api_key.rb) is a user credential (UUIDv4 for WakaTime compatibility). The Hackatime-compatible controller accepts Bearer, Basic, or legacy `api_key` query input, resolves the key's user, then calls the ingestion service. It skips CSRF because it is token-authenticated; pending deletion blocks writes. See [`HackatimeController`](app/controllers/api/hackatime/v1/hackatime_controller.rb). * Doorkeeper is a separate delegated-user boundary. Its configured scopes are `profile` (default), `read`, and `admin`; validate token acceptability and required scopes, then load the resource owner. Ordinary OAuth/API access is denied for convicted or pending-deletion users via `api_access_restricted?`. Controllers that support ordinary user credentials declare their accepted API-key sources and OAuth scopes through [`UserApiAuthentication`](app/controllers/concerns/user_api_authentication.rb). See [`doorkeeper.rb`](config/initializers/doorkeeper.rb). * Admin API credentials are either active `AdminApiKey`s or acceptable Doorkeeper `admin` tokens. OAuth admin access additionally requires a confidential, verified, admin-scoped application. The API boundary is [`Api::Admin::ApplicationController`](app/controllers/api/admin/application_controller.rb). ### Admin authorization Use [`AuthHelpers`](app/controllers/concerns/auth_helpers.rb) and explicit controller/model predicates; hiding a nav link is not authorization. `viewer` can enter read-only admin surfaces and use admin API authentication, but general browser admin writes require `admin`, `superadmin`, or `ultraadmin`. The enum's stored numeric order is historical and **not privilege order**. Use `User::ADMIN_LEVEL_RANK` and helpers. Effective order is `default < viewer < admin < superadmin < ultraadmin`. Role/trust changes prohibit self-action and require the actor to strictly outrank the target; only superadmin+ changes admin levels, only ultraadmin grants ultraadmin, and a red trust conviction requires superadmin+. ## 3. Heartbeat ingestion and duration semantics Non-deleted [`Heartbeat`](app/models/heartbeat.rb) rows are authoritative activity. All direct and imported writes should flow through [`HeartbeatIngest`](app/services/heartbeat_ingest.rb), which owns: * accepted input normalization, sane epoch validation/repair, null/control cleanup, default categories, language and user-agent inference, source type, request metadata, and WakaTime placeholder handling; * model validation before bulk insertion (bulk insertion deliberately bypasses callbacks), plus explicit callback-equivalent fields; * deduplication and race-safe persistence; and * scheduling rollup refresh and best-effort project mapping only after inserts. `fields_hash` is the persisted identity of a normalized heartbeat and includes the user, time and activity metadata listed by `Heartbeat.indexed_attributes` (plus present AI attributes). Direct batches collapse equal hashes; `insert_all ... unique_by` lets the database settle cross-request races, then ingestion fetches the winning row. Import batches keep the latest row per hash and also check legacy hashes so normalization changes do not duplicate old imports. Uniqueness is the partial `fields_hash` index over non-deleted rows. Soft deletion is implemented by `deleted_at`; the model's default scope hides those rows. Use `soft_delete` / `restore`, which also invalidate rollups. [`HeartbeatExclusion`](app/models/heartbeat_exclusion.rb) rules (admin poisons, project deletions) hide heartbeats without deleting them. A second default scope applies them to every model read. Raw SQL must include `HeartbeatExclusion::VISIBLE_SQL`; code that intentionally needs hidden rows uses `Heartbeat.with_excluded` or embeds `HeartbeatExclusion::INCLUDE_HIDDEN_COMMENT`. The test suite raises on heartbeat SQL that does neither, including writes that would silently skip hidden rows. Per-user caches of heartbeat-derived data must include `User#heartbeat_cache_version` in their keys so rule changes bust them. Duration is not stored. [`Heartbeatable`](app/models/concerns/heartbeatable.rb) derives it from ordered heartbeat timestamps. The default timeout is 2 minutes: * the first heartbeat contributes zero; * each later heartbeat contributes `min(current_time - previous_time, 120s)`; * grouped duration partitions by the requested group, while `attributed_durations_by` computes globally ordered gaps and attributes each gap to the current heartbeat's bucket; * `to_span` splits when a gap exceeds the timeout and caps the prior span's tail at the timeout; and * boundary-aware calculations include the preceding heartbeat so a requested window does not incorrectly lose its opening interval. Preserve deterministic ordering by `time, id`, timestamp validity filters, and the timeout cap when adding reports. Eligibility scopes additionally distinguish coding, browser activity and the `<>` sentinel. ## 4. Dashboard/profile rollups and caches [`DashboardStats`](app/services/dashboard_stats.rb) is the read facade. An unfiltered all-time dashboard can use `dashboard_rollups`; filtered/custom time ranges query heartbeats. A missing aggregate total falls back to live calculation and schedules a refresh. A dirty or stale aggregate total is served while refresh is scheduled. Invalid activity-graph/today fragments and malformed filter options fall back to live calculation and schedule refresh. Short Rails caches (currently 1/5/15 minutes depending on fragment) are also disposable. [`DashboardRollupRefreshService`](app/services/dashboard_rollup_refresh_service.rb) rebuilds totals, dimensions, weekly projects, project details, filter options, activity graph and today's stats from the user's non-archived heartbeats. It atomically replaces all of one user's rows in a transaction. The refresh job marks the user dirty before enqueue, coalesces scheduling with a cache key, and uses a per-user GoodJob concurrency limit. Heartbeat commits, soft-delete/ restore, timezone changes, and project archive changes schedule refreshes. [`ProfileStatsService`](app/services/profile_stats_service.rb) is a thin projection of `DashboardStats`, including OG-image totals. It has no independent authoritative statistic. Change shared duration/snapshot logic below both dashboard and profile rather than patching profile output independently. ## 5. Projects, repositories and repo hosts [`ProjectRepoMapping`](app/models/project_repo_mapping.rb) owns a user's repository association, archive state and sharing state keyed by a heartbeat project name. The heartbeat remains authoritative for the project identity and a mapping may not exist. Archiving affects dashboard scope and invalidates rollups. Ingestion asynchronously attempts mapping for new non-sentinel project names; discovery currently searches the linked GitHub user and organizations. [`Repository`](app/models/repository.rb) is shared by URL and owns parsed host/owner/name plus synchronized host metadata. Mapping callbacks create/reuse it and trigger metadata/commit work. A mapping is user-specific; a repository is not. Do not put user preferences on `Repository` or shared host metadata on the mapping. External repository calls belong behind [`RepoHost::ServiceFactory`](app/services/repo_host/service_factory.rb) and [`BaseService`](app/services/repo_host/base_service.rb). Only GitHub is supported today; [`GithubService`](app/services/repo_host/github_service.rb) owns GitHub headers, existence checks, metadata requests and rate-limit/error translation. Extending hosts requires factory/host validation and a service implementation, plus updating jobs that currently contain GitHub-specific discovery logic. Several periodic repository scan/sync cron entries are currently disabled; do not assume they run. Every GitHub REST request goes through `RepoHost::GithubService.get`. It logs a `[GitHubAPI]` line with the endpoint template, status and remaining allowance, but never the token or repository name. After a primary or secondary rate limit it raises `RepoHost::RateLimitError` and refuses to use that token again until GitHub's reset time. Secondary limits are also reported to Sentry. Background calls use the user's own token, which shares that user's 5,000 requests per hour with their other tools, so keep them bounded. Background sync is off unless the `github_sync` Flipper flag is enabled. While it is off, `GithubApiJob` jobs finish without calling GitHub and no mapping lookups are queued. Sign-in and manual mapping checks are not affected. `AttemptProjectRepoMappingJob` only maps public repositories. A repository in the user's own namespace wins. An organisation repository is only mapped when the user has commits in it and no other organisation has one that also qualifies. Any existing mapping row, including one with a blank URL, stops automatic mapping for that project, so user edits are never overwritten. The job remembers a finished lookup for 14 days, caches organisation lists for a day and spends at most 50 GitHub requests per user per day. These counters live in Solid Cache, which can evict entries early, so treat them as throttles rather than durable state. A mapping whose repository URL is set or changed queues one commit pull and one metadata sync after commit. Existing mappings are kept fresh by the hourly `ReconcileActiveRepositoriesJob`. Each run picks at most 100 repositories whose mapped project had heartbeats in the last day and that were last synced over 6 hours ago, oldest first. It queues a metadata sync for each and at most 100 commit pulls for their active coders. Each job gets its own 15 second slot. A sync that gets no metadata (missing, private or no usable token) still updates `last_synced_at`, so the same repository is not retried every hour. A GitHub 5xx raises `RepoHost::ServerError` instead: the job retries twice and then stops without writing anything, so the repository stays due. The old `SyncStaleRepoMetadataJob`, which re-queued every stale repository daily, stays disabled. GitHub data never decides leaderboard eligibility. Eligibility only needs a linked `github_uid`. A rejected token clears `github_access_token` and keeps `github_uid`, so missing or stale GitHub data cannot hide a user. Repository sync only handles public repositories. Automatic mapping skips private repositories, and metadata sync stores nothing for them. The GitHub sign-in app needs no permission or webhook changes for the current code. Its owner should check that the only repository permission is the mandatory Metadata (read) permission, and leave the webhook inactive. Do not add the Contents permission or push webhooks: GitHub only delivers push events to apps with Contents access, and that access covers every repository an installer selects, private ones included. GitHub has no public-only alternative. ## 6. GoodJob, mail and Slack All jobs inherit [`ApplicationJob`](app/jobs/application_job.rb), which provides shared error-reporting helpers and discards deserialization and concurrency-limit failures. [`good_job.rb`](config/initializers/good_job.rb) is the queue/cron source of truth: development runs async threads, non-development expects external workers, and cron is production-only. Choose a queue by latency/ownership; do not perform slow remote work in request controllers merely because development can execute jobs in-process. Action Mailer owns message composition/delivery. Production SMTP configuration and the `latency_10s` `deliver_later` queue live in [`production.rb`](config/environments/production.rb). Some jobs intentionally call `deliver_now` *inside an already-queued job*; preserve that boundary unless changing retry/queue semantics deliberately. Slack has three boundaries: OAuth/provider identity in the user concerns, signed command ingress in [`SlackController`](app/controllers/slack_controller.rb), and queued command/profile/status work. Outside development, commands require a valid Slack HMAC signature and timestamp within five minutes. Background profile and notification name lookups use [`Cachet`](lib/cachet.rb), without Slack tokens or a direct Slack API fallback. A Cachet cache miss (HTTP 202) must not overwrite stored profile data; the profile job retries before leaving it due for the next scheduled refresh. Slack OAuth still fetches identity data directly from Slack. Profile assignment and Slack status API calls live in [`SlackIntegration`](app/models/concerns/slack_integration.rb); controllers should authenticate, validate and enqueue. ## 7. Time zones, transactions and concurrency Heartbeat `time` is Unix epoch time. Calendar concepts (today, week, streak, activity dates) use the validated `User#timezone`. Browser requests run inside `Time.use_zone(current_user.timezone)`; services/jobs without that wrapper must use `Time.use_zone` explicitly. SQL day grouping converts epochs with the user timezone and falls back to UTC only where the reporting code explicitly guards invalid legacy data. A timezone change invalidates both old/new activity cache keys and rollups. Use database constraints/upserts for cross-process correctness, transactions for multi-row replacement, `after_commit` for derived work, and GoodJob concurrency plus cache coalescing for expensive idempotent refreshes. Rails cache alone is an optimization, not a lock or source of truth. In particular, keep heartbeat dedup race-safe and rollup replacement atomic. ## Where should this change go? | Change | Put it here | | --- | --- | | Parse/normalize/accept heartbeat input | `HeartbeatIngest`; controller only permits/authenticates/responds | | Change activity or gap math | `Heartbeatable` / shared snapshot query code, then verify every consumer | | Add dashboard/profile statistic | `DashboardData::Snapshots` + `DashboardStats`; add rollup dimension only if appropriate | | Change page data or validation | Rails controller/service/model; serialize minimal Inertia props | | Change page interaction/presentation | Svelte page/component; use Inertia primitives | | Add a frontend Rails URL | Rails route with `export: true` + generated helper import | | Add browser/admin/API authorization | Existing auth concern/controller boundary and model capability predicate | | Change sign-in/provider identity | `SessionsController` plus the relevant user OAuth/provider concern | | Change project archive/share/user mapping | `ProjectRepoMapping` and its controller/job | | Change shared repository metadata/API calls | `Repository` + `RepoHost` service + sync job | | Add slow, scheduled or retryable work | `ApplicationJob` subclass and GoodJob queue/cron config as needed | | Compose/send mail | Mailer; enqueue from the owning lifecycle/job | | Handle Slack command/API behavior | Verified ingress controller, Slack concern/client boundary, then job | | Change a calendar-day report | Explicit user-zone service/query; invalidate timezone-sensitive derived data |