Skip to content

feat(schedule): change the theme on a timer while the user is present - #22

Merged
datapointchris merged 10 commits into
mainfrom
theme-schedule
Oct 18, 2026
Merged

datapointchris merged 10 commits into
mainfrom
theme-schedule

Conversation

@datapointchris

@datapointchris datapointchris commented Oct 18, 2026 •

Copy link
Copy Markdown
Owner

The usage history cannot tell a theme someone chose to keep from one nobody looked at. Hours count every stretch between applies, idle ones included. This adds theme schedule, a timer that changes the theme while the user is at the machine. A scheduled theme gets 6 hours, and a hand-applied one 12. Each apply now records whether the schedule or a person made it, so theme rank can count which scheduled turns were kept and which were switched away from.

What to look at

  1. schedule_run in lib/schedule.sh (b12811d, 441a66b). It runs the verdict, the desktop-environment guard, the pull, the backgrounded apply, then reads the outcome off the history row. Check that every path writes schedule.json, and that blocked and failed exit non-zero.
  2. start_session_daemon in lib/lib.sh (197c9c2, 1e4c50b). The literal ok answer is the only test that Hyprland's dispatcher took the command. Check that the fallback runs when hyprctl is missing or answers anything else, and that it takes systemd-run --user --scope whenever a user manager answers.
  3. apply_theme in bin/theme (b12811d) skips sync_after_action for the schedule trigger. Check that schedule_run pushes on the applied path and nowhere else.
  4. _storage_get_machine_id (e1edd28), calculate_usage_time (3a4af1c) and get_schedule_turns (b12811d) in lib/storage.sh. The id now passes through the same normalize_machine that get_history applies to every row, and both readers group applies by machine. Check a machine with a single apply, and rows with no machine key.

How it was verified

  • The commit hook's bats tests/ passed on every commit, with 253 tests at the head.
  • A scheduled apply ran as a real systemd oneshot. systemd-run --user --wait ran theme schedule run with THEME_ENV=development, whose empty history made a change due now. It applied a random theme to the live desktop, wrote "trigger":"schedule" into the history, and wrote applied into schedule.json. waybar and dunst survived the service's exit and sat in the login session's scope. Before 197c9c2 they sat in the scope of whichever terminal pane last ran an apply.
  • What a oneshot does to a child it starts. One started with setsid sleep, the way the old reload did, died when the service exited. One started through systemd-run --user --scope outlived it, in a scope of its own under app.slice. So did a stand-in daemon that start_session_daemon started from a oneshot with HYPRLAND_INSTANCE_SIGNATURE unset, where the dispatcher could not take it.
  • The desktop-environment guard, through the real user manager. A oneshot running theme schedule run with UnsetEnvironment=HYPRLAND_INSTANCE_SIGNATURE and a change due exited 1 and wrote no history row. It recorded blocked: no usable HYPRLAND_INSTANCE_SIGNATURE, so a change could not reach the desktop. From a terminal on the desktop, run: systemctl --user import-environment HYPRLAND_INSTANCE_SIGNATURE. With the manager's full environment, the guard found nothing missing.
  • A host named linux-box. Against the code before e1edd28, the new test counted 0 of the host's own rows, and schedule_decide answered due one minute after a scheduled apply. After it, the test counts 1 and the decision is waiting.
  • The real units, against the real history. theme schedule on wrote both units and started the timer. systemctl --user start theme-schedule.service logged waiting: next change due Sun 09:46, 12 hours after the last hand apply. status showed the timer active and that check. off removed both files, and systemctl --user is-enabled answered not-found. With the schedule off, status prints Status: Off and no Next change: line.
  • Presence, through real logind. After SetIdleHint b true, status read away (session 2 is idle). After b false, it read active.
  • launchd, on a Mac over SSH, from a scratch copy. on loaded the agent with AbandonProcessGroup set. launchctl kickstart ran a check that recorded away: … no input for 2d 12h. off unloaded the agent and removed the plist.
  • The per-machine usage timeline, against a real four-machine history. Two themes went from 22 and 19 days to 149 and 84. The new figures come from stretches of 103 and 49 days on one machine, which the merged timeline had cut at the other machines' applies.
  • The SCHEDULED TURNS section, rendered from a fixture history. Its median was checked by hand: switches at 14 and 70 minutes give 42.

