Tick-based activity session tracking. No start/stop calls — report elapsed time as it happens, and stints (bounded periods of activity) assemble themselves.
# on every activity ping (a reader's progress tick, a player's
# watch-time beat, a practice timer):
Stint.track(user_id, "manga:mangadex/one-piece", 30)If the owner's latest stint on that item ended within the gap window
(default 5 minutes), the tick extends it — ended_at moves to
now, seconds accumulates. A longer silence means the next tick
opens a new stint. That inference is the whole point: session
ends are unobservable in real apps (tabs close, phones lock,
processes die), while periodic ticks are easy and reliable. A
"session" stops being an API anyone can forget to call and becomes
an emergent fact of the data.
Each stint carries second-resolution started_at / ended_at, so
the data distinguishes one two-hour binge from six three-minute
peeks — not just "N minutes in hour H".
Setup
config :stint, repo: MyApp.RepoAnd a migration:
def up, do: Stint.Migration.up()
def down, do: Stint.Migration.down()Identity
owner_id and item are opaque strings — a user UUID, a device id,
"manga:source/slug", "habit:guitar" — the library never
interprets them.
Options
:gap— seconds of silence that split stints (default300, configurable app-wide viaconfig :stint, default_gap: n):at— the tick's timestamp (defaultDateTime.utc_now/0):meta— map shallow-merged into the stint'smetaon every tick (last write wins per key)
Timezone-aware day queries take :utc_offset in seconds (e.g. a
UTC+2 user is utc_offset: 7200).
Summary
Functions
Clamp a stint's interval to the owner's local date — the slice a
day view should render for a midnight-crossing stint. Returns
{started_at, ended_at} in UTC, or nil when the stint doesn't
touch the date.
Number of stints by owner on item — "read in 14 stints".
Stint counts per item for an owner: %{item => count}.
The owner's most recent stint, optionally per item.
Stints intersecting the owner's local date, oldest first. A stint
crossing midnight appears on both dates it touches — clamp for
display with clamp_to_date/3 if needed.
Total tracked seconds for an owner, optionally per item.
Record seconds of activity by owner on item.
Types
Functions
@spec clamp_to_date(Stint.Record.t(), Date.t(), keyword()) :: {DateTime.t(), DateTime.t()} | nil
Clamp a stint's interval to the owner's local date — the slice a
day view should render for a midnight-crossing stint. Returns
{started_at, ended_at} in UTC, or nil when the stint doesn't
touch the date.
@spec count(owner(), item()) :: non_neg_integer()
Number of stints by owner on item — "read in 14 stints".
@spec counts(owner()) :: %{required(item()) => non_neg_integer()}
Stint counts per item for an owner: %{item => count}.
@spec last(owner(), item() | nil) :: Stint.Record.t() | nil
The owner's most recent stint, optionally per item.
@spec on_date(owner(), Date.t(), keyword()) :: [Stint.Record.t()]
Stints intersecting the owner's local date, oldest first. A stint
crossing midnight appears on both dates it touches — clamp for
display with clamp_to_date/3 if needed.
Options: :utc_offset (seconds, default 0), :item to filter.
@spec total_seconds(owner(), item() | nil) :: non_neg_integer()
Total tracked seconds for an owner, optionally per item.
@spec track(owner(), item(), non_neg_integer(), keyword()) :: {:ok, Stint.Record.t(), :extended | :started} | {:error, term()}
Record seconds of activity by owner on item.
Extends the latest stint when its end is within the gap window of
:at; opens a new stint otherwise (with started_at back-dated by
seconds, so the first tick doesn't lose its own duration).
Returns {:ok, stint, :extended | :started}.