feat: customizable dashboard with persistent home icon and widget system

Adds always-visible home button in tab bar, toggleable dashboard overlay,
widget card system with 8 widgets (tasks, calendar, activity, members,
tools, quick actions, wallet, flows), customize mode with toggle/reorder,
and dashboard summary API endpoint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-10 23:10:27 -04:00
parent 2f4258aa32
commit e6328581a7
6 changed files with 725 additions and 179 deletions

View File

@ -0,0 +1,27 @@
/**
* Dashboard summary API single aggregation endpoint for dashboard widgets.
*
* GET /dashboard-summary/:space { tasks, calendar, flows }
*
* Uses existing MI data functions (zero new DB queries).
*/
import { Hono } from "hono";
import { getRecentTasksForMI } from "../modules/rtasks/mod";
import { getUpcomingEventsForMI } from "../modules/rcal/mod";
import { getRecentFlowsForMI } from "../modules/rflows/mod";
const dashboardRoutes = new Hono();
dashboardRoutes.get("/dashboard-summary/:space", (c) => {
const space = c.req.param("space");
if (!space) return c.json({ error: "space required" }, 400);
const tasks = getRecentTasksForMI(space, 5);
const calendar = getUpcomingEventsForMI(space, 14, 5);
const flows = getRecentFlowsForMI(space, 3);
return c.json({ tasks, calendar, flows });
});
export { dashboardRoutes };

View File

@ -526,6 +526,10 @@ app.route("/api/mi", miRoutes);
app.route("/rtasks/check", checklistCheckRoutes); app.route("/rtasks/check", checklistCheckRoutes);
app.route("/api/rtasks", checklistApiRoutes); app.route("/api/rtasks", checklistApiRoutes);
// ── Dashboard summary API ──
import { dashboardRoutes } from "./dashboard-routes";
app.route("/api", dashboardRoutes);
// ── Bug Report API ── // ── Bug Report API ──
app.route("/api/bug-report", bugReportRouter); app.route("/api/bug-report", bugReportRouter);

View File