What changes

  • theme schedule on|off|status|run is new. It is off until on, and --help says how it decides.
  • Every apply row carries trigger. apply, random and change write manual, and the timer writes schedule. A row without the key is read as manual.
  • While the schedule is on, theme apply ends with the time of the next scheduled change.
  • A scheduled check is blocked while the user manager lacks the desktop's variables: WAYLAND_DISPLAY or DISPLAY, and HYPRLAND_INSTANCE_SIGNATURE while Hyprland runs. The blocked message and --help give the systemctl --user import-environment line that supplies them.
  • theme rank's hours now come from one timeline per machine. A theme left on one machine for a long stretch gains hours, and those hours still include idle time.
  • theme rank adds SCHEDULED TURNS once any scheduled turn has finished.
  • A hostname starting with a legacy platform prefix (linux-, arch-, archlinux-, macos-, wsl-, unknown-) is recorded and compared without it, the way history was already read. Such a machine's gist file is named for the shorter id.
  • theme opacity restarts waybar instead of sending it SIGUSR2.
  • Hyprland reloads once per apply, and the stray ok between the per-app ticks is gone.
  • A waybar or dunst restarted by an apply runs as Hyprland's child, whoever ran the apply. Without Hyprland it runs in a transient systemd scope.

Decisions, and what they rejected

  • The turn runs on wall-clock time, and presence only decides when a due change fires. That keeps every change landing during use. A theme applied late in the evening gets less active time before its turn ends. Rejected: a clock counting only active time, which gives each turn equal exposure and changes the theme less often.
  • Presence on Linux comes from logind's IdleHint and LockedHint. Hyprland has no idle query, and logind is the one place any idle daemon can publish to. The cost is a hypridle listener calling SetIdleHint, which --help prints. status warns while IdleHint has not changed since login.
  • Restarted daemons go through Hyprland's exec dispatcher, then a transient systemd scope. Rejected: KillMode=process on the service. It leaves the restarted bar in the finished service's cgroup, and systemd reports leftover processes on every run.
  • The machine id goes through the history reader's own normalize_machine. Writes, comparisons and the gist filename then agree with what reading produces. Rejected: normalizing at each comparison site, which leaves the next comparison to remember it.
  • The outcome is read off the history row, not the apply's exit status. A row with the schedule trigger, written after the run started, is what the apply produced.
  • Smallest change that would work: a timer running theme random every 6 hours. It writes the same row as a hand apply, so the history could not tell a kept turn from a switch, and it fires while nobody is at the machine.

Risk and rollback

Nothing runs until theme schedule on, and theme schedule off removes the units. A revert leaves trigger keys in history rows, which no other reader uses. Run theme schedule off before reverting, or the timer keeps calling theme schedule run, which the reverted CLI does not have.

What this does not do

  • WSL is refused, since it has no display session to read presence from.
  • Hours still count idle stretches. Only the scheduled turns separate kept from ignored.
  • It does not install the hypridle listener. The desktop's own config owns that.
  • The dispatcher call uses Hyprland's Lua syntax, which the README already requires. On an older config it answers something other than ok, and the restart falls back to the systemd scope.

The review

#22 (comment) — 3 correctness, 1 design, 1 prose.

  1. fixed — e1edd28
  2. fixed — 441a66b, with the scope fallback in 1e4c50b
  3. fixed — 2c29d67
  4. fixed — 70a102d
  5. fixed — 5d53615

A restarted waybar or dunst was a child of whatever ran the apply, and
setsid does not move it out of that process's cgroup. From a terminal it
landed in the tmux pane's scope. From a systemd service, systemd kills
the cgroup when the service exits, taking the new bar with it.

