diff --git a/CLAUDE.md b/CLAUDE.md index b79521a..5782dfe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,6 +34,7 @@ Svelte 5 + TypeScript + Vite. Public repository. | Overview tab, next events, notifications | `lib/events.ts`, `lib/overview.ts` (dec. 023) | | Hiding cards from the Overview and alerts | `visibleEvents` in `lib/overview.ts` (dec. 028) | | Theme tokens | `src/app.css` (decision 009) | +| Hint, Note, empty state, timed Undo | `ui/`, `lib/cards.svelte.ts` (decision 029) | | Link previews (Open Graph), preview image | `index.html`, `scripts/og-image.html` | | CI, templates | `.github/` | | Skills: `handoff`, `finish-pr`; agent: `scout` | `.claude/` | diff --git a/docs/decisions/010-resync.md b/docs/decisions/010-resync.md index 8875b85..60c3f6e 100644 --- a/docs/decisions/010-resync.md +++ b/docs/decisions/010-resync.md @@ -1,6 +1,6 @@ # 010: Resync: correcting tracked values by hand -2026-10-14, issue #4. Builds on decision 003 ("Resync"). Duration format amended by decision 022. +2026-10-14, issue #4. Builds on decision 003 ("Resync"). Duration format amended by decision 022; presets and the edit view by decision 029. ## Decision diff --git a/docs/decisions/029-shared-ui.md b/docs/decisions/029-shared-ui.md new file mode 100644 index 0000000..bd82d7c --- /dev/null +++ b/docs/decisions/029-shared-ui.md @@ -0,0 +1,62 @@ +# 029: Shared card parts, always-editable cards and the Settings list + +2026-10-15, issue #60. Builds on decisions 009, 011 and 026; amends 010. + +## Decision + +- **One component per repeated pattern** in `src/components/ui/`: + - `Hint`: muted help text in two sizes, normal (`--text-sm`) and `fine` (`--text-xs`, for + footnotes such as how an estimate is made). + - `Note`: a callout with a bar on its left and a bold title first. Tones: + - `warn`: needs a look ("Estimate.", "Payout cut."). + - `quiet`: a plain state ("Shut down."). + - `good`: a tip, with `actions` on their own line. + - `MarkOwned`: what a tab shows while nothing on it is owned, with the link to Settings. With + a property, the link opens Settings on that property (`#/settings/`). + - `UndoStatus` with `TimedUndo` (`lib/cards.svelte.ts`): "Boost started. [Undo]" for 15 + seconds, right where the press was. Used for the Acid Lab boost, the Timers cards and the + Overview's quick actions. The Undo puts back what the press replaced. + - `Button small`: for rows of several actions and the title bar. +- **Cards are always editable.** No Edit button and no separate edit view: + - Every value can be clicked to type it in. + - Bars can be dragged. The drag handle shows only while the pointer is over the card, or the + bar has focus. Touch screens always show it. + - The read-only extras stay, next to the editable value: units, "of $cap", value per goods. + - Decided after trying both styles side by side. +- **Fewer one-click presets** (amends 010): no Full on stock, safes or goods, and no Raided. + No Maxed on popularity either. Sold, Supplies bought, Collected and the popularity boosts stay. + Rarely used, and every value can be typed or dragged instead. +- **No card footer.** The settings gear is a small button at the right end of the title bar. + One card per tab has its settings open at a time. +- **Money Fronts heat:** each front's bar has its three actions right under it, as small + buttons in one row: +10% Laundering, +10% Faber, Legal job. +- **One card width:** `--card-width` (20rem) for every card grid. +- **Order by use:** `USE_ORDER` in `src/data/properties.ts` sorts Settings and gives each tab + its card order until the user moves the cards: + - Acid Lab, Bunker and Nightclub first. + - Then the MC businesses by money per hour at full upgrades, from `production.ts`: Cocaine, + Meth, Cash, Weed, Documents. + - Then the Money Fronts and the safes. The properties that only unlock something or add to a + safe come last, with the Mansion and the Bail Enforcement Office at the bottom. + - A test checks that the list names every property. +- **Bars grab at their ends:** the draggable area reaches half a handle past each end of the + bar, so a handle at 0% or 100% can be grabbed anywhere on it. +- **Settings is a list with a detail panel:** + - Every property is a row that keeps its height, with a gear in front of its name, a + one-line upgrade summary, and Owned (and Running) buttons in fixed-width slots. + - Pick a row to set its upgrades in a panel beside the list. The panel is centred on the + row, kept within the list, and has an arrow pointing at the row. + - Without room beside the list, the upgrades open under the picked row instead. + - A property that is not owned shows its upgrades greyed, so marking it owned changes + nothing in the layout. + - On wide screens the other settings are a narrow column on the right. + +## Alternatives + +- Edit and settings as two exclusive modes behind buttons: tried. It kept a footer and a + second view of every card. +- Global CSS classes instead of `Hint` and `Note`: shorter markup, but the tones and the title + would still be written out by hand in every card. +- Settings with collapsible rows, or grouped cards: either one still moves things around as + properties are marked owned or opened. +- One "+10%" button with a choice of mission: one more click for every mission logged. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 5b1706a..dd28190 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -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 (duration format amended by 022) +- [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; presets and edit view amended by 029) - [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 @@ -39,3 +39,4 @@ free number, add one line below. **Replacing one:** write a new decision, then m - [026](026-movable-cards-everywhere.md) Movable cards on Businesses, Safes and Timers (`ui/CardGrid`): picked up by the title bar; swap by the dragged card's middle, 10% in; orders saved per tab - [027](027-weekly-contents.md) Weekly events: typed `src/data/weekly.ts` (no JSON, no fetch), checked by a test; Weekly tab only (no news bar); out of date: warning over the dimmed old week; contents: multipliers, Weekly Challenge, rotations, discounts, gifts, a less prominent "Also running" list; no GTA+, no daily map-bound items; explicit UTC start/end (Thursday 2:00 AM US Pacific); source gta.wiki weekly page; routine Thursday ~16:00 UTC - [028](028-hide-from-overview.md) Hide from Overview: per card (event `focus`, timers too), Hide on each item with undo, Settings lists them with Show; still tracked and counted in the totals; showing again brings no alert +- [029](029-shared-ui.md) Shared card parts (`Hint`, `Note`, `MarkOwned`, timed `UndoStatus`, small buttons); cards always editable, drag handles on hover, no Full/Raided/Maxed; gear in the title bar, no footer; `--card-width`; default order by use (`USE_ORDER`); Settings as a fixed-row property list with the picked property's upgrades beside it diff --git a/src/App.svelte b/src/App.svelte index dffdd9a..0a61900 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -139,7 +139,7 @@ {:else if active === 'weekly'} {:else if active === 'settings'} - + {:else}

