feat(overview): a custom date range beside the range presets
The Insights page's range group gains a calendar picker: one panel that takes BOTH ends, in either order. A backwards pick — end before start — is sorted on the second click, so `orderedDayRange` hands every reader downstream (the scope window, the ledger-day predicate, the chip that names it) a pair with `from <= to`; `dayRangeWindow` orders it again at the seam, so a hand-built pair can never filter the page to nothing. The trigger IS the scope: one pill carrying the start and the end, fed the LIVE window — a picked range when one owns the scope, the preset's own window read as days otherwise, so "last 30 days" prints the thirty days it covers and the two controls can never disagree. Only a picked range takes the chip's brand voice and grows the way back, and the panel keeps no second copy of the two dates: the chip above it is already showing them, live, while the pick runs. Underneath, the range model moves from a start-only floor to a two-sided `RangeWindow`, which closes the top of a picked scope: the ledger-day predicate gains the ceiling it never had, so a day beyond the range no longer leaks into the skill card, and both picked days count whole (midnight through the last millisecond). `days.ts` gains the calendar arithmetic this needs — a day's epoch bounds, its weekday, month stepping, and one month laid out as Sunday-first weeks — with the round-trip key validation its siblings already used. The panel portals to `<body>` and seats itself off its trigger's rect, the containment escape the page's hover tips already take: the page is the `lc-ov` query container, so an in-tree popover would be both captured and clipped by the one scroller. It measures itself before paint, clamps inside the viewport, flips above the trigger when it would fall off the bottom, and retracts on any scroll, resize, outside press, or Escape. Closes #128
This commit is contained in:
1 parent
d3f2399440
commit
6051e39180
12 files changed
+1357
-109
No files matched your search
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* The Insights page's date-range picker: one calendar panel that takes BOTH
|
||||
* ends of a custom range, parked beside the range group's own presets.
|
||||
*
|
||||
* The trigger IS the scope: one pill carrying the start and the end side by
|
||||
* side, fed the LIVE window — a picked range when one owns the scope, the
|
||||
* preset's own window read as days otherwise ("last 30 days" shows the thirty
|
||||
* days it covers). So the picker and the presets never disagree, and a scope
|
||||
* the reader has to click into to see is a scope they cannot trust. Only a
|
||||
* picked range takes the chip's brand voice and grows the way back.
|
||||
*
|
||||
* Two clicks make the range, in any order. The second click is paired against
|
||||
* the first and SORTED, so a backwards drag (end before start) simply swaps
|
||||
* the two ends rather than committing a reversed window — every reader
|
||||
* downstream (the scope window, the ledger-day predicate, the chip that names
|
||||
* the range) only ever sees `from <= to`. While the second end is pending the
|
||||
* band previews under the cursor; the pick commits on that second click and
|
||||
* hands the scope straight back to the page, so the dashboard answers
|
||||
* immediately.
|
||||
*
|
||||
* The panel itself stays a plain calendar — no second copy of the two ends:
|
||||
* the chip above it is already showing them, live, while the pick runs.
|
||||
*
|
||||
* The panel is portaled to <body> and fixed-positioned off its trigger's rect,
|
||||
* the same containment escape the page's hover tips and skill tips take: the
|
||||
* page is the `lc-ov` query container and its scroller clips an in-tree
|
||||
* popover, so an unportaled panel would be cropped and offset. It measures
|
||||
* itself after commit (hidden until then, no flash at an unmeasured spot),
|
||||
* clamps inside the viewport, and flips above the trigger when it would fall
|
||||
* off the bottom. Any scroll, resize, outside press, or Escape closes it —
|
||||
* retract rather than float detached from a moved anchor.
|
||||
*/
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type ReactElement } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { MONTH_KEY_RE, monthGridOf, shiftDayKey, shiftMonthKey } from '../../shared/days'
|
||||
import { orderedDayRange, type DayRange } from '../overview'
|
||||
import type { ViewKit } from '../viewkit'
|
||||
import { useEscapeClose } from './escapeClose'
|
||||
import { IconChevronDown } from '../primitives'
|
||||
|
||||
export interface DateRangeProps {
|
||||
/**
|
||||
* The scope's day span — the picked range while one owns the scope, else the
|
||||
* preset's own window read as days (its open end is today). Null when the
|
||||
* scope is unbounded ("all"), where there is no span to print.
|
||||
*/
|
||||
value: DayRange | null
|
||||
/** Whether `value` is a picked range rather than a preset's window — the chip's voice and the way back. */
|
||||
picked: boolean
|
||||
/** Range relay: null hands the scope back to the preset. */
|
||||
onChange: (range: DayRange | null) => void
|
||||
/** The local today key (injected so specs pin the calendar) — the last selectable day. */
|
||||
today: string
|
||||
/** The active locale tag, for the panel's month header. */
|
||||
locale: string
|
||||
}
|
||||
|
||||
/** Trigger-to-panel distance and the viewport margin the panel clamps inside, in px. */
|
||||
const GAP = 6
|
||||
const MARGIN = 8
|
||||
|
||||
/** The grid's cell id, the target its `aria-activedescendant` cursor names. */
|
||||
const CELL_ID = 'lc-dp-day'
|
||||
|
||||
/** The grid's arrow-key day steps: left/right one day, up/down one week. */
|
||||
function dayStepOf(key: string): number | null {
|
||||
if (key === 'ArrowLeft') return -1
|
||||
if (key === 'ArrowRight') return 1
|
||||
if (key === 'ArrowUp') return -7
|
||||
if (key === 'ArrowDown') return 7
|
||||
return null
|
||||
}
|
||||
|
||||
/** The month header's localized name, or the bare key when it does not convert. */
|
||||
export function monthLabelOf(month: string, locale: string): string {
|
||||
if (!MONTH_KEY_RE.test(month)) return month
|
||||
const date = new Date(Number(month.slice(0, 4)), Number(month.slice(5, 7)) - 1, 1)
|
||||
return new Intl.DateTimeFormat(locale, { year: 'numeric', month: 'long' }).format(date)
|
||||
}
|
||||
|
||||
export function makeDateRange(kit: ViewKit): (props: DateRangeProps) => ReactElement {
|
||||
const { t } = kit
|
||||
return function DateRange(props: DateRangeProps): ReactElement {
|
||||
const [open, setOpen] = useState(false)
|
||||
// The month the panel shows, and the grid's keyboard cursor inside it.
|
||||
const [month, setMonth] = useState('')
|
||||
const [cursor, setCursor] = useState('')
|
||||
// The first picked end, while the second is still pending.
|
||||
const [anchor, setAnchor] = useState<string | null>(null)
|
||||
const [hover, setHover] = useState<string | null>(null)
|
||||
const [spot, setSpot] = useState<{ left: number; top: number } | null>(null)
|
||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||
const panelRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const close = (): void => { setOpen(false) }
|
||||
useEscapeClose(open, close)
|
||||
|
||||
// Any press outside the trigger and the panel closes it. The scroll and
|
||||
// resize rules match the page's hover tips: the trigger moves with the
|
||||
// pane, so the panel retracts instead of floating off its anchor.
|
||||
useEffect(() => {
|
||||
if (!open) return undefined
|
||||
const onDown = (ev: MouseEvent): void => {
|
||||
const target = ev.target
|
||||
if (!(target instanceof Node)) return
|
||||
if (rootRef.current?.contains(target) === true || panelRef.current?.contains(target) === true) return
|
||||
close()
|
||||
}
|
||||
window.addEventListener('mousedown', onDown)
|
||||
window.addEventListener('scroll', close, true)
|
||||
window.addEventListener('resize', close)
|
||||
return () => {
|
||||
window.removeEventListener('mousedown', onDown)
|
||||
window.removeEventListener('scroll', close, true)
|
||||
window.removeEventListener('resize', close)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Seat the panel after it commits, then keep it seated: the comparison
|
||||
// makes a re-measure that lands on the same spot a no-op, so the
|
||||
// every-render measurement cannot loop. Object.is, not ===: an
|
||||
// unmeasurable anchor reads NaN, and NaN === NaN is false — which would
|
||||
// make every re-measure a fresh object and spin the tree.
|
||||
useLayoutEffect(() => {
|
||||
const anchorEl = rootRef.current
|
||||
const panelEl = panelRef.current
|
||||
if (!open || anchorEl === null || panelEl === null) return
|
||||
const rect = anchorEl.getBoundingClientRect()
|
||||
const left = Math.max(MARGIN, Math.min(rect.right - panelEl.offsetWidth, window.innerWidth - panelEl.offsetWidth - MARGIN))
|
||||
const below = rect.bottom + GAP
|
||||
const top = below + panelEl.offsetHeight > window.innerHeight - MARGIN
|
||||
? Math.max(MARGIN, rect.top - panelEl.offsetHeight - GAP)
|
||||
: below
|
||||
setSpot(current => current !== null && Object.is(current.left, left) && Object.is(current.top, top) ? current : { left, top })
|
||||
})
|
||||
|
||||
// The committed range, or the pending pair under the cursor (the anchor
|
||||
// alone until the hover gives the second end a preview).
|
||||
const draft = anchor === null ? props.value : orderedDayRange(anchor, hover ?? anchor)
|
||||
const grid = open ? monthGridOf(month) : null
|
||||
|
||||
const show = (): void => {
|
||||
// Always this month: the calendar is a picking tool, and today is where
|
||||
// a pick starts. The live scope's band is highlighted wherever it falls.
|
||||
const home = props.today.slice(0, 7)
|
||||
setMonth(home)
|
||||
setCursor(`${home}-01`)
|
||||
setAnchor(null)
|
||||
setHover(null)
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const pick = (key: string): void => {
|
||||
if (anchor === null) {
|
||||
setAnchor(key)
|
||||
setHover(null)
|
||||
return
|
||||
}
|
||||
const next = orderedDayRange(anchor, key)
|
||||
setAnchor(null)
|
||||
setHover(null)
|
||||
setOpen(false)
|
||||
props.onChange(next)
|
||||
}
|
||||
|
||||
const stepMonth = (delta: number): void => {
|
||||
const next = shiftMonthKey(month, delta)
|
||||
if (next === null) return
|
||||
setMonth(next)
|
||||
setCursor(`${next}-01`)
|
||||
}
|
||||
|
||||
// One tab stop for the whole grid: the arrows walk the cursor a day or a
|
||||
// week (staying inside the shown month and out of the future), Enter or
|
||||
// Space picks it.
|
||||
const onKeyDown = (ev: React.KeyboardEvent): void => {
|
||||
if (ev.key === 'Enter' || ev.key === ' ') {
|
||||
ev.preventDefault()
|
||||
pick(cursor)
|
||||
return
|
||||
}
|
||||
const step = dayStepOf(ev.key)
|
||||
if (step === null) return
|
||||
ev.preventDefault()
|
||||
const next = shiftDayKey(cursor, step)
|
||||
if (next !== null && next <= props.today && next.slice(0, 7) === month) setCursor(next)
|
||||
}
|
||||
|
||||
const shown = spot !== null && open ? spot : null
|
||||
// The two ends the head reads: the live scope's span while it has one, the
|
||||
// placeholders otherwise. A first click with no hover yet has chosen only
|
||||
// the start, so the end keeps its placeholder rather than echoing it.
|
||||
const from = draft?.from
|
||||
const to = anchor !== null && hover === null ? undefined : draft?.to
|
||||
return (
|
||||
<div className="lc-dp" ref={rootRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={'lc-dp-trigger' + (props.picked ? ' lc-dp-trigger-on' : '')}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={grid !== null}
|
||||
aria-label={t('ov.date.open')}
|
||||
onClick={() => { if (open) { close() } else { show() } }}
|
||||
>
|
||||
<span className={'lc-dp-half' + (from === undefined ? ' lc-dp-half-empty' : '')}>{from ?? t('ov.date.from')}</span>
|
||||
<span className="lc-dp-sep">→</span>
|
||||
<span className={'lc-dp-half' + (to === undefined ? ' lc-dp-half-empty' : '')}>{to ?? t('ov.date.to')}</span>
|
||||
<IconChevronDown size={12} />
|
||||
</button>
|
||||
{grid === null ? null : createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="lc-tip lc-dp-panel"
|
||||
role="dialog"
|
||||
aria-label={t('ov.date.label')}
|
||||
style={shown === null
|
||||
? { left: 0, top: 0, visibility: 'hidden' }
|
||||
: { left: shown.left, top: shown.top }}
|
||||
>
|
||||
<div className="lc-dp-nav">
|
||||
<button type="button" className="lc-dp-navbtn" aria-label={t('ov.date.prev')} onClick={() => { stepMonth(-1) }}>‹</button>
|
||||
<span className="lc-dp-month">{monthLabelOf(month, props.locale)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="lc-dp-navbtn"
|
||||
aria-label={t('ov.date.next')}
|
||||
disabled={month >= props.today.slice(0, 7)}
|
||||
onClick={() => { stepMonth(1) }}
|
||||
>›</button>
|
||||
</div>
|
||||
<div className="lc-dp-wds" aria-hidden="true">
|
||||
{[0, 1, 2, 3, 4, 5, 6].map(d => <span key={d} className="lc-dp-wd">{t(`ov.date.wd.${String(d)}`)}</span>)}
|
||||
</div>
|
||||
<div
|
||||
className="lc-dp-grid"
|
||||
role="grid"
|
||||
tabIndex={0}
|
||||
aria-label={t('ov.date.pick')}
|
||||
aria-activedescendant={CELL_ID}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
{grid.map((week, w) => (
|
||||
<div className="lc-dp-week" role="row" key={w}>
|
||||
{week.map((key, d) => {
|
||||
if (key === null) return <span className="lc-dp-cell lc-dp-blank" key={d} />
|
||||
const future = key > props.today
|
||||
const edge = draft !== null && (key === draft.from || key === draft.to)
|
||||
const inRange = draft !== null && key >= draft.from && key <= draft.to
|
||||
// Only the cursor carries the id `aria-activedescendant` names.
|
||||
const className = 'lc-dp-cell'
|
||||
+ (inRange ? ' lc-dp-in' : '')
|
||||
+ (edge ? ' lc-dp-edge' : '')
|
||||
+ (key === cursor ? ' lc-dp-cursor' : '')
|
||||
+ (key === props.today ? ' lc-dp-today' : '')
|
||||
+ (future ? ' lc-dp-future' : '')
|
||||
// The day of the month, unpadded: the header already names
|
||||
// the month, so "3" is the whole cell.
|
||||
const day = String(Number(key.slice(8, 10)))
|
||||
if (future) {
|
||||
// The cursor never lands here (the keyboard stops at
|
||||
// today), so this cell carries no activedescendant id.
|
||||
return (
|
||||
<span className={className} role="gridcell" aria-disabled="true" aria-label={key} key={d}>
|
||||
{day}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className={className}
|
||||
role="gridcell"
|
||||
id={key === cursor ? CELL_ID : undefined}
|
||||
aria-label={key}
|
||||
aria-selected={edge}
|
||||
key={d}
|
||||
onMouseEnter={() => { if (anchor !== null) setHover(key) }}
|
||||
onClick={() => { pick(key) }}
|
||||
>
|
||||
{day}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="lc-dp-foot">
|
||||
<span className="lc-dp-hint">{anchor === null ? t('ov.date.hint') : t('ov.date.hintEnd')}</span>
|
||||
{props.picked && (
|
||||
<button
|
||||
type="button"
|
||||
className="lc-dp-clear"
|
||||
onClick={() => { props.onChange(null) }}
|
||||
>{t('ov.date.clear')}</button>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,8 @@
|
||||
*
|
||||
* The chrome keeps the shipped first-level pages' discipline — one scroll
|
||||
* region that opens with the page heading, the title beside the preferences
|
||||
* chip, the range group (and the DeepSeek balance capsule when it serves) —
|
||||
* chip, the scope cluster (the calendar date-range picker beside the range
|
||||
* group, with the DeepSeek balance capsule to their left) —
|
||||
* but the content column spans the pane's full width rather than the shipped
|
||||
* pages' centered 960px frame: the dashboard's grids and legends adapt to the
|
||||
* pane, so the width reads as more dashboard, not wider lines. The page
|
||||
@@ -18,6 +19,13 @@
|
||||
* own width. Hover tips portal to <body> (hoverTip.tsx) because the container
|
||||
* would otherwise capture their fixed positioning.
|
||||
*
|
||||
* The cluster holds ONE scope: a picked calendar range (an ordered day pair,
|
||||
* resolved into a closed window by dayRangeWindow) replaces the rolling
|
||||
* preset, and clicking any preset hands it back — the preset chips and the
|
||||
* picker never both claim it. Everything the presets scope (KPI band,
|
||||
* aggregate cards, skill card, session list) reads that one resolved window,
|
||||
* so a picked range narrows the whole page at once.
|
||||
*
|
||||
* The body's first row is a 1:1 column pair: the KPI metrics band (the
|
||||
* range's six figures — sessions, billed tokens, cost, cache hit, tool
|
||||
* calls, active time — three per row) beside the last-7-days usage chart
|
||||
@@ -45,18 +53,20 @@ import { estimateSessionCost, formatCost, type CostCurrency, type ModelBook } fr
|
||||
import { fmt } from '../format'
|
||||
import { useModelPrices } from '../modelPrices'
|
||||
import {
|
||||
aggregateDays, filterRows, groupCountsOf, inGroup, kpisOf,
|
||||
pageOf, refreshSessions, requestActivityBackfill, rowsOfSnapshot,
|
||||
aggregateDays, dayRangeWindow, filterRows, groupCountsOf, inGroup, kpisOf,
|
||||
pageOf, rangeWindowOf, refreshSessions, requestActivityBackfill, rowsOfSnapshot,
|
||||
rowLoadedSkill, sessionGroupsOf, sessionsSnapshotOf, skillLoadsOf, sortRows,
|
||||
UNGROUPED_KEY, workspacesSnapshotOf,
|
||||
type OverviewRange, type OverviewRow, type OverviewSort, type SkillSort,
|
||||
type DayRange, type OverviewRange, type OverviewRow, type OverviewSort, type SkillSort,
|
||||
} from '../overview'
|
||||
import { openSessionVia, type ClientCtx } from '../services'
|
||||
import { readSkillCatalog } from '../skills'
|
||||
import { openPluginSettings } from '../settingsJump'
|
||||
import type { SkillInfo } from '../../shared/types'
|
||||
import { dayKeyOf } from '../../shared/days'
|
||||
import type { ViewKit } from '../viewkit'
|
||||
import { makeBalanceCapsule } from './balanceCapsule'
|
||||
import { makeDateRange } from './dateRange'
|
||||
import { makeErrorBoundary } from './errorBoundary'
|
||||
import { makeHeatmap, todayKey, type HeatMetric } from './heatmap'
|
||||
import { makeDonut } from './donut'
|
||||
@@ -85,6 +95,7 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie
|
||||
const Heatmap = makeHeatmap(kit)
|
||||
const OverviewCard = makeOverviewCard(kit)
|
||||
const BalanceCapsule = makeBalanceCapsule(ctx, kit)
|
||||
const DateRange = makeDateRange(kit)
|
||||
// The first row's aggregate pair: the range's sessions folded into the two
|
||||
// donut cards the per-session Context tab opens with (Timing Stats reused
|
||||
// verbatim over the summed totals).
|
||||
@@ -95,11 +106,15 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie
|
||||
const OverviewSkills = makeOverviewSkills(kit)
|
||||
const ErrorBoundary = makeErrorBoundary(t)
|
||||
|
||||
/** The display currency follows the active locale (zh → CNY), read per render — the slot outlet re-renders on a locale switch. */
|
||||
function activeCurrency(): CostCurrency {
|
||||
/** The active locale tag, read per render — the slot outlet re-renders on a locale switch. */
|
||||
function activeLocale(): string {
|
||||
const locale = ctx.locale
|
||||
const active = typeof locale.getLocale === 'function' ? locale.getLocale().active : 'en'
|
||||
return active === 'zh' ? 'cny' : 'usd'
|
||||
return typeof locale.getLocale === 'function' ? locale.getLocale().active : 'en'
|
||||
}
|
||||
|
||||
/** The display currency follows the active locale (zh → CNY). */
|
||||
function activeCurrency(): CostCurrency {
|
||||
return activeLocale() === 'zh' ? 'cny' : 'usd'
|
||||
}
|
||||
|
||||
function OverviewBody(props: OverviewPanelProps): ReactElement | null {
|
||||
@@ -107,7 +122,9 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie
|
||||
// The hook-level standard-kit reads (unconditional; guarded inside).
|
||||
const snapshot = sessionsSnapshotOf(props)
|
||||
const wsSnapshot = workspacesSnapshotOf(props)
|
||||
const [range, setRange] = useState<OverviewRange>('30d')
|
||||
const [preset, setPreset] = useState<OverviewRange>('30d')
|
||||
// The calendar picker's own range; while set it replaces the preset scope.
|
||||
const [custom, setCustom] = useState<DayRange | null>(null)
|
||||
const [day, setDay] = useState<string | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const [group, setGroup] = useState<string | null>(null)
|
||||
@@ -146,17 +163,27 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie
|
||||
}, [])
|
||||
|
||||
// Any filter change re-anchors the pager at the first page.
|
||||
useEffect(() => { setPage(0) }, [range, day, query, group, skill, sort])
|
||||
useEffect(() => { setPage(0) }, [preset, custom, day, query, group, skill, sort])
|
||||
|
||||
const currency = activeCurrency()
|
||||
const now = Date.now()
|
||||
const allRows = rows ?? []
|
||||
// The range scopes the KPI band, the composition donut, and the grid;
|
||||
// The scope window every figure below reads: the calendar's own picked
|
||||
// range while one is set (a malformed key keeps the preset, never an
|
||||
// unprovable window), else the preset's rolling floor.
|
||||
const scope = (custom !== null ? dayRangeWindow(custom) : null) ?? rangeWindowOf(preset, now)
|
||||
// The head's date span, read off that same window so the picker and the
|
||||
// presets can never disagree: a picked range's own two days, a preset's
|
||||
// floor through today (its end is open), nothing at all under "all".
|
||||
const spanFrom = scope.start === null ? null : dayKeyOf(scope.start)
|
||||
const spanTo = scope.start === null ? null : dayKeyOf(scope.end ?? now)
|
||||
const span = spanFrom !== null && spanTo !== null ? { from: spanFrom, to: spanTo } : null
|
||||
// The scope scopes the KPI band, the composition donut, and the grid;
|
||||
// the heatmap keeps its own fixed window over the whole list.
|
||||
const ranged = filterRows(allRows, { range, day: null, query: '' }, now)
|
||||
const ranged = filterRows(allRows, { scope, day: null, query: '' })
|
||||
// The group chips count the day/query-scoped rows BEFORE the group filter
|
||||
// applies, so selecting a chip never collapses the row itself.
|
||||
const scoped = filterRows(ranged, { range: 'all', day, query }, now)
|
||||
const scoped = filterRows(ranged, { scope, day, query })
|
||||
const counts = groupCountsOf(scoped, wsSnapshot)
|
||||
// A selection whose group fell out of scope keeps a phantom chip (count
|
||||
// 0) so the active filter stays visible and one click out of it.
|
||||
@@ -166,8 +193,8 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie
|
||||
const visible = sortRows(
|
||||
(group === null ? scoped : scoped.filter(row => inGroup(row, group, groups)))
|
||||
// The skill card's row pin narrows the list to the skill's loaders
|
||||
// within the card's own scope (the range floor plus the pinned day).
|
||||
.filter(row => skill === null || rowLoadedSkill(row, skill, { range, day }, now)),
|
||||
// within the card's own scope (the scope window plus the pinned day).
|
||||
.filter(row => skill === null || rowLoadedSkill(row, skill, { scope, day })),
|
||||
sort,
|
||||
)
|
||||
const paged = pageOf(visible, page)
|
||||
@@ -198,15 +225,28 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie
|
||||
{/* The DeepSeek platform balance (client/balance.ts): renders nothing
|
||||
until a live figure lands, so the heading row never reflows for it. */}
|
||||
<BalanceCapsule />
|
||||
<div className="lc-gran lc-ov-range" role="group" aria-label={t('ov.range.label')}>
|
||||
{RANGES.map(r => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
className={'lc-gran-btn' + (range === r ? ' lc-gran-on' : '')}
|
||||
onClick={() => { setRange(r) }}
|
||||
>{t('ov.range.' + r)}</button>
|
||||
))}
|
||||
{/* One wrapper so the head's right cluster pushes once: the
|
||||
calendar picker, then the preset group. A bare `.lc-gran`
|
||||
sibling would eat a second slice of the free space and the
|
||||
two would drift apart. */}
|
||||
<div className="lc-ov-headctl">
|
||||
<DateRange
|
||||
value={span}
|
||||
picked={custom !== null}
|
||||
onChange={(next) => { setCustom(next) }}
|
||||
today={todayKey()}
|
||||
locale={activeLocale()}
|
||||
/>
|
||||
<div className="lc-gran lc-ov-range" role="group" aria-label={t('ov.range.label')}>
|
||||
{RANGES.map(r => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
className={'lc-gran-btn' + (custom === null && preset === r ? ' lc-gran-on' : '')}
|
||||
onClick={() => { setPreset(r); setCustom(null) }}
|
||||
>{t('ov.range.' + r)}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -291,7 +331,7 @@ export function makeOverviewPanel(ctx: ClientCtx, kit: ViewKit): (props: Overvie
|
||||
or group chips (those are list-local). A row pin narrows
|
||||
the session list to the skill's loaders. */}
|
||||
<OverviewSkills
|
||||
stats={skillLoadsOf(ranged, { range, day, sort: skillSort }, now)}
|
||||
stats={skillLoadsOf(ranged, { scope, day, sort: skillSort })}
|
||||
day={day}
|
||||
selected={skill}
|
||||
onSelect={setSkill}
|
||||
|
||||
@@ -354,6 +354,25 @@ export const DICT_ZH: Record<string, string> = {
|
||||
'ov.range.7d': '近 7 天',
|
||||
'ov.range.30d': '近 30 天',
|
||||
'ov.range.all': '全部',
|
||||
// The custom date range (components/dateRange.tsx) — the head's fifth
|
||||
// choice, beside the presets above.
|
||||
'ov.date.label': '日期范围',
|
||||
'ov.date.open': '选择自定义日期范围',
|
||||
'ov.date.from': '开始日期',
|
||||
'ov.date.to': '结束日期',
|
||||
'ov.date.pick': '选择日期',
|
||||
'ov.date.prev': '上个月',
|
||||
'ov.date.next': '下个月',
|
||||
'ov.date.hint': '点击选择开始日期',
|
||||
'ov.date.hintEnd': '再点一下选择结束日期',
|
||||
'ov.date.clear': '清除',
|
||||
'ov.date.wd.0': '日',
|
||||
'ov.date.wd.1': '一',
|
||||
'ov.date.wd.2': '二',
|
||||
'ov.date.wd.3': '三',
|
||||
'ov.date.wd.4': '四',
|
||||
'ov.date.wd.5': '五',
|
||||
'ov.date.wd.6': '六',
|
||||
'ov.kpi.sessions': '活跃会话',
|
||||
'ov.kpi.ofTotal': '共 {n} 个',
|
||||
'ov.kpi.tokens': '累计消耗',
|
||||
@@ -793,6 +812,25 @@ export const DICT_EN: Record<string, string> = {
|
||||
'ov.range.7d': 'Last 7 days',
|
||||
'ov.range.30d': 'Last 30 days',
|
||||
'ov.range.all': 'All',
|
||||
// The custom date range (components/dateRange.tsx) — the head's fifth
|
||||
// choice, beside the presets above.
|
||||
'ov.date.label': 'Date range',
|
||||
'ov.date.open': 'Pick a custom date range',
|
||||
'ov.date.from': 'Start date',
|
||||
'ov.date.to': 'End date',
|
||||
'ov.date.pick': 'Pick a date',
|
||||
'ov.date.prev': 'Previous month',
|
||||
'ov.date.next': 'Next month',
|
||||
'ov.date.hint': 'Click a start date',
|
||||
'ov.date.hintEnd': 'Now click the end date',
|
||||
'ov.date.clear': 'Clear',
|
||||
'ov.date.wd.0': 'S',
|
||||
'ov.date.wd.1': 'M',
|
||||
'ov.date.wd.2': 'T',
|
||||
'ov.date.wd.3': 'W',
|
||||
'ov.date.wd.4': 'T',
|
||||
'ov.date.wd.5': 'F',
|
||||
'ov.date.wd.6': 'S',
|
||||
'ov.kpi.sessions': 'Active Sessions',
|
||||
'ov.kpi.ofTotal': 'of {n} total',
|
||||
'ov.kpi.tokens': 'Tokens Used',
|
||||
|
||||
@@ -61,6 +61,7 @@ import './styles/detailSections.css'
|
||||
import './styles/attachments.css'
|
||||
import './styles/agentGraph.css'
|
||||
import './styles/overview.css'
|
||||
import './styles/dateRange.css'
|
||||
|
||||
const NS = 'dsh-context'
|
||||
|
||||
|
||||
+84
-30
@@ -21,7 +21,7 @@
|
||||
*/
|
||||
|
||||
import { workspaceTitleOf } from '@deepseek-ai/dsh-util-workspace-path'
|
||||
import { dayKeyOf } from '../shared/days'
|
||||
import { dayKeyOf, endOfDayKey, startOfDayKey } from '../shared/days'
|
||||
import { billedParts } from './categories'
|
||||
import { cacheHitPercent } from './format'
|
||||
import { estimateSessionCost, mergeCostUsage } from './cost'
|
||||
@@ -328,23 +328,77 @@ function mergeSkillTallies(a: ActivityDay['skills'], b: NonNullable<ActivityDay[
|
||||
|
||||
// ---- range / filter / sort -------------------------------------------------
|
||||
|
||||
/** The range group's preset keys — each a rolling window anchored on `now`. */
|
||||
export type OverviewRange = 'today' | '7d' | '30d' | 'all'
|
||||
|
||||
/**
|
||||
* The range window's start instant (epoch ms), or null for "all". "Today"
|
||||
* opens on the local calendar day's midnight — date-field arithmetic (the
|
||||
* harness's own `setHours(0, 0, 0, 0)` idiom) keeps a DST-short or long day
|
||||
* exact, where an epoch-ms subtract would drift into the neighbouring day.
|
||||
* A resolved scope window: inclusive epoch-ms bounds, each null when that end
|
||||
* is open. The presets only ever pin the start ("last 7 days" is a floor, not
|
||||
* a closed interval); the calendar picker's own range closes both ends.
|
||||
*/
|
||||
export function rangeStartOf(range: OverviewRange, now: number): number | null {
|
||||
export interface RangeWindow {
|
||||
start: number | null
|
||||
end: number | null
|
||||
}
|
||||
|
||||
/** The open window — the "all" preset. Frozen: it is handed straight to every scope reader. */
|
||||
export const OPEN_WINDOW: RangeWindow = Object.freeze({ start: null, end: null })
|
||||
|
||||
/**
|
||||
* A calendar-picked range: two inclusive local day keys, ALWAYS ordered
|
||||
* (`from <= to`). The picker sorts the pair on its second click (a backwards
|
||||
* drag just swaps the ends), and {@link dayRangeWindow} sorts it again at the
|
||||
* seam, so no reader downstream — window, ledger predicate, or the chip that
|
||||
* names it — can ever see a reversed range, and an inverted pair can never
|
||||
* filter the page down to nothing.
|
||||
*/
|
||||
export interface DayRange {
|
||||
from: string
|
||||
to: string
|
||||
}
|
||||
|
||||
/** Two picked ends in calendar order — a backwards pair swaps rather than inverting. */
|
||||
export function orderedDayRange(a: string, b: string): DayRange {
|
||||
return a <= b ? { from: a, to: b } : { from: b, to: a }
|
||||
}
|
||||
|
||||
/**
|
||||
* The preset's window (epoch ms). "Today" opens on the local calendar day's
|
||||
* midnight — date-field arithmetic (the harness's own `setHours(0, 0, 0, 0)`
|
||||
* idiom) keeps a DST-short or long day exact, where an epoch-ms subtract
|
||||
* would drift into the neighbouring day. "All" is the open window; every
|
||||
* other preset is a floor with an open end, exactly as before the calendar
|
||||
* picker arrived.
|
||||
*/
|
||||
export function rangeWindowOf(range: OverviewRange, now: number): RangeWindow {
|
||||
if (range === 'all') return OPEN_WINDOW
|
||||
const start = rangeStartOf(range, now)
|
||||
return { start, end: null }
|
||||
}
|
||||
|
||||
/** The preset's start instant, the shared floor of {@link rangeWindowOf}. */
|
||||
function rangeStartOf(range: Exclude<OverviewRange, 'all'>, now: number): number {
|
||||
if (range === 'today') {
|
||||
const midnight = new Date(now)
|
||||
midnight.setHours(0, 0, 0, 0)
|
||||
return midnight.getTime()
|
||||
}
|
||||
if (range === '7d') return now - 7 * 86_400_000
|
||||
if (range === '30d') return now - 30 * 86_400_000
|
||||
return null
|
||||
return now - (range === '7d' ? 7 : 30) * 86_400_000
|
||||
}
|
||||
|
||||
/**
|
||||
* A picked range's window: the start day's midnight through the end day's
|
||||
* last millisecond, so both picked days count whole. The pair is ordered on
|
||||
* the way in, which is what keeps an end picked before its start from reading
|
||||
* as an empty window. Null when a key is malformed — the caller then keeps the
|
||||
* preset scope rather than filtering on a window it cannot prove.
|
||||
*/
|
||||
export function dayRangeWindow(range: DayRange): RangeWindow | null {
|
||||
const ordered = orderedDayRange(range.from, range.to)
|
||||
const start = startOfDayKey(ordered.from)
|
||||
const end = endOfDayKey(ordered.to)
|
||||
if (start === null || end === null) return null
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
export type OverviewSort = 'recent' | 'tokens' | 'context'
|
||||
@@ -382,20 +436,20 @@ export function createdDayOf(activity: ContextActivity | null): string | undefin
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel's row pipeline: range (by last-activity), then the heatmap's
|
||||
* picked day (sessions contributing to that day's merged ledger), then the
|
||||
* search box (title, directory, or last-message substring). Each stage keeps
|
||||
* the rows it cannot prove out of the result — never an exception.
|
||||
* The panel's row pipeline: the scope window (by last-activity), then the
|
||||
* heatmap's picked day (sessions contributing to that day's merged ledger),
|
||||
* then the search box (title, directory, or last-message substring). Each
|
||||
* stage keeps the rows it cannot prove out of the result — never an
|
||||
* exception.
|
||||
*/
|
||||
export function filterRows(
|
||||
rows: readonly OverviewRow[],
|
||||
opts: { range: OverviewRange; day: string | null; query: string },
|
||||
now: number,
|
||||
opts: { scope: RangeWindow; day: string | null; query: string },
|
||||
): OverviewRow[] {
|
||||
const start = rangeStartOf(opts.range, now)
|
||||
const query = opts.query.trim().toLowerCase()
|
||||
return rows.filter((row) => {
|
||||
if (start !== null && row.updatedAt < start) return false
|
||||
if (opts.scope.start !== null && row.updatedAt < opts.scope.start) return false
|
||||
if (opts.scope.end !== null && row.updatedAt > opts.scope.end) return false
|
||||
if (opts.day !== null) {
|
||||
const entry = row.activity?.days[opts.day]
|
||||
if (entry === undefined || (entry.tokens <= 0 && entry.requests <= 0)) return false
|
||||
@@ -451,16 +505,18 @@ export type SkillSort = 'loads' | 'recent'
|
||||
|
||||
/**
|
||||
* The scope's ledger-day predicate: the heatmap's pinned day admits exactly
|
||||
* that day, else the range's day floor admits its own and later days (the
|
||||
* ledger is day-grained: "today" starts at this very day's key and the 7d/30d
|
||||
* windows admit their start day whole, the same resolution the heatmap reads
|
||||
* at); 'all' admits every day.
|
||||
* that day, else the scope window admits its own days whole (the ledger is
|
||||
* day-grained: "today" starts at this very day's key and the 7d/30d windows
|
||||
* admit their start day whole, the same resolution the heatmap reads at; a
|
||||
* picked range closes the top at its end day's key); an open window admits
|
||||
* every day.
|
||||
*/
|
||||
function skillDayPredicate(opts: { range: OverviewRange; day: string | null }, now: number): (key: string) => boolean {
|
||||
function skillDayPredicate(opts: { scope: RangeWindow; day: string | null }): (key: string) => boolean {
|
||||
if (opts.day !== null) return key => key === opts.day
|
||||
const start = rangeStartOf(opts.range, now)
|
||||
const { start, end } = opts.scope
|
||||
const floor = start === null ? null : dayKeyOf(start)
|
||||
return floor === null ? () => true : key => key >= floor
|
||||
const ceiling = end === null ? null : dayKeyOf(end)
|
||||
return key => (floor === null || key >= floor) && (ceiling === null || key <= ceiling)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -474,10 +530,9 @@ function skillDayPredicate(opts: { range: OverviewRange; day: string | null }, n
|
||||
*/
|
||||
export function skillLoadsOf(
|
||||
rows: readonly OverviewRow[],
|
||||
opts: { range: OverviewRange; day: string | null; sort?: SkillSort },
|
||||
now: number,
|
||||
opts: { scope: RangeWindow; day: string | null; sort?: SkillSort },
|
||||
): SkillLoadStat[] {
|
||||
const admit = skillDayPredicate(opts, now)
|
||||
const admit = skillDayPredicate(opts)
|
||||
const stats = new Map<string, SkillLoadStat>()
|
||||
for (const row of rows) {
|
||||
const days = row.activity?.days
|
||||
@@ -518,10 +573,9 @@ export function skillLoadsOf(
|
||||
export function rowLoadedSkill(
|
||||
row: OverviewRow,
|
||||
name: string,
|
||||
opts: { range: OverviewRange; day: string | null },
|
||||
now: number,
|
||||
opts: { scope: RangeWindow; day: string | null },
|
||||
): boolean {
|
||||
const admit = skillDayPredicate(opts, now)
|
||||
const admit = skillDayPredicate(opts)
|
||||
const days = row.activity?.days
|
||||
if (days === undefined) return false
|
||||
for (const key of Object.keys(days)) {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* The Insights page's date-range picker (components/dateRange.tsx): the
|
||||
* trigger chip in the page head, and the portaled calendar panel it opens.
|
||||
*
|
||||
* The panel rides the shared `.lc-tip` face and overrides its positioning
|
||||
* seat — the page is the `lc-ov` query container, so an in-tree popover would
|
||||
* be captured by the container's containing block AND clipped by the page's
|
||||
* one scroller; hence the portal to <body> and the fixed seat here (the page's
|
||||
* hover tips' own escape, hoverTip.tsx). Everything below the seat is
|
||||
* ordinary component chrome.
|
||||
*/
|
||||
|
||||
/* ---- the trigger: the range itself, as a quiet chip cut like the head's
|
||||
preferences/balance pills ---- */
|
||||
.lc-dp { position: relative; flex: none; }
|
||||
.lc-dp-trigger { display: inline-flex; align-items: center; gap: 6px; box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-2); border-radius: 999px; padding: 3px 12px; font: inherit; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); white-space: nowrap; cursor: pointer; transition: border-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out), color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
|
||||
.lc-dp-trigger:hover { border-color: var(--dsw-alias-label-secondary); color: var(--dsw-alias-label-primary); }
|
||||
/* A live range takes the chip's own voice: the brand-bordered pill the page
|
||||
uses for the balance capsule, so the head reads "this scope is custom"
|
||||
without adding another control. */
|
||||
.lc-dp-trigger-on { border-color: color-mix(in srgb, var(--dsw-alias-brand-primary) 45%, transparent); color: var(--dsw-alias-brand-primary); }
|
||||
/* The two ends, always on show: the start reads left of the end, so the scope
|
||||
is legible straight off the head without opening the panel. Neither half
|
||||
shrinks — the chip is nowrap, so a date never ellipsizes into nonsense. */
|
||||
.lc-dp-half { flex: none; text-align: center; font-variant-numeric: tabular-nums; }
|
||||
.lc-dp-half-empty { color: var(--dsw-alias-label-tertiary); font-variant-numeric: normal; }
|
||||
.lc-dp-sep { flex: none; color: var(--dsw-alias-label-tertiary); }
|
||||
|
||||
/* ---- the panel: `.lc-tip`'s face, this sheet's fixed seat ---- */
|
||||
.lc-dp-panel { position: fixed; z-index: 300; left: 0; top: 0; width: max-content; max-width: calc(100vw - 16px); padding: 8px; pointer-events: auto; opacity: 1; }
|
||||
|
||||
/* The month's own nav — the panel's only chrome above the grid. */
|
||||
.lc-dp-nav { display: flex; align-items: center; gap: 4px; margin-bottom: 4px; }
|
||||
.lc-dp-month { flex: 1 1 auto; text-align: center; font-size: 12px; line-height: 20px; font-weight: 500; white-space: nowrap; }
|
||||
.lc-dp-navbtn { flex: none; width: 22px; height: 22px; border: 0; border-radius: 5px; background: transparent; color: var(--dsw-alias-label-secondary); font: inherit; font-size: 14px; line-height: 1; cursor: pointer; }
|
||||
.lc-dp-navbtn:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
|
||||
.lc-dp-navbtn:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||
.lc-dp-navbtn:disabled:hover { background: transparent; color: var(--dsw-alias-label-secondary); }
|
||||
|
||||
/* ---- the month grid ----
|
||||
Fixed 28px cells: a calendar that sized to its content would change width
|
||||
month to month (a five-week February against a six-week one), and the panel
|
||||
would jump under the cursor. The grid also sets the panel's own width, so
|
||||
the month header and the foot hint lay out against a stable measure. */
|
||||
.lc-dp-wds, .lc-dp-week { display: flex; }
|
||||
.lc-dp-wd { flex: 0 0 28px; text-align: center; font-size: 10px; line-height: 18px; color: var(--dsw-alias-label-tertiary); }
|
||||
.lc-dp-grid { display: flex; flex-direction: column; outline: none; }
|
||||
.lc-dp-grid:focus-visible { outline: 1px solid var(--dsw-alias-brand-primary); border-radius: 6px; }
|
||||
.lc-dp-cell { flex: 0 0 28px; height: 26px; display: flex; align-items: center; justify-content: center; box-sizing: border-box; border-radius: 5px; font-size: 11px; line-height: 1; color: var(--dsw-alias-label-primary); font-variant-numeric: tabular-nums; user-select: none; cursor: pointer; }
|
||||
.lc-dp-cell:hover { background: var(--dsw-alias-interactive-bg-hover); }
|
||||
/* The picked band: a continuous run between the two ends, squared off at the
|
||||
run's ends so consecutive days read as one interval. */
|
||||
.lc-dp-in { background: color-mix(in srgb, var(--dsw-alias-brand-primary) 16%, transparent); border-radius: 0; }
|
||||
.lc-dp-in:hover { background: color-mix(in srgb, var(--dsw-alias-brand-primary) 24%, transparent); }
|
||||
/* The ends themselves ride the page's primary fill, like the range group's own
|
||||
lit chip — the band rules come first so this wins on both counts. */
|
||||
.lc-dp-edge { background: var(--dsw-alias-button-primary-fill); color: var(--dsw-alias-label-primary-foreground); border-radius: 5px; font-weight: 500; }
|
||||
.lc-dp-edge:hover { background: var(--dsw-alias-button-primary-fill); }
|
||||
/* The keyboard cursor: a ring that never shifts the cell's layout. */
|
||||
.lc-dp-cursor { box-shadow: inset 0 0 0 1px var(--dsw-alias-label-secondary); }
|
||||
.lc-dp-today { font-weight: 600; }
|
||||
/* A day with no session can be had: the future stays selectable-looking but
|
||||
inert, so a wasted pick never lands as an empty scope. */
|
||||
.lc-dp-future { color: var(--dsw-alias-label-tertiary); opacity: 0.4; cursor: not-allowed; }
|
||||
.lc-dp-future:hover { background: transparent; }
|
||||
.lc-dp-blank { cursor: default; }
|
||||
.lc-dp-blank:hover { background: transparent; }
|
||||
|
||||
/* ---- the foot: which end comes next, and the way back to the presets ---- */
|
||||
.lc-dp-foot { display: flex; align-items: center; gap: 8px; margin-top: 6px; padding-top: 6px; border-top: 1px solid var(--dsw-alias-border-l2); }
|
||||
.lc-dp-hint { flex: 1 1 auto; min-width: 0; font-size: 11px; line-height: 18px; color: var(--dsw-alias-label-tertiary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.lc-dp-clear { flex: none; border: 0; background: transparent; color: var(--dsw-alias-label-secondary); font: inherit; font-size: 11px; line-height: 18px; cursor: pointer; }
|
||||
.lc-dp-clear:hover { color: var(--dsw-alias-label-primary); text-decoration: underline; }
|
||||
@@ -3,8 +3,9 @@
|
||||
* panel-list entry opens it in the shell's center column), a sibling of the
|
||||
* shipped Plugins and Automation tasks pages. The chrome mirrors those
|
||||
* pages: one scroll region whose content column spans the pane's own
|
||||
* width and opens with the page heading — the title beside the
|
||||
* range group and the balance capsule — then the first row (a 1:1 pair: the
|
||||
* width and opens with the page heading — the title beside the scope cluster
|
||||
* (the calendar picker and the range group, with the balance capsule left of
|
||||
* it) — then the first row (a 1:1 pair: the
|
||||
* KPI metrics band, the range's six figures three per row, beside the
|
||||
* last-7-days usage chart's token/cost bar columns) over the aggregate
|
||||
* stats row (Token Stats | Timing Stats, the range's sessions folded into
|
||||
@@ -40,13 +41,18 @@
|
||||
/* The first-level page title on the shipped pages' own step (20px/28px/500);
|
||||
the theme carries no size token for it, so every first-level page states
|
||||
the same literal. flex: 0 1 auto (it only shrinks) so the preferences
|
||||
chip rides the title's right edge — the range group's own auto margin
|
||||
(`.lc-gran`) keeps the head's right side pinned. */
|
||||
chip rides the title's right edge — the scope cluster's own auto margin
|
||||
(`.lc-ov-headctl`) keeps the head's right side pinned. */
|
||||
.lc-ov-pagetitle { flex: 0 1 auto; min-width: 0; margin: 0; font-size: 20px; line-height: 28px; font-weight: 500; }
|
||||
/* The preferences entry: a quiet chip beside the title, cut like the balance
|
||||
pill below it (layer-2 fill, l2 border, secondary label), with the long
|
||||
"open plugin settings" hint on the hover title. */
|
||||
.lc-ov-settings { flex: none; display: inline-flex; align-items: center; gap: 6px; box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-2); border-radius: 999px; padding: 3px 12px; font: inherit; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); white-space: nowrap; cursor: pointer; transition: border-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out), color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
|
||||
/* The head's right cluster: the calendar picker beside the preset range group
|
||||
(the balance capsule sits left of it, outside). One wrapper so the free space
|
||||
is pushed once — two bare auto-margin siblings would split it and the cluster
|
||||
would drift apart (the card-title wrapper idiom, base.css). */
|
||||
.lc-ov-headctl { margin-left: auto; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.lc-ov-settings:hover { border-color: var(--dsw-alias-label-secondary); color: var(--dsw-alias-label-primary); }
|
||||
/* The DeepSeek balance pill (balanceCapsule.tsx): a quiet chip between the
|
||||
title and the range group, one link to the platform's usage console —
|
||||
|
||||
+100
-13
@@ -5,11 +5,18 @@
|
||||
* keys and the browser's cells speak the same calendar. Lexicographic order
|
||||
* on the key IS chronological order — the fold's retention cap and the
|
||||
* heatmap's week math both ride that.
|
||||
*
|
||||
* The module also owns the pure calendar arithmetic the client grids need off
|
||||
* those keys: a day's epoch bounds (the picked range's window), a day's
|
||||
* weekday, month stepping, and one month laid out as Sunday-first weeks.
|
||||
*/
|
||||
|
||||
/** The strict key shape (round-trip validated on construction too). */
|
||||
export const DAY_KEY_RE = /^\d{4}-\d{2}-\d{2}$/
|
||||
|
||||
/** The strict month-key shape (`YYYY-MM`), the stepping and header unit. */
|
||||
export const MONTH_KEY_RE = /^\d{4}-\d{2}$/
|
||||
|
||||
/**
|
||||
* The local calendar-day key of an epoch-ms instant, or null when the time
|
||||
* is not a finite number or falls outside the representable date range —
|
||||
@@ -23,6 +30,21 @@ export function dayKeyOf(time: number): string | null {
|
||||
return `${String(y).padStart(4, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* A day key's local Date (midnight), or null when the key is malformed or its
|
||||
* fields would roll over (2026-02-30) — the one validation the key→calendar
|
||||
* helpers below share, so none of them can invent a date.
|
||||
*/
|
||||
function dateOfDayKey(key: string): Date | null {
|
||||
if (!DAY_KEY_RE.test(key)) return null
|
||||
const y = Number(key.slice(0, 4))
|
||||
const m = Number(key.slice(5, 7))
|
||||
const d = Number(key.slice(8, 10))
|
||||
const date = new Date(y, m - 1, d)
|
||||
if (date.getFullYear() !== y || date.getMonth() !== m - 1 || date.getDate() !== d) return null
|
||||
return date
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance a day key by `delta` calendar days (local). Date-field arithmetic
|
||||
* keeps DST-short/long days exact (an epoch-ms add would drift an hour into
|
||||
@@ -31,24 +53,89 @@ export function dayKeyOf(time: number): string | null {
|
||||
* yields null as well.
|
||||
*/
|
||||
export function shiftDayKey(key: string, delta: number): string | null {
|
||||
if (!DAY_KEY_RE.test(key)) return null
|
||||
const y = Number(key.slice(0, 4))
|
||||
const m = Number(key.slice(5, 7))
|
||||
const d = Number(key.slice(8, 10))
|
||||
const date = new Date(y, m - 1, d)
|
||||
if (date.getFullYear() !== y || date.getMonth() !== m - 1 || date.getDate() !== d) return null
|
||||
const date = dateOfDayKey(key)
|
||||
if (date === null) return null
|
||||
date.setDate(date.getDate() + delta)
|
||||
return dayKeyOf(date.getTime())
|
||||
}
|
||||
|
||||
/** A day key's weekday index (0=Sunday..6=Saturday, local) — the calendar month's leading blanks. Null on a malformed key. */
|
||||
export function weekdayOf(key: string): number | null {
|
||||
const date = dateOfDayKey(key)
|
||||
return date === null ? null : date.getDay()
|
||||
}
|
||||
|
||||
/** The Sunday (local) of the week containing `key` — the heatmap's column anchor. */
|
||||
export function sundayOfWeek(key: string): string | null {
|
||||
if (!DAY_KEY_RE.test(key)) return null
|
||||
const y = Number(key.slice(0, 4))
|
||||
const m = Number(key.slice(5, 7))
|
||||
const d = Number(key.slice(8, 10))
|
||||
const date = new Date(y, m - 1, d)
|
||||
if (date.getFullYear() !== y || date.getMonth() !== m - 1 || date.getDate() !== d) return null
|
||||
const weekday = weekdayOf(key)
|
||||
// getDay: 0=Sunday..6=Saturday → days since Sunday.
|
||||
return shiftDayKey(key, -date.getDay())
|
||||
return weekday === null ? null : shiftDayKey(key, -weekday)
|
||||
}
|
||||
|
||||
/** A day key's local midnight (epoch ms) — a picked range's inclusive start. Null on a malformed key. */
|
||||
export function startOfDayKey(key: string): number | null {
|
||||
const date = dateOfDayKey(key)
|
||||
if (date === null) return null
|
||||
date.setHours(0, 0, 0, 0)
|
||||
return date.getTime()
|
||||
}
|
||||
|
||||
/** A day key's last local millisecond (epoch ms) — a picked range's inclusive end, so the whole day is in. Null on a malformed key. */
|
||||
export function endOfDayKey(key: string): number | null {
|
||||
const date = dateOfDayKey(key)
|
||||
if (date === null) return null
|
||||
date.setHours(23, 59, 59, 999)
|
||||
return date.getTime()
|
||||
}
|
||||
|
||||
/**
|
||||
* Shift a month key by `delta` months (local), clamping the day overflow the
|
||||
* way `Date` does (Jan 31 + 1 month lands in March). Null on a malformed key,
|
||||
* a month outside 1-12, or a target outside the representable year range —
|
||||
* the same contract as shiftDayKey.
|
||||
*/
|
||||
export function shiftMonthKey(month: string, delta: number): string | null {
|
||||
if (!MONTH_KEY_RE.test(month)) return null
|
||||
const year = Number(month.slice(0, 4))
|
||||
const index = Number(month.slice(5, 7))
|
||||
if (index < 1 || index > 12) return null
|
||||
// Months since year 0, so a step across the year boundary is plain addition.
|
||||
const total = year * 12 + (index - 1) + delta
|
||||
const y = Math.floor(total / 12)
|
||||
if (y < 1970 || y > 9999) return null
|
||||
return `${String(y).padStart(4, '0')}-${String(total - y * 12 + 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/** A day key's weekday index (0=Sunday..6=Saturday, local) — the calendar month's leading blanks. Null on a malformed key. */
|
||||
|
||||
/**
|
||||
* One month as Sunday-first weeks of day keys, padded with nulls so every
|
||||
* week carries seven cells (the leading blanks are the 1st's weekday). Null
|
||||
* for a malformed key or a day-key math failure — the calendar then degrades
|
||||
* instead of drawing a wrong month.
|
||||
*/
|
||||
export function monthGridOf(month: string): (string | null)[][] | null {
|
||||
const first = `${month}-01`
|
||||
const lead = weekdayOf(first)
|
||||
if (lead === null) return null
|
||||
const weeks: (string | null)[][] = []
|
||||
// Built by pushing onto an empty week, never by sizing an array literal: the
|
||||
// lead count is the 1st's weekday, and this keeps the element type honest.
|
||||
let week: (string | null)[] = []
|
||||
for (let i = 0; i < lead; i++) week.push(null)
|
||||
for (let day = 0; ; day++) {
|
||||
const key = shiftDayKey(first, day)
|
||||
// Past the month's last day (or out of the representable range): pad the
|
||||
// open week and stop — the loop always returns.
|
||||
if (key === null || key.slice(0, 7) !== month) {
|
||||
while (week.length < 7) week.push(null)
|
||||
weeks.push(week)
|
||||
return weeks
|
||||
}
|
||||
week.push(key)
|
||||
if (week.length === 7) {
|
||||
weeks.push(week)
|
||||
week = []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
// The Insights page's date-range picker (src/client/components/dateRange.tsx)
|
||||
// — the two-click range in either order, the highlighted band and edges, the
|
||||
// portaled panel's own seat and every way out of it, and the month nav plus
|
||||
// the grid's keyboard cursor.
|
||||
|
||||
import { act, createElement as h } from 'react'
|
||||
import assert from 'node:assert/strict'
|
||||
import { afterEach, describe, test } from 'vitest'
|
||||
import {
|
||||
makeDateRange,
|
||||
monthLabelOf,
|
||||
type DateRangeProps,
|
||||
} from '../../../src/client/components/dateRange'
|
||||
import type { DayRange } from '../../../src/client/overview'
|
||||
import { click, hover, keydown, makeKit, mount, query, queryAll, text, type Mounted } from '../helpers/kit'
|
||||
|
||||
/** The specs' calendar: a Friday, so the month opens mid-week and ends mid-week. */
|
||||
const TODAY = '2026-10-09'
|
||||
|
||||
const DateRange = makeDateRange(makeKit())
|
||||
|
||||
/** Every tree this spec mounted, torn down after each test (the panel portals to <body>). */
|
||||
const mounted: Mounted[] = []
|
||||
|
||||
/** The portaled panel, wherever it landed. */
|
||||
function panel(): HTMLElement | null {
|
||||
return document.querySelector<HTMLElement>('.lc-dp-panel')
|
||||
}
|
||||
|
||||
/** The trigger chip, in the mounted tree. */
|
||||
function trigger(m: Mounted): HTMLElement {
|
||||
return query(m.container, '.lc-dp-trigger')
|
||||
}
|
||||
|
||||
/** One calendar day cell, by its day key. */
|
||||
function cell(m: Mounted, key: string): HTMLElement {
|
||||
const found = queryAll(document.body, '.lc-dp-cell').find(el => el.getAttribute('aria-label') === key)
|
||||
if (found === undefined) throw new Error(`no day cell: ${key}`)
|
||||
return found
|
||||
}
|
||||
|
||||
/** Dispatch a raw event on a window listener, act-wrapped like the kit's own relays. */
|
||||
async function fireWindow(ev: Event): Promise<void> {
|
||||
await act(async () => { window.dispatchEvent(ev) })
|
||||
}
|
||||
|
||||
interface Harness {
|
||||
m: Mounted
|
||||
/** Every value the picker handed up, in order. */
|
||||
changes: (DayRange | null)[]
|
||||
/** Re-render with new props, as a controlled parent would. */
|
||||
set: (next: Partial<DateRangeProps>) => Promise<void>
|
||||
}
|
||||
|
||||
async function open(over: Partial<DateRangeProps> = {}): Promise<Harness> {
|
||||
const changes: (DayRange | null)[] = []
|
||||
let props: DateRangeProps = {
|
||||
value: null,
|
||||
picked: false,
|
||||
onChange: (range) => { changes.push(range) },
|
||||
today: TODAY,
|
||||
locale: 'en',
|
||||
...over,
|
||||
}
|
||||
const m = await mount(h(DateRange, props))
|
||||
mounted.push(m)
|
||||
return {
|
||||
m,
|
||||
changes,
|
||||
async set(next) {
|
||||
props = { ...props, ...next }
|
||||
await m.update(h(DateRange, props))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Press the trigger, opening (or closing) the panel. */
|
||||
async function toggle(m: Mounted): Promise<void> {
|
||||
await click(trigger(m))
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
while (mounted.length > 0) await mounted.pop()?.unmount()
|
||||
})
|
||||
|
||||
describe('monthLabelOf', () => {
|
||||
test('the panel header names the month in the active locale', () => {
|
||||
assert.match(monthLabelOf('2026-10', 'en'), /2026/)
|
||||
assert.match(monthLabelOf('2026-10', 'zh'), /2026/)
|
||||
assert.equal(monthLabelOf('not-a-month', 'en'), 'not-a-month', 'a key that will not convert falls back to itself')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the trigger', () => {
|
||||
test('carries the scope’s two ends, or the two placeholders', async () => {
|
||||
const { m, set } = await open()
|
||||
const halves = () => queryAll(m.container, '.lc-dp-half').map(el => el.textContent)
|
||||
assert.deepEqual(halves(), ['Start date', 'End date'], 'an unbounded scope has no span to print')
|
||||
assert.match(query(m.container, '.lc-dp-trigger').getAttribute('aria-label') ?? '', /custom date range/)
|
||||
assert.equal(trigger(m).getAttribute('aria-expanded'), 'false')
|
||||
await toggle(m)
|
||||
assert.equal(trigger(m).getAttribute('aria-expanded'), 'true')
|
||||
assert.ok(panel() !== null, 'the panel is portaled to the body')
|
||||
|
||||
// A preset's window rides the chip too — linked, but never in the brand
|
||||
// voice and with no way back, because a preset is not a pick.
|
||||
await set({ value: { from: '2026-09-10', to: '2026-10-09' } })
|
||||
assert.deepEqual(halves(), ['2026-09-10', '2026-10-09'])
|
||||
assert.ok(!trigger(m).className.includes('lc-dp-trigger-on'))
|
||||
assert.equal(queryAll(panel()!, '.lc-dp-clear').length, 0, 'nothing to clear while a preset is in charge')
|
||||
|
||||
await set({ picked: true })
|
||||
assert.match(trigger(m).className, /lc-dp-trigger-on/)
|
||||
assert.equal(queryAll(panel()!, '.lc-dp-clear').length, 1, 'a pick grows the way back')
|
||||
})
|
||||
|
||||
test('a press while open closes it again', async () => {
|
||||
const { m } = await open()
|
||||
await toggle(m)
|
||||
await toggle(m)
|
||||
assert.ok(panel() === null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('picking a range', () => {
|
||||
test('two clicks in order commit the pair and close the panel', async () => {
|
||||
const { m, changes } = await open()
|
||||
await toggle(m)
|
||||
assert.match(text(query(panel()!, '.lc-dp-hint')), /start date/)
|
||||
await click(cell(m, '2026-10-03'))
|
||||
assert.deepEqual(changes, [], 'the first click only anchors')
|
||||
assert.match(text(query(panel()!, '.lc-dp-hint')), /end date/)
|
||||
assert.match(cell(m, '2026-10-03').className, /lc-dp-edge/, 'the anchor is the start until the end lands')
|
||||
assert.match(text(query(m.container, '.lc-dp-half')), /2026-10-03/, 'the head reads the anchor as it lands')
|
||||
|
||||
await hover(cell(m, '2026-10-09'))
|
||||
assert.match(text(queryAll(m.container, '.lc-dp-half')[1]), /2026-10-09/, 'the head previews the end under the cursor')
|
||||
await click(cell(m, '2026-10-09'))
|
||||
assert.deepEqual(changes, [{ from: '2026-10-03', to: '2026-10-09' }])
|
||||
assert.ok(panel() === null, 'the pick answers the page immediately')
|
||||
})
|
||||
|
||||
test('the end may come first: the pair is sorted, never inverted', async () => {
|
||||
const { m, changes } = await open()
|
||||
await toggle(m)
|
||||
await click(cell(m, '2026-10-09'))
|
||||
await click(cell(m, '2026-10-03'))
|
||||
assert.deepEqual(changes, [{ from: '2026-10-03', to: '2026-10-09' }])
|
||||
})
|
||||
|
||||
test('the same day twice is a one-day range', async () => {
|
||||
const { m, changes } = await open()
|
||||
await toggle(m)
|
||||
await click(cell(m, '2026-10-05'))
|
||||
await click(cell(m, '2026-10-05'))
|
||||
assert.deepEqual(changes, [{ from: '2026-10-05', to: '2026-10-05' }])
|
||||
})
|
||||
|
||||
test('the band previews under the cursor once the start is anchored', async () => {
|
||||
const { m } = await open()
|
||||
await toggle(m)
|
||||
await click(cell(m, '2026-10-03'))
|
||||
assert.match(cell(m, '2026-10-03').className, /lc-dp-edge/, 'the anchor alone lights up as an edge')
|
||||
await hover(cell(m, '2026-10-07'))
|
||||
assert.match(cell(m, '2026-10-05').className, /lc-dp-in/, 'the band runs to the hovered day')
|
||||
assert.match(cell(m, '2026-10-07').className, /lc-dp-edge/, 'the hovered day previews where the end would land')
|
||||
await hover(cell(m, '2026-10-01'))
|
||||
assert.match(cell(m, '2026-10-02').className, /lc-dp-in/, 'hovering back past the anchor previews the swapped band')
|
||||
assert.match(cell(m, '2026-10-01').className, /lc-dp-edge/, 'the swap moves the previewed end to the near side')
|
||||
})
|
||||
|
||||
test('a hover before any anchor previews nothing', async () => {
|
||||
const { m } = await open()
|
||||
await toggle(m)
|
||||
const before = cell(m, '2026-10-07').className
|
||||
await hover(cell(m, '2026-10-07'))
|
||||
assert.equal(cell(m, '2026-10-07').className, before)
|
||||
})
|
||||
|
||||
test('the live scope is highlighted when the panel opens, and a click restarts the pick', async () => {
|
||||
const { m } = await open({ value: { from: '2026-10-03', to: '2026-10-06' }, picked: true })
|
||||
await toggle(m)
|
||||
assert.match(cell(m, '2026-10-04').className, /lc-dp-in/)
|
||||
assert.match(cell(m, '2026-10-03').className, /lc-dp-edge/)
|
||||
assert.match(cell(m, '2026-10-06').className, /lc-dp-edge/)
|
||||
assert.match(cell(m, '2026-10-09').className, /lc-dp-today/)
|
||||
assert.match(cell(m, '2026-10-07').textContent ?? '', /^7$/, 'each cell prints its own day of the month')
|
||||
// Clicking anywhere restarts the pick, so the old band releases.
|
||||
await click(cell(m, '2026-10-08'))
|
||||
assert.equal(cell(m, '2026-10-04').className.includes('lc-dp-in'), false)
|
||||
assert.match(text(query(panel()!, '.lc-dp-hint')), /end date/)
|
||||
})
|
||||
|
||||
test('a preset’s window is the band on show, with no second copy of its dates', async () => {
|
||||
const { m } = await open({ value: { from: '2026-10-03', to: '2026-10-09' } })
|
||||
await toggle(m)
|
||||
assert.match(cell(m, '2026-10-05').className, /lc-dp-in/, 'the preset’s own days show as the live band')
|
||||
assert.equal(queryAll(panel()!, '.lc-dp-box').length, 0, 'the chip above is the only place the ends are printed')
|
||||
assert.equal(queryAll(panel()!, '.lc-dp-pair').length, 0)
|
||||
})
|
||||
|
||||
test('a future day is inert and a blank pad cell is inert', async () => {
|
||||
const { m, changes } = await open()
|
||||
await toggle(m)
|
||||
const future = cell(m, '2026-10-10')
|
||||
assert.match(future.className, /lc-dp-future/)
|
||||
assert.equal(future.getAttribute('aria-disabled'), 'true')
|
||||
await click(future)
|
||||
assert.deepEqual(changes, [])
|
||||
assert.match(text(query(panel()!, '.lc-dp-hint')), /start date/, 'the future day did not anchor')
|
||||
await hover(future)
|
||||
assert.ok(queryAll(panel()!, '.lc-dp-blank').length > 0, 'the month pads its weeks')
|
||||
})
|
||||
|
||||
test('the clear button hands the scope back to the presets', async () => {
|
||||
const { m, changes } = await open({ value: { from: '2026-10-03', to: '2026-10-06' }, picked: true })
|
||||
await toggle(m)
|
||||
await click(query(panel()!, '.lc-dp-clear'))
|
||||
assert.deepEqual(changes, [null])
|
||||
})
|
||||
|
||||
test('no clear button while nothing is picked', async () => {
|
||||
const { m } = await open()
|
||||
await toggle(m)
|
||||
assert.equal(queryAll(panel()!, '.lc-dp-clear').length, 0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the month nav', () => {
|
||||
test('steps a month at a time and stops at this month', async () => {
|
||||
const { m } = await open()
|
||||
await toggle(m)
|
||||
assert.match(query(panel()!, '.lc-dp-month').textContent ?? '', /2026/)
|
||||
assert.equal(queryAll(panel()!, '.lc-dp-navbtn')[1].hasAttribute('disabled'), true, 'the panel opens on this month, so next is spent')
|
||||
|
||||
await click(queryAll(panel()!, '.lc-dp-navbtn')[0])
|
||||
assert.equal(queryAll(panel()!, '.lc-dp-navbtn')[1].hasAttribute('disabled'), false, 'back a month, next serves again')
|
||||
await click(queryAll(panel()!, '.lc-dp-navbtn')[1])
|
||||
assert.equal(queryAll(panel()!, '.lc-dp-navbtn')[1].hasAttribute('disabled'), true, 'no month past this one')
|
||||
assert.equal(queryAll(panel()!, '.lc-dp-cell').length > 0, true)
|
||||
})
|
||||
|
||||
test('the panel always comes back on this month, not the browsed one', async () => {
|
||||
const { m } = await open()
|
||||
await toggle(m)
|
||||
await click(queryAll(panel()!, '.lc-dp-navbtn')[0])
|
||||
const away = query(panel()!, '.lc-dp-month').textContent
|
||||
await toggle(m)
|
||||
await toggle(m)
|
||||
assert.notEqual(query(panel()!, '.lc-dp-month').textContent, away, 'the panel came back on today, not the browsed month')
|
||||
})
|
||||
|
||||
test('the earliest representable month cannot step back', async () => {
|
||||
const { m } = await open({ today: '1970-01-05' })
|
||||
await toggle(m)
|
||||
await click(queryAll(panel()!, '.lc-dp-navbtn')[0])
|
||||
assert.match(query(panel()!, '.lc-dp-month').textContent ?? '', /1970/)
|
||||
})
|
||||
|
||||
test('a malformed today opens no panel and claims no expansion', async () => {
|
||||
const { m } = await open({ today: 'garbage' })
|
||||
await toggle(m)
|
||||
assert.ok(panel() === null)
|
||||
assert.equal(trigger(m).getAttribute('aria-expanded'), 'false', 'nothing rendered, so nothing reported as open')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the keyboard cursor', () => {
|
||||
test('arrows walk the shown month and Enter picks the day under them', async () => {
|
||||
const { m, changes } = await open()
|
||||
await toggle(m)
|
||||
const grid = query(panel()!, '.lc-dp-grid')
|
||||
assert.equal(grid.getAttribute('aria-activedescendant'), 'lc-dp-day')
|
||||
assert.match(cell(m, '2026-10-01').className, /lc-dp-cursor/, 'the cursor opens on the month’s first day')
|
||||
|
||||
await keydown('ArrowRight', grid)
|
||||
assert.match(cell(m, '2026-10-02').className, /lc-dp-cursor/)
|
||||
await keydown('ArrowDown', grid)
|
||||
assert.match(cell(m, '2026-10-09').className, /lc-dp-cursor/)
|
||||
await keydown('ArrowUp', grid)
|
||||
assert.match(cell(m, '2026-10-02').className, /lc-dp-cursor/)
|
||||
await keydown('ArrowLeft', grid)
|
||||
assert.match(cell(m, '2026-10-01').className, /lc-dp-cursor/)
|
||||
|
||||
await keydown('Enter', grid)
|
||||
await keydown(' ', grid)
|
||||
assert.deepEqual(changes, [{ from: '2026-10-01', to: '2026-10-01' }], 'Enter anchors, Space closes the pair')
|
||||
})
|
||||
|
||||
test('the cursor never leaves the shown month or enters the future', async () => {
|
||||
const { m } = await open()
|
||||
await toggle(m)
|
||||
const grid = () => query(panel()!, '.lc-dp-grid')
|
||||
// The 1st is a Thursday; a week up from it would leave October.
|
||||
await keydown('ArrowUp', grid())
|
||||
assert.match(cell(m, '2026-10-01').className, /lc-dp-cursor/, 'the cursor stays inside the shown month')
|
||||
// Rightward it walks, and today is the last stop — the future is not a seat.
|
||||
for (let i = 0; i < 40; i++) await keydown('ArrowRight', grid())
|
||||
assert.match(cell(m, TODAY).className, /lc-dp-cursor/)
|
||||
await keydown('ArrowRight', grid())
|
||||
assert.match(cell(m, TODAY).className, /lc-dp-cursor/)
|
||||
|
||||
// Back a month, the whole month walks.
|
||||
await click(queryAll(panel()!, '.lc-dp-navbtn')[0])
|
||||
assert.match(cell(m, '2026-09-01').className, /lc-dp-cursor/, 'a month step re-seats the cursor on the 1st')
|
||||
await keydown('ArrowDown', grid())
|
||||
assert.match(cell(m, '2026-09-08').className, /lc-dp-cursor/)
|
||||
await keydown('ArrowDown', grid())
|
||||
assert.match(cell(m, '2026-09-15').className, /lc-dp-cursor/)
|
||||
})
|
||||
|
||||
test('any other key is left to the page', async () => {
|
||||
const { m } = await open()
|
||||
await toggle(m)
|
||||
const grid = query(panel()!, '.lc-dp-grid')
|
||||
await keydown('Tab', grid)
|
||||
await keydown('a', grid)
|
||||
assert.match(cell(m, '2026-10-01').className, /lc-dp-cursor/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the panel seat', () => {
|
||||
test('sits under its trigger, inside the viewport, and flips when it would fall off', async () => {
|
||||
// jsdom measures every element as 0; drive the panel's own box, then put
|
||||
// the prototype's own descriptors back (deleting them would leave every
|
||||
// later measurement NaN).
|
||||
const size = { w: 240, h: 300 }
|
||||
const own = (name: string): PropertyDescriptor | undefined => Object.getOwnPropertyDescriptor(HTMLElement.prototype, name)
|
||||
const width = own('offsetWidth')
|
||||
const height = own('offsetHeight')
|
||||
const restore = (name: string, was: PropertyDescriptor | undefined): void => {
|
||||
if (was === undefined) Reflect.deleteProperty(HTMLElement.prototype, name)
|
||||
else Object.defineProperty(HTMLElement.prototype, name, was)
|
||||
}
|
||||
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, get: () => size.w })
|
||||
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, get: () => size.h })
|
||||
try {
|
||||
const { m } = await open()
|
||||
// The panel measures its anchor wrapper (the trigger's own box): jsdom's
|
||||
// rects are all zeros, so drive the one it reads.
|
||||
const rect = { left: 800, right: 900, top: 680, bottom: 700 }
|
||||
query(m.container, '.lc-dp').getBoundingClientRect = () =>
|
||||
({ ...rect, x: rect.left, y: rect.top, width: 100, height: 20, toJSON: () => '' }) as DOMRect
|
||||
|
||||
await toggle(m)
|
||||
const seated = panel()!
|
||||
assert.equal(seated.style.left, '660px', 'right-aligned to the trigger')
|
||||
assert.equal(seated.style.top, '374px', 'flipped above: below the trigger there is no room for 300px')
|
||||
|
||||
// A trigger at the left edge: the seat clamps inside the viewport.
|
||||
rect.left = 10
|
||||
rect.right = 110
|
||||
await toggle(m)
|
||||
await toggle(m)
|
||||
const clamped = panel()!
|
||||
assert.equal(clamped.style.left, '8px', 'clamped inside the viewport')
|
||||
assert.equal(clamped.style.top, '374px')
|
||||
} finally {
|
||||
restore('offsetWidth', width)
|
||||
restore('offsetHeight', height)
|
||||
}
|
||||
})
|
||||
|
||||
test('renders hidden until it has measured itself', async () => {
|
||||
const { m } = await open()
|
||||
await toggle(m)
|
||||
// The layout effect measures before paint, so the seated panel is what a
|
||||
// reader ever sees; a fresh mount would flash at (0, 0) without it.
|
||||
assert.equal(panel()!.style.visibility, '')
|
||||
assert.notEqual(panel()!.style.left, '0px')
|
||||
})
|
||||
})
|
||||
|
||||
describe('leaving the panel', () => {
|
||||
test('Escape closes it and puts focus back on the trigger', async () => {
|
||||
const { m } = await open()
|
||||
await toggle(m)
|
||||
trigger(m).focus()
|
||||
await keydown('Escape')
|
||||
assert.ok(panel() === null)
|
||||
assert.equal(document.activeElement?.className, trigger(m).className, 'focus comes back to the trigger')
|
||||
})
|
||||
|
||||
test('a press outside closes it; a press inside does not', async () => {
|
||||
const { m } = await open()
|
||||
await toggle(m)
|
||||
await act(async () => { query(m.container, '.lc-dp').dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) })
|
||||
assert.ok(panel() !== null, 'the tree the panel was opened from stays open')
|
||||
await act(async () => { document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) })
|
||||
assert.ok(panel() === null)
|
||||
})
|
||||
|
||||
test('a press inside the panel is not an outside press', async () => {
|
||||
const { m } = await open()
|
||||
await toggle(m)
|
||||
await act(async () => { cell(m, '2026-10-03').dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) })
|
||||
assert.ok(panel() !== null)
|
||||
})
|
||||
|
||||
test('a press on the trigger itself does not race the toggle', async () => {
|
||||
const { m } = await open()
|
||||
await toggle(m)
|
||||
await act(async () => { trigger(m).dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) })
|
||||
assert.ok(panel() !== null)
|
||||
})
|
||||
|
||||
test('an event with no node target is ignored', async () => {
|
||||
const { m } = await open()
|
||||
await toggle(m)
|
||||
// Dispatched at window itself, so the target is not a node at all.
|
||||
await fireWindow(new MouseEvent('mousedown'))
|
||||
assert.ok(panel() !== null)
|
||||
})
|
||||
|
||||
test('a scroll or a resize retracts it rather than floating off the anchor', async () => {
|
||||
const { m } = await open()
|
||||
await toggle(m)
|
||||
await fireWindow(new Event('scroll'))
|
||||
assert.ok(panel() === null)
|
||||
|
||||
await toggle(m)
|
||||
await fireWindow(new Event('resize'))
|
||||
assert.ok(panel() === null)
|
||||
})
|
||||
})
|
||||
@@ -108,6 +108,13 @@ async function openPanel(
|
||||
/** The fetch calls the page fired (the warm-up trigger POST), per test. */
|
||||
const backfillPosts: string[] = []
|
||||
|
||||
/** One day cell of the page's portaled calendar picker, by its day key. */
|
||||
function calendarDay(key: string): HTMLElement {
|
||||
const el = queryAll(document.body, '.lc-dp-cell').find(d => d.getAttribute('aria-label') === key)
|
||||
assert.ok(el !== undefined, `the calendar drew ${key}`)
|
||||
return el
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetModelPrices()
|
||||
setModelPricesLoader(() => Promise.resolve(PROVIDERS))
|
||||
@@ -206,18 +213,69 @@ describe('OverviewPanel', () => {
|
||||
const { m } = await openPanel(ctx)
|
||||
const rangeButtons = queryAll<HTMLButtonElement>(m.container, '.lc-ov-range .lc-gran-btn')
|
||||
assert.equal(rangeButtons.length, 4)
|
||||
// The chip reads the LIVE window, so every preset prints its own days.
|
||||
const span = () => queryAll(m.container, '.lc-dp-half').map(el => el.textContent)
|
||||
const thirty = new Date(NOW - 30 * 86_400_000)
|
||||
assert.deepEqual(span(), [dayKeyOf(thirty.getTime()), TODAY], '30d shows the thirty days it covers')
|
||||
await click(rangeButtons[3]) // All
|
||||
assert.deepEqual(span(), ['Start date', 'End date'], 'an unbounded scope has no span to print')
|
||||
assert.equal(queryAll(m.container, '.lc-ov-grid > .lc-ov-session').length, 3, 'the stale session joins')
|
||||
assert.equal(queryAll(m.container, '.lc-stat-value')[0].textContent, '3')
|
||||
await click(rangeButtons[0]) // Today
|
||||
assert.deepEqual(span(), [TODAY, TODAY], "today's own day on both ends")
|
||||
assert.equal(queryAll(m.container, '.lc-ov-grid > .lc-ov-session').length, 1, "only today's session stays")
|
||||
await click(rangeButtons[1]) // Last 7 days
|
||||
assert.deepEqual(span(), [dayKeyOf(NOW - 7 * 86_400_000), TODAY])
|
||||
assert.equal(queryAll(m.container, '.lc-ov-grid > .lc-ov-session').length, 1, 'only the freshest stays')
|
||||
await click(rangeButtons[2]) // back to 30d
|
||||
assert.equal(queryAll(m.container, '.lc-ov-grid > .lc-ov-session').length, 2)
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('a picked calendar range scopes the page and hands the presets back', async () => {
|
||||
const ctx = makeCtx()
|
||||
const { m } = await openPanel(ctx)
|
||||
const rangeButtons = queryAll<HTMLButtonElement>(m.container, '.lc-ov-range .lc-gran-btn')
|
||||
await click(query(m.container, '.lc-dp-trigger'))
|
||||
await click(calendarDay(YESTERDAY))
|
||||
await click(calendarDay(TODAY))
|
||||
assert.ok(query(m.container, '.lc-dp-trigger').className.includes('lc-dp-trigger-on'), 'the chip carries the picked range')
|
||||
assert.deepEqual(
|
||||
queryAll(m.container, '.lc-dp-trigger .lc-dp-half').map(el => el.textContent),
|
||||
[YESTERDAY, TODAY],
|
||||
'the head names both ends, start first',
|
||||
)
|
||||
// The window closes at both ends: only the session last touched inside it stays.
|
||||
assert.equal(queryAll(m.container, '.lc-ov-grid > .lc-ov-session').length, 1)
|
||||
assert.equal(queryAll(m.container, '.lc-stat-value')[0].textContent, '1', 'the KPI band narrowed with it')
|
||||
assert.ok(
|
||||
rangeButtons.every(b => !b.className.includes('lc-gran-on')),
|
||||
'a picked range and a preset are never both lit',
|
||||
)
|
||||
// Clicking any preset takes the scope back and drops the chip's voice.
|
||||
await click(rangeButtons[3])
|
||||
assert.equal(queryAll(m.container, '.lc-ov-grid > .lc-ov-session').length, 3, 'the stale session joins again')
|
||||
assert.ok(!query(m.container, '.lc-dp-trigger').className.includes('lc-dp-trigger-on'))
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('a backwards pick still scopes the window the short way round', async () => {
|
||||
const ctx = makeCtx()
|
||||
const { m } = await openPanel(ctx)
|
||||
await click(query(m.container, '.lc-dp-trigger'))
|
||||
// The end day first, then a start day BEFORE it: the pair swaps rather
|
||||
// than leaving the page with a window that runs backwards.
|
||||
await click(calendarDay(TODAY))
|
||||
await click(calendarDay(YESTERDAY))
|
||||
assert.deepEqual(
|
||||
queryAll(m.container, '.lc-dp-trigger .lc-dp-half').map(el => el.textContent),
|
||||
[YESTERDAY, TODAY],
|
||||
'the picked pair is sorted, start day first',
|
||||
)
|
||||
assert.equal(queryAll(m.container, '.lc-ov-grid > .lc-ov-session').length, 1, 'the same window a forward pick would have left')
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('the heatmap day pin filters the grid and the chip clears it', async () => {
|
||||
const ctx = makeCtx()
|
||||
const { m } = await openPanel(ctx)
|
||||
|
||||
+106
-36
@@ -8,13 +8,15 @@ import {
|
||||
aggregateDays,
|
||||
billedOf,
|
||||
createdDayOf,
|
||||
dayRangeWindow,
|
||||
filterRows,
|
||||
groupCountsOf,
|
||||
inGroup,
|
||||
kpisOf,
|
||||
orderedDayRange,
|
||||
pageOf,
|
||||
projectOf,
|
||||
rangeStartOf,
|
||||
rangeWindowOf,
|
||||
refreshSessions,
|
||||
relativeTime,
|
||||
requestActivityBackfill,
|
||||
@@ -27,9 +29,11 @@ import {
|
||||
timingSumOf,
|
||||
tokenPartsOf,
|
||||
turnsOf,
|
||||
OPEN_WINDOW,
|
||||
UNGROUPED_KEY,
|
||||
usageTotalsOf,
|
||||
workspacesSnapshotOf,
|
||||
type OverviewRange,
|
||||
type OverviewRow,
|
||||
} from '../../src/client/overview'
|
||||
import type { ClientCtx } from '../../src/client/services'
|
||||
@@ -37,6 +41,9 @@ import { dayKeyOf } from '../../src/shared/days'
|
||||
import type { ContextActivity, ContextTimeline, SessionCostUsage } from '../../src/shared/types'
|
||||
import { mergeCostUsage, priceIndexOf } from '../../src/client/cost'
|
||||
|
||||
/** A preset's resolved scope window at `now` — the page's own resolution, spelled the way the panel spells it. */
|
||||
const win = (range: OverviewRange, now: number) => rangeWindowOf(range, now)
|
||||
|
||||
/** The minimal wire-valid timeline head, overridable per case. */
|
||||
function timelineOf(over: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
@@ -193,14 +200,48 @@ describe('billedOf / turnsOf', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('rangeStartOf', () => {
|
||||
test('each window’s start instant; all is unbounded', () => {
|
||||
describe('rangeWindowOf', () => {
|
||||
test('each preset pins its start; all is the open window', () => {
|
||||
// Local anchors, so the calendar-day window reads the same in every zone.
|
||||
const now = new Date(2026, 8, 20, 12).getTime()
|
||||
assert.equal(rangeStartOf('today', now), new Date(2026, 8, 20).getTime(), 'today opens on the local midnight')
|
||||
assert.equal(rangeStartOf('7d', now), now - 7 * 86_400_000)
|
||||
assert.equal(rangeStartOf('30d', now), now - 30 * 86_400_000)
|
||||
assert.equal(rangeStartOf('all', now), null)
|
||||
const midnight = new Date(2026, 8, 20).getTime()
|
||||
assert.deepEqual(rangeWindowOf('today', now), { start: midnight, end: null }, 'today opens on the local midnight')
|
||||
assert.deepEqual(rangeWindowOf('7d', now), { start: now - 7 * 86_400_000, end: null })
|
||||
assert.deepEqual(rangeWindowOf('30d', now), { start: now - 30 * 86_400_000, end: null })
|
||||
assert.deepEqual(rangeWindowOf('all', now), OPEN_WINDOW, 'no bound at either end')
|
||||
assert.deepEqual(OPEN_WINDOW, { start: null, end: null })
|
||||
})
|
||||
})
|
||||
|
||||
describe('dayRangeWindow', () => {
|
||||
test('a picked range spans both of its days whole', () => {
|
||||
assert.deepEqual(dayRangeWindow({ from: '2026-09-10', to: '2026-09-12' }), {
|
||||
start: new Date(2026, 8, 10).getTime(),
|
||||
end: new Date(2026, 8, 12, 23, 59, 59, 999).getTime(),
|
||||
}, 'start at the first midnight, end at the last millisecond')
|
||||
assert.deepEqual(dayRangeWindow({ from: '2026-09-10', to: '2026-09-10' }), {
|
||||
start: new Date(2026, 8, 10).getTime(),
|
||||
end: new Date(2026, 8, 10, 23, 59, 59, 999).getTime(),
|
||||
}, 'a one-day range counts that whole day')
|
||||
})
|
||||
|
||||
test('a malformed key on either end yields no window at all', () => {
|
||||
assert.equal(dayRangeWindow({ from: 'garbage', to: '2026-09-12' }), null)
|
||||
assert.equal(dayRangeWindow({ from: '2026-09-10', to: '2026-02-30' }), null)
|
||||
})
|
||||
|
||||
test('a pair that arrives backwards is ordered on the way in', () => {
|
||||
// The pick path sorts already; this is the seam that keeps a hand-built
|
||||
// pair from filtering the page down to an empty window.
|
||||
assert.deepEqual(dayRangeWindow({ from: '2026-09-12', to: '2026-09-10' }), dayRangeWindow({ from: '2026-09-10', to: '2026-09-12' }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('orderedDayRange', () => {
|
||||
test('a backwards pair swaps its ends rather than inverting', () => {
|
||||
assert.deepEqual(orderedDayRange('2026-09-10', '2026-09-12'), { from: '2026-09-10', to: '2026-09-12' })
|
||||
assert.deepEqual(orderedDayRange('2026-09-12', '2026-09-10'), { from: '2026-09-10', to: '2026-09-12' })
|
||||
assert.deepEqual(orderedDayRange('2026-09-10', '2026-09-10'), { from: '2026-09-10', to: '2026-09-10' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -219,36 +260,50 @@ describe('filterRows', () => {
|
||||
]
|
||||
|
||||
test('the range window filters by last activity', () => {
|
||||
assert.deepEqual(filterRows(rows, { range: 'today', day: null, query: '' }, now).map(r => r.id), ['new', 'active'])
|
||||
assert.deepEqual(filterRows(rows, { range: '7d', day: null, query: '' }, now).map(r => r.id), ['new', 'active'])
|
||||
assert.deepEqual(filterRows(rows, { range: 'all', day: null, query: '' }, now).map(r => r.id), ['old', 'new', 'active'])
|
||||
assert.deepEqual(filterRows(rows, { scope: win('today', now), day: null, query: '' }).map(r => r.id), ['new', 'active'])
|
||||
assert.deepEqual(filterRows(rows, { scope: win('7d', now), day: null, query: '' }).map(r => r.id), ['new', 'active'])
|
||||
assert.deepEqual(filterRows(rows, { scope: win('all', now), day: null, query: '' }).map(r => r.id), ['old', 'new', 'active'])
|
||||
// Today's own boundary: hours in, one minute before the local midnight out.
|
||||
const dRows = [
|
||||
rowOf({ id: 'in', title: 'this morning', updatedAt: now - 2 * 3_600_000 }),
|
||||
rowOf({ id: 'out', title: 'last night', updatedAt: midnight - 60_000 }),
|
||||
]
|
||||
assert.deepEqual(filterRows(dRows, { range: 'today', day: null, query: '' }, now).map(r => r.id), ['in'])
|
||||
assert.deepEqual(filterRows(dRows, { range: '7d', day: null, query: '' }, now).map(r => r.id), ['in', 'out'], 'last night is still inside the week')
|
||||
assert.deepEqual(filterRows(dRows, { scope: win('today', now), day: null, query: '' }).map(r => r.id), ['in'])
|
||||
assert.deepEqual(filterRows(dRows, { scope: win('7d', now), day: null, query: '' }).map(r => r.id), ['in', 'out'], 'last night is still inside the week')
|
||||
})
|
||||
|
||||
test('a picked range closes both ends of the window', () => {
|
||||
// A two-day window: the day before its start and the day after its end are
|
||||
// both out, and the end day's last millisecond is still in.
|
||||
const scope = dayRangeWindow({ from: '2026-09-10', to: '2026-09-11' })
|
||||
assert.ok(scope !== null)
|
||||
const window = [
|
||||
rowOf({ id: 'before', updatedAt: new Date(2026, 8, 9, 23, 59).getTime() }),
|
||||
rowOf({ id: 'first', updatedAt: new Date(2026, 8, 10).getTime() }),
|
||||
rowOf({ id: 'last', updatedAt: new Date(2026, 8, 11, 23, 59, 59, 999).getTime() }),
|
||||
rowOf({ id: 'after', updatedAt: new Date(2026, 8, 12).getTime() }),
|
||||
]
|
||||
assert.deepEqual(filterRows(window, { scope, day: null, query: '' }).map(r => r.id), ['first', 'last'])
|
||||
})
|
||||
|
||||
test('the day pin keeps only sessions contributing to that day', () => {
|
||||
assert.deepEqual(filterRows(rows, { range: 'all', day: '2026-09-16', query: '' }, now).map(r => r.id), ['active'])
|
||||
assert.deepEqual(filterRows(rows, { range: 'all', day: '2026-09-10', query: '' }, now), [], 'a zero day is no contribution')
|
||||
assert.deepEqual(filterRows(rows, { range: 'all', day: '2026-09-11', query: '' }, now), [], 'no ledger entry')
|
||||
assert.deepEqual(filterRows(rows, { scope: win('all', now), day: '2026-09-16', query: '' }).map(r => r.id), ['active'])
|
||||
assert.deepEqual(filterRows(rows, { scope: win('all', now), day: '2026-09-10', query: '' }), [], 'a zero day is no contribution')
|
||||
assert.deepEqual(filterRows(rows, { scope: win('all', now), day: '2026-09-11', query: '' }), [], 'no ledger entry')
|
||||
})
|
||||
|
||||
test('the query matches title, directory, or last message, case-insensitively', () => {
|
||||
assert.deepEqual(filterRows(rows, { range: 'all', day: null, query: 'FRESH' }, now).map(r => r.id), ['new'])
|
||||
assert.deepEqual(filterRows(rows, { range: 'all', day: null, query: '/repo' }, now).map(r => r.id), ['new'])
|
||||
assert.deepEqual(filterRows(rows, { range: 'all', day: null, query: ' ' }, now).length, 3, 'a blank query matches all')
|
||||
assert.deepEqual(filterRows(rows, { range: 'all', day: null, query: 'zzz' }, now), [])
|
||||
assert.deepEqual(filterRows(rows, { scope: win('all', now), day: null, query: 'FRESH' }).map(r => r.id), ['new'])
|
||||
assert.deepEqual(filterRows(rows, { scope: win('all', now), day: null, query: '/repo' }).map(r => r.id), ['new'])
|
||||
assert.deepEqual(filterRows(rows, { scope: win('all', now), day: null, query: ' ' }).length, 3, 'a blank query matches all')
|
||||
assert.deepEqual(filterRows(rows, { scope: win('all', now), day: null, query: 'zzz' }), [])
|
||||
// The session's newest own message joins the haystack.
|
||||
const talked = rowOf({ id: 'talk', title: 'quiet title', timeline: { lastUser: 'ship the QUARTERLY report' } as unknown as ContextTimeline })
|
||||
assert.deepEqual(filterRows([talked], { range: 'all', day: null, query: 'quarterly' }, now).map(r => r.id), ['talk'])
|
||||
assert.deepEqual(filterRows([talked], { range: 'all', day: null, query: 'quiet' }, now).map(r => r.id), ['talk'], 'the title still matches')
|
||||
assert.deepEqual(filterRows([talked], { scope: win('all', now), day: null, query: 'quarterly' }).map(r => r.id), ['talk'])
|
||||
assert.deepEqual(filterRows([talked], { scope: win('all', now), day: null, query: 'quiet' }).map(r => r.id), ['talk'], 'the title still matches')
|
||||
// A non-string last message (a hostile fold shape) never matches.
|
||||
const odd = rowOf({ id: 'odd', title: 'odd one', timeline: { lastUser: 42 } as unknown as ContextTimeline })
|
||||
assert.deepEqual(filterRows([odd], { range: 'all', day: null, query: '42' }, now), [])
|
||||
assert.deepEqual(filterRows([odd], { scope: win('all', now), day: null, query: '42' }), [])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -747,20 +802,35 @@ describe('skillLoadsOf', () => {
|
||||
[day(1)]: { tokens: 1, requests: 1, skills: { tdd: { n: 3, last: NOW - 2000 }, grill: { n: 1, last: NOW - 500 } } },
|
||||
}),
|
||||
]
|
||||
assert.deepEqual(skillLoadsOf(rows, { range: '7d', day: null }, NOW), [
|
||||
assert.deepEqual(skillLoadsOf(rows, { scope: win('7d', NOW), day: null }), [
|
||||
{ name: 'tdd', loads: 5, sessions: 2, last: NOW - 1000 },
|
||||
{ name: 'grill', loads: 1, sessions: 1, last: NOW - 500 },
|
||||
], 'the 10-days-ago entries fall below the floor')
|
||||
assert.deepEqual(skillLoadsOf(rows, { range: 'today', day: null }, NOW), [
|
||||
assert.deepEqual(skillLoadsOf(rows, { scope: win('today', NOW), day: null }), [
|
||||
{ name: 'tdd', loads: 2, sessions: 1, last: NOW - 1000 },
|
||||
], "today's floor admits this very day only")
|
||||
assert.deepEqual(skillLoadsOf(rows, { range: 'all', day: null }, NOW), [
|
||||
assert.deepEqual(skillLoadsOf(rows, { scope: win('all', NOW), day: null }), [
|
||||
{ name: 'tdd', loads: 10, sessions: 2, last: NOW - 1000 },
|
||||
{ name: 'grill', loads: 1, sessions: 1, last: NOW - 500 },
|
||||
{ name: 'stale', loads: 1, sessions: 1, last: NOW - 900_000 },
|
||||
])
|
||||
})
|
||||
|
||||
test('a picked range closes the ledger window at its end day', () => {
|
||||
const rows = [
|
||||
rowWith('a', {
|
||||
[day(0)]: { tokens: 1, requests: 1, skills: { fresh: { n: 1, last: NOW } } },
|
||||
[day(1)]: { tokens: 1, requests: 1, skills: { edge: { n: 2, last: NOW } } },
|
||||
[day(5)]: { tokens: 1, requests: 1, skills: { late: { n: 4, last: NOW } } },
|
||||
}),
|
||||
]
|
||||
const scope = dayRangeWindow({ from: day(4), to: day(1) })
|
||||
assert.ok(scope !== null)
|
||||
assert.deepEqual(skillLoadsOf(rows, { scope, day: null }).map(s => s.name), ['edge'], 'the end day counts whole; the day past it does not')
|
||||
assert.equal(rowLoadedSkill(rows[0], 'late', { scope, day: null }), false)
|
||||
assert.equal(rowLoadedSkill(rows[0], 'edge', { scope, day: null }), true)
|
||||
})
|
||||
|
||||
test('the loader tally counts a row once however many of its days carry the name', () => {
|
||||
const rows = [
|
||||
rowWith('a', {
|
||||
@@ -769,7 +839,7 @@ describe('skillLoadsOf', () => {
|
||||
}),
|
||||
rowWith('b', { [day(0)]: { tokens: 1, requests: 1, skills: { tdd: { n: 1, last: NOW - 500 } } } }),
|
||||
]
|
||||
assert.deepEqual(skillLoadsOf(rows, { range: 'all', day: null }, NOW), [
|
||||
assert.deepEqual(skillLoadsOf(rows, { scope: win('all', NOW), day: null }), [
|
||||
{ name: 'tdd', loads: 3, sessions: 2, last: NOW - 500 },
|
||||
])
|
||||
})
|
||||
@@ -792,7 +862,7 @@ describe('skillLoadsOf', () => {
|
||||
rowWith('b', { [day(0)]: { tokens: 1, requests: 1, skills: { beta: { n: 1, last: NOW - 2000 } } } }),
|
||||
]
|
||||
const names = (sort?: 'loads' | 'recent'): string[] =>
|
||||
skillLoadsOf(rows, { range: 'all', day: null, sort }, NOW).map(s => s.name)
|
||||
skillLoadsOf(rows, { scope: win('all', NOW), day: null, sort }).map(s => s.name)
|
||||
assert.deepEqual(names(), ['alpha', 'gamma', 'beta', 'delta', 'epsilon'], 'loads (the default): loads desc, then recent — the gamma/beta tie breaks on freshness')
|
||||
assert.deepEqual(names('loads'), ['alpha', 'gamma', 'beta', 'delta', 'epsilon'], 'the explicit key matches the omission')
|
||||
assert.deepEqual(names('recent'), ['gamma', 'delta', 'epsilon', 'beta', 'alpha'], 'recent: the freshest first')
|
||||
@@ -805,7 +875,7 @@ describe('skillLoadsOf', () => {
|
||||
[day(1)]: { tokens: 1, requests: 1, skills: { grill: { n: 4, last: NOW - 80_000 } } },
|
||||
}),
|
||||
]
|
||||
assert.deepEqual(skillLoadsOf(rows, { range: 'all', day: day(1) }, NOW), [
|
||||
assert.deepEqual(skillLoadsOf(rows, { scope: win('all', NOW), day: day(1) }), [
|
||||
{ name: 'grill', loads: 4, sessions: 1, last: NOW - 80_000 },
|
||||
])
|
||||
})
|
||||
@@ -827,7 +897,7 @@ describe('skillLoadsOf', () => {
|
||||
},
|
||||
}),
|
||||
]
|
||||
assert.deepEqual(skillLoadsOf(rows, { range: 'all', day: null }, NOW).map(s => s.name), ['delta', 'alpha', 'beta', 'gamma'])
|
||||
assert.deepEqual(skillLoadsOf(rows, { scope: win('all', NOW), day: null }).map(s => s.name), ['delta', 'alpha', 'beta', 'gamma'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -851,13 +921,13 @@ describe('rowLoadedSkill', () => {
|
||||
},
|
||||
},
|
||||
})
|
||||
assert.equal(rowLoadedSkill(row, 'tdd', { range: 'today', day: null }, NOW), true, "loaded today — inside today's floor")
|
||||
assert.equal(rowLoadedSkill(row, 'grill', { range: 'today', day: null }, NOW), false, 'the 10-days-ago load falls below the floor')
|
||||
assert.equal(rowLoadedSkill(row, 'grill', { range: 'all', day: null }, NOW), true)
|
||||
assert.equal(rowLoadedSkill(row, 'tdd', { range: 'all', day: day(1) }, NOW), false, 'the pinned day admits only itself')
|
||||
assert.equal(rowLoadedSkill(row, 'grill', { range: 'all', day: day(10) }, NOW), true)
|
||||
assert.equal(rowLoadedSkill(row, 'never', { range: 'all', day: null }, NOW), false, 'a name no day carries')
|
||||
assert.equal(rowLoadedSkill(rowOf({ id: 'bare' }), 'tdd', { range: 'all', day: null }, NOW), false, 'no ledger at all')
|
||||
assert.equal(rowLoadedSkill(row, 'tdd', { scope: win('today', NOW), day: null }), true, "loaded today — inside today's floor")
|
||||
assert.equal(rowLoadedSkill(row, 'grill', { scope: win('today', NOW), day: null }), false, 'the 10-days-ago load falls below the floor')
|
||||
assert.equal(rowLoadedSkill(row, 'grill', { scope: win('all', NOW), day: null }), true)
|
||||
assert.equal(rowLoadedSkill(row, 'tdd', { scope: win('all', NOW), day: day(1) }), false, 'the pinned day admits only itself')
|
||||
assert.equal(rowLoadedSkill(row, 'grill', { scope: win('all', NOW), day: day(10) }), true)
|
||||
assert.equal(rowLoadedSkill(row, 'never', { scope: win('all', NOW), day: null }), false, 'a name no day carries')
|
||||
assert.equal(rowLoadedSkill(rowOf({ id: 'bare' }), 'tdd', { scope: win('all', NOW), day: null }), false, 'no ledger at all')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -3,7 +3,18 @@
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, test } from 'vitest'
|
||||
import { DAY_KEY_RE, dayKeyOf, shiftDayKey, sundayOfWeek } from '../../src/shared/days'
|
||||
import {
|
||||
DAY_KEY_RE,
|
||||
MONTH_KEY_RE,
|
||||
dayKeyOf,
|
||||
endOfDayKey,
|
||||
monthGridOf,
|
||||
shiftDayKey,
|
||||
shiftMonthKey,
|
||||
startOfDayKey,
|
||||
sundayOfWeek,
|
||||
weekdayOf,
|
||||
} from '../../src/shared/days'
|
||||
|
||||
describe('dayKeyOf', () => {
|
||||
test('keys a local instant as YYYY-MM-DD with zero padding', () => {
|
||||
@@ -65,3 +76,85 @@ describe('DAY_KEY_RE', () => {
|
||||
assert.equal(DAY_KEY_RE.test('2026-09-16T00:00:00'), false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('startOfDayKey / endOfDayKey', () => {
|
||||
test('a picked range spans the whole local day', () => {
|
||||
// Endpoints, not a duration: a DST-short day is 23 hours long, so the
|
||||
// difference is not a fixed number of milliseconds.
|
||||
assert.equal(startOfDayKey('2026-09-16'), new Date(2026, 8, 16, 0, 0, 0, 0).getTime())
|
||||
assert.equal(endOfDayKey('2026-09-16'), new Date(2026, 8, 16, 23, 59, 59, 999).getTime())
|
||||
assert.equal(startOfDayKey('2026-03-08'), new Date(2026, 2, 8, 0, 0, 0, 0).getTime(), 'a DST-short day still opens on its own midnight')
|
||||
})
|
||||
|
||||
test('malformed keys yield null', () => {
|
||||
assert.equal(startOfDayKey('2026-02-30'), null)
|
||||
assert.equal(endOfDayKey('nope'), null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shiftMonthKey', () => {
|
||||
test('steps across month and year boundaries', () => {
|
||||
assert.equal(shiftMonthKey('2026-10', 1), '2026-11')
|
||||
assert.equal(shiftMonthKey('2026-10', -1), '2026-09')
|
||||
assert.equal(shiftMonthKey('2026-12', 1), '2027-01')
|
||||
assert.equal(shiftMonthKey('2026-01', -1), '2025-12')
|
||||
assert.equal(shiftMonthKey('2026-10', 14), '2027-12')
|
||||
})
|
||||
|
||||
test('malformed keys and out-of-range targets yield null', () => {
|
||||
assert.equal(shiftMonthKey('2026-13', 1), null)
|
||||
assert.equal(shiftMonthKey('2026-00', 1), null)
|
||||
assert.equal(shiftMonthKey('2026-1', 1), null)
|
||||
assert.equal(shiftMonthKey('junk', 1), null)
|
||||
assert.equal(shiftMonthKey('1970-01', -1), null, 'before the representable range')
|
||||
assert.equal(shiftMonthKey('9999-12', 1), null, 'past the representable range')
|
||||
})
|
||||
})
|
||||
|
||||
describe('weekdayOf', () => {
|
||||
test('returns the local weekday index, 0 = Sunday', () => {
|
||||
assert.equal(weekdayOf('2026-09-13'), 0)
|
||||
assert.equal(weekdayOf('2026-09-16'), 3)
|
||||
assert.equal(weekdayOf('2026-09-19'), 6)
|
||||
assert.equal(weekdayOf('2026-02-30'), null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('monthGridOf', () => {
|
||||
test('lays a month out as Sunday-first weeks of seven cells', () => {
|
||||
// September 2026 opens on a Tuesday (2 lead blanks) and closes on the 30th.
|
||||
const grid = monthGridOf('2026-09')
|
||||
assert.ok(grid !== null)
|
||||
assert.equal(grid[0].length, 7)
|
||||
assert.deepEqual(grid[0].slice(0, 3), [null, null, '2026-09-01'])
|
||||
assert.equal(grid[0][6], '2026-09-05')
|
||||
const last = grid[grid.length - 1]
|
||||
assert.deepEqual(last.filter((k) => k !== null), ['2026-09-27', '2026-09-28', '2026-09-29', '2026-09-30'], 'the open week is padded, never half-filled')
|
||||
const days = grid.flat().filter((k) => k !== null)
|
||||
assert.equal(days.length, 30)
|
||||
assert.equal(days.every((k) => k.startsWith('2026-09')), true)
|
||||
})
|
||||
|
||||
test('a month that opens on Sunday needs no lead blank', () => {
|
||||
// February 2027 opens on a Monday; November 2026 opens on a Sunday.
|
||||
const nov = monthGridOf('2026-11')
|
||||
assert.ok(nov !== null)
|
||||
assert.equal(nov[0][0], '2026-11-01')
|
||||
assert.equal(nov[0].filter((k) => k !== null).length, 7)
|
||||
})
|
||||
|
||||
test('malformed months and a month that leaves the key range yield null', () => {
|
||||
assert.equal(monthGridOf('2026-13'), null)
|
||||
assert.equal(monthGridOf('junk'), null)
|
||||
// The last representable month runs out of day keys one day past its end.
|
||||
assert.ok(monthGridOf('9999-12') !== null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MONTH_KEY_RE', () => {
|
||||
test('matches the emitted shape only', () => {
|
||||
assert.equal(MONTH_KEY_RE.test('2026-10'), true)
|
||||
assert.equal(MONTH_KEY_RE.test('2026-1'), false)
|
||||
assert.equal(MONTH_KEY_RE.test('2026-10-01'), false)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user