diff --git a/web/style.css b/web/style.css
index 4373f85..8dd74e0 100644
--- a/web/style.css
+++ b/web/style.css
@@ -1171,6 +1171,116 @@ body {
align-items: flex-start;
}
+.radial-urchin__share {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 16px 18px;
+ background: rgba(15, 23, 42, 0.65);
+ border: 1px solid rgba(148, 163, 184, 0.2);
+ border-radius: 18px;
+ box-shadow: 0 18px 32px rgba(15, 23, 42, 0.32);
+}
+
+.activity-share__header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 12px;
+ font-weight: 600;
+ color: #cbd5f5;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+}
+
+.activity-share__title {
+ color: #e2e8f0;
+}
+
+.activity-share__total {
+ font-size: 11px;
+ font-weight: 500;
+ color: rgba(148, 163, 184, 0.85);
+}
+
+.activity-share__track {
+ display: flex;
+ border-radius: 14px;
+ overflow: hidden;
+ min-height: 32px;
+ border: 1px solid rgba(148, 163, 184, 0.28);
+ background: rgba(148, 163, 184, 0.12);
+ box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.25);
+}
+
+.activity-share__segment {
+ flex: 1 1 0%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+ padding: 4px 8px;
+ min-width: 6px;
+ background: var(--segment-color, #6366f1);
+ color: var(--segment-text-color, #0f172a);
+ transition: transform 0.18s ease;
+ cursor: pointer;
+}
+
+.activity-share__segment:hover {
+ transform: translateY(-1px);
+}
+
+.activity-share__segment-label {
+ font-size: 12px;
+ font-weight: 600;
+ text-shadow: 0 1px 2px rgba(15, 23, 42, 0.35);
+ white-space: nowrap;
+}
+
+.activity-share__empty {
+ margin: 0;
+ font-size: 12px;
+ color: rgba(148, 163, 184, 0.85);
+}
+
+.activity-share__tooltip {
+ position: absolute;
+ transform: translate(-50%, -100%) translateY(-12px);
+ background: rgba(15, 23, 42, 0.92);
+ border-radius: 12px;
+ padding: 10px 12px;
+ border: 1px solid rgba(148, 163, 184, 0.4);
+ box-shadow: 0 16px 28px rgba(15, 23, 42, 0.45);
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ font-size: 12px;
+ pointer-events: none;
+ color: #f8fafc;
+ z-index: 2;
+}
+
+.activity-share__tooltip strong {
+ font-size: 13px;
+ font-weight: 700;
+ color: #e0e7ff;
+}
+
+.activity-share__tooltip span {
+ display: block;
+ color: rgba(226, 232, 240, 0.92);
+}
+
+.activity-share__track[hidden] {
+ display: none;
+}
+
+.activity-share__tooltip[hidden] {
+ display: none;
+}
+
.radial-urchin__legend-empty {
margin: 0;
color: #94a3b8;
diff --git a/web/ui/visuals/ActivityShareBar.js b/web/ui/visuals/ActivityShareBar.js
new file mode 100644
index 0000000..9e643c1
--- /dev/null
+++ b/web/ui/visuals/ActivityShareBar.js
@@ -0,0 +1,173 @@
+import { formatDuration } from './useUrchinLayout.js';
+
+function toHexChannel(value) {
+ const channel = Number.parseInt(value, 16);
+ if (Number.isNaN(channel)) {
+ return 0;
+ }
+ return channel;
+}
+
+function computeTextColor(background) {
+ if (typeof background !== 'string') {
+ return '#0f172a';
+ }
+ let hex = background.trim();
+ if (hex.startsWith('#')) {
+ hex = hex.slice(1);
+ }
+ if (hex.length === 3) {
+ hex = hex
+ .split('')
+ .map((char) => char + char)
+ .join('');
+ }
+ if (hex.length !== 6) {
+ return '#0f172a';
+ }
+ const r = toHexChannel(hex.slice(0, 2));
+ const g = toHexChannel(hex.slice(2, 4));
+ const b = toHexChannel(hex.slice(4, 6));
+ const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
+ return luminance > 0.6 ? '#0f172a' : '#f8fafc';
+}
+
+export function prepareActivityShareSegments(activities) {
+ const filtered = Array.isArray(activities)
+ ? activities.filter((activity) => Number.isFinite(activity?.minutes) && activity.minutes > 0)
+ : [];
+ const totalMinutes = filtered.reduce((sum, activity) => sum + activity.minutes, 0);
+ if (totalMinutes <= 0) {
+ return { totalMinutes: 0, segments: [] };
+ }
+ const segments = filtered.map((activity) => ({
+ id: activity.id ?? activity.label,
+ label: activity.label,
+ minutes: activity.minutes,
+ color: activity.color,
+ percentage: activity.minutes / totalMinutes,
+ }));
+ return { totalMinutes, segments };
+}
+
+export class ActivityShareBar {
+ constructor(container) {
+ this.root = container;
+ this.root.classList.add('radial-urchin__share');
+ this.root.setAttribute('role', 'group');
+ this.root.setAttribute('aria-label', 'Activity balance overview');
+
+ this.header = document.createElement('div');
+ this.header.className = 'activity-share__header';
+ this.title = document.createElement('span');
+ this.title.className = 'activity-share__title';
+ this.title.textContent = 'Activity balance';
+ this.total = document.createElement('span');
+ this.total.className = 'activity-share__total';
+ this.total.textContent = '—';
+ this.header.append(this.title, this.total);
+
+ this.track = document.createElement('div');
+ this.track.className = 'activity-share__track';
+ this.track.setAttribute('role', 'list');
+ this.track.hidden = true;
+ this.track.addEventListener('mouseleave', () => {
+ this.hideTooltip();
+ });
+
+ this.empty = document.createElement('p');
+ this.empty.className = 'activity-share__empty';
+ this.empty.textContent = 'Select activities to see their balance.';
+ this.empty.hidden = false;
+
+ this.tooltip = document.createElement('div');
+ this.tooltip.className = 'activity-share__tooltip';
+ this.tooltip.hidden = true;
+ this.tooltip.setAttribute('role', 'dialog');
+ this.tooltip.setAttribute('aria-live', 'polite');
+ this.tooltip.setAttribute('aria-hidden', 'true');
+
+ this.root.append(this.header, this.track, this.empty, this.tooltip);
+
+ this.currentSegments = [];
+ this.boundHideTooltip = this.hideTooltip.bind(this);
+ }
+
+ update(segments, totalMinutes = 0) {
+ this.hideTooltip();
+ const hasSegments = Array.isArray(segments) && segments.length > 0 && totalMinutes > 0;
+ this.track.innerHTML = '';
+ this.currentSegments = hasSegments ? segments : [];
+ this.total.textContent = hasSegments ? formatDuration(Math.round(totalMinutes)) : '—';
+ this.track.hidden = !hasSegments;
+ this.empty.hidden = hasSegments;
+ if (!hasSegments) {
+ return;
+ }
+
+ segments.forEach((segment, index) => {
+ const element = document.createElement('div');
+ element.className = 'activity-share__segment';
+ element.style.setProperty('--segment-color', segment.color || '#6366f1');
+ element.style.setProperty('--segment-text-color', computeTextColor(segment.color));
+ element.style.flexGrow = String(segment.minutes);
+ element.setAttribute('role', 'listitem');
+ const percentValue = Math.round(segment.percentage * 100);
+ const labelText = this.getSegmentLabel(segment, percentValue);
+ if (labelText) {
+ const label = document.createElement('span');
+ label.className = 'activity-share__segment-label';
+ label.textContent = labelText;
+ element.append(label);
+ }
+ element.setAttribute(
+ 'aria-label',
+ `${segment.label}: ${formatDuration(Math.round(segment.minutes))} (${percentValue}%)`,
+ );
+ element.dataset.index = String(index);
+ element.addEventListener('mouseenter', (event) => {
+ this.showTooltip(event, segment);
+ });
+ element.addEventListener('mousemove', (event) => {
+ this.positionTooltip(event);
+ });
+ element.addEventListener('mouseleave', this.boundHideTooltip);
+ this.track.append(element);
+ });
+ }
+
+ getSegmentLabel(segment, percentValue) {
+ if (segment.percentage >= 0.18) {
+ return `${segment.label} · ${percentValue}%`;
+ }
+ if (segment.percentage >= 0.1) {
+ return `${percentValue}%`;
+ }
+ return '';
+ }
+
+ showTooltip(event, segment) {
+ const percentValue = Math.round(segment.percentage * 100);
+ const durationLabel = formatDuration(Math.round(segment.minutes));
+ this.tooltip.innerHTML = `${segment.label}${durationLabel}${percentValue}%`;
+ this.tooltip.hidden = false;
+ this.tooltip.setAttribute('aria-hidden', 'false');
+ this.positionTooltip(event);
+ }
+
+ positionTooltip(event) {
+ if (this.tooltip.hidden) {
+ return;
+ }
+ const rootRect = this.root.getBoundingClientRect();
+ const x = event.clientX - rootRect.left;
+ const y = event.clientY - rootRect.top;
+ this.tooltip.style.left = `${x}px`;
+ this.tooltip.style.top = `${y}px`;
+ }
+
+ hideTooltip() {
+ this.tooltip.hidden = true;
+ this.tooltip.setAttribute('aria-hidden', 'true');
+ }
+}
diff --git a/web/ui/visuals/RadialUrchin.js b/web/ui/visuals/RadialUrchin.js
index 07c38b9..60ea941 100644
--- a/web/ui/visuals/RadialUrchin.js
+++ b/web/ui/visuals/RadialUrchin.js
@@ -4,6 +4,7 @@ import {
formatDuration,
minutesToTime,
} from './useUrchinLayout.js';
+import { ActivityShareBar, prepareActivityShareSegments } from './ActivityShareBar.js';
import { mapLabelToColor, resolveSurface, resolveStateLayer } from './palette.js';
const FULL_DAY_MINUTES = 24 * 60;
@@ -142,6 +143,8 @@ export class RadialUrchin {
this.metaElement = null;
this.metaSlot = null;
+ this.shareBar = null;
+ this.shareContainer = null;
this.handleResize = this.handleResize.bind(this);
this.handlePointerMove = this.handlePointerMove.bind(this);
@@ -307,6 +310,10 @@ export class RadialUrchin {
this.exportPngButton,
);
+ this.shareContainer = document.createElement('div');
+ this.container.append(this.shareContainer);
+ this.shareBar = new ActivityShareBar(this.shareContainer);
+
this.canvasWrapper = document.createElement('div');
this.canvasWrapper.className = 'radial-urchin__stage visuals-canvas-block';
this.container.append(this.canvasWrapper);
@@ -550,6 +557,7 @@ export class RadialUrchin {
if (!this.layout) {
this.displayArcs = [];
this.visibleArcs = [];
+ this.updateShareBar();
return;
}
const zoom = this.getZoom();
@@ -589,6 +597,45 @@ export class RadialUrchin {
return a.ringIndex - b.ringIndex;
});
this.displayMaxRadius = (this.layout?.maxRadius || 160) * scale;
+ this.updateShareBar();
+ }
+
+ computeVisibleActivityTotals() {
+ if (!Array.isArray(this.visibleArcs) || this.visibleArcs.length === 0) {
+ return [];
+ }
+ const totals = new Map();
+ this.visibleArcs.forEach((arc) => {
+ const duration = Number.isFinite(arc.segmentDuration)
+ ? arc.segmentDuration
+ : Number.isFinite(arc.duration)
+ ? arc.duration
+ : 0;
+ if (!(duration > 0)) {
+ return;
+ }
+ const label = arc.label || arc.event?.activity || 'Activity';
+ if (!totals.has(label)) {
+ totals.set(label, {
+ id: label,
+ label,
+ minutes: 0,
+ color: arc.color || mapLabelToColor(label, { highContrast: this.state.highContrast }),
+ });
+ }
+ const entry = totals.get(label);
+ entry.minutes += duration;
+ });
+ return Array.from(totals.values()).sort((a, b) => b.minutes - a.minutes);
+ }
+
+ updateShareBar() {
+ if (!this.shareBar) {
+ return;
+ }
+ const totals = this.computeVisibleActivityTotals();
+ const { segments, totalMinutes } = prepareActivityShareSegments(totals);
+ this.shareBar.update(segments, totalMinutes);
}
computeSegments(arc, zoom) {
diff --git a/web/ui/visuals/__tests__/activityShareBar.test.js b/web/ui/visuals/__tests__/activityShareBar.test.js
new file mode 100644
index 0000000..874b2b7
--- /dev/null
+++ b/web/ui/visuals/__tests__/activityShareBar.test.js
@@ -0,0 +1,28 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { prepareActivityShareSegments } from '../ActivityShareBar.js';
+
+test('prepareActivityShareSegments ignores empty values and normalises percentages', () => {
+ const { segments, totalMinutes } = prepareActivityShareSegments([
+ { id: 'work', label: 'Work', minutes: 300, color: '#123456' },
+ { id: 'sleep', label: 'Sleep', minutes: 420, color: '#654321' },
+ { id: 'zero', label: 'Zero', minutes: 0, color: '#000000' },
+ ]);
+
+ assert.equal(segments.length, 2);
+ assert.equal(totalMinutes, 720);
+ const work = segments.find((segment) => segment.id === 'work');
+ const sleep = segments.find((segment) => segment.id === 'sleep');
+ assert(work);
+ assert(sleep);
+ assert.ok(Math.abs(work.percentage - 300 / 720) < 1e-9);
+ assert.ok(Math.abs(sleep.percentage - 420 / 720) < 1e-9);
+});
+
+test('prepareActivityShareSegments returns empty state when no minutes available', () => {
+ const { segments, totalMinutes } = prepareActivityShareSegments([
+ { id: 'breakfast', label: 'Breakfast', minutes: 0, color: '#fff' },
+ ]);
+ assert.equal(totalMinutes, 0);
+ assert.deepEqual(segments, []);
+});
diff --git a/web/ui/visuals/package.json b/web/ui/visuals/package.json
new file mode 100644
index 0000000..3dbc1ca
--- /dev/null
+++ b/web/ui/visuals/package.json
@@ -0,0 +1,3 @@
+{
+ "type": "module"
+}