Not built yet.

{/if} diff --git a/src/app.css b/src/app.css index 1f90484..0d37224 100644 --- a/src/app.css +++ b/src/app.css @@ -57,6 +57,8 @@ --scanline-gap: 3px; --bar-height: 0.5rem; --max-width: 80rem; + /* Narrowest card column in a tab's card grid (`ui/CardGrid`). */ + --card-width: 20rem; color-scheme: dark; font-family: var(--font-body); @@ -104,6 +106,14 @@ a { color: var(--accent-text); } +/* Every checkbox in the theme's green, a little larger than the browser's. */ +input[type='checkbox'] { + accent-color: var(--accent); + flex: none; + height: 1.125rem; + width: 1.125rem; +} + :focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; diff --git a/src/components/BusinessProduction.svelte b/src/components/BusinessProduction.svelte index fb3f4fb..1a685c3 100644 --- a/src/components/BusinessProduction.svelte +++ b/src/components/BusinessProduction.svelte @@ -1,7 +1,7 @@ -{#if editing} - -{:else} - - - {#if current.suppliesDueAt !== undefined} - - {/if} -{/if} + {#if value} @@ -144,16 +139,11 @@ {#if production.boost && running?.boostedMinutesPerUnit !== undefined && !shutDown} {#if boosting} - - {Math.ceil(boost!.unitsLeft)} - {production.unitName} left - - + {Math.ceil(boost!.unitsLeft)} {production.unitName} left + {:else if now >= nextBoost} - + {/if} {#if !boosting && now < nextBoost} @@ -169,9 +159,9 @@ {/if} {#if shutDown} -

- Shut down. No stock is made and no supplies are used until you start it again. -

+ No stock is made and no supplies are used until you start it again. {:else if ahead} {#if ahead.fullInMs !== null} @@ -189,26 +179,22 @@ /> {/if} {#if rate?.estimate} -

- Estimate. No reliable data for these upgrades. {rate.estimate.how} -

+ No reliable data for these upgrades. {rate.estimate.how} {/if} {#if stacked} -

- Estimate. No source gives the speed with both boosts on: LSOS takes the - boosted speed ×{MANSION_BOOST.speed}. -

+ No source gives the speed with both boosts on: LSOS takes the boosted speed ×{MANSION_BOOST.speed}. {/if} {:else} -

- Not tracked. + {#if rate} There is no reliable data on how fast this business uses supplies with these upgrades. {:else} There is no reliable data for this combination of upgrades. {/if} Stock and supplies stay where you set them. -

+ {/if} diff --git a/src/components/Businesses.svelte b/src/components/Businesses.svelte index d405208..6fb8430 100644 --- a/src/components/Businesses.svelte +++ b/src/components/Businesses.svelte @@ -19,18 +19,18 @@ import { isShutDown, ownedProperties, - upgradeValue, + upgradeSummary, type OwnedProperties, } from '../lib/properties' import type { GoodsStock } from '../lib/nightclub' import type { Bars } from '../lib/resync' - import { hashForTab } from '../lib/tabs' import type { MansionBoost } from '../lib/timers' import BusinessProduction from './BusinessProduction.svelte' import NightclubWarehouse from './NightclubWarehouse.svelte' import PropertySettings from './PropertySettings.svelte' - import Button from './ui/Button.svelte' import CardGrid from './ui/CardGrid.svelte' + import Hint from './ui/Hint.svelte' + import MarkOwned from './ui/MarkOwned.svelte' import Panel from './ui/Panel.svelte' import SettingsButton from './ui/SettingsButton.svelte' @@ -78,33 +78,12 @@ const ownedList = $derived( ownedProperties(catalog, owned).filter((property) => property.card !== false), ) + // The card whose settings are open, one at a time. let open = $state(null) - // Cards start collapsed: bars, value and next countdown only (issue #6). - let editing = $state(null) - - /** Short summary of the bought upgrades, e.g. "Equipment, Technician: Cash". */ - function summary(property: Property): string { - const parts = property.upgrades.flatMap((upgrade) => { - const value = upgradeValue(owned, property.id, upgrade) - if (upgrade.kind === 'toggle') return value === true ? [upgrade.label] : [] - if (upgrade.kind === 'count') return [`${upgrade.label}: ${value}`] - if (upgrade.summary === false || value === upgrade.options[0]?.id) return [] - const option = upgrade.options.find((o) => o.id === value) - return option ? [`${upgrade.label}: ${option.label}`] : [] - }) - return parts.length > 0 ? parts.join(', ') : 'No upgrades' - } {#if ownedList.length === 0} -

- {#if catalog.length === 0} - No businesses are tracked yet. - {:else} - You do not own any business yet. Mark what you own in - Settings. - {/if} -

+ You do not own any business yet. {:else} 0}
+ {#snippet actions()} + (open = open === property.id ? null : property.id)} + /> + {/snippet} {#if open === property.id} {:else} -

{summary(property)}

+ {upgradeSummary(owned, property)} {/if} {#if production} onresync(property.id, bars)} bonus={valueBonus(production, owned, property)} farEstimated={farValueEstimated(production, owned)} @@ -147,24 +132,9 @@ {owned} {onchange} stock={nightclubStock} - editing={editing === property.id} onresync={onnightclub} /> {/if} - {#snippet footer()} - {#if production || warehouse} - - {/if} - (open = open === property.id ? null : property.id)} - /> - {/snippet}
{/snippet} @@ -181,21 +151,4 @@ .shell.shut { filter: brightness(0.65); } - - p { - margin: 0; - } - - .empty { - color: var(--muted); - } - - .empty a { - color: var(--accent-text); - } - - .hint { - color: var(--muted); - font-size: var(--text-sm); - } diff --git a/src/components/MoneyFrontsCard.svelte b/src/components/MoneyFrontsCard.svelte index 25a0f96..5597644 100644 --- a/src/components/MoneyFrontsCard.svelte +++ b/src/components/MoneyFrontsCard.svelte @@ -1,7 +1,7 @@ -{#if editing} - {#each goods as g (g.id)} - {@const cap = capacity(g, setup.floors)} - onresync(g.id, value)} - > - - - {/each} -{:else if shown.length === 0} -

No technician is making goods. Assign one in this card's settings.

+{#if shown.length === 0} + No technician is making goods. Assign one in this card's settings. {:else} - + {#each shown as g (g.id)} {@const cap = capacity(g, setup.floors)} {@const ms = fullInMs(g, crates(g.id), setup)} {@const producing = isProducing(g, setup, owned)}
-
- {g.label} - {wholeCrates(crates(g.id))} / {cap} · {formatValue( - goodsValue(g, crates(g.id)), - 'money', - )} -
-
- - {#if !producing} - Idle - {:else if ms <= 0} - Full - {:else} - - {/if} -
+ onresync(g.id, value)} + > +
+ + {#if !producing} + Idle + {:else if ms <= 0} + Full + {:else} + + {/if} +
+
{/each} {/if} @@ -124,65 +109,55 @@ {formatValue(total, 'money')} {#each fullStuck as id (id)} -

- {label(id)} is full. Sell it: its technician makes nothing until then. -

+ Sell it: its technician makes nothing until then. {/each} {#if suggestion} -
-

- More per hour: - {#each suggestion.moves as move (move.technician)} - {#if full.includes(move.from)} - {label(move.from)} is full. Sell it, or move technician {move.technician} to - {label(move.to)}. - {:else} - Technician {move.technician}: {label(move.to)} - (now {label(move.from)}) - {/if} - {/each} -

-
- +{formatValue(suggestion.gainPerHour, 'money')} / h - -
-
+ + {#each suggestion.moves as move (move.technician)} + {#if full.includes(move.from)} + {label(move.from)} is full. Sell it, or move technician {move.technician} to + {label(move.to)}. + {:else} + Technician {move.technician}: {label(move.to)} + (now {label(move.from)}) + {/if} + {/each} + {#snippet actions()} +
+ +{formatValue(suggestion.gainPerHour, 'money')} / h + +
+ {/snippet} +
{/if} {#if locked.length > 0} -

- Locked. + {locked.map((g) => g.label).join(', ')}: own the business that makes {locked.length === 1 ? 'it' : 'them'}, and keep it running, to start production. -

+ {/if} {#if setup.doubled} -

- Two technicians on one category. The game allows one technician per category, so - only one counts. -

+ The game allows one technician per category, so only one counts. {/if} diff --git a/src/components/Overview.svelte b/src/components/Overview.svelte index f7e081b..6f35f49 100644 --- a/src/components/Overview.svelte +++ b/src/components/Overview.svelte @@ -11,8 +11,11 @@ import { hiddenLabel, type Holdings, type Popularity, type QuickResult } from '../lib/overview' import { formatValue } from '../lib/resync' import { hashForTab } from '../lib/tabs' + import { TimedUndo } from '../lib/cards.svelte' import Button from './ui/Button.svelte' import Digits from './ui/Digits.svelte' + import MarkOwned from './ui/MarkOwned.svelte' + import UndoStatus from './ui/UndoStatus.svelte' import Meter from './ui/Meter.svelte' import Select from './ui/Select.svelte' @@ -35,10 +38,8 @@ let { events, holdings, popularity, paused, onquick, onundo, onhide, onshow }: Props = $props() - // The last quick action or hide, offered for undo for a while (a UI timer, not game time). - const UNDO_MS = 15_000 - let undo = $state<{ text: string; run: () => void } | null>(null) - let undoTimer: ReturnType | undefined + // The last quick action or hide, offered for undo for a while. + const undo = new TimedUndo() // The business picked for each event with choices (the Mansion boost). let picked = $state>({}) @@ -47,23 +48,12 @@ const choice = choices && (picked[event.id] ?? choices[0]?.id) const result = onquick(event, choice) if (!result) return - offerUndo(result.done, () => onundo(event.id, result.undo)) + undo.offer('overview', result.done, () => onundo(event.id, result.undo)) } function hide(event: NextEvent) { onhide(event.focus) - offerUndo(`Hid ${card(event)} from the Overview`, () => onshow(event.focus)) - } - - function offerUndo(text: string, run: () => void) { - undo = { text, run } - clearTimeout(undoTimer) - undoTimer = setTimeout(() => (undo = null), UNDO_MS) - } - - function takeBack() { - undo?.run() - undo = null + undo.offer('overview', `Hid ${card(event)} from the Overview`, () => onshow(event.focus)) } const link = (e: NextEvent) => hashForTab(e.tab, e.focus) @@ -122,12 +112,7 @@

Ready now - {#if undo} - - {undo.text}. - - - {/if} +

{#if due.length === 0}

Nothing to do right now.

@@ -184,9 +169,7 @@ {#if paused}Paused: play-time countdowns wait for Start{/if} {#if coming.length === 0} -

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

+ Nothing else is tracked. {:else}
    {#each coming as event (event.id)} @@ -275,11 +258,6 @@ margin: 0; } - .quiet a { - color: var(--accent-text); - text-decoration: underline; - } - /* Money waiting: one strip of readouts, like the in-game business totals. */ .summary { display: grid; @@ -414,17 +392,6 @@ white-space: normal; } - .undo { - align-items: center; - color: var(--fg); - display: inline-flex; - font-family: var(--font-body); - font-size: var(--text-sm); - gap: var(--space-2); - letter-spacing: normal; - text-transform: none; - } - .popularity { gap: 2px; } diff --git a/src/components/PropertyList.svelte b/src/components/PropertyList.svelte index 2a65870..e446e8b 100644 --- a/src/components/PropertyList.svelte +++ b/src/components/PropertyList.svelte @@ -1,44 +1,274 @@ - -
    - {#each catalog as property (property.id)} - - {:else} -

    No properties yet. They appear here as their trackers are built.

    - {/each} +
    +
    + + Trackers show only what you own. Pick one to set its upgrades. +
      + {#each catalog as property (property.id)} + {@const isPicked = property.id === current?.id} +
    • + + { + picked = property.id + onchange(next) + }} + /> +
    • + {#if isPicked} +
    • {@render upgrades(property)}
    • + {/if} + {:else} +
    • No properties yet. They appear here as their trackers are built.
    • + {/each} +
    +
    + + {#if current} +
    + {@render upgrades(current)} +
    + {/if}
    -

    Trackers show only what you own. Upgrades change how fast they count.

    - +
    + +{#snippet upgrades(property: Property)} + {#if property.upgrades.length === 0} + Nothing to set: LSOS tracks it as soon as you mark it owned. + {:else} + + {isOwned(owned, property.id) + ? 'Upgrades change how fast its trackers count.' + : `Mark ${property.label} owned to set its upgrades.`} + + {/if} +{/snippet} diff --git a/src/components/PropertySettings.svelte b/src/components/PropertySettings.svelte index 8f8397f..50c7b69 100644 --- a/src/components/PropertySettings.svelte +++ b/src/components/PropertySettings.svelte @@ -1,18 +1,10 @@
    {property.label} -
    - {#if isPropertyOwned && property.canShutDown} - - {/if} - -
    +
    - {#if isPropertyOwned && property.upgrades.length > 0} + {#if isOwned(owned, property.id) && property.upgrades.length > 0}
    - {#each property.upgrades as upgrade (upgrade.id)} - {#if upgrade.kind === 'toggle'} - - {:else if upgrade.kind === 'count'} -
    - onchange(setUpgrade(owned, property.id, upgrade.id, value))} - /> -
    - {:else} -
    - - + onchange(setUpgrade(owned, property.id, upgrade.id, event.currentTarget.checked))} + /> + {upgrade.label} + + {:else if upgrade.kind === 'count'} +
    + onchange(setUpgrade(owned, property.id, upgrade.id, value))} + /> +
    + {:else} +
    + + - Start tracking play time when LSOS opens - -

    - Off (recommended): LSOS opens paused. Press Start once you have loaded into a GTA Online - session, since that is when the game starts counting. On: loading screens count as play time - too, unless you open LSOS only once you are in. -

    - - - -

    - When stock is full, supplies run out, a safe is full or a timer is ready: anything on the - Overview. -

    - - - {#if alertError} -

    {alertError}

    - {:else if !notificationsSupported()} -

    This browser cannot show notifications.

    - {/if} -
    - - - (store.state.alerts = { ...alerts, unfocusedOnly: e.currentTarget.checked })} - /> - Only while LSOS is not the focused window, e.g. while you play - -

    Alerts come only while LSOS is open in a tab.

    - - - -

    - Left out of the Overview and the alerts, but still tracked on their own tabs and counted in - the money waiting. Hide one with Hide on its Overview item. -

    - {#if hidden.length === 0} -

    Nothing is hidden.

    - {:else} - - {/if} -
    - - -

    - Your data lives only in this browser. Export it to keep a copy or to move to another device. -

    -
    - - - -
    - {#if message} -

    {message.text}

    - {/if} -
    - - -
    - +
    +
    + + + + Off (recommended): LSOS opens paused. Press Start once you have loaded into a GTA Online + session, since that is when the game starts counting. On: loading screens count as play time + too, unless you open LSOS only once you are in. + + + + + + When stock is full, supplies run out, a safe is full or a timer is ready: anything on the + Overview. + + + + {#if alertError} +

    {alertError}

    + {:else if !notificationsSupported()} + This browser cannot show notifications. + {/if} +
    + + + (store.state.alerts = { ...alerts, unfocusedOnly: e.currentTarget.checked })} + /> + Only while LSOS is not the focused window, e.g. while you play + + Alerts come only while LSOS is open in a tab. + + + + + Left out of the Overview and the alerts, but still tracked on their own tabs and counted in + the money waiting. Hide one with Hide on its Overview item. + + {#if hidden.length === 0} +

    Nothing is hidden.

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

    + Your data lives only in this browser. Export it to keep a copy or to move to another device. +

    +
    + + + +
    + {#if message} +

    {message.text}

    + {/if} +
    +
    + + +
    +
    diff --git a/src/components/ui/BusinessBars.svelte b/src/components/ui/BusinessBars.svelte index a344cff..6721627 100644 --- a/src/components/ui/BusinessBars.svelte +++ b/src/components/ui/BusinessBars.svelte @@ -21,24 +21,29 @@ now: number /** Called with the corrected bars; the tracker stores them as of now (decision 003). */ onresync: (bars: Bars) => void - /** False for a business that cannot be raided: no "Raided" button. */ - raids?: boolean + /** Bar labels, e.g. with "(est.)" when the rates are estimated (decision 013). */ + stockLabel?: string + suppliesLabel?: string + /** Muted text after the stock label, e.g. "40 / 100 units". */ + stockDetail?: string } - let { bars: stored, now, onresync, raids = true }: Props = $props() + let { + bars: stored, + now, + onresync, + stockLabel = 'Stock', + suppliesLabel = 'Supplies', + stockDetail, + }: 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.entries(BUSINESS_EVENTS) - .filter(([id]) => raids || id !== 'raided') - .map(([, event]) => event) - .filter((event) => bars.supplies !== undefined || !event.empties.includes('supplies')), - ) + const sold = BUSINESS_EVENTS.sold - // One undo for the whole business: the last edit, Full or event, whichever bars it changed. + // One undo for the whole business: the last edit or event, whichever bars it changed. // Dragging and nudging one bar with the keys in a row count as one edit. let last = $state(null) @@ -66,7 +71,8 @@
    {#if bars.supplies !== undefined}
    - {#if bars.supplies !== undefined} {/if} - {#each events as event (event.label)} - - {/each} + {#if last} {/if} diff --git a/src/components/ui/Button.svelte b/src/components/ui/Button.svelte index 0327268..f3db074 100644 --- a/src/components/ui/Button.svelte +++ b/src/components/ui/Button.svelte @@ -9,13 +9,15 @@ * when pressed and `ghost` when not. */ variant?: 'primary' | 'ghost' + /** Smaller, for a row of several actions or a title bar. */ + small?: boolean children: Snippet } - let { variant = 'ghost', type = 'button', children, ...rest }: Props = $props() + let { variant = 'ghost', small = false, type = 'button', children, ...rest }: Props = $props() - + diff --git a/src/components/ui/Hint.svelte b/src/components/ui/Hint.svelte new file mode 100644 index 0000000..054b791 --- /dev/null +++ b/src/components/ui/Hint.svelte @@ -0,0 +1,27 @@ + + +

    {@render children()}

    + + diff --git a/src/components/ui/Kit.svelte b/src/components/ui/Kit.svelte index 695e54b..103360a 100644 --- a/src/components/ui/Kit.svelte +++ b/src/components/ui/Kit.svelte @@ -13,10 +13,13 @@ import Countdown from './Countdown.svelte' import EditableValue from './EditableValue.svelte' import Fact from './Fact.svelte' + import Hint from './Hint.svelte' import Meter from './Meter.svelte' + import Note from './Note.svelte' import NumberField from './NumberField.svelte' import Panel from './Panel.svelte' import Select from './Select.svelte' + import SettingsButton from './SettingsButton.svelte' let amount = $state(40) let picked = $state('a') @@ -82,13 +85,18 @@ - {#snippet footer()} + + + + {#snippet actions()} {}} />{/snippet} +
    - {/snippet} + +
    @@ -112,6 +120,14 @@ + + A hint: muted help text. + A fine hint, for footnotes like how an estimate is made. + Warn: something to look at. + Quiet: a plain state. + Good: a suggestion. + + + // What a tab shows while nothing on it is owned (issue #60): what is missing, then a link to + // mark it in Settings. With `property`, Settings opens on that property. + import type { Snippet } from 'svelte' + import { hashForTab } from '../../lib/tabs' + import Hint from './Hint.svelte' + + interface Props { + property?: string + children: Snippet + } + + let { property, children }: Props = $props() + + +{@render children()} Mark what you own in + Settings. diff --git a/src/components/ui/Meter.svelte b/src/components/ui/Meter.svelte index 20754c5..cbff4d6 100644 --- a/src/components/ui/Meter.svelte +++ b/src/components/ui/Meter.svelte @@ -111,7 +111,10 @@ onkeydown={key} > {@render bar()} -
    +
    {:else}
    diff --git a/src/components/ui/Note.svelte b/src/components/ui/Note.svelte new file mode 100644 index 0000000..d1bde96 --- /dev/null +++ b/src/components/ui/Note.svelte @@ -0,0 +1,58 @@ + + +
    +

    + {#if title}{title}{/if} + {@render children()} +

    + {#if actions}{@render actions()}{/if} +
    + + diff --git a/src/components/ui/Panel.svelte b/src/components/ui/Panel.svelte index 0f1aba3..cd1a32d 100644 --- a/src/components/ui/Panel.svelte +++ b/src/components/ui/Panel.svelte @@ -5,8 +5,8 @@ interface Props { title: string children: Snippet - /** Optional strip under the body, like the action row on the in-game business cards. */ - footer?: Snippet + /** Small controls at the right end of the title bar, e.g. the settings gear. */ + actions?: Snippet /** * Makes the title bar a handle to move the panel: these handlers go on it, it takes focus, * and the parent does the moving (`CardGrid`, decision 026). @@ -18,32 +18,34 @@ focus?: string } - let { title, children, footer, move, moving = false, focus }: Props = $props() + let { title, children, actions, move, moving = false, focus }: Props = $props() const id = $props.id()
    -

    - {#if move} -
    - {@render heading()} -
    - - {:else} -
    {@render heading()}
    +
    +

    + {#if move} +
    + {@render heading()} +
    + + {:else} +
    {@render heading()}
    + {/if} +

    + {#if actions} +
    {@render actions()}
    {/if} -

    +
    {@render children()}
    - {#if footer} - - {/if}
{#snippet heading()} @@ -60,13 +62,33 @@ min-width: 0; } - h3 { + .head { background: var(--panel-raised); border-bottom: 1px solid var(--line); + position: relative; + } + + h3 { font-size: var(--text-md); margin: 0; } + /* Room for the actions on both sides, so a long title neither runs under them nor + leaves the centre. */ + .head:has(.actions) .title { + padding-inline: 2.75rem; + } + + /* Over the right end of the title bar, so the title stays centred on the card. */ + .actions { + display: flex; + gap: var(--space-1); + position: absolute; + right: var(--space-2); + top: 50%; + transform: translateY(-50%); + } + .title { align-items: center; display: flex; @@ -112,12 +134,4 @@ gap: var(--space-3); padding: var(--space-3) var(--space-4); } - - .footer { - border-top: 1px solid var(--line); - display: flex; - flex-wrap: wrap; - gap: var(--space-2); - padding: var(--space-2) var(--space-4); - } diff --git a/src/components/ui/SettingsButton.svelte b/src/components/ui/SettingsButton.svelte index 068d8c8..eeb7398 100644 --- a/src/components/ui/SettingsButton.svelte +++ b/src/components/ui/SettingsButton.svelte @@ -1,6 +1,7 @@ - - - + diff --git a/src/components/ui/UndoStatus.svelte b/src/components/ui/UndoStatus.svelte new file mode 100644 index 0000000..fbc75e3 --- /dev/null +++ b/src/components/ui/UndoStatus.svelte @@ -0,0 +1,35 @@ + + +{#if offer} + + {offer.text}. + + +{/if} + + diff --git a/src/data/business-events.ts b/src/data/business-events.ts index 36114ce..dc4a23e 100644 --- a/src/data/business-events.ts +++ b/src/data/business-events.ts @@ -12,7 +12,7 @@ export interface BusinessEvent { checked: string } -export const BUSINESS_EVENTS: Record<'sold' | 'raided', BusinessEvent> = { +export const BUSINESS_EVENTS: Record<'sold', BusinessEvent> = { sold: { label: 'Sold', empties: ['stock'], @@ -20,15 +20,6 @@ export const BUSINESS_EVENTS: Record<'sold' | 'raided', BusinessEvent> = { source: 'Maintainer, from play (issue #4): selling empties the stock, supplies stay.', checked: '2026-10-14', }, - raided: { - label: 'Raided', - empties: ['stock', 'supplies'], - cancelsDelivery: true, - source: - 'Maintainer, from play (issue #4): a successful police raid zeroes stock and supplies ' + - 'and cancels supplies still on their way.', - checked: '2026-10-14', - }, } /** Buying supplies: they arrive after a delay and fill the supplies bar. */ diff --git a/src/data/production.ts b/src/data/production.ts index 640d5bc..4cb4c3b 100644 --- a/src/data/production.ts +++ b/src/data/production.ts @@ -48,8 +48,6 @@ export interface Production { valueBonus?: { upgrade: string; bonus: number } /** Owning another property raises the sale value, e.g. a Money Front (decision 019). */ ownedBonus?: OwnedBonus - /** False if the business cannot be raided. */ - raids?: false /** A production boost the player can start in the business. */ boost?: ProductionBoost /** A choice of what the staff work on, e.g. manufacturing or research. */ @@ -264,7 +262,6 @@ export const PRODUCTION: readonly Production[] = [ unitName: 'units', maxUnits: 160, valueBonus: { upgrade: 'named', bonus: 0.05 }, - raids: false, boost: { units: 80, maxRealMs: 24 * 60 * 60_000, diff --git a/src/data/properties.ts b/src/data/properties.ts index d9c10d2..5ffafc0 100644 --- a/src/data/properties.ts +++ b/src/data/properties.ts @@ -70,7 +70,7 @@ const BUNKER_SOURCE = 'gta.wiki, Disruption Logistics (Buy Upgrades): equipment, staff and security upgrades, ' + 'and staff on Manufacturing, Research or an even split to Both.' -export const PROPERTIES: readonly Property[] = [ +const CATALOG: readonly Property[] = [ mc('mc-cocaine', 'Cocaine Lockup'), mc('mc-meth', 'Methamphetamine Lab'), mc('mc-cash', 'Counterfeit Cash Factory'), @@ -261,6 +261,45 @@ export const PROPERTIES: readonly Property[] = [ })), ] +/** + * The order of Settings and of the cards on each tab until the user moves them (issue #60): + * roughly how often each is used. Money makers first, MC businesses by money per hour at full + * upgrades (from `production.ts`: Cocaine $70k, Meth $49.6k, Cash $45.9k, Weed $39.4k, + * Documents $35k); properties that only unlock something or add to a safe last. + */ +export const USE_ORDER = [ + 'acid-lab', + 'bunker', + 'nightclub', + 'mc-cocaine', + 'mc-meth', + 'mc-cash', + 'mc-weed', + 'mc-documents', + 'car-wash', + 'helitours', + 'smoke-on-the-water', + 'agency', + 'salvage-yard', + 'arcade', + 'garment-factory', + 'special-cargo', + 'hangar', + 'vehicle-warehouse', + 'auto-shop', + 'mckenzie-hangar', + MANSION_BOOST.propertyId, + 'bail-office', +] as const + +const rank = (property: Property) => { + const index = (USE_ORDER as readonly string[]).indexOf(property.id) + return index === -1 ? USE_ORDER.length : index +} + +/** Every property, in `USE_ORDER`. */ +export const PROPERTIES: readonly Property[] = [...CATALOG].sort((a, b) => rank(a) - rank(b)) + function mc(id: string, label: string): Property { return { id, diff --git a/src/data/safes.ts b/src/data/safes.ts index e07b766..8f23675 100644 --- a/src/data/safes.ts +++ b/src/data/safes.ts @@ -111,7 +111,7 @@ export const SAFES: readonly Safe[] = [ { label: 'DJ +10%', adds: 10, - title: 'Switching back to a DJ you had before (a new DJ fills the bar: use Maxed)', + title: 'Switching back to a DJ you had before (a new DJ fills the bar: drag it to full)', }, { label: 'Event +10% (est.)', diff --git a/src/lib/cards.svelte.ts b/src/lib/cards.svelte.ts new file mode 100644 index 0000000..1f5e1b0 --- /dev/null +++ b/src/lib/cards.svelte.ts @@ -0,0 +1,28 @@ +// An Undo offered for a while right after a quick press, where the press was (issue #60): a +// boost started, a spin marked, an Overview action. A UI timer, not game time (decision 003 +// covers tracker logic only). + +const UNDO_MS = 15_000 + +export class TimedUndo { + /** The press that can be taken back now: where it was (`key`), what it did, how to undo. */ + current = $state<{ key: string; text: string; run: () => void } | null>(null) + #timer: ReturnType | undefined + + offer(key: string, text: string, run: () => void) { + this.current = { key, text, run } + clearTimeout(this.#timer) + this.#timer = setTimeout(() => (this.current = null), UNDO_MS) + } + + /** The offer at this place, if any. */ + at(key: string) { + return this.current?.key === key ? this.current : null + } + + take() { + this.current?.run() + this.current = null + clearTimeout(this.#timer) + } +} diff --git a/src/lib/properties.test.ts b/src/lib/properties.test.ts index 6fc183c..1671ecb 100644 --- a/src/lib/properties.test.ts +++ b/src/lib/properties.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { Property, Upgrade } from '../data/properties' +import { PROPERTIES, USE_ORDER, type Property, type Upgrade } from '../data/properties' import { isOwned, isRunning, @@ -9,6 +9,7 @@ import { setOwned, setShutDown, setUpgrade, + upgradeSummary, upgradeValue, type OwnedProperties, } from './properties' @@ -91,6 +92,23 @@ describe('upgradeValue', () => { }) }) +describe('USE_ORDER', () => { + it('lists every property once, so each has its place', () => { + expect(PROPERTIES.map((p) => p.id)).toEqual([...USE_ORDER]) + }) +}) + +describe('upgradeSummary', () => { + it('names the bought upgrades and leaves out the first choice', () => { + const property = sample('lab') + let owned = setOwned({}, 'lab', true) + expect(upgradeSummary(owned, property)).toBe('No upgrades') + owned = setUpgrade(owned, 'lab', 'equipment', true) + owned = setUpgrade(owned, 'lab', 'tech', 'cash') + expect(upgradeSummary(owned, property)).toBe('Equipment, Technician: Cash') + }) +}) + describe('parseOwnedProperties', () => { it('keeps every entry, listed or not', () => { const data = { diff --git a/src/lib/properties.ts b/src/lib/properties.ts index 3e59ea2..b8c6d99 100644 --- a/src/lib/properties.ts +++ b/src/lib/properties.ts @@ -63,6 +63,19 @@ export function upgradeValue( : (upgrade.options[0]?.id ?? '') } +/** Short summary of the bought upgrades, e.g. "Equipment, Technician: Cash". */ +export function upgradeSummary(owned: OwnedProperties, property: Property): string { + const parts = property.upgrades.flatMap((upgrade) => { + const value = upgradeValue(owned, property.id, upgrade) + if (upgrade.kind === 'toggle') return value === true ? [upgrade.label] : [] + if (upgrade.kind === 'count') return [`${upgrade.label}: ${value}`] + if (upgrade.summary === false || value === upgrade.options[0]?.id) return [] + const option = upgrade.options.find((o) => o.id === value) + return option ? [`${upgrade.label}: ${option.label}`] : [] + }) + return parts.length > 0 ? parts.join(', ') : 'No upgrades' +} + /** Marks a property owned or not. Upgrades are kept, so owning it again restores them. */ export function setOwned( owned: OwnedProperties, diff --git a/src/lib/timers.test.ts b/src/lib/timers.test.ts index f08c794..ecd1b57 100644 --- a/src/lib/timers.test.ts +++ b/src/lib/timers.test.ts @@ -59,7 +59,7 @@ describe('boostableBusinesses', () => { ).map((p) => p.id) it('lists owned, running businesses the boost covers', () => { - expect(ids(owned)).toEqual(['mc-meth', 'mc-weed', 'bunker']) + expect(ids(owned)).toEqual(['bunker', 'mc-meth', 'mc-weed']) expect(ids(setShutDown(owned, 'bunker', true))).toEqual(['mc-meth', 'mc-weed']) })