Stint
Tick-based activity session tracking for Elixir. 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 forward,
seconds accumulates. A longer silence means the next tick opens a new
stint. That inference is the 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 someone can forget
to call and becomes an emergent fact of the data.
Each stint carries second-resolution started_at / ended_at — so your
data can distinguish one two-hour binge from six three-minute peeks, not
just "35 minutes somewhere in hour 15".
Install
def deps do
[
{:stint, "~> 0.1"}
]
end
# config/config.exs
config :stint, repo: MyApp.Repo
# a migration (versioned, Oban-style — future stint releases with
# schema changes just need a fresh migration calling up/0 again)
defmodule MyApp.Repo.Migrations.AddStint do
use Ecto.Migration
def up, do: Stint.Migration.up()
def down, do: Stint.Migration.down()
end
API
# record — extends or opens automatically; first tick is back-dated by
# its own duration so it doesn't lose itself
{:ok, stint, :extended | :started} =
Stint.track(owner, item, seconds,
gap: 300, # silence that splits stints (s)
at: DateTime.utc_now(), # tick timestamp
meta: %{"chapter" => "153"} # shallow-merged, last write wins
)
# query
Stint.on_date(owner, ~D[2026-07-14], utc_offset: 7200) # local-date sessions
Stint.count(owner, item) # "read in 14 stints"
Stint.counts(owner) # %{item => count}
Stint.total_seconds(owner) # or (owner, item)
Stint.last(owner) # most recent stint, or (owner, item)
# display helper: the slice of a midnight-crossing stint that belongs
# to one local date (for day/timeline views)
Stint.clamp_to_date(stint, ~D[2026-07-14], utc_offset: 7200)
#=> {started_at, ended_at} | nil
owner_id and item are opaque strings — a user UUID, a device id,
"manga:source/slug", "habit:guitar". The library never interprets
them.
Semantics worth knowing
- Precision is bounded by your tick cadence: a stint's start is its first tick back-dated by that tick's elapsed; its end is the last tick received. Tick every ~30s and stints are accurate to seconds.
- Out-of-order ticks never move
ended_atbackwards (their seconds still count). - Different items never stitch — a stint belongs to one owner and one item. Reading two series in one evening = (at least) two stints.
- Midnight-crossing stints appear in
on_date/3for both local dates they touch;clamp_to_date/3slices them for display. - Requires PostgreSQL (jsonb
meta;postgrexis your dependency).
License
MIT