2026 10 11 powers privilege ledger design - xopherdeep/do-it-for-the-xp GitHub Wiki

Powers: the privilege ledger (design, 2026-10-11)

Repo: /home/xopher/www/x/do-it-for-the-xp (Vue 3 + Ionic 7 + Pinia, jest 27, Cypress 8.7). Gate: chemx verify. Never raw pnpm typecheck|lint|test|jest.

Why

The owner's words (2026-10-11): a Power like "30 min of Video Games" is something the hero casts. Casting needs MP, tells the parent what happened, and runs on its own timer. "For the most part I think this works better as a gamified ledger to help bridge communication and motivation for the parent and child." Nothing is enforced; the ledger is the product.

Two standing rules carry over:

  • Anything with real-world money value is a shop item bought with GP. Powers are privileges and spells. AP is never spent on anything.
  • The rules the Game Master set are the approval. A cast needs no sign-off.

Vocabulary

  • Power: one Ability record (src/types/abilities.ts). Privilege = type: "real-life". Spell = type: "in-game".
  • Earned: the hero's AP meets the Power's AP requirement right now.
  • Cast: the hero uses an earned Power. Costs MP, writes the ledger, starts the timer.
  • Running: the Power's timer has not ended yet.
  • Cap: how many casts the parent allows per frequency window.
  • Ledger: the cast log, the chat line, and the notifications a cast produces.

The rules

R1. AP is an append-only earned ledger. stats.ap = { total, history: [{ amount, timestamp, source }] }, written only by userStore.recordAPGain. Nothing ever subtracts from it. recordAPGain gains an optional at (ISO string) so an approved quest stamps its AP at the quest-log entry's completedAt, not at approval time.

R2. Earned. For a periodic requirement (apRequirement.period ≠ flat) the hero is earned while the AP in a rolling window ending now is ≥ amount:

period window
hourly 60 min
daily 24 h
weekly 7 days
monthly 30 days
quarterly 91 days
biannual 182 days
yearly 365 days
flat lifetime total (a milestone, never lost)

earnedUntil is the exact moment the window sum would fall below amount if nothing more is earned: the timestamp of the oldest record still needed, plus the window. Records dated after now count toward the lifetime total only (device clock set back). Level, class-level and prerequisite gates stay exactly as heroAbilityStatus.ts applies them today.

R3. Castable = earned ∧ mp.now ≥ mpCost ∧ not running ∧ castsUsed < castsCap. Every refusal carries one reason, in this order: prereq, level, class, ap, running, cap, mp. The kid always reads why.

