DataGrid was already injecting displayMode='cards' on small viewports
via useMobile() and leaving it undefined (default 'table') everywhere
else. The user had no way to override either side: someone on a wide
screen who preferred a denser card list could not get there, and
someone on a tablet with a tall narrow window could not force the
table view to keep their layout consistent.
Add a small icon toggle in the DataGrid header row that flips between
table and cards, and persist the choice in localStorage under
umami.datagrid.displayMode. The user choice wins; if there is none,
the existing useMobile-driven default applies. Every DataGrid
consumer (sessions, websites, links, pixels, boards, team admin,
etc.) gets the toggle automatically with no caller-side change.
Verified in playwright on the sessions page: at 1400 viewport the
default is table; clicking the toggle switches to cards and a reload
keeps cards. At 800 viewport the default is cards; clicking the
toggle switches to table even though useMobile would otherwise force
cards. Round trip in both directions works and the choice survives
navigation away and back.
The chart canvas in src/components/charts/Chart.tsx was rendered
directly inside the Box wrapper. Chart.js writes inline pixel sizes
onto the canvas, and while the canvas lives in the normal flow that
pixel width propagates up as min and max content through every flex
parent and into the surrounding CSS Grid track on the Tabs panel.
The track therefore stayed at whatever width the canvas had when the
page first loaded, and the chart could only grow on resize, never
shrink, until the user reloaded.
Wrap the canvas in a position-relative div and position the canvas
absolutely. Out-of-flow elements do not contribute to ancestor
intrinsic sizing, so the wrapper now takes its size purely from the
parent layout. Chart.js' ResizeObserver picks up the wrapper size and
resizes the canvas to fit, in both directions, without a reload.
Verified in playwright with resize 1280 to 800 (canvas 925 to 699)
and 800 to 1400 (canvas 699 to 1117), both without reload, and that
the click-to-toggle legend, the focusLabel hover behaviour, and the
website overview / revenue charts that share this component all
still render and update normally.
Address Greptile P2 review on PR #4259. willBeHidden was derived from
!ds.hidden, but ds.hidden can be set by the focusLabel pass too, so
the dataset flag is not a faithful read of the controlled hidden
state. In a chart that uses both focusLabel and hiddenLabels at once,
the callback could fire with a misleading toggle direction.
Read directly from hiddenLabels instead, with optional chaining so
the new path is a no-op when hiddenLabels is not provided. Behavior
is unchanged for EventsChart (which does not use focusLabel today)
but the controlled state is now the single source of truth.
Address Greptile P1 review on PR #4259. The hiddenLabels loop only
wrote ds.hidden = true and never reset to false when a label was
removed from the set. Toggle-off worked today only because the
focusLabel block above ran first and unconditionally cleared all
ds.hidden when focusLabel was falsy. That block is guarded by
chartData.focusLabel !== null, so any caller passing focusLabel={null}
would skip the reset and leave a stale true on reused dataset objects
across effect re-runs, making un-hide silently fail.
Add an else-if branch to the hiddenLabels loop that resets ds.hidden
to false when the label is not in the set and no focusLabel is
active. Behavior is unchanged for EventsChart (which never passes a
null focusLabel) and the contract of the new props is now independent
of execution order.
Address Greptile review on PR #4257: hex6(key) was parsed twice per
label, once in the sort comparator and once when deriving the
preferred palette slot. Cache the parsed integer in a local hashOf
map so the sort comparator and the slot lookup share one computation
per label, no behaviour change.
Clicking a legend item on the events chart toggled hidden via
chart.current.getDatasetMeta(idx).hidden, which lives on Chart.js's
per-dataset meta object. Each time chartData changed (date-range
switch, refetch, focusLabel update) the second useEffect in Chart.tsx
replaces datasets wholesale, Chart.js regenerates the meta, and the
hidden flags vanish, so previously-toggled-off events came back on
their own.
Lift the hidden state into React. EventsChart owns a Set<string> of
hidden labels and passes it down via a new optional hiddenLabels prop
on Chart, plus an onLegendClick callback for controlled toggling.
Chart re-applies hidden after the existing focusLabel pass so the set
survives every data refresh, and falls back to the original
meta-based behaviour when no callback is provided so other charts
(website overview, revenue) keep their existing semantics.
Verified with the seeded Demo SaaS data: hide signup_started in the
Last 24 hours view, switch to Last 7 days then Last 30 days, the
label stays greyed in the legend and absent from the bars; clicking
again restores it. State is component-scoped, so it intentionally
resets on reload or navigation away from the events page.
Colors in the events-tab chart were assigned by the dataset index from
Object.keys(map), so changing the date range or reloading the page
reshuffled keys and produced a different color for the same event each
time.
Pick the palette slot deterministically from a hash of the label
(hex6 / FNV-1a), and walk the palette greedily in hash-sorted order so
the assignment is independent of the API response order. When two
labels prefer the same slot, the later one steps to the next free
slot, so the visible set of up to 12 events all get distinct colors.
The right shift on the hash sidesteps the FNV-1a low-bit bias mod 12
(FNV prime is close to 2^24).
Both packages are required at runtime but were not declared in package.json:
- react-simple-maps imports prop-types but does not list it as a peer or
direct dependency, so prop-types must be provided by the host project.
- @umami/react-zen lists react-aria-components in peerDependencies, so
the host project must provide it.
These are auto-installed by pnpm 8+ when auto-install-peers is true (the
project default), which masks the issue. They are not auto-installed by
npm or by pnpm with auto-install-peers disabled, causing build failures
on a fresh checkout.
Three bugs in src/tracker/index.js, all empirically reproduced.
1. handleClicks() missed clicks on container elements: closest('a,button')
could not find a non-anchor ancestor with data-umami-event, so a click on
any descendant of <div data-umami-event=...> went untracked. Reworked to
closest([data-umami-event]) so any annotated ancestor matches.
2. handlePush() called new URL(url, location.href) outside normalize()'s
try/catch, so a host page calling history.pushState({}, '', invalidUrl)
would have umami's wrapper throw a TypeError into the host's router.
normalize(url) already handles base resolution and catches parse errors.
3. The history hook ran the umami callback BEFORE native pushState, so a
failed native call (SecurityError on invalid URL, etc.) would still mutate
currentUrl/currentRef and schedule a phantom pageview. Run native first;
if it throws, the callback never fires and tracker state stays consistent.
Bug 2 verified: 4/10 representative click scenarios missed before
(span inside div, deep span inside div, a with no href, button inside a),
all 10/10 tracked after.
Bug 1+3 verified: pushState({}, '', invalidUrl) now leaves tracker state
unchanged (currentUrl unchanged, no phantom pageview).
The INP observer sorted Object.values(interactions) on every event entry,
even though metrics.inp is only read when sendPerformance() flushes.
Defer the sort + p98 computation to flush, and drain queued observer
entries via observer.takeRecords() to capture the most recent
interactions on pagehide/visibilitychange.
Output is identical (same INP value computed). Savings are largest on
interaction-heavy pages and low-end devices where the per-event sort
compounds.
Address Greptile review feedback on #4243.
- Cloud-mode link.updateMany / pixel.updateMany now filter where: { ..., deletedAt: null } so a previously soft-deleted row keeps its original deletion timestamp instead of being restamped with the current time.
- Pre-transaction findMany now selects deletedAt; the Redis invalidation list filters to only live slugs, avoiding harmless but wasted DEL calls for already-soft-deleted entries.
Note: the share.deleteMany cleanup still uses the broad entityId list (not filtered by deletedAt) so that orphan share rows of already-soft-deleted links/pixels are still cleaned up. Filtering the prefetch itself, as Greptile's exact suggestion proposed, would skip those shares while link.deleteMany still hard-deletes the rows, leaving orphan share rows behind. Verified empirically with a 3-scenario reproduction.
deleteUser and deleteTeam left link/pixel/board rows (and their share rows)
in the database after the owner was removed. /q/<slug> and /p/<slug>
also kept serving deleted entries because the routes did not filter
deletedAt and Redis cached lookups for 24h.
- deleteUser: clean up link/pixel/board + shares for the deleted user.
Cloud mode: soft-delete link/pixel, hard-delete board, only userId-owned.
Non-cloud: hard-delete everything matching userId or owned teamIds.
- deleteTeam: same cleanup, scoped to teamId.
- /q and /p route handlers: filter deletedAt: null at the call sites
(not in findLink/findPixel helpers, which would null-deref the
permission checks at src/permissions/link.ts and pixel.ts).
- Post-transaction Redis invalidation mirrors deleteWebsite.