Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- Add sev upgrade as an alias for sev update
- Add research doc cataloging ways past the macOS interrupted-shutdown dialog
- Add research doc on Do Not Disturb control
- Reset the daemon at midnight as a fresh day, so overtime is a single-day opt-out and the next day's shutdown is enforced again

## [0.17.0] -- 2026-06-04

Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ mode. Instead of shutting down at T-0, Severance fires a notification
every 5 seconds for 60 seconds, then stops. It trusts your judgment after
that.

Overtime is a single-day opt-out. If you leave the machine on past
midnight, the daemon resets at midnight as though it just started —
back to severance mode, waiting to enforce the next day's shutdown.

## Requirements

- macOS (uses `osascript` for notifications and shutdown)
Expand Down Expand Up @@ -257,9 +261,8 @@ Small, well-understood changes go straight to code. For anything larger:
- Per-day shutdown schedules (e.g. earlier on Fridays)

## TODO
- [x] Add sev upgrade as an alias for sev update
- [x] Add research doc cataloging ways past the macOS interrupted-shutdown dialog
- [x] Add research doc on Do Not Disturb control
- It would be nice to give it a script or add a function to the config that is run at the do not disturb intervals
- I'd like to give it access to my calendar so that I can Guarantee that it won't cause users to miss meetings
- [ ] When I use overtime protocol and forget to turn my computer off until th e next day, severance should reset at midnight as though it was just started
- [x] Reset the daemon at midnight as a fresh day, so overtime is a single-day opt-out and the next day's shutdown is enforced again
72 changes: 70 additions & 2 deletions lib/severance/countdown.ex
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,13 @@ defmodule Severance.Countdown do
@type t :: %__MODULE__{
shutdown_time: Time.t() | nil,
mode: :severance | :overtime,
phase: :waiting | :gentle | :aggressive | :final | :shutdown | :done
phase: :waiting | :gentle | :aggressive | :final | :shutdown | :done,
day: Date.t() | nil
}

defstruct [
:shutdown_time,
:day,
mode: :severance,
phase: :waiting
]
Expand Down Expand Up @@ -112,12 +114,55 @@ defmodule Severance.Countdown do
Time.compare(now, shutdown_time) != :lt
end

@doc """
Returns milliseconds from the given moment until the next local midnight.

## Examples

iex> Severance.Countdown.ms_until_midnight(~N[2026-04-09 23:00:00])
3600000

"""
@spec ms_until_midnight(NaiveDateTime.t()) :: non_neg_integer()
def ms_until_midnight(now) do
next_midnight =
now
|> NaiveDateTime.to_date()
|> Date.add(1)
|> NaiveDateTime.new!(~T[00:00:00])

NaiveDateTime.diff(next_midnight, now, :millisecond)
Comment thread
KTSCode marked this conversation as resolved.
end

@doc """
Decides the next state for a midnight-reset tick at `current_day`.

Overtime is a single-day opt-out, so once the local date advances past the
session's day the daemon starts the new day clean: back to severance mode,
waiting for the configured shutdown time. The reset timer fires on monotonic
time while the interval to midnight is wall-clock, so a DST transition can
fire it before the wall clock crosses midnight; in that case the date has
not advanced and the session is left untouched.

Returns `{:reset, fresh_state}` on a day rollover, or `{:wait, state}` when
the date has not advanced yet.
"""
@spec reset_state(t(), Date.t()) :: {:reset, t()} | {:wait, t()}
def reset_state(%__MODULE__{day: day} = state, current_day) do
if Date.after?(current_day, day) do
{:reset, %__MODULE__{shutdown_time: state.shutdown_time, day: current_day}}
else
{:wait, state}
end
end

# --- GenServer Callbacks ---

@impl true
def init({shutdown_time, mode}) do
state = %__MODULE__{shutdown_time: shutdown_time, mode: mode}
state = %__MODULE__{shutdown_time: shutdown_time, mode: mode, day: today()}
schedule_countdown_start(state)
schedule_midnight_reset()
{:ok, state}
end