start_session_daemon hands the program to Hyprland's exec dispatcher, so
it runs in the login session's scope as it did at login. Without a
Hyprland instance answering "ok" it falls back to the setsid launch.

theme opacity restarts waybar through the same path instead of sending
SIGUSR2. A SIGUSR2 reload is one of the triggers of the waybar 0.15
mpris use-after-free that reload_waybar already restarts to avoid.
apply_hyprland reloaded Hyprland itself, and reload_apps reloaded it again
on the same apply. It was the only apply_* that reloaded its own app, so it
now leaves the reload to reload_apps like the rest.

reload_hyprland discarded only stderr, so hyprctl's "ok" landed between
the per-app ticks of every apply.
calculate_usage_time merged every machine's applies into one timeline, so
an apply on one machine ended the session another machine was still
showing. A theme left on one machine for 103 days without a change was
credited only until the next apply on any other machine.

Each machine is now its own timeline. Another machine's latest apply is
left out, since nothing here knows when its session ended, and only this
machine's current theme runs to now.
theme schedule on installs a systemd user timer, or a launchd agent on
macOS, that runs theme schedule run every five minutes. A run reads this
machine's latest apply. A scheduled apply is due again after 6 hours and
a hand apply after 12, so a theme picked by hand keeps the screen for
half a day. A due change waits until the user is present: logind's
IdleHint and LockedHint for the display session on Linux, ten minutes
without input on macOS. Every change lands while the machine is in use.

Apply rows carry a trigger, schedule or manual, and a row without one
was applied by hand. theme rank adds SCHEDULED TURNS: a turn is kept when
the machine's next apply was also scheduled, and switched away from
otherwise, with the median time before the switch.

Each run records its outcome in schedule.json, which theme schedule
status reads: waiting, away, applied, blocked when the user manager has
no display to reach, or failed when the apply wrote no history row. A
scheduled apply pushes to the gist in the foreground, because the unit
ends a backgrounded push when the run exits. The launchd agent sets
AbandonProcessGroup for the same reason. WSL is refused, since it has no
display session to read presence from.

@datapointchris datapointchris left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR review

Used: Ran bats history.bats schedule.bats opacity.bats apply-install.bats in the suite's sandbox, and all 65 passed. Read each test the diff adds or edits, and each existing test covering log_action, calculate_usage_time, set_waybar_opacity and apply_hyprland, beside the code it covers.
Standards: testing.md § "Test behavior the user cares about, not what this commit happened to change"; pull-requests.md § "Tests the diff adds are checked for whether they test the program or the change"; testing.md § "Never assert on rendered output — assert the value it was built from".
Findings: 0 correctness · 1 design · 0 prose

Design and architecture

  1. tests/history.bats — the absent-key half of one log_action test repeats an assertion another test already makes. The affected test:

    • tests/history.bats "log_action writes the message and trigger keys only when given", its first block (log_action apply gruvbox, then [has("message"), has("trigger")] == false,false).

    Reach: "log_action writes one JSON object per call" runs the same log_action apply gruvbox. It asserts the record's keys are exactly action,machine,platform,theme,ts. A blank message or trigger key fails that assertion too. So the first block can only fail when its neighbor fails. The absent-trigger case also pins nothing a reader uses. apply_theme always passes a trigger, defaulting to manual. The calls that leave it out are like, dislike, note, reject and unreject, and no reader looks at .trigger on those rows. Delete the first block and the : >"$THEME_HISTORY_FILE" reset after it. Keep the second block. It is the only test that log_action writes the trigger the schedule reads, since schedule.bats and the get_schedule_turns test build their rows with add_apply_record.

This review does not gate merge.

@datapointchris datapointchris left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR review

