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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ Svelte 5 + TypeScript + Vite. Public repository.
| Money Fronts safe and heat | `data/fronts.ts`, `lib/fronts.ts` (018) |
| 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) |
| Theme tokens | `src/app.css` (decision 009) |
| Link previews (Open Graph), preview image | `index.html`, `scripts/og-image.html` |
| CI, templates | `.github/` |
Expand Down
2 changes: 1 addition & 1 deletion docs/decisions/010-resync.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 010: Resync: correcting tracked values by hand

2026-10-14, issue #4. Builds on decision 003 ("Resync").
2026-10-14, issue #4. Builds on decision 003 ("Resync"). Duration format amended by decision 022.

## Decision

Expand Down
36 changes: 36 additions & 0 deletions docs/decisions/022-real-time-timers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# 022: Timers tab: Lucky Wheel and Mansion boost on real time

2026-10-15, issues #13 and #14. Builds on decisions 003, 010, 012 and 017.

## Decision

- A **Timers** tab holds cooldowns that run on **real time** (decision 003), also while LSOS is
closed. Values and sources are in `src/data/timers.ts`; the logic is in `src/lib/timers.ts`.
- **Lucky Wheel:** "Spun" starts a 24-hour real-time countdown to the next spin. The extra daily
GTA+ spin is left out because no source says how it is timed.
- **Mansion boost:** the Mansion is an owned property with no card (decision 011), and the boost
card shows only while you own one. You pick an eligible business that is owned and running,
then press "Activated". That business makes stock 3× as fast for 24 real hours. The next boost
can start 24 real hours after the last one started (gta.wiki, Mansions), and never on the same
business twice in a row.
- **Production:** `produceAll` cuts a play-time stretch at the boost's real-time start and end,
the same way it cuts at the Acid Lab boost (decision 017), and runs the boosted business at its
rate ÷ 3. Supplies per unit stay the same, so they run out 3× as fast. The business card shows
the time left on the boost, and its forecast counts that time as play time.
- **With the Acid Lab's own boost:** no source gives the combined speed, so LSOS takes the
boosted rate ÷ 3 and marks the production numbers "(est.)" while both are on (decision 013).
- Both countdowns can be corrected as "time left" (decision 010). A `duration` value now shows
as h:mm:ss like every `Countdown`, so it visibly ticks. It accepts h:mm, h:mm:ss, "5h 30m", or
plain minutes. After "Spun" or "Activated", an Undo button for that press shows in the card's
footer strip. It restores the previous value, including the last boost the back-to-back rule
needs.
- **Saved state** (version 11): `timers` has `luckyWheelSpunAt` and `mansionBoost`
(`propertyId`, `startedAt`). Each is left out until first used.

## Why