R4. Timer. New field durationMinutes (default 0 = no timer). A cast is running until castAt + durationMinutes. While running the Power cannot be cast again and shows a countdown. Powers without minutes (Captain's Seat, Night Owl) have no running state.

R5. Cap. New field maxCasts (default 1) counted inside the Power's existing frequency window, which is calendar-aligned in local time: hourly = the last 60 minutes (rolling), daily = today, weekly = this week from Monday, monthly = this month, quarterly, biannual and yearly likewise. A cast counts in the window of its castAt. maxCasts: 1 reproduces today's cooldown, so nothing already authored changes meaning. capResetsAt is the start of the next window.

R6. A cast does exactly this, in order: charge MP through userStore.updateUserMP; append a CastLogEntry; post a system line in the family-chat room; push a power-cast notification to every Game Master profile and show it through the alert bridge; when durationMinutes > 0, book the timer end: a power-ended notification and alert for the hero plus a chat line, delivered by scheduleAlert where the platform supports it and by a resume check otherwise (R9).

R7. Casts are per hero. The shared ability.lastUsed stamp is retired: the cast log is the only memory of casts. Old lastUsed values are ignored.

R8. Everything on screen is derived. One pure module computes the standing of a Power for a hero from the AP history, the hero's casts, MP, level and classes. No stored "charged" or "unlocked" flag can drift. The only stored receipts are the cast log entries themselves.

R9. Timer ends are never missed. On app open, on resume, and once a minute while the hero's portal is open, any cast whose endsAt ≤ now and whose endedNoticeAt is empty gets its end notice (notification + chat line), then endedNoticeAt is written. Native builds also receive the OS alert booked at cast time; the two are deduped by the notification dedupe key (power-ended + cast id + day).

Data

Ability (src/types/abilities.ts): add durationMinutes?: number and maxCasts?: number. isAbility does not require them.

CastLogDb (src/lib/databases/CastLogDb.ts, storage __castLog, IndexedDB then LocalStorage, modelled on PurchaseLogDb):

interface CastLogEntry {
  id: string;            // uuid
  userId: string;        // the hero
  abilityId: string;
  abilityName: string;   // kept so the ledger survives a deleted Power
  abilityType: "real-life" | "in-game";
  castAt: string;        // ISO
  endsAt?: string;       // castAt + durationMinutes, absent when 0
  mpPaid: number;
  endedNoticeAt?: string;
}

Methods: logCast, getForHero(userId), getForHeroAndPower(userId, abilityId), getAll, markEnded(id, at).

Notifications (src/lib/services/notifications/Notifications.ts): add kinds "power-earned", "power-cast", "power-ended". refId = ability id for earned, cast id for cast/ended, so the daily dedupe key stops repeats.

Chat (src/lib/databases/ChatDb.ts): add optional kind?: "system" to ChatMessage. System lines use senderId: "town-crier", senderName: "Town Crier", no avatar, and the chat list styles them as a centred italic line. A new chatStore.postSystemLine(roomId, text) writes them without a logged-in sender.

Stats class (src/lib/utils/User/stats.ts): APStats becomes { total: number; history: ApRecord[] }; the constructor seeds that shape. The unused hour/day/week/month/year/ledger arrays go away. Existing profiles are not migrated; readers already tolerate both.

The pure module

src/lib/services/abilities/PowerStanding.ts:

powerStanding(ability, hero: HeroAbilityProfile, casts: CastLogEntry[], unlockedIds: string[], now: Date): PowerStanding

interface PowerStanding {
  earned: boolean;          // all unlock gates pass
  earnedAp: number;         // AP counted in the window, or lifetime for flat
  neededAp: number;
  earnedUntil: string | null;
  running: boolean;
  runningUntil: string | null;
  castsUsed: number;
  castsCap: number;
  capResetsAt: string | null;
  castable: boolean;
  reason: "ok" | "prereq" | "level" | "class" | "ap" | "running" | "cap" | "mp";
}

heroApWindows in heroAbilityStatus.ts switches to the rolling windows of R2 so the Compendium preview and the hero's screen obey one rule; computeHeroAbilityStatuses keeps its signature and takes the casts in place of ability.lastUsed, mapping running → Cooling, cap reached → Used, earned-but-no-MP → Unlocked, castable → Available. Its spec is updated for the rolling day.

AbilityService.useAbility becomes castPower(abilityId, userId): compute standing; refuse with the standing's reason; otherwise do R6. useAbilitySystem.useAbility calls it and toasts the reason in kid words.

Screens

My Powers (/my-abilities/:userId, MyAbilities.vue/.ts): rebuilt as a real page over powerStanding. Two groups, Privileges then Spells. Each card: icon, name, what it grants (the description), a state line, the AP meter (earnedAp / neededAp), the cap ("1 of 2 today"), and one 48 px Cast button that is disabled with the reason as its label when not castable. A running card shows the countdown instead of the button. The deprecated useAbilities hook and the fake pagination headers are deleted. The nav bars are untouched.

Profile (UserProfileModal.vue, XpFabUserHud.vue): the AP box shows the lifetime total (abbreviated like GP), is-percentage removed. Its bar is today's earned AP over the highest daily requirement among the family's privileges, full when there is none. The stats.ap.today/.week reads are deleted.

Compendium editor (XpCreateUpdateAbility.vue): two fields under Frequency: "How long it lasts" (minutes, 0 = no timer) and "How many times per {frequency}" (max casts, min 1). The hero preview in XpAbilities.vue shows the standing line per Power for the selected hero.

Chat (XpChatList.vue, XpChatMessage.vue): render kind: "system" lines centred and muted.

Notifications modal: the three new kinds render with the AP icon; power-cast links to the Game Master's chat, power-earned and power-ended to My Powers.

Copy (kid-facing, short)

  • earned: "Charged! Cast by 4:12pm tomorrow."
  • not earned: "3 AP to go" (meter beneath)
  • running: "22 min left"
  • cap: "Cap reached. Back tomorrow."
  • mp: "Need 5 MP. Rest at the Hotel."
  • cast chat line: "Dad/dy/dos cast Video Games · 30 min · 5 MP"
  • ended chat line: "Video Games time is up for Dad/dy/dos"
  • GM notification: title "Video Games cast", body "Dad/dy/dos · 30 min · 4:12pm"
  • hero ended notification: title "Time's up", body "Video Games ended at 4:42pm"

Edge cases

  • Two heroes: casts never cross profiles (R7). A GM profile may cast; it only logs.
  • A Power deleted after casts: the ledger keeps the name; standing for it is never computed.
  • A cast spanning midnight counts in the day of castAt (R5).
  • Device clock set back: future-dated AP counts toward the lifetime total only; future-dated casts count as running until their endsAt.
  • App closed at timer end: native gets the booked OS alert; web gets the notice on next open (R9). The chat line is written once, by whichever path runs first.
  • Approval the next morning: AP lands on the completion day (R1), so a 24 h window is honest.
  • Legacy stats.ap as a bare number: treated as lifetime total with an empty history (already so).

Testing

  • tests/unit/PowerStanding.spec.ts: rolling windows per period, earnedUntil exact, flat milestone, double-cast impossible while running, cap per calendar window incl. midnight and Monday boundaries, reason order, future-dated records, bare-number AP.
  • tests/unit/CastLogDb.spec.ts: log, per-hero reads, markEnded.
  • tests/unit/AbilityService.spec.ts: castPower charges MP, logs, posts the chat line, pushes the GM notification, books the end; each refusal charges nothing.
  • tests/unit/heroAbilityStatus.spec.ts: updated for rolling days and cast-log cooldowns.
  • Component specs (no @ionic/vue imports): the Powers card states and the profile AP box.
  • Cypress: seed world, earn AP by a quest, open My Powers, cast, see the countdown, see the chat line as the GM.

Phase 1: the MVP cut (owner asked 2026-10-11 for the least that works without breaking anything)

Ships the ledger on what already exists. Nothing in the quest or battle loop changes; every addition is additive.

  1. AP shows the total. UserProfileModal.vue and XpFabUserHud.vue read heroApWindows(stats.ap).total; is-percentage removed. Two files.
  2. My Powers is real. MyAbilities.vue/.ts lists the family's Powers with the status badge from the existing, tested computeHeroAbilityStatuses (calendar windows as they are today) and one Cast button that calls the existing useAbility (MP charge already tested). The deprecated useAbilities hook goes.
  3. Casts are per hero. New CastLogDb (id, userId, abilityId, abilityName, castAt, mpPaid). computeHeroAbilityStatuses takes the hero's last cast per ability in place of ability.lastUsed; useAbility keeps writing lastUsed so old screens stay whole.
  4. The ledger. useAbility also posts the chat line to family-chat (ChatMessage.kind: "system", Town Crier) and pushes a power-cast notification to every Game Master profile, shown through showAlert.
  5. AP lands on the completion day. recordAPGain takes at; approveCompletion passes the entry's completedAt.

No timer, no cap above 1, no rolling windows, no editor fields, no Stats class change. "30 min" lives in the Power's name and description, the frequency is the cap, and the parent's watch is the timer. Specs: CastLogDb, the heroAbilityStatus cooldown-by-cast cases, useAbility ledger side effects, the AP box. Everything above the line in this document is Phase 2.

Out of scope (next specs)

  • Skill tree for spells. Learning is a lifetime-AP milestone plus prerequisites and class level (all already in the model); choice comes from quick-draw slots, swapped at the Hotel; spells bend time, HP, MP, clocks and information in battle and temples but never mint AP, XP or GP. Own spec after this ships.
  • Rest days (pause windows over a holiday), a GM "end early" button, enforcement timers, a HUD chip for a running Power, repricing the four periodic spell presets.
⚠️ **GitHub.com Fallback** ⚠️