Used: I ran theme schedule status|on|off|run and theme rank from the branch. HOME and the XDG variables pointed at a scratch directory, and systemctl, loginctl, hyprctl and pgrep were stubs. I sourced the libraries with a stubbed apply_random to drive schedule_run through applied, failed (return and exit), away and waiting. I rendered SCHEDULED TURNS from a fixture history. I ran tests/schedule.bats, tests/history.bats and tests/opacity.bats, and all pass. I ran hyprctl version with HYPRLAND_INSTANCE_SIGNATURE unset.
Standards: help.md § "An error is the help screen for the failure in hand" · documentation.md § "Point at the source instead of copying it, and write the pointer with the source open" · testing.md § "Test behavior the user cares about, not what this commit happened to change". infrastructure.md § "Scheduled work reports its own outcome" is met, because schedule.json is read off the history row.
Findings: 1 correctness · 1 design · 1 prose

Correctness

  1. lib/schedule.sh:158 — The display guard checks the wrong variable. Every Hyprland call in a scheduled apply needs HYPRLAND_INSTANCE_SIGNATURE, and the guard never checks it. Without it, hyprctl answers HYPRLAND_INSTANCE_SIGNATURE not set! (is hyprland running?) instead of ok. start_session_daemon (lib/lib.sh:2374) then takes the setsid fallback. systemd kills that waybar when the oneshot exits, so the bar disappears. reload_hyprland fails silently and the window colors stay. hyprctl hyprpaper wallpaper fails too, so the wallpaper stays. The history row is still written, so schedule.json records applied and status reports success.
    Reach: a Hyprland session that exports only WAYLAND_DISPLAY to the user manager. The common exec-once = dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP line does exactly that. The help at bin/theme:494 tells the user to supply WAYLAND_DISPLAY or DISPLAY and names nothing else. This machine's manager carries the signature, which is why the body's systemd-run check passed.
    The blocked message also says what is missing but not how to supply it. That breaks help.md § "An error is the help screen for the failure in hand".
    Fix: on Arch, block unless hyprctl version succeeds, and say so in the message. Name the import line in the blocked message and in --help: systemctl --user import-environment WAYLAND_DISPLAY HYPRLAND_INSTANCE_SIGNATURE. A cgroup-safe fallback in start_session_daemon would cover non-Hyprland compositors as well: systemd-run --user --scope instead of setsid.

Design and architecture

  1. tests/history.bats:35 — The trigger half of "log_action writes the message and trigger keys only when given" pins the diff's shape. It does not pin behavior. Every reader treats an empty trigger and a missing one the same way. schedule_last_apply reads .trigger // "manual" and compares it to "schedule". get_schedule_turns uses select(.trigger == "schedule"). So no user can see has("trigger"). Every caller that writes an apply passes manual or schedule.
    Reach: each later change to log_action's record shape has to keep this assertion green. Nothing a user sees depends on it. testing.md § "Test behavior the user cares about, not what this commit happened to change".
    Fix: delete the trigger assertions and keep the message half. That half is real, because get_background_stats reads .message // empty. The hold and turn behavior that trigger drives is already pinned by the first test in tests/schedule.bats and by the get_schedule_turns test.

Prose

  1. README.md:90 and CLAUDE.md:213 — "A theme the schedule applied keeps the screen for 6 hours, and one you applied by hand for 12" and "A scheduled apply is due again after 6 hours and a hand apply after 12." Both repeat THEME_SCHEDULE_TURN_HOURS and THEME_SCHEDULE_HOLD_HOURS. --help already builds its text from those constants. Changing either constant leaves these two sentences wrong, and nothing would notice. documentation.md § "Point at the source instead of copying it, and write the pointer with the source open".
    Rewrite (README): "A theme the schedule applied keeps the screen for a shorter turn than one you applied by hand. theme schedule --help gives both lengths."
    Rewrite (CLAUDE.md): "A scheduled apply is due again after THEME_SCHEDULE_TURN_HOURS and a hand apply after THEME_SCHEDULE_HOLD_HOURS, both in lib/schedule.sh."

This review does not gate merge.

@datapointchris datapointchris left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR review

