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 @@ -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/` |
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ 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. 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.
Expand Down
56 changes: 56 additions & 0 deletions docs/decisions/023-overview.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# 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, 010, 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, whether it rests
on an estimate, the card it belongs to (`tab` and `focus`) and, for timers that need nothing
else, a quick action. The trackers build them: `productionEvents`, `goodsEvents`, `safeEvents`,
`frontsEvents`, `timerEvents`. `src/lib/overview.ts` gathers and sorts them.
- **Events:** per running business with a known rate, whichever comes first: stock full (Sell) or
supplies out (Resupply). 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.
- **Supplies on their way** are no event of their own: they need nothing from the player. The
forecast (`forecastAt`, also on the business cards) runs the business on what it has until
they arrive, then on full supplies. If the stock fills before they arrive, that comes first.
Play time is taken to run along with real time meanwhile, as `produceAll` does.
- **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, the money waiting (business stock at the value the cards show, nightclub
goods, safes, total) and nightclub popularity with its bar, amber when it cuts the payout;
"Ready now" as large cards with the action first; "Coming up" as one line per item (time,
name, bar, value) in two columns. While play time is paused, play-time countdowns turn grey.
- **Jump to the card:** every item links to `#/<tab>/<focus>`. 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 `#/<tab>` so the same link works again.
- **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
optionally only while LSOS is not the focused window. A pop-up shows the live countdown, links
to the card, and closes when dismissed or once the item is handled. Items already that close
when LSOS opens, when the alert time changes, or after an undo, do not alert.
- **Saved state** (version 12): `alerts: { browser, popup, leadMs, unfocusedOnly }`.

## 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. A supply delivery is not something to do, so it must not hide what
comes next. Nightclub popularity is rarely checked in game, yet it cuts the safe's payout, so it
gets a place in the strip rather than a list item: as an event it would almost always be due,
since it falls below the top payout within a day of being maxed. 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.
1 change: 1 addition & 0 deletions docs/decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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
37 changes: 36 additions & 1 deletion src/App.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<script lang="ts">
import Boot from './components/Boot.svelte'
import Overview from './components/Overview.svelte'
import Popups from './components/Popups.svelte'
import Businesses from './components/Businesses.svelte'
import ClockBar from './components/ClockBar.svelte'
import Safes from './components/Safes.svelte'
Expand All @@ -12,10 +14,12 @@
import { PROPERTIES } from './data/properties'
import { SAFES } from './data/safes'
import { clocks } from './lib/clock.svelte'
import { dismissPopup, overview, quick, undoQuick } from './lib/overview.svelte'
import './lib/production.svelte'
import './lib/safes.svelte'
import { setProperties, store } from './lib/store.svelte'
import { TABS, tabFromHash } from './lib/tabs'
import { tick } from 'svelte'
import { TABS, focusFromHash, hashForTab, tabFromHash } from './lib/tabs'

let hash = $state(location.hash)
const active = $derived(tabFromHash(hash))
Expand All @@ -28,6 +32,26 @@
window.addEventListener('hashchange', onHashChange)
return () => window.removeEventListener('hashchange', onHashChange)
})

// A link to one card (`#/safes/agency`, from the Overview): scroll to it and make it glow, then
// drop the card from the address so the same link works again (issue #15).
const FLASH_MS = 2400
$effect(() => {
const target = focusFromHash(hash)
if (!target) return
const tab = active
tick().then(() => {
const card = document.querySelector<HTMLElement>(`[data-focus="${CSS.escape(target)}"]`)
history.replaceState(null, '', hashForTab(tab))
hash = location.hash
if (!card) return
card.scrollIntoView({ behavior: 'smooth', block: 'center' })
card.classList.remove('flash')
void card.offsetWidth
card.classList.add('flash')
setTimeout(() => card.classList.remove('flash'), FLASH_MS)
})
})
</script>