@ -1164,6 +1164,12 @@ export function renderShell(opts: ShellOptions): string {
tabBar.addEventListener('layer-switch', (e) => { tabBar.addEventListener('layer-switch', (e) => {
const { layerId, moduleId } = e.detail; const { layerId, moduleId } = e.detail;
currentModuleId = moduleId; currentModuleId = moduleId;
// Hide dashboard overlay if it was showing
const dashOnSwitch = document.querySelector('rstack-user-dashboard');
if (dashOnSwitch && dashOnSwitch.style.display !== 'none') {
dashOnSwitch.style.display = 'none';
tabBar.setAttribute('home-active', 'false');
}
saveTabs(); saveTabs();
if (tabCache) { if (tabCache) {
const switchId = moduleId; // capture for staleness check const switchId = moduleId; // capture for staleness check
@ -1322,6 +1328,7 @@ export function renderShell(opts: ShellOptions): string {
const { moduleId: targetModule, spaceSlug: targetSpace } = e.detail; const { moduleId: targetModule, spaceSlug: targetSpace } = e.detail;
const dashboard = document.querySelector('rstack-user-dashboard'); const dashboard = document.querySelector('rstack-user-dashboard');
if (dashboard) dashboard.style.display = 'none'; if (dashboard) dashboard.style.display = 'none';
tabBar.setAttribute('home-active', 'false');
// If navigating to a different space, do a full navigation // If navigating to a different space, do a full navigation
if (targetSpace && targetSpace !== spaceSlug) { if (targetSpace && targetSpace !== spaceSlug) {
@ -1348,6 +1355,34 @@ export function renderShell(opts: ShellOptions): string {
} }
}); });
// ── Home button: toggle dashboard overlay when tabs are open ──
tabBar.addEventListener('home-click', () => {
const dashboard = document.querySelector('rstack-user-dashboard');
if (!dashboard) return;
// If no tabs, dashboard is already visible — no-op
if (layers.length === 0) return;
const isVisible = dashboard.style.display !== 'none';
if (isVisible) {
// Hide dashboard overlay, return to active tab
dashboard.style.display = 'none';
tabBar.setAttribute('home-active', 'false');
// Restore active tab pane
if (tabCache && currentModuleId) {
tabCache.switchTo(currentModuleId);
}
} else {
// Show dashboard as overlay, hide active pane
if (tabCache) tabCache.hideAllPanes();
dashboard.style.display = '';
if (dashboard.refresh) dashboard.refresh();
tabBar.setAttribute('home-active', 'true');
var dashUrl = window.location.hostname.endsWith('.rspace.online') ? '/' : '/' + spaceSlug;
history.pushState({ dashboard: true, spaceSlug: spaceSlug }, '', dashUrl);
}
});
tabBar.addEventListener('view-toggle', (e) => { tabBar.addEventListener('view-toggle', (e) => {
const { mode } = e.detail; const { mode } = e.detail;
document.dispatchEvent(new CustomEvent('layer-view-mode', { detail: { mode } })); document.dispatchEvent(new CustomEvent('layer-view-mode', { detail: { mode } }));

View File

@ -134,7 +134,7 @@ export class RStackTabBar extends HTMLElement {
} }
static get observedAttributes() { static get observedAttributes() {
return ["active", "space", "view-mode"]; return ["active", "space", "view-mode", "home-active"];
} }
get active(): string { get active(): string {
@ -415,15 +415,14 @@ export class RStackTabBar extends HTMLElement {
<style>${STYLES}</style> <style>${STYLES}</style>
<div class="tab-bar" data-view="${this.#viewMode}"> <div class="tab-bar" data-view="${this.#viewMode}">
<div class="tabs-scroll"> <div class="tabs-scroll">
${this.#layers.length === 0 <button class="tab-home ${!active || this.getAttribute('home-active') === 'true' ? 'tab-home--active' : ''}"
? `<div class="tab active tab--dashboard"> id="home-btn" title="Dashboard">
<span class="tab-indicator" style="background:#5eead4"></span> <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<span class="tab-badge" style="background:#5eead4"> <path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg> </svg>
</span> <span class="tab-home__label">Home</span>
<span class="tab-label">Dashboard</span> </button>
</div>` ${this.#layers.map(l => this.#renderTab(l, active)).join("")}
: this.#layers.map(l => this.#renderTab(l, active)).join("")}
</div> </div>
<div class="tab-add-wrap"> <div class="tab-add-wrap">
<button class="tab-add" id="add-btn" title="Add layer">+</button> <button class="tab-add" id="add-btn" title="Add layer">+</button>
@ -942,6 +941,11 @@ export class RStackTabBar extends HTMLElement {
// Clean up previous document-level listeners to prevent leak // Clean up previous document-level listeners to prevent leak
if (this.#docCleanup) { this.#docCleanup(); this.#docCleanup = null; } if (this.#docCleanup) { this.#docCleanup(); this.#docCleanup = null; }
// Home button — always present, fires home-click event
this.#shadow.getElementById("home-btn")?.addEventListener("click", () => {
this.dispatchEvent(new CustomEvent("home-click", { bubbles: true }));
});
// Tab clicks — dispatch event but do NOT set active yet. // Tab clicks — dispatch event but do NOT set active yet.
// The shell's event handler calls switchTo() and sets active only after success. // The shell's event handler calls switchTo() and sets active only after success.
this.#shadow.querySelectorAll<HTMLElement>(".tab").forEach(tab => { this.#shadow.querySelectorAll<HTMLElement>(".tab").forEach(tab => {
@ -1535,6 +1539,38 @@ const STYLES = `
display: block; display: block;
} }
/* ── Persistent home button ── */
.tab-home {
display: flex;
flex-direction: column;
align-items: center;
gap: 1px;
padding: 4px 10px;
border: none;
background: none;
color: var(--rs-text-muted);
cursor: pointer;
border-radius: 6px;
transition: color 0.15s, background 0.15s;
flex-shrink: 0;
font-size: inherit;
font-family: inherit;
line-height: 1;
}
.tab-home:hover {
color: var(--rs-text-primary);
background: rgba(255,255,255,0.06);
}
.tab-home--active {
color: #14b8a6;
background: rgba(20,184,166,0.1);
}
.tab-home__label {
font-size: 0.55rem;
font-weight: 600;
letter-spacing: 0.02em;
}
/* Active indicator line at bottom */ /* Active indicator line at bottom */
.tab-indicator { .tab-indicator {
position: absolute; position: absolute;

File diff suppressed because it is too large Load Diff

View File

@ -94,9 +94,10 @@ export class TabCache {
return; return;
} }
// If returning from dashboard, hide it // If returning from dashboard, hide it and clear home-active
const dashboard = document.querySelector("rstack-user-dashboard"); const dashboard = document.querySelector("rstack-user-dashboard");
if (dashboard) (dashboard as HTMLElement).style.display = "none"; if (dashboard) (dashboard as HTMLElement).style.display = "none";
document.querySelector("rstack-tab-bar")?.setAttribute("home-active", "false");
const key = this.paneKey(stateSpace, state.moduleId); const key = this.paneKey(stateSpace, state.moduleId);
if (this.panes.has(key)) { if (this.panes.has(key)) {
if (stateSpace !== this.spaceSlug) { if (stateSpace !== this.spaceSlug) {