Used: ran bats tests/ at b12811d (252 pass), and ran theme schedule status, schedule run, schedule bogus and theme rank from the branch against a sandbox HOME with a seeded multi-machine history, with systemctl, hyprctl, pkill, tmux and gh stubbed. I also called schedule_decide and calculate_usage_time from a scratch script with uname stubbed to a prefixed hostname.
Standards: data.md § "Machine identity is a bare lowercased hostname"; testing.md § "Test behavior the user cares about, not what this commit happened to change"; documentation.md § "Point at the source instead of copying it, and write the pointer with the source open"
Findings: 2 correctness · 1 design · 1 prose

Correctness

  1. lib/schedule.sh:60 — On some machines the schedule never sees its own last apply, so it changes the theme at every 5-minute check while the user is active. With sync on, each of those runs also erases this machine's history rows. Reach: any machine whose hostname begins macos-, arch-, archlinux-, wsl-, linux- or unknown-, such as linux-box or arch-desktop. None of the four machines in the live history has one. The README offers the tool to anyone who installs it, though. The cause is in get_history. It runs every row through normalize_machine, which strips those prefixes as legacy platform-host labels. --arg machine "$(_storage_get_machine_id)" is compared raw. With uname stubbed to linux-box, the written row says "machine":"linux-box" and reads back as box. schedule_last_apply then returns nothing. One minute after a scheduled apply, schedule_decide answers due, where studio answers waiting. The same raw comparison has three more sites. calculate_usage_time (lib/storage.sh:183) returns {} for the current theme, so it never accrues hours. _sync_own_records (lib/sync.sh:99) selects zero rows, so a push sends nothing. _sync_merge_histories (lib/sync.sh:137 and :145) keeps none of this machine's local rows. I merged a one-row local file with one remote macmini row, and the result held only the macmini row. With sync on, schedule_run calls sync_pull before every apply, so each scheduled run deletes the rows from earlier runs. The sync sites predate this branch. The schedule is what turns them into a loss every 5 minutes. data.md § "Machine identity is a bare lowercased hostname" says to canonicalize at read time, and that has to cover the value being compared too. Fix: give lib/storage.sh one function that returns the id passed through normalize_machine, and use it at all five sites.

  2. lib/schedule.sh:383 — theme schedule status says a change is coming when the schedule is off. A user who has never run on sees Status: Off. Below it they read Next change: due since Sat 20:25, at the first check while you are active. No check will ever run, so nothing will change. Reach: running status before on, which is the first thing the help's Examples suggest. I ran it against the sandbox history with no timer installed, and it printed exactly those two lines. Fix: print the Next change: line only when $on is true. When the schedule is off, the Turn it on with: line already says what to do.

Design and architecture

  1. tests/history.bats:39 — The has("trigger") half of "log_action writes the message and trigger keys only when given" checks a shape that no reader can tell apart. A row with "trigger":"" reads as manual everywhere. .trigger // "manual" yields "", and schedule_last_apply, schedule_status and get_schedule_turns all compare against "schedule". So a blank trigger and a missing one behave the same. The assertion fails only if someone changes log_action's jq on purpose. Reach: testing.md § "Test behavior the user cares about, not what this commit happened to change". Delete the has("trigger") assertion. Keep the half that writes schedule and reads it back, because nothing else pins log_action writing the trigger. tests/schedule.bats seeds rows through add_apply_record.

Prose

  1. CLAUDE.md:213 and README.md:90 — Both copy the values of THEME_SCHEDULE_TURN_HOURS and THEME_SCHEDULE_HOLD_HOURS. CLAUDE.md says: "A scheduled apply is due again after 6 hours and a hand apply after 12." README.md says: "A theme the schedule applied keeps the screen for 6 hours, and one you applied by hand for 12." theme schedule --help interpolates the constants. These two sentences go stale the first time one of the constants is tuned. Rewrite in CLAUDE.md: "A scheduled apply is due again after THEME_SCHEDULE_TURN_HOURS and a hand apply after the longer THEME_SCHEDULE_HOLD_HOURS, both in lib/schedule.sh." Rewrite in README.md: "A theme you applied by hand keeps the screen longer than one the schedule applied. theme schedule --help gives both durations."

This review does not gate merge.

@datapointchris

