feat(app): Overview v2 phase 1 — dashboard layout, KPI strip, activity heatmap

- Full-width responsive dashboard grid (2-col body, reflows <900px) so the
  Overview uses the window instead of a narrow column.
- KPI strip: Spend/Calls/Sessions + one-shot rate (current.oneShotRate,
  the previously-missing metric; null → em dash) + cache-hit % + Saved.
- New ActivityHeatmap: GitHub-style 26-week contribution grid from
  history.daily (5 cost levels, active-day count, portal tooltip), ported
  from the menubar's ContributionHeatmapInsight.
- Top activities rail: name · cost · turns · one-shot % (current.topActivities).
- All real payload data, no new bridge calls. typecheck clean, 87 tests pass.
This commit is contained in:
iamtoruk 2026-07-11 11:12:41 -07:00
parent 5a3b9c0cea
commit 9d906832f6
4 changed files with 294 additions and 26 deletions

View file

@ -0,0 +1,147 @@
import { useLayoutEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { formatUsd } from '../lib/format'
import { localDateKey } from '../lib/period'
import type { DailyHistoryEntry } from '../lib/types'
type HeatmapDay = {
date: string
cost: number
calls: number
level: number
isFuture: boolean
}
const WEEK_COUNT = 26
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
function dateFromKey(key: string): Date {
const [year, month, day] = key.split('-').map(Number)
return new Date(year, month - 1, day)
}
function formatDate(key: string): string {
return dateFromKey(key).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})
}
function intensityLevel(cost: number, maxCost: number): number {
if (cost <= 0 || maxCost <= 0) return 0
const ratio = Math.min(1, cost / maxCost)
if (ratio < 0.25) return 1
if (ratio < 0.5) return 2
if (ratio < 0.75) return 3
return 4
}
function buildHeatmapDays(daily: DailyHistoryEntry[], now: Date): HeatmapDay[] {
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const startOfWeek = new Date(today)
startOfWeek.setDate(today.getDate() - today.getDay())
const firstDay = new Date(startOfWeek)
firstDay.setDate(startOfWeek.getDate() - (WEEK_COUNT - 1) * 7)
const byDate = new Map(daily.map(day => [day.date, day]))
const visibleCosts: number[] = []
for (let offset = 0; offset < WEEK_COUNT * 7; offset++) {
const date = new Date(firstDay)
date.setDate(firstDay.getDate() + offset)
if (date <= today) visibleCosts.push(byDate.get(localDateKey(date))?.cost ?? 0)
}
const maxCost = Math.max(...visibleCosts, 0)
return Array.from({ length: WEEK_COUNT * 7 }, (_, offset) => {
const date = new Date(firstDay)
date.setDate(firstDay.getDate() + offset)
const isFuture = date > today
const entry = byDate.get(localDateKey(date))
const cost = isFuture ? 0 : (entry?.cost ?? 0)
return {
date: localDateKey(date),
cost,
calls: isFuture ? 0 : (entry?.calls ?? 0),
level: intensityLevel(cost, maxCost),
isFuture,
}
})
}
export function ActivityHeatmap({ daily }: { daily: DailyHistoryEntry[] }) {
const days = useMemo(() => buildHeatmapDays(daily, new Date()), [daily])
const activeDays = days.filter(day => !day.isFuture && day.cost > 0).length
const [tip, setTip] = useState<{ day: HeatmapDay; x: number; y: number } | null>(null)
const [tipPosition, setTipPosition] = useState<{ left: number; top: number } | null>(null)
const tipRef = useRef<HTMLDivElement>(null)
useLayoutEffect(() => {
if (!tip) {
setTipPosition(null)
return
}
const width = tipRef.current?.offsetWidth ?? 180
const height = tipRef.current?.offsetHeight ?? 58
const gutter = 8
const cursorGap = 12
let left = tip.x + cursorGap
if (left + width > window.innerWidth - gutter) left = tip.x - width - cursorGap
left = Math.max(gutter, Math.min(left, window.innerWidth - width - gutter))
let top = tip.y - height - cursorGap
if (top < gutter) top = tip.y + cursorGap
top = Math.max(gutter, Math.min(top, window.innerHeight - height - gutter))
setTipPosition({ left, top })
}, [tip])
return (
<div className="ov-card ov-panel ov-heatmap-panel">
<div className="ov-panel-head">
<h3>Daily activity</h3>
<span className="r ov-active-days">{activeDays} active days</span>
</div>
<div className="ov-panel-body">
<div className="ov-heatmap-scroll">
<div className="ov-heatmap" role="grid" aria-label="Daily activity contribution heatmap">
<div className="ov-heatmap-labels" aria-hidden="true">
{WEEKDAYS.map((weekday, index) => (
<span key={weekday}>{index === 1 || index === 3 || index === 5 ? weekday : ''}</span>
))}
</div>
<div className="ov-heatmap-cells">
{days.map(day => (
<button
type="button"
role="gridcell"
key={day.date}
className={`ov-heat-cell heat-level-${day.level}${day.isFuture ? ' future' : ''}`}
aria-label={`${formatDate(day.date)}: ${day.isFuture ? 'future day' : `${formatUsd(day.cost)}, ${day.calls} calls`}`}
data-date={day.date}
data-cost={day.cost}
data-active={!day.isFuture && day.cost > 0 ? 'true' : 'false'}
onMouseEnter={event => setTip({ day, x: event.clientX, y: event.clientY })}
onMouseMove={event => setTip({ day, x: event.clientX, y: event.clientY })}
onMouseLeave={() => setTip(null)}
/>
))}
</div>
</div>
</div>
</div>
{tip && createPortal(
<div
ref={tipRef}
className={`chart-tip${tipPosition ? ' on' : ''}`}
style={{ position: 'fixed', ...(tipPosition ?? { left: 0, top: 0 }) }}
role="tooltip"
>
<div className="chart-tip-d">{formatDate(tip.day.date)}</div>
<div className="chart-tip-v">{tip.day.isFuture ? 'Future day' : formatUsd(tip.day.cost)}</div>
<div className="chart-tip-s">{tip.day.isFuture ? 'No activity yet' : `${tip.day.calls} calls`}</div>
</div>,
document.body,
)}
</div>
)
}

