From fab678ddbd8015f8433c227e3ad91a04dbac14e9 Mon Sep 17 00:00:00 2001 From: zestones Date: Sun, 10 May 2026 19:05:57 +0200 Subject: [PATCH 1/3] feat: Implement Team Stats sidebar panel with aggregated time-tracking statistics #51 #52 --- .../vscode-ext/__tests__/extension.test.js | 17 ++ packages/vscode-ext/package.json | 39 ++- packages/vscode-ext/src/commands.js | 11 +- packages/vscode-ext/src/extension.js | 30 ++- packages/vscode-ext/src/tracked-time-tree.js | 254 ++++++++++++++++++ .../src/webview/sidebar/team-stats/html.js | 75 ++++++ .../webview/sidebar/team-stats/provider.js | 181 ++++++++++--- .../team-stats/view/TeamStatsPanel.jsx | 139 ++++++++++ .../webview/sidebar/team-stats/view/app.jsx | 8 + .../webview/sidebar/tracked-time/provider.js | 54 ---- vite.config.vscode.webview.js | 1 + 11 files changed, 716 insertions(+), 93 deletions(-) create mode 100644 packages/vscode-ext/src/tracked-time-tree.js create mode 100644 packages/vscode-ext/src/webview/sidebar/team-stats/html.js create mode 100644 packages/vscode-ext/src/webview/sidebar/team-stats/view/TeamStatsPanel.jsx create mode 100644 packages/vscode-ext/src/webview/sidebar/team-stats/view/app.jsx delete mode 100644 packages/vscode-ext/src/webview/sidebar/tracked-time/provider.js diff --git a/packages/vscode-ext/__tests__/extension.test.js b/packages/vscode-ext/__tests__/extension.test.js index 7d6c7ff..4819717 100644 --- a/packages/vscode-ext/__tests__/extension.test.js +++ b/packages/vscode-ext/__tests__/extension.test.js @@ -43,6 +43,23 @@ vi.mock('vscode', () => ({ createTreeView: vi.fn(() => ({ dispose: vi.fn() })), registerWebviewViewProvider: vi.fn(() => ({ dispose: vi.fn() })), }, + workspace: { + workspaceFolders: [], + onDidChangeWorkspaceFolders: vi.fn(() => ({ dispose: vi.fn() })), + fs: { readFile: vi.fn().mockRejectedValue(new Error('not found')) }, + }, + Uri: { joinPath: vi.fn((base, ...segs) => ({ ...base, path: `${base?.path ?? ''}/${segs.join('/')}` })) }, + ThemeIcon: class ThemeIcon { + constructor(id, color) { + this.id = id; + this.color = color; + } + }, + ThemeColor: class ThemeColor { + constructor(id) { + this.id = id; + } + }, })); vi.mock('../../core/src/services/sync.service.js', () => ({ diff --git a/packages/vscode-ext/package.json b/packages/vscode-ext/package.json index 87acacc..67e6e43 100644 --- a/packages/vscode-ext/package.json +++ b/packages/vscode-ext/package.json @@ -38,7 +38,6 @@ "name": "My Issues" }, { - "type": "webview", "id": "octoclock.trackedTime", "name": "Tracked Time" }, @@ -100,6 +99,17 @@ "command": "octoclock.unpinRepo", "title": "Unpin repository", "category": "OctoClock" + }, + { + "command": "octoclock.toggleWorkspaceFilter", + "title": "Toggle workspace filter", + "category": "OctoClock", + "icon": "$(filter)" + }, + { + "command": "octoclock.openDashboard", + "title": "Open Dashboard", + "category": "OctoClock" } ], "menus": { @@ -108,6 +118,11 @@ "command": "octoclock.pinRepo", "when": "view == octoclock.repoTree", "group": "navigation" + }, + { + "command": "octoclock.toggleWorkspaceFilter", + "when": "view == octoclock.trackedTime", + "group": "navigation" } ], "view/item/context": [ @@ -125,6 +140,26 @@ "command": "octoclock.editSession", "when": "viewItem == octoclock.session", "group": "octoclock@2" + }, + { + "command": "octoclock.stopTimer", + "when": "viewItem == oc-issue-active", + "group": "inline" + }, + { + "command": "octoclock.startTimer", + "when": "viewItem == oc-issue", + "group": "inline" + }, + { + "command": "octoclock.editSession", + "when": "viewItem == oc-session", + "group": "inline" + }, + { + "command": "octoclock.deleteSession", + "when": "viewItem == oc-session", + "group": "inline" } ] } @@ -139,4 +174,4 @@ "@vscode/test-cli": "0.0.12", "@vscode/test-electron": "2.5.2" } -} +} \ No newline at end of file diff --git a/packages/vscode-ext/src/commands.js b/packages/vscode-ext/src/commands.js index 677bf02..d300a76 100644 --- a/packages/vscode-ext/src/commands.js +++ b/packages/vscode-ext/src/commands.js @@ -68,12 +68,13 @@ export function normalizeIssueUrl(raw) { export function registerCommands(context) { context.subscriptions.push( // ---------------------------------------------------------------- - // octoclock.startTimer [issueUrl?] - // When called with a URL (e.g. from the My Issues panel), skip the - // input prompt and start immediately. Without an argument, fall back - // to the interactive InputBox flow. + // octoclock.startTimer [issueUrl? | TreeItem?] + // Accepts either a URL string (e.g. from My Issues), a TreeItem with an + // `issueUrl` property (Tracked Time inline action), or no argument + // (fall back to the interactive InputBox flow). // ---------------------------------------------------------------- - vscode.commands.registerCommand('octoclock.startTimer', async (issueUrl) => { + vscode.commands.registerCommand('octoclock.startTimer', async (arg) => { + let issueUrl = typeof arg === 'string' ? arg : arg?.issueUrl; if (!issueUrl) { const raw = await vscode.window.showInputBox({ prompt: 'Enter the GitHub issue URL', diff --git a/packages/vscode-ext/src/extension.js b/packages/vscode-ext/src/extension.js index f93a0cc..fa42a3b 100644 --- a/packages/vscode-ext/src/extension.js +++ b/packages/vscode-ext/src/extension.js @@ -14,9 +14,12 @@ import { VSCodeMessagingAdapter } from './adapters/vscode-messaging.adapter.js'; import { VSCodeStorageAdapter } from './adapters/vscode-storage.adapter.js'; import { registerCommands } from './commands.js'; import { createStatusBarController } from './status-bar.js'; +import { TrackedTimeProvider } from './tracked-time-tree.js'; import { RepoTreeProvider } from './tree-view.js'; +import { DashboardPanel } from './webview/dashboard/panel.js'; import { ActiveTimerProvider } from './webview/sidebar/active-timer/provider.js'; import { MyIssuesProvider } from './webview/sidebar/my-issues/provider.js'; +import { TeamStatsProvider } from './webview/sidebar/team-stats/provider.js'; /** * Called by VS Code when the extension is activated. @@ -80,7 +83,32 @@ export function activate(context) { treeProvider, ); + const trackedTimeProvider = new TrackedTimeProvider(context, storageEvents); + context.subscriptions.push( + vscode.window.createTreeView(TrackedTimeProvider.viewType, { treeDataProvider: trackedTimeProvider }), + trackedTimeProvider, + vscode.commands.registerCommand('octoclock.toggleWorkspaceFilter', () => + trackedTimeProvider.toggleWorkspaceFilter(), + ), + ); + // Initialise the `setContext` flag so the title-bar icon reflects the + // persisted toggle state from the moment the view loads. + vscode.commands.executeCommand( + 'setContext', + 'octoclock.trackedTime.workspaceFilter', + trackedTimeProvider.workspaceFilterEnabled, + ); + + const teamStatsProvider = new TeamStatsProvider(context, storageEvents); + context.subscriptions.push( + vscode.window.registerWebviewViewProvider(TeamStatsProvider.viewType, teamStatsProvider, { + webviewOptions: { retainContextWhenHidden: true }, + }), + teamStatsProvider, + vscode.commands.registerCommand('octoclock.openDashboard', () => DashboardPanel.open(context)), + ); + console.log('OctoClock: activated'); } -export function deactivate() {} +export function deactivate() { } diff --git a/packages/vscode-ext/src/tracked-time-tree.js b/packages/vscode-ext/src/tracked-time-tree.js new file mode 100644 index 0000000..09f21c8 --- /dev/null +++ b/packages/vscode-ext/src/tracked-time-tree.js @@ -0,0 +1,254 @@ +// packages/vscode-ext/src/tracked-time-tree.js +// +// TrackedTimeProvider — drives the "Tracked Time" tree view inside the +// OctoClock activity-bar container. It is the **only** OctoClock panel +// rendered as a native TreeView (every other panel is a Webview): the +// three-level expand/collapse hierarchy is trivially correct in a TreeView +// and would be costly to reproduce inside a webview. +// +// Hierarchy: +// RepoNode (owner/repo, total time) +// IssueNode (#id title, total time, may be the active issue) +// SessionNode (date — duration) +// +// Data sources: +// PINNED_REPOS — surfaced even when no sessions recorded yet +// TRACKED_TIMES — session entries, aggregated via AggregationService +// ACTIVE_ISSUE — drives the "active" icon/contextValue on the matching IssueNode +// +// Refresh triggers (TRACKED_TIMES, PINNED_REPOS, ACTIVE_ISSUE storage events). + +import * as vscode from 'vscode'; +import { StorageService } from '../../core/src/services/storage.service.js'; +import { AggregationService } from '../../core/src/utils/aggregation.utils.js'; +import { STORAGE_KEYS } from '../../core/src/utils/constants.utils.js'; +import { TimeService } from '../../core/src/utils/time.utils.js'; + +// --------------------------------------------------------------------------- +// Workspace filter helpers +// --------------------------------------------------------------------------- + +const WORKSPACE_FILTER_KEY = 'octoclock.trackedTime.workspaceFilter'; + +/** + * Returns the set of "owner/repo" pairs derivable from open workspace folders' + * git remotes. Only matches GitHub HTTPS / SSH remotes; non-git folders or + * folders without a `.git/config` are silently ignored. + * + * @returns {Promise>} + */ +async function getWorkspaceRepoSlugs() { + const folders = vscode.workspace.workspaceFolders ?? []; + const slugs = new Set(); + await Promise.all( + folders.map(async (folder) => { + try { + const configUri = vscode.Uri.joinPath(folder.uri, '.git', 'config'); + const bytes = await vscode.workspace.fs.readFile(configUri); + const text = Buffer.from(bytes).toString('utf8'); + // Match `url = ...github.com[:/]owner/repo(.git)?` on any remote. + const re = + /url\s*=\s*(?:https?:\/\/[^\s/]*github\.com\/|git@github\.com:)([^\s/]+)\/([^\s/]+?)(?:\.git)?\s*$/gim; + for (let m = re.exec(text); m; m = re.exec(text)) { + slugs.add(`${m[1]}/${m[2]}`); + } + } catch { + // No .git/config — folder isn't a git repo. Skip. + } + }), + ); + return slugs; +} + +// --------------------------------------------------------------------------- +// Node classes +// --------------------------------------------------------------------------- + +export class TrackedRepoNode extends vscode.TreeItem { + /** + * @param {string} fullName e.g. "owner/repo" + * @param {number} totalSeconds aggregate of all child issue sessions + * @param {TrackedIssueNode[]} issueNodes + */ + constructor(fullName, totalSeconds, issueNodes) { + const state = + issueNodes.length > 0 ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.None; + super(fullName, state); + this.fullName = fullName; + this.issueNodes = issueNodes; + this.totalSeconds = totalSeconds; + this.iconPath = new vscode.ThemeIcon('repo'); + this.description = totalSeconds > 0 ? TimeService.formatHuman(totalSeconds) : ''; + this.tooltip = totalSeconds > 0 ? `Total: ${TimeService.formatHuman(totalSeconds)}` : 'No sessions yet'; + this.contextValue = 'oc-repo'; + } +} + +export class TrackedIssueNode extends vscode.TreeItem { + /** + * @param {string} issueUrl e.g. "/owner/repo/issues/123" + * @param {string} displayTitle + * @param {number} totalSeconds + * @param {TrackedSessionNode[]} sessionNodes + * @param {boolean} isActive true when this issue currently has the running timer + */ + constructor(issueUrl, displayTitle, totalSeconds, sessionNodes, isActive) { + const state = + sessionNodes.length > 0 ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.None; + super(displayTitle, state); + this.issueUrl = issueUrl; + this.totalSeconds = totalSeconds; + this.sessionNodes = sessionNodes; + this.isActive = isActive; + this.description = TimeService.formatHuman(totalSeconds); + this.tooltip = `${issueUrl}\nTotal: ${TimeService.formatHuman(totalSeconds)}`; + if (isActive) { + this.iconPath = new vscode.ThemeIcon('clock', new vscode.ThemeColor('charts.green')); + this.contextValue = 'oc-issue-active'; + } else { + this.iconPath = new vscode.ThemeIcon('history'); + this.contextValue = 'oc-issue'; + } + } +} + +export class TrackedSessionNode extends vscode.TreeItem { + /** + * @param {string} issueUrl + * @param {string} date "YYYY-MM-DD" + * @param {number} seconds + */ + constructor(issueUrl, date, seconds) { + super(date, vscode.TreeItemCollapsibleState.None); + this.issueUrl = issueUrl; + this.date = date; + this.seconds = seconds; + this.description = TimeService.formatHuman(seconds); + this.tooltip = `${date} — ${TimeService.formatHuman(seconds)}`; + this.iconPath = new vscode.ThemeIcon('history'); + this.contextValue = 'oc-session'; + } +} + +// --------------------------------------------------------------------------- +// Tree data provider +// --------------------------------------------------------------------------- + +export class TrackedTimeProvider { + static viewType = 'octoclock.trackedTime'; + + /** @type {vscode.EventEmitter} */ + #emitter = new vscode.EventEmitter(); + + /** VS Code subscribes to this for tree refreshes. */ + onDidChangeTreeData = this.#emitter.event; + + /** @type {() => void} */ + #unsubscribe; + + /** @type {() => void} */ + #fsWatcherDispose; + + /** @type {import('vscode').ExtensionContext} */ + #context; + + /** + * @param {import('vscode').ExtensionContext} context + * @param {import('../../core/src/ports/storage-events.port.js').StorageEventsPort} events + */ + constructor(context, events) { + this.#context = context; + this.#unsubscribe = events.subscribe((event) => { + const keys = event.type === 'removeMultiple' ? event.keys : [event.key]; + if ( + keys.includes(STORAGE_KEYS.TRACKED_TIMES) || + keys.includes(STORAGE_KEYS.PINNED_REPOS) || + keys.includes(STORAGE_KEYS.ACTIVE_ISSUE) + ) { + this.refresh(); + } + }); + + // Refresh when workspace folders change so the workspace filter stays correct. + const wsListener = vscode.workspace.onDidChangeWorkspaceFolders(() => this.refresh()); + this.#fsWatcherDispose = () => wsListener.dispose(); + } + + /** @returns {boolean} */ + get workspaceFilterEnabled() { + return this.#context.globalState.get(WORKSPACE_FILTER_KEY, false); + } + + /** + * Toggle the workspace filter and refresh the view. Persists in globalState. + * @returns {Promise} the new state + */ + async toggleWorkspaceFilter() { + const next = !this.workspaceFilterEnabled; + await this.#context.globalState.update(WORKSPACE_FILTER_KEY, next); + await vscode.commands.executeCommand('setContext', 'octoclock.trackedTime.workspaceFilter', next); + this.refresh(); + return next; + } + + /** Force a re-render of the tree. */ + refresh() { + this.#emitter.fire(undefined); + } + + /** @param {vscode.TreeItem} element */ + getTreeItem(element) { + return element; + } + + /** + * @param {TrackedRepoNode | TrackedIssueNode | TrackedSessionNode | undefined} element + */ + async getChildren(element) { + if (!element) return this.#buildRepoNodes(); + if (element instanceof TrackedRepoNode) return element.issueNodes; + if (element instanceof TrackedIssueNode) return element.sessionNodes; + return []; + } + + /** @returns {Promise} */ + async #buildRepoNodes() { + const [pinned, trackedTimes, activeIssue] = await Promise.all([ + StorageService.get(STORAGE_KEYS.PINNED_REPOS), + StorageService.get(STORAGE_KEYS.TRACKED_TIMES), + StorageService.get(STORAGE_KEYS.ACTIVE_ISSUE), + ]); + + const pinnedNames = (pinned ?? []).map((r) => r.fullName); + const breakdown = AggregationService.getRepoBreakdownDetailed(trackedTimes ?? []); + const trackedNames = Object.keys(breakdown); + let allNames = [...new Set([...pinnedNames, ...trackedNames])]; + + if (this.workspaceFilterEnabled) { + const wsSlugs = await getWorkspaceRepoSlugs(); + allNames = allNames.filter((n) => wsSlugs.has(n)); + } + + return allNames.map((fullName) => { + const issueMap = breakdown[fullName] ?? {}; + const issueNodes = Object.entries(issueMap).map(([issueUrl, data]) => { + const sessionNodes = data.sessions.map((s) => new TrackedSessionNode(issueUrl, s.date, s.seconds)); + return new TrackedIssueNode( + issueUrl, + data.title || issueUrl, + data.totalSeconds, + sessionNodes, + issueUrl === activeIssue, + ); + }); + const totalSeconds = issueNodes.reduce((s, n) => s + n.totalSeconds, 0); + return new TrackedRepoNode(fullName, totalSeconds, issueNodes); + }); + } + + dispose() { + this.#unsubscribe(); + this.#fsWatcherDispose?.(); + this.#emitter.dispose(); + } +} diff --git a/packages/vscode-ext/src/webview/sidebar/team-stats/html.js b/packages/vscode-ext/src/webview/sidebar/team-stats/html.js new file mode 100644 index 0000000..428ffe6 --- /dev/null +++ b/packages/vscode-ext/src/webview/sidebar/team-stats/html.js @@ -0,0 +1,75 @@ +// packages/vscode-ext/src/webview/sidebar/team-stats/html.js +// +// HTML shell for the Team Stats compact sidebar panel. Mirrors active-timer/html.js. + +import * as vscode from 'vscode'; +import { buildCsp } from '../../csp.js'; +import { getNonce } from '../../nonce.js'; + +/** + * @param {import('vscode').Webview} webview + * @param {import('vscode').Uri} extensionUri + * @returns {string} + */ +export function getHtml(webview, extensionUri) { + const nonce = getNonce(); + const csp = buildCsp(nonce, webview); + + const tokensUri = webview.asWebviewUri(vscode.Uri.joinPath(extensionUri, 'dist', 'webview', 'tokens.css')); + const componentsUri = webview.asWebviewUri( + vscode.Uri.joinPath(extensionUri, 'dist', 'webview', 'shared', 'components.css'), + ); + const codiconsUri = webview.asWebviewUri(vscode.Uri.joinPath(extensionUri, 'dist', 'fonts', 'codicon.ttf')); + const appUri = webview.asWebviewUri( + vscode.Uri.joinPath(extensionUri, 'dist', 'webview', 'sidebar', 'team-stats', 'app.js'), + ); + + return /* html */ ` + + + + + + + + + Team Stats + + +
+ + +`; +} diff --git a/packages/vscode-ext/src/webview/sidebar/team-stats/provider.js b/packages/vscode-ext/src/webview/sidebar/team-stats/provider.js index a91b81d..c631045 100644 --- a/packages/vscode-ext/src/webview/sidebar/team-stats/provider.js +++ b/packages/vscode-ext/src/webview/sidebar/team-stats/provider.js @@ -1,54 +1,173 @@ // packages/vscode-ext/src/webview/sidebar/team-stats/provider.js // // WebviewViewProvider for the "Team Stats" sidebar panel. -// Displays aggregated time-tracking statistics across the team. // -// TODO (UI-4): implement full Team Stats UI. +// Architecture: +// - The host aggregates EVERYONE_DATA + TRACKED_TIMES into a small +// stats payload and posts it to the webview as `{ type: 'stats' }`. +// - The webview is presentation-only (Preact) and never touches storage. +// +// Refresh triggers (per UI-4 implementation plan): +// - storage events on TRACKED_TIMES or EVERYONE_DATA (covers timer +// stop and team-sync completion in one subscription). +// - Visibility-aware 5-minute interval: when the panel is visible and +// the last refresh is older than 5 minutes, push fresh data. +// +// postMessage protocol: +// host → webview { type: 'stats', payload: { myTimeToday, teamTimeWeek, +// issuesTouchedToday, issueBars, teamRows } } +// webview → host { type: 'openDashboard' } import * as vscode from 'vscode'; -import { buildCsp } from '../../csp.js'; -import { getNonce } from '../../nonce.js'; +import { StorageService } from '../../../../../core/src/services/storage.service.js'; +import { AggregationService } from '../../../../../core/src/utils/aggregation.utils.js'; +import { STORAGE_KEYS } from '../../../../../core/src/utils/constants.utils.js'; +import { getHtml } from './html.js'; + +const FIVE_MINUTES_MS = 5 * 60 * 1000; +const TOP_ISSUE_BARS = 5; export class TeamStatsProvider { static viewType = 'octoclock.teamStats'; - /** @param {vscode.ExtensionContext} context */ - constructor(context) { + /** @type {vscode.WebviewView | undefined} */ + _view; + + /** @type {ReturnType | undefined} */ + _interval; + + /** @type {number} */ + _lastSentAt = 0; + + /** + * @param {vscode.ExtensionContext} context + * @param {import('../../../../../core/src/ports/storage-events.port.js').StorageEventsPort} events + */ + constructor(context, events) { this._context = context; + + const unsubscribe = events.subscribe((event) => { + const keys = event.type === 'removeMultiple' ? event.keys : [event.key]; + if (keys.includes(STORAGE_KEYS.TRACKED_TIMES) || keys.includes(STORAGE_KEYS.EVERYONE_DATA)) { + this._sendStats(); + } + }); + + this.dispose = () => { + unsubscribe(); + if (this._interval) clearInterval(this._interval); + }; } /** @param {vscode.WebviewView} webviewView */ resolveWebviewView(webviewView) { + this._view = webviewView; + webviewView.webview.options = { enableScripts: true, localResourceRoots: [vscode.Uri.joinPath(this._context.extensionUri, 'dist')], }; - webviewView.webview.html = this._getHtml(webviewView.webview); + + webviewView.webview.html = getHtml(webviewView.webview, this._context.extensionUri); + + webviewView.webview.onDidReceiveMessage((message) => { + if (message.type === 'openDashboard') { + vscode.commands.executeCommand('octoclock.openDashboard'); + } + }); + + // Visibility-aware refresh: refire when becoming visible if the last + // push is stale; the interval handles the steady-state. + webviewView.onDidChangeVisibility(() => { + if (webviewView.visible && Date.now() - this._lastSentAt > FIVE_MINUTES_MS) { + this._sendStats(); + } + }); + + if (!this._interval) { + this._interval = setInterval(() => { + if (this._view?.visible) this._sendStats(); + }, FIVE_MINUTES_MS); + } + + // Initial push. + this._sendStats(); + } + + async _sendStats() { + if (!this._view) return; + try { + const [trackedTimes, everyoneData] = await Promise.all([ + StorageService.get(STORAGE_KEYS.TRACKED_TIMES), + StorageService.get(STORAGE_KEYS.EVERYONE_DATA), + ]); + const payload = TeamStatsProvider._aggregate(trackedTimes ?? [], everyoneData ?? []); + this._view.webview.postMessage({ type: 'stats', payload }); + this._lastSentAt = Date.now(); + } catch { + // Storage not ready or read failed — webview keeps showing the + // previous (or "Loading…") state, no user-facing error needed. + } } - /** @param {vscode.Webview} webview */ - _getHtml(webview) { - const nonce = getNonce(); - const csp = buildCsp(nonce, webview); - const scriptUri = webview.asWebviewUri( - vscode.Uri.joinPath(this._context.extensionUri, 'dist', 'webview', 'sidebar', 'app.js'), - ); - const tokensUri = webview.asWebviewUri( - vscode.Uri.joinPath(this._context.extensionUri, 'dist', 'webview', 'tokens.css'), - ); - return /* html */ ` - - - - - - - Team Stats - - -
- - -`; + /** + * Pure aggregation — exported as a static method so it is unit-testable + * without spinning up a webview. + * + * @param {import('../../../../../core/src/utils/schema.utils.js').TrackedTimeEntry[]} trackedTimes + * @param {import('../../../../../core/src/utils/schema.utils.js').EveryoneDataEntry[]} everyoneData + */ + static _aggregate(trackedTimes, everyoneData) { + // Your time today + issues touched today (from local TRACKED_TIMES). + const todayEntries = AggregationService.getTodayEntries(trackedTimes); + const myTimeToday = AggregationService.getTotalSeconds(todayEntries); + const issuesTouchedToday = new Set(todayEntries.map((e) => e.issueUrl)).size; + + // Team time this week (everyone's entries within the current week). + const weekEntries = AggregationService.getWeekEntries(everyoneData); + const teamTimeWeek = AggregationService.getTotalSeconds(weekEntries); + + // Top N issues by total time across the team. + /** @type {Record} */ + const issueAgg = {}; + for (const e of everyoneData) { + const k = e.issueUrl; + if (!issueAgg[k]) { + issueAgg[k] = { + issueUrl: k, + title: AggregationService.extractCleanTitle(e.title), + seconds: 0, + }; + } + issueAgg[k].seconds += e.seconds || 0; + } + const issueBars = Object.values(issueAgg) + .sort((a, b) => b.seconds - a.seconds) + .slice(0, TOP_ISSUE_BARS); + + // Per-user today rows: avatar initials, name, last issue, today seconds, last activity date. + /** @type {Record} */ + const teamAgg = {}; + for (const e of everyoneData) { + const u = e.user; + if (!u) continue; + if (!teamAgg[u]) { + teamAgg[u] = { user: u, todaySeconds: 0, lastDate: '', lastIssueUrl: '', lastIssueTitle: '' }; + } + if (e.date && e.date > teamAgg[u].lastDate) { + teamAgg[u].lastDate = e.date; + teamAgg[u].lastIssueUrl = e.issueUrl; + teamAgg[u].lastIssueTitle = AggregationService.extractCleanTitle(e.title); + } + } + const todayStr = new Date().toISOString().slice(0, 10); + for (const e of everyoneData) { + if (e.date === todayStr && e.user) { + teamAgg[e.user].todaySeconds += e.seconds || 0; + } + } + const teamRows = Object.values(teamAgg).sort((a, b) => b.todaySeconds - a.todaySeconds); + + return { myTimeToday, teamTimeWeek, issuesTouchedToday, issueBars, teamRows }; } } diff --git a/packages/vscode-ext/src/webview/sidebar/team-stats/view/TeamStatsPanel.jsx b/packages/vscode-ext/src/webview/sidebar/team-stats/view/TeamStatsPanel.jsx new file mode 100644 index 0000000..b044098 --- /dev/null +++ b/packages/vscode-ext/src/webview/sidebar/team-stats/view/TeamStatsPanel.jsx @@ -0,0 +1,139 @@ +// packages/vscode-ext/src/webview/sidebar/team-stats/view/TeamStatsPanel.jsx +// +// Compact stats panel: KPI cards (your time today, team time this week), +// horizontal bar chart of top issues, team activity rows, and the +// "Full Dashboard →" button. +// +// Receives a single `stats` message from the host carrying everything +// pre-aggregated; the webview is presentation-only. + +import { useState } from 'preact/hooks'; +import { useVscodeMessage } from '../../../shared/hooks/useVscodeMessage.js'; + +// eslint-disable-next-line no-undef +const vscode = acquireVsCodeApi(); + +/** @param {number} seconds */ +function fmtHM(seconds) { + if (!seconds || seconds < 0) return '0h'; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + if (h > 0) return m > 0 ? `${h}h ${m}m` : `${h}h`; + return `${m}m`; +} + +/** @param {string} login */ +function initials(login) { + if (!login) return '?'; + const parts = login.replace(/[-_]/g, ' ').split(/\s+/).filter(Boolean); + if (parts.length === 0) return '?'; + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + return (parts[0][0] + parts[1][0]).toUpperCase(); +} + +/** @param {string} dateStr — "YYYY-MM-DD" */ +function recencyLabel(dateStr) { + if (!dateStr) return ''; + const today = new Date(); + today.setHours(0, 0, 0, 0); + const d = new Date(dateStr); + const diff = Math.floor((today.getTime() - d.getTime()) / 86400000); + if (diff <= 0) return 'today'; + if (diff === 1) return 'yesterday'; + if (diff < 7) return `${diff}d ago`; + return dateStr; +} + +export function TeamStatsPanel() { + const [stats, setStats] = useState(/** @type {any} */(null)); + + useVscodeMessage('stats', (msg) => setStats(msg.payload)); + + if (!stats) { + return ( +
+
Loading team stats…
+
+ ); + } + + const { myTimeToday, teamTimeWeek, issuesTouchedToday, issueBars, teamRows } = stats; + const maxBar = issueBars && issueBars.length > 0 ? Math.max(...issueBars.map((b) => b.seconds), 1) : 1; + + return ( +
+
+
+
+
Your time today
+
{fmtHM(myTimeToday)}
+
+ {issuesTouchedToday || 0} issue{issuesTouchedToday === 1 ? '' : 's'} +
+
+
+
Team this week
+
{fmtHM(teamTimeWeek)}
+
+ {(teamRows || []).length} member{teamRows && teamRows.length === 1 ? '' : 's'} +
+
+
+
+ +
+
Top issues
+ {!issueBars || issueBars.length === 0 ? ( +
No tracked time yet
+ ) : ( +
+ {issueBars.map((b) => ( +
+
+ + {b.title} + + {fmtHM(b.seconds)} +
+
+
+
+
+ ))} +
+ )} +
+ +
+
Team today
+ {!teamRows || teamRows.length === 0 ? ( +
No team activity today
+ ) : ( + teamRows.map((r) => ( +
+ {initials(r.user)} +
+
{r.user}
+
+ {r.lastIssueTitle || '—'} · {recencyLabel(r.lastDate)} +
+
+ {fmtHM(r.todaySeconds)} +
+ )) + )} +
+ + +
+ ); +} diff --git a/packages/vscode-ext/src/webview/sidebar/team-stats/view/app.jsx b/packages/vscode-ext/src/webview/sidebar/team-stats/view/app.jsx new file mode 100644 index 0000000..2fde69b --- /dev/null +++ b/packages/vscode-ext/src/webview/sidebar/team-stats/view/app.jsx @@ -0,0 +1,8 @@ +// packages/vscode-ext/src/webview/sidebar/team-stats/view/app.jsx +// +// Entry point for the Team Stats webview bundle. + +import { h, render } from 'preact'; +import { TeamStatsPanel } from './TeamStatsPanel.jsx'; + +render(h(TeamStatsPanel, null), /** @type {Element} */(document.getElementById('app'))); diff --git a/packages/vscode-ext/src/webview/sidebar/tracked-time/provider.js b/packages/vscode-ext/src/webview/sidebar/tracked-time/provider.js deleted file mode 100644 index a9b2c5f..0000000 --- a/packages/vscode-ext/src/webview/sidebar/tracked-time/provider.js +++ /dev/null @@ -1,54 +0,0 @@ -// packages/vscode-ext/src/webview/sidebar/tracked-time/provider.js -// -// WebviewViewProvider for the "Tracked Time" sidebar panel. -// Shows a breakdown of time tracked per issue / repository. -// -// TODO (UI-3): implement full Tracked Time UI. - -import * as vscode from 'vscode'; -import { buildCsp } from '../../csp.js'; -import { getNonce } from '../../nonce.js'; - -export class TrackedTimeProvider { - static viewType = 'octoclock.trackedTime'; - - /** @param {vscode.ExtensionContext} context */ - constructor(context) { - this._context = context; - } - - /** @param {vscode.WebviewView} webviewView */ - resolveWebviewView(webviewView) { - webviewView.webview.options = { - enableScripts: true, - localResourceRoots: [vscode.Uri.joinPath(this._context.extensionUri, 'dist')], - }; - webviewView.webview.html = this._getHtml(webviewView.webview); - } - - /** @param {vscode.Webview} webview */ - _getHtml(webview) { - const nonce = getNonce(); - const csp = buildCsp(nonce, webview); - const scriptUri = webview.asWebviewUri( - vscode.Uri.joinPath(this._context.extensionUri, 'dist', 'webview', 'sidebar', 'app.js'), - ); - const tokensUri = webview.asWebviewUri( - vscode.Uri.joinPath(this._context.extensionUri, 'dist', 'webview', 'tokens.css'), - ); - return /* html */ ` - - - - - - - Tracked Time - - -
- - -`; - } -} diff --git a/vite.config.vscode.webview.js b/vite.config.vscode.webview.js index 616d121..db5de4b 100644 --- a/vite.config.vscode.webview.js +++ b/vite.config.vscode.webview.js @@ -53,6 +53,7 @@ export default defineConfig({ input: { 'sidebar/active-timer/app': resolve(webviewDir, 'sidebar/active-timer/view/app.jsx'), 'sidebar/my-issues/app': resolve(webviewDir, 'sidebar/my-issues/view/app.jsx'), + 'sidebar/team-stats/app': resolve(webviewDir, 'sidebar/team-stats/view/app.jsx'), 'dashboard/app': resolve(webviewDir, 'dashboard/app.js'), }, output: { From 9fee4c36947a46de2399865423c8a637fe82d601 Mon Sep 17 00:00:00 2001 From: zestones Date: Sun, 10 May 2026 19:11:07 +0200 Subject: [PATCH 2/3] feat: Refactor Dashboard webview structure with new components and filtering logic #55 #56 #57 --- .../vscode-ext/src/webview/dashboard/app.js | 28 --- .../src/webview/dashboard/filtering.js | 180 ++++++++++++++++++ .../vscode-ext/src/webview/dashboard/html.js | 110 +++++++++++ .../vscode-ext/src/webview/dashboard/panel.js | 152 +++++++++++---- .../webview/dashboard/view/DashboardApp.jsx | 107 +++++++++++ .../webview/dashboard/view/OverviewView.jsx | 117 ++++++++++++ .../webview/dashboard/view/SessionsView.jsx | 109 +++++++++++ .../src/webview/dashboard/view/Topbar.jsx | 60 ++++++ .../src/webview/dashboard/view/app.jsx | 8 + vite.config.vscode.webview.js | 2 +- 10 files changed, 807 insertions(+), 66 deletions(-) delete mode 100644 packages/vscode-ext/src/webview/dashboard/app.js create mode 100644 packages/vscode-ext/src/webview/dashboard/filtering.js create mode 100644 packages/vscode-ext/src/webview/dashboard/html.js create mode 100644 packages/vscode-ext/src/webview/dashboard/view/DashboardApp.jsx create mode 100644 packages/vscode-ext/src/webview/dashboard/view/OverviewView.jsx create mode 100644 packages/vscode-ext/src/webview/dashboard/view/SessionsView.jsx create mode 100644 packages/vscode-ext/src/webview/dashboard/view/Topbar.jsx create mode 100644 packages/vscode-ext/src/webview/dashboard/view/app.jsx diff --git a/packages/vscode-ext/src/webview/dashboard/app.js b/packages/vscode-ext/src/webview/dashboard/app.js deleted file mode 100644 index bccecde..0000000 --- a/packages/vscode-ext/src/webview/dashboard/app.js +++ /dev/null @@ -1,28 +0,0 @@ -// packages/vscode-ext/src/webview/dashboard/app.js -// -// Dashboard webview bundle entry point. -// -// This script runs inside the OctoClock dashboard WebviewPanel. -// The panel passes the full session/repo dataset via an initial -// { type: 'init', data: { … } } message. -// -// TODO (UI-5): replace stub with real dashboard components. - -// acquireVsCodeApi is injected by VS Code into every webview context. -// eslint-disable-next-line no-undef -const vscode = acquireVsCodeApi(); - -document.addEventListener('DOMContentLoaded', () => { - const app = document.getElementById('app'); - if (app) { - app.textContent = 'OctoClock dashboard loading…'; - } -}); - -// Keep the compiler happy — vscode is used once we have real panels. -void vscode; - -// Required: marks this file as an ES module so TypeScript keeps its -// declarations in module scope (prevents redeclaration conflicts across -// webview entry points in the same tsconfig compilation unit). -export {}; diff --git a/packages/vscode-ext/src/webview/dashboard/filtering.js b/packages/vscode-ext/src/webview/dashboard/filtering.js new file mode 100644 index 0000000..d2aa0c9 --- /dev/null +++ b/packages/vscode-ext/src/webview/dashboard/filtering.js @@ -0,0 +1,180 @@ +// packages/vscode-ext/src/webview/dashboard/filtering.js +// +// Pure, host-side filtering helpers for the Dashboard panel. +// Kept separate from `panel.js` so they can be unit-tested without +// spinning up a webview and so the panel stays small and readable. +// +// The webview never imports these — it only requests data via the +// message protocol (`rangeChange` / `filterChange`) and renders what the +// host returns. All business logic stays in the extension host. + +import { AggregationService } from '../../../../core/src/utils/aggregation.utils.js'; +import { TimeService } from '../../../../core/src/utils/time.utils.js'; + +/** + * Compute [startDate, endDate] (inclusive, "YYYY-MM-DD") for a given range. + * + * @param {{ range: 'today' | 'week' | 'month' | 'all', weekOffset?: number, customStart?: string, customEnd?: string }} params + * @returns {{ start: string | null, end: string | null }} + */ +export function computeDateRange({ range, weekOffset = 0, customStart, customEnd }) { + if (customStart && customEnd) return { start: customStart, end: customEnd }; + const today = new Date(); + if (range === 'today') { + const d = TimeService.getLocalDateString(today); + return { start: d, end: d }; + } + if (range === 'week') { + const dayOfWeek = today.getDay(); + const daysFromMonday = (dayOfWeek + 6) % 7; + const monday = new Date(today); + monday.setDate(today.getDate() - daysFromMonday + weekOffset * 7); + const sunday = new Date(monday); + sunday.setDate(monday.getDate() + 6); + return { + start: TimeService.getLocalDateString(monday), + end: TimeService.getLocalDateString(sunday), + }; + } + if (range === 'month') { + const first = new Date(today.getFullYear(), today.getMonth(), 1); + const last = new Date(today.getFullYear(), today.getMonth() + 1, 0); + return { + start: TimeService.getLocalDateString(first), + end: TimeService.getLocalDateString(last), + }; + } + // 'all' + return { start: null, end: null }; +} + +/** + * Filter an array of session-like entries (TrackedTimeEntry or EveryoneDataEntry) + * by an inclusive date window. `null` start/end means "no bound on that side". + * + * @template {{ date: string }} T + * @param {T[]} entries + * @param {{ start: string | null, end: string | null }} window + * @returns {T[]} + */ +export function filterByWindow(entries, { start, end }) { + if (!start && !end) return entries; + return entries.filter((e) => { + if (start && e.date < start) return false; + if (end && e.date > end) return false; + return true; + }); +} + +/** + * Aggregate filtered entries into the Dashboard's `data` payload. + * + * `entries` is the canonical sessions array; we treat it as `EveryoneDataEntry[]` + * (each row has `user`) but local-user-only TrackedTimeEntry rows (no `user`) + * are also accepted: they are bucketed under the synthetic key `'(you)'`. + * + * @param {Array<{ issueUrl: string, title: string, seconds: number, date: string, user?: string }>} entries + * @returns {{ + * total: number, + * sessionsCount: number, + * issuesTouched: number, + * membersCount: number, + * dailyBuckets: Array<{ date: string, seconds: number }>, + * issueRows: Array<{ issueUrl: string, title: string, seconds: number, sessions: number, members: string[] }>, + * memberRows: Array<{ user: string, seconds: number, sessions: number, lastIssueTitle: string, lastDate: string }>, + * sessionLog: Array<{ issueUrl: string, title: string, seconds: number, date: string, user: string }> + * }} + */ +export function aggregate(entries) { + const total = AggregationService.getTotalSeconds(entries); + const sessionsCount = entries.length; + + /** @type {Map} */ + const dayMap = new Map(); + /** @type {Map }>} */ + const issueMap = new Map(); + /** @type {Map} */ + const memberMap = new Map(); + + for (const e of entries) { + const user = e.user || '(you)'; + const seconds = e.seconds || 0; + + // Daily totals. + const day = dayMap.get(e.date) ?? { date: e.date, seconds: 0 }; + day.seconds += seconds; + dayMap.set(e.date, day); + + // Per-issue. + const cleanTitle = AggregationService.extractCleanTitle(e.title); + const ir = issueMap.get(e.issueUrl) ?? { + issueUrl: e.issueUrl, + title: cleanTitle, + seconds: 0, + sessions: 0, + members: new Set(), + }; + ir.seconds += seconds; + ir.sessions += 1; + ir.members.add(user); + issueMap.set(e.issueUrl, ir); + + // Per-member. + const mr = memberMap.get(user) ?? { + user, + seconds: 0, + sessions: 0, + lastIssueTitle: '', + lastDate: '', + }; + mr.seconds += seconds; + mr.sessions += 1; + if (!mr.lastDate || e.date > mr.lastDate) { + mr.lastDate = e.date; + mr.lastIssueTitle = cleanTitle; + } + memberMap.set(user, mr); + } + + const dailyBuckets = [...dayMap.values()].sort((a, b) => (a.date < b.date ? -1 : 1)); + const issueRows = [...issueMap.values()] + .map((r) => ({ ...r, members: [...r.members] })) + .sort((a, b) => b.seconds - a.seconds); + const memberRows = [...memberMap.values()].sort((a, b) => b.seconds - a.seconds); + const sessionLog = [...entries] + .map((e) => ({ + issueUrl: e.issueUrl, + title: AggregationService.extractCleanTitle(e.title), + seconds: e.seconds || 0, + date: e.date, + user: e.user || '(you)', + })) + .sort((a, b) => (a.date < b.date ? 1 : -1)); + + return { + total, + sessionsCount, + issuesTouched: issueMap.size, + membersCount: memberMap.size, + dailyBuckets, + issueRows, + memberRows, + sessionLog, + }; +} + +/** + * Apply the (optional) member + issue filter sent by the webview. + * + * @template {{ user?: string, issueUrl: string }} T + * @param {T[]} entries + * @param {{ memberFilter?: string | null, issueFilter?: string | null }} filters + * @returns {T[]} + */ +export function applyFilters(entries, { memberFilter, issueFilter } = {}) { + return entries.filter((e) => { + if (memberFilter && (e.user || '(you)') !== memberFilter) return false; + if (issueFilter && e.issueUrl !== issueFilter) return false; + return true; + }); +} diff --git a/packages/vscode-ext/src/webview/dashboard/html.js b/packages/vscode-ext/src/webview/dashboard/html.js new file mode 100644 index 0000000..d60526a --- /dev/null +++ b/packages/vscode-ext/src/webview/dashboard/html.js @@ -0,0 +1,110 @@ +// packages/vscode-ext/src/webview/dashboard/html.js +// +// HTML shell for the OctoClock Dashboard WebviewPanel. + +import * as vscode from 'vscode'; +import { buildCsp } from '../csp.js'; +import { getNonce } from '../nonce.js'; + +/** + * @param {import('vscode').Webview} webview + * @param {import('vscode').Uri} extensionUri + * @returns {string} + */ +export function getHtml(webview, extensionUri) { + const nonce = getNonce(); + const csp = buildCsp(nonce, webview); + + const tokensUri = webview.asWebviewUri(vscode.Uri.joinPath(extensionUri, 'dist', 'webview', 'tokens.css')); + const componentsUri = webview.asWebviewUri( + vscode.Uri.joinPath(extensionUri, 'dist', 'webview', 'shared', 'components.css'), + ); + const codiconsUri = webview.asWebviewUri(vscode.Uri.joinPath(extensionUri, 'dist', 'fonts', 'codicon.ttf')); + const appUri = webview.asWebviewUri(vscode.Uri.joinPath(extensionUri, 'dist', 'webview', 'dashboard', 'app.js')); + + return /* html */ ` + + + + + + + + + OctoClock Dashboard + + +
+ + +`; +} diff --git a/packages/vscode-ext/src/webview/dashboard/panel.js b/packages/vscode-ext/src/webview/dashboard/panel.js index ccfe3b3..15f884e 100644 --- a/packages/vscode-ext/src/webview/dashboard/panel.js +++ b/packages/vscode-ext/src/webview/dashboard/panel.js @@ -1,28 +1,52 @@ // packages/vscode-ext/src/webview/dashboard/panel.js // -// WebviewPanel for the full-screen OctoClock dashboard. -// Opened via the "Open Dashboard" command; displays aggregated stats -// across all pinned repositories with chart-style time breakdowns. +// DashboardPanel — singleton WebviewPanel for the full-screen OctoClock dashboard. // -// TODO (UI-5): implement full Dashboard UI. +// Architectural notes: +// - Singleton via `DashboardPanel.currentPanel`. `open(context)` reveals +// the existing panel or creates a new one. +// - All filtering happens HOST-SIDE (`filtering.js`); the webview only +// receives pre-computed payloads via the `data` message. +// - State that is purely visual (active tab, scroll, expand/collapse) +// stays in the webview and is preserved across hide/show by +// `retainContextWhenHidden: true`. +// +// Message protocol: +// webview → host +// { type: 'ready' } +// { type: 'rangeChange', range, weekOffset?, customStart?, customEnd? } +// { type: 'filterChange', memberFilter?, issueFilter? } +// { type: 'memberDrill', memberId } // UI-6 +// +// host → webview +// { type: 'init', sessions, payload } +// { type: 'data', payload } +// { type: 'memberDetail', member, payload } // UI-6 import * as vscode from 'vscode'; -import { buildCsp } from '../csp.js'; -import { getNonce } from '../nonce.js'; +import { StorageService } from '../../../../core/src/services/storage.service.js'; +import { STORAGE_KEYS } from '../../../../core/src/utils/constants.utils.js'; +import { aggregate, applyFilters, computeDateRange, filterByWindow } from './filtering.js'; +import { getHtml } from './html.js'; export class DashboardPanel { static viewType = 'octoclock.dashboard'; /** @type {DashboardPanel | undefined} */ - static _current; + static currentPanel; - /** @param {vscode.ExtensionContext} context */ + /** + * Reveal the existing panel or create a new one. + * Mirrors the `createOrShow` convention from the implementation plan. + * + * @param {vscode.ExtensionContext} context + */ static open(context) { - if (DashboardPanel._current) { - DashboardPanel._current._panel.reveal(); + if (DashboardPanel.currentPanel) { + DashboardPanel.currentPanel._panel.reveal(); return; } - DashboardPanel._current = new DashboardPanel(context); + DashboardPanel.currentPanel = new DashboardPanel(context); } /** @param {vscode.ExtensionContext} context */ @@ -38,35 +62,89 @@ export class DashboardPanel { retainContextWhenHidden: true, }, ); - this._panel.webview.html = this._getHtml(this._panel.webview); + this._panel.webview.html = getHtml(this._panel.webview, context.extensionUri); + + this._panel.webview.onDidReceiveMessage((msg) => this._handleMessage(msg)); + this._panel.onDidDispose(() => { - DashboardPanel._current = undefined; + DashboardPanel.currentPanel = undefined; }); } - /** @param {vscode.Webview} webview */ - _getHtml(webview) { - const nonce = getNonce(); - const csp = buildCsp(nonce, webview); - const scriptUri = webview.asWebviewUri( - vscode.Uri.joinPath(this._context.extensionUri, 'dist', 'webview', 'dashboard', 'app.js'), - ); - const tokensUri = webview.asWebviewUri( - vscode.Uri.joinPath(this._context.extensionUri, 'dist', 'webview', 'tokens.css'), - ); - return /* html */ ` - - - - - - - OctoClock Dashboard - - -
- - -`; + /** @param {{ type: string, [k: string]: any }} msg */ + async _handleMessage(msg) { + if (msg.type === 'ready') return this._sendInit(); + if (msg.type === 'rangeChange' || msg.type === 'filterChange') return this._sendFiltered(msg); + // 'memberDrill' is UI-6 — accept now without crashing if a stale build sends it. + } + + async _sendInit() { + const allSessions = await DashboardPanel._loadSessions(); + // Default range: current week, no filters. + const window = computeDateRange({ range: 'week', weekOffset: 0 }); + const filtered = filterByWindow(allSessions, window); + const payload = aggregate(filtered); + this._panel.webview.postMessage({ + type: 'init', + range: 'week', + weekOffset: 0, + window, + payload, + }); + } + + /** + * Handle both `rangeChange` and `filterChange`. The webview always sends + * its full current state, so the host can compute the answer in one shot + * without keeping shadow state. + * + * @param {{ type: string, range?: string, weekOffset?: number, customStart?: string, customEnd?: string, memberFilter?: string|null, issueFilter?: string|null }} msg + */ + async _sendFiltered(msg) { + const allSessions = await DashboardPanel._loadSessions(); + const window = computeDateRange({ + range: /** @type {any} */ (msg.range || 'week'), + weekOffset: msg.weekOffset || 0, + customStart: msg.customStart, + customEnd: msg.customEnd, + }); + const inRange = filterByWindow(allSessions, window); + const filtered = applyFilters(inRange, { + memberFilter: msg.memberFilter ?? null, + issueFilter: msg.issueFilter ?? null, + }); + const payload = aggregate(filtered); + this._panel.webview.postMessage({ + type: 'data', + range: msg.range || 'week', + weekOffset: msg.weekOffset || 0, + window, + memberFilter: msg.memberFilter ?? null, + issueFilter: msg.issueFilter ?? null, + payload, + }); + } + + /** + * Merge EVERYONE_DATA + TRACKED_TIMES into a single sessions array. + * Local-only entries (no `user`) are kept so the panel works pre-sync. + * + * @returns {Promise>} + */ + static async _loadSessions() { + const [trackedTimes, everyoneData] = await Promise.all([ + StorageService.get(STORAGE_KEYS.TRACKED_TIMES), + StorageService.get(STORAGE_KEYS.EVERYONE_DATA), + ]); + const everyone = everyoneData ?? []; + const local = trackedTimes ?? []; + // Avoid double-counting: if a local entry exists in EVERYONE_DATA + // (issueUrl + date + seconds + user-field-absent vs same user) skip it. + // Heuristic — match on (issueUrl, date, seconds). EveryoneDataEntry rows + // have `user`; local rows do not. Local rows that are NOT mirrored in + // EVERYONE_DATA show up as the `(you)` synthetic user in aggregation. + const everyoneKeys = new Set(everyone.map((e) => `${e.issueUrl}|${e.date}|${e.seconds}`)); + const localOnly = local.filter((e) => !everyoneKeys.has(`${e.issueUrl}|${e.date}|${e.seconds}`)); + return [...everyone, ...localOnly]; } } diff --git a/packages/vscode-ext/src/webview/dashboard/view/DashboardApp.jsx b/packages/vscode-ext/src/webview/dashboard/view/DashboardApp.jsx new file mode 100644 index 0000000..3e5eb08 --- /dev/null +++ b/packages/vscode-ext/src/webview/dashboard/view/DashboardApp.jsx @@ -0,0 +1,107 @@ +// packages/vscode-ext/src/webview/dashboard/view/DashboardApp.jsx +// +// Top-level dashboard component — handles the message protocol with the +// host, manages local UI state (active tab, range, filters), and routes +// to the per-view renderers. + +import { useEffect, useState } from 'preact/hooks'; +import { useVscodeMessage } from '../../shared/hooks/useVscodeMessage.js'; +import { OverviewView } from './OverviewView.jsx'; +import { SessionsView } from './SessionsView.jsx'; +import { Topbar } from './Topbar.jsx'; + +// eslint-disable-next-line no-undef +const vscode = acquireVsCodeApi(); + +/** @typedef {'overview' | 'sessions'} TabId */ + +const TABS = /** @type {Array<{ id: TabId, label: string, icon: string }>} */ ([ + { id: 'overview', label: 'Overview', icon: 'graph' }, + { id: 'sessions', label: 'Sessions', icon: 'history' }, +]); + +export function DashboardApp() { + const [activeTab, setActiveTab] = useState(/** @type {TabId} */ ('overview')); + const [range, setRange] = useState(/** @type {'today'|'week'|'month'|'all'} */ ('week')); + const [weekOffset, setWeekOffset] = useState(0); + const [memberFilter, setMemberFilter] = useState(/** @type {string|null} */ (null)); + const [issueFilter, setIssueFilter] = useState(/** @type {string|null} */ (null)); + const [payload, setPayload] = useState(/** @type {any} */ (null)); + const [windowRange, setWindowRange] = useState( + /** @type {{ start: string|null, end: string|null }} */ ({ start: null, end: null }), + ); + + // Send `ready` once on mount so the host pushes init data. + useEffect(() => { + vscode.postMessage({ type: 'ready' }); + }, []); + + useVscodeMessage('init', (msg) => { + setPayload(msg.payload); + setWindowRange(msg.window); + if (msg.range) setRange(msg.range); + if (typeof msg.weekOffset === 'number') setWeekOffset(msg.weekOffset); + }); + useVscodeMessage('data', (msg) => { + setPayload(msg.payload); + setWindowRange(msg.window); + }); + + /** + * @param {{ range?: string, weekOffset?: number, memberFilter?: string|null, issueFilter?: string|null }} patch + */ + const requestData = (patch) => { + const next = { + range: patch.range ?? range, + weekOffset: patch.weekOffset ?? weekOffset, + memberFilter: patch.memberFilter !== undefined ? patch.memberFilter : memberFilter, + issueFilter: patch.issueFilter !== undefined ? patch.issueFilter : issueFilter, + }; + if (patch.range !== undefined) setRange(/** @type {any} */ (patch.range)); + if (patch.weekOffset !== undefined) setWeekOffset(patch.weekOffset); + if (patch.memberFilter !== undefined) setMemberFilter(patch.memberFilter); + if (patch.issueFilter !== undefined) setIssueFilter(patch.issueFilter); + vscode.postMessage({ type: 'rangeChange', ...next }); + }; + + return ( +
+ requestData({ range: r, weekOffset: 0 })} + onWeekShift={(delta) => requestData({ range: 'week', weekOffset: weekOffset + delta })} + /> +
+ {TABS.map((t) => ( + + ))} +
+
+ {!payload ? ( +
Loading…
+ ) : activeTab === 'overview' ? ( + + ) : ( + requestData(p)} + /> + )} +
+
+ ); +} diff --git a/packages/vscode-ext/src/webview/dashboard/view/OverviewView.jsx b/packages/vscode-ext/src/webview/dashboard/view/OverviewView.jsx new file mode 100644 index 0000000..ebe40d6 --- /dev/null +++ b/packages/vscode-ext/src/webview/dashboard/view/OverviewView.jsx @@ -0,0 +1,117 @@ +// packages/vscode-ext/src/webview/dashboard/view/OverviewView.jsx +// +// Overview tab: KPI strip, daily bar chart, top-issue table, team leaderboard. + +/** @param {number} seconds */ +function fmtHM(seconds) { + if (!seconds || seconds < 0) return '0h'; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + if (h > 0) return m > 0 ? `${h}h ${m}m` : `${h}h`; + return `${m}m`; +} + +/** @param {{ payload: any }} props */ +export function OverviewView({ payload }) { + const { total, sessionsCount, issuesTouched, membersCount, dailyBuckets, issueRows, memberRows } = payload; + const maxDay = dailyBuckets.length > 0 ? Math.max(...dailyBuckets.map((d) => d.seconds), 1) : 1; + + return ( +
+
+
+
Total time
+
{fmtHM(total)}
+
in selected range
+
+
+
Sessions
+
{sessionsCount}
+
recorded
+
+
+
Issues
+
{issuesTouched}
+
touched
+
+
+
Members
+
{membersCount}
+
active
+
+
+ +
+
Daily breakdown
+ {dailyBuckets.length === 0 ? ( +
No data in this range
+ ) : ( +
+ {dailyBuckets.map((d) => ( +
+
+
{d.date.slice(5)}
+
+ ))} +
+ )} +
+ +
+

Top issues

+ {issueRows.length === 0 ? ( +
No issues tracked in this range
+ ) : ( + + + + + + + + + + + {issueRows.map((r) => ( + + + + + + + ))} + +
IssueMembersSessionsTotal
{r.title}{r.members.join(', ')}{r.sessions}{fmtHM(r.seconds)}
+ )} +
+ +
+

Team leaderboard

+ {memberRows.length === 0 ? ( +
No member activity in this range
+ ) : ( + + + + + + + + + + + {memberRows.map((r) => ( + + + + + + + ))} + +
MemberLast issueSessionsTotal
{r.user}{r.lastIssueTitle || '—'}{r.sessions}{fmtHM(r.seconds)}
+ )} +
+
+ ); +} diff --git a/packages/vscode-ext/src/webview/dashboard/view/SessionsView.jsx b/packages/vscode-ext/src/webview/dashboard/view/SessionsView.jsx new file mode 100644 index 0000000..b81cc11 --- /dev/null +++ b/packages/vscode-ext/src/webview/dashboard/view/SessionsView.jsx @@ -0,0 +1,109 @@ +// packages/vscode-ext/src/webview/dashboard/view/SessionsView.jsx +// +// Sessions tab: filter dropdowns (member + issue) and a date-grouped session log. +// Filtering happens host-side; this view only renders what the host returns. + +import { useMemo } from 'preact/hooks'; + +/** @param {number} seconds */ +function fmtHM(seconds) { + if (!seconds || seconds < 0) return '0h'; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + if (h > 0) return m > 0 ? `${h}h ${m}m` : `${h}h`; + return `${m}m`; +} + +/** + * @param {{ + * payload: any, + * memberFilter: string|null, + * issueFilter: string|null, + * onFilterChange: (patch: { memberFilter?: string|null, issueFilter?: string|null }) => void, + * }} props + */ +export function SessionsView({ payload, memberFilter, issueFilter, onFilterChange }) { + const { memberRows, issueRows, sessionLog } = payload; + + const grouped = useMemo(() => { + /** @type {Map} */ + const m = new Map(); + for (const s of sessionLog) { + const arr = m.get(s.date) ?? []; + arr.push(s); + m.set(s.date, arr); + } + return [...m.entries()].sort((a, b) => (a[0] < b[0] ? 1 : -1)); + }, [sessionLog]); + + return ( +
+
+ + + {(memberFilter || issueFilter) && ( + + )} +
+ + {grouped.length === 0 ? ( +
No sessions in this range
+ ) : ( + grouped.map(([date, rows]) => ( +
+
{date}
+ {rows.map((s, i) => ( +
+ + {s.title} + + {s.user} + {fmtHM(s.seconds)} +
+ ))} +
+ )) + )} +
+ ); +} diff --git a/packages/vscode-ext/src/webview/dashboard/view/Topbar.jsx b/packages/vscode-ext/src/webview/dashboard/view/Topbar.jsx new file mode 100644 index 0000000..d630a27 --- /dev/null +++ b/packages/vscode-ext/src/webview/dashboard/view/Topbar.jsx @@ -0,0 +1,60 @@ +// packages/vscode-ext/src/webview/dashboard/view/Topbar.jsx +// +// Range pills (Today / Week / Month / All) + week navigation arrows. +// Matches the prototype topbar (index.html L2491–L2510). + +const RANGES = /** @type {Array<{ id: 'today'|'week'|'month'|'all', label: string }>} */ ([ + { id: 'today', label: 'Today' }, + { id: 'week', label: 'Week' }, + { id: 'month', label: 'Month' }, + { id: 'all', label: 'All time' }, +]); + +/** + * @param {{ + * range: 'today'|'week'|'month'|'all', + * weekOffset: number, + * window: { start: string|null, end: string|null }, + * onRangeChange: (r: 'today'|'week'|'month'|'all') => void, + * onWeekShift: (delta: number) => void, + * }} props + */ +export function Topbar({ range, weekOffset, window: w, onRangeChange, onWeekShift }) { + const isWeek = range === 'week'; + const label = w.start && w.end ? (w.start === w.end ? w.start : `${w.start} → ${w.end}`) : 'all time'; + + return ( +
+ + +  OctoClock Dashboard + +
+ {RANGES.map((r) => ( + + ))} +
+
+ + {label} + +
+
+ ); +} diff --git a/packages/vscode-ext/src/webview/dashboard/view/app.jsx b/packages/vscode-ext/src/webview/dashboard/view/app.jsx new file mode 100644 index 0000000..02a2e42 --- /dev/null +++ b/packages/vscode-ext/src/webview/dashboard/view/app.jsx @@ -0,0 +1,8 @@ +// packages/vscode-ext/src/webview/dashboard/view/app.jsx +// +// Entry point for the Dashboard webview bundle. + +import { h, render } from 'preact'; +import { DashboardApp } from './DashboardApp.jsx'; + +render(h(DashboardApp, null), /** @type {Element} */ (document.getElementById('app'))); diff --git a/vite.config.vscode.webview.js b/vite.config.vscode.webview.js index db5de4b..96d19b1 100644 --- a/vite.config.vscode.webview.js +++ b/vite.config.vscode.webview.js @@ -54,7 +54,7 @@ export default defineConfig({ 'sidebar/active-timer/app': resolve(webviewDir, 'sidebar/active-timer/view/app.jsx'), 'sidebar/my-issues/app': resolve(webviewDir, 'sidebar/my-issues/view/app.jsx'), 'sidebar/team-stats/app': resolve(webviewDir, 'sidebar/team-stats/view/app.jsx'), - 'dashboard/app': resolve(webviewDir, 'dashboard/app.js'), + 'dashboard/app': resolve(webviewDir, 'dashboard/view/app.jsx'), }, output: { format: 'es', From aaa3487bca8e54d9d8dcf58842af39de4bb6d288 Mon Sep 17 00:00:00 2001 From: zestones Date: Sun, 10 May 2026 19:18:20 +0200 Subject: [PATCH 3/3] feat: Implement member drill-down functionality with detailed views and issue breakdowns --- .../src/webview/dashboard/filtering.js | 78 ++++++++++++- .../vscode-ext/src/webview/dashboard/html.js | 34 ++++++ .../vscode-ext/src/webview/dashboard/panel.js | 29 ++++- .../webview/dashboard/view/DashboardApp.jsx | 95 ++++++++++++--- .../src/webview/dashboard/view/IssuesView.jsx | 70 ++++++++++++ .../dashboard/view/MemberDetailView.jsx | 108 ++++++++++++++++++ .../webview/dashboard/view/MembersView.jsx | 72 ++++++++++++ 7 files changed, 467 insertions(+), 19 deletions(-) create mode 100644 packages/vscode-ext/src/webview/dashboard/view/IssuesView.jsx create mode 100644 packages/vscode-ext/src/webview/dashboard/view/MemberDetailView.jsx create mode 100644 packages/vscode-ext/src/webview/dashboard/view/MembersView.jsx diff --git a/packages/vscode-ext/src/webview/dashboard/filtering.js b/packages/vscode-ext/src/webview/dashboard/filtering.js index d2aa0c9..48dda36 100644 --- a/packages/vscode-ext/src/webview/dashboard/filtering.js +++ b/packages/vscode-ext/src/webview/dashboard/filtering.js @@ -66,6 +66,65 @@ export function filterByWindow(entries, { start, end }) { }); } +/** + * Extract `owner/repo` from a GitHub issue URL. Returns an empty string when + * the URL does not match the expected GitHub layout. + * + * @param {string} url + * @returns {string} + */ +export function parseRepoFromUrl(url) { + if (!url) return ''; + const m = /github\.com\/([^/]+\/[^/]+)\//.exec(url); + return m ? m[1] : ''; +} + +/** + * Compute per-member drill-down stats from the already-filtered entries. + * + * @param {Array<{ issueUrl: string, title: string, seconds: number, date: string, user?: string }>} entries + * @param {string} user + * @returns {{ + * user: string, + * total: number, + * sessionsCount: number, + * issuesTouched: number, + * issueRows: Array<{ issueUrl: string, title: string, seconds: number, sessions: number }>, + * sessionLog: Array<{ issueUrl: string, title: string, seconds: number, date: string }> + * }} + */ +export function computeMemberDetail(entries, user) { + const own = entries.filter((e) => (e.user || '(you)') === user); + /** @type {Map} */ + const issueMap = new Map(); + let total = 0; + for (const e of own) { + total += e.seconds || 0; + const cleanTitle = AggregationService.extractCleanTitle(e.title); + const r = issueMap.get(e.issueUrl) ?? { issueUrl: e.issueUrl, title: cleanTitle, seconds: 0, sessions: 0 }; + r.seconds += e.seconds || 0; + r.sessions += 1; + issueMap.set(e.issueUrl, r); + } + const issueRows = [...issueMap.values()].sort((a, b) => b.seconds - a.seconds); + const sessionLog = own + .map((e) => ({ + issueUrl: e.issueUrl, + title: AggregationService.extractCleanTitle(e.title), + seconds: e.seconds || 0, + date: e.date, + })) + .sort((a, b) => (a.date < b.date ? 1 : -1)); + return { + user, + total, + sessionsCount: own.length, + issuesTouched: issueMap.size, + issueRows, + sessionLog, + }; +} + /** * Aggregate filtered entries into the Dashboard's `data` payload. * @@ -80,7 +139,7 @@ export function filterByWindow(entries, { start, end }) { * issuesTouched: number, * membersCount: number, * dailyBuckets: Array<{ date: string, seconds: number }>, - * issueRows: Array<{ issueUrl: string, title: string, seconds: number, sessions: number, members: string[] }>, + * issueRows: Array<{ issueUrl: string, title: string, repo: string, seconds: number, sessions: number, members: string[], byMember: Array<{ user: string, seconds: number, sessions: number }> }>, * memberRows: Array<{ user: string, seconds: number, sessions: number, lastIssueTitle: string, lastDate: string }>, * sessionLog: Array<{ issueUrl: string, title: string, seconds: number, date: string, user: string }> * }} @@ -91,7 +150,7 @@ export function aggregate(entries) { /** @type {Map} */ const dayMap = new Map(); - /** @type {Map }>} */ + /** @type {Map, byMember: Map }>} */ const issueMap = new Map(); /** @type {Map} */ const memberMap = new Map(); @@ -113,10 +172,15 @@ export function aggregate(entries) { seconds: 0, sessions: 0, members: new Set(), + byMember: new Map(), }; ir.seconds += seconds; ir.sessions += 1; ir.members.add(user); + const im = ir.byMember.get(user) ?? { user, seconds: 0, sessions: 0 }; + im.seconds += seconds; + im.sessions += 1; + ir.byMember.set(user, im); issueMap.set(e.issueUrl, ir); // Per-member. @@ -138,7 +202,15 @@ export function aggregate(entries) { const dailyBuckets = [...dayMap.values()].sort((a, b) => (a.date < b.date ? -1 : 1)); const issueRows = [...issueMap.values()] - .map((r) => ({ ...r, members: [...r.members] })) + .map((r) => ({ + issueUrl: r.issueUrl, + title: r.title, + repo: parseRepoFromUrl(r.issueUrl), + seconds: r.seconds, + sessions: r.sessions, + members: [...r.members], + byMember: [...r.byMember.values()].sort((a, b) => b.seconds - a.seconds), + })) .sort((a, b) => b.seconds - a.seconds); const memberRows = [...memberMap.values()].sort((a, b) => b.seconds - a.seconds); const sessionLog = [...entries] diff --git a/packages/vscode-ext/src/webview/dashboard/html.js b/packages/vscode-ext/src/webview/dashboard/html.js index d60526a..a6f3cfb 100644 --- a/packages/vscode-ext/src/webview/dashboard/html.js +++ b/packages/vscode-ext/src/webview/dashboard/html.js @@ -98,6 +98,40 @@ export function getHtml(webview, extensionUri) { .sess-row .user { font-size: 11px; color: var(--oc-desc); } .sess-row .dur { font-family: var(--oc-font-mono); text-align: right; color: var(--oc-fg); } + /* Issues view (UI-6) */ + .issue-row { border-bottom: 1px solid var(--oc-border); } + .issue-row .hdr { display: grid; grid-template-columns: 16px 1fr 140px 80px; gap: 8px; padding: 8px 0; align-items: center; cursor: pointer; font-size: 12px; } + .issue-row .hdr:hover { background: var(--oc-bg-alt, rgba(255,255,255,.04)); } + .issue-row .caret { color: var(--oc-muted); transition: transform .12s ease; } + .issue-row.open .caret { transform: rotate(90deg); } + .issue-row .title { color: var(--oc-fg); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .issue-row .repo { font-size: 11px; color: var(--oc-desc); font-family: var(--oc-font-mono); } + .issue-row .total { font-family: var(--oc-font-mono); text-align: right; color: var(--oc-fg); } + .issue-row .breakdown { padding: 4px 0 8px 32px; display: flex; flex-direction: column; gap: 2px; } + .issue-row .br-row { display: grid; grid-template-columns: 1fr 80px 60px; gap: 8px; font-size: 11px; padding: 2px 0; } + .issue-row .br-row .u { color: var(--oc-fg); } + .issue-row .br-row .s { color: var(--oc-desc); text-align: right; } + .issue-row .br-row .t { font-family: var(--oc-font-mono); text-align: right; color: var(--oc-fg); } + + /* Members view (UI-6) */ + .members-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } + .member-card { background: var(--oc-bg-alt, rgba(255,255,255,.03)); border: 1px solid var(--oc-border); border-radius: 6px; padding: 12px; cursor: pointer; transition: border-color .12s ease; } + .member-card:hover { border-color: var(--oc-accent, var(--oc-fg)); } + .member-card .top { display: flex; align-items: center; gap: 10px; } + .member-card .avatar { width: 32px; height: 32px; border-radius: 50%; background: var(--oc-accent, #4a8fe7); color: #fff; display: flex; align-items: center; justify-content: center; font-weight: 600; font-size: 12px; } + .member-card .name { font-size: 13px; color: var(--oc-fg); font-weight: 600; } + .member-card .stats { margin-top: 8px; display: flex; gap: 12px; font-size: 11px; color: var(--oc-desc); } + .member-card .stats .v { font-family: var(--oc-font-mono); color: var(--oc-fg); } + .member-card .bar { margin-top: 8px; height: 4px; background: var(--oc-border); border-radius: 2px; overflow: hidden; } + .member-card .bar .fill { height: 100%; background: var(--oc-accent, #4a8fe7); } + + /* Member drill-down (UI-6) */ + .drill-back { display: inline-flex; align-items: center; gap: 6px; background: none; border: 1px solid var(--oc-border); color: var(--oc-fg); padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 12px; margin-bottom: 12px; } + .drill-back:hover { border-color: var(--oc-accent, var(--oc-fg)); } + .drill-hdr { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; } + .drill-hdr .avatar { width: 40px; height: 40px; border-radius: 50%; background: var(--oc-accent, #4a8fe7); color: #fff; display: flex; align-items: center; justify-content: center; font-weight: 600; font-size: 14px; } + .drill-hdr .name { font-size: 16px; color: var(--oc-fg); font-weight: 600; } + .empty-msg { color: var(--oc-muted); font-style: italic; padding: 12px 0; font-size: 12px; } OctoClock Dashboard diff --git a/packages/vscode-ext/src/webview/dashboard/panel.js b/packages/vscode-ext/src/webview/dashboard/panel.js index 15f884e..87c945d 100644 --- a/packages/vscode-ext/src/webview/dashboard/panel.js +++ b/packages/vscode-ext/src/webview/dashboard/panel.js @@ -26,7 +26,7 @@ import * as vscode from 'vscode'; import { StorageService } from '../../../../core/src/services/storage.service.js'; import { STORAGE_KEYS } from '../../../../core/src/utils/constants.utils.js'; -import { aggregate, applyFilters, computeDateRange, filterByWindow } from './filtering.js'; +import { aggregate, applyFilters, computeDateRange, computeMemberDetail, filterByWindow } from './filtering.js'; import { getHtml } from './html.js'; export class DashboardPanel { @@ -75,7 +75,32 @@ export class DashboardPanel { async _handleMessage(msg) { if (msg.type === 'ready') return this._sendInit(); if (msg.type === 'rangeChange' || msg.type === 'filterChange') return this._sendFiltered(msg); - // 'memberDrill' is UI-6 — accept now without crashing if a stale build sends it. + if (msg.type === 'memberDrill') return this._sendMemberDetail(msg); + } + + /** + * Send a per-member drill-down payload. Re-uses the most recent + * range + filters provided by the webview so the detail view stays + * consistent with the list it was launched from. + * + * @param {{ memberId: string, range?: string, weekOffset?: number, customStart?: string, customEnd?: string, memberFilter?: string|null, issueFilter?: string|null }} msg + */ + async _sendMemberDetail(msg) { + const allSessions = await DashboardPanel._loadSessions(); + const window = computeDateRange({ + range: /** @type {any} */ (msg.range || 'week'), + weekOffset: msg.weekOffset || 0, + customStart: msg.customStart, + customEnd: msg.customEnd, + }); + const inRange = filterByWindow(allSessions, window); + const detail = computeMemberDetail(inRange, msg.memberId); + this._panel.webview.postMessage({ + type: 'memberDetail', + member: msg.memberId, + window, + payload: detail, + }); } async _sendInit() { diff --git a/packages/vscode-ext/src/webview/dashboard/view/DashboardApp.jsx b/packages/vscode-ext/src/webview/dashboard/view/DashboardApp.jsx index 3e5eb08..544f715 100644 --- a/packages/vscode-ext/src/webview/dashboard/view/DashboardApp.jsx +++ b/packages/vscode-ext/src/webview/dashboard/view/DashboardApp.jsx @@ -1,11 +1,14 @@ // packages/vscode-ext/src/webview/dashboard/view/DashboardApp.jsx // // Top-level dashboard component — handles the message protocol with the -// host, manages local UI state (active tab, range, filters), and routes -// to the per-view renderers. +// host, manages local UI state (active tab, range, filters, selected +// member), and routes to the per-view renderers. -import { useEffect, useState } from 'preact/hooks'; +import { useEffect, useRef, useState } from 'preact/hooks'; import { useVscodeMessage } from '../../shared/hooks/useVscodeMessage.js'; +import { IssuesView } from './IssuesView.jsx'; +import { MemberDetailView } from './MemberDetailView.jsx'; +import { MembersView } from './MembersView.jsx'; import { OverviewView } from './OverviewView.jsx'; import { SessionsView } from './SessionsView.jsx'; import { Topbar } from './Topbar.jsx'; @@ -13,25 +16,32 @@ import { Topbar } from './Topbar.jsx'; // eslint-disable-next-line no-undef const vscode = acquireVsCodeApi(); -/** @typedef {'overview' | 'sessions'} TabId */ +/** @typedef {'overview' | 'issues' | 'members' | 'sessions'} TabId */ const TABS = /** @type {Array<{ id: TabId, label: string, icon: string }>} */ ([ { id: 'overview', label: 'Overview', icon: 'graph' }, + { id: 'issues', label: 'Issues', icon: 'issues' }, + { id: 'members', label: 'Members', icon: 'organization' }, { id: 'sessions', label: 'Sessions', icon: 'history' }, ]); export function DashboardApp() { - const [activeTab, setActiveTab] = useState(/** @type {TabId} */ ('overview')); - const [range, setRange] = useState(/** @type {'today'|'week'|'month'|'all'} */ ('week')); + const [activeTab, setActiveTab] = useState(/** @type {TabId} */('overview')); + const [range, setRange] = useState(/** @type {'today'|'week'|'month'|'all'} */('week')); const [weekOffset, setWeekOffset] = useState(0); - const [memberFilter, setMemberFilter] = useState(/** @type {string|null} */ (null)); - const [issueFilter, setIssueFilter] = useState(/** @type {string|null} */ (null)); - const [payload, setPayload] = useState(/** @type {any} */ (null)); + const [memberFilter, setMemberFilter] = useState(/** @type {string|null} */(null)); + const [issueFilter, setIssueFilter] = useState(/** @type {string|null} */(null)); + const [payload, setPayload] = useState(/** @type {any} */(null)); const [windowRange, setWindowRange] = useState( - /** @type {{ start: string|null, end: string|null }} */ ({ start: null, end: null }), + /** @type {{ start: string|null, end: string|null }} */({ start: null, end: null }), ); - // Send `ready` once on mount so the host pushes init data. + // Member drill-down state. + const [selectedMember, setSelectedMember] = useState(/** @type {string|null} */(null)); + const [memberDetail, setMemberDetail] = useState(/** @type {any} */(null)); + const savedScrollRef = useRef(0); + const contentRef = useRef(/** @type {HTMLDivElement|null} */(null)); + useEffect(() => { vscode.postMessage({ type: 'ready' }); }, []); @@ -46,6 +56,9 @@ export function DashboardApp() { setPayload(msg.payload); setWindowRange(msg.window); }); + useVscodeMessage('memberDetail', (msg) => { + setMemberDetail(msg.payload); + }); /** * @param {{ range?: string, weekOffset?: number, memberFilter?: string|null, issueFilter?: string|null }} patch @@ -57,13 +70,59 @@ export function DashboardApp() { memberFilter: patch.memberFilter !== undefined ? patch.memberFilter : memberFilter, issueFilter: patch.issueFilter !== undefined ? patch.issueFilter : issueFilter, }; - if (patch.range !== undefined) setRange(/** @type {any} */ (patch.range)); + if (patch.range !== undefined) setRange(/** @type {any} */(patch.range)); if (patch.weekOffset !== undefined) setWeekOffset(patch.weekOffset); if (patch.memberFilter !== undefined) setMemberFilter(patch.memberFilter); if (patch.issueFilter !== undefined) setIssueFilter(patch.issueFilter); vscode.postMessage({ type: 'rangeChange', ...next }); }; + /** @param {string} user */ + const openMember = (user) => { + savedScrollRef.current = contentRef.current?.scrollTop ?? 0; + setSelectedMember(user); + setMemberDetail(null); + vscode.postMessage({ + type: 'memberDrill', + memberId: user, + range, + weekOffset, + memberFilter, + issueFilter, + }); + }; + + const closeMember = () => { + setSelectedMember(null); + setMemberDetail(null); + // Restore scroll after the list re-mounts. + requestAnimationFrame(() => { + if (contentRef.current) contentRef.current.scrollTop = savedScrollRef.current; + }); + }; + + // Re-fetch member detail when the range or filters change while drilled in. + useEffect(() => { + if (!selectedMember) return; + vscode.postMessage({ + type: 'memberDrill', + memberId: selectedMember, + range, + weekOffset, + memberFilter, + issueFilter, + }); + }, [range, weekOffset, memberFilter, issueFilter, selectedMember]); + + /** @param {TabId} t */ + const switchTab = (t) => { + if (t !== 'members' && selectedMember) { + setSelectedMember(null); + setMemberDetail(null); + } + setActiveTab(t); + }; + return (
setActiveTab(t.id)} + onClick={() => switchTab(t.id)} >  {t.label} ))}
-
+
{!payload ? (
Loading…
) : activeTab === 'overview' ? ( + ) : activeTab === 'issues' ? ( + + ) : activeTab === 'members' ? ( + selectedMember ? ( + + ) : ( + + ) ) : ( 0) return m > 0 ? `${h}h ${m}m` : `${h}h`; + return `${m}m`; +} + +/** @param {{ payload: any }} props */ +export function IssuesView({ payload }) { + const [open, setOpen] = useState(/** @type {Record} */({})); + const { issueRows } = payload; + + /** @param {string} url */ + const toggle = (url) => setOpen((o) => ({ ...o, [url]: !o[url] })); + + if (issueRows.length === 0) { + return
No issues tracked in this range
; + } + + return ( +
+ {issueRows.map((r) => { + const isOpen = !!open[r.issueUrl]; + return ( +
+
toggle(r.issueUrl)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') toggle(r.issueUrl); + }} + > + + + {r.title} + + {r.repo || '—'} + {fmtHM(r.seconds)} +
+ {isOpen && ( +
+ {r.byMember.map((m) => ( +
+ {m.user} + + {m.sessions} session{m.sessions === 1 ? '' : 's'} + + {fmtHM(m.seconds)} +
+ ))} +
+ )} +
+ ); + })} +
+ ); +} diff --git a/packages/vscode-ext/src/webview/dashboard/view/MemberDetailView.jsx b/packages/vscode-ext/src/webview/dashboard/view/MemberDetailView.jsx new file mode 100644 index 0000000..98bb219 --- /dev/null +++ b/packages/vscode-ext/src/webview/dashboard/view/MemberDetailView.jsx @@ -0,0 +1,108 @@ +// packages/vscode-ext/src/webview/dashboard/view/MemberDetailView.jsx +// +// Drill-down sub-view for a single member: KPI cards + per-issue table + session log. + +/** @param {string} name */ +function initials(name) { + if (!name) return '?'; + const parts = name + .replace(/[()]/g, '') + .split(/[\s_\-.]+/) + .filter(Boolean); + if (parts.length === 0) return name.charAt(0).toUpperCase(); + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + return (parts[0][0] + parts[1][0]).toUpperCase(); +} + +/** @param {number} seconds */ +function fmtHM(seconds) { + if (!seconds || seconds < 0) return '0h'; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + if (h > 0) return m > 0 ? `${h}h ${m}m` : `${h}h`; + return `${m}m`; +} + +/** + * @param {{ + * detail: any, + * onBack: () => void, + * }} props + */ +export function MemberDetailView({ detail, onBack }) { + if (!detail) return
Loading member…
; + const { user, total, sessionsCount, issuesTouched, issueRows, sessionLog } = detail; + + return ( +
+ +
+
{initials(user)}
+
{user}
+
+
+
+
Total time
+
{fmtHM(total)}
+
in selected range
+
+
+
Sessions
+
{sessionsCount}
+
recorded
+
+
+
Issues
+
{issuesTouched}
+
touched
+
+
+ +
+

Issues

+ {issueRows.length === 0 ? ( +
No issues for this member in this range
+ ) : ( + + + + + + + + + + {issueRows.map((r) => ( + + + + + + ))} + +
IssueSessionsTotal
{r.title}{r.sessions}{fmtHM(r.seconds)}
+ )} +
+ +
+

Session log

+ {sessionLog.length === 0 ? ( +
No sessions
+ ) : ( + sessionLog.map((s, i) => ( +
+ + {s.title} + + {s.date} + {fmtHM(s.seconds)} +
+ )) + )} +
+
+ ); +} diff --git a/packages/vscode-ext/src/webview/dashboard/view/MembersView.jsx b/packages/vscode-ext/src/webview/dashboard/view/MembersView.jsx new file mode 100644 index 0000000..07b46ec --- /dev/null +++ b/packages/vscode-ext/src/webview/dashboard/view/MembersView.jsx @@ -0,0 +1,72 @@ +// packages/vscode-ext/src/webview/dashboard/view/MembersView.jsx +// +// Members tab — 2-column CSS grid of member cards. Clicking a card +// triggers the drill-down via the `onSelect` callback. + +/** @param {string} name */ +function initials(name) { + if (!name) return '?'; + const parts = name + .replace(/[()]/g, '') + .split(/[\s_\-.]+/) + .filter(Boolean); + if (parts.length === 0) return name.charAt(0).toUpperCase(); + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + return (parts[0][0] + parts[1][0]).toUpperCase(); +} + +/** @param {number} seconds */ +function fmtHM(seconds) { + if (!seconds || seconds < 0) return '0h'; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + if (h > 0) return m > 0 ? `${h}h ${m}m` : `${h}h`; + return `${m}m`; +} + +/** + * @param {{ + * payload: any, + * onSelect: (user: string) => void, + * }} props + */ +export function MembersView({ payload, onSelect }) { + const { memberRows } = payload; + if (memberRows.length === 0) { + return
No members in this range
; + } + const max = Math.max(...memberRows.map((m) => m.seconds), 1); + + return ( +
+ {memberRows.map((m) => ( +
onSelect(m.user)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') onSelect(m.user); + }} + > +
+
{initials(m.user)}
+
{m.user}
+
+
+ + Total: {fmtHM(m.seconds)} + + + Sessions: {m.sessions} + +
+
+
+
+
+ ))} +
+ ); +}