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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ emptying.
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 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
session, or turn on tracking on open in Settings. Played a while without LSOS? Skip time
moves every play-time tracker forward by however long you enter. Real-world cooldowns such as the Lucky Wheel
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.)".
Expand Down
5 changes: 5 additions & 0 deletions docs/decisions/008-clock-details.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ browser events, listeners), `src/components/ClockBar.svelte` (indicator, toggle,
- **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.
- **Skip time** (issue #70): the clock bar's "Skip time" button takes a duration the user played
without LSOS tracking (same formats as resync: `10`, `1h 30m`, `1:30`) and reports it through
`onPlayTime` like any counted stretch, so every play-time tracker catches up at once. Real-time
timers are left alone: real time already passed. There is no undo, because trackers only take
positive stretches; the button shows the exact amount (`Skip 1:30:00`) before it is pressed.

## Why

Expand Down
2 changes: 1 addition & 1 deletion docs/decisions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ free number, add one line below. **Replacing one:** write a new decision, then m
- [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
- [008](008-clock-details.md) Clock details: session play time, opens paused (setting), throttled ticks counted, long gaps asked about, Skip time for play without LSOS
- [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; 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
Expand Down
114 changes: 113 additions & 1 deletion src/components/ClockBar.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
<script lang="ts">
import { formatDuration } from '../lib/clock'
import { answerGaps, clocks, togglePause } from '../lib/clock.svelte'
import { tick } from 'svelte'
import { answerGaps, clocks, skipPlayTime, togglePause } from '../lib/clock.svelte'
import { parseValue } from '../lib/resync'
import Button from './ui/Button.svelte'
import Digits from './ui/Digits.svelte'

Expand All @@ -18,6 +20,40 @@
hourCycle: 'h23',
}),
)

// Skip time (issue #70): play time spent without LSOS tracking, added to every tracker.
let skipping = $state(false)
let draft = $state('')
let invalid = $state(false)
let skipped = $state<string | null>(null)
let input = $state<HTMLInputElement>()
const preview = $derived(parseValue(draft, 'duration'))

async function startSkipping() {
draft = ''
invalid = false
skipped = null
skipping = true
await tick()
input?.focus()
}

function stopSkipping() {
skipping = false
}

function confirmSkip(event: SubmitEvent) {
event.preventDefault()
if (preview === null) {
invalid = true
return
}
if (preview > 0) {
skipPlayTime(preview)
skipped = formatDuration(preview)
}
stopSkipping()
}
</script>

<!-- The portal bar at the top of the window, like "Business Management Portal" in game. -->
Expand All @@ -32,9 +68,48 @@
<Button variant={paused ? 'primary' : 'ghost'} aria-pressed={paused} onclick={togglePause}>
{paused ? 'Start' : 'Pause'}
</Button>
<Button
aria-expanded={skipping}
title="Played without LSOS tracking? Move every play-time tracker forward"
onclick={() => (skipping ? stopSkipping() : startSkipping())}
>
Skip time
</Button>
</div>
</section>

{#if skipping}
<form class="skip" aria-label="Skip time" onsubmit={confirmSkip}>
<label for="skip-time">Played without LSOS tracking? How long:</label>
<input
id="skip-time"
bind:this={input}
bind:value={draft}
aria-invalid={invalid}
aria-describedby="skip-time-hint"
autocomplete="off"
placeholder="10m"
oninput={() => (invalid = false)}
onkeydown={(event) => {
if (event.key === 'Escape') stopSkipping()
}}
/>
<Button variant="primary" type="submit">
{preview ? `Skip ${formatDuration(preview)}` : 'Skip'}
</Button>
<Button onclick={stopSkipping}>Cancel</Button>
<span id="skip-time-hint" class="skip-hint" class:invalid>
{invalid ? 'Not a duration. ' : ''}Minutes (10), 1h 30m or 1:30. Every play-time tracker moves
forward by that much; real-time timers already count time away.
</span>
</form>
{:else if skipped}
<p class="hint" role="status">
Skipped {skipped} of play time.
<Button small onclick={() => (skipped = null)}>OK</Button>
</p>
{/if}

{#if paused}
<p class="hint">
Press Start once you have loaded into a GTA Online session, not during loading screens: that is
Expand Down Expand Up @@ -119,6 +194,43 @@
padding: var(--space-2) var(--space-4);
}

.skip {
align-items: center;
border-bottom: 1px solid var(--line);
display: flex;
flex-wrap: wrap;
font-size: var(--text-sm);
gap: var(--space-2) var(--space-3);
padding: var(--space-2) var(--space-4);
}

.skip input {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
color: var(--fg);
font-family: var(--font-display);
font-size: var(--text-md);
letter-spacing: var(--tracking);
min-height: 2.25rem;
min-width: 0;
padding: 0 var(--space-2);
width: 8rem;
}

.skip input[aria-invalid='true'] {
border-color: var(--danger);
}

.skip-hint {
color: var(--muted);
flex-basis: 100%;
}

.skip-hint.invalid {
color: var(--danger);
}

.gap {
align-items: center;
border-bottom: 1px solid var(--warn);
Expand Down
7 changes: 7 additions & 0 deletions src/lib/clock.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
dismissGaps,
pause,
resume,
skipAhead,
startSession,
tick,
type ClockState,
Expand Down Expand Up @@ -71,6 +72,12 @@ export function answerGaps(playing: boolean): void {
else dismissGaps(clocks.play)
}

/** Counts `ms` of play time the user spent without LSOS tracking, so every tracker catches up. */
export function skipPlayTime(ms: number): void {
report(skipAhead(clocks.play, ms))
saveNow()
}

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
Expand Down
22 changes: 22 additions & 0 deletions src/lib/clock.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
pause,
realElapsed,
resume,
skipAhead,
startSession,
tick,
type ClockState,
Expand Down Expand Up @@ -104,6 +105,27 @@ describe('confirming long gaps', () => {
})
})

describe('skipping ahead', () => {
it('counts time played without LSOS and reports it, paused or not', () => {
const t = setup()
t.tickAfter(MINUTE)
expect(skipAhead(t.clock, 10 * MINUTE)).toBe(10 * MINUTE)
expect(t.clock.playMs).toBe(11 * MINUTE)
pause(t.clock, t.now())
expect(skipAhead(t.clock, 5 * MINUTE)).toBe(5 * MINUTE)
expect(t.clock).toMatchObject({ playMs: 16 * MINUTE, paused: true })
})

it('does not move the next tick or count negative time', () => {
const t = setup()
skipAhead(t.clock, 10 * MINUTE)
t.tickAfter(SECOND)
expect(t.reported()).toBe(SECOND)
expect(skipAhead(t.clock, -MINUTE)).toBe(0)
expect(t.clock.playMs).toBe(10 * MINUTE + SECOND)
})
})

describe('pause and resume', () => {
it('does not count paused time, and counts up to the pause', () => {
const t = setup()
Expand Down
10 changes: 10 additions & 0 deletions src/lib/clock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,16 @@ export function dismissGaps(clock: ClockState): void {
clock.unconfirmedMs = 0
}

/**
* The user played without LSOS tracking (issue #70): count `ms` as play time, as if LSOS had
* been running. Returns the play time counted, for trackers to add to their progress.
*/
export function skipAhead(clock: ClockState, ms: number): number {
const counted = Math.max(0, ms)
clock.playMs += counted
return counted
}

/** Real time since `sinceMs` (epoch ms). Never negative, even if the clock was set back. */
export function realElapsed(sinceMs: number, now: number): number {
return Math.max(0, now - sinceMs)
Expand Down
Loading