From f8c7779975cde66e46c24aa05615170a5656c0f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 12:18:41 +0000 Subject: [PATCH 1/4] Overview tab: what is ready now and what comes next, for a second screen Each tracker gives its next events in a shared NextEvent shape (stock full, supplies out or arriving, boosts ready, nightclub goods full, safes full, Car Wash max heat, Lucky Wheel). The Overview shows the money waiting, the items ready now as large action cards, and everything coming up in two columns, sized to fit a 1080p screen without scrolling. Optional browser notifications in Settings (state version 12). Decision 023. Closes #15 Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 1 + README.md | 2 + docs/decisions/023-overview.md | 38 +++ docs/decisions/README.md | 1 + src/App.svelte | 8 + src/components/BusinessProduction.svelte | 13 +- src/components/Overview.svelte | 389 +++++++++++++++++++++++ src/components/Settings.svelte | 29 ++ src/lib/events.ts | 51 +++ src/lib/fronts.ts | 46 +++ src/lib/nightclub.ts | 34 ++ src/lib/overview.svelte.ts | 75 +++++ src/lib/overview.test.ts | 204 ++++++++++++ src/lib/overview.ts | 96 ++++++ src/lib/production.ts | 121 +++++++ src/lib/safes.ts | 32 ++ src/lib/state.test.ts | 68 +++- src/lib/state.ts | 21 +- src/lib/timers.ts | 48 ++- 19 files changed, 1257 insertions(+), 20 deletions(-) create mode 100644 docs/decisions/023-overview.md create mode 100644 src/components/Overview.svelte create mode 100644 src/lib/events.ts create mode 100644 src/lib/overview.svelte.ts create mode 100644 src/lib/overview.test.ts create mode 100644 src/lib/overview.ts diff --git a/CLAUDE.md b/CLAUDE.md index 54c69d2..5fd2f84 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,6 +29,7 @@ Svelte 5 + TypeScript + Vite. Public repository. | Value bonus for owning a front | `ownedBonus` in `data/production.ts` (019) | | Shut-down businesses (owned, not running) | `isRunning` in `lib/properties.ts` (020) | | Real-time timers: Lucky Wheel, Mansion boost | `data/timers.ts`, `lib/timers.ts` (022) | +| Overview tab, next events, notifications | `lib/events.ts`, `lib/overview.ts` (dec. 023) | | Theme tokens | `src/app.css` (decision 009) | | Link previews (Open Graph), preview image | `index.html`, `scripts/og-image.html` | | CI, templates | `.github/` | diff --git a/README.md b/README.md index afe7f8e..9a4dba4 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ emptying. run on the wall clock even when LSOS is closed. - **Sourced game data.** Rates and values come from sources such as gta.wiki. Where none exists for your upgrades, LSOS uses a best estimate and labels those numbers "(est.)". +- **Overview for a second screen.** One page lists what is ready now and what comes next across + every tracker, soonest first, with the money waiting. Browser notifications are optional. - **Resync anytime.** When an estimate drifts from what the game shows, click the value and type the real one. - **Weekly events** (bonuses, discounts) come from a data file in this repo that's updated weekly. diff --git a/docs/decisions/023-overview.md b/docs/decisions/023-overview.md new file mode 100644 index 0000000..1aff520 --- /dev/null +++ b/docs/decisions/023-overview.md @@ -0,0 +1,38 @@ +# 023: Overview tab: every tracker's next event in one list, made for a second screen + +2026-10-15, issue #15. Builds on decisions 003, 012, 013, 015, 016, 017, 018 and 022. + +## Decision + +- **Shared shape:** each tracker gives its next events as `NextEvent` (`src/lib/events.ts`): what + happens, where, the time left and on which clock (play or real), whether it is good or bad news + once due, the action to take, the bar that moves toward it, the money waiting and whether it + rests on an estimate. The trackers build them: `productionEvents`, `goodsEvents`, `safeEvents`, + `frontsEvents`, `timerEvents`. `src/lib/overview.ts` only gathers and sorts them; the Overview + knows nothing else of a tracker. +- **Events:** per running business with a known rate, whichever comes first: stock full (Sell) or + supplies out (Resupply), or "Supplies arrive" while bought supplies are on their way. The Acid + Lab boost and the Mansion boost when they can be started; the Lucky Wheel spin; each nightclub + goods type a technician makes, when full; each owned safe, when full; the Money Fronts safe, + when full, and the Car Wash at max heat, as the safe card shows them. Nightclub popularity is + left out: it falls below the top payout within a day of being maxed, so it would always be due. +- **Order:** due items first, bad news (something stopped) before good news, then the rest by + time left. Play and real time are compared directly: they run at the same pace while playing. +- **Layout, made to stay open on a 1080p second screen without scrolling:** a strip with the + number of items ready and the money waiting (business stock at the value the cards show, + nightclub goods, safes, total); "Ready now" as large cards with the action first; "Coming up" + as one line per item (time, name, bar, value) in two columns. Every item links to the tab where + it is handled. While play time is paused, play-time countdowns turn grey and the heading says + they wait for Start. +- **Notifications:** opt-in in Settings ("Alerts"). The browser is asked for permission when the + box is ticked. One notification per item that becomes due while the LSOS page is not focused; + items already due when LSOS opens, or when notifications are turned on, do not notify. +- **Saved state** (version 12): `alerts: { notify }`, false by default. + +## Why + +A player glancing at a second screen wants three answers: what to do now, what comes next and +how much money is waiting. Actions as verbs ("Sell", "Resupply", "Collect") answer the first +without reading further. The shape came from the trackers that exist; each one fills it from its +own logic, so the Overview's numbers are the cards' numbers. Quick actions on the Overview (mark +sold, collected, spun) are left for later: they need each tracker's undo and resync rules. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 04ca7b2..2d666db 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -33,3 +33,4 @@ free number, add one line below. **Replacing one:** write a new decision, then m - [020](020-shut-down.md) Shut-down businesses: a Running/Shut down button next to Owned for biker businesses and the Bunker; shutting down clears the stock; no production, card greyed, nightclub goods locked, nothing to the Money Fronts safe - [021](021-no-heist-tracker.md) No heist cooldown tracker: only Cayo Perico has one worth a timer; Heists tab removed - [022](022-real-time-timers.md) Timers tab on real time: Lucky Wheel (24 h) and Mansion production boost (×3 for 24 h, 24 h cooldown, not the same business twice in a row; with the Acid Lab boost: est.) +- [023](023-overview.md) Overview tab: trackers give a shared `NextEvent`, due first then soonest, money waiting, sized for a 1080p second screen; opt-in browser notifications diff --git a/src/App.svelte b/src/App.svelte index 571cc87..16c6d55 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -1,5 +1,6 @@ + +
+
+
0} class:good={due.length > 0 && stopped === 0}> + Ready now + {due.length} +
+ {#each tiles as tile (tile.label)} +
+ {tile.label}{tile.est ? ' (est.)' : ''} + {money(tile.value)} +
+ {/each} +
+ Waiting in total{holdings.estimated ? ' (est.)' : ''} + {money(total)} +
+
+ +
+

Ready now

+ {#if due.length === 0} +

Nothing to do right now.

+ {:else} + + {/if} +
+ +
+

+ Coming up + {#if paused}Paused: play-time countdowns wait for Start{/if} +

+ {#if coming.length === 0} +

+ Nothing else is tracked. Mark what you own in Settings. +

+ {:else} + + {/if} +
+
+ + diff --git a/src/components/Settings.svelte b/src/components/Settings.svelte index 3552584..625ba3a 100644 --- a/src/components/Settings.svelte +++ b/src/components/Settings.svelte @@ -1,6 +1,7 @@
@@ -54,7 +76,10 @@ {:else if active === 'businesses'}
+ + diff --git a/src/components/Safes.svelte b/src/components/Safes.svelte index 9db9b68..7459613 100644 --- a/src/components/Safes.svelte +++ b/src/components/Safes.svelte @@ -50,7 +50,7 @@ {:else}
{#if fronts} - + {#if open === CAR_WASH} {#each frontProperties as property (property.id)} @@ -79,7 +79,7 @@ {/if} {#each shown as { safe, property } (property.id)} - + {#if open === property.id} {/if} diff --git a/src/components/Settings.svelte b/src/components/Settings.svelte index 625ba3a..9f551eb 100644 --- a/src/components/Settings.svelte +++ b/src/components/Settings.svelte @@ -1,22 +1,31 @@ -
+

{#if move}
rank(a) - rank(b) || Math.max(a.inMs, 0) - Math.max(b.inMs, 0)) } -/** The events due now that were not due in `before` (ids), for alerts. */ -export function newlyDue(before: ReadonlySet, events: readonly NextEvent[]): NextEvent[] { - return events.filter((e) => isDue(e) && !before.has(e.id)) +/** Whether an alert `leadMs` ahead is due for the event: it is that close or due. */ +export function isNear(event: NextEvent, leadMs: number): boolean { + return event.inMs <= leadMs } -/** The ids of the events due now. */ -export function dueIds(events: readonly NextEvent[]): Set { - return new Set(events.filter(isDue).map((e) => e.id)) +/** The ids of the events within `leadMs` of being due, or due. */ +export function nearIds(events: readonly NextEvent[], leadMs: number): Set { + return new Set(events.filter((e) => isNear(e, leadMs)).map((e) => e.id)) +} + +/** The events that came within `leadMs` since `before` (ids, from `nearIds`): one alert each. */ +export function newlyNear( + before: ReadonlySet, + events: readonly NextEvent[], + leadMs: number, +): NextEvent[] { + return events.filter((e) => isNear(e, leadMs) && !before.has(e.id)) } diff --git a/src/lib/fronts.ts b/src/lib/fronts.ts index d6eb0c0..4d5ceb9 100644 --- a/src/lib/fronts.ts +++ b/src/lib/fronts.ts @@ -228,6 +228,7 @@ export function frontsEvents( value: state.money, estimated: false, tab: 'safes', + focus: CAR_WASH, }) } const heat = maxHeatInMs(state, owned, CAR_WASH) @@ -243,6 +244,7 @@ export function frontsEvents( level: { value: heatOf(state, CAR_WASH), max: MAX_HEAT, kind: 'heat' }, estimated: true, tab: 'safes', + focus: CAR_WASH, }) } return events diff --git a/src/lib/nightclub.ts b/src/lib/nightclub.ts index 5408ae8..4672fdb 100644 --- a/src/lib/nightclub.ts +++ b/src/lib/nightclub.ts @@ -164,6 +164,7 @@ export function goodsEvents( value: goodsValue(goods, crates), estimated: false, tab: 'businesses', + focus: nightclub.id, } }) } diff --git a/src/lib/overview.svelte.ts b/src/lib/overview.svelte.ts index 66244c4..877dbf7 100644 --- a/src/lib/overview.svelte.ts +++ b/src/lib/overview.svelte.ts @@ -1,6 +1,7 @@ -// The Overview's events and holdings on the live state, and browser notifications when one -// becomes due (issue #15). Logic lives in overview.ts and events.ts; this file only wires it to -// the clock, the store and the browser. +// The Overview's events, holdings and popularity on the live state, its quick actions, and the +// alerts when an item comes due: browser notifications and pop-ups (issue #15). Logic lives in +// overview.ts and events.ts; this file only wires it to the clock, the store and the browser. +import { untrack } from 'svelte' import { SUPPLY_PURCHASE } from '../data/business-events' import { GOODS } from '../data/nightclub' import { PRODUCTION } from '../data/production' @@ -8,8 +9,15 @@ import { PROPERTIES } from '../data/properties' import { SAFES } from '../data/safes' import { LUCKY_WHEEL, MANSION_BOOST } from '../data/timers' import { clocks } from './clock.svelte' -import { dueIds, newlyDue } from './events' -import { allEvents, holdings, type TrackerData } from './overview' +import { isDue, nearIds, newlyNear, type NextEvent } from './events' +import { + allEvents, + clubPopularity, + holdings, + quickAction, + type QuickResult, + type TrackerData, +} from './overview' import { store } from './store.svelte' const DATA: TrackerData = { @@ -25,51 +33,86 @@ const DATA: TrackerData = { class Overview { events = $derived(allEvents(store.state, DATA, clocks.now)) holdings = $derived(holdings(store.state, DATA, clocks.now)) + popularity = $derived(clubPopularity(store.state, DATA)) + /** Ids of the events with a pop-up open, oldest first. */ + popups: string[] = $state([]) } export const overview = new Overview() +/** Marks an event done from the Overview (its `quick`). Returns what undoes it, if done. */ +export function quick(event: NextEvent, choice?: string): QuickResult | null { + const result = quickAction(store.state, DATA, event.id, Date.now(), choice) + if (result) apply(result.change) + return result +} + +/** Takes back a quick action on event `id`: it is ready again, but that is no news to alert. */ +export function undoQuick(id: string, undo: QuickResult['undo']): void { + Object.assign(store.state, undo) + seen?.add(id) +} + +function apply(change: QuickResult['change']): void { + Object.assign(store.state, change) +} + +export function dismissPopup(id: string): void { + overview.popups = overview.popups.filter((p) => p !== id) +} + /** Whether this browser can show notifications at all. */ export function notificationsSupported(): boolean { return typeof Notification !== 'undefined' } /** - * Turns notifications on after asking the browser for permission, or off. Returns why they - * could not be turned on, if they could not. + * Turns browser notifications on after asking the browser for permission, or off. Returns why + * they could not be turned on, if they could not. */ -export async function setNotify(on: boolean): Promise { - if (!on) { - store.state.alerts = { notify: false } - return null - } - if (!notificationsSupported()) return 'This browser cannot show notifications.' - const permission = await Notification.requestPermission() - if (permission !== 'granted') { - return 'The browser blocked notifications for LSOS. Allow them in its site settings, then try again.' +export async function setBrowserAlerts(on: boolean): Promise { + if (on) { + if (!notificationsSupported()) return 'This browser cannot show notifications.' + const permission = await Notification.requestPermission() + if (permission !== 'granted') { + return 'The browser blocked notifications for LSOS. Allow them in its site settings, then try again.' + } } - store.state.alerts = { notify: true } + store.state.alerts = { ...store.state.alerts, browser: on } return null } -// Due when LSOS opened (or when notifications were turned on) counts as already seen. +function notify(event: NextEvent): void { + const body = isDue(event) + ? event.action && `${event.action} now.` + : `In ${Math.ceil(event.inMs / 60_000)} min.` + new Notification(`${event.source}: ${event.what}`, { body: body || undefined, tag: event.id }) +} + +// What was already near when LSOS opened, or when the alert time changed, alerts no one. let seen: Set | null = null +let seenLead = -1 $effect.root(() => { $effect(() => { const events = overview.events - const on = store.state.alerts.notify - const due = dueIds(events) - // Only while the user looks elsewhere, e.g. at the game: the page shows it otherwise. - const allowed = notificationsSupported() && Notification.permission === 'granted' - if (seen && on && allowed && !document.hasFocus()) { - for (const event of newlyDue(seen, events)) { - new Notification(`${event.source}: ${event.what}`, { - body: event.action ? `${event.action} now.` : undefined, - tag: event.id, - }) + const { browser, popup, leadMs, unfocusedOnly } = store.state.alerts + const near = nearIds(events, leadMs) + if (seen && seenLead === leadMs && !(unfocusedOnly && document.hasFocus())) { + const fresh = newlyNear(seen, events, leadMs) + if (browser && notificationsSupported() && Notification.permission === 'granted') { + fresh.forEach(notify) + } + if (popup && fresh.length > 0) { + untrack(() => (overview.popups = [...overview.popups, ...fresh.map((e) => e.id)])) } } - seen = on ? due : null + seen = near + seenLead = leadMs + // A pop-up closes on its own once its item is handled (sold, collected, spun). + untrack(() => { + const open = overview.popups.filter((id) => near.has(id)) + if (open.length !== overview.popups.length) overview.popups = open + }) }) }) diff --git a/src/lib/overview.test.ts b/src/lib/overview.test.ts index ec203ad..6f167b4 100644 --- a/src/lib/overview.test.ts +++ b/src/lib/overview.test.ts @@ -5,8 +5,8 @@ import { PRODUCTION } from '../data/production' import { PROPERTIES } from '../data/properties' import { SAFES } from '../data/safes' import { LUCKY_WHEEL, MANSION_BOOST } from '../data/timers' -import { dueIds, newlyDue, sortEvents, type NextEvent } from './events' -import { allEvents, holdings, type TrackerData } from './overview' +import { nearIds, newlyNear, sortEvents, type NextEvent } from './events' +import { allEvents, clubPopularity, holdings, quickAction, type TrackerData } from './overview' import type { OwnedProperties } from './properties' import { DAY_MS } from './safes' import { defaultState, type AppState } from './state' @@ -65,10 +65,11 @@ describe('allEvents', () => { value: 945_000, estimated: false, tab: 'businesses', + focus: 'bunker', }) }) - it('gives supplies out when that comes first, and arriving once bought', () => { + it('gives supplies out when that comes first', () => { const owned = owning({ bunker: { equipment: true, staff: true } }) const s = state({ properties: owned, businesses: { bunker: { stock: 50, supplies: 100 } } }) expect(find(allEvents(s, DATA, NOW), 'supplies:bunker')).toMatchObject({ @@ -76,15 +77,36 @@ describe('allEvents', () => { tone: 'bad', action: 'Resupply', }) - const bought = state({ - properties: owned, - businesses: { bunker: { stock: 50, supplies: 0, suppliesDueAt: NOW + 4 * MIN } }, + }) + + describe('with supplies on their way', () => { + const owned = owning({ bunker: { equipment: true, staff: true } }) + const at = (stock: number, supplies: number) => + allEvents( + state({ + properties: owned, + businesses: { bunker: { stock, supplies, suppliesDueAt: NOW + 4 * MIN } }, + }), + DATA, + NOW, + ) + + it('counts them from their arrival', () => { + // Empty: nothing is made for 4 minutes, then full supplies last 140 minutes. + expect(find(at(50, 0), 'supplies:bunker')?.inMs).toBe(144 * MIN) + // Supplies for 2 more units: they keep running until the delivery tops them up. + expect(find(at(50, 10), 'supplies:bunker')?.inMs).toBeCloseTo(144 * MIN) + }) + + it('gives stock full if it fills before or after they arrive', () => { + expect(find(at(99, 100), 'stock:bunker')?.inMs).toBeCloseTo(7 * MIN) + // No supplies: the last unit starts once they arrive. + expect(find(at(99, 0), 'stock:bunker')?.inMs).toBeCloseTo(11 * MIN) + }) + + it('shows no countdown of its own', () => { + expect(at(50, 0).map((e) => e.id)).toEqual(['lucky-wheel', 'supplies:bunker']) }) - const events = allEvents(bought, DATA, NOW) - expect(find(events, 'supplies:bunker')).toBeUndefined() - expect(find(events, 'delivery:bunker')).toMatchObject({ inMs: 4 * MIN, clock: 'real' }) - // Once arrived, the supplies count again. - expect(find(allEvents(bought, DATA, NOW + 4 * MIN), 'supplies:bunker')?.inMs).toBe(140 * MIN) }) it('counts the Mansion boost and offers it again after its cooldown', () => { @@ -165,6 +187,7 @@ describe('sortEvents', () => { tone, estimated: false, tab: 'overview', + focus: id, }) it('puts due bad news first, then due good news, then the soonest', () => { @@ -172,10 +195,13 @@ describe('sortEvents', () => { expect(sortEvents(events).map((e) => e.id)).toEqual(['out', 'ready', 'soon', 'later']) }) - it('finds what became due since the last look', () => { - const before = dueIds([event('a', 0), event('b', 3)]) - const now = [event('a', 0), event('b', 0), event('c', 2)] - expect(newlyDue(before, now).map((e) => e.id)).toEqual(['b']) + it('finds what came within the alert time since the last look', () => { + const before = nearIds([event('a', 0), event('b', 3 * MIN), event('c', 20 * MIN)], 0) + const now = [event('a', 0), event('b', 0), event('c', 9 * MIN), event('d', 11 * MIN)] + expect(newlyNear(before, now, 0).map((e) => e.id)).toEqual(['b']) + // Alerting 10 minutes ahead: c is that close now. + expect(newlyNear(nearIds(now, 10 * MIN), now, 10 * MIN)).toEqual([]) + expect(newlyNear(before, now, 10 * MIN).map((e) => e.id)).toEqual(['b', 'c']) }) }) @@ -202,3 +228,53 @@ describe('holdings', () => { }) }) }) + +describe('quickAction', () => { + it('spins the Lucky Wheel, with an undo', () => { + const s = state({ timers: { luckyWheelSpunAt: 1 } }) + expect(quickAction(s, DATA, 'lucky-wheel', NOW)).toEqual({ + change: { timers: { luckyWheelSpunAt: NOW } }, + undo: { timers: { luckyWheelSpunAt: 1 } }, + done: 'Lucky Wheel spun', + }) + }) + + it('starts the Mansion boost on a business it can take', () => { + const owned = owning({ mansion: {}, bunker: {}, 'mc-weed': {} }) + const last = { propertyId: 'bunker', startedAt: NOW - 25 * HOUR } + const s = state({ properties: owned, timers: { mansionBoost: last } }) + const event = find(allEvents(s, DATA, NOW), 'mansion-boost') + expect(event?.quick?.choices).toEqual([{ id: 'mc-weed', label: 'Weed Farm' }]) + expect(quickAction(s, DATA, 'mansion-boost', NOW, 'mc-weed')?.change).toEqual({ + timers: { mansionBoost: { propertyId: 'mc-weed', startedAt: NOW } }, + }) + // Not the same business twice in a row, and not without a pick. + expect(quickAction(s, DATA, 'mansion-boost', NOW, 'bunker')).toBeNull() + expect(quickAction(s, DATA, 'mansion-boost', NOW)).toBeNull() + }) + + it('starts a production boost', () => { + const s = state({ properties: owning({ 'acid-lab': { equipment: true } }) }) + expect(quickAction(s, DATA, 'boost:acid-lab', NOW)).toEqual({ + change: { boosts: { 'acid-lab': { startedAt: NOW, unitsLeft: 80 } } }, + undo: { boosts: {} }, + done: 'Acid Lab boost started', + }) + }) + + it('has nothing for other events', () => { + expect(quickAction(defaultState(), DATA, 'stock:bunker', NOW)).toBeNull() + expect(quickAction(defaultState(), DATA, 'boost:bunker', NOW)).toBeNull() + }) +}) + +describe('clubPopularity', () => { + it('gives popularity and the payout it makes, with a nightclub owned', () => { + expect(clubPopularity(defaultState(), DATA)).toBeNull() + const s = state({ + properties: owning({ nightclub: {} }), + safes: { nightclub: { money: 0, sinceMs: 0, popularity: 85 } }, + }) + expect(clubPopularity(s, DATA)).toEqual({ value: 85, payout: 25_000, best: 50_000 }) + }) +}) diff --git a/src/lib/overview.ts b/src/lib/overview.ts index 892fcdd..6ae247f 100644 --- a/src/lib/overview.ts +++ b/src/lib/overview.ts @@ -15,14 +15,15 @@ import { farValueEstimated, matchingRate, productionEvents, + startBoost, stockValue, valueBonus, } from './production' import { isOwned } from './properties' import { settleBars } from './resync' -import { safeEvents } from './safes' +import { bestPayout, dailyPayout, emptySafe, safeEvents } from './safes' import type { AppState } from './state' -import { timerEvents, type MansionBoostData } from './timers' +import { boostableBusinesses, timerEvents, type MansionBoostData } from './timers' /** The game data the trackers run on, passed in so tests can use their own. */ export interface TrackerData { @@ -94,3 +95,80 @@ export function holdings(state: AppState, data: TrackerData, now: number): Holdi if (ownedFronts(owned).length > 0) safes += state.safes[CAR_WASH]?.money ?? 0 return { stock, goods, safes, estimated } } + +/** A quick action's result: what to change in the state, what undoes it, and what was done. */ +export interface QuickResult { + change: Partial + undo: Partial + done: string +} + +/** + * Marks event `id` done from the Overview at `now` (its `quick`): the Lucky Wheel spun, a + * production boost or the Mansion boost (on `choice`) started. Null if the event has none, or + * the choice is not one the boost can take. + */ +export function quickAction( + state: AppState, + data: TrackerData, + id: string, + now: number, + choice?: string, +): QuickResult | null { + const { timers, boosts } = state + const label = (propertyId: string) => + data.catalog.find((p) => p.id === propertyId)?.label ?? propertyId + if (id === 'lucky-wheel') { + return { + change: { timers: { ...timers, luckyWheelSpunAt: now } }, + undo: { timers }, + done: 'Lucky Wheel spun', + } + } + if (id === 'mansion-boost') { + const choices = boostableBusinesses( + data.catalog, + state.properties, + data.mansion, + timers.mansionBoost, + ) + if (!choice || !choices.some((p) => p.id === choice)) return null + return { + change: { timers: { ...timers, mansionBoost: { propertyId: choice, startedAt: now } } }, + undo: { timers }, + done: `Mansion boost started on ${label(choice)}`, + } + } + const boosted = id.startsWith('boost:') ? id.slice('boost:'.length) : undefined + const boost = data.productions.find((p) => p.propertyId === boosted)?.boost + if (!boosted || !boost) return null + return { + change: { boosts: { ...boosts, [boosted]: startBoost(boost, now) } }, + undo: { boosts }, + done: `${label(boosted)} boost started`, + } +} + +/** Nightclub popularity, if a nightclub is owned: it sets the safe's payout (decision 016). */ +export interface Popularity { + /** Percent. */ + value: number + /** The payout at this popularity, and the most it can be. */ + payout: number + best: number +} + +export function clubPopularity(state: AppState, data: TrackerData): Popularity | null { + for (const safe of data.safes) { + const property = data.catalog.find((p) => p.id === safe.propertyId) + if (!safe.popularity || !property || !isOwned(state.properties, property.id)) continue + const saved = state.safes[safe.propertyId] ?? emptySafe(safe) + const value = saved.popularity ?? 0 + return { + value, + payout: dailyPayout(safe, state.properties, property, value), + best: bestPayout(safe, state.properties, property), + } + } + return null +} diff --git a/src/lib/production.ts b/src/lib/production.ts index 8494b68..36974f5 100644 --- a/src/lib/production.ts +++ b/src/lib/production.ts @@ -142,25 +142,48 @@ export function forecast( return later(plainForecast(produce(bars, production, fast, boostMs), production, rate), boostMs) } +/** The Mansion boost as production needs it: the last one started, and its data. */ +export interface MansionRun { + boost: MansionBoost | undefined + data: MansionBoostData +} + /** - * What comes first from wall-clock time `now`, with the production boost and the Mansion boost - * (until `mansion.endsAt`) as they run then. + * What comes first for business `id` from wall-clock time `now`, with its production boost and + * the Mansion boost as they run then. Supplies bought and still on their way count from their + * arrival (issue #15): until then the business runs on what it has, and if the stock fills + * first that is what comes first. Play time is taken to run along with real time meanwhile. */ export function forecastAt( bars: Bars, production: Production, rate: KnownRate, + id: string, now: number, - { boost, mansion }: { boost?: Boost; mansion?: { endsAt: number; speed: number } } = {}, + { boost, mansion, fillsTo }: { boost?: Boost; mansion?: MansionRun; fillsTo: number }, ): Forecast { + const due = bars.suppliesDueAt + if (due !== undefined && due > now) { + const { suppliesDueAt: _, ...rest } = bars + const first = forecastAt(rest, production, rate, id, now, { boost, mansion, fillsTo }) + const waitMs = due - now + if (first.fullInMs !== null && first.fullInMs <= waitMs) return first + const run = runBusiness(bars, production, rate, id, now, due, fillsTo, boost, mansion) + const after = { boost: run.boost, mansion, fillsTo } + return later(forecastAt(run.bars, production, rate, id, due, after), waitMs) + } const boosting = boostRunning(boost, production.boost, now) + const sped = mansion && mansionBoostOn(mansion.boost, mansion.data, id, now) return forecast( bars, production, rate, boosting ? boost!.unitsLeft : 0, - mansion && now < mansion.endsAt - ? { msLeft: mansion.endsAt - now, speed: mansion.speed } + sped + ? { + msLeft: mansion.boost!.startedAt + mansion.data.durationMs - now, + speed: mansion.data.speed, + } : undefined, ) } @@ -333,6 +356,40 @@ function step( return { bars: run.bars, boost: { ...boost, unitsLeft: run.unitsLeft } } } +/** + * Business `id` producing from `start` to `end` (wall clock, all of it play time): supplies + * bought count from their arrival, and a boost runs from its start to its end, as does the + * Mansion boost for the business it boosts. The boost counts the units it made. + */ +function runBusiness( + bars: Bars, + production: Production, + rate: KnownRate, + id: string, + start: number, + end: number, + fillsTo: number, + boost: Boost | undefined, + mansion: MansionRun | undefined, +): { bars: Bars; boost: Boost | undefined } { + // Where something changes during the stretch, in wall-clock time. + const ends = boost && production.boost ? boost.startedAt + production.boost.maxRealMs : 0 + const sped = mansion?.boost?.propertyId === id ? mansion.boost : undefined + const spedEnds = sped && sped.startedAt + mansion!.data.durationMs + const cuts = [bars.suppliesDueAt, boost?.startedAt, boost && ends, sped?.startedAt, spedEnds] + .filter((t): t is number => t !== undefined && t > start && t < end) + .sort((a, b) => a - b) + let at = start + for (const cut of [...cuts, end]) { + bars = settleBars(bars, at, fillsTo) + const fast = mansion && mansionBoostOn(mansion.boost, mansion.data, id, at) + const r = fast ? spedUp(rate, mansion.data.speed) : rate + ;({ bars, boost } = step(bars, production, r, boost, cut - at, at)) + at = cut + } + return { bars: settleBars(bars, end, fillsTo), boost } +} + /** * Counts a stretch of play time for every running business with a known rate: a shut-down one * keeps its bars as they are (issue #38). The stretch is @@ -351,7 +408,7 @@ export function produceAll( now: number, fillsTo: number, boosts: BoostsById = {}, - mansion?: { boost: MansionBoost | undefined; data: MansionBoostData }, + mansion?: MansionRun, ): { businesses: BusinessBarsById; boosts: BoostsById } { let next = { businesses, boosts } for (const production of productions) { @@ -360,25 +417,17 @@ export function produceAll( const rate = rateFor(production, owned, property) if (!rate) continue const id = property.id - let bars = businesses[id] ?? emptyBars() - let boost = boosts[id] - // Where something changes during the stretch, in wall-clock time. - const start = now - playMs - const ends = boost && production.boost ? boost.startedAt + production.boost.maxRealMs : 0 - const sped = mansion?.boost?.propertyId === id ? mansion.boost : undefined - const spedEnds = sped && sped.startedAt + mansion!.data.durationMs - const cuts = [bars.suppliesDueAt, boost?.startedAt, boost && ends, sped?.startedAt, spedEnds] - .filter((t): t is number => t !== undefined && t > start && t < now) - .sort((a, b) => a - b) - let at = start - for (const cut of [...cuts, now]) { - bars = settleBars(bars, at, fillsTo) - const fast = mansion && mansionBoostOn(mansion.boost, mansion.data, id, at) - const r = fast ? spedUp(rate, mansion.data.speed) : rate - ;({ bars, boost } = step(bars, production, r, boost, cut - at, at)) - at = cut - } - bars = settleBars(bars, now, fillsTo) + const { bars, boost } = runBusiness( + businesses[id] ?? emptyBars(), + production, + rate, + id, + now - playMs, + now, + fillsTo, + boosts[id], + mansion, + ) if (bars !== businesses[id]) next = { ...next, businesses: { ...next.businesses, [id]: bars } } if (boost !== boosts[id] && boost) next = { ...next, boosts: { ...next.boosts, [id]: boost } } } @@ -386,8 +435,8 @@ export function produceAll( } /** - * What comes next for each running business with a known rate (issue #15): stock full, or - * supplies out (or arriving, if bought), and a production boost that is ready to start again. + * What comes next for each running business with a known rate (issue #15): stock full or + * supplies out, counting supplies on their way, and a production boost ready to start again. */ export function productionEvents( businesses: BusinessBarsById, @@ -397,7 +446,7 @@ export function productionEvents( now: number, fillsTo: number, boosts: BoostsById, - mansion: { boost: MansionBoost | undefined; data: MansionBoostData }, + mansion: MansionRun, ): NextEvent[] { const events: NextEvent[] = [] for (const production of productions) { @@ -409,12 +458,7 @@ export function productionEvents( const bars = settleBars(businesses[id] ?? emptyBars(), now, fillsTo) const boost = boosts[id] const sped = mansionBoostOn(mansion.boost, mansion.data, id, now) - const ahead = forecastAt(bars, production, rate, now, { - boost, - mansion: sped - ? { endsAt: mansion.boost!.startedAt + mansion.data.durationMs, speed: mansion.data.speed } - : undefined, - }) + const ahead = forecastAt(bars, production, rate, id, now, { boost, mansion, fillsTo }) const parts = estimatedParts(rate) // Both boosts at once: no source gives the speed (BusinessProduction says so). const estimated = parts.production || (sped && boostRunning(boost, production.boost, now)) @@ -434,18 +478,7 @@ export function productionEvents( value: value.far ?? value.nearby, estimated: estimated || parts.value || farValueEstimated(production, owned), tab, - }) - } else if (bars.suppliesDueAt !== undefined) { - events.push({ - id: `delivery:${id}`, - source, - what: 'Supplies arrive', - inMs: Math.max(0, bars.suppliesDueAt - now), - clock: 'real', - tone: 'good', - level: { value: bars.supplies ?? 0, max: 100, kind: 'supplies' }, - estimated: false, - tab, + focus: id, }) } else { events.push({ @@ -459,6 +492,7 @@ export function productionEvents( level: { value: bars.supplies ?? 0, max: 100, kind: 'supplies' }, estimated, tab, + focus: id, }) } if ( @@ -476,6 +510,8 @@ export function productionEvents( tone: 'good', estimated: false, tab, + focus: id, + quick: { label: 'Started' }, }) } } diff --git a/src/lib/safes.ts b/src/lib/safes.ts index 8a15216..1bf1b93 100644 --- a/src/lib/safes.ts +++ b/src/lib/safes.ts @@ -212,6 +212,7 @@ export function safeEvents( value: state.money, estimated: estimatedParts(safe, owned, property).payout, tab: 'safes', + focus: property.id, }, ] }) diff --git a/src/lib/state.test.ts b/src/lib/state.test.ts index 0fdff33..b9feb7a 100644 --- a/src/lib/state.test.ts +++ b/src/lib/state.test.ts @@ -9,6 +9,9 @@ import { type Migration, } from './state' +// Version 12 defaults: alerts off, 10 minutes ahead, also while focused. +const ALERTS = { browser: false, popup: false, leadMs: 600_000, unfocusedOnly: false } + describe('parseState', () => { it('round-trips the default state', () => { const result = parseState(serializeState(defaultState())) @@ -34,7 +37,7 @@ describe('parseState', () => { safes: {}, boosts: {}, timers: {}, - alerts: { notify: false }, + alerts: ALERTS, }, fromVersion: 1, }) @@ -54,7 +57,7 @@ describe('parseState', () => { safes: {}, boosts: {}, timers: {}, - alerts: { notify: false }, + alerts: ALERTS, }, fromVersion: 2, }) @@ -77,7 +80,7 @@ describe('parseState', () => { safes: {}, boosts: {}, timers: {}, - alerts: { notify: false }, + alerts: ALERTS, }, fromVersion: 3, }) @@ -101,7 +104,7 @@ describe('parseState', () => { safes: {}, boosts: {}, timers: {}, - alerts: { notify: false }, + alerts: ALERTS, }, fromVersion: 4, }) @@ -125,7 +128,7 @@ describe('parseState', () => { safes: {}, boosts: {}, timers: {}, - alerts: { notify: false }, + alerts: ALERTS, }, fromVersion: 5, }) @@ -148,7 +151,7 @@ describe('parseState', () => { safes: {}, boosts: {}, timers: {}, - alerts: { notify: false }, + alerts: ALERTS, }, fromVersion: 6, }) @@ -166,7 +169,7 @@ describe('parseState', () => { } expect(parseState(JSON.stringify(sample))).toEqual({ ok: true, - state: { ...sample, version: 12, boosts: {}, timers: {}, alerts: { notify: false } }, + state: { ...sample, version: 12, boosts: {}, timers: {}, alerts: ALERTS }, fromVersion: 7, }) }) @@ -184,7 +187,7 @@ describe('parseState', () => { } expect(parseState(JSON.stringify(sample))).toEqual({ ok: true, - state: { ...sample, version: 12, timers: {}, alerts: { notify: false } }, + state: { ...sample, version: 12, timers: {}, alerts: ALERTS }, fromVersion: 8, }) }) @@ -202,7 +205,7 @@ describe('parseState', () => { } expect(parseState(JSON.stringify(sample))).toEqual({ ok: true, - state: { ...sample, version: 12, timers: {}, alerts: { notify: false } }, + state: { ...sample, version: 12, timers: {}, alerts: ALERTS }, fromVersion: 9, }) }) @@ -220,12 +223,12 @@ describe('parseState', () => { } expect(parseState(JSON.stringify(sample))).toEqual({ ok: true, - state: { ...sample, version: 12, timers: {}, alerts: { notify: false } }, + state: { ...sample, version: 12, timers: {}, alerts: ALERTS }, fromVersion: 10, }) }) - it('migrates a version 11 sample, with notifications off', () => { + it('migrates a version 11 sample, with alerts off', () => { const sample = { version: 11, clock: { trackOnOpen: false }, @@ -239,18 +242,19 @@ describe('parseState', () => { } expect(parseState(JSON.stringify(sample))).toEqual({ ok: true, - state: { ...sample, version: 12, alerts: { notify: false } }, + state: { ...sample, version: 12, alerts: ALERTS }, fromVersion: 11, }) }) - it('keeps the notification setting', () => { - const text = JSON.stringify({ ...defaultState(), alerts: { notify: true } }) - expect(parseState(text)).toMatchObject({ ok: true, state: { alerts: { notify: true } } }) + it('keeps the alert settings', () => { + const alerts = { browser: true, popup: true, leadMs: 0, unfocusedOnly: true } + const text = JSON.stringify({ ...defaultState(), alerts }) + expect(parseState(text)).toMatchObject({ ok: true, state: { alerts } }) }) - it('rejects an unreadable notification setting', () => { - const text = JSON.stringify({ ...defaultState(), alerts: { notify: 'yes' } }) + it('rejects unreadable alert settings', () => { + const text = JSON.stringify({ ...defaultState(), alerts: { ...ALERTS, leadMs: -1 } }) expect(parseState(text)).toMatchObject({ ok: false, reason: 'invalid' }) }) diff --git a/src/lib/state.ts b/src/lib/state.ts index dac2fe5..339f190 100644 --- a/src/lib/state.ts +++ b/src/lib/state.ts @@ -34,12 +34,23 @@ export interface AppState { boosts: BoostsById /** Real-time timers: the Lucky Wheel and the Mansion boost (issues #13, #14). */ timers: Timers - /** Browser notifications when an Overview item becomes due (issue #15); off until opted in. */ + /** Alerts when an Overview item comes due (issue #15); off until opted in. */ alerts: AlertSettings } export interface AlertSettings { - notify: boolean + /** Browser (system) notifications; the browser asks for permission first. */ + browser: boolean + /** Pop-ups inside LSOS, on any tab. */ + popup: boolean + /** How long before an item is due to alert; 0 alerts when it is due. */ + leadMs: number + /** Alert only while LSOS is not the focused window, e.g. while in the game. */ + unfocusedOnly: boolean +} + +export function defaultAlerts(): AlertSettings { + return { browser: false, popup: false, leadMs: 10 * 60_000, unfocusedOnly: false } } export function defaultState(): AppState { @@ -53,7 +64,7 @@ export function defaultState(): AppState { safes: {}, boosts: {}, timers: {}, - alerts: { notify: false }, + alerts: defaultAlerts(), } } @@ -86,8 +97,8 @@ export const MIGRATIONS: Readonly> = { 9: (data) => ({ ...data }), // Version 11 adds the real-time timers (issues #13, #14); none started yet. 10: (data) => ({ ...data, timers: {} }), - // Version 12 adds the alert setting (issue #15); notifications are off until opted in. - 11: (data) => ({ ...data, alerts: { notify: false } }), + // Version 12 adds the alert settings (issue #15); alerts are off until opted in. + 11: (data) => ({ ...data, alerts: defaultAlerts() }), } export type ParseResult = @@ -197,9 +208,18 @@ function toClock(data: unknown): ClockSettings | string { function toAlerts(data: unknown): AlertSettings | string { if (!isRecord(data)) return 'The alerts section is missing.' - if (typeof data.notify !== 'boolean') - return 'The saved notification setting is not true or false.' - return { notify: data.notify } + const { browser, popup, leadMs, unfocusedOnly } = data + if ( + typeof browser !== 'boolean' || + typeof popup !== 'boolean' || + typeof unfocusedOnly !== 'boolean' || + typeof leadMs !== 'number' || + !Number.isFinite(leadMs) || + leadMs < 0 + ) { + return 'The saved alert settings are not readable.' + } + return { browser, popup, leadMs, unfocusedOnly } } function isRecord(value: unknown): value is Record { diff --git a/src/lib/tabs.test.ts b/src/lib/tabs.test.ts index 4679b65..892a1b8 100644 --- a/src/lib/tabs.test.ts +++ b/src/lib/tabs.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { DEFAULT_TAB, TABS, hashForTab, tabFromHash } from './tabs' +import { DEFAULT_TAB, TABS, focusFromHash, hashForTab, tabFromHash } from './tabs' describe('tabFromHash', () => { it('round-trips every tab through its hash', () => { @@ -15,3 +15,17 @@ describe('tabFromHash', () => { expect(tabFromHash('#/nope')).toBe(DEFAULT_TAB) }) }) + +describe('focusFromHash', () => { + it('round-trips an item on a tab', () => { + const hash = hashForTab('safes', 'salvage-yard') + expect(hash).toBe('#/safes/salvage-yard') + expect(tabFromHash(hash)).toBe('safes') + expect(focusFromHash(hash)).toBe('salvage-yard') + }) + + it('is undefined without an item', () => { + expect(focusFromHash('#/safes')).toBeUndefined() + expect(focusFromHash('')).toBeUndefined() + }) +}) diff --git a/src/lib/tabs.ts b/src/lib/tabs.ts index b9935ee..3d90479 100644 --- a/src/lib/tabs.ts +++ b/src/lib/tabs.ts @@ -11,12 +11,22 @@ export type TabId = (typeof TABS)[number]['id'] export const DEFAULT_TAB: TabId = 'overview' -/** Maps a location hash like `#/safes` to a tab id, falling back to the default tab. */ +/** + * Maps a location hash like `#/safes` (or `#/safes/agency`) to a tab id, falling back to the + * default tab. + */ export function tabFromHash(hash: string): TabId { - const id = hash.replace(/^#\/?/, '') + const id = hash.replace(/^#\/?/, '').split('/')[0] return TABS.find((tab) => tab.id === id)?.id ?? DEFAULT_TAB } -export function hashForTab(id: TabId): string { - return `#/${id}` +/** What to scroll to and highlight on the tab, e.g. `agency` in `#/safes/agency` (issue #15). */ +export function focusFromHash(hash: string): string | undefined { + const rest = hash.replace(/^#\/?/, '').split('/').slice(1).join('/') + return rest ? decodeURIComponent(rest) : undefined +} + +/** The hash of a tab, or of one item on it to scroll to and highlight. */ +export function hashForTab(id: TabId, focus?: string): string { + return focus ? `#/${id}/${encodeURIComponent(focus)}` : `#/${id}` } diff --git a/src/lib/timers.ts b/src/lib/timers.ts index 2b6f1c1..977e452 100644 --- a/src/lib/timers.ts +++ b/src/lib/timers.ts @@ -91,13 +91,13 @@ export function timerEvents( tone: 'good', estimated: false, tab: 'timers', + focus: 'lucky-wheel', + quick: { label: 'Spun' }, }, ] const last = timers.mansionBoost - if ( - isOwned(owned, mansion.propertyId) && - boostableBusinesses(catalog, owned, mansion, last).length > 0 - ) { + const choices = boostableBusinesses(catalog, owned, mansion, last) + if (isOwned(owned, mansion.propertyId) && choices.length > 0) { events.push({ id: 'mansion-boost', source: catalog.find((p) => p.id === mansion.propertyId)?.label ?? 'Mansion', @@ -108,6 +108,8 @@ export function timerEvents( tone: 'good', estimated: false, tab: 'timers', + focus: 'mansion', + quick: { label: 'Started', choices: choices.map((p) => ({ id: p.id, label: p.label })) }, }) } return events From a7839f237830be2291d32c408b1516107c78bf46 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 13:03:43 +0000 Subject: [PATCH 3/4] Overview: Sold, Bought, Collected and legal job as quick actions on ready cards Every Ready now card can be marked done on the Overview. Undo puts back only what the action took, on top of progress made since. The money waiting moves to its own line on the card so names stay readable. Co-Authored-By: Claude Opus 5.5 --- docs/decisions/023-overview.md | 11 ++- docs/decisions/README.md | 2 +- src/components/Overview.svelte | 6 +- src/lib/fronts.ts | 2 + src/lib/nightclub.ts | 1 + src/lib/overview.svelte.ts | 3 +- src/lib/overview.test.ts | 82 ++++++++++++++--- src/lib/overview.ts | 160 +++++++++++++++++++++++++-------- src/lib/production.ts | 2 + src/lib/safes.ts | 1 + 10 files changed, 214 insertions(+), 56 deletions(-) diff --git a/docs/decisions/023-overview.md b/docs/decisions/023-overview.md index 597a553..3de00c8 100644 --- a/docs/decisions/023-overview.md +++ b/docs/decisions/023-overview.md @@ -29,10 +29,13 @@ - **Jump to the card:** every item links to `#//`. The tab opens, the card with that `data-focus` (a `Panel` prop) scrolls into view and glows for a moment, and the address drops back to `#/` so the same link works again. -- **Quick actions:** the Lucky Wheel ("Spun") and a production boost ("Started") are marked done - on the Overview; the Mansion boost picks its business in a dropdown first. Undo stays offered - for 15 seconds, like the undo buttons on the Timers tab. Selling, collecting and resupplying - stay on their cards: they need the resync controls (decision 010). +- **Quick actions:** every Ready now card has a button to mark it done right there: Sold (stock + or nightclub goods, as the Sold event on the card), Bought (supplies, a delivery as on the + card), Collected (a safe), Done (a legal job clears the Car Wash heat), Spun (Lucky Wheel), + Started (a production boost; the Mansion boost picks its business in a dropdown first). Undo + stays offered for 15 seconds and puts back only what the action took, on top of progress + since: stock or money made meanwhile is kept. Partial sales and corrections stay on the cards + (decision 010). - **Alerts** (Settings, all off until opted in): pop-ups inside LSOS on any tab, and browser notifications (permission asked when ticked). Both come a chosen time before an item is due (when it happens, or 1, 5, 10, 15 or 30 minutes before; default 10), once per item, and diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 29f36b8..3c6ec83 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -33,4 +33,4 @@ free number, add one line below. **Replacing one:** write a new decision, then m - [020](020-shut-down.md) Shut-down businesses: a Running/Shut down button next to Owned for biker businesses and the Bunker; shutting down clears the stock; no production, card greyed, nightclub goods locked, nothing to the Money Fronts safe - [021](021-no-heist-tracker.md) No heist cooldown tracker: only Cayo Perico has one worth a timer; Heists tab removed - [022](022-real-time-timers.md) Timers tab on real time: Lucky Wheel (24 h) and Mansion production boost (×3 for 24 h, 24 h cooldown, not the same business twice in a row; with the Acid Lab boost: est.) -- [023](023-overview.md) Overview tab: trackers give a shared `NextEvent`, due first then soonest, supplies on their way counted in the forecast, links that scroll to the card, quick actions for timers, popularity in view; alerts as pop-ups and/or notifications, set time ahead +- [023](023-overview.md) Overview tab: trackers give a shared `NextEvent`, due first then soonest, supplies on their way counted in the forecast, links that scroll to the card, quick actions with undo on every ready item, popularity in view; alerts as pop-ups and/or notifications, set time ahead diff --git a/src/components/Overview.svelte b/src/components/Overview.svelte index 92d8ab5..e2e5844 100644 --- a/src/components/Overview.svelte +++ b/src/components/Overview.svelte @@ -123,8 +123,8 @@ {event.source} {what(event)} + {#if event.value}{money(event.value)}{/if} - {#if event.value}{money(event.value)}{/if} {#if event.quick} @@ -439,8 +439,10 @@ white-space: nowrap; } - .card .value { + .money { color: var(--accent-text); + font-size: var(--text-sm); + font-variant-numeric: tabular-nums; } /* Coming up: one line per item in as many columns as fit, read down then across. */ diff --git a/src/lib/fronts.ts b/src/lib/fronts.ts index 4d5ceb9..25033df 100644 --- a/src/lib/fronts.ts +++ b/src/lib/fronts.ts @@ -229,6 +229,7 @@ export function frontsEvents( estimated: false, tab: 'safes', focus: CAR_WASH, + quick: { label: 'Collected' }, }) } const heat = maxHeatInMs(state, owned, CAR_WASH) @@ -245,6 +246,7 @@ export function frontsEvents( estimated: true, tab: 'safes', focus: CAR_WASH, + quick: { label: 'Done' }, }) } return events diff --git a/src/lib/nightclub.ts b/src/lib/nightclub.ts index 4672fdb..5544633 100644 --- a/src/lib/nightclub.ts +++ b/src/lib/nightclub.ts @@ -165,6 +165,7 @@ export function goodsEvents( estimated: false, tab: 'businesses', focus: nightclub.id, + quick: { label: 'Sold' }, } }) } diff --git a/src/lib/overview.svelte.ts b/src/lib/overview.svelte.ts index 877dbf7..0bc5f95 100644 --- a/src/lib/overview.svelte.ts +++ b/src/lib/overview.svelte.ts @@ -28,6 +28,7 @@ const DATA: TrackerData = { wheel: LUCKY_WHEEL, mansion: MANSION_BOOST, fillsTo: SUPPLY_PURCHASE.fillsTo, + deliveryMs: SUPPLY_PURCHASE.deliveryMs, } class Overview { @@ -49,7 +50,7 @@ export function quick(event: NextEvent, choice?: string): QuickResult | null { /** Takes back a quick action on event `id`: it is ready again, but that is no news to alert. */ export function undoQuick(id: string, undo: QuickResult['undo']): void { - Object.assign(store.state, undo) + Object.assign(store.state, undo(store.state)) seen?.add(id) } diff --git a/src/lib/overview.test.ts b/src/lib/overview.test.ts index 6f167b4..1ca9499 100644 --- a/src/lib/overview.test.ts +++ b/src/lib/overview.test.ts @@ -23,6 +23,7 @@ const DATA: TrackerData = { wheel: LUCKY_WHEEL, mansion: MANSION_BOOST, fillsTo: SUPPLY_PURCHASE.fillsTo, + deliveryMs: SUPPLY_PURCHASE.deliveryMs, } function owning(upgrades: Record>) { @@ -66,6 +67,7 @@ describe('allEvents', () => { estimated: false, tab: 'businesses', focus: 'bunker', + quick: { label: 'Sold' }, }) }) @@ -230,12 +232,13 @@ describe('holdings', () => { }) describe('quickAction', () => { - it('spins the Lucky Wheel, with an undo', () => { + it('spins the Lucky Wheel; undo puts back the last spin', () => { const s = state({ timers: { luckyWheelSpunAt: 1 } }) - expect(quickAction(s, DATA, 'lucky-wheel', NOW)).toEqual({ - change: { timers: { luckyWheelSpunAt: NOW } }, - undo: { timers: { luckyWheelSpunAt: 1 } }, - done: 'Lucky Wheel spun', + const result = quickAction(s, DATA, 'lucky-wheel', NOW)! + expect(result.change).toEqual({ timers: { luckyWheelSpunAt: NOW } }) + expect(result.done).toBe('Lucky Wheel spun') + expect(result.undo(state({ timers: { luckyWheelSpunAt: NOW } }))).toEqual({ + timers: { luckyWheelSpunAt: 1 }, }) }) @@ -245,26 +248,79 @@ describe('quickAction', () => { const s = state({ properties: owned, timers: { mansionBoost: last } }) const event = find(allEvents(s, DATA, NOW), 'mansion-boost') expect(event?.quick?.choices).toEqual([{ id: 'mc-weed', label: 'Weed Farm' }]) - expect(quickAction(s, DATA, 'mansion-boost', NOW, 'mc-weed')?.change).toEqual({ + const result = quickAction(s, DATA, 'mansion-boost', NOW, 'mc-weed')! + expect(result.change).toEqual({ timers: { mansionBoost: { propertyId: 'mc-weed', startedAt: NOW } }, }) + expect(result.undo(state({ ...s, ...result.change }))).toEqual({ + timers: { mansionBoost: last }, + }) // Not the same business twice in a row, and not without a pick. expect(quickAction(s, DATA, 'mansion-boost', NOW, 'bunker')).toBeNull() expect(quickAction(s, DATA, 'mansion-boost', NOW)).toBeNull() }) - it('starts a production boost', () => { + it('starts a production boost; undo removes it', () => { const s = state({ properties: owning({ 'acid-lab': { equipment: true } }) }) - expect(quickAction(s, DATA, 'boost:acid-lab', NOW)).toEqual({ - change: { boosts: { 'acid-lab': { startedAt: NOW, unitsLeft: 80 } } }, - undo: { boosts: {} }, - done: 'Acid Lab boost started', + const result = quickAction(s, DATA, 'boost:acid-lab', NOW)! + expect(result.change).toEqual({ boosts: { 'acid-lab': { startedAt: NOW, unitsLeft: 80 } } }) + expect(result.done).toBe('Acid Lab boost started') + expect(result.undo(state({ ...s, ...result.change }))).toEqual({ boosts: {} }) + }) + + it('sells the stock; undo adds it back on top of what was made since', () => { + const s = state({ businesses: { bunker: { stock: 100, supplies: 40 } } }) + const result = quickAction(s, DATA, 'stock:bunker', NOW)! + expect(result.change).toEqual({ businesses: { bunker: { stock: 0, supplies: 40 } } }) + expect(result.done).toBe('Bunker: stock sold') + const later = state({ businesses: { bunker: { stock: 2, supplies: 30 } } }) + expect(result.undo(later)).toEqual({ businesses: { bunker: { stock: 100, supplies: 30 } } }) + }) + + it('buys supplies; undo cancels the delivery', () => { + const s = state({ businesses: { bunker: { stock: 50, supplies: 0 } } }) + const result = quickAction(s, DATA, 'supplies:bunker', NOW)! + expect(result.change).toEqual({ + businesses: { bunker: { stock: 50, supplies: 0, suppliesDueAt: NOW + 10 * MIN } }, + }) + expect(result.undo(state({ ...s, ...result.change }))).toEqual({ + businesses: { bunker: { stock: 50, supplies: 0 } }, + }) + }) + + it('collects a safe; undo adds the money back to any payout since', () => { + const s = state({ safes: { agency: { money: 250_000, sinceMs: 30 * MIN } } }) + const result = quickAction(s, DATA, 'safe:agency', NOW)! + expect(result.change).toEqual({ safes: { agency: { money: 0, sinceMs: 30 * MIN } } }) + expect(result.done).toBe('Agency safe collected') + const paid = state({ safes: { agency: { money: 1_250, sinceMs: 2 * MIN } } }) + expect(result.undo(paid)).toEqual({ safes: { agency: { money: 251_250, sinceMs: 2 * MIN } } }) + }) + + it('collects the Money Fronts safe and clears the Car Wash heat', () => { + const fronts = { money: 100_000, sinceMs: 0, heat: { 'car-wash': 100 } } + const s = state({ safes: { 'car-wash': fronts } }) + expect(quickAction(s, DATA, 'safe:car-wash', NOW)?.change).toEqual({ + safes: { 'car-wash': { ...fronts, money: 0 } }, + }) + const job = quickAction(s, DATA, 'heat:car-wash', NOW)! + expect(job.change).toEqual({ safes: { 'car-wash': { ...fronts, heat: { 'car-wash': 0 } } } }) + expect(job.undo(state({ ...s, ...job.change }))).toEqual({ safes: { 'car-wash': fronts } }) + }) + + it('sells nightclub goods; undo adds them back', () => { + const s = state({ nightclub: { cash: 8, cargo: 3 } }) + const result = quickAction(s, DATA, 'goods:cash', NOW)! + expect(result.change).toEqual({ nightclub: { cash: 0, cargo: 3 } }) + expect(result.undo(state({ nightclub: { cash: 0.5, cargo: 3 } }))).toEqual({ + nightclub: { cash: 8.5, cargo: 3 }, }) }) - it('has nothing for other events', () => { - expect(quickAction(defaultState(), DATA, 'stock:bunker', NOW)).toBeNull() + it('has nothing for unknown events', () => { + expect(quickAction(defaultState(), DATA, 'stock:nope', NOW)).toBeNull() expect(quickAction(defaultState(), DATA, 'boost:bunker', NOW)).toBeNull() + expect(quickAction(defaultState(), DATA, 'safe:nope', NOW)).toBeNull() }) }) diff --git a/src/lib/overview.ts b/src/lib/overview.ts index 6ae247f..b5e3f9d 100644 --- a/src/lib/overview.ts +++ b/src/lib/overview.ts @@ -1,5 +1,6 @@ // The Overview tab (issue #15, decision 023): every tracker's next events in one list, and the // money waiting across them. Pure logic: wall-clock time comes in as an argument (decision 003). +import { BUSINESS_EVENTS } from '../data/business-events' import { CAR_WASH } from '../data/fronts' import { NIGHTCLUB_ID, type Goods } from '../data/nightclub' import type { Production } from '../data/production' @@ -7,7 +8,7 @@ import type { Property } from '../data/properties' import type { Safe } from '../data/safes' import type { LUCKY_WHEEL } from '../data/timers' import { sortEvents, type NextEvent } from './events' -import { emptyFronts, frontsEvents, ownedFronts } from './fronts' +import { emptyFronts, frontsEvents, heatOf, legalJob, ownedFronts, setHeat } from './fronts' import { goodsEvents, goodsValue } from './nightclub' import { emptyBars, @@ -20,7 +21,7 @@ import { valueBonus, } from './production' import { isOwned } from './properties' -import { settleBars } from './resync' +import { applyEvent, buySupplies, settleBars } from './resync' import { bestPayout, dailyPayout, emptySafe, safeEvents } from './safes' import type { AppState } from './state' import { boostableBusinesses, timerEvents, type MansionBoostData } from './timers' @@ -33,8 +34,9 @@ export interface TrackerData { safes: readonly Safe[] wheel: typeof LUCKY_WHEEL mansion: MansionBoostData - /** Supplies after a bought delivery arrives, in percent. */ + /** Supplies after a bought delivery arrives, in percent, and how long it takes. */ fillsTo: number + deliveryMs: number } /** Every tracker's next events at `now`, due ones first (`sortEvents`). */ @@ -96,17 +98,21 @@ export function holdings(state: AppState, data: TrackerData, now: number): Holdi return { stock, goods, safes, estimated } } -/** A quick action's result: what to change in the state, what undoes it, and what was done. */ +/** + * A quick action's result: what to change in the state, what was done, and how to take it back + * later. The undo puts back only what the action changed, so progress made since stays. + */ export interface QuickResult { change: Partial - undo: Partial + undo: (state: AppState) => Partial done: string } /** - * Marks event `id` done from the Overview at `now` (its `quick`): the Lucky Wheel spun, a - * production boost or the Mansion boost (on `choice`) started. Null if the event has none, or - * the choice is not one the boost can take. + * Marks event `id` done from the Overview at `now` (its `quick`): stock sold, supplies bought, + * a safe collected, a legal job at the Car Wash, the Lucky Wheel spun, a production boost or the + * Mansion boost (on `choice`) started. Null if the event has none, or the choice is not one the + * boost can take. */ export function quickAction( state: AppState, @@ -115,38 +121,122 @@ export function quickAction( now: number, choice?: string, ): QuickResult | null { - const { timers, boosts } = state + const [kind, target = ''] = id.split(':') const label = (propertyId: string) => data.catalog.find((p) => p.id === propertyId)?.label ?? propertyId - if (id === 'lucky-wheel') { - return { - change: { timers: { ...timers, luckyWheelSpunAt: now } }, - undo: { timers }, - done: 'Lucky Wheel spun', + const { timers } = state + switch (kind) { + case 'stock': + case 'supplies': { + const before = state.businesses[target] + if (!data.productions.some((p) => p.propertyId === target)) return null + const bars = settleBars(before ?? emptyBars(), now, data.fillsTo) + const after = + kind === 'stock' + ? applyEvent(bars, BUSINESS_EVENTS.sold) + : buySupplies(bars, now, data.deliveryMs) + return { + change: { businesses: { ...state.businesses, [target]: after } }, + undo: (s) => { + const now = s.businesses[target] ?? after + if (kind === 'supplies') { + const { suppliesDueAt: _, ...rest } = now + return { businesses: { ...s.businesses, [target]: rest } } + } + // The stock sold comes back on top of what was made since. + const stock = Math.min(100, bars.stock + now.stock) + return { businesses: { ...s.businesses, [target]: { ...now, stock } } } + }, + done: `${label(target)}: ${kind === 'stock' ? 'stock sold' : 'supplies bought'}`, + } } - } - if (id === 'mansion-boost') { - const choices = boostableBusinesses( - data.catalog, - state.properties, - data.mansion, - timers.mansionBoost, - ) - if (!choice || !choices.some((p) => p.id === choice)) return null - return { - change: { timers: { ...timers, mansionBoost: { propertyId: choice, startedAt: now } } }, - undo: { timers }, - done: `Mansion boost started on ${label(choice)}`, + case 'goods': { + const goods = data.goods.find((g) => g.id === target) + if (!goods) return null + const before = state.nightclub[target] + return { + change: { nightclub: { ...state.nightclub, [target]: 0 } }, + undo: (s) => ({ + nightclub: { ...s.nightclub, [target]: (before ?? 0) + (s.nightclub[target] ?? 0) }, + }), + done: `${goods.label} sold`, + } + } + case 'safe': { + const fronts = target === CAR_WASH + if (!fronts && !data.safes.some((safe) => safe.propertyId === target)) return null + const before = state.safes[target] + const current = + before ?? + (fronts ? emptyFronts() : emptySafe(data.safes.find((x) => x.propertyId === target)!)) + return { + change: { safes: { ...state.safes, [target]: { ...current, money: 0 } } }, + // The money collected comes back on top of any payout since. + undo: (s) => { + const now = s.safes[target] ?? current + return { safes: { ...s.safes, [target]: { ...now, money: current.money + now.money } } } + }, + done: `${fronts ? 'Money Fronts' : label(target)} safe collected`, + } + } + case 'heat': { + const before = state.safes[target] ?? emptyFronts() + return { + change: { safes: { ...state.safes, [target]: legalJob(before, target) } }, + undo: (s) => ({ + safes: { + ...s.safes, + [target]: setHeat(s.safes[target] ?? before, target, heatOf(before, target)), + }, + }), + done: `Legal job at ${label(target)}`, + } + } + case 'lucky-wheel': { + const before = timers.luckyWheelSpunAt + return { + change: { timers: { ...timers, luckyWheelSpunAt: now } }, + undo: (s) => ({ timers: { ...s.timers, luckyWheelSpunAt: before } }), + done: 'Lucky Wheel spun', + } + } + case 'mansion-boost': { + const choices = boostableBusinesses( + data.catalog, + state.properties, + data.mansion, + timers.mansionBoost, + ) + if (!choice || !choices.some((p) => p.id === choice)) return null + const before = timers.mansionBoost + return { + change: { timers: { ...timers, mansionBoost: { propertyId: choice, startedAt: now } } }, + undo: (s) => ({ timers: { ...s.timers, mansionBoost: before } }), + done: `Mansion boost started on ${label(choice)}`, + } + } + case 'boost': { + const boost = data.productions.find((p) => p.propertyId === target)?.boost + if (!boost) return null + const before = state.boosts[target] + return { + change: { boosts: { ...state.boosts, [target]: startBoost(boost, now) } }, + undo: (s) => ({ boosts: restore(s.boosts, target, before) }), + done: `${label(target)} boost started`, + } } } - const boosted = id.startsWith('boost:') ? id.slice('boost:'.length) : undefined - const boost = data.productions.find((p) => p.propertyId === boosted)?.boost - if (!boosted || !boost) return null - return { - change: { boosts: { ...boosts, [boosted]: startBoost(boost, now) } }, - undo: { boosts }, - done: `${label(boosted)} boost started`, - } + return null +} + +/** `record` with entry `id` put back to `value`, or removed if there was none. */ +function restore( + record: Record, + id: string, + value: T | undefined, +): Record { + const { [id]: _, ...rest } = record + return value === undefined ? rest : { ...rest, [id]: value } } /** Nightclub popularity, if a nightclub is owned: it sets the safe's payout (decision 016). */ diff --git a/src/lib/production.ts b/src/lib/production.ts index 36974f5..2bfff51 100644 --- a/src/lib/production.ts +++ b/src/lib/production.ts @@ -479,6 +479,7 @@ export function productionEvents( estimated: estimated || parts.value || farValueEstimated(production, owned), tab, focus: id, + quick: { label: 'Sold' }, }) } else { events.push({ @@ -493,6 +494,7 @@ export function productionEvents( estimated, tab, focus: id, + quick: { label: 'Bought' }, }) } if ( diff --git a/src/lib/safes.ts b/src/lib/safes.ts index 1bf1b93..455fc1e 100644 --- a/src/lib/safes.ts +++ b/src/lib/safes.ts @@ -213,6 +213,7 @@ export function safeEvents( estimated: estimatedParts(safe, owned, property).payout, tab: 'safes', focus: property.id, + quick: { label: 'Collected' }, }, ] }) From 10f7b7033b2ced8db82a02ebfa787eb082462ccf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 13:11:20 +0000 Subject: [PATCH 4/4] README: Overview quick actions and alerts Co-Authored-By: Claude Opus 5.5 --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9a4dba4..59e86a5 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ emptying. - **Sourced game data.** Rates and values come from sources such as gta.wiki. Where none exists for your upgrades, LSOS uses a best estimate and labels those numbers "(est.)". - **Overview for a second screen.** One page lists what is ready now and what comes next across - every tracker, soonest first, with the money waiting. Browser notifications are optional. + every tracker, soonest first, with the money waiting. Mark things done right there (sold, + collected, spun), and get optional alerts as pop-ups or browser notifications. - **Resync anytime.** When an estimate drifts from what the game shows, click the value and type the real one. - **Weekly events** (bonuses, discounts) come from a data file in this repo that's updated weekly.