View file

@ -71,14 +71,17 @@ function makePayload(now: Date): MenubarPayload {
cost: 312.4,
calls: 4200,
sessions: 88,
oneShotRate: null,
oneShotRate: 0.74,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
cacheHitPercent: 0,
cacheHitPercent: 63.4,
codexCredits: 0,
topActivities: [],
topActivities: [
{ name: 'coding', cost: 92.5, savingsUSD: 7.2, turns: 120, oneShotRate: 0.8 },
{ name: 'debugging', cost: 41.25, savingsUSD: 2.1, turns: 64, oneShotRate: null },
],
topModels: [{ name: 'claude-opus-4', cost: 200, savingsUSD: 0, savingsBaselineModel: '', calls: 100 }],
localModelSavings: { totalUSD: 0, calls: 0, byModel: [], byProvider: [] },
providers: {},
@ -152,6 +155,23 @@ describe('Overview', () => {
expect(screen.getByText('Last 30 days')).toBeInTheDocument()
expect(container.querySelector('.ov-streak')).toHaveTextContent('30-day streak')
// The KPI strip surfaces the payload's previously hidden success/cache
// metrics, and the saved KPI remains backed by the ACT report poll.
const kpis = screen.getByLabelText('Key performance indicators')
expect(within(kpis).getByText('74%')).toBeInTheDocument()
expect(within(kpis).getByText('63%')).toBeInTheDocument()
expect(within(kpis).getByText('$84.20')).toBeInTheDocument()
// The contribution grid contains the real active history days, and the
// right rail renders real, cost-sorted activity data including one-shot.
const heatmap = screen.getByRole('grid', { name: 'Daily activity contribution heatmap' })
expect(heatmap.querySelectorAll('[data-active="true"]')).toHaveLength(30)
expect(screen.getByText('30 active days')).toBeInTheDocument()
expect(screen.getByText('coding')).toBeInTheDocument()
expect(screen.getByText('$92.50')).toBeInTheDocument()
expect(screen.getByText('120 turns')).toBeInTheDocument()
expect(screen.getByText('80% one-shot')).toBeInTheDocument()
// Session row title = the session's project (topSessions has no title field).
expect(screen.getByText('parser-service')).toBeInTheDocument()

View file

@ -2,6 +2,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { CliErrorPanel } from '../components/CliErrorPanel'
import { ActivityHeatmap } from '../components/ActivityHeatmap'
import { ListRow, seriesColorForModel } from '../components/ListRow'
import { Panel } from '../components/Panel'
import { type Polled, usePolled } from '../hooks/usePolled'
@ -350,6 +351,36 @@ function EmptyNote({ children }: { children: React.ReactNode }) {
return <p style={{ color: 'var(--t3)', margin: 0, fontSize: 12 }}>{children}</p>
}
function formatRate(rate: number | null): string {
return rate === null ? '—' : `${Math.round(rate * 100)}%`
}
function TopActivities({ activities }: { activities: MenubarPayload['current']['topActivities'] }) {
const rows = [...activities].sort((a, b) => b.cost - a.cost).slice(0, 6)
if (!rows.length) return <EmptyNote>No activity in this range yet.</EmptyNote>
const maxCost = rows[0].cost
return (
<div className="ov-activities">
{rows.map(activity => (
<div className="ov-activity" key={activity.name}>
<div className="ov-activity-bar" aria-hidden="true">
<span style={{ width: `${maxCost > 0 ? activity.cost / maxCost * 100 : 0}%` }} />
</div>
<div className="ov-activity-main">
<span className="ov-activity-name">{activity.name}</span>
<strong>{formatUsd(activity.cost)}</strong>
</div>
<div className="ov-activity-meta">
<span>{activity.turns.toLocaleString('en-US')} turns</span>
<span>{formatRate(activity.oneShotRate)} one-shot</span>
</div>
</div>
))}
</div>
)
}
export function Overview({ period, provider }: { period: Period; provider: string }) {
const overview = usePolled<MenubarPayload>(() => codeburn.getOverview(period, provider), [period, provider])
return <OverviewContent period={period} overview={overview} />
@ -388,7 +419,16 @@ export function OverviewContent({
const saved = actReport.data?.totals.realizedCostUSD ?? 0
const applied = saved > 0 ? (actReport.data?.totals.measuredActions ?? 0) : 0
return (
<>
<div className="ov-dashboard">
<div className="ov-kpis" aria-label="Key performance indicators">
<div className="ov-kpi"><span>Spend</span><strong>{formatUsd(data.current.cost)}</strong></div>
<div className="ov-kpi"><span>Calls</span><strong>{data.current.calls.toLocaleString('en-US')}</strong></div>
<div className="ov-kpi"><span>Sessions</span><strong>{data.current.sessions.toLocaleString('en-US')}</strong></div>
<div className="ov-kpi ov-kpi-primary"><span>One-shot</span><strong>{formatRate(data.current.oneShotRate)}</strong></div>
<div className="ov-kpi"><span>Cache hit</span><strong>{Math.round(data.current.cacheHitPercent)}%</strong></div>
<div className="ov-kpi ov-kpi-saved"><span>Saved</span><strong>{formatUsd(saved)}</strong><small>from {applied} applied fixes</small></div>
</div>
<div className="ov-hero-row">
<div className="ov-card ov-hero">
<div className="ov-hero-top"><span className="ov-label">{data.current.label}</span><span className="ov-streak"><b>{streakDays(data.history.daily, now)}</b>-day streak</span></div>
@ -409,29 +449,41 @@ export function OverviewContent({
<div className="ov-stats3">
<div className="ov-card ov-stat"><div className="ov-label">Month to date</div><div className="v">{formatUsd(stats.mtd)}</div><div className="d">{stats.pacePct === null ? `No ${stats.prevMonthName} pace yet` : `${stats.pacePct >= 0 ? '+' : ''}${Math.round(stats.pacePct)}% vs ${stats.prevMonthName} pace`}</div></div>
<div className="ov-card ov-stat"><div className="ov-label">Projected month</div><div className="v">{formatUsd(stats.projected)} <small>est</small></div><div className="d warn">{formatUsd(Math.max(0, stats.projected - stats.mtd))} to go</div></div>
<div className="ov-card ov-stat"><div className="ov-label">Saved to date</div><div className="v" style={{ color: 'var(--ok)' }}>{formatUsd(saved)}</div><div className="d ok">from {applied} applied fixes</div></div>
</div>
<div className="ov-card ov-panel">
<div className="ov-panel-head"><h3>Models this period</h3><span className="r">Sorted by cost</span></div>
<div className="ov-panel-body ov-model-panel"><ModelsTable models={models} /></div>
</div>
<div className="ov-body-grid">
<div className="ov-main-column">
<div className="ov-card ov-panel">
<div className="ov-panel-head"><h3>Models this period</h3><span className="r">Sorted by cost</span></div>
<div className="ov-panel-body ov-model-panel"><ModelsTable models={models} /></div>
</div>
<div className="ov-card ov-panel">
<div className="ov-panel-head"><h3>Daily spend</h3><span className="r">{topModel ? `Biggest driver: ${topModel.name}` : 'No model driver yet'}</span></div>
<div className="ov-panel-body">{data.history.daily.length ? <DailyChart daily={chartDaily} /> : <EmptyNote>No spend yet.</EmptyNote>}</div>
</div>
<div className="ov-card ov-panel">
<div className="ov-panel-head"><h3>Daily spend</h3><span className="r">{topModel ? `Biggest driver: ${topModel.name}` : 'No model driver yet'}</span></div>
<div className="ov-panel-body">{data.history.daily.length ? <DailyChart daily={chartDaily} /> : <EmptyNote>No spend yet.</EmptyNote>}</div>
</div>
</div>
<div className="ov-card ov-panel">
<div className="ov-panel-head"><h3>Most expensive sessions</h3><span className="r"><button className="ov-link" type="button">See all </button></span></div>
<div className="ov-panel-body">
{data.current.topSessions.length ? data.current.topSessions.map((session, index) => {
const model = modelIndex.get(sessionModelKey(session.project, session.date, session.calls, session.cost))
const sub = [formatDay(session.date), model, `${session.calls} calls`].filter(Boolean).join(' · ')
return <ListRow key={`${session.project}-${session.date}-${index}`} no={String(index + 1).padStart(2, '0')} dotColor={seriesColorForModel(model)} title={session.project} sub={sub} value={formatUsd(session.cost)} />
}) : <EmptyNote>No sessions in this range.</EmptyNote>}
<div className="ov-side-column">
<div className="ov-card ov-panel">
<div className="ov-panel-head"><h3>Top activities</h3><span className="r">Sorted by cost</span></div>
<div className="ov-panel-body"><TopActivities activities={data.current.topActivities} /></div>
</div>
<div className="ov-card ov-panel">
<div className="ov-panel-head"><h3>Most expensive sessions</h3><span className="r"><button className="ov-link" type="button">See all </button></span></div>
<div className="ov-panel-body">
{data.current.topSessions.length ? data.current.topSessions.map((session, index) => {
const model = modelIndex.get(sessionModelKey(session.project, session.date, session.calls, session.cost))
const sub = [formatDay(session.date), model, `${session.calls} calls`].filter(Boolean).join(' · ')
return <ListRow key={`${session.project}-${session.date}-${index}`} no={String(index + 1).padStart(2, '0')} dotColor={seriesColorForModel(model)} title={session.project} sub={sub} value={formatUsd(session.cost)} />
}) : <EmptyNote>No sessions in this range.</EmptyNote>}
</div>
</div>
</div>
</div>
</>
<ActivityHeatmap daily={data.history.daily} />
</div>
)
}

View file

@ -196,6 +196,16 @@ body { overflow: hidden; background: var(--bg); color: var(--ink); }
.track i.over { background: var(--bad); }
.track i.mut { background: var(--bar); }
.ov-dashboard { display: grid; width: 100%; gap: 12px; }
.ov-kpis { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); overflow: hidden; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; }
.ov-kpi { position: relative; display: flex; min-width: 0; flex-direction: column; justify-content: center; gap: 4px; min-height: 64px; padding: 9px 13px; border-right: 1px solid var(--line2); }
.ov-kpi:last-child { border-right: 0; }
.ov-kpi > span { color: var(--mut); font-size: 11px; font-weight: 520; }
.ov-kpi > strong { overflow: hidden; color: var(--ink); font-family: var(--mono); font-size: 18px; font-weight: 650; font-variant-numeric: tabular-nums; line-height: 1.15; text-overflow: ellipsis; white-space: nowrap; }
.ov-kpi > small { overflow: hidden; color: var(--mut); font-size: 9.5px; text-overflow: ellipsis; white-space: nowrap; }
.ov-kpi-primary { box-shadow: inset 0 2px 0 var(--accent); }
.ov-kpi-primary > span, .ov-kpi-primary > strong { color: var(--accent); }
.ov-kpi-saved > strong { color: var(--ok); }
.ov-hero-row { display: grid; grid-template-columns: minmax(0, 1fr) 196px; gap: 12px; }
.ov-card { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; }
.ov-hero { display: flex; flex-direction: column; gap: 6px; padding: 14px 16px; }
@ -227,7 +237,7 @@ body { overflow: hidden; background: var(--bg); color: var(--ink); }
.ov-coach-tx { font-size: 12.5px; color: var(--mut); flex: 1; line-height: 1.45; }
.ov-coach-tx .num { color: var(--ink); font-weight: 600; }
.ov-coach-cta { white-space: nowrap; color: var(--accent); text-decoration: none; font-size: 11.5px; font-weight: 540; border: 0; background: none; cursor: pointer; }
.ov-stats3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.ov-stats3 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.ov-stat { padding: 11px 13px; }
.ov-stat .v { font-size: 23px; font-weight: 640; letter-spacing: -.02em; margin-top: 6px; font-variant-numeric: tabular-nums; }
.ov-stat .v small { font-size: 12px; color: var(--mut2); font-weight: 500; }
@ -252,7 +262,21 @@ body { overflow: hidden; background: var(--bg); color: var(--ink); }
.ov-models th:nth-child(2), .ov-models td:nth-child(2) { width: 100%; text-align: left; }
.ov-models .ov-model-name { overflow: hidden; color: var(--ink); font-weight: 550; text-overflow: ellipsis; }
.ov-models td.mono { font-family: var(--mono); color: var(--ink); }
.chart { position: relative; height: 150px; display: flex; align-items: flex-end; gap: 4px; padding-top: 8px; border-bottom: 1px solid var(--line2); background: repeating-linear-gradient(to top, transparent 0, transparent 36px, var(--line2) 36px, var(--line2) 37px); }
.ov-body-grid { display: grid; grid-template-columns: minmax(0, 2fr) minmax(300px, 1fr); align-items: start; gap: 12px; }
.ov-main-column, .ov-side-column { display: grid; min-width: 0; gap: 12px; }
.ov-activities { display: grid; gap: 0; }
.ov-activity { position: relative; padding: 9px 0; border-top: 1px solid var(--line2); }
.ov-activity:first-child { padding-top: 0; border-top: 0; }
.ov-activity:last-child { padding-bottom: 0; }
.ov-activity-bar { height: 3px; overflow: hidden; margin-bottom: 6px; border-radius: 2px; background: var(--fill); }
.ov-activity-bar span { display: block; height: 100%; border-radius: inherit; background: var(--accent); }
.ov-activity-main, .ov-activity-meta { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
.ov-activity-main { font-size: 12px; }
.ov-activity-name { overflow: hidden; color: var(--ink); font-weight: 560; text-overflow: ellipsis; white-space: nowrap; }
.ov-activity-main strong { flex: 0 0 auto; font-family: var(--mono); font-size: 11.5px; font-variant-numeric: tabular-nums; }
.ov-activity-meta { margin-top: 3px; color: var(--mut); font-size: 10.5px; font-variant-numeric: tabular-nums; }
.chart { position: relative; height: 150px; display: flex; align-items: flex-end; gap: 4px; padding-top: 8px; border-bottom: 1px solid var(--line2); }
.chart::before { content: ''; position: absolute; right: 0; bottom: 36px; left: 0; height: 1px; background: var(--line2); box-shadow: 0 -37px var(--line2), 0 -74px var(--line2); pointer-events: none; }
.chart .col { flex: 1; background: var(--bar); border-radius: 3px 3px 0 0; min-height: 3px; transition: background .14s ease; border: 0; padding: 0; }
.chart .col.hi { background: var(--bar-hi); } .chart .col:hover { background: var(--accent); }
.chart-tip { position: fixed; pointer-events: none; max-width: calc(100vw - 16px); opacity: 0; background: var(--tip-bg); color: var(--tip-ink); border: 1px solid color-mix(in srgb, var(--tip-ink) 14%, transparent); border-radius: 7px; padding: 7px 10px; line-height: 1.35; white-space: nowrap; box-shadow: 0 6px 20px rgba(0,0,0,.22); transition: opacity .1s ease; z-index: 1000; }
@ -268,6 +292,31 @@ body { overflow: hidden; background: var(--bg); color: var(--ink); }
.ov-summary-chip { display: flex; align-items: baseline; gap: 7px; padding: 5px 9px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); font-size: 10.5px; }
.ov-summary-chip span { color: var(--mut2); }
.ov-summary-chip strong { color: var(--ink); font-family: var(--mono); font-size: 11px; font-weight: 600; font-variant-numeric: tabular-nums; }
.ov-active-days { color: var(--accent) !important; font-family: var(--mono); font-variant-numeric: tabular-nums; }
.ov-heatmap-scroll { max-width: 100%; overflow-x: auto; padding: 2px 0 3px; }
.ov-heatmap { display: grid; grid-template-columns: 28px max-content; align-items: start; gap: 7px; min-width: max-content; }
.ov-heatmap-labels { display: grid; grid-template-rows: repeat(7, 11px); gap: 3px; color: var(--mut2); font-size: 9px; font-weight: 520; line-height: 11px; }
.ov-heatmap-labels span { text-align: right; }
.ov-heatmap-cells { display: grid; grid-template-rows: repeat(7, 11px); grid-auto-flow: column; grid-auto-columns: 11px; gap: 3px; }
.ov-heat-cell { width: 11px; height: 11px; padding: 0; border: 0; border-radius: 2px; background: var(--fill); cursor: default; }
.ov-heat-cell.heat-level-1 { background: color-mix(in srgb, var(--accent) 18%, var(--fill)); }
.ov-heat-cell.heat-level-2 { background: color-mix(in srgb, var(--accent) 38%, var(--fill)); }
.ov-heat-cell.heat-level-3 { background: color-mix(in srgb, var(--accent) 64%, var(--fill)); }
.ov-heat-cell.heat-level-4 { background: var(--accent); }
.ov-heat-cell.future { opacity: .42; }
.ov-heat-cell:hover, .ov-heat-cell:focus-visible { outline: 1px solid var(--accent); outline-offset: 1px; }
@media (max-width: 900px) { .ov-hero-row { grid-template-columns: 1fr; } }
@media (max-width: 720px) { .ov-stats3 { grid-template-columns: 1fr; } }
@media (max-width: 900px) {
.ov-kpis { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.ov-kpi:nth-child(3) { border-right: 0; }
.ov-kpi:nth-child(-n+3) { border-bottom: 1px solid var(--line2); }
.ov-body-grid, .ov-hero-row { grid-template-columns: 1fr; }
}
@media (max-width: 600px) {
.ov-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.ov-kpi:nth-child(3) { border-right: 1px solid var(--line2); }
.ov-kpi:nth-child(even) { border-right: 0; }
.ov-kpi:nth-child(-n+4) { border-bottom: 1px solid var(--line2); }
.ov-stats3 { grid-template-columns: 1fr; }
.ov-coach { align-items: flex-start; }
}