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

License

MIT