Copy link
Copy Markdown
Owner Author

PR review — merged

Three reviewers ran: two general and one test reviewer. This comment unions reviews 5561116718, 5561123745 and 5561105245. The author's session launched the round through a review lead.

Findings: 3 correctness · 1 design · 1 prose

Correctness

  1. lib/schedule.sh:60 — The schedule compares a raw hostname against normalized history rows. On an affected machine it changes the theme at every 5-minute check while the user is active. With sync on, each run also deletes this machine's history rows. (1 of 2 general reviewers.)
    Cause: get_history runs every row's machine through normalize_machine. That function strips the macos-, arch-, archlinux-, wsl-, linux- and unknown- prefixes as legacy platform-host labels. The comparison side, --arg machine "$(_storage_get_machine_id)", is not normalized. With uname stubbed to linux-box, the row is written as linux-box and read back as box. schedule_last_apply then returns nothing. One minute after a scheduled apply, schedule_decide answers due, where studio answers waiting.
    The same raw comparison appears at three more places, five comparisons in all:

    • calculate_usage_time (lib/storage.sh:183) returns {} for the current theme, so the current theme never accrues hours.
    • _sync_own_records (lib/sync.sh:99) selects zero rows, so a push sends nothing.
    • _sync_merge_histories (lib/sync.sh:137 and :145) keeps none of this machine's local rows. A one-row local file merged with one remote macmini row came out holding only the macmini row.
      With sync on, schedule_run calls sync_pull before every apply. So each scheduled run deletes the rows the earlier runs wrote. The sync sites are older than this branch. The schedule turns them into a loss every 5 minutes.
      Reach: any machine whose hostname begins with one of those prefixes, such as linux-box or arch-desktop. None of the four machines in the live history has one. The README offers the tool to anyone who installs it. data.md § "Machine identity is a bare lowercased hostname" says to canonicalize at read time, and that covers the compared value too.
      Fix: give lib/storage.sh one function that returns the machine id passed through normalize_machine, and use it at all five sites.
  2. lib/schedule.sh:158 — The display guard checks WAYLAND_DISPLAY and DISPLAY. It never checks HYPRLAND_INSTANCE_SIGNATURE, which every Hyprland call in a scheduled apply needs. (1 of 2 general reviewers.)
    Without the signature, hyprctl answers HYPRLAND_INSTANCE_SIGNATURE not set! (is hyprland running?) instead of ok. The apply then fails in three places:

    • start_session_daemon (lib/lib.sh:2374) takes the setsid fallback. systemd kills that waybar when the oneshot exits, so the bar disappears.
    • reload_hyprland fails silently, so the window colors stay.
    • hyprctl hyprpaper wallpaper fails, so the wallpaper stays.
      The history row is still written. So schedule.json records applied, and status reports success.
      Reach: a Hyprland session that exports only WAYLAND_DISPLAY to the user manager. The common line exec-once = dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP does exactly that. The help at bin/theme:494 tells the user to supply WAYLAND_DISPLAY or DISPLAY and names nothing else. This machine's manager carries the signature, which is why the body's systemd-run check passed.
      The blocked message names what is missing but not how to supply it. That breaks help.md § "An error is the help screen for the failure in hand".
      Fix: on Arch, block unless hyprctl version succeeds. Name the import line in the blocked message and in --help: systemctl --user import-environment WAYLAND_DISPLAY HYPRLAND_INSTANCE_SIGNATURE. Replacing setsid with systemd-run --user --scope in start_session_daemon would also keep waybar alive under compositors other than Hyprland.
  3. lib/schedule.sh:383 — theme schedule status promises a change while the schedule is off. (1 of 2 general reviewers.)
    A user who has never run on sees Status: Off. Below it they read Next change: due since Sat 20:25, at the first check while you are active. No check will run, so nothing will change.
    Reach: running status before on. That is the first thing the help's Examples suggest. Run against a sandbox history with no timer installed, it printed exactly those two lines.
    Fix: print the Next change: line only when $on is true. When the schedule is off, the Turn it on with: line already says what to do.