These cooldowns don't depend on play, and a separate tab keeps them apart from the play-time
trackers. One community guide puts the Mansion boost's reset at the daily server reset
(6:00 UTC). gta.wiki says 24 real hours, and gta.wiki is the trusted source. The boost is saved
with the Timers state rather than in `boosts`, because it belongs to the Mansion and not to the
business it speeds up.
3 changes: 2 additions & 1 deletion docs/decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ free number, add one line below. **Replacing one:** write a new decision, then m
- [007](007-state-store.md) State store: unreadable data blocks saving like a newer version; import overrides
- [008](008-clock-details.md) Clock details: session play time, opens paused (setting), throttled ticks counted, long gaps asked about
- [009](009-terminal-design.md) Terminal look: tokens in `app.css`, self-hosted Oswald (OFL), browser-style tabs, base components in `src/components/ui/`
- [010](010-resync.md) Resync: `EditableValue` for every tracked value, `BusinessBars` with Sold/Raided/Full and supply deliveries, undo keeps progress since, five-segment bars you can drag
- [010](010-resync.md) Resync: `EditableValue` for every tracked value, `BusinessBars` with Sold/Raided/Full and supply deliveries, undo keeps progress since, five-segment bars you can drag (duration format amended by 022)
- [011](011-owned-properties.md) Owned properties and upgrades: catalog in `src/data/properties.ts`, toggle/choice upgrades (count: 016), Settings panel and Businesses gear
- [012](012-business-production.md) Business production: rate per exact upgrade combination in `src/data/production.ts`, continuous on play time, unknown rates do not run
- [013](013-marked-estimates.md) Missing game values may be estimated by a stated rule from sourced ones, marked "(est.)" wherever shown
Expand All @@ -32,3 +32,4 @@ free number, add one line below. **Replacing one:** write a new decision, then m
- [019](019-owned-value-bonus.md) Money Front value bonus: owning Car Wash or Smoke on the Water adds 35% to Counterfeit Cash or Weed, far value adds the bonuses (est.), Helitours left out
- [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.)
10 changes: 10 additions & 0 deletions src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import Safes from './components/Safes.svelte'
import SaveStatus from './components/SaveStatus.svelte'
import Settings from './components/Settings.svelte'
import Timers from './components/Timers.svelte'
import TabBar from './components/ui/TabBar.svelte'
import { GOODS } from './data/nightclub'
import { PRODUCTION } from './data/production'
Expand Down Expand Up @@ -57,6 +58,7 @@
now={clocks.now}
onresync={(id, bars) =>
(store.state.businesses = { ...store.state.businesses, [id]: bars })}
mansionBoost={store.state.timers.mansionBoost}
boosts={store.state.boosts}
onboost={(id, boost) => {
const { [id]: _, ...rest } = store.state.boosts
Expand All @@ -78,6 +80,14 @@
stored={store.state.safes}
onresync={(id, safe) => (store.state.safes = { ...store.state.safes, [id]: safe })}
/>
{:else if active === 'timers'}
<Timers
catalog={PROPERTIES}
owned={store.state.properties}
timers={store.state.timers}
now={clocks.now}
onchange={(timers) => (store.state.timers = timers)}
/>
{:else if active === 'settings'}
<Settings />
{:else}
Expand Down
101 changes: 55 additions & 46 deletions src/components/BusinessProduction.svelte
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
<script lang="ts">
// Stock, supplies, value and what comes next for one business (issue #6). Collapsed it shows
// only that; `editing` adds the resync controls. A business with a production boost shows it
// and lets the user start it (issue #9). Logic: lib/production.ts.
// and lets the user start it (issue #9), and the Mansion boost while it is on (issue #14).
// Logic: lib/production.ts.
import { SUPPLY_PURCHASE } from '../data/business-events'
import type { Production, ProductionRate } from '../data/production'
import { MANSION_BOOST } from '../data/timers'
import {
boostRunning,
estimatedParts,
Expand All @@ -19,6 +21,7 @@
import BusinessBars from './ui/BusinessBars.svelte'
import Button from './ui/Button.svelte'
import Countdown from './ui/Countdown.svelte'
import Fact from './ui/Fact.svelte'
import Meter from './ui/Meter.svelte'

interface Props {
Expand All @@ -43,6 +46,8 @@
boost?: Boost
/** Called with the new boost, or undefined to take back one started by mistake. */
onboost?: (boost: Boost | undefined) => void
/** When the Mansion boost on this business ends (wall clock), while it is on (issue #14). */
mansionEndsAt?: number
}

let {
Expand All @@ -58,6 +63,7 @@
farEstimated = false,
boost,
onboost = () => {},
mansionEndsAt,
}: Props = $props()

const current = $derived(settleBars(bars, now, SUPPLY_PURCHASE.fillsTo))
Expand All @@ -73,15 +79,27 @@
)
const boosting = $derived(boostRunning(boost, production.boost, now))
const nextBoost = $derived(production.boost ? nextBoostAt(boost, production.boost) : 0)
const mansionOn = $derived(mansionEndsAt !== undefined && now < mansionEndsAt)
const ahead = $derived(
running && !shutDown
? forecast(current, production, running, boosting ? boost!.unitsLeft : 0)
? forecast(
current,
production,
running,
boosting ? boost!.unitsLeft : 0,
mansionOn ? { msLeft: mansionEndsAt! - now, speed: MANSION_BOOST.speed } : undefined,
)
: null,
)
const percent = (value: number) => formatValue(value, 'percent')
// Numbers resting on estimated rates say so (decision 013). The value also counts units made
// at an estimated rate.
const estimated = $derived(estimatedParts(rate))
// No source gives the speed of both boosts at once: LSOS multiplies them (est.).
const stacked = $derived(boosting && mansionOn)
const estimated = $derived.by(() => {
const parts = estimatedParts(rate)
return { ...parts, production: parts.production || stacked }
})
const est = (label: string, isEstimate: boolean) => (isEstimate ? `${label} (est.)` : label)
</script>

Expand All @@ -106,42 +124,47 @@
{/if}

{#if value}
<div class="facts">
<span class="label">{est('Value', estimated.value || estimated.production)}</span>
<!-- Far sales pay more, so that is the headline; nearby is the alternative. -->
<Fact
label={est('Value', estimated.value || estimated.production)}
title={value.far !== null ? (farTitle ?? undefined) : undefined}
>
{#if value.far !== null}
<!-- Far sales pay more, so that is the headline; nearby is the alternative. -->
<span class="value" title={farTitle}>
{formatValue(value.far, 'money')} far{farEstimated ? ' (est.)' : ''}
<span class="alt">· {formatValue(value.nearby, 'money')} nearby</span>
</span>
{formatValue(value.far, 'money')} far{farEstimated ? ' (est.)' : ''}
<span class="alt">· {formatValue(value.nearby, 'money')} nearby</span>
{:else}
<span class="value">{formatValue(value.nearby, 'money')}</span>
{formatValue(value.nearby, 'money')}
{/if}
</div>
</Fact>
{/if}

{#if production.boost && running?.boostedMinutesPerUnit !== undefined && !shutDown}
<!-- Play time while it runs; the next one is a real-time wait (daily reset). -->
<div class="facts">
{#if boosting}
<span class="label">Boost on</span>
<span class="value">
{Math.ceil(boost!.unitsLeft)}
{production.unitName} left
<Button onclick={() => onboost(undefined)} title="Take back a boost started by mistake"
>Undo</Button
>
</span>
{:else if now >= nextBoost}
<span class="label">Boost ready</span>
{#if boosting}
<Fact label="Boost on">
{Math.ceil(boost!.unitsLeft)}
{production.unitName} left
<Button onclick={() => onboost(undefined)} title="Take back a boost started by mistake"
>Undo</Button
>
</Fact>
{:else if now >= nextBoost}
<Fact label="Boost ready">
<Button onclick={() => onboost(startBoost(production.boost!, now))}>Boost started</Button>
{/if}
</div>
</Fact>
{/if}
{#if !boosting && now < nextBoost}
<Countdown label="Next boost in" remainingMs={nextBoost - now} />
{/if}
{/if}

{#if mansionOn && !shutDown}
<Countdown
label="Mansion boost ×{MANSION_BOOST.speed} ends in"
remainingMs={mansionEndsAt! - now}
/>
{/if}

{#if shutDown}
<p class="note quiet" role="note">
<strong>Shut down.</strong> No stock is made and no supplies are used until you start it again.
Expand All @@ -167,6 +190,12 @@
<strong>Estimate.</strong> No reliable data for these upgrades. {rate.estimate.how}
</p>
{/if}
{#if stacked}
<p class="note" role="note">
<strong>Estimate.</strong> No source gives the speed with both boosts on: LSOS takes the
boosted speed ×{MANSION_BOOST.speed}.
</p>
{/if}
{:else}
<p class="note" role="note">
<strong>Not tracked.</strong>
Expand All @@ -180,26 +209,6 @@
{/if}

<style>
.facts {
align-items: baseline;
display: flex;
gap: var(--space-3);
justify-content: space-between;
}

.label {
color: var(--muted);
font-family: var(--font-display);
font-size: var(--text-sm);
letter-spacing: var(--tracking);
text-transform: uppercase;
}

.value {
font-variant-numeric: tabular-nums;
text-align: right;
}

.alt {
color: var(--muted);
font-size: var(--text-sm);
Expand Down
8 changes: 8 additions & 0 deletions src/components/Businesses.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import { NIGHTCLUB_ID, type Goods } from '../data/nightclub'
import type { Production } from '../data/production'
import type { Property } from '../data/properties'
import { MANSION_BOOST } from '../data/timers'
import { applyOrder, cardAt, moveId, saveOrder, swapAt, type CardBox } from '../lib/order'
import {
emptyBars,
Expand All @@ -30,6 +31,7 @@
import type { GoodsStock } from '../lib/nightclub'
import type { Bars } from '../lib/resync'
import { hashForTab } from '../lib/tabs'
import { mansionBoostOn, type MansionBoost } from '../lib/timers'
import BusinessProduction from './BusinessProduction.svelte'
import NightclubWarehouse from './NightclubWarehouse.svelte'
import PropertySettings from './PropertySettings.svelte'
Expand All @@ -50,6 +52,8 @@
/** The last production boost by property id (issue #9). */
boosts?: BoostsById
onboost?: (propertyId: string, boost: Boost | undefined) => void
/** The last Mansion boost (issue #14). */
mansionBoost?: MansionBoost
/** Saved card order: property ids (issue #30). */
order?: readonly string[]
onorder?: (order: string[]) => void
Expand All @@ -69,6 +73,7 @@
onresync = () => {},
boosts = {},
onboost = () => {},
mansionBoost,
order = [],
onorder = () => {},
goods = [],
Expand Down Expand Up @@ -266,6 +271,9 @@
farEstimated={farValueEstimated(production, owned)}
boost={boosts[property.id]}
onboost={(boost) => onboost(property.id, boost)}
mansionEndsAt={mansionBoostOn(mansionBoost, MANSION_BOOST, property.id, now)
? mansionBoost!.startedAt + MANSION_BOOST.durationMs
: undefined}
/>
{/if}
{#if warehouse}
Expand Down
25 changes: 2 additions & 23 deletions src/components/MoneyFrontsCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import { nextPayoutInMs, type SafeState } from '../lib/safes'
import Button from './ui/Button.svelte'
import Countdown from './ui/Countdown.svelte'
import Fact from './ui/Fact.svelte'
import EditableBar from './ui/EditableBar.svelte'
import EditableValue from './ui/EditableValue.svelte'
import Meter from './ui/Meter.svelte'
Expand Down Expand Up @@ -126,10 +127,7 @@
/>
{/if}

<div class="facts">
<span class="label">Payout</span>
<span class="value">{money(parts.fronts + parts.others)} a day</span>
</div>
<Fact label="Payout">{money(parts.fronts + parts.others)} a day</Fact>
<p class="hint">
Fronts {money(parts.fronts)} + your other businesses {money(parts.others)}. A shut-down business
adds nothing.
Expand Down Expand Up @@ -160,25 +158,6 @@
margin: 0;
}

.facts {
align-items: baseline;
display: flex;
gap: var(--space-3);
justify-content: space-between;
}

.label {
color: var(--muted);
font-family: var(--font-display);
font-size: var(--text-sm);
letter-spacing: var(--tracking);
text-transform: uppercase;
}

.value {
font-variant-numeric: tabular-nums;
}

.actions {
align-items: center;
display: flex;
Expand Down
Loading
Loading