Migrate Layercharts patch
Files and images attached to this public thread are only available to signed-in users.
Sign inLayerChart v2 migration completed
- Upgraded LayerChart from
^1.0.13to^2.0.4and regeneratedbun.lock. - Migrated the timeline tooltip from legacy slots to Svelte 5 snippets.
- Replaced the removed
motion: falseAPI withmotion: "none". - Updated the pie chart’s resize callback and legend class:
onresize→onResizeclasses.swatches→classes.item
- Removed the unnecessary LayerChart Tailwind distribution scan. Version 2 ships its own component styles.
- Removed
patchedDependenciesand deletedpatches/layercake@8.4.3.patch.
Patch verdict
The patch is no longer needed. LayerChart 2 completely removes its LayerCake dependency, including the sizing code the patch modified. The updated lockfile contains neither LayerCake nor its patch registration.
Verification
Passed against a clean installation of LayerChart 2.0.4:
- Frozen lockfile installation
svelte-check: 0 errors, 3 unrelated existing warnings- Prettier check
- Knip
- Production Vite build: 2,379 modules transformed successfully
git diff --check
Docker is inaccessible from this orb, so I could not run these through the required docker compose exec web wrapper or perform browser/system-test verification. A full docker compose exec web bin/ci run is recommended before merging.
Docker and portal are running
Docker was already installed and its daemon was healthy. The socket intentionally requires sudo, as specified by .agents/setup; my earlier command omitted it.
Open the app here:
Use /__dev/log-me-in/test@example.com to sign in as the seeded user.
LayerChart verification passed
Inside Docker Compose:
- Clean frozen install selected
layerchart@2.0.4. svelte-check: 0 errors, 3 pre-existing warnings.- Prettier: passed.
- Knip: passed.
- Forced Vite build: 2,379 modules transformed successfully.
- Headless Chromium tested the real Rails dashboard:
- 4 LayerChart roots rendered.
- Languages: 8 pie arcs.
- Editors: 4 pie arcs.
- Operating Systems: 3 pie arcs.
- Project Timeline: 6 visible stacked bars.
- Hover tooltip rendered
Aug 10 - 16, project durations and total. - Browser console errors: 0.
System-test result
The full suite ran 43 tests and 221 assertions. 42 passed. One unrelated profile test failed persistently:
ProfilesTest#test_public_profile_renders_visible_bio
expected to find text "Profile bio from system test"
Its screenshot is a completely black profile page. It does not exercise LayerChart and no profile code was changed by this migration, but it means the full system suite is not currently green.
You were right. I applied flex-wrap to classes.item, which caused each marker and label to wrap independently. The migration guide’s mechanical rename was wrong for our container-level styling.
Changed it to:
classes: {
root: "w-full px-2",
items: "flex-wrap justify-center",
label: "text-xs text-surface-content/70",
}
Now:
- Markers and labels are horizontally inline.
- Their vertical centres match exactly.
- Wrapping occurs between complete legend items.
- Long legends wrap onto additional centred rows, matching production.
The portal has the rebuilt assets: Hackatime portal
Verification passed again with svelte-check, Prettier and a forced Vite build.
The LayerCake invariant is preserved
PR #1193 added the patch because LayerCake reused caller-provided dimensions as DOM measurement targets. During hydration, browser measurements could overwrite the SSR coordinate system and move or resize the pie chart.
The current implementation no longer SSR-renders LayerChart itself. It renders a deterministic static SVG pie and legend, then replaces that with interactive LayerChart after mount. LayerChart v2 also no longer depends on LayerCake.
I tested this with a real SSR server, not the portal’s previous CSR fallback.
SSR and layout results
Using 4× CPU throttling, 150 ms latency and constrained 4G:
| Measurement | Result |
|---|---|
| V2 cold runs | 8 |
| CLS | 0.000 in every run |
| Dashboard skeleton frames | 0 |
| Frames with neither static nor interactive chart | 0 |
| Pie-card height before and after hydration | 424 px → 424 px |
| Browser console errors | 0 |
| Measurement | Result |
|---|---|
| V2 cold runs | 8 |
| CLS | 0.000 in every run |
| Dashboard skeleton frames | 0 |
| Frames with neither static nor interactive chart | 0 |
| Pie-card height before and after hydration | 424 px → 424 px |
| Browser console errors | 0 |
The initial SSR HTML contains:
- All dashboard content
- Three real static pie charts
- Three complete legends
- No
DashboardSkeleton
The only animate-pulse in the HTML is the streak icon’s hover class, not a loading skeleton.
Geometry parity
I compared the server-rendered pie bounds with the hydrated LayerChart bounds:
| Chart | SSR size | Hydrated size | Position difference |
|---|---|---|---|
| Languages | 270 × 270 px | 270 × 270 px | 0.3 px rounding |
| Editors | 288 × 288 px | 288 × 288 px | 0.3 px rounding |
| Operating Systems | 288 × 288 px | 288 × 288 px | 0.3 px rounding |
| Chart | SSR size | Hydrated size | Position difference |
|---|---|---|---|
| Languages | 270 × 270 px | 270 × 270 px | 0.3 px rounding |
| Editors | 288 × 288 px | 288 × 288 px | 0.3 px rounding |
| Operating Systems | 288 × 288 px | 288 × 288 px | 0.3 px rounding |
There is no visible resize or recentering during the handoff.
Bundle-size comparison
I built clean production bundles for committed LayerChart v1 and the final v2 migration, then recursively traversed the Vite manifest from pages/Home/SignedIn.svelte.
I also changed the imports to layerchart/svg, since Hackatime only uses SVG charts.
Signed-in dashboard assets actually required by the route
| V1 | V2 | Change | |
|---|---|---|---|
| JS, raw | 822,193 B | 749,899 B | −72,294 B, −8.79% |
| JS, gzip | 277,200 B | 254,707 B | −22,493 B, −8.11% |
| CSS, raw | 929 B | 8,975 B | +8,046 B |
| CSS, gzip | 497 B | 3,228 B | +2,731 B |
| Combined, raw | 823,122 B | 758,874 B | −64,248 B, −7.81% |
| Combined, gzip | 277,697 B | 257,935 B | −19,762 B, −7.12% |
| V1 | V2 | Change | |
|---|---|---|---|
| JS, raw | 822,193 B | 749,899 B | −72,294 B, −8.79% |
| JS, gzip | 277,200 B | 254,707 B | −22,493 B, −8.11% |
| CSS, raw | 929 B | 8,975 B | +8,046 B |
| CSS, gzip | 497 B | 3,228 B | +2,731 B |
| Combined, raw | 823,122 B | 758,874 B | −64,248 B, −7.81% |
| Combined, gzip | 277,697 B | 257,935 B | −19,762 B, −7.12% |
The SVG subpath saves another 11.7 KB gzip compared with importing v2 from the layer-agnostic root.
V2 is more fragmented:
- V1: 41 JS and 2 CSS files
- V2: 74 JS and 10 CSS files
The complete emitted application bundle, including chunks for routes the dashboard does not load, grows by 38.3 KB gzip, 8.6%. The user-facing dashboard closure is the more relevant number and is 19.8 KB gzip smaller.
Runtime performance
Medians from five unthrottled cold loads:
| Metric | V1 | V2 | Change |
|---|---|---|---|
| TTFB | 77 ms | 58 ms | −19 ms |
| FCP / LCP | 220 ms | 192 ms | −28 ms |
| Window load | 253 ms | 269 ms | +16 ms |
| Interactive charts | 666 ms | 988 ms | +322 ms |
| Metric | V1 | V2 | Change |
|---|---|---|---|
| TTFB | 77 ms | 58 ms | −19 ms |
| FCP / LCP | 220 ms | 192 ms | −28 ms |
| Window load | 253 ms | 269 ms | +16 ms |
| Interactive charts | 666 ms | 988 ms | +322 ms |
Under heavy throttling:
| Metric | V1 | V2 | Change |
|---|---|---|---|
| FCP / LCP | 2.67 s | 2.62 s | −0.05 s |
| Window load | 3.10 s | 3.24 s | +0.14 s |
| Interactive charts | 8.05 s | 9.48 s | +1.43 s |
| Metric | V1 | V2 | Change |
|---|---|---|---|
| FCP / LCP | 2.67 s | 2.62 s | −0.05 s |
| Window load | 3.10 s | 3.24 s | +0.14 s |
| Interactive charts | 8.05 s | 9.48 s | +1.43 s |
So v2 paints the real SSR chart just as quickly or slightly faster, but tooltips become available later. Users see the complete chart and legend throughout that delay, not a skeleton or blank area.
Verdict
The LayerCake patch can safely be removed:
- LayerCake is no longer installed.
- SSR content remains deterministic.
- No dashboard skeleton appears.
- Static-to-interactive handoff has no blank frame.
- CLS is zero.
- Pie dimensions and position remain stable.
- The dashboard route is 7.1% smaller gzip.
The tradeoff is later chart interactivity: roughly +322 ms normally and +1.43 s under severe throttling. Given that the actual chart is already visible and stable during this period, I do not consider it a blocker, but it is the measurable performance regression in this migration.
The updated SSR-enabled portal remains available: Hackatime portal.
layerchart/svg is a specialised entry point
It is not a different LayerChart version. It selects a renderer at build time.
// Layer-agnostic entry point
import { PieChart, BarChart } from "layerchart";
// SVG-specialised entry point
import { PieChart, BarChart } from "layerchart/svg";
The default entry point supports choosing SVG, Canvas or HTML at runtime:
layerchart
│
▼
PieChart.base
│
├── Chart dispatcher ──┬── SVG
│ ├── Canvas
│ └── HTML
│
└── Arc dispatcher ────┬── SVG
└── Canvas
The SVG entry point injects the SVG implementations directly:
layerchart/svg
│
▼
PieChart.base
│
├── Chart.svg
├── Arc.svg
├── Group.svg
└── Pie.svg
That removes runtime renderer selection from the main chart path and lets Vite exclude more Canvas and HTML mark implementations.
It does not mean everything becomes SVG:
- Legends are HTML.
- Tooltips are HTML overlays.
Tooltipis exactly the same implementation from both entry points.- Shared
ChartStatestill contains Canvas-aware infrastructure. TooltipContextcurrently imports an agnostic clip-path component.- Some optional features remain dynamic imports.
That is why a couple of Canvas/HTML-named chunks remain in the manifest. One is genuinely shared plumbing and one contains actual HTML UI such as legends/tooltips. A chunk’s name also does not necessarily mean that renderer executes.
What the SVG entry point saved
Compared with the original v2 root import:
| V2 import | Dashboard gzip |
|---|---|
layerchart |
269,596 B |
layerchart/svg |
257,935 B |
| Difference | −11,661 B, −4.3% |
| V2 import | Dashboard gzip |
|---|---|
layerchart |
269,596 B |
layerchart/svg |
257,935 B |
| Difference | −11,661 B, −4.3% |
Interactive readiness improved only slightly:
| Environment | Root import | SVG import | Improvement |
|---|---|---|---|
| Normal | 1,026 ms | 988 ms | 38 ms |
| Heavily throttled | 9.58 s | 9.48 s | 0.11 s |
| Environment | Root import | SVG import | Improvement |
|---|---|---|---|
| Normal | 1,026 ms | 988 ms | 38 ms |
| Heavily throttled | 9.58 s | 9.48 s | 0.11 s |
So renderer dispatch was part of the cost, but clearly not the principal reason for the v1-to-v2 difference.
Why v2 becomes interactive later
The short answer is: v2 transfers less code but performs more client-side initialisation.
Gzip size measures network transfer. It does not measure:
- Number of modules to resolve and evaluate
- Number of Svelte components instantiated
- Reactive state and effects created
- DOM measurement passes
- Dynamic import boundaries
- Mark-registration and scale-settling work
1. The module graph is much more fragmented
For the signed-in dashboard:
| V1 | V2 SVG | |
|---|---|---|
| JS chunks | 41 | 74 |
| CSS files | 2 | 10 |
| Combined gzip | 277.7 KB | 257.9 KB |
| V1 | V2 SVG | |
|---|---|---|
| JS chunks | 41 | 74 |
| CSS files | 2 | 10 |
| Combined gzip | 277.7 KB | 257.9 KB |
V2 saves about 20 KB gzip, but the browser must resolve, request, parse and link many more modules.
V1 largely delivered LayerChart and LayerCake as one 401 KB raw shared chunk. V2 splits chart core, primitives, SVG implementations, state, tooltip, legend, motion and optional features into separate chunks.
This explains some of the throttled-network difference, though not all of the normal local difference.
2. V2 replaced LayerCake with a larger internal state system
V1 delegated most sizing, domains, ranges and scales to one LayerCake context backed by Svelte stores.
V2 creates its own ChartState for every chart. Each one constructs or coordinates:
ChartStateGeoStateSeriesState- Scale and domain derivation
- Resize state
- Selection and highlight state
- Tooltip state and hit testing
- Brush and transform capability
- Chart-level motion
- Multiple rune-backed effects
Hackatime renders four LayerChart roots, so that setup occurs four times.
3. Marks now register themselves into a component tree
V2 added automatic mark registration and implicit series support.
Each bar, arc and related component can:
- Find its parent chart context
- Create a component-tree node
- Register mark metadata
- Establish cleanup effects
- Invalidate derived series/domains
- Participate in a subsequent reactive settling pass
That makes LayerChart v2 much more composable, but it is work that the simpler LayerCake-based v1 path did not perform.
4. Pie charts initialise more than their visible arcs
Each v2 PieChart also prepares:
- Animated pie geometry state
- Colour domains and series
- Legend state and formatting
- Tooltip components
- Tooltip positioning and hit testing
- Highlight and selection derivations
The SVG entry point avoids Canvas arc rendering, but none of the above is renderer-specific.
5. Our readiness condition includes measurement
Hackatime keeps the SSR chart visible until LayerChart calls onResize with a real container height:
if (containerHeight > 100) interactiveReady = true;
So the measured “interactive” time includes:
- Downloading and evaluating LayerChart
- Hydrating the component tree
- Constructing chart state
- Running initial reactive effects
- Measuring the container
- Publishing the resize event
- Replacing the static chart
That is a meaningful user-facing definition because it is when tooltips become usable, but it is later than merely evaluating the module.
What the measurements imply
Normal-load medians:
| Metric | V1 | V2 SVG |
|---|---|---|
| FCP/LCP | 220 ms | 192 ms |
| Interactive chart | 666 ms | 988 ms |
| FCP-to-interactive interval | 446 ms | 796 ms |
| Metric | V1 | V2 SVG |
|---|---|---|
| FCP/LCP | 220 ms | 192 ms |
| Interactive chart | 666 ms | 988 ms |
| FCP-to-interactive interval | 446 ms | 796 ms |
V2 displays the SSR chart about as quickly or slightly faster, but spends roughly 350 ms longer after first paint building the interactive chart.
Under heavy throttling, that post-paint interval grows from about 5.38 s to 6.85 s.
The evidence points to two causes:
- More fragmented loading, especially under latency.
- More substantial v2 chart-state/component initialisation, which explains why a difference remains even on local unthrottled loads.
The SVG entry point addresses the first and removes runtime renderer dispatch, but cannot remove the second.
Is that concerning?
It is a real regression in tooltip readiness, but not in visual rendering or Core Web Vitals:
- FCP/LCP is unchanged or slightly better.
- CLS remains 0.
- A complete real chart is visible from SSR.
- There is no skeleton or blank period.
- Geometry does not change during hydration.
- The route transfers 7.1% less gzip overall.
If we want to investigate the remaining ~322 ms further, the next useful step is a Chrome performance trace with controlled ablations:
- Disable tooltip contexts.
- Disable legends.
- Disable motion.
- Compare each result against the full chart.
That would quantify which v2 subsystem dominates rather than attributing the remainder broadly to ChartState.
Yes.
The only direct d3-shape usage is the hand-built SSR fallback in PieChart.svelte. We can replace it with the real PieChart from layerchart/svg using:
<PieChart
ssr={true}
width={480}
height={300}
...
/>
LayerChart v2 gives explicit dimensions precedence over browser measurements, so it natively protects the invariant that required the old LayerCake patch.
That lets us remove:
- The custom
d3arc/d3piefallback staticArcs- The duplicated static legend
hasMountedandinteractiveReady- The direct
d3-shapedependency
Caveat: LayerChart itself still uses D3 packages internally. We would be removing Hackatime’s direct D3 chart implementation and dependency, not eliminating D3 from the transitive dependency tree.
We’ll need to preserve responsive scaling around the fixed logical SVG dimensions and rerun the same SSR/CLS geometry checks.
LayerChart migration complete
Direct D3 implementation removed
The pie charts now use one SSR-capable PieChart from layerchart/svg. I removed:
- The custom
d3-shapearc and pie generation - The duplicated static SVG and legend
- The mount/resize hand-off state
- The direct
d3-shapedependency - The old LayerCake patch configuration and
layercake@8.4.3.patch
LayerChart still uses D3 internally, but Hackatime no longer imports or maintains a D3 chart implementation directly.
The native chart uses a fixed 300 × 300 SSR coordinate system and initial CSS scales LayerChart’s root, tooltip layers and SVG to the responsive card. This preserves deterministic geometry without overflowing desktop or 390px mobile layouts.
SSR, layout and interaction verification
Tested both with JavaScript blocked and after hydration:
- Three pie charts and the timeline are present in server-rendered HTML
- SSR path counts: 8 language, 4 editor and 3 OS slices
- All legends are in the SSR response
- SSR and hydrated SVG paths are byte-for-byte identical
- All three root and SVG bounding boxes are identical before and after hydration
- No dashboard skeleton appeared
- No chart blanking occurred after SSR content was displayed
- Chart-attributable CLS: 0
- Full-page CLS was
0.0061, but trace sources were unrelated webfont shifts in navigation and text elements. No chart appeared among the shift sources - Tooltip interaction works after hydration, for example
CSS 34m - Desktop and 390px mobile charts have no horizontal overflow
- No page errors were observed
Performance ablations identify the actual bottleneck
Seven isolated cold sessions were measured per variant. The trace probe waited for real tooltip interaction, not merely SSR selectors.
| Variant | Median FCP | Tooltip ready | Total long-task time | Difference |
|---|---|---|---|---|
| Full chart | 888 ms | 881 ms | 786 ms | baseline |
| All tooltip contexts disabled | 856 ms | N/A | 754 ms | −32 ms |
| Pie legends disabled | 892 ms | 876 ms | 778 ms | −8 ms |
| Motion disabled | 912 ms | 893 ms | 795 ms | noise |
| Timeline omitted, diagnostic only | 496 ms | 471 ms | 154 ms | −632 ms |
| Variant | Median FCP | Tooltip ready | Total long-task time | Difference |
|---|---|---|---|---|
| Full chart | 888 ms | 881 ms | 786 ms | baseline |
| All tooltip contexts disabled | 856 ms | N/A | 754 ms | −32 ms |
| Pie legends disabled | 892 ms | 876 ms | 778 ms | −8 ms |
| Motion disabled | 912 ms | 893 ms | 795 ms | noise |
| Timeline omitted, diagnostic only | 496 ms | 471 ms | 154 ms | −632 ms |
Conclusions:
- Tooltip contexts contribute approximately 30 ms, mostly from the timeline rather than the pies.
- Legends contribute approximately 8 ms.
- Motion is not responsible. Pie arcs already use LayerChart’s no-motion fast path when motion is unspecified, while the timeline tooltip already specifies
"none". - Omitting the timeline makes pie interaction ready approximately 409 ms earlier and removes around 80% of measured long-task time.
- An uninstrumented CPU profile agrees:
Rect.svg: approximately 128 ms self timeBar.svg: approximately 34 ms- Shared
Chart.svg: approximately 29 ms ProjectTimelineChart: approximately 17 msPieChart: approximately 5 msArc.svg: approximately 5 ms
So the previous ~322 ms regression was not dominated by tooltip, legend or pie ChartState work. It is primarily contention from LayerChart v2 hydrating the stacked project timeline, especially its many reactive SVG rectangles. The custom D3 fallback also added roughly another 100 ms to the old readiness measurement; native SSR eliminates that hand-off.
I left the timeline enabled and full chart behaviour intact. The no-timeline variant was diagnostic only.
Bundle size improved again
Signed-in dashboard route closure:
| Asset | Final raw | Final gzip |
|---|---|---|
| JavaScript | 748,357 B | 254,007 B |
| CSS | 8,975 B | 3,228 B |
| Combined | 757,332 B | 257,235 B |
| Asset | Final raw | Final gzip |
|---|---|---|
| JavaScript | 748,357 B | 254,007 B |
| CSS | 8,975 B | 3,228 B |
| Combined | 757,332 B | 257,235 B |
Compared with LayerChart v1:
- Raw:
823,122 → 757,332 B, −7.99% - Gzip:
277,697 → 257,235 B, −7.37%
Removing the direct D3 implementation saved another 1,542 raw bytes / 700 gzip bytes over the earlier v2 SVG result.
Checks
Passed:
bun run check:svelte: 0 errors, 3 existing warningsbun run format:svelte:checkbun run knip- Forced client Vite build
- Forced SSR Vite build
- Production Vite build and bundle traversal
git diff --check
The production build emitted a non-fatal Rails initialisation warning for the placeholder R2 URI, but Vite completed and generated a fresh manifest. I have not rerun the entire bin/ci suite; I recommend doing that before merging.
The timeline is hydration-heavy, not render-heavy
The timeline currently contains 12 weeks × 5 project series = 60 stacked marks. Four series render as 48 <rect> elements and the rounded top series renders as 12 <path> elements.
LayerChart v2 builds each mark through this component chain:
BarChart
└── 5 × Bars
└── 12 × Bar
└── 12 × Rect.svg
For every one of those 60 segments, LayerChart creates:
- A
BarSvelte component andBarState - A
Rect.svgSvelte component andRectState - Roughly 40 reactive derivations across those two state objects
- Chart and geo context lookups
- Scale/accessor calculations for x, y, width and height
- Corner and path calculations
- A chart component-tree registration node
- Cleanup and element-binding effects
- Multiple reactive prop-proxy spreads
That is roughly 120 nested component instances and around 2,000–3,000 reactive graph nodes to hydrate for a chart with only 60 visible marks.
The profile corroborates this:
Rect.svg: approximately 128 ms self timeBar.svg: approximately 34 ms- Most of the remaining work was inside Svelte effect/microtask flushing
- Layout was only approximately 59 ms
- Tooltip, legends and motion ablations barely changed the result
- Omitting the timeline removed approximately 632 ms of long-task time
So the expensive part is not stacking the values or drawing SVG. It is constructing and flushing LayerChart v2’s per-mark Svelte state graph.
Why SSR does not solve it
SSR makes the chart visible immediately and explains why we measured:
- No skeleton
- No chart-attributable CLS
- Identical visible chart geometry through hydration
But the browser must still hydrate every Bars, Bar and Rect.svg instance. That hydration happens in the same Svelte microtask sequence as the rest of the dashboard, so it also delays unrelated interactions such as pie tooltip readiness.
Why motion made no difference
No bar motion is configured. LayerChart’s createMotion() detects undefined and uses its lightweight getter-only fast path. The timeline’s explicit motion: "none" applies to the tooltip root, not the bars.
Therefore disabling motion again cannot remove the expensive per-bar BarState/RectState construction.
The v2-specific regression
LayerChart v1 also used a component per bar, but v2 replaced its comparatively direct reactive component implementation with general-purpose state classes and a chart component-registration tree. That architecture supports multiple rendering layers, automatic mark discovery, dynamic series, motion and composition, but the machinery is instantiated even for this straightforward pixel-positioned stacked chart.
The strongest concrete culprit is therefore:
LayerChart v2’s per-mark
BarState+RectStatehydration overhead, multiplied by timeline cardinality.
The SVG itself is not intrinsically slow. Sixty plain SVG rectangles/paths should be cheap. If we need to optimise this next, the useful direction is a lower-level LayerChart composition that retains LayerChart scales and SVG mode but renders the stacked segments in one Svelte {#each} instead of instantiating Bar and RectState for every segment. That would avoid restoring the direct D3 implementation while bypassing the expensive high-level BarChart mark graph.
The best approach is to batch the timeline marks.
Recommended: one collection Rect instead of 60 Bar components
LayerChart v2’s Rect supports a data-driven collection mode:
<Rect
data={segments}
x={(segment) => segment.x}
y={(segment) => segment.y}
width={(segment) => segment.width}
height={(segment) => segment.height}
fill={(segment) => segment.color}
/>
One Rect component then renders all the SVG elements internally. We can override BarChart’s marks snippet and use that instead of its default hierarchy:
Current
BarChart
└── 5 × Bars
└── 60 × Bar
└── 60 × RectState
Proposed
BarChart
└── 1–2 × RectState
└── 60 SVG rect/path elements
We may need two collection Rects:
- Ordinary stacked segments
- Top segments with rounded upper corners
This preserves:
- LayerChart’s scales and stacking
- Axes and grid
- Band tooltip behaviour
- SSR
- Existing appearance
- SVG output
- No direct D3 implementation or dependency
But it removes almost all of the per-segment Svelte component and reactive-state overhead that appeared in the profile.
Other options
Reduce cardinality
Cap the timeline to fewer weeks or project series. Cost scales approximately with:
weeks×seriesThis is easy but sacrifices detail, so I would only do it as a secondary measure.
Render raw SVG in the marks snippet
We could emit <rect> and <path> directly while using LayerChart’s context scales. This would be the absolute minimum overhead, but it duplicates more LayerChart geometry behaviour. The collection Rect approach is a better first attempt.
Canvas mode
Probably not worthwhile here. The profile says DOM layout and painting are not dominant. Canvas may reduce SVG DOM nodes, but LayerChart would still instantiate much of the per-mark component/state machinery unless we batch the marks anyway.
Defer hydration
We could hydrate the timeline when idle or when it enters the viewport. That would improve initial responsiveness, but merely moves the CPU cost later and complicates tooltip availability. Batching fixes the underlying cost instead.
Suggested next step
Implement the collection-mode Rect marks override, then rerun the same cold-session benchmark and verify:
- Timeline appearance before and after hydration
- Tooltip behaviour
- CLS remains zero
- Rectangle/path count remains 60
- Long-task time drops substantially
Given the profile, this is the change most likely to recover most of the timeline’s approximately 632 ms contribution without abandoning LayerChart.
Batching makes a substantial difference
I replaced the high-level BarChart marks with a lower-level Chart plus collection-mode Rect.
The timeline now creates:
- 5
RectStateinstances, one per project series - The same 60 visible SVG marks
- No per-segment
BarState - No per-segment
RectState
Cold-load results
Compared with the previous seven-run full-chart baseline:
| Metric | Default BarChart |
Batched marks | Change |
|---|---|---|---|
| Median FCP | 888 ms | 728 ms | −160 ms, −18% |
| Median long-task time | 786 ms | 431 ms | −355 ms, −45% |
| SVG marks | 60 | 60 | No change |
| Metric | Default BarChart |
Batched marks | Change |
|---|---|---|---|
| Median FCP | 888 ms | 728 ms | −160 ms, −18% |
| Median long-task time | 786 ms | 431 ms | −355 ms, −45% |
| SVG marks | 60 | 60 | No change |
The FCP median had five valid paint samples; long-task time had all seven.
Previously, removing the timeline entirely reached 154 ms of long-task time. Batching therefore recovered approximately 56% of the timeline’s measured overhead while retaining the chart.
The remaining difference above the no-timeline result is about 277 ms, likely from the shared ChartState, axes, grid, stack calculation, tooltip context and the five collection RectStates.
Behaviour verified
- SSR is working again
- 48 ordinary rectangles and 12 rounded paths render
- Stacked geometry and colours look correct
- The band tooltip still displays all projects and the total
- Chart CLS remains
0 - No browser console errors
svelte-check: 0 errors, 3 pre-existing warnings- Prettier, Knip and
git diff --checkpass - Client and SSR production builds pass
One finding along the way: using BarChart’s custom marks prop triggers infinite recursion in LayerChart v2’s SSR output. Using the lower-level Chart avoids that LayerChart bug and preserves SSR.
The updated chart is available in the portal.
Fixed. The tooltip panel existed, but LayerChart’s light-dark(...) background declaration was being incorrectly transformed by Lightning CSS, so Chrome discarded it as invalid.
I added a theme-aware override in application.css matching LayerChart’s intended styling:
- Dark themes use
surface-300at 90% opacity - Light themes use
surface-100at 90% opacity
Verified:
- Tooltip now has a visible panel background
- Correct colours under dark, GitHub Light and Rosé Pine Dawn themes
- Client build passes
- Prettier and
git diff --checkpass
The fix is live in the portal.
The full timeline test passes, with one formatting issue
I added 600 development-only fixture heartbeats to the orb database:
- 12 consecutive weeks
- 5 projects in every week
- Varying project durations
- Existing seeded activity retained in the latest weeks
- No fixture or seed files changed
Verified
- All 12 weeks render visible stacked bars
- Each fixture week has 5 non-zero segments
- 120 total SVG marks: 108 rectangles and 12 rounded top paths
- Rounded top corners render correctly
- No chart or document overflow at desktop or 390 px mobile
- Chart-attributable CLS remains
0 - Tooltip data is correct at the beginning, middle and end:
May 25–31: 5 projects, total 1h 30m
Jun 29–Jul 5: 5 projects, total 1h 30m
Aug 10–16: 7 projects, total 1h 40m
The tooltip backgrounds also remain visible.
Issue revealed by the better dataset
The y-axis formatter produces duplicate labels when ticks fall within the same hour:
0m, 33m, 1h, 1h, 2h, 2h, 3h, 3h, 4h
The underlying scale and bar geometry are correct. This is because secondsToCompactDisplay discards minutes once a value reaches one hour. It predates the batching work, but the denser dataset makes it obvious.
The populated timeline remains available in the portal.
Fixed the duplicate y-axis labels by using the precise duration formatter for timeline ticks.
The full 12-week dataset now shows distinct labels:
0m
33m
1h 6m
1h 40m
2h 13m
2h 46m
3h 20m
3h 53m
4h 26m
Verified:
- Labels are unique and readable
- Stacked geometry remains unchanged
- No chart or document overflow
- CLS remains
0 - Client and SSR builds pass
svelte-check: 0 errors, 3 pre-existing warnings- Prettier and
git diff --checkpass
The denser test also exposed a separate pre-existing mobile issue: all 12 x-axis week labels overlap at 390 px. Desktop is fine. The y-axis fix is live in the portal.
Fixed by enabling LayerChart’s responsive tickSpacing for the timeline x-axis.
At 390 px:
- 4 readable labels:
May 25,Jun 15,Jul 6,Jul 27 - No label overlap
- All 12 stacked bars remain visible
- No chart or document overflow
- CLS remains
0
At desktop width:
- All 12 week labels remain visible
- No overlap or clipping
Client and SSR builds pass, and svelte-check reports 0 errors with the same 3 pre-existing warnings. The fix is live in the portal.
Pull request opened
PR #1545: Migrate dashboard charts to LayerChart v2
- Rebased onto the latest
origin/main - Resolved the generated
bun.lockconflict and retained LayerChart 2.0.4 - Committed and pushed all changes as
e4bd7b8c - Included bundle size, FCP, long-task time, component and reactive-node estimates, SVG node counts and CLS measurements in the PR description
- Included the populated 12-week timeline screenshot
- Confirmed the local branch is clean and matches the remote
Full CI completed after the rebase. All checks succeeded except the unrelated, reproducible ProfilesTest#test_public_profile_renders_visible_bio visibility assertion. It fails identically when the system suite is run independently.
The current Amp thread has been archived.
Valid and fixed
The review comment was correct. Rounding was assigned by the series’ global position rather than the highest non-zero series for each week.
The timeline now:
- Identifies the highest visible, non-zero series independently for each week
- Excludes zero-height segments from mark rendering
- Splits each series into square internal segments and rounded top segments
- Applies corners only to the actual visible top segments
Verification:
- Client Vite build completed
- SSR Vite build completed
svelte-check: 0 errors, 3 pre-existing warnings- Prettier check completed
git diff --checkcompleted
Committed and pushed to PR #1545 as 65dc75f3.
PR updated
Fixed and pushed the profile regression in commit 46315681:
- Timelines with week data but no project series no longer initialise LayerChart v2.
- This prevents the browser exception that stopped Inertia mounting and left the profile bio hidden in the page payload.
- The existing profile system test remains unchanged and now passes.
Validation:
- Profile regression test: 1 test, 1 assertion, 0 failures
- Full system suite: 42 tests, 220 assertions, 0 failures
- Svelte check: 0 errors, 3 pre-existing warnings
- Svelte formatting: passed
The pull request now includes explicit pre-migration and final post-migration figures:
| Metric | LayerChart v1 | Final LayerChart v2 | Change |
|---|---|---|---|
| Combined raw | 823,122 B | 745,818 B | 77,304 B smaller, 9.39% |
| Combined gzip | 277,697 B | 253,570 B | 24,127 B smaller, 8.69% |
| Timeline SVG marks | 60 | 60 | Unchanged |
| Chart-attributable CLS | 0 | 0 | Unchanged |
| Metric | LayerChart v1 | Final LayerChart v2 | Change |
|---|---|---|---|
| Combined raw | 823,122 B | 745,818 B | 77,304 B smaller, 9.39% |
| Combined gzip | 277,697 B | 253,570 B | 24,127 B smaller, 8.69% |
| Timeline SVG marks | 60 | 60 | Unchanged |
| Chart-attributable CLS | 0 | 0 | Unchanged |
It also documents:
- The initial v2 migration's 322 ms interaction regression
- The Chrome profile and subsystem ablations used to diagnose it
- The final batching improvement of 18% median FCP and 45% median long-task time versus default v2
BarChart - The benchmark protocol caveat, avoiding an unsupported claim that final v2 runtime is faster than v1
The branch is clean, pushed and matches the PR head. The thread has been archived as requested.
Removed the stale COPY patches patches instructions from both:
DockerfileDockerfile.dev
Verified successfully with:
docker compose build web- Production
javascript-dependenciesDocker target build
Committed and pushed as 76b1bff3 (Remove stale patch copies from Docker builds). The branch is clean and matches the remote PR branch.
Started a high-mode benchmark investigation in a fresh orb.
It was instructed to:
- Install Docker and any other required dependencies using
.agents/setup - Benchmark a fair pre-migration v1 baseline against final v2
- Use matching commits, fixtures, production builds, browser versions and cache/CPU/network conditions
- Publish raw samples, medians and spread statistics across repeated runs
- Measure rendering, interactivity, long tasks, CLS, bundle size and node counts
- Use Chrome traces and controlled one-at-a-time ablations
- Implement and commit only improvements supported by repeatable results
- Document everything needed to reproduce the benchmark
The high-mode orb will reply to this thread when it finishes.
Benchmark received and critical fix pushed
The high-mode benchmark found that PR #1545’s explicit PieChart ssr={true} recursively invoked LayerChart’s marks snippet. This caused a stack overflow, blank SSR fallback and extreme FCP regression:
| Metric | v1 | Original v2 |
|---|---|---|
| Cold FCP | 400 ms | 4,804 ms |
| Warm FCP | 248 ms | 3,464 ms |
| Metric | v1 | Original v2 |
|---|---|---|
| Cold FCP | 400 ms | 4,804 ms |
| Warm FCP | 248 ms | 3,464 ms |
I removed ssr={true}, committed it as bfd2285f and pushed it to PR #1545. The fix restores progressive SSR paint.
Validation completed:
- Svelte check: 0 errors, 3 unrelated existing warnings
- Svelte formatting: passed
- Knip: passed
- Test-environment Vite build: passed
- The benchmark orb’s production image and browser interaction checks passed
Fair v1 versus fixed-v2 result
After the fix, there is no supported FCP difference:
| Metric | v1 | Fixed v2 |
|---|---|---|
| Cold FCP | 378 ms | 352 ms |
| Warm FCP | 224 ms | 214 ms |
| Cold tooltip interaction | 3,809 ms | 6,333 ms |
| Warm tooltip interaction | 2,897 ms | 5,044 ms |
| Metric | v1 | Fixed v2 |
|---|---|---|
| Cold FCP | 378 ms | 352 ms |
| Warm FCP | 224 ms | 214 ms |
| Cold tooltip interaction | 3,809 ms | 6,333 ms |
| Warm tooltip interaction | 2,897 ms | 5,044 ms |
Fixed v2 remains materially slower for interaction:
- +2,524 ms cold, or 66.3%
- +2,146 ms warm, or 74.1%
- Long-task time: +77.5% cold, +87.4% warm
- All 12 of 12 samples agreed on the regression direction
- Median CLS remains 0
The selected route closure is 8.58% smaller gzip, but the wider emitted asset union is larger and resource count increases from 51 to 93.
The PR description now contains the authoritative methodology, raw conclusions, bundle measurements and caveats. It characterises the migration correctly as a maintainability and selected-route-bundle trade-off, not a performance improvement.
The strongest unproven next target is LayerChart v2’s tooltip structure: it creates 216 timeline hit regions versus v1’s 12. Traces indicate scripting and reactive work dominate, but the benchmark has not yet proven how much reducing those regions would recover.
Agreed. I’ve restarted the high-mode benchmark investigation with a focused optimisation brief.
We have already identified the exact reason for the tooltip-region explosion:
- LayerChart v2
bandmode iterates over expandedflatData, not unique weeks. - The timeline has 12 aggregate week rows plus 204 stacked segment records.
- That produces 216 overlapping SVG hit rectangles.
- Every rectangle is Svelte-owned and carries pointer handlers.
Tooltip.Rootis not creating them.
The benchmark orb is now testing, in order:
Manual mode with 12 week regions
- One transparent interaction rectangle per week
- Preserve LayerChart tooltip state, highlighting and exact breakdown
- Matches v1’s region count
bisect-x- One chart-wide pointer surface
- Zero hit rectangles
- Must verify categorical band selection and expanded-data ordering
quadtree-x- Removes the DOM explosion
- Still indexes all 216 records, so likely less effective
Custom lightweight tooltip
- Disable LayerChart tooltip context entirely
- One plot-wide pointer handler
- Update local state only when the selected week changes
I also asked it to benchmark independent timeline, pie-chart and axis/text ablations. That will prevent us from incorrectly blaming all 2.4 seconds on tooltip regions if the three pie charts or shared LayerChart hydration are also significant.
Each candidate must retain:
- Exact project values and totals
- All 204 timeline segments
- Hover and pointer-leave behaviour
- Legend filtering
- Responsive labels
- Highlighting
- CLS of 0
Nothing will be accepted without repeated measurements against the unchanged fixed-v2 control. The benchmark thread will report back here with the proven implementation and commit.
Much faster, and now faster than v1
The proven optimisation is committed and pushed to PR #1545 as 4f106242.
Root cause
LayerChart’s collection <Rect> did more than render SVG:
- It registered all 204 timeline segments in LayerChart’s reactive graph.
TooltipContextexpanded from 12 week records to 216 records.- LayerChart then hydrated 204 marks and 216 tooltip regions.
Rect.svg, tooltip expansion, style calculation and garbage collection dominated the trace.
The fix keeps LayerChart’s chart, scales, stacking, axes, tooltip and highlighting, but emits the segments as native SVG:
- 192 native
<rect>marks - 12 rounded native
<path>marks - 12 tooltip regions instead of 216
- No custom tooltip protocol or deferred work
Results
Optimised v2 versus previous fixed v2
| Metric | Cold improvement | Warm improvement |
|---|---|---|
| Trusted interaction | 3,372 ms faster, 46.9% | 3,905 ms faster, 62.1% |
| Long-task total | 58.0% lower | 68.5% lower |
| Scripting | 70.0% lower | 77.0% lower |
| Chart ready | 49.5% faster | 64.1% faster |
| Metric | Cold improvement | Warm improvement |
|---|---|---|
| Trusted interaction | 3,372 ms faster, 46.9% | 3,905 ms faster, 62.1% |
| Long-task total | 58.0% lower | 68.5% lower |
| Scripting | 70.0% lower | 77.0% lower |
| Chart ready | 49.5% faster | 64.1% faster |
Optimised v2 versus fair v1 baseline
| Metric | Cold | Warm |
|---|---|---|
| Trusted interaction | 847 ms faster, 18.1% | 2,317 ms faster, 49.3% |
| Long-task total | 31.1% lower | 53.4% lower |
| Scripting | 35.6% lower | 49.4% lower |
| Chart ready | 20.6% faster | 47.2% faster |
| Metric | Cold | Warm |
|---|---|---|
| Trusted interaction | 847 ms faster, 18.1% | 2,317 ms faster, 49.3% |
| Long-task total | 31.1% lower | 53.4% lower |
| Scripting | 35.6% lower | 49.4% lower |
| Chart ready | 20.6% faster | 47.2% faster |
All authoritative groups used 12 cold and 12 warm samples. All 72 measurements were valid, the relevant IQRs were disjoint and the bootstrap intervals excluded zero.
DOM and bundle
| Metric | v1 | Previous v2 | Optimised v2 |
|---|---|---|---|
| DOM elements | 902 | 1,126 | 922 |
| SVG descendants | 436 | 626 | 422 |
| Tooltip regions | 12 | 216 | 12 |
| Visible timeline marks | 204 | 204 | 204 |
| Chrome nodes | 7,222 | 9,099 | 6,489 |
| Metric | v1 | Previous v2 | Optimised v2 |
|---|---|---|---|
| DOM elements | 902 | 1,126 | 922 |
| SVG descendants | 436 | 626 | 422 |
| Tooltip regions | 12 | 216 | 12 |
| Visible timeline marks | 204 | 204 | 204 |
| Chrome nodes | 7,222 | 9,099 | 6,489 |
Optimised v2 remains 23,678 B smaller gzip, 8.52%, than v1. Native marks add only 169 gzip bytes compared with the previous v2 implementation.
Behaviour remained intact
The benchmark verified:
- All 204 segment geometries, fills and strokes
- All labels and axis lines at 390, 768 and 1440 px
- Pixel-identical 1440 px timeline output
- Every week at its centre and both boundaries
- Exact ordered tooltip breakdowns and totals
- Highlight, pointer leave and pointer cancel
- Pie hover and legend hide/restore
- Correct touch tooltip, which is actually improved
- Median CLS of 0
Validation
- Full Rails suite: 583 tests, 2,506 assertions, 0 failures
- Full system suite: 42 tests, 220 assertions, 0 failures
- RuboCop, Zeitwerk, Brakeman, Importmap audit, seeds, docs, Svelte typecheck, Prettier and Knip passed
- Production Docker build passed in the benchmark orb
- Swagger generation passed
bin/ci initially reported red only because rebuilding the development image removed Git’s /app safe-directory setting. Swagger generation itself succeeded, and the final git diff check passed after restoring that container setting.
The PR description now contains the authoritative final benchmark, methodology, DOM comparison, bundle figures and decision. The migration is now a clear performance and maintenance win rather than a trade-off.
Yes. The tooltips were exercised with real browser pointer events, not just inferred from DOM output.
The benchmark verified:
- All 12 weeks
- Each week at its centre
- Each week 0.5 px inside both boundaries
- Correct week heading/range
- Full ordered project breakdown
- Correct per-project durations
- Correct total
- Highlight follows the selected week
- Tooltip hides on pointer leave and pointer cancel
- Touch interaction shows the correct week tooltip
- Legend filtering continues to work
- All 204 visible segments retain matching geometry and data
The optimisation only changes how the visible segment shapes are emitted. LayerChart still owns the tooltip context and receives the original 12 aggregate week records, which is exactly what we want for one tooltip per week. In fact, touch tooltips improve: the previous fixed-v2 implementation exposed none, while the optimised version displays the correct week.
Started a separate high-mode independent verification and optimisation orb.
It has been instructed to:
Independently verify the PR
- Review the complete diff against the exact v1 merge-base
- Reproduce the populated 12-week timeline
- Exercise mouse and touch tooltips across every week and boundary
- Verify exact breakdowns, totals, highlights, legends and empty-series behaviour
- Compare geometry at 390, 768 and 1440 px
- Check SSR, hydration, accessibility and CLS
- Independently rerun the production v1 versus v2 benchmark
- Run relevant CI checks and a production Docker build
Pursue only substantial further improvements
It must profile first and investigate:
- Shared LayerChart
Chartstate - Axes and
Text/Linehydration - Pie charts
- Tooltip and highlight state
- Stacking and scales
- Module/resource loading
- Potential upstream-quality LayerChart or LayerCake patches
An optimisation only qualifies if it achieves approximately:
- At least 15% lower trusted-interaction, long-task or scripting time, with repeated convincing evidence, or
- At least 10% less route gzip, without worsening runtime
It may not claim gains by removing behaviour, weakening readiness checks, hiding or deferring work, reducing fixture data or changing benchmark conditions. Any proven change should be committed locally but not pushed. If nothing substantial remains, it has been told to recommend stopping rather than add complexity.
The orb will report back to this thread when complete.
PR updated and pushed
PR #1545: Migrate dashboard charts to LayerChart v2 is now at aa430e7f.
Added two commits:
44125a41Render timeline axes as native SVGaa430e7fRestore pie chart server rendering
Results
- Native axes reduce scripting by 34.2% cold and 34.0% warm versus the native-mark PR head.
- Long-task time falls 23.6% cold and 24.0% warm.
- Timeline output remains pixel-identical at 390, 768 and 1440 px.
- Route JavaScript remains 8.0% smaller gzip than LayerChart v1 in the measured native-axis candidate.
- LayerChart pie SSR is restored using a narrow 2.0.4 dependency patch that fixes
marksprop/snippet shadowing. - Raw SSR contains all three accessible pie charts.
- Hydration produced no errors.
- Pie hover showed the expected
CSS 4h 31m. - Legend filtering changed 8 arcs to 1 and restored all 8.
- Clean Docker dependency installation applies the patch successfully.
The PR description now includes the pre-migration, native-mark and incremental native-axis benchmark numbers. It also clearly states that the later SSR-patched final SHA was functionally verified but not rerun through the repeated performance harness.
Verification
Full bin/ci passed:
- 583 Rails tests, 2,506 assertions
- 42 system tests, 220 assertions
- RuboCop, Zeitwerk, Brakeman and importmap audit
- Client build, Svelte typecheck, formatting and Knip
- Documentation and Swagger checks
The branch is clean and matches the remote. The requested thread has been archived.
Fixed in follow-up PR
PR #1545 was merged while I was working, so I opened PR #1550: Fix LayerChart rendering regressions.
Changes
- Bar heights: native marks now calculate their stack bounds and y-domain directly from the same weekly values used by tooltips. This guarantees higher totals render taller.
- Y-axis: restored compact labels such as
5h,11hand27h. - Pie flash: disabled pie SSR. The fixed-height cards remain stable while the interactive charts mount, avoiding the oversized pre-hydration pie.
- Patch cleanup: removed the LayerChart patch, patched-dependency configuration and Docker patch-copy steps.
Verification
- Browser audit compared every pair of weekly totals: zero height-ordering failures.
- Refresh produced CLS 0, no browser errors and all three interactive pies.
- Raw SSR contains no pie SVG capable of causing the flash.
- Full CI passed: 583 Rails tests and 42 system tests.
- Clean Docker build passed without the patches directory.
You can review the updated dashboard on the portal.
Fixed and pushed to PR #1550.
The pies now reproduce the v1 behaviour:
- SSR renders stable native SVG pies and legends.
- The interactive LayerChart pies mount invisibly after hydration.
- After two animation frames, the measured client charts replace the static fallbacks.
This preserves SSR without using LayerChart’s problematic SSR path. It also keeps the direct D3 dependency and LayerChart patch removed.
Verified:
- SSR pies contain 8, 4 and 3 arcs, with matching legends.
- Client hydration leaves exactly one visible chart per card.
- Tooltip shows
CSS 4h 31m. - Legend filtering changes 8 → 1 → 8 arcs.
- Repeated refresh measured CLS 0 with no browser errors.
- Targeted system test passes.
Review it on the portal.
Opened a medium-mode independent verification orb: verification thread.
It will verify exact PR head ff4923dd, including:
- The 26h versus 31h timeline case
- Compact v1-style y-axis labels
- Raw SSR and no-JS pie output
- SSR-to-interactive hydration handoff frame by frame
- All pie tooltips and legend interactions
- Repeated cold-load CLS measurements
- Empty, zero-value and single-slice data
- 390, 768 and 1440 px layouts
- Clean Docker build and relevant repository checks
- Absence of direct D3 and obsolete patch requirements
It will report back here when complete.
Independent verification passed
The medium orb verified exact PR #1550 head ff4923dd with no blockers.
Key results:
- 26h bar: 254.28 px
- 31h bar: 303.18 px
- All ten populated weeks increased strictly with their totals.
- Every tooltip matched the fixture and rendered stack exactly.
- Rounded corners always appeared on the actual top non-zero segment.
- X-axis labels aligned within 0.30 px.
- Y-axis labels match v1 style:
0m, 5h, 11h, 16h, 22h, 27h, 33h.
Pie handoff passed at 390, 768 and 1440 px:
- Useful pie and legend present in raw no-JS SSR
- No blank frame or oversized flash
- The one-frame DOM overlap is not painted because the client chart remains invisible
- Static and interactive chart geometry is pixel-identical
- Exactly one visible chart after hydration
- All nine tooltips correct
- Every legend filter and restore interaction works
- Empty, zero-value and single-slice cases work
CLS was 0 throughout at 768 and 1440 px. One intermittent 0.011716 shift occurred at 390 px, traced to existing heading/font reflow rather than any chart node.
Clean development and production Docker builds passed, along with Svelte checks, relevant Rails tests and all 42 system tests. Direct D3, patched dependencies and patch-folder build references remain absent.
Full evidence is in the verification thread.
Simplified to one chart
You were right: the fallback and handoff architecture was unnecessarily complicated.
PR #1551 now uses one LayerChart pie for both server rendering and client interaction:
- No duplicate chart trees
- No manual SVG arc generation
- No
onMountstate - No animation-frame handoff or readiness polling
- No disappearing legend
- No direct D3 implementation
PieChart.svelte dropped from 162 to 77 lines, a 52.5% reduction.
The small LayerChart patch now fixes the actual upstream issues:
- The
marksprop/snippet name collision causing SSR recursion - The asynchronously loaded legend causing a hydration gap
Stable positioning CSS prevents the SSR SVG from briefly using the viewport as its containing block.
Verification
Across 999 visible animation frames at 390, 768, 1280 and 1440 px:
- Zero missing-arc frames
- Zero missing-legend frames
- Zero oversized SVG frames after the containment fix
- All three charts remained present
- SVGs never exceeded their chart containers
- Tooltip hover worked
- Legend filtering worked: 8 → 1 → 8 arcs
- Raw SSR contained 3 SVGs, 15 arcs and 15 legend items
Both Docker dependency-install paths successfully applied the patch:
- Development Compose image built successfully
- Production
javascript-dependenciestarget built successfully
Rails tests passed: 583 tests, 2,506 assertions.
System tests passed: 42 tests, 220 assertions.
Frontend checks, RuboCop, Zeitwerk and security scans passed.
bin/ci reports failure only because the Compose container lacks .git, causing its Swagger git diff command to say “Not a git repository”. Swagger generation itself completed with 209 examples and zero failures, and the host worktree confirms no Swagger diff.
The updated app remains available on the portal.
A fresh independent medium-orb verification is also running in the verification thread.
No. The Project Timeline card and heading are SSR-rendered, but its chart content is not.
ProjectTimelineChart.svelte uses LayerChart’s <Chart> without ssr={true}, so the bars, axes and grid appear after client mount. This matches the previous LayerChart v1 behaviour.
The pie charts are different: they explicitly use ssr={true}, so their SVGs and legends render on the server and become interactive during hydration.
Done. The portal now runs the actual production Docker image with:
RAILS_ENV=production- Production Vite assets under
/vite/assets/ - Production Inertia SSR server
- The seeded local database
Verified:
- Rails reports
Environment: production - Inertia reports
SSR server started - Raw authenticated HTML contains 3 pie SVGs, 15 arcs and 15 legend items
- No
vite-devassets are loaded /__devreturns 404, as expected in production- Existing authenticated portal session remains usable
The configuration is committed and pushed to PR #1551 at 7db32ab7.
Use this one-time production sign-in link.
It signs you in as test@example.com and redirects to the dashboard. The link expires after use, but the resulting session remains logged in. Production mode intentionally disables the old /__dev/log-me-in/... endpoint.
Fixed and pushed
The flicker was real. Frame analysis showed a roughly 133 ms, four-frame gap where LayerChart destroyed its SSR chart subtree during hydration before recreating the interactive client chart.
The fix:
- Keeps
ssr={true}LayerCharts mounted across hydration. - Preserves LayerChart’s remount behaviour for client-only charts.
- Removes the initial width animation that made Project Duration bars pass through an empty state.
- Retains SSR pie markup, client tooltips and legend filtering.
Verification
- 10/10 consecutive production SSR refreshes: no disappearing arcs, legends, duration bars or card content.
- Pie tooltip verified:
CSS 4h 31m. - Legend filtering verified:
8 → 1 → 8arcs. - Test Vite production-shaped build passed.
- System tests: 42 runs, 220 assertions, 0 failures.
- Svelte check, formatting and Knip passed earlier against the same changes.
Pushed as commit 1689148f and updated PR #1551 with the diagnosis and refresh-stress results. The independent verification orb is now checking the exact pushed commit too.
Reverted .amp/services.yaml to its original development portal configuration, committed and pushed to PR #1551.
Commit: c11de5fd Restore development portal configuration
Root cause found and fixed
This was a CSS loading race, separate from the earlier hydration remount.
Production SSR emitted all three legends and all 15 legend items, but the initial HTML linked only the application and Inertia stylesheets. LayerChart’s Chart-*.css was requested asynchronously during client startup.
The legend labels have intrinsic dimensions, so they appeared immediately. The coloured swatches rely on LayerChart CSS for their dimensions, so they occasionally appeared one frame later.
Controlled reproduction
With Chart-*.css deliberately blocked:
- All 15 swatches remained in the DOM.
- Their inline colours were present.
- Their measured height was 0 px.
- The labels remained visible, matching the recording exactly.
The fix adds the necessary critical legend layout rules to the application stylesheet. With LayerChart CSS still blocked:
- All swatches measured 16 × 16 px.
- All three legends retained their final layout.
- The application stylesheet increased by only 0.36 kB raw / 0.08 kB gzip.
The fix is in PR #1552.
Validation included Svelte check, formatting, Knip, the test Vite build and a clean production Docker build.
Is v2 worth it?
My current recommendation is yes, narrowly, but this is the last visual regression I would tolerate before reconsidering the pies.
Reasons to retain v2:
- Timeline chart-ready, scripting and long-task metrics improved roughly 29–42% in repeated production runs.
- The selected route closure is approximately 8.5% smaller gzip.
- The native timeline implementation substantially reduces LayerChart component and reactive work.
- Reverting only the pies would require carrying two LayerChart versions or rebuilding interactive pie behaviour ourselves.
Reasons for concern:
- Pie SSR has required three LayerChart patches plus critical first-paint CSS.
- Immediate tooltip response remained approximately 33–38% slower, despite overall chart readiness improving.
- The broader emitted asset union grew roughly 10.8% gzip. Actual cold transfer improved by only about 8.6 kB.
- We have encountered too many user-visible regressions during this migration.
So the honest conclusion is: the timeline performance makes v2 worthwhile, not the bundle result or pie implementation quality. If another pie hydration or first-paint defect remains after PR #1552, I would stop patching and replace or revert the pie path rather than adding another workaround.
It is not normally blocked. I deliberately blocked Chart-*.css in Chromium as a diagnostic experiment.
The actual production sequence is:
- The server returns SSR HTML containing the complete pie and legend markup.
- The initial document links
application-*.cssandinertia-*.css. - LayerChart’s
Chart-*.cssis discovered and requested later when the client-side chart chunk loads. - Usually that request finishes before anyone notices.
- Occasionally the browser paints an intervening frame first. The legend text is visible, but the swatches have no dimensions until
Chart-*.cssarrives.
Blocking the stylesheet permanently reproduced that transient state deterministically: the swatches had their inline colours but were 0 px high. That confirmed delayed CSS, rather than missing data or a hydration remount, was responsible.
PR #1552 moves only the first-paint-critical legend dimensions into the already-linked application CSS. The full LayerChart stylesheet still loads normally afterwards, but the SSR legend no longer depends on its timing.
Best option: native Svelte SVG with d3-shape
Yes, we can use something else for the pies while retaining LayerChart v2 for the timeline.
The strongest option is not another full chart framework. It is a single native Svelte pie renderer using d3-shape only for arc geometry.
| Option | Styling parity | Real SSR | Interactive without replacing SSR SVG | Tooltip |
|---|---|---|---|---|
Svelte + d3-shape |
Exact | Yes | Yes | Yes, locally rendered |
LayerCake + d3-shape |
Exact | Yes | Yes | Locally rendered |
| Framework7 PieChart | Similar | Yes | Yes | Requires Framework7 app infrastructure |
| ECharts | Similar | Yes | Only with limited SSR runtime | Custom tooltip required |
| Vega/Vega-Lite | Similar | Static SSR only | No, client clears SSR content | After client reconstruction |
| Option | Styling parity | Real SSR | Interactive without replacing SSR SVG | Tooltip |
|---|---|---|---|---|
Svelte + d3-shape |
Exact | Yes | Yes | Yes, locally rendered |
LayerCake + d3-shape |
Exact | Yes | Yes | Locally rendered |
| Framework7 PieChart | Similar | Yes | Yes | Requires Framework7 app infrastructure |
| ECharts | Similar | Yes | Only with limited SSR runtime | Custom tooltip required |
| Vega/Vega-Lite | Similar | Static SSR only | No, client clears SSR content | After client reconstruction |
Why native Svelte is materially simpler
It would be one rendering tree:
Server render
Data ──▶ d3-shape ───────────────▶ SVG paths + legend
│ │
│ │ Svelte hydrates
└────────────────────────────▼
Pointer/focus handlers
│
▼
HTML tooltip
d3-shapecalculates deterministicdattributes.- Svelte renders ordinary
<svg>and<path>elements during SSR. - Svelte hydrates those exact nodes and attaches pointer/focus handlers.
- The legend is ordinary Hackatime markup.
- The tooltip is ordinary Hackatime HTML.
- No fallback tree, mount handoff, DOM replacement or delayed library stylesheet.
This is different from the previous direct-D3 implementation. That implementation had two renderers: a static native fallback followed by a LayerChart client chart. The proposed version would have one native renderer for both SSR and the client.
Because LayerChart already depends on d3-shape, this should not introduce a new underlying geometry dependency while LayerChart remains installed for the timeline.
It would eliminate the pie-specific LayerChart debt
All three current LayerChart patch hunks exist for the pies:
markssnippet shadowing inPieChart- Asynchronous default legend rendering
- The
ssr={true}chart-subtree hydration remount
The timeline is client-only and does not use the LayerChart legend. Replacing only the pies should therefore let us remove:
- The complete
layerchart@2.0.4.patch - The pie-specific critical LayerChart CSS
- LayerChart’s pie, arc and legend runtime from this route where tree-shaking permits
- The pinned patch constraint, potentially allowing normal LayerChart updates again
We retain the timeline performance improvements without carrying the problematic v2 pie lifecycle.
Other libraries are weaker fits
Framework7 Svelte PieChart
Framework7’s PieChart genuinely renders SVG during SSR and hydrates the same paths. Visually, we could restyle it closely.
However:
- Its built-in tooltip only works with an initialised Framework7 application singleton.
- It needs Framework7 tooltip JS, CSS and theme infrastructure.
- Its tooltip accepts HTML strings.
- Slice keyboard accessibility is weak.
- Without Framework7 infrastructure, we would still need to write our own tooltip and styles.
Pulling in an application framework to avoid approximately one small Svelte component is disproportionate.
Apache ECharts
ECharts supports server-rendered SVG, but there is a catch:
- Its lightweight hydration runtime preserves the SSR SVG, but does not provide the normal moving tooltip.
- Its full tooltip support requires loading full ECharts and rerendering the chart on the client.
- That reintroduces the SSR-to-client handoff we are trying to eliminate.
- It is substantially heavier and harder to style exactly.
Vega/Vega-Lite
Vega can render static SVG server-side and has excellent interactive tooltips client-side. Unfortunately, interactive initialisation explicitly clears the existing container and constructs a new SVG. It does not hydrate the SSR nodes.
That makes it unsuitable for your no-flicker requirement.
LayerCake
LayerCake supports Svelte SSR and could wrap a d3-shape pie, but it does not provide the pie or tooltip itself. For a fixed 300 × 300 view box, it would add context and sizing machinery without removing meaningful code.
Recommendation
Keep LayerChart v2 for the optimised timeline, but replace the three pies with one first-party Svelte + d3-shape component.
That gives us:
- Exact current styling
- Real SSR markup
- No replacement during hydration
- Mouse, touch and keyboard tooltips
- Interactive legend filtering
- No LayerChart patch file
- No LayerChart first-paint CSS workaround
- Retention of the measured timeline performance gains
The independent verifier’s 60 cold-load run found no legend discontinuity at the patched v2 head, so this is not an emergency rollback. It is nevertheless the cleanest long-term architecture and would remove most of the migration’s maintenance risk.
Native d3-shape pies are worthwhile
Implemented the replacement and opened PR #1555.
The new pie implementation:
- Uses
d3-shapeonly to calculate arc geometry - Renders one native SVG tree during SSR
- Hydrates those same nodes without replacing or blanking them
- Implements accessible legend filtering and tooltips with local Svelte state
- Removes the LayerChart pie patch and CSS workarounds
- Keeps LayerChart for the project timeline
Measured improvement
Exact production Docker builds of baseline 7156619a and candidate f53564bf were tested with 108 measured samples under 4× CPU and network throttling.
| Metric | Baseline | Native pie | Difference |
|---|---|---|---|
| App + route gzip | 256,606 B | 249,550 B | −7,056 B (−2.75%) |
| Pie chunk gzip | 5,662 B | 2,529 B | −3,133 B (−55.3%) |
| Cold transfer | 311,360 B | 304,449 B | −6,911 B (−2.22%) |
| Warm scripting | 320.25 ms | 273.07 ms | −47.18 ms (−14.7%) |
| Warm long tasks | 552 ms | 393 ms | −159 ms (−28.8%) |
| Cold tooltip latency | 28.5 ms | 9.4 ms | −67.0% |
| Warm tooltip latency | 32.45 ms | 9.5 ms | −70.7% |
| Warm DOM nodes | 623 | 576 | −47 (−7.5%) |
| Metric | Baseline | Native pie | Difference |
|---|---|---|---|
| App + route gzip | 256,606 B | 249,550 B | −7,056 B (−2.75%) |
| Pie chunk gzip | 5,662 B | 2,529 B | −3,133 B (−55.3%) |
| Cold transfer | 311,360 B | 304,449 B | −6,911 B (−2.22%) |
| Warm scripting | 320.25 ms | 273.07 ms | −47.18 ms (−14.7%) |
| Warm long tasks | 552 ms | 393 ms | −159 ms (−28.8%) |
| Cold tooltip latency | 28.5 ms | 9.4 ms | −67.0% |
| Warm tooltip latency | 32.45 ms | 9.5 ms | −70.7% |
| Warm DOM nodes | 623 | 576 | −47 (−7.5%) |
Cold FCP improved from 338 to 318 ms, but its confidence interval included zero, so that is not a supported improvement. CLS was unchanged.
Behaviour verification
- All 24 paths, colours and labels matched at 390, 768 and 1440 px
- Nine chart-card screenshot comparisons had zero differing pixels
- Every tooltip value matched
- Each legend filtered 8 → 1 → 8 correctly
- Keyboard and touch interaction worked
- Empty, one-slice and zero-value charts worked
- SSR contained all three pies and legends
- Hydration preserved node identity in all 54 candidate loads
- No blank frames, hydration replacements or browser errors occurred
One minor difference: after deliberately holding a touch, the native tooltip disappears on release. LayerChart retained it after release. Ordinary quick taps disappear in both implementations.
Validation
The production Docker build, Rails tests, system tests, RuboCop, security checks, Svelte typecheck, Prettier and Knip passed. bin/ci itself reported failure only because its Swagger step runs git diff inside the Compose container, where .git is not mounted; Swagger generation completed and the host worktree remained unchanged.
The branch is clean and pushed as native-d3-pie-charts.
Thanks, received. These exact-SHA results are already reflected in PR #1555.
Separately, on the candidate branch I ran the checks the benchmark orb omitted:
- Svelte check: 0 errors, 3 pre-existing warnings
- Prettier: passed
- Knip: passed
- Rails: 583 tests, 2,506 assertions
- System: 42 tests, 220 assertions
- RuboCop, Brakeman, Zeitwerk and dependency audit: passed
- Production Docker build: passed
The only bin/ci issue was its Swagger git diff command running inside a container without .git mounted. Swagger generation itself succeeded and the host worktree remained clean.
Production SSR portal is ready
Use this one-time development sign-in link:
Open native pie candidate and sign in
Or open the portal root afterward.
This runs the exact PR candidate f53564bf with:
RAILS_ENV=production- Production Vite client bundle
- Production Vite SSR bundle
- Inertia SSR server active on its internal container port
- The public
onamp.devURL configured asPUBLIC_URL
I also verified the authenticated raw response before client hydration. It contains:
- 3 server-rendered pie legends
- 15 server-rendered pie paths
- The Project Timeline card
Removing LayerChart looks potentially worthwhile
LayerChart now has exactly one first-party consumer: ProjectTimelineChart.svelte.
More importantly, that component already renders its own:
- SVG bars and rounded paths
- X and Y axes
- Grid lines and baseline
- Tooltip contents
- Stack calculations
LayerChart is mainly supplying responsive dimensions, band/linear scales, hover state and tooltip plumbing.
The exact production manifest shows a theoretical maximum removable closure of:
- 308,171 raw bytes
- 100,552 gzip bytes
- 38 generated files
That is roughly 40% of the current 249,550-byte gzip app + signed-in route closure. A native replacement would add some code back, so 100.6 kB is an upper bound, not a promised saving. Nevertheless, it is large enough that this is absolutely worth a measured prototype rather than dismissing as cleanup.
I opened a fresh high-mode orb to implement and benchmark that experiment fairly: LayerChart removal benchmark. It will compare exact production images, bundle closure, transfer, scripting, long tasks, tooltip latency, CLS and visual/behaviour parity before making a recommendation.
LayerChart has been removed
I integrated the independently benchmarked native timeline into PR #1555 and updated its title, description and benchmark tables.
The branch now contains:
- Native Svelte +
d3-shapepie charts - Native Svelte +
d3-arrayproject timeline - No LayerChart dependency
- No LayerChart patch
- No generated production assets referencing LayerChart
I also fixed a lifecycle issue found during final review: an Inertia data refresh can no longer leave an old tooltip or week highlight visible against newly loaded statistics.
Updated production SSR portal
Open the final native-chart implementation
That one-time link signs in as the seeded test user. The portal runs the final production image with the production Vite SSR bundle.
Verified from its authenticated raw SSR response:
- 3 pie legends
- 15 pie paths
- Project Timeline card
- 0 LayerChart references in the production manifest or assets
Why removal is worthwhile
Incrementally replacing the remaining timeline removes:
- 97,684 bytes gzip from the app + signed-in route
- 122,872 transferred bytes
- 40 browser requests
It also improves:
- Cold chart-ready by 30.7%
- Cold scripting by 20.2%
- Cold long-task time by 34.5%
- Warm chart-ready by 28.5%
- Warm scripting by 34.7%
- Warm long-task time by 49.0%
- Timeline tooltip latency from 16.4 to 6.8 ms cold and 16.4 to 5.9 ms warm
FCP and CLS remain neutral.
Verification
- Rails: 583 tests, 2,506 assertions
- System: 42 tests, 220 assertions
- Svelte/TypeScript: 0 errors
- Prettier and Knip: passed
- RuboCop, Zeitwerk, Brakeman and dependency audit: passed
- Exact final production Docker image: built successfully
- Worktree: clean and pushed at
76c1d941
bin/ci still reports only the known Swagger wrapper problem: Swagger generation succeeds, but its subsequent git diff cannot run inside the Compose container because .git is unavailable there. All other CI steps passed.
Yes, Lighthouse performance improves
I ran Lighthouse 13.4.1 five times against each exact production image, alternating baseline and final order. Chrome 151 used Lighthouse’s default mobile emulation and throttling against the authenticated dashboard.
| Lighthouse category | LayerChart | Native charts |
|---|---|---|
| Performance | 93 median (92–94) | 97 (97 every run) |
| Accessibility | 94 | 94 |
| Best Practices | 74 | 74 |
| SEO | 100 | 100 |
| Lighthouse category | LayerChart | Native charts |
|---|---|---|
| Performance | 93 median (92–94) | 97 (97 every run) |
| Accessibility | 94 | 94 |
| Best Practices | 74 | 74 |
| SEO | 100 | 100 |
Why performance increased
| Metric | LayerChart median | Native median | Change |
|---|---|---|---|
| Total Blocking Time | 238.5 ms | 93.5 ms | −60.8% |
| Time to Interactive | 4,890 ms | 3,367 ms | −31.1% |
| Main-thread work | 1,628 ms | 1,116 ms | −31.5% |
| JavaScript boot-up | 604.7 ms | 377.6 ms | −37.6% |
| Transferred resources | 438.7 kB | 323.7 kB | −26.2% |
| Network requests | 94 | 54 | −40 |
| LCP | 2,103 ms | 1,953 ms | −150 ms |
| FCP | 1,953 ms | 1,953 ms | Neutral |
| CLS | 0 | 0 | Neutral |
| Metric | LayerChart median | Native median | Change |
|---|---|---|---|
| Total Blocking Time | 238.5 ms | 93.5 ms | −60.8% |
| Time to Interactive | 4,890 ms | 3,367 ms | −31.1% |
| Main-thread work | 1,628 ms | 1,116 ms | −31.5% |
| JavaScript boot-up | 604.7 ms | 377.6 ms | −37.6% |
| Transferred resources | 438.7 kB | 323.7 kB | −26.2% |
| Network requests | 94 | 54 | −40 |
| LCP | 2,103 ms | 1,953 ms | −150 ms |
| FCP | 1,953 ms | 1,953 ms | Neutral |
| CLS | 0 | 0 | Neutral |
So the practical Lighthouse effect is approximately +4 performance points, primarily because the browser performs much less JavaScript work. It is not coming from an artificial improvement to initial paint or layout stability.
The unchanged Best Practices score of 74 is an orb measurement artefact: Lighthouse accessed the production containers through internal HTTP and consequently reported HTTPS/redirect failures. Both variants were measured identically, so it does not affect the comparison. The unchanged accessibility score reflects two existing page-level findings, colour contrast and heading order, not chart regressions.
Tooltip animations now match LayerChart
I read LayerChart 2.0.4’s implementation and reproduced its three relevant behaviours in both native chart types:
- Independent default Svelte
Springs animate tooltipleftandtop - The first tooltip position appears instantly, while subsequent positions spring smoothly
- Tooltip entry and exit use LayerChart’s 100 ms fade
- Hiding is deferred by one event-loop turn, preserving one tooltip while crossing adjacent hit regions
The pie hover dimming now has its own 100 ms opacity transition in PieChart.svelte. Tooltip motion and fading are implemented at lines 50–135 and 213–220.
The timeline uses the same spring and fade behaviour at ProjectTimelineChart.svelte, lines 218–244 and 409–417.
Browser verification
Executed against the real dashboard in Chromium:
- Tooltip fade sampled:
0 → 0.167 → 0.333 → 0.500 → 0.667 → 0.833 → 1 - Pie dimming sampled:
1 → 0.957 → 0.772 → 0.612 → 0.540 → 0.508 → 0.5 - Pie tooltip position produced continuous intermediate spring values
- Timeline tooltip position produced 16 continuous intermediate spring values rather than jumping
Svelte/TypeScript, Prettier and Knip pass. The exact production Docker image also builds successfully.
The change is committed and pushed to PR #1555 at f7cdad1a.
Updated SSR portal
Open the animated native charts
This portal runs the exact pushed commit in production mode with the production Inertia SSR server active.
No. The timeline itself is not SSR-rendered.
The server renders:
- Timeline card
- “Project Timeline” heading
- Reserved 350 px chart area
The SVG, bars and axes are client-rendered after hydration because they are currently guarded by:
{#if typeof window !== "undefined"}
This is unchanged from the LayerChart implementation, which also left the timeline chart area empty during SSR. The three pie charts, by contrast, are fully rendered during SSR and hydrate the same SVG nodes.
Making the timeline SSR-rendered is possible, but responsive geometry currently depends on the measured client width. We would need a stable viewBox-based layout or an SSR width policy that does not introduce hydration mismatch or layout shift.
The tradeoff is performance and control versus local complexity
For Hackatime specifically, moving away from LayerChart still looks worthwhile. The performance and reliability gains are substantial, but the LOC increase is real and should not be minimised.
LOC difference
| Component | LayerChart version | Native version | Difference |
|---|---|---|---|
| Pie chart | 77 lines | 235 lines | +158 |
| Project timeline | 291 lines | 603 lines | +312 |
| Combined chart components | 368 lines | 838 lines | +470 lines, +128% |
| Component | LayerChart version | Native version | Difference |
|---|---|---|---|
| Pie chart | 77 lines | 235 lines | +158 |
| Project timeline | 291 lines | 603 lines | +312 |
| Combined chart components | 368 lines | 838 lines | +470 lines, +128% |
The chart components are therefore about 2.3× larger.
Across the complete PR:
- 686 insertions
- 362 deletions
- Net +324 tracked lines
- 8 files changed
That overall net is smaller because the migration also removes:
- 59-line LayerChart patch
- 55 lines of LayerChart-specific CSS
- 2 Docker patch-copy lines
- 15 net lockfile package records
Pros
1. Materially better performance
The timeline replacement alone produced:
- 97.7 kB less route gzip
- 122.9 kB less cold transfer
- 40 fewer requests
- 20.2% less cold scripting
- 34.7% less warm scripting
- 34.5% less cold long-task time
- 49.0% less warm long-task time
The complete replacement improved median Lighthouse Performance from 93 to 97.
This is not a marginal bundle-cleanup argument. Users receive and execute meaningfully less code.
2. The behaviour is under our control
We directly own the behaviours that caused repeated migration issues:
- Correct stack heights
- Correct top-segment rounding
- V1-style axes
- Stable SSR pies
- Tooltip movement and fading
- Hover dimming
- Legend behaviour
- Responsive labels
- Touch handling
Fixing these no longer requires understanding LayerChart internals, overriding generated markup or patching the package.
3. No dependency patch or stylesheet race
The old implementation required a 59-line patch across LayerChart internals. It also depended on asynchronously discovered component CSS, which caused the rare legend flicker.
The native pies use one SVG tree for SSR and hydration and rely only on application CSS. There is no separate chart stylesheet race.
4. Less generic hydration work
LayerChart instantiated a generic chart state system designed to support many chart types and interactions that Hackatime does not use.
The native components create only the state required by these charts. This reduced:
- DOM nodes
- Main-thread work
- Tooltip latency
- Hydration cost
- Long tasks
5. Some accessibility semantics improved
The native implementation adds:
- Names for pie arcs
aria-pressedto interactive legends- One named timeline figure instead of two unnamed layout figures
Accessibility Lighthouse remained 94, so there is no measured regression.
Cons
1. We own 470 more lines of chart code
This is the main cost. Hackatime now maintains:
- Responsive geometry
- Band and linear scaling
- Nice tick bounds
- Stacking
- SVG paths
- Tooltip containment and positioning
- Pointer and touch sequencing
- Animation lifecycle
- Data-refresh cleanup
Those responsibilities previously came from LayerChart.
2. More opportunities for subtle interaction bugs
The final review already found one example: a tooltip could retain stale data if Inertia refreshed weeklyStats while it was open. That required an explicit reactive reset.
Similar edge cases are now ours to consider:
- Browser pointer-capture differences
- Viewport and transformed-ancestor tooltip positioning
- Unusual chart dimensions
- Reactive data changes
- Touch hardware differences
- Reduced-motion policy
- Future accessibility expectations
The production benchmark covered the important current states, but it cannot prove every future modification safe.
3. New chart features will require more work
LayerChart would make features such as these easier:
- Keyboard navigation between marks
- Zooming and brushing
- Animated data-domain changes
- More chart types
- Generalised legends
- Configurable tooltip anchoring
- Alternate scales
The native approach is attractive because Hackatime currently has only two specialised charts. If the application becomes a chart-heavy analytics product, this decision could reverse.
4. The timeline still is not SSR-rendered
Removing LayerChart did not improve timeline SSR. Both versions reserve the card area and render the SVG after hydration because responsive geometry depends on measured client width.
The pies do SSR correctly. The timeline would require a separate responsive SSR design.
5. Low-level dependencies remain
This is not “dependency-free” charting:
d3-shapecalculates pie arcsd3-arrayprovides compatible ticks and nice bounds- Svelte supplies spring and fade motion
These are much smaller and narrower dependencies, but Hackatime still relies on their contracts.
Bottom line
For the current product, yes, moving away is worthwhile.
The cost is 470 additional component lines. In return, Hackatime removes the patch and CSS race, cuts approximately 98 kB gzip from the timeline route, eliminates 40 requests, reduces main-thread work significantly and gains direct control over two highly specific charts.
I would reconsider only if Hackatime plans to add several more sophisticated chart types. For two dashboard charts with tightly defined styling and interaction, the native implementation is the better boundary.
Best chart ideas for Hackatime
I would prioritise charts that answer a personal question, rather than adding visualisations just because the data exists.
1. Coding rhythm heatmap
Question: “When do I normally code?”
A 7 × 24 heatmap:
- Rows: Monday through Sunday
- Columns: hour of day
- Colour intensity: coding duration
- Tooltip: exact duration and percentage of that week’s activity
- Displayed in the user’s timezone
This would be distinct from the existing daily activity graph and could reveal patterns such as late-night coding or weekend-heavy work.
Recommendation: Best next chart. Useful, understandable and relatively easy to render as native SVG or CSS grid.
2. Goal pace chart
Question: “Am I on pace to reach my coding goal?”
A cumulative line chart comparing:
- Actual coding time
- Required pace
- Goal threshold
- Projected completion based on the current pace
For a 20-hour monthly goal, it could show whether the user is ahead or behind without treating raw coding time as productivity.
This would make the existing goal feature substantially more useful than a simple progress value.
3. Project momentum
Question: “Which projects am I actively maintaining and which are fading?”
For each project, show a compact sparkline covering perhaps eight weeks, alongside:
- Current-week duration
- Change from the previous week
- Last active date
- Increasing, stable or declining classification
This might work better as sparklines inside the Projects list than as one large dashboard chart.
4. Focus-session distribution
Question: “Do I code in sustained sessions or short bursts?”
Group adjacent heartbeats into sessions, then chart the distribution:
- Under 5 minutes
- 5–15 minutes
- 15–30 minutes
- 30–60 minutes
- Over 60 minutes
Useful companion statistics:
- Median session length
- Longest session
- Number of sessions
- Focused time in sessions over 30 minutes
The difficult part is defining a session boundary honestly, perhaps a gap of 15 minutes. The UI should disclose that definition.
5. Language evolution
Question: “How has my technology usage changed?”
A weekly stacked area or stacked bar chart showing language share over time.
Possible modes:
- Absolute time
- Percentage of weekly coding time
The percentage view would reveal transitions such as Ruby becoming less dominant even when total coding time changes significantly.
This reuses the timeline interaction model, but the user should be able to select or hide languages.
6. Context-switching chart
Question: “How fragmented was my coding time?”
A daily line or bar chart showing:
- Project switches per hour
- Languages used per day
- Median uninterrupted project block
- Percentage of time spent on the primary project
This could be informative, but wording matters. Switching projects is not necessarily bad, particularly for maintainers or people reviewing code.
7. “Where did my week go?” waterfall
Question: “Why was this week different from last week?”
A waterfall comparing two periods:
- Project A: +3h
- Project B: −1h 20m
- New project: +45m
- Other projects: −30m
- Net change: +1h 55m
This is more actionable than merely saying total coding time increased by 12%.
8. Activity consistency calendar
A GitHub-style calendar where colour represents duration, with overlays for:
- Goal reached
- Longest session
- Active project count
- Streak boundaries
Hackatime already has activity visualisation, so this is worthwhile only if it provides richer history or drill-down rather than duplicating the current graph.
9. Project-language relationship
Question: “Which languages belong to which projects?”
Possible presentations:
- Grouped horizontal bars
- Project rows split by language
- A matrix with projects × languages
I would avoid a Sankey diagram. It looks impressive but is harder to read, more expensive to implement and usually inferior to a sorted matrix for this data.
Suggested order
- Coding rhythm heatmap
- Goal pace
- Project momentum sparklines
- Focus-session distribution
- Language evolution
- Weekly comparison waterfall
The first three offer the best combination of user value, understandable semantics and manageable implementation complexity.
I would particularly prototype the coding rhythm heatmap. It answers a question Hackatime currently cannot, fits naturally on the dashboard and can be built without introducing another chart framework.
Those two complement each other nicely:
- Coding rhythm explains when you work
- Project momentum explains what is gaining or losing attention
Coding rhythm heatmap
I’d place this on the dashboard beneath the existing activity summary.
MVP
- 7 rows for weekdays
- 24 columns for local hours
- Colour intensity based on coding duration
- Tooltip such as
Tuesday, 9–10 PM · 2h 14m - Range selector follows the dashboard’s existing interval
- Uses the user’s configured timezone
- Empty cells remain visibly distinct from low-activity cells
Useful summary text could accompany it:
You code most often on Tuesday evenings. Your most active hour is 9–10 PM.
I would initially avoid filtering by project or language. A single aggregate view is easier to understand and much cheaper to calculate and cache.
Project momentum
This belongs in the Projects list rather than as another large dashboard card.
Each project row or card could show:
- An eight-week sparkline
- Current-week duration
- Change from the previous comparable week
- Last active date
- A restrained trend label:
Increasing,Steady,DecreasingorRecently active
Example:
Hackatime ▁▂▂▃▄▆▇█ 8h 42m +24%
Personal site ▆▅▄▃▂▁▁▁ 18m −73%
New project ▁▁▁▁▁▁▄█ 2h 10m New
The labels need sensible thresholds so tiny changes do not produce dramatic claims. For example:
- Increasing: at least +20% and +30 minutes
- Decreasing: at least −20% and −30 minutes
- Steady: anything between
- New: activity this period but none in the comparison period
For projects with irregular activity, the sparkline is more trustworthy than the label.
Recommended sequence
I’d build Coding Rhythm first:
- It introduces a genuinely new insight.
- It has a clear, self-contained visual contract.
- It works well with native SVG or CSS.
- It can reuse dashboard interval and tooltip conventions.
- Its aggregate rollup should be relatively straightforward.
Then add Project Momentum as a Projects-page enhancement. The underlying weekly project rollups may already provide much of what it needs, making it a good second feature without adding more dashboard density.
Implemented on a separate follow-up branch
Created coding-rhythm-project-momentum, stacked on PR #1555, with local commit 334ed481. Nothing was pushed and PR #1555 remains unchanged.
Dashboard
- Coding Rhythm
- 7 weekdays × 24 local-hour cells
- Uses the selected dashboard interval and filters
- All-time data is persisted in dashboard rollups
- Timezone-aware aggregation
- Animated mouse tooltips and keyboard-accessible cells
- Coding Time Today
- Compares today with the previous eight matching weekdays
- Shows today’s duration, usual duration and percentage difference
- Labelled as coding time rather than claiming hours measure productivity
- AI vs Human Coding
- Compares the
ai codingandcodingcategories - Follows the current dashboard filters
- Correctly handles singular category filters and categories outside the displayed top ten
- Compares the
Projects
- Eight-week sparkline on every project card
- Last four weeks compared with the preceding four
Increasing,Steady,DecreasingandNewclassifications- Requires both a ≥20% change and ≥30-minute absolute change before declaring increasing/decreasing
- Includes last-active information
- Uses batched aggregation rather than per-project queries
Production portal
Sign in and review the production/SSR portal
The sign-in URL is one-time. I can reset it if it has already been consumed.
The three dashboard analytics cards are present in raw SSR. Project momentum renders after hydration because the existing Projects list uses WindowVirtualizer.
Verification
- Rails: 586 tests, 2,524 assertions
- System: 42 tests, 221 assertions
- Svelte check: 0 errors, 3 unrelated existing warnings
- RuboCop: 505 files, 0 offences
- Knip passed
- Zeitwerk passed
- Brakeman: 0 warnings
- Importmap audit: no vulnerabilities
- Production client and SSR Docker build passed
- Production browser check:
- 168 rhythm cells
- Tooltip value verified
- 10 project sparklines with 80 points
- Clean worktree after commit
Warm aggregation timings on the 706-heartbeat development fixture:
- Rhythm: 0.47–9.04 ms, 718-byte payload
- Eight-week momentum: 0.84–15.2 ms, 774-byte weekly payload
- Batched last-active query: 0.47–1.79 ms
Bundle impact versus the PR #1555 head:
| Route closure | Raw | Gzip |
|---|---|---|
| Signed-in dashboard | +10,081 B | +4,698 B |
| Projects index | +3,834 B | +1,813 B |
| All emitted JS/CSS | +15,949 B | +6,447 B |
| Route closure | Raw | Gzip |
|---|---|---|
| Signed-in dashboard | +10,081 B | +4,698 B |
| Projects index | +3,834 B | +1,813 B |
| All emitted JS/CSS | +15,949 B | +6,447 B |
Before opening the follow-up PR, I recommend running bin/ci once to include the remaining documentation, seed and Swagger-only checks.
Shipped as a native stacked PR
- Upper PR: #1556 Add coding rhythm and project momentum insights
- Lower PR: #1555 Replace LayerChart with native SVG charts
- GitHub stack: #1557
- #1556 correctly targets
native-d3-pie-charts, while #1555 targetsmain. - Branch pushed at commit
334ed481d4ce5eac25ad81547abf783f299aed47. - Worktree is clean.
- PR description uses the repository template and includes backend timing, payload size, bundle deltas and screenshots.
The full CI suite ran. All substantive checks passed, including 586 Rails tests, 42 system tests, RuboCop, Svelte checks, Knip, security checks, asset builds and documentation generation. The aggregate command exited non-zero only because its Swagger step runs git diff inside the Compose container where .git is deliberately unavailable. Swagger itself generated successfully with 209 examples and 0 failures, and the host worktree confirmed no generated changes.
The production preview was stopped and this thread has been archived.
Fixed the images in PR #1556.
The original attachment URLs required Amp authentication and returned HTTP 401 outside Amp. I replaced them with permanent public artifact URLs and verified all four return HTTP 200 with image/png.
Removed project momentum entirely from PR #1556:
- Removed the sparkline UI and project-card integration
- Removed momentum types, payloads, controller aggregation and caching
- Removed momentum-specific tests
- Restored the Projects virtualiser sizing and existing 12-week helper API
- Updated the PR title, description, benchmarks and screenshots
The PR now contains only:
- Coding Rhythm
- Coding Time Today
- AI vs Human Coding
Verification passed:
- Rails: 30 tests, 184 assertions
- Projects system tests: 3 tests, 27 assertions
- Svelte check: 0 errors
- Prettier and Knip
- Test Vite build
The branch was rebased onto the newly merged #1555 and pushed successfully.
Cause confirmed
The aggregation is correct. User 58 has:
- 662,704 valid heartbeats
- Activity spanning April 2023 to August 2026
- Activity in 165 of 168 weekday/hour slots
The chart looked overly full because every non-zero slot received at least 30% primary-colour intensity, even when its duration was tiny relative to the busiest slot.
Local fix
I changed the four-level scale to a steeper seven-level scale:
| Relative activity | Colour intensity |
|---|---|
| Under 15% | 5% |
| 15% to 30% | 10% |
| 30% to 45% | 20% |
| 45% to 60% | 35% |
| 60% to 75% | 55% |
| 75% to 90% | 75% |
| At least 90% | 100% |
| Relative activity | Colour intensity |
|---|---|
| Under 15% | 5% |
| 15% to 30% | 10% |
| 30% to 45% | 20% |
| 45% to 60% | 35% |
| 60% to 75% | 55% |
| 75% to 90% | 75% |
| At least 90% | 100% |
For user 58, 102 of 168 cells will now render at 20% intensity or below, while only 15 cells use 75% or full intensity. This should expose the actual rhythm without pretending rare historical activity never occurred.
Verification passed:
- Svelte check: 0 errors
- Prettier
- Test Vite build
- Confirmed all seven Tailwind fill classes are present in the compiled CSS
The change is currently local on dim-coding-rhythm. Since the original PR has already merged, I have not pushed or opened a follow-up PR without approval.
Implemented locally.
New behaviour
- All Time / Today: keeps Coding Time Today, compared with the last eight matching weekdays.
- Yesterday, Last 30 Days, High Seas, custom ranges, etc.: becomes Average Coding Time Per Day.
- The card uses the currently selected period and all active project, language, editor, OS and category filters.
- It displays:
- Average coding time per calendar day
- Number of elapsed days
- Total coding time for the period
- In-progress periods such as This Week, This Month and This Year only count elapsed days, not future days.
- The meaningless percentage gauge is omitted in average mode.
Example for Yesterday:
Average Coding Time Per Day
Across Yesterday
1h 56m
per day across 1 day
1h 56m total
I also renamed the component from ProductivityToday.svelte to the more accurate CodingTimeCard.svelte.
Verification:
- Reproduced the original bug: yesterday’s total was 6,960 seconds while the card still consumed the separate activity graph ending today
- Rails: 21 tests, 115 assertions
- Added service and externally visible Inertia payload regression coverage
- Svelte check: 0 errors
- Prettier, Knip and RuboCop
- Test Vite build passed
These changes remain local on dim-coding-rhythm, together with the stronger Coding Rhythm dimming adjustment.
Opened PR #1558: Improve dashboard period insights.
It includes:
- Stronger Coding Rhythm dimming
- Period-aware Average Coding Time Per Day
- Existing today comparison preserved for Today and the default dashboard
- Selected filters included in the average
- Future days excluded from in-progress periods
- Regression coverage for Yesterday, This Month and High Seas
The branch is pushed at 8423ecdd, the worktree is clean and the PR image is publicly accessible. Relevant Rails, Svelte, RuboCop, Knip, Zeitwerk and Vite checks passed.
Fixed PR #1558. It now has both Before and After images using the same aggregated production rhythm data.
I inspected the after image to verify the complete card, title, timezone, labels and all 168 cells are visible. Both public image URLs return HTTP 200.
You were right. Two things were wrong:
- The 5% to 100% scale was too aggressive. It made legitimate activity almost indistinguishable from the background.
- The PR’s previous after image used the wrong data. A local rollup refresh replaced the temporary production aggregate with the seeded user’s Monday-heavy data before capture.
I fixed both:
- Rebalanced the scale to 10%, 20%, 30%, 45%, 60%, 80% and 100%
- Verified the rendered page payload’s busiest slot is the production aggregate’s actual Sunday at 4 PM
- Captured and inspected a corrected after image showing activity across all weekdays
- Updated PR #1558 and pushed commit
c0072451 - Restored the local preview database afterwards
The corrected after image is publicly accessible and the branch is clean.
Done. Coding Rhythm now uses 19 intensity levels:
- 10%, 15%, 20% … through 95%, then 100%
- A slight perceptual curve keeps weak activity visible without making the chart uniformly bright
- User 58’s real aggregate exercises every one of the 19 levels
- 41 cells are at 20% or below
- Only 12 cells are at 80% or above
I pushed commit 3f49b991 to PR #1558 and replaced the after image with a verified screenshot rendering the correct production aggregate. The image URL returns HTTP 200 and the local preview data was restored afterward.
Adjusted the scale again so dominant periods are much clearer:
- Still uses all 19 detail levels
- Uses the 90th percentile as the visual ceiling
- The dominant top 10% of slots now render at full intensity
- A squared curve preserves separation among medium and low activity
- For user 58:
- 17 dominant slots now render at full intensity
- 49 low-activity slots remain at 20% or below
- All 19 levels are still exercised
I verified the corrected screenshot against the exact production aggregate. Sunday and Monday afternoon/evening now stand out clearly without erasing the weaker patterns.
Pushed commit 50668752 and updated the after image in PR #1558.
Valid and fixed
The review comment was correct. For an Until-only custom period, the numerator used filtered heartbeats while the denominator started from the user’s earliest unfiltered heartbeat.
The fix:
- Reuses one filtering path for both aggregate data and the first-heartbeat lookup.
- Covers project, language, editor, operating-system and category filters, including display-name mappings.
- Adds a regression test where an unrelated older project previously caused a two-day filtered period to be counted as 104 days.
Verification:
- Before fix: expected 2 days, received 104.
- After fix: 19 service tests, 83 assertions, all passing.
- RuboCop: 2 files inspected, no offences.
- Worktree clean.
Pushed commit 138933c9 to PR #1558.
PR merged
Oracle initially found one blocker: filtered Today totals were compared against unfiltered historical data. I fixed that by keeping both values on the same activity-graph scope.
After the fix:
- Svelte check: 0 errors, 3 pre-existing warnings
- Prettier: passed
- Knip: passed
- GitHub CI: all checks passed
- Oracle re-review: GO, no blocking findings
PR #1558 was squash-merged as e36a9c83.