Skip to content

Commit 9810aa7

Browse files
committed
refactor(ui): ADR-012 IA — Insights tab, read-only Overview, empty-state landing (om-bm7n)
Split Overview's conflated altitudes into four surfaces (ADR-012): - New Insights.svelte hosts the analysis cluster (composition, stack-by-type, hunt-yield-by-bank, trophies, hit-rate), lifted out of Dashboard. - Overview slims to glance+ledger and goes read-only. - Add the 4th Insights tab; rename the third view entry->edit to match the ADR. - Move the spot editor to Settings (own update action, won't clobber the polled spot); Overview keeps the read-only freshness chip. - Empty database lands on Do with a get-started card, not a wall of zeros. Establishes the addition contract every later feature obeys: new data -> Edit grid, action -> Do tile, signal -> Do hint, analysis -> Insights row; never a new Dashboard section.
1 parent d469a14 commit 9810aa7

4 files changed

Lines changed: 192 additions & 129 deletions

File tree

web/app/src/App.svelte

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import { today } from '$lib/format'
55
import Dashboard from '$lib/components/Dashboard.svelte'
66
import Do from '$lib/components/Do.svelte'
7+
import Insights from '$lib/components/Insights.svelte'
78
import EditableGrid from '$lib/components/EditableGrid.svelte'
89
import Button from '$lib/components/ui/Button.svelte'
910
import { cn } from '$lib/utils'
@@ -17,9 +18,9 @@
1718
type FlatHolding,
1819
} from '$lib/grids'
1920
import SettingsPanel from '$lib/components/SettingsPanel.svelte'
20-
import { Moon, Sun, RefreshCw, LayoutDashboard, Table2, Zap, Settings as SettingsIcon } from 'lucide-svelte'
21+
import { Moon, Sun, RefreshCw, LayoutDashboard, Table2, Zap, BarChart3, Settings as SettingsIcon } from 'lucide-svelte'
2122
22-
type View = 'overview' | 'do' | 'entry'
23+
type View = 'overview' | 'do' | 'insights' | 'edit'
2324
type DataTab = 'holdings' | 'rolls' | 'trips' | 'supplies' | 'keepers' | 'losses'
2425
2526
let view = $state<View>('overview')
@@ -29,11 +30,23 @@
2930
let error = $state('')
3031
let dark = $state(false)
3132
let settingsOpen = $state(false)
33+
let landed = $state(false)
34+
35+
// A fresh database has no holdings and no roll-txn buys. ADR-012 §4: land such
36+
// a user on an obvious action, not a wall of zeros — Overview also shows a
37+
// get-started state below.
38+
const isEmpty = $derived(!!report && report.lots.length === 0 && report.buy_count === 0)
3239
3340
async function refresh() {
3441
try {
3542
report = await api.summary()
3643
error = ''
44+
// First load only: send a brand-new user straight to Do (ADR-012 §4).
45+
// Never override a navigation the user has already made.
46+
if (!landed) {
47+
landed = true
48+
if (report.lots.length === 0 && report.buy_count === 0) view = 'do'
49+
}
3750
} catch (e) {
3851
error = (e as Error).message
3952
} finally {
@@ -141,9 +154,18 @@
141154
<button
142155
class={cn(
143156
'flex items-center gap-1.5 rounded-md px-4 py-1.5 transition-colors',
144-
view === 'entry' ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
157+
view === 'insights' ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
145158
)}
146-
onclick={() => (view = 'entry')}
159+
onclick={() => (view = 'insights')}
160+
>
161+
<BarChart3 class="size-4" /> Insights
162+
</button>
163+
<button
164+
class={cn(
165+
'flex items-center gap-1.5 rounded-md px-4 py-1.5 transition-colors',
166+
view === 'edit' ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
167+
)}
168+
onclick={() => (view = 'edit')}
147169
>
148170
<Table2 class="size-4" /> Edit
149171
</button>
@@ -160,12 +182,29 @@
160182
<p class="text-sm text-muted-foreground">Loading…</p>
161183
{:else if view === 'overview'}
162184
{#if report}
163-
<Dashboard {report} onRefresh={refresh} />
185+
{#if isEmpty}
186+
<!-- get-started state: one obvious action, not a wall of zeros (ADR-012 §4) -->
187+
<div class="mx-auto mt-6 max-w-md rounded-xl border bg-card p-8 text-center shadow-sm">
188+
<div class="text-4xl">🪙</div>
189+
<h2 class="mt-3 text-lg font-semibold text-foreground">Start your first hunt</h2>
190+
<p class="mt-1.5 text-sm text-muted-foreground">
191+
Log a box of coins you picked up from the bank — CoinRollHunter tracks the rest:
192+
finds, costs, and whether it's paying off.
193+
</p>
194+
<Button class="mt-4" onclick={() => (view = 'do')}>Log your first box →</Button>
195+
</div>
196+
{:else}
197+
<Dashboard {report} />
198+
{/if}
164199
{/if}
165200
{:else if view === 'do'}
166201
{#if report}
167202
<Do {report} onChanged={refresh} />
168203
{/if}
204+
{:else if view === 'insights'}
205+
{#if report}
206+
<Insights {report} />
207+
{/if}
169208
{:else}
170209
<section class="space-y-4">
171210
<div class="flex items-center justify-between gap-3">
@@ -217,7 +256,7 @@
217256
</div>
218257

219258
{#if settingsOpen}
220-
<SettingsPanel onClose={() => (settingsOpen = false)} onSaved={refresh} />
259+
<SettingsPanel spot={report?.spot} onClose={() => (settingsOpen = false)} onSaved={refresh} />
221260
{/if}
222261

223262
{#if sellRow}

web/app/src/lib/components/Dashboard.svelte

Lines changed: 11 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,19 @@
11
<script lang="ts">
2-
import type { Report, EnrichedLot, Spot } from '$lib/types'
2+
import type { Report, EnrichedLot } from '$lib/types'
33
import { verdict } from '$lib/types'
4-
import { money, pct, oz, num, today } from '$lib/format'
5-
import { api } from '$lib/api'
4+
import { money, pct, oz, num } from '$lib/format'
65
import Card from '$lib/components/ui/Card.svelte'
7-
import Button from '$lib/components/ui/Button.svelte'
86
import Badge from '$lib/components/ui/Badge.svelte'
97
import StatCard from './StatCard.svelte'
10-
import Composition from './Composition.svelte'
11-
import StackByType from './StackByType.svelte'
12-
import HuntYield from './HuntYield.svelte'
13-
import HitRateGrid from './HitRateGrid.svelte'
14-
import TrophyFeed from './TrophyFeed.svelte'
158
import { cn } from '$lib/utils'
169
import { Check, TriangleAlert, RadioTower } from 'lucide-svelte'
1710
18-
let { report, onRefresh }: { report: Report; onRefresh: () => void } = $props()
11+
// Overview owns the *glance + ledger* altitude only (ADR-012): what you can
12+
// read without interpreting — is the hunt costing money, what's it worth, is
13+
// the float square. Analysis (yield-by-bank, hit-rate, trophies, composition,
14+
// stack-by-type) lives in Insights; the spot *editor* lives in Settings. This
15+
// view stays read-only.
16+
let { report }: { report: Report } = $props()
1917
2018
const r = $derived(report)
2119
const bullion = $derived(r.lots.filter((l) => l.activity !== 'crh'))
@@ -31,47 +29,11 @@
3129
.map(([d, n]) => `${num(n)} ${d}`)
3230
.join(', ') || 'none logged',
3331
)
34-
35-
// Inline spot editor.
36-
let spotGold = $state(0)
37-
let spotSilver = $state(0)
38-
let spotPlat = $state(0)
39-
let spotPall = $state(0)
40-
let spotDate = $state('')
41-
let spotBusy = $state(false)
42-
let spotErr = $state('')
43-
$effect(() => {
44-
spotGold = r.spot.gold_usd
45-
spotSilver = r.spot.silver_usd
46-
spotPlat = r.spot.platinum_usd
47-
spotPall = r.spot.palladium_usd
48-
spotDate = r.spot.as_of || today()
49-
})
50-
51-
async function saveSpot() {
52-
spotBusy = true
53-
spotErr = ''
54-
try {
55-
const s: Spot = {
56-
as_of: spotDate || today(),
57-
gold_usd: Number(spotGold) || 0,
58-
silver_usd: Number(spotSilver) || 0,
59-
platinum_usd: Number(spotPlat) || 0,
60-
palladium_usd: Number(spotPall) || 0,
61-
source: 'manual',
62-
}
63-
await api.putSpot(s)
64-
onRefresh()
65-
} catch (e) {
66-
spotErr = (e as Error).message
67-
} finally {
68-
spotBusy = false
69-
}
70-
}
7132
</script>
7233

7334
<div class="space-y-6">
74-
<!-- meta line + spot freshness chip (ADR-007: a background poller refreshes spot) -->
35+
<!-- meta line + spot freshness chip (ADR-007: a background poller refreshes spot).
36+
Read-only here; the spot editor moved to Settings (ADR-012 §5). -->
7537
<div class="flex flex-wrap items-center justify-between gap-x-4 gap-y-1">
7638
<p class="text-sm text-muted-foreground">
7739
As of <span class="font-medium text-foreground">{r.spot.as_of || ''}</span>
@@ -80,7 +42,7 @@
8042
</p>
8143
<span
8244
class="inline-flex items-center gap-1.5 rounded-full border bg-muted/40 px-2.5 py-0.5 text-xs text-muted-foreground"
83-
title="Spot prices refresh in the background while the app runs (ADR-007). Manual entry is the offline fallback."
45+
title="Spot prices refresh in the background while the app runs (ADR-007). Manual entry is the offline fallback in Settings."
8446
>
8547
<RadioTower class="size-3" />
8648
<span>Spot: {r.spot.source || 'none'}</span>
@@ -121,12 +83,6 @@
12183
<StatCard label="CRH net (cash)" value={money(r.crh_net_real)} sub="finds minus costs" tone={crhTone} />
12284
</div>
12385

124-
<!-- live composition snapshot -->
125-
<Composition {report} />
126-
127-
<!-- unified inventory: stack by coin type (bought + found combined) -->
128-
<StackByType {report} />
129-
13086
<!-- bullion -->
13187
<section class="space-y-2">
13288
<div class="flex items-center justify-between">
@@ -260,17 +216,6 @@
260216
</div>
261217
</section>
262218

263-
<!-- hunt yield by bank & box -->
264-
{#if r.box_yields?.length}
265-
<HuntYield {report} />
266-
{/if}
267-
268-
<!-- greatest hits: finds flagged as trophies (ADR-006) -->
269-
<TrophyFeed {report} />
270-
271-
<!-- hit-rate report: 1 per face $, per denom × category × source (ADR-006) -->
272-
<HitRateGrid {report} />
273-
274219
<!-- realized (sold) -->
275220
{#if r.realized?.length}
276221
<section class="space-y-2">
@@ -318,60 +263,4 @@
318263
</Card>
319264
</section>
320265
{/if}
321-
322-
<!-- spot updater -->
323-
<section class="space-y-2">
324-
<h2 class="text-lg font-semibold">Spot prices</h2>
325-
<Card class="flex flex-wrap items-end gap-4 p-4">
326-
<label class="flex flex-col gap-1 text-xs text-muted-foreground">
327-
Gold $/ozt
328-
<input
329-
type="number"
330-
step="0.01"
331-
bind:value={spotGold}
332-
class="w-32 rounded-md border border-input bg-card px-2 py-1.5 text-sm text-foreground tnum focus:border-ring focus:outline-none"
333-
/>
334-
</label>
335-
<label class="flex flex-col gap-1 text-xs text-muted-foreground">
336-
Silver $/ozt
337-
<input
338-
type="number"
339-
step="0.01"
340-
bind:value={spotSilver}
341-
class="w-32 rounded-md border border-input bg-card px-2 py-1.5 text-sm text-foreground tnum focus:border-ring focus:outline-none"
342-
/>
343-
</label>
344-
<label class="flex flex-col gap-1 text-xs text-muted-foreground">
345-
Platinum $/ozt
346-
<input
347-
type="number"
348-
step="0.01"
349-
bind:value={spotPlat}
350-
class="w-32 rounded-md border border-input bg-card px-2 py-1.5 text-sm text-foreground tnum focus:border-ring focus:outline-none"
351-
/>
352-
</label>
353-
<label class="flex flex-col gap-1 text-xs text-muted-foreground">
354-
Palladium $/ozt
355-
<input
356-
type="number"
357-
step="0.01"
358-
bind:value={spotPall}
359-
class="w-32 rounded-md border border-input bg-card px-2 py-1.5 text-sm text-foreground tnum focus:border-ring focus:outline-none"
360-
/>
361-
</label>
362-
<label class="flex flex-col gap-1 text-xs text-muted-foreground">
363-
As of
364-
<input
365-
type="date"
366-
bind:value={spotDate}
367-
class="rounded-md border border-input bg-card px-2 py-1.5 text-sm text-foreground focus:border-ring focus:outline-none"
368-
/>
369-
</label>
370-
<Button onclick={saveSpot} disabled={spotBusy}>Update spot</Button>
371-
{#if spotErr}<span class="text-sm text-destructive">{spotErr}</span>{/if}
372-
</Card>
373-
<p class="text-xs text-muted-foreground">
374-
Manual entry is the offline fallback; a live spot feed can be wired behind the same endpoint later.
375-
</p>
376-
</section>
377266
</div>
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<script lang="ts">
2+
// Insights — the *analysis* altitude (ADR-012). Everything that needs a legend
3+
// or study lives here, lifted out of Overview so the day-to-day glance stays
4+
// read-without-interpreting. A day-to-day user never has to open this tab.
5+
import type { Report } from '$lib/types'
6+
import Composition from './Composition.svelte'
7+
import StackByType from './StackByType.svelte'
8+
import HuntYield from './HuntYield.svelte'
9+
import TrophyFeed from './TrophyFeed.svelte'
10+
import HitRateGrid from './HitRateGrid.svelte'
11+
12+
let { report }: { report: Report } = $props()
13+
const r = $derived(report)
14+
15+
// Progressive disclosure (ADR-012 §3): analysis is *earned* by data. With
16+
// nothing logged, don't render empty grids — a simple derived predicate, not a
17+
// stored flag. "Which banks pay off" stays dormant until finds link to boxes.
18+
const hasAnything = $derived(r.lots.length > 0 || r.buy_count > 0)
19+
</script>
20+
21+
<div class="space-y-6">
22+
<div>
23+
<h2 class="text-lg font-semibold">Insights</h2>
24+
<p class="text-sm text-muted-foreground">
25+
The analysis layer — composition, which banks pay off, hit-rate, and your best finds.
26+
Each view fills in as you log more hunts.
27+
</p>
28+
</div>
29+
30+
{#if !hasAnything}
31+
<div class="rounded-lg border border-dashed px-4 py-10 text-center text-sm text-muted-foreground">
32+
Nothing to analyze yet. Log a box and some finds in
33+
<span class="font-medium text-foreground">Do</span>, and your yield-by-bank, hit-rate, and
34+
trophies will appear here.
35+
</div>
36+
{:else}
37+
<!-- live composition snapshot -->
38+
<Composition {report} />
39+
40+
<!-- unified inventory: stack by coin type (bought + found combined) -->
41+
<StackByType {report} />
42+
43+
<!-- hunt yield by bank & box: "which banks pay off" — dormant until finds link to boxes -->
44+
{#if r.box_yields?.length}
45+
<HuntYield {report} />
46+
{/if}
47+
48+
<!-- greatest hits: finds flagged as trophies (ADR-006) -->
49+
<TrophyFeed {report} />
50+
51+
<!-- hit-rate report: 1 per face $, per denom × category × source (ADR-006) -->
52+
<HitRateGrid {report} />
53+
{/if}
54+
</div>

0 commit comments

Comments
 (0)