Skip to content
Hackatime
Esc
navigateopen⌘Jpreview
On this page

Hackatime architecture guide

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, which supplies session identity, lockout enforcement, no-store headers, error reporting, PaperTrail attribution, and the user’s time zone.
  2. Inertia controllers inherit InertiaController. 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 resolves that name to app/javascript/pages/Directory/Page.svelte and wraps pages in AppLayout.svelte. Inertia <Link>, <Form> 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. Add a named route to EXPORTED_ROUTES in js_from_routes.rb, regenerate, and import the controller module from app/javascript/api. Call .path() with path/query parameters. The generated directory is gitignored. The allowlist exports named routes and nameless siblings for an already-exported controller; 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 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.

EmailAddress 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; provider concerns own token exchange and remote profile synchronization.

API identities

  • ApiKey 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.
  • 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. See doorkeeper.rb.
  • Admin API credentials are either active AdminApiKeys or acceptable Doorkeeper admin tokens. OAuth admin access additionally requires a confidential, verified, admin-scoped application. The API boundary is Api::Admin::ApplicationController.

Admin authorization

Use AuthHelpers 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 rows are authoritative activity. All direct and imported writes should flow through HeartbeatIngest, 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. During the Timescale cutover, uniqueness may be (fields_hash) or (fields_hash, time_epoch); ingestion detects the schema, explicitly sets the partition epoch, refreshes stale schema metadata, and retries only outside an open transaction.

Soft deletion is implemented by deleted_at; the model’s default scope hides those rows. Use soft_delete / restore, which also invalidate rollups.

Duration is not stored. Heartbeatable 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 <<LAST_PROJECT>> sentinel.

4. Dashboard/profile rollups and caches

DashboardStats 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 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 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 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 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 and BaseService. Only GitHub is supported today; GithubService 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/event logic. Several periodic repository scan/sync cron entries are currently disabled; do not assume they run.

6. GoodJob, mail and Slack

All jobs inherit ApplicationJob, which provides shared error-reporting helpers and discards deserialization and concurrency-limit failures. 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. 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, and queued command/profile/status work. Outside development, commands require a valid Slack HMAC signature and timestamp within five minutes. Remote API calls, token choice and typed rate-limit behavior live in SlackIntegration; 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 + js_from_routes allowlist + 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

Was this page helpful?