<div class="terminal">
Expand All @@ -48,6 +72,15 @@
{#await kitModule then { default: Kit }}
<Kit />
{/await}
{:else if active === 'overview'}
<Overview
events={overview.events}
holdings={overview.holdings}
popularity={overview.popularity}
paused={clocks.play.paused}
onquick={quick}
onundo={undoQuick}
/>
{:else if active === 'businesses'}
<Businesses
catalog={PROPERTIES}
Expand Down Expand Up @@ -111,6 +144,8 @@
</footer>
</div>

<Popups events={overview.events} open={overview.popups} ondismiss={dismissPopup} />

<Boot />

<style>
Expand Down
23 changes: 23 additions & 0 deletions src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,30 @@ a {
outline-offset: 2px;
}

/* A card the Overview linked to: a glow that fades out (issue #15). */
.flash {
animation: flash 2.4s ease-out;
}

@keyframes flash {
0%,
40% {
box-shadow:
0 0 0 2px var(--focus),
0 0 1.5rem rgb(255 215 94 / 0.35);
}

100% {
box-shadow: 0 0 0 2px transparent;
}
}

@media (prefers-reduced-motion: reduce) {
.flash {
animation: none;
outline: 2px solid var(--focus);
}

*,
*::before,
*::after {
Expand Down
29 changes: 16 additions & 13 deletions src/components/BusinessProduction.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import {
boostRunning,
estimatedParts,
forecast,
forecastAt,
nextBoostAt,
startBoost,
stockUnits,
Expand All @@ -18,6 +18,7 @@
type KnownRate,
} from '../lib/production'
import { formatValue, settleBars, type Bars } from '../lib/resync'
import { mansionBoostOn, type MansionBoost } from '../lib/timers'
import BusinessBars from './ui/BusinessBars.svelte'
import Button from './ui/Button.svelte'
import Countdown from './ui/Countdown.svelte'
Expand Down Expand Up @@ -46,8 +47,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
/** The last Mansion boost, on any business (issue #14). */
mansionBoost?: MansionBoost
}

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

const current = $derived(settleBars(bars, now, SUPPLY_PURCHASE.fillsTo))
Expand All @@ -79,16 +80,18 @@
)
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 mansionOn = $derived(
mansionBoostOn(mansionBoost, MANSION_BOOST, production.propertyId, now),
)
const mansionEndsAt = $derived((mansionBoost?.startedAt ?? 0) + MANSION_BOOST.durationMs)
// Counts supplies on their way from their arrival (issue #15).
const ahead = $derived(
running && !shutDown
? forecast(
current,
production,
running,
boosting ? boost!.unitsLeft : 0,
mansionOn ? { msLeft: mansionEndsAt! - now, speed: MANSION_BOOST.speed } : undefined,
)
? forecastAt(current, production, running, production.propertyId, now, {
boost,
mansion: { boost: mansionBoost, data: MANSION_BOOST },
fillsTo: SUPPLY_PURCHASE.fillsTo,
})
: null,
)
const percent = (value: number) => formatValue(value, 'percent')
Expand Down Expand Up @@ -161,7 +164,7 @@
{#if mansionOn && !shutDown}
<Countdown
label="Mansion boost ×{MANSION_BOOST.speed} ends in"
remainingMs={mansionEndsAt! - now}
remainingMs={mansionEndsAt - now}
/>
{/if}

Expand Down
14 changes: 8 additions & 6 deletions src/components/Businesses.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
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 @@ -31,7 +30,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 type { MansionBoost } from '../lib/timers'
import BusinessProduction from './BusinessProduction.svelte'
import NightclubWarehouse from './NightclubWarehouse.svelte'
import PropertySettings from './PropertySettings.svelte'
Expand Down Expand Up @@ -251,7 +250,12 @@
class:shut={isShutDown(owned, property.id)}
style:translate={moving === property.id ? `${lift.x}px ${lift.y}px` : undefined}
>
<Panel title={property.label} move={handle(property.id)} moving={moving === property.id}>
<Panel
title={property.label}
move={handle(property.id)}
moving={moving === property.id}
focus={property.id}
>
{#if open === property.id}
<PropertySettings {property} {owned} {onchange} />
{:else}
Expand All @@ -271,9 +275,7 @@
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}
{mansionBoost}
/>
{/if}
{#if warehouse}
Expand Down
Loading
Loading