From 48f1a4c22577d3799ae644f85ee33496f079bab0 Mon Sep 17 00:00:00 2001 From: tommy0103 Date: Sun, 12 Jul 2026 15:15:48 +0800 Subject: [PATCH] feat(activity): add contribution ledger --- app/src/main/index.ts | 5 +- app/src/preload/index.ts | 3 +- app/src/renderer/src/activity-ledger.mjs | 34 ++ .../src/components/ActivityLedger.vue | 246 ++++++++++ .../src/components/ActivityLedgerRow.vue | 98 ++++ app/src/renderer/src/views/Activity.vue | 431 ++++-------------- app/src/shared/ipc-types.ts | 5 + tests/app-activity-ledger.test.mjs | 67 +++ tests/app-activity-usage.test.mjs | 55 +++ tests/app-main-settings.test.mjs | 77 +++- 10 files changed, 686 insertions(+), 335 deletions(-) create mode 100644 app/src/renderer/src/activity-ledger.mjs create mode 100644 app/src/renderer/src/components/ActivityLedger.vue create mode 100644 app/src/renderer/src/components/ActivityLedgerRow.vue create mode 100644 app/src/shared/ipc-types.ts create mode 100644 tests/app-activity-ledger.test.mjs create mode 100644 tests/app-activity-usage.test.mjs diff --git a/app/src/main/index.ts b/app/src/main/index.ts index 4c576f1..0b888e4 100644 --- a/app/src/main/index.ts +++ b/app/src/main/index.ts @@ -10,6 +10,7 @@ import { createIndexerService } from './indexer-service.ts'; import { createWorkerBuildIndex } from './indexer-worker-client.ts'; import { buildRecapExportQuery } from './recap-capture-query.ts'; import { acquireWriterLease, writerLockPathFor } from '../../../packages/core/src/writer-lease.ts'; +import type { SourceQueryOptions } from '../shared/ipc-types.ts'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -220,8 +221,8 @@ function notifyIndexUpdated(result: { affectedSessionIds?: unknown } = {}) { } } -function sourceWhereClause(opts: { includeCodex?: boolean; source?: string } = {}, column = "source"): { sql: string; params: unknown[] } { - if (opts.includeCodex || opts.source === 'all') return { sql: '', params: [] }; +function sourceWhereClause(opts: SourceQueryOptions = {}, column = "source"): { sql: string; params: unknown[] } { + if (opts.source === 'all') return { sql: '', params: [] }; if (opts.source) return { sql: `COALESCE(${column}, 'claude') = ?`, params: [opts.source] }; return { sql: `COALESCE(${column}, 'claude') = 'claude'`, params: [] }; } diff --git a/app/src/preload/index.ts b/app/src/preload/index.ts index 5308f25..f0cdd1a 100644 --- a/app/src/preload/index.ts +++ b/app/src/preload/index.ts @@ -1,4 +1,5 @@ import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'; +import type { UsageStatsOptions } from '../shared/ipc-types.ts'; contextBridge.exposeInMainWorld('obelisk', { getSessions: (opts?: unknown) => ipcRenderer.invoke('db:getSessions', opts), @@ -18,7 +19,7 @@ contextBridge.exposeInMainWorld('obelisk', { restoreMemory: (id: string) => ipcRenderer.invoke('db:restoreMemory', id), getProjects: () => ipcRenderer.invoke('db:getProjects'), getStats: () => ipcRenderer.invoke('db:getStats'), - getUsageStats: () => ipcRenderer.invoke('db:getUsageStats'), + getUsageStats: (opts?: UsageStatsOptions) => ipcRenderer.invoke('db:getUsageStats', opts), onIndexUpdated: (callback: (payload: unknown) => void) => { const listener = (_: IpcRendererEvent, payload: unknown) => callback(payload); ipcRenderer.on('obelisk:index-updated', listener); diff --git a/app/src/renderer/src/activity-ledger.mjs b/app/src/renderer/src/activity-ledger.mjs new file mode 100644 index 0000000..6ebbd42 --- /dev/null +++ b/app/src/renderer/src/activity-ledger.mjs @@ -0,0 +1,34 @@ +export function activitySourceKey(session) { + const source = typeof session?.source === 'string' ? session.source.trim().toLowerCase() : ''; + return source || 'claude'; +} + +export function activitySourceLabel(session) { + const source = activitySourceKey(session); + if (source === 'codex') return 'Codex'; + if (source === 'claude') return 'Claude Code'; + return source.charAt(0).toUpperCase() + source.slice(1); +} + +export function activityGroupSessions(split) { + return [...(split?.normal || []), ...(split?.noise || [])]; +} + +export function activityGroupHasMixedSources(split) { + return new Set(activityGroupSessions(split).map(activitySourceKey)).size > 1; +} + +export function activitySessionMetaParts(session, { + mixedSources = false, + projectLabel = '', + includeProject = true, +} = {}) { + const parts = []; + if (mixedSources) parts.push({ kind: 'source', text: activitySourceLabel(session) }); + if (includeProject && projectLabel) parts.push({ kind: 'project', text: projectLabel }); + parts.push({ + kind: 'count', + text: `${Number(session?.message_count || 0).toLocaleString('en-US')} msg`, + }); + return parts; +} diff --git a/app/src/renderer/src/components/ActivityLedger.vue b/app/src/renderer/src/components/ActivityLedger.vue new file mode 100644 index 0000000..1a5b1c2 --- /dev/null +++ b/app/src/renderer/src/components/ActivityLedger.vue @@ -0,0 +1,246 @@ + + + + + diff --git a/app/src/renderer/src/components/ActivityLedgerRow.vue b/app/src/renderer/src/components/ActivityLedgerRow.vue new file mode 100644 index 0000000..98b926b --- /dev/null +++ b/app/src/renderer/src/components/ActivityLedgerRow.vue @@ -0,0 +1,98 @@ + + + + + diff --git a/app/src/renderer/src/views/Activity.vue b/app/src/renderer/src/views/Activity.vue index 9c01085..1bf68b8 100644 --- a/app/src/renderer/src/views/Activity.vue +++ b/app/src/renderer/src/views/Activity.vue @@ -1,8 +1,9 @@