11 — v2 → v3 Hands-on Challenges
Goal: stop reading, start touching — the v3 edition. Chapters 09–10 gave you the shipped v2 architecture and its seams; these challenges make you feel both before you refactor anything. Same rules as Chapter 07: ordered easy → hard, each says what it teaches, the files, and how to verify. Hints are tiered — unfold one at a time, only when stuck.
Prereqs: Chapters 09–10, plus the build/run/preview-override moves from Chapter 07’s setup section. Work on a branch; several of these are deliberately throwaway.
Challenge 1 — Watch the slice directory live (≈15 min, no code)
Section titled “Challenge 1 — Watch the slice directory live (≈15 min, no code)”Teaches: the state.d contract (Ch.09) with your own eyes.
ls -la ~/.codogotchi/state.d/— match filenames toorigin:session_id.- In one terminal:
watch -n1 'ls -lt ~/.codogotchi/state.d | head'(orfswatch). In another, run a short Claude Code prompt. - Watch your session’s slice mtime tick, then
catit mid-run and after.
Verify: you saw activity_state change and updated_at advance, and you
can name which slice is the “winner” for the origin right now (freshest
updated_at).
Challenge 2 — Trigger all four lifecycle tiers on purpose (≈30 min, no code)
Section titled “Challenge 2 — Trigger all four lifecycle tiers on purpose (≈30 min, no code)”Teaches: the Active / Live / Archived / Pruned diagram (Ch.09) as a real thing, not a picture; the three clocks.
- Set the dismiss TTL to 1 minute (Settings → Customization).
- Get a pet on screen (Active), then let it idle out (Live — not rendered, slice fresh).
- Backdate a copy of a slice to fake Archived:
Terminal window touch -t $(date -v-3H +%Y%m%d%H%M) ~/.codogotchi/state.d/<slice>.json - Use the menubar “Show” on each tier and observe: Live → reappears with its
state; Archived → reappears as idle (the
refreshForShowstale-slice rule); a deleted slice → the menu entry itself disappears on next open.
Verify: you predicted each Show outcome before clicking. If any surprised you, re-read Ch.09’s lifecycle table.
Hints — unfold one at a time
Section titled “Hints — unfold one at a time”Hint 1 — why does Archived come back idle?
StateJsonWriter.refreshForShow resets activity_state to idle only when the
slice’s mtime is past the reader’s 2h staleTTL — a state that old describes a
session that will never emit a correcting event. Fresh slices keep their state.
Hint 2 — the menu entry vanished — where did that happen?
MenubarMenu is its own NSMenuDelegate; menuWillOpen calls the pool’s
pruneHiddenKeysWithoutBackingSlice(stateDirectory:) before rebuilding the
pet section.
Challenge 3 — Count the string-key blast radius (≈30 min, no code)
Section titled “Challenge 3 — Count the string-key blast radius (≈30 min, no code)”Teaches: Seam 1 (stringly-typed window keys) as measured fact, not vibes.
grep -rn '"combined"' apps/menubar/Sources | wc -l, then read each hit.- Same for
firstIndex(of: ":")andsessionIdentity(forWindowKey:)call sites. - Classify every hit: parse (string → meaning), policy (a decision keyed on the shape), or serialization (writing the string form).
Verify: you have three lists. The parse list is what a WindowKey enum
deletes outright; the policy list is what it turns into exhaustive switches
the compiler checks for you.
Challenge 4 — Add a throwaway right-click affordance (≈1–2 h)
Section titled “Challenge 4 — Add a throwaway right-click affordance (≈1–2 h)”Teaches: Seam 3 (three-surface parity) by walking the exact path every v3-preview affordance walked. Throwaway branch — do not merge.
Add a “Woof” pill to the right-click prompt that just NSLogs, on both the
Own pet window and the Minimalist strip.
- Add a title constant to
FloatingPetHidePrompt. - Own mode:
FloatingPetInteractionView.presentHidePrompt+ a handler property + wiring inmakeContentView. - Minimalist:
MinimalistBadgeView.presentHidePrompt+ handler + wiring inMinimalistPanelController.init. - MenubarApp: wire both factories’ panels to the log statement.
Verify: the pill appears and fires on both surfaces. Reflect: count the files and the parallel edits you just made (the same feature, twice, plus app wiring). That’s the tax Chapter 10 wants to delete — one prompt builder parameterized by window shape.
Hint 1 — which existing pill to crib from
“Pet Mode” / “Minimalist Mode” (commit 293e2412) is the smallest recent
example — grep for minimalistModeTitle and follow its wiring end to end on
both surfaces before writing anything.
Hint 2 — the prompt items are ordered
Both presentHidePrompt implementations build items: top-to-bottom with the
Hide action last. Slot “Woof” just above Hide and the stack sizing
(FloatingPetHidePrompt.stackSize) handles the rest.
Challenge 5 — Break the TTL on purpose (≈1 h)
Section titled “Challenge 5 — Break the TTL on purpose (≈1 h)”Teaches: the idle-frozen clock (Ch.09) and update()’s step ordering
(Seam 4) — by violating an invariant and watching the failure.
On a branch, make lastSeenAt[renderKey] = currentTime unconditional in
FloatingPetWindowPool.update() (remove the != .idle guard), then run
bun run mac:test.
Verify: name the failing tests before running them (which behaviors depend on the clock freezing?). Then explain in one sentence why an idle pet would now never dismiss. Revert.
Hint 1 — what does the guard actually implement?
“TTL-expired” means continuously idle longer than the TTL. If the clock
advances every tick regardless, currentTime - lastSeen is always ~one tick,
so isTTLExpired can never fire for a pet that’s still being polled.
Challenge 6 — Sketch WindowKey for real (≈2 h, compiles but throwaway)
Section titled “Challenge 6 — Sketch WindowKey for real (≈2 h, compiles but throwaway)”Teaches: Seam 1’s fix shape; Swift enums with associated values doing what TS discriminated unions do (Ch.05).
- In a new file, define
enum WindowKey { case origin(String); case session(origin: String, id: String); case combined }withinit(parsing: String)andvar storageKey: Stringround-tripping the legacy strings. - Port one consumer: reimplement
modeSwitchOrigin(forWindowKey:)as aswitchoverWindowKey. - Write the round-trip test: for each of the three shapes,
WindowKey(parsing: k).storageKey == k.
Verify: tests pass; the switch has no default: — the compiler now
proves the three-way split every call site currently re-derives by hand.
Challenge 7 — Derive the desired window set as a value (design only, ≈2 h)
Section titled “Challenge 7 — Derive the desired window set as a value (design only, ≈2 h)”Teaches: Seam 4’s derive / diff / apply split, at whiteboard depth.
Without writing Swift: read update() top to bottom and write the type
signature and fields of the pure DesiredWindows value it should compute —
per key: shape (own/minimalist/combined-member), visibility, session number,
and everything apply would need to spawn or update with zero further
policy decisions.
Verify: cross-check your sketch against every step of update(). Each
step should map to either a derive input (clocks, caps, hidden keys, modes)
or an apply effect (spawn, dismiss, push state). Anything that maps to
neither is a policy hiding inside an effect — you’ve found another seam.
Challenge 8 — Prototype one Sessions-panel row (≈half a day)
Section titled “Challenge 8 — Prototype one Sessions-panel row (≈half a day)”Teaches: the v3 Sessions panel’s data problem (Ch.10, Track 3) end to end, in miniature.
Build a throwaway function (test-only is fine) that scans state.d/ and
returns [SessionRow] — origin, session id, label (from
SessionLabelStore), tier (Active / Live / Archived via pool state + mtime
against the two TTLs), and last-activity date. Unit-test it against a fixture
directory like CustomizationTabViewModelTests builds.
Verify: your test covers all three tiers plus the “hidden but fresh” case — which tier did you put that in, and does the menubar’s Show behavior agree with your answer?
Where to go next
Section titled “Where to go next”You’ve now touched every seam v3’s consolidation track names. Read the
roadmap (notes/private/codogotchi-v3-polish-roadmap.md in the main repo),
pick a Track 1 gap or the WindowKey refactor, and take it through the
normal SoA phase path. Chapter 12 has the external resources for the
distribution track (notarization, Sparkle, cask).