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
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ LSOS (Los Santos Operating System) is a static, browser-only GTA Online tracker:
safes, cooldowns and weekly events. User data lives in `localStorage`; there is no backend.
Svelte 5 + TypeScript + Vite. Public repository.

**Next up: #2** (clocks). Update this line when a PR finishes.
**Next up: #3** (terminal design). Update this line when a PR finishes.

## Where things are

Expand All @@ -14,6 +14,7 @@ Svelte 5 + TypeScript + Vite. Public repository.
| What to work on | the issue named in "Next up", only that issue |
| Continue an unfinished PR | `docs/handoff/<file>.md` on the PR branch |
| Tabs and hash routing | `src/lib/tabs.ts`, `src/App.svelte` |
| Play/real clocks, `onPlayTime` for trackers | `src/lib/clock.ts`, `clock.svelte.ts` (dec. 008) |
| Game logic (pure TS, unit-tested) | `src/lib/` |
| Saved user state, migrations, export/import | `src/lib/state.ts`, `src/lib/store.svelte.ts` |
| UI components (thin) | `src/components/` |
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ is ready again.
- **Static site, no account.** Everything you enter stays in your browser (`localStorage`).
Nothing is sent anywhere. Export/import lets you back up or move your data.
- **Time-based estimates.** LSOS can't see the game. Business production runs while LSOS is
open and not paused ("play time"). Real-world cooldowns such as the Lucky Wheel run on the wall
clock even when LSOS is closed.
open and tracking ("play time"). LSOS opens paused: press Start once you have loaded into a
session, or turn on tracking on open in Settings. Real-world cooldowns such as the Lucky Wheel
run on the wall clock even when LSOS is closed.
- **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
5 changes: 3 additions & 2 deletions docs/decisions/003-time-model.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 003: Two clocks, play time and real time

2026-10-13, project bootstrap
2026-10-13, project bootstrap. How play time is kept between sessions: changed by decision 008.

## Decision

Expand All @@ -23,7 +23,8 @@ Each tracker declares which clock it uses, and the data for it cites why (decisi
- If the gap between two ticks is too large to be throttling (e.g. the computer slept), it is not
counted. The exact threshold is set when the clock is built and must exceed browser background
throttling (about one minute).
- Closing the tab stops play time. Reopening resumes from the saved total.
- Closing the tab stops play time. (Decision 008: play time is not kept between sessions;
trackers save their own progress.)
- A pause toggle covers "LSOS open, but not playing".

## Resync
Expand Down
42 changes: 42 additions & 0 deletions docs/decisions/008-clock-details.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# 008: Clock details: session play time, start paused, throttling and frozen tabs

2026-10-13, issue #2. Changes one point of decision 003 (play time is no longer kept between
sessions).

## Decision

Builds on decision 003. Code: `src/lib/clock.ts` (logic), `src/lib/clock.svelte.ts` (timers,
browser events, listeners), `src/components/ClockBar.svelte` (indicator, toggle, gap prompt).

- **Play time lasts one session.** It starts at zero each time LSOS opens and is not saved.
Trackers keep their own saved progress, so a running total across sessions adds nothing.
- **Trackers get play time as it is counted**: `onPlayTime(listener)` calls the listener with
each counted stretch (every tick, on pause, on close, and when a long gap is confirmed), and
the tracker adds it to its saved progress. Real-time trackers store a wall-clock time and use
`realElapsed(sinceMs, now)`.
- **LSOS opens paused** unless "Start tracking play time when LSOS opens" is on in Settings
(`clock.trackOnOpen`, saved, off by default). Opening LSOS without playing then counts nothing.
While paused, the clock bar says to press Start once loaded into a GTA Online session, not
during loading screens: per the maintainer, that is close to when the game starts counting.
- **Throttled ticks lose nothing.** Each tick adds the time since the previous one, so a hidden
tab ticking once a minute (Chrome's intensive throttling, typical while a fullscreen game
covers the browser) still counts every second.
- **Gaps up to 5 minutes count; longer gaps are asked about.** Throttled gaps stay under about
two minutes. Longer ones come from the browser freezing the tab (Chrome Energy Saver freezes
tabs hidden for over five minutes) or the computer sleeping. The first happens while the user
plays, the second does not, and the page cannot tell them apart. So long gaps add up and a
prompt asks "Were you playing?" with "Count it" and "Skip". Until answered nothing is counted.
Negative gaps (clock set back) count zero.
- **The page saves when hidden or frozen** (`visibilitychange`, `freeze`), so trackers' progress
survives a tab the browser discards without another event. `saveNow` writes the current state
directly because the save effect would run too late. A discarded tab reloads as a new session.
- **Leaving the page** (`pagehide`) counts up to that moment and stops measuring, so time away
is not counted if the page returns from the back/forward cache. `visibilitychange` fires after
`pagehide` and must not restart the clock.