Expand Down Expand Up @@ -232,6 +277,21 @@ defmodule Severance.Countdown do
{:noreply, state}
end

@impl true
def handle_info(:midnight_reset, state) do
case reset_state(state, today()) do
{:reset, fresh} ->
Logger.info("Midnight reset — starting a fresh day.")
schedule_countdown_start(fresh)
schedule_midnight_reset()
{:noreply, fresh}

{:wait, state} ->
schedule_midnight_reset()
{:noreply, state}
end
end

@impl true
def handle_info(_msg, state) do
{:noreply, state}
Expand Down Expand Up @@ -269,6 +329,10 @@ defmodule Severance.Countdown do
Process.send_after(self(), :tick, tick_interval_ms(phase))
end

defp schedule_midnight_reset do
Process.send_after(self(), :midnight_reset, ms_until_midnight(local_now()))
end

defp tick do
send(self(), :tick)
:ok
Expand Down Expand Up @@ -321,6 +385,10 @@ defmodule Severance.Countdown do
end
end

defp today do
NaiveDateTime.to_date(local_now())
end

defp normal_shutdown?(:normal), do: true
defp normal_shutdown?(:shutdown), do: true
defp normal_shutdown?({:shutdown, _}), do: true
Expand Down
60 changes: 60 additions & 0 deletions test/severance/countdown_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ defmodule Severance.CountdownTest do

alias Severance.Countdown

doctest Countdown

@frozen_now ~N[2026-04-09 10:00:00]

setup do
Expand Down Expand Up @@ -271,6 +273,64 @@ defmodule Severance.CountdownTest do
end
end

describe "reset_state/2" do
test "rolls over to a fresh waiting state when the day advances" do
state = %Countdown{
shutdown_time: ~T[17:00:00],
mode: :overtime,
phase: :done,
day: ~D[2026-04-09]
}

assert {:reset, fresh} = Countdown.reset_state(state, ~D[2026-04-10])
assert fresh.mode == :severance
assert fresh.phase == :waiting
assert fresh.shutdown_time == ~T[17:00:00]
assert fresh.day == ~D[2026-04-10]
end

test "leaves the session untouched when the date has not advanced" do
# A DST-driven early fire: the timer elapsed but the wall clock has not
# crossed midnight, so the session must be left untouched.
state = %Countdown{
shutdown_time: ~T[17:00:00],
mode: :overtime,
phase: :done,
day: ~D[2026-04-09]
}

assert {:wait, ^state} = Countdown.reset_state(state, ~D[2026-04-09])
end
end

describe "midnight reset" do
test "reschedules the countdown lifecycle on a day rollover" do
capture_log(fn ->
# Shutdown 20 minutes out puts T-30 in the past, so a fresh start
# transitions straight into the countdown. A stale session day makes
# the frozen "today" a rollover.
pid = start_supervised!({Countdown, shutdown_time: ~T[10:20:00], mode: :overtime})
Process.sleep(50)
:sys.replace_state(pid, fn state -> %{state | phase: :done, day: ~D[2026-04-08]} end)

send(pid, :midnight_reset)
Process.sleep(50)

state = :sys.get_state(pid)
assert state.phase == :gentle
assert state.mode == :severance
assert state.day == ~D[2026-04-09]
end)
end
end

describe "ms_until_midnight/1" do
test "returns milliseconds until the next local midnight" do
assert Countdown.ms_until_midnight(~N[2026-04-09 23:00:00]) == 3_600_000
assert Countdown.ms_until_midnight(~N[2026-04-09 00:00:00]) == 86_400_000
end
end

describe "weekend detection" do
test "weekend?/1 returns true for Saturday and Sunday" do
# 2026-03-28 is a Saturday
Expand Down
Loading