feat(rtime): shell tab bar + pool legend, remove internal tabs/stats bar

- Add shell tabs: Commitment Pool, Fulfillment, Open in Cyclos
- Move stats (hours, contributors, skill breakdown) into pool legend
- Remove internal tab-bar and stats-bar from component
- Listen for rapp-tab-change shell events for view switching
- Legacy routes redirect to new tab paths

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-15 17:27:46 -04:00
parent c82eca38fa
commit cb521cad12
2 changed files with 104 additions and 147 deletions

View File

@ -362,6 +362,29 @@ class FolkTimebankApp extends HTMLElement {
if (name === 'view' && (val === 'canvas' || val === 'collaborate' || val === 'dashboard')) this.currentView = val;
}
private _onShellTabChange = (e: Event) => {
const tab = (e as CustomEvent).detail?.tab;
if (!tab) return;
if (tab === 'pool') this._switchView('canvas');
else if (tab === 'fulfillment') this._switchView('dashboard');
// 'cyclos' tab is a redirect, no client-side handling needed
};
private _switchView(view: 'canvas' | 'collaborate' | 'dashboard') {
if (view === this.currentView) return;
this._history.push(view);
this.currentView = view;
const canvasView = this.shadow.getElementById('canvas-view');
const collabView = this.shadow.getElementById('collaborate-view');
const dashView = this.shadow.getElementById('dashboard-view');
if (canvasView) canvasView.style.display = view === 'canvas' ? 'flex' : 'none';
if (collabView) collabView.style.display = view === 'collaborate' ? 'flex' : 'none';
if (dashView) dashView.style.display = view === 'dashboard' ? 'flex' : 'none';
if (view === 'canvas') { this.resizePoolCanvas(); this.rebuildSidebar(); }
if (view === 'collaborate') this.refreshCollaborate();
if (view === 'dashboard') this.refreshDashboard();
}
connectedCallback() {
this.space = this.getAttribute('space') || 'demo';
const rawView = this.getAttribute('view');
@ -383,6 +406,7 @@ class FolkTimebankApp extends HTMLElement {
this.fetchData();
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rtime', context: 'Timebank' }));
if (this.space !== 'demo') this.subscribeOffline();
document.addEventListener('rapp-tab-change', this._onShellTabChange);
window.addEventListener('rspace-view-restored', this._onViewRestored as EventListener);
}
@ -421,6 +445,7 @@ class FolkTimebankApp extends HTMLElement {
disconnectedCallback() {
this._history.destroy();
document.removeEventListener('rapp-tab-change', this._onShellTabChange);
window.removeEventListener('rspace-view-restored', this._onViewRestored as EventListener);
if (this.animFrame) cancelAnimationFrame(this.animFrame);
this._stopPresence?.(); this._offlineUnsub?.(); this._offlineUnsub = null;
@ -435,18 +460,7 @@ class FolkTimebankApp extends HTMLElement {
private _onViewRestored = (e: CustomEvent) => {
if (e.detail?.moduleId !== 'rtime') return;
this.currentView = e.detail.view;
// Toggle visibility of view panels
const canvasView = this.shadow.getElementById('canvas-view');
const collabView = this.shadow.getElementById('collaborate-view');
const dashView = this.shadow.getElementById('dashboard-view');
if (canvasView) canvasView.style.display = this.currentView === 'canvas' ? 'flex' : 'none';
if (collabView) collabView.style.display = this.currentView === 'collaborate' ? 'flex' : 'none';
if (dashView) dashView.style.display = this.currentView === 'dashboard' ? 'flex' : 'none';
this.shadow.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', (t as HTMLElement).dataset.view === this.currentView));
if (this.currentView === 'canvas') { this.resizePoolCanvas(); this.rebuildSidebar(); }
if (this.currentView === 'collaborate') this.refreshCollaborate();
if (this.currentView === 'dashboard') this.refreshDashboard();
this._switchView(e.detail.view);
};
private async fetchData() {
@ -528,18 +542,6 @@ class FolkTimebankApp extends HTMLElement {
private render() {
this.shadow.innerHTML = `
<style>${CSS_TEXT}</style>
<div class="tab-bar">
<div class="tab active" data-view="canvas">Canvas</div>
<div class="tab" data-view="collaborate">Collaborate</div>
<div class="tab" data-view="dashboard">Fulfillment</div>
<button class="theme-toggle" id="themeToggle" title="Toggle light/dark theme"></button>
</div>
<div class="stats-bar">
<div class="stat"><span class="stat-value" id="statHours">0</span>&nbsp;hours available</div>
<div class="stat"><span class="stat-value" id="statContributors">0</span>&nbsp;contributors</div>
<div class="skill-bar" id="skillBar"></div>
<div class="skill-legend" id="skillLegend"></div>
</div>
<div class="main">
<div id="canvas-view">
<!-- Left pool panel -->
@ -547,9 +549,18 @@ class FolkTimebankApp extends HTMLElement {
<div class="pool-panel-header">
<span>Commitment Pool</span>
<span class="pool-hint">drag orb to weave \u2192</span>
<button class="theme-toggle" id="themeToggle" title="Toggle light/dark theme"></button>
<button id="poolPanelToggle" title="Collapse panel">\u27E8</button>
</div>
<canvas id="pool-canvas"></canvas>
<div class="pool-legend" id="poolLegend">
<div class="pool-legend-stats">
<span><strong id="statHours">0</strong> hours</span>
<span class="pool-legend-sep">\u00b7</span>
<span><strong id="statContributors">0</strong> contributors</span>
</div>
<div class="pool-legend-skills" id="skillLegend"></div>
</div>
<div class="pool-detail" id="poolDetail">
<div class="pool-detail-header">
<div class="pool-detail-dot" id="detailDot"></div>
@ -781,26 +792,6 @@ class FolkTimebankApp extends HTMLElement {
this.canvas = this.shadow.getElementById('pool-canvas') as HTMLCanvasElement;
this.ctx = this.canvas.getContext('2d')!;
// Tab switching (3 tabs: canvas, collaborate, dashboard)
this.shadow.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
const view = (tab as HTMLElement).dataset.view as 'canvas' | 'collaborate' | 'dashboard';
if (view === this.currentView) return;
this._history.push(view);
this.currentView = view;
this.shadow.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', (t as HTMLElement).dataset.view === view));
const canvasView = this.shadow.getElementById('canvas-view')!;
const collabView = this.shadow.getElementById('collaborate-view')!;
const dashView = this.shadow.getElementById('dashboard-view')!;
canvasView.style.display = view === 'canvas' ? 'flex' : 'none';
collabView.style.display = view === 'collaborate' ? 'flex' : 'none';
dashView.style.display = view === 'dashboard' ? 'flex' : 'none';
if (view === 'canvas') { this.resizePoolCanvas(); this.rebuildSidebar(); }
if (view === 'collaborate') this.refreshCollaborate();
if (view === 'dashboard') this.refreshDashboard();
});
});
// Theme toggle
this.shadow.getElementById('themeToggle')!.addEventListener('click', () => {
this._theme = this._theme === 'dark' ? 'light' : 'dark';
@ -1222,20 +1213,17 @@ class FolkTimebankApp extends HTMLElement {
private updateStats() {
const total = this.commitments.reduce((s, c) => s + c.hours, 0);
this.shadow.getElementById('statHours')!.textContent = String(total);
this.shadow.getElementById('statContributors')!.textContent = String(this.commitments.length);
const hoursEl = this.shadow.getElementById('statHours');
const contribEl = this.shadow.getElementById('statContributors');
if (hoursEl) hoursEl.textContent = String(total);
if (contribEl) contribEl.textContent = String(this.commitments.length);
const bySkill: Record<string, number> = {};
this.commitments.forEach(c => { bySkill[c.skill] = (bySkill[c.skill] || 0) + c.hours; });
const bar = this.shadow.getElementById('skillBar')!;
const legend = this.shadow.getElementById('skillLegend')!;
bar.innerHTML = ''; legend.innerHTML = '';
const legend = this.shadow.getElementById('skillLegend');
if (!legend) return;
legend.innerHTML = '';
if (total === 0) return;
for (const [skill, hrs] of Object.entries(bySkill)) {
const seg = document.createElement('div');
seg.className = 'skill-bar-segment';
seg.style.width = ((hrs as number) / total * 100) + '%';
seg.style.background = SKILL_COLORS[skill] || '#888';
bar.appendChild(seg);
const item = document.createElement('div');
item.className = 'skill-legend-item';
item.innerHTML = '<div class="skill-legend-dot" style="background:' + (SKILL_COLORS[skill] || '#888') + '"></div>' + (SKILL_LABELS[skill] || skill) + ' ' + hrs + 'h';
@ -3040,10 +3028,10 @@ class FolkTimebankApp extends HTMLElement {
if (this._tour) return;
const { TourEngine } = await import('../../../shared/tour-engine');
this._tour = new TourEngine(this.shadow as unknown as ShadowRoot, [
{ target: '.tab-bar', title: 'Canvas & Collaborate', message: 'The Canvas shows commitment orbs alongside the weaving SVG. Collaborate matches intents via the solver.' },
{ target: '.pool-panel-header', title: 'Canvas & Collaborate', message: 'The Canvas shows commitment orbs alongside the weaving SVG. Collaborate matches intents via the solver.' },
{ target: '#pool-canvas', title: 'Commitment Pool', message: 'Each floating orb represents a time commitment — sized by hours, colored by skill. Long-press to drag onto the canvas.' },
{ target: '#addBtn', title: 'Add a Commitment', message: 'Pledge your hours with a skill category. Your commitment joins the pool for others to see.', advanceOnClick: true },
{ target: '.stats-bar', title: 'Community Stats', message: 'See total hours available and how many contributors are in the pool at a glance.' },
{ target: '.pool-legend', title: 'Community Stats', message: 'See total hours available and how many contributors are in the pool at a glance.' },
], 'rtime_tour_done', () => this.shadow.querySelector('.main') as HTMLElement);
}
@ -3400,12 +3388,7 @@ class FolkTimebankApp extends HTMLElement {
this.rebuildSidebar();
// Switch to canvas view
this.currentView = 'canvas';
this.shadow.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', (t as HTMLElement).dataset.view === 'canvas'));
this.shadow.getElementById('canvas-view')!.style.display = 'flex';
this.shadow.getElementById('collaborate-view')!.style.display = 'none';
const dashView = this.shadow.getElementById('dashboard-view');
if (dashView) dashView.style.display = 'none';
this._switchView('canvas');
}
private renderSkillPrices() {
@ -3588,42 +3571,22 @@ const CSS_TEXT = `
overflow: hidden;
}
.tab-bar {
display: flex;
background: #1e293b;
border-bottom: 1px solid #334155;
.pool-legend {
padding: 0.5rem 0.75rem;
background: rgba(30, 27, 75, 0.6);
border-top: 1px solid #334155;
flex-shrink: 0;
}
.tab {
padding: 0.65rem 1.5rem;
font-size: 0.9rem;
font-weight: 500;
color: #64748b;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.2s;
user-select: none;
.pool-legend-stats {
display: flex; align-items: center; gap: 0.4rem;
font-size: 0.78rem; color: #94a3b8; margin-bottom: 0.3rem;
}
.tab:hover { color: #e2e8f0; }
.tab.active { color: #8b5cf6; border-bottom-color: #8b5cf6; }
.stats-bar {
display: flex;
align-items: center;
gap: 1.5rem;
padding: 0.6rem 1.5rem;
background: linear-gradient(135deg, #1e1b4b 0%, #2d1b3d 100%);
border-bottom: 1px solid #334155;
flex-shrink: 0;
flex-wrap: wrap;
}
.stat { display: flex; align-items: center; gap: 0.4rem; font-size: 0.82rem; color: #94a3b8; }
.stat-value { font-weight: 700; font-size: 1rem; color: #f1f5f9; }
.skill-bar { display: flex; height: 6px; border-radius: 3px; overflow: hidden; flex: 1; min-width: 150px; max-width: 300px; }
.skill-bar-segment { transition: width 0.5s ease; }
.skill-legend { display: flex; gap: 0.75rem; flex-wrap: wrap; }
.skill-legend-item { display: flex; align-items: center; gap: 0.3rem; font-size: 0.75rem; color: #94a3b8; }
.skill-legend-dot { width: 8px; height: 8px; border-radius: 50%; }
.pool-legend-stats strong { font-weight: 700; color: #f1f5f9; }
.pool-legend-sep { color: #475569; }
.pool-legend-skills { display: flex; gap: 0.6rem; flex-wrap: wrap; }
.skill-legend { display: flex; gap: 0.6rem; flex-wrap: wrap; }
.skill-legend-item { display: flex; align-items: center; gap: 0.25rem; font-size: 0.7rem; color: #94a3b8; }
.skill-legend-dot { width: 7px; height: 7px; border-radius: 50%; }
.main { flex: 1; position: relative; overflow: hidden; }
@ -4320,11 +4283,10 @@ const CSS_TEXT = `
/* Light theme overrides */
:host([data-theme="light"]) { background: #f8fafc; color: #1e293b; }
:host([data-theme="light"]) .tab-bar { background: #fff; border-bottom-color: #e2e8f0; }
:host([data-theme="light"]) .tab { color: #94a3b8; }
:host([data-theme="light"]) .tab:hover { color: #1e293b; }
:host([data-theme="light"]) .tab.active { color: #8b5cf6; }
:host([data-theme="light"]) .stats-bar { background: linear-gradient(135deg, #f0ecff 0%, #fdf2f8 100%); border-bottom-color: #e2e8f0; }
:host([data-theme="light"]) .pool-legend { background: rgba(240, 236, 255, 0.6); border-top-color: #e2e8f0; }
:host([data-theme="light"]) .pool-legend-stats { color: #64748b; }
:host([data-theme="light"]) .skill-legend-item { color: #64748b; }
:host([data-theme="light"]) .pool-legend-stats strong { color: #1e293b; }
:host([data-theme="light"]) .stat { color: #64748b; }
:host([data-theme="light"]) .stat-value { color: #1e293b; }
:host([data-theme="light"]) .pool-panel { background: #fff; border-right-color: #e2e8f0; }
@ -4407,9 +4369,7 @@ const CSS_TEXT = `
.exec-step-checklist input[type="checkbox"] { accent-color: #8b5cf6; }
@media (max-width: 768px) {
.tab-bar { gap: 0; }
.tab { padding: 0.45rem 1rem; font-size: 0.82rem; }
.stats-bar { padding: 0.35rem 0.75rem; gap: 0.75rem; }
.pool-legend { padding: 0.35rem 0.5rem; }
.stat { font-size: 0.75rem; }
.stat-value { font-size: 0.85rem; }
.skill-bar { min-width: 80px; max-width: 160px; }
@ -4434,9 +4394,7 @@ const CSS_TEXT = `
.task-edit-panel { width: 95vw; }
}
@media (max-width: 640px) {
.tab { padding: 0.4rem 0.75rem; font-size: 0.78rem; }
.stats-bar { padding: 0.3rem 0.5rem; gap: 0.5rem; }
.skill-legend { display: none; }
.pool-legend-skills { display: none; }
.pool-panel.collapsed { width: 100% !important; }
}
`;

View File

@ -1108,19 +1108,49 @@ routes.post("/api/cyclos/transfers", async (c) => {
// ── Page routes ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
// ── Shell tabs for rTime ──
const RTIME_TABS = [
{ id: "pool", label: "Commitment Pool" },
{ id: "fulfillment", label: "Fulfillment" },
{ id: "cyclos", label: "Open in Cyclos" },
] as const;
return c.html(renderShell({
const RTIME_TAB_IDS = new Set(RTIME_TABS.map(t => t.id));
function renderTimePage(space: string, view: string, activeTab: string, isSubdomain: boolean) {
return renderShell({
title: `${space} — rTime | rSpace`,
moduleId: "rtime",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-timebank-app space="${space}" view="pool"></folk-timebank-app>`,
scripts: `<script type="module" src="/modules/rtime/folk-timebank-app.js?v=1"></script>`,
body: `<folk-timebank-app space="${space}" view="${view}"></folk-timebank-app>`,
scripts: `<script type="module" src="/modules/rtime/folk-timebank-app.js?v=2"></script>`,
styles: `<link rel="stylesheet" href="/modules/rtime/rtime.css">`,
}));
tabs: [...RTIME_TABS],
activeTab,
tabBasePath: isSubdomain ? `/rtime` : `/${space}/rtime`,
});
}
routes.get("/pool", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderTimePage(space, "canvas", "pool", c.get("isSubdomain")));
});
routes.get("/fulfillment", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderTimePage(space, "dashboard", "fulfillment", c.get("isSubdomain")));
});
routes.get("/cyclos", (c) => {
const cyclosUrl = process.env.CYCLOS_URL || "https://www.cyclos.org";
return c.redirect(cyclosUrl, 302);
});
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderTimePage(space, "canvas", "pool", c.get("isSubdomain")));
});
routes.post("/api/tasks/:id/export-to-backlog", async (c) => {
@ -1175,49 +1205,18 @@ routes.post("/api/tasks/:id/export-to-backlog", async (c) => {
});
});
// Legacy routes — redirect to new tab paths
routes.get("/canvas", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderShell({
title: `${space} — Canvas | rTime | rSpace`,
moduleId: "rtime",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-timebank-app space="${space}" view="canvas"></folk-timebank-app>`,
scripts: `<script type="module" src="/modules/rtime/folk-timebank-app.js?v=1"></script>`,
styles: `<link rel="stylesheet" href="/modules/rtime/rtime.css">`,
}));
return c.redirect(c.get("isSubdomain") ? `/rtime/pool` : `/${space}/rtime/pool`, 301);
});
routes.get("/collaborate", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderShell({
title: `${space} — Collaborate | rTime | rSpace`,
moduleId: "rtime",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-timebank-app space="${space}" view="collaborate"></folk-timebank-app>`,
scripts: `<script type="module" src="/modules/rtime/folk-timebank-app.js?v=1"></script>`,
styles: `<link rel="stylesheet" href="/modules/rtime/rtime.css">`,
}));
return c.redirect(c.get("isSubdomain") ? `/rtime/pool` : `/${space}/rtime/pool`, 301);
});
routes.get("/dashboard", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderShell({
title: `${space} — Fulfillment | rTime | rSpace`,
moduleId: "rtime",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-timebank-app space="${space}" view="dashboard"></folk-timebank-app>`,
scripts: `<script type="module" src="/modules/rtime/folk-timebank-app.js?v=1"></script>`,
styles: `<link rel="stylesheet" href="/modules/rtime/rtime.css">`,
}));
return c.redirect(c.get("isSubdomain") ? `/rtime/fulfillment` : `/${space}/rtime/fulfillment`, 301);
});
// ── Module export ──