## Why

Play time drives most estimates, and browsers throttle or freeze background tabs exactly when the
user is gaming. Guessing either way is wrong in one of the cases, so LSOS counts what it can prove
and asks about the rest. Starting paused errs the same way: nothing is counted until the user
says they are playing.
3 changes: 2 additions & 1 deletion docs/decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ free number, add one line below. **Replacing one:** write a new decision, then m

- [001](001-static-site.md) Static site, all user data in browser storage, no backend or accounts
- [002](002-stack.md) Svelte 5 + TypeScript + Vite, Vitest for tests, Prettier for formatting
- [003](003-time-model.md) Two clocks: play time (only while LSOS is open and not paused) and real time
- [003](003-time-model.md) Two clocks: play time (only while LSOS is open and not paused) and real time (session play time: 008)
- [004](004-game-data.md) Every game value lives in `src/data/` with a source; never guessed
- [005](005-weekly-data.md) Weekly events come from a JSON file updated by PR, not a live API
- [006](006-persisted-state.md) Persisted state is versioned, migrated on load, exportable as JSON
- [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
2 changes: 2 additions & 0 deletions src/App.svelte
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<script lang="ts">
import ClockBar from './components/ClockBar.svelte'
import SaveStatus from './components/SaveStatus.svelte'
import Settings from './components/Settings.svelte'
import { TABS, hashForTab, tabFromHash, type TabId } from './lib/tabs'
Expand All @@ -23,6 +24,7 @@
</nav>
</header>

<ClockBar />
<SaveStatus />

<main>
Expand Down
100 changes: 100 additions & 0 deletions src/components/ClockBar.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<script lang="ts">
import { formatDuration } from '../lib/clock'
import { answerGaps, clocks, togglePause } from '../lib/clock.svelte'

const paused = $derived(clocks.play.paused)
const playTime = $derived(formatDuration(clocks.play.playMs))
const unconfirmed = $derived(clocks.play.unconfirmedMs)
const realTime = $derived(
new Date(clocks.now).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
// Always 24h: browsers expose only their language, not the OS 12h/24h setting.
hourCycle: 'h23',
}),
)
</script>

