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
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Svelte 5 + TypeScript + Vite. Public repository.
| Saved user state, migrations, export/import | `src/lib/state.ts`, `src/lib/store.svelte.ts` |
| UI components (thin) | `src/components/` |
| Base UI kit (panel, meter, button...), preview | `src/components/ui/`, `#/kit` in dev (dec. 009) |
| Resync values; business bars (Sold, Raided) | `ui/EditableValue`, `ui/BusinessBars` (dec. 010) |
| Game data with sources | `src/data/` (decision 004) |
| Theme tokens | `src/app.css` (decision 009) |
| CI, templates | `.github/` |
Expand Down Expand Up @@ -60,6 +61,7 @@ npm run format # fix formatting
so. Check with the maintainer when it tries to redirect the task.
- **Never invent game facts** (rates, capacities, cooldowns, payouts). Use only sourced values in
`src/data/` (decision 004). Missing → leave it out, comment the open question on the issue, ask.
gta.wiki is a trusted source for in-game data and facts (cite the page as the source).
- Ambiguous task → ask instead of guessing. Simple, googleable facts → look them up.
- **Determinism:** logic takes `now` as input or uses an injected clock; no `Date.now()` in
logic, no real timers in tests (decision 003).
Expand Down
44 changes: 44 additions & 0 deletions docs/decisions/010-resync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# 010: Resync: correcting tracked values by hand

2026-10-14, issue #4. Builds on decision 003 ("Resync").

## Decision

- **One component for every tracked value:** `src/components/ui/EditableValue.svelte`. It shows
the current estimate; clicking it opens a text field prefilled with that value. Enter or "Set"
confirms, Escape or "Cancel" leaves it unchanged. Input the field cannot read is not guessed
at: it stays open and says what it expects.
- **Units** (`src/lib/resync.ts`): `percent` (stock, supplies), `money` (safes; "$120,000",
"120k" and "1.2m" all work) and `duration` (time left as h:mm or plain minutes, stored in
milliseconds; shown rounded up to the minute, so "0:00" means done). Typed values are clamped
to the tracker's `min`/`max`. "Bars" are the stock and supplies meters, edited in percent:
most businesses have both, some only stock.
- **The tracker owns "as of now".** The component calls `onresync(value)`; the tracker stores
it with its own clock and keeps counting from there (a play-time tracker sets its saved
progress, a real-time cooldown stores `now + timeLeft`). The component never reads a clock.
- **Presets** are one-click values passed in by the tracker ("Full" sets a bar to 100%,
"Collected" empties a safe).
- **Business bars** (`src/components/ui/BusinessBars.svelte`): stock and, if the business has
one, supplies, each an `EditableValue` with its meter, plus the business events "Sold"
(empties stock) and "Raided" (a successful police raid empties stock and supplies). Stock has
"Full". Raided is hidden for a business without supplies. Trackers pass the bars in percent
and store what comes back.
- **Supplies bought** starts a delivery instead of filling at once: the bars keep
`suppliesDueAt` (wall-clock time), the panel counts down, and once due `settleBars` fills
supplies to 100%. Trackers store bars as set and settle them whenever they read them, so a
delivery that arrives while nobody looks still counts. The wait is 10 minutes for every
business and runs on real time for now; which clock the game uses is not confirmed. A raid
also cancels a delivery on its way.
- What each event does, and the delivery, are game data in `src/data/business-events.ts`
(decision 004).
- **Undo takes back the last edit or preset** of that value; in `BusinessBars` one Undo covers
the last change to either bar, events included. It subtracts what the edit changed
rather than restoring the old number, so progress counted since is kept: a cooldown mistyped
as 1:30 instead of 2:00 and undone ten minutes later shows 1:50. The undo lives in the
component for the session and is not saved.

## Why

Estimates drift (forgotten pause, disconnects, raids), so correcting a value must be one click
and a number, everywhere, in the unit the game shows. Keeping the clock out of the component
lets each tracker use its own clock (decision 003) and keeps the logic testable.
1 change: 1 addition & 0 deletions docs/decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ 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
119 changes: 119 additions & 0 deletions src/components/ui/BusinessBars.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<script lang="ts">
import { BUSINESS_EVENTS, SUPPLY_PURCHASE } from '../../data/business-events'
import {
applyEvent,
buySupplies,
formatValue,
settleBars,
undoBars,
type Bars,
} from '../../lib/resync'
import Button from './Button.svelte'
import Countdown from './Countdown.svelte'
import EditableValue from './EditableValue.svelte'
import Meter from './Meter.svelte'

interface Props {
/** Stock and supplies in percent; leave supplies out for a business with only a stock bar. */
bars: Bars
/** Wall-clock time, for supply deliveries (real time until confirmed, issue #4). */
now: number
/** Called with the corrected bars; the tracker stores them as of now (decision 003). */
onresync: (bars: Bars) => void
}

let { bars: stored, now, onresync }: Props = $props()

// The bars as of now, with an arrived delivery filled in. Every change starts from these.
const bars = $derived(settleBars(stored, now, SUPPLY_PURCHASE.fillsTo))
const dueAt = $derived(bars.suppliesDueAt)

const events = $derived(
Object.values(BUSINESS_EVENTS).filter(
(event) => bars.supplies !== undefined || !event.empties.includes('supplies'),
),
)

// One undo for the whole business: the last edit, Full or event, whichever bars it changed.
let last = $state<{ before: Bars; after: Bars; label: string } | null>(null)

function set(after: Bars, label: string) {
if (
after.stock === bars.stock &&
after.supplies === bars.supplies &&
after.suppliesDueAt === bars.suppliesDueAt
)
return
last = { before: { ...bars }, after, label }
onresync(after)
}

function undo() {
if (!last) return
onresync(undoBars(last, bars))
last = null
}

const FULL = [{ label: 'Full', value: 100 }]

function buy() {
set(buySupplies(bars, now, SUPPLY_PURCHASE.deliveryMs), SUPPLY_PURCHASE.label)
}
</script>

<div class="bars">
<EditableValue
label="Stock"
value={bars.stock}
unit="percent"
min={0}
max={100}
presets={FULL}
undoable={false}
onresync={(stock) => set({ ...bars, stock }, `stock was ${formatValue(bars.stock, 'percent')}`)}
>
<Meter label="Stock" value={bars.stock} max={100} bare />
</EditableValue>
{#if bars.supplies !== undefined}
<EditableValue
label="Supplies"
value={bars.supplies}
unit="percent"
min={0}
max={100}
undoable={false}
onresync={(supplies) =>
set({ ...bars, supplies }, `supplies was ${formatValue(bars.supplies ?? 0, 'percent')}`)}
>
<Meter label="Supplies" value={bars.supplies} max={100} kind="supplies" bare />
{#if dueAt !== undefined}
<Countdown label="Supplies arrive in" remainingMs={dueAt - now} />
{/if}
<div class="actions">
<Button onclick={buy} disabled={dueAt !== undefined}>{SUPPLY_PURCHASE.label}</Button>
</div>
</EditableValue>
{/if}
<div class="actions">
{#each events as event (event.label)}
<Button onclick={() => set(applyEvent(bars, event), event.label)}>{event.label}</Button>
{/each}
{#if last}
<Button onclick={undo} title="Take back the last change ({last.label})">Undo</Button>
{/if}
</div>
</div>

<style>
.bars {
display: flex;
flex-direction: column;
gap: var(--space-3);
}

.actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
</style>
Loading
Loading