From cb521cad12adf09dd3648d1e6cadc5feb5122a6a Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 15 Apr 2026 17:27:46 -0400 Subject: [PATCH] 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 --- modules/rtime/components/folk-timebank-app.ts | 170 +++++++----------- modules/rtime/mod.ts | 81 +++++---- 2 files changed, 104 insertions(+), 147 deletions(-) diff --git a/modules/rtime/components/folk-timebank-app.ts b/modules/rtime/components/folk-timebank-app.ts index fda9aafd..8c8c9c72 100644 --- a/modules/rtime/components/folk-timebank-app.ts +++ b/modules/rtime/components/folk-timebank-app.ts @@ -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 = ` -
-
Canvas
-
Collaborate
-
Fulfillment
- -
-
-
0 hours available
-
0 contributors
-
-
-
@@ -547,9 +549,18 @@ class FolkTimebankApp extends HTMLElement {
Commitment Pool drag orb to weave \u2192 +
+
+
+ 0 hours + \u00b7 + 0 contributors +
+
+
@@ -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 = {}; 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 = '
' + (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; } } `; diff --git a/modules/rtime/mod.ts b/modules/rtime/mod.ts index 57554ed1..3216bdf9 100644 --- a/modules/rtime/mod.ts +++ b/modules/rtime/mod.ts @@ -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: ``, - scripts: ``, + body: ``, + scripts: ``, styles: ``, - })); + 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: ``, - scripts: ``, - styles: ``, - })); + 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: ``, - scripts: ``, - styles: ``, - })); + 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: ``, - scripts: ``, - styles: ``, - })); + return c.redirect(c.get("isSubdomain") ? `/rtime/fulfillment` : `/${space}/rtime/fulfillment`, 301); }); // ── Module export ──