<section class="clocks" aria-label="Clocks">
<span class="indicator" class:paused role="status">{paused ? 'PAUSED' : 'TRACKING'}</span>
<span>Session play time <strong>{playTime}</strong></span>
<span>Real time <strong>{realTime}</strong></span>
<button type="button" aria-pressed={paused} onclick={togglePause}>
{paused ? 'Start' : 'Pause'}
</button>
{#if paused}
<span class="hint">
Press Start once you have loaded into a GTA Online session, not during loading screens: that
is when the game starts counting.
</span>
{/if}
</section>

{#if unconfirmed > 0}
<section class="gap" aria-label="Unconfirmed time" role="status">
<span>
LSOS could not track <strong>{formatDuration(unconfirmed)}</strong>: the browser paused this
tab or the computer slept. Were you playing?
</span>
<button type="button" onclick={() => answerGaps(true)}>Count it</button>
<button type="button" onclick={() => answerGaps(false)}>Skip</button>
</section>
{/if}

<style>
.clocks {
align-items: center;
border-bottom: 1px solid var(--line);
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.5rem;
padding: 0.5rem 1rem;
}

.gap {
align-items: center;
border-bottom: 1px solid var(--warn);
color: var(--warn);
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1rem;
padding: 0.5rem 1rem;
}

.hint {
color: var(--muted);
}

.indicator {
border: 1px solid var(--accent);
color: var(--accent);
font-weight: bold;
letter-spacing: 0.1em;
padding: 0.125rem 0.5rem;
}

.indicator.paused {
border-color: var(--warn);
color: var(--warn);
}

strong {
font-variant-numeric: tabular-nums;
}

button {
background: none;
border: 1px solid var(--line);
color: var(--fg);
cursor: pointer;
font: inherit;
padding: 0.25rem 0.75rem;
text-transform: uppercase;
}

button:hover,
button:focus-visible {
border-color: var(--accent);
}
</style>
28 changes: 28 additions & 0 deletions src/components/Settings.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,19 @@
}
</script>

<section>
<h2>Clock</h2>
<label>
<input type="checkbox" bind:checked={store.state.clock.trackOnOpen} />
Start tracking play time when LSOS opens
</label>
<p class="hint">
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.
</p>
</section>

<section>
<h2>Backup</h2>
<p>
Expand Down Expand Up @@ -75,6 +88,21 @@
text-transform: uppercase;
}

label {
align-items: center;
cursor: pointer;
display: flex;
gap: 0.5rem;
}

input[type='checkbox'] {
accent-color: var(--accent);
}

.hint {
color: var(--muted);
}

.error {
color: var(--warn);
}
Expand Down
84 changes: 84 additions & 0 deletions src/lib/clock.svelte.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Runs the session's play clock and offers both clocks to the UI (decision 003).
// Logic lives in clock.ts; this file only wires it to Svelte, timers and the browser.
import {
TICK_INTERVAL_MS,
close,
confirmGaps,
dismissGaps,
pause,
resume,
startSession,
tick,
type ClockState,
} from './clock'
import { saveNow, store } from './store.svelte'

class Clocks {
/** Wall-clock time of the latest tick, epoch ms. Read it to re-render once per tick. */
now = $state(Date.now())
/** This session's play clock. */
play: ClockState = $state(startSession(store.state.clock, Date.now()))
}

export const clocks = new Clocks()

type PlayListener = (ms: number) => void
const listeners = new Set<PlayListener>()

/**
* Calls `listener` with each stretch of play time as it is counted, so a tracker can add it to
* its own saved progress. Returns a function that stops the calls.
*/
export function onPlayTime(listener: PlayListener): () => void {
listeners.add(listener)
return () => listeners.delete(listener)
}

function report(ms: number) {
if (ms > 0) for (const listener of listeners) listener(ms)
}

/** Set on `pagehide`. Browsers fire `visibilitychange` after it, which must not restart the clock. */
let closed = false

function onTick() {
if (closed) return
clocks.now = Date.now()
report(tick(clocks.play, clocks.now))
}

export function togglePause(): void {
clocks.now = Date.now()
if (clocks.play.paused) resume(clocks.play, clocks.now)
else report(pause(clocks.play, clocks.now))
}

export function answerGaps(playing: boolean): void {
if (playing) report(confirmGaps(clocks.play))
else dismissGaps(clocks.play)
}

setInterval(onTick, TICK_INTERVAL_MS)
// A hidden tab may not have ticked for up to a minute. Count that time whenever the page is
// hidden, frozen (Chrome Energy Saver) or shown again, and save trackers' progress before the
// browser can discard the tab.
document.addEventListener('visibilitychange', () => {
onTick()
saveNow()
})
document.addEventListener('freeze', () => {
onTick()
saveNow()
})
document.addEventListener('resume', onTick)
window.addEventListener('pagehide', () => {
report(close(clocks.play, Date.now()))
closed = true
saveNow()
})
// Back from the back/forward cache: the time away was not spent in LSOS, so start measuring anew.
window.addEventListener('pageshow', (event) => {
if (!event.persisted) return
closed = false
onTick()
})
Loading
Loading