Design and architecture

  1. tests/history.bats — The first block of "log_action writes the message and trigger keys only when given" repeats an assertion another test already makes. (All 3 reviewers.)
    The block runs log_action apply gruvbox and asserts [has("message"), has("trigger")] is false,false. "log_action writes one JSON object per call" runs the same call. It asserts the record's keys are exactly action,machine,platform,theme,ts. A blank message or trigger key fails that test too. So this block can fail only when its neighbor fails.
    Nothing a user sees tells a missing trigger from a blank one. schedule_last_apply reads .trigger // "manual". schedule_last_apply, schedule_status and get_schedule_turns all compare against "schedule". A row with "trigger":"" reads as manual at every one of them.
    Reach: testing.md § "Test behavior the user cares about, not what this commit happened to change". Each later change to log_action's record shape has to keep this block green, and no user sees what it checks.
    Fix: delete the first block and the : >"$THEME_HISTORY_FILE" reset after it. Keep the second block. It is the only test that log_action writes the trigger the schedule reads, because tests/schedule.bats and the get_schedule_turns test build their rows with add_apply_record.

Prose

  1. README.md:90 and CLAUDE.md:213 — Both sentences copy the values of THEME_SCHEDULE_TURN_HOURS and THEME_SCHEDULE_HOLD_HOURS. (Both general reviewers.)
    README.md says: "A theme the schedule applied keeps the screen for 6 hours, and one you applied by hand for 12." CLAUDE.md says: "A scheduled apply is due again after 6 hours and a hand apply after 12." theme schedule --help builds its text from the constants. Tuning either constant leaves these two sentences wrong, and nothing notices. documentation.md § "Point at the source instead of copying it, and write the pointer with the source open".
    Rewrite (README.md): "A theme you applied by hand keeps the screen longer than one the schedule applied. theme schedule --help gives both durations."
    Rewrite (CLAUDE.md): "A scheduled apply is due again after THEME_SCHEDULE_TURN_HOURS and a hand apply after the longer THEME_SCHEDULE_HOLD_HOURS, both in lib/schedule.sh."

This review does not gate merge.

2 rule(s) proposed, filed into the fleet inbox as proposals.

get_history passes every row's machine through normalize_machine, which strips legacy platform prefixes such as linux- and arch-. The id this machine compared against was the raw hostname, so on a host named linux-box no row ever matched. The schedule found no last apply and changed the theme at every check, the usage timeline never ran the current theme to now, and sync pushed nothing and dropped this machine's local rows on every pull.

The id now goes through the same jq definition, so writes, comparisons and the gist filename agree with what reading produces.
…cannot take it

A process the apply starts stays in the apply's cgroup even after setsid, and systemd kills that cgroup when a scheduled oneshot exits. Outside Hyprland the restarted waybar or dunst died with every scheduled change. systemd-run --user --scope moves it into a transient scope of its own, which outlives the service and still inherits the apply's environment. setsid stays for a machine with no user manager.
The display guard checked WAYLAND_DISPLAY and DISPLAY, but every Hyprland call in the apply also needs HYPRLAND_INSTANCE_SIGNATURE. A user manager without it still wrote the history row, so the check recorded applied while the window colors, the wallpaper and the restarted bar never changed.

The guard now asks hyprctl whether it reaches a running Hyprland. A blocked check names each missing variable and the systemctl --user import-environment line that supplies it, and --help says the same.
With the timer off, status still printed when the next change was due, though no check would ever run. The Turn it on with line already says what to do.
README and CLAUDE.md restated the turn and hold hours, which --help already builds from the constants. Tuning either constant would have left both sentences wrong.
"log_action writes one JSON object per call" asserts the record's exact key set, so a blank message or trigger key fails it too. What stays is the only test that log_action writes the trigger the schedule reads.
@datapointchris
datapointchris merged commit 639709c into main Oct 18, 2026
1 check passed
@datapointchris
datapointchris deleted the theme-schedule branch October 18, 2026 02:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant