defmodule FrozenClock do
@moduledoc """
A minimal, async-safe wrapper around `DateTime.utc_now/0` that lets tests
freeze and travel time **in the calling process**.
Replace direct calls to `DateTime.utc_now/0` with `FrozenClock.utc_now/0` in
production code. In tests, `freeze/0`, `freeze/1`, `travel/1`, and `travel/2`
pin or shift the clock for the current process only.
## Isolation
All state lives in the process dictionary under a single key, so freezing time
in one process never affects another. This makes `async: true` tests safe out
of the box.
> #### Spawned processes see real time {: .warning}
>
> Freezing affects only the calling process. Code that spawns processes
> (`Task.async/1`, sending to a `GenServer`, Phoenix channels, ...) will see
> the real system time in those children. If you need cross-process control,
> use [`klotho`](https://hex.pm/packages/klotho).
"""
@key :frozen_clock_at
@doc """
Returns the current time.
When the calling process has frozen time, returns the frozen value; otherwise
delegates to `DateTime.utc_now/0`.
## Examples
iex> FrozenClock.freeze(~U[2026-01-01 00:00:00Z])
iex> FrozenClock.utc_now()
~U[2026-01-01 00:00:00Z]
"""
@spec utc_now() :: DateTime.t()
def utc_now do
case Process.get(@key) do
nil -> DateTime.utc_now()
%DateTime{} = frozen -> frozen
end
end
@doc """
Freezes time for the calling process.
With no argument, freezes at the current real time. With a `DateTime`, freezes
at that instant.
"""
@spec freeze() :: :ok
def freeze, do: freeze(DateTime.utc_now())
@spec freeze(DateTime.t()) :: :ok
def freeze(%DateTime{} = at) do
Process.put(@key, at)
:ok
end
@doc """
Removes any freeze for the calling process, restoring real time.
Safe to call when time is not frozen.
"""
@spec unfreeze() :: :ok
def unfreeze do
Process.delete(@key)
:ok
end
@doc """
Sets the frozen time to `target`.
If the process is not already frozen, this freezes it at `target`.
"""
@spec travel(DateTime.t()) :: :ok
def travel(%DateTime{} = target) do
Process.put(@key, target)
:ok
end
@doc """
Shifts the frozen time by `amount` of `unit`.
Raises if the calling process has not frozen time first.
## Examples
iex> FrozenClock.freeze(~U[2026-01-01 00:00:00Z])
iex> FrozenClock.travel(1, :hour)
iex> FrozenClock.utc_now()
~U[2026-01-01 01:00:00Z]
"""
@spec travel(integer(), :day | :hour | :minute | System.time_unit()) :: :ok
def travel(amount, unit) when is_integer(amount) do
case Process.get(@key) do
nil ->
raise "FrozenClock is not frozen; call FrozenClock.freeze/0 before travel/2"
%DateTime{} = current ->
Process.put(@key, DateTime.add(current, amount, unit))
:ok
end
end
@doc """
Returns `true` when the calling process has frozen time.
"""
@spec frozen?() :: boolean()
def frozen? do
Process.get(@key) != nil
end
end