feat(activity): add contribution ledger

This commit is contained in:
tommy0103
2026-07-12 15:15:48 +08:00
parent 644942202e
commit 48f1a4c225
10 changed files with 686 additions and 335 deletions
+3 -2
View File
@@ -10,6 +10,7 @@ import { createIndexerService } from './indexer-service.ts';
import { createWorkerBuildIndex } from './indexer-worker-client.ts'; import { createWorkerBuildIndex } from './indexer-worker-client.ts';
import { buildRecapExportQuery } from './recap-capture-query.ts'; import { buildRecapExportQuery } from './recap-capture-query.ts';
import { acquireWriterLease, writerLockPathFor } from '../../../packages/core/src/writer-lease.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)); 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[] } { function sourceWhereClause(opts: SourceQueryOptions = {}, column = "source"): { sql: string; params: unknown[] } {
if (opts.includeCodex || opts.source === 'all') return { sql: '', params: [] }; if (opts.source === 'all') return { sql: '', params: [] };
if (opts.source) return { sql: `COALESCE(${column}, 'claude') = ?`, params: [opts.source] }; if (opts.source) return { sql: `COALESCE(${column}, 'claude') = ?`, params: [opts.source] };
return { sql: `COALESCE(${column}, 'claude') = 'claude'`, params: [] }; return { sql: `COALESCE(${column}, 'claude') = 'claude'`, params: [] };
} }
+2 -1
View File
@@ -1,4 +1,5 @@
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'; import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron';
import type { UsageStatsOptions } from '../shared/ipc-types.ts';
contextBridge.exposeInMainWorld('obelisk', { contextBridge.exposeInMainWorld('obelisk', {
getSessions: (opts?: unknown) => ipcRenderer.invoke('db:getSessions', opts), getSessions: (opts?: unknown) => ipcRenderer.invoke('db:getSessions', opts),
@@ -18,7 +19,7 @@ contextBridge.exposeInMainWorld('obelisk', {
restoreMemory: (id: string) => ipcRenderer.invoke('db:restoreMemory', id), restoreMemory: (id: string) => ipcRenderer.invoke('db:restoreMemory', id),
getProjects: () => ipcRenderer.invoke('db:getProjects'), getProjects: () => ipcRenderer.invoke('db:getProjects'),
getStats: () => ipcRenderer.invoke('db:getStats'), getStats: () => ipcRenderer.invoke('db:getStats'),
getUsageStats: () => ipcRenderer.invoke('db:getUsageStats'), getUsageStats: (opts?: UsageStatsOptions) => ipcRenderer.invoke('db:getUsageStats', opts),
onIndexUpdated: (callback: (payload: unknown) => void) => { onIndexUpdated: (callback: (payload: unknown) => void) => {
const listener = (_: IpcRendererEvent, payload: unknown) => callback(payload); const listener = (_: IpcRendererEvent, payload: unknown) => callback(payload);
ipcRenderer.on('obelisk:index-updated', listener); ipcRenderer.on('obelisk:index-updated', listener);
+34
View File
@@ -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;
}
@@ -0,0 +1,246 @@
<script setup>
import { computed, reactive } from 'vue';
import {
activityGroupHasMixedSources,
activityGroupSessions,
} from '../activity-ledger.mjs';
import ActivityLedgerRow from './ActivityLedgerRow.vue';
const props = defineProps({
block: { type: Object, required: true },
eventDate: { type: String, default: '' },
});
const emit = defineEmits(['open-session']);
const expanded = reactive({});
function plural(count, singular, pluralForm = `${singular}s`) {
return count === 1 ? singular : pluralForm;
}
function projectCount(split) {
return new Set(activityGroupSessions(split).map(session => session.project || '(none)')).size;
}
const groups = computed(() => [
{
key: 'workspaces',
kind: 'workspace',
split: props.block.newWorkspaces,
title: `Created ${props.block.newWorkspaces.total} new ${plural(props.block.newWorkspaces.total, 'workspace')}`,
includeProject: false,
},
{
key: 'sessions',
kind: 'started',
split: props.block.newSessions,
title: `Started ${props.block.newSessions.total} ${plural(props.block.newSessions.total, 'session')} in ${projectCount(props.block.newSessions)} ${plural(projectCount(props.block.newSessions), 'project')}`,
includeProject: true,
},
{
key: 'continued',
kind: 'continued',
split: props.block.continued,
title: `Continued ${props.block.continued.total} ${plural(props.block.continued.total, 'session')}`,
includeProject: true,
},
].filter(group => group.split.total > 0).map(group => ({
...group,
mixedSources: activityGroupHasMixedSources(group.split),
})));
function toggleNoise(key) {
expanded[key] = !expanded[key];
}
</script>
<template>
<div class="activity-ledger" v-if="groups.length">
<article
v-for="group in groups"
:key="group.key"
class="ledger-group"
:class="group.kind"
>
<div class="ledger-node" aria-hidden="true">
<svg v-if="group.kind === 'workspace'" viewBox="0 0 24 24">
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>
</svg>
<svg v-else-if="group.kind === 'started'" viewBox="0 0 24 24">
<path d="M21 15a4 4 0 0 1-4 4H8l-5 3V7a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4Z"/>
</svg>
<svg v-else viewBox="0 0 24 24">
<path d="m17 2 4 4-4 4"/>
<path d="M3 11v-1a4 4 0 0 1 4-4h14"/>
<path d="m7 22-4-4 4-4"/>
<path d="M21 13v1a4 4 0 0 1-4 4H3"/>
</svg>
</div>
<header class="ledger-group-header">
<h3>{{ group.title }}</h3>
<time v-if="eventDate">{{ eventDate }}</time>
</header>
<div class="ledger-items">
<ActivityLedgerRow
v-for="session in group.split.normal"
:key="session.id"
:session="session"
:mixed-sources="group.mixedSources"
:include-project="group.includeProject"
:tone="group.kind"
@open="emit('open-session', $event)"
/>
<template v-if="group.split.noise.length">
<button
class="noise-fold-row"
:class="{ expanded: expanded[group.key] }"
type="button"
:aria-expanded="Boolean(expanded[group.key])"
@click="toggleNoise(group.key)"
>
<svg class="chev" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 2.5l3 3.5-3 3.5"/></svg>
<span>{{ group.split.noise.length }} hidden, likely test or throwaway runs</span>
</button>
<ActivityLedgerRow
v-for="session in (expanded[group.key] ? group.split.noise : [])"
:key="session.id"
:session="session"
:mixed-sources="group.mixedSources"
:include-project="group.includeProject"
:tone="group.kind"
noise
@open="emit('open-session', $event)"
/>
</template>
</div>
</article>
</div>
</template>
<style scoped>
.activity-ledger {
position: relative;
margin-left: 16px;
padding-left: 58px;
}
.activity-ledger::before {
content: '';
position: absolute;
top: 2px;
bottom: 4px;
left: 15px;
width: 1px;
background: var(--hairline);
}
.ledger-group {
position: relative;
padding-bottom: 38px;
}
.ledger-group:last-child { padding-bottom: 6px; }
.ledger-node {
position: absolute;
top: -5px;
left: -58px;
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: 1px solid var(--hairline-strong);
border-radius: 50%;
background: var(--surface);
color: var(--muted);
box-shadow: 0 0 0 5px var(--bg);
}
.ledger-node svg {
width: 17px;
height: 17px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.workspace .ledger-node {
color: #f59e0b;
border-color: rgba(245, 158, 11, .28);
background: rgba(245, 158, 11, .1);
}
.started .ledger-node {
color: var(--accent-2);
border-color: rgba(167, 139, 250, .28);
background: var(--accent-soft);
}
.ledger-group-header {
min-height: 27px;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 24px;
margin-bottom: 14px;
}
.ledger-group-header h3 {
margin: 0;
color: var(--fg);
font-size: 15px;
font-weight: 600;
letter-spacing: -.01em;
}
.ledger-group-header time {
color: var(--muted-2);
font: 10px/1 var(--font-mono);
letter-spacing: .05em;
white-space: nowrap;
}
.ledger-items {
display: grid;
gap: 2px;
}
.noise-fold-row {
width: fit-content;
display: flex;
align-items: center;
gap: 8px;
margin: 5px 0 0 -4px;
padding: 7px 8px;
border: 0;
border-radius: 5px;
background: transparent;
color: var(--muted-2);
font: 10.5px/1.25 var(--font-mono);
text-align: left;
cursor: pointer;
}
.noise-fold-row:hover { color: var(--muted); background: var(--surface-strong); }
.noise-fold-row .chev {
width: 9px;
height: 9px;
flex-shrink: 0;
transition: transform .15s cubic-bezier(.22, 1, .36, 1);
}
.noise-fold-row.expanded .chev { transform: rotate(90deg); }
@media (max-width: 760px) {
.activity-ledger { margin-left: 8px; padding-left: 46px; }
.ledger-node { left: -46px; }
.ledger-group-header { gap: 12px; }
}
</style>
@@ -0,0 +1,98 @@
<script setup>
import { computed } from 'vue';
import { formatProjectLabel } from '../utils.js';
import { activitySessionMetaParts } from '../activity-ledger.mjs';
const props = defineProps({
session: { type: Object, required: true },
mixedSources: { type: Boolean, default: false },
includeProject: { type: Boolean, default: true },
tone: { type: String, default: 'started' },
noise: { type: Boolean, default: false },
});
const emit = defineEmits(['open']);
const metaParts = computed(() => activitySessionMetaParts(props.session, {
mixedSources: props.mixedSources,
projectLabel: formatProjectLabel(props.session.project) || '',
includeProject: props.includeProject,
}));
</script>
<template>
<button
class="ledger-item"
:class="[tone, { noise }]"
type="button"
@click="emit('open', session.id)"
>
<span class="ledger-item-title">{{ session.title || '(untitled)' }}</span>
<span class="ledger-item-meta">
<template v-for="(part, index) in metaParts" :key="`${part.kind}-${index}`">
<span v-if="index" class="meta-separator">·</span>
<span :class="`meta-${part.kind}`">{{ part.text }}</span>
</template>
</span>
</button>
</template>
<style scoped>
.ledger-item {
width: 100%;
display: block;
margin-left: -12px;
padding: 8px 12px;
border: 0;
border-radius: 6px;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
transition: background .14s cubic-bezier(.22, 1, .36, 1);
}
.ledger-item:hover { background: var(--surface-strong); }
.ledger-item-title {
display: block;
overflow: hidden;
color: var(--accent-2);
font-size: 13.5px;
font-weight: 520;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.ledger-item.continued .ledger-item-title { color: var(--fg-2); }
.ledger-item:hover .ledger-item-title { text-decoration: underline; text-underline-offset: 2px; }
.ledger-item-meta {
display: flex;
align-items: center;
gap: 8px;
min-height: 15px;
margin-top: 4px;
color: var(--muted-2);
font: 10.5px/1.35 var(--font-mono);
white-space: nowrap;
}
.meta-source {
color: var(--fg-2);
font-weight: 620;
}
.meta-project { color: var(--muted); }
.meta-count { color: var(--muted-2); }
.meta-separator { color: var(--muted); font-weight: 700; opacity: .82; }
.ledger-item.noise { opacity: .7; }
.ledger-item.noise .ledger-item-title { color: var(--fg-2); font-weight: 430; }
@media (max-width: 760px) {
.ledger-item-meta { flex-wrap: wrap; row-gap: 4px; white-space: normal; }
}
</style>
+101 -330
View File
@@ -1,8 +1,9 @@
<script setup> <script setup>
import { ref, reactive, computed, onMounted } from 'vue'; import { ref, reactive, computed, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { state } from '../store.js'; import { state } from '../store.js';
import { fmtTokens, fmtDuration, fmtTooltipDate, positionTooltip, escapeHTML, formatProjectLabel } from '../utils.js'; import { fmtTokens, fmtDuration, fmtTooltipDate, positionTooltip, escapeHTML, formatProjectLabel } from '../utils.js';
import ActivityLedger from '../components/ActivityLedger.vue';
defineOptions({ name: 'Activity' }); defineOptions({ name: 'Activity' });
@@ -14,7 +15,6 @@ const loading = ref(true);
const usageData = reactive({ daily: [], totalTokens: 0, peakDay: null, longestTurn: null }); const usageData = reactive({ daily: [], totalTokens: 0, peakDay: null, longestTurn: null });
const selectedDayKey = ref(null); const selectedDayKey = ref(null);
const loadedMonths = ref(0); const loadedMonths = ref(0);
const monthBlocks = ref([]);
// Tooltip // Tooltip
const tooltip = reactive({ text: '', show: false, x: 0, y: 0 }); const tooltip = reactive({ text: '', show: false, x: 0, y: 0 });
@@ -37,11 +37,6 @@ function splitNoise(arr) {
return { normal, noise, total: normal.length + noise.length }; return { normal, noise, total: normal.length + noise.length };
} }
const expandedNoise = reactive({});
function toggleNoise(key) {
expandedNoise[key] = !expandedNoise[key];
}
function localDateStr(d) { function localDateStr(d) {
const y = d.getFullYear(); const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0'); const m = String(d.getMonth() + 1).padStart(2, '0');
@@ -250,7 +245,9 @@ const daySessions = computed(() => {
return { return {
dateKey, dateKey,
dateLabel: fmtTooltipDate(dateKey), header: `${MONTHS_FULL[Number(dateKey.slice(5, 7)) - 1]} ${dateKey.slice(0, 4)}`,
eventDate: `${MONTHS_SHORT[Number(dateKey.slice(5, 7)) - 1].toUpperCase()} ${Number(dateKey.slice(8, 10))}`,
sessionTotal: classified.length,
newWorkspaces: classified.filter(s => s.kind === 'new-workspace'), newWorkspaces: classified.filter(s => s.kind === 'new-workspace'),
newSessions: classified.filter(s => s.kind === 'new-session'), newSessions: classified.filter(s => s.kind === 'new-session'),
continued: classified.filter(s => s.kind === 'continued'), continued: classified.filter(s => s.kind === 'continued'),
@@ -269,13 +266,17 @@ const daySessionsSplit = computed(() => {
}); });
const monthBlocksSplit = computed(() => const monthBlocksSplit = computed(() =>
monthBlocks.value.map((b, bi) => ({ Array.from({ length: loadedMonths.value }, (_, offset) => {
...b, const today = new Date();
bi, const targetDate = new Date(today.getFullYear(), today.getMonth() - offset, 1);
newWorkspaces: splitNoise(b.newWorkspaces), const block = buildMonthBlock(targetDate.getFullYear(), targetDate.getMonth());
newSessions: splitNoise(b.newSessions), return {
continued: splitNoise(b.continued), ...block,
})) newWorkspaces: splitNoise(block.newWorkspaces),
newSessions: splitNoise(block.newSessions),
continued: splitNoise(block.continued),
};
})
); );
// --- Methods --- // --- Methods ---
@@ -349,6 +350,7 @@ function buildMonthBlock(year, month) {
return { return {
header: `${MONTHS_FULL[month]} ${year}`, header: `${MONTHS_FULL[month]} ${year}`,
sessionTotal: classified.length,
newWorkspaces: classified.filter(s => s.kind === 'new-workspace'), newWorkspaces: classified.filter(s => s.kind === 'new-workspace'),
newSessions: classified.filter(s => s.kind === 'new-session'), newSessions: classified.filter(s => s.kind === 'new-session'),
continued: classified.filter(s => s.kind === 'continued'), continued: classified.filter(s => s.kind === 'continued'),
@@ -357,26 +359,12 @@ function buildMonthBlock(year, month) {
} }
function showNextMonth() { function showNextMonth() {
const today = new Date();
const targetDate = new Date(today.getFullYear(), today.getMonth() - loadedMonths.value, 1);
const block = buildMonthBlock(targetDate.getFullYear(), targetDate.getMonth());
monthBlocks.value.push(block);
loadedMonths.value++; loadedMonths.value++;
} }
function projectLabel(project) { async function loadUsageStats() {
return formatProjectLabel(project);
}
function newSessionProjectCount(sessions) {
const projects = new Set(sessions.map(s => s.project || '(none)'));
return projects.size;
}
// --- Lifecycle ---
onMounted(async () => {
try { try {
const data = await window.obelisk.getUsageStats(); const data = await window.obelisk.getUsageStats({ source: 'all' });
usageData.daily = data.daily || []; usageData.daily = data.daily || [];
usageData.totalTokens = data.totalTokens || 0; usageData.totalTokens = data.totalTokens || 0;
usageData.peakDay = data.peakDay || null; usageData.peakDay = data.peakDay || null;
@@ -384,9 +372,21 @@ onMounted(async () => {
} catch (e) { } catch (e) {
console.error('Failed to load usage stats:', e); console.error('Failed to load usage stats:', e);
} }
}
// --- Lifecycle ---
let stopUsageUpdates = () => {};
onMounted(async () => {
stopUsageUpdates = window.obelisk?.onIndexUpdated?.(() => {
void loadUsageStats();
}) || (() => {});
await loadUsageStats();
loading.value = false; loading.value = false;
showNextMonth(); showNextMonth();
}); });
onUnmounted(() => stopUsageUpdates());
</script> </script>
<template> <template>
@@ -544,230 +544,42 @@ onMounted(async () => {
<div v-else class="empty">No data</div> <div v-else class="empty">No data</div>
</div> </div>
<!-- Day sessions panel (from heatmap click) --> <!-- Session activity ledger -->
<div class="day-sessions" v-if="daySessionsSplit"> <section class="session-activity" v-if="daySessionsSplit">
<div class="day-sessions-header">{{ daySessionsSplit.dateLabel }}<template v-if="daySessionsSplit.isEmpty"> no sessions</template></div> <div class="activity-month-heading">
<div class="day-activity-timeline" v-if="!daySessionsSplit.isEmpty"> <h2>{{ daySessionsSplit.header }}</h2>
<!-- New workspaces --> <span class="activity-month-rule"></span>
<div class="activity-group" v-if="daySessionsSplit.newWorkspaces.total"> <span class="activity-month-count">{{ daySessionsSplit.sessionTotal }} session{{ daySessionsSplit.sessionTotal === 1 ? '' : 's' }}</span>
<div class="activity-group-header">
<span class="activity-icon workspace">&#9733;</span>
<span class="activity-group-title">Created {{ daySessionsSplit.newWorkspaces.total }} new workspace{{ daySessionsSplit.newWorkspaces.total > 1 ? 's' : '' }}</span>
</div>
<div class="activity-group-items">
<button
v-for="s in daySessionsSplit.newWorkspaces.normal"
:key="s.id"
class="activity-item"
@click="goToSession(s.id)"
>
<span class="activity-item-name"><span class="activity-item-project">{{ projectLabel(s.project) }}</span> {{ s.title || '(untitled)' }}</span>
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ s.message_count || 0 }} msg</span>
</button>
<template v-if="daySessionsSplit.newWorkspaces.noise.length">
<button class="noise-fold-row" :class="{ expanded: expandedNoise['day-workspaces'] }" @click="toggleNoise('day-workspaces')">
<svg class="chev" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 2.5l3 3.5-3 3.5"/></svg>
<span>{{ daySessionsSplit.newWorkspaces.noise.length }} hidden likely test or throwaway runs</span>
</button>
<template v-if="expandedNoise['day-workspaces']">
<button
v-for="s in daySessionsSplit.newWorkspaces.noise"
:key="s.id"
class="activity-item noise"
@click="goToSession(s.id)"
>
<span class="activity-item-name"><span class="activity-item-project">{{ projectLabel(s.project) }}</span> {{ s.title || '(untitled)' }}</span>
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ s.message_count || 0 }} msg</span>
</button>
</template>
</template>
</div>
</div>
<!-- New sessions -->
<div class="activity-group" v-if="daySessionsSplit.newSessions.total">
<div class="activity-group-header">
<span class="activity-icon new">+</span>
<span class="activity-group-title">Started {{ daySessionsSplit.newSessions.total }} session{{ daySessionsSplit.newSessions.total > 1 ? 's' : '' }} in {{ newSessionProjectCount([...daySessionsSplit.newSessions.normal, ...daySessionsSplit.newSessions.noise]) }} project{{ newSessionProjectCount([...daySessionsSplit.newSessions.normal, ...daySessionsSplit.newSessions.noise]) > 1 ? 's' : '' }}</span>
</div>
<div class="activity-group-items">
<button
v-for="s in daySessionsSplit.newSessions.normal"
:key="s.id"
class="activity-item"
@click="goToSession(s.id)"
>
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
</button>
<template v-if="daySessionsSplit.newSessions.noise.length">
<button class="noise-fold-row" :class="{ expanded: expandedNoise['day-sessions'] }" @click="toggleNoise('day-sessions')">
<svg class="chev" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 2.5l3 3.5-3 3.5"/></svg>
<span>{{ daySessionsSplit.newSessions.noise.length }} hidden likely test or throwaway runs</span>
</button>
<template v-if="expandedNoise['day-sessions']">
<button
v-for="s in daySessionsSplit.newSessions.noise"
:key="s.id"
class="activity-item noise"
@click="goToSession(s.id)"
>
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
</button>
</template>
</template>
</div>
</div>
<!-- Continued sessions -->
<div class="activity-group continued" v-if="daySessionsSplit.continued.total">
<div class="activity-group-header">
<span class="activity-icon continued">&#8627;</span>
<span class="activity-group-title">Continued {{ daySessionsSplit.continued.total }} session{{ daySessionsSplit.continued.total > 1 ? 's' : '' }}</span>
</div>
<div class="activity-group-items">
<button
v-for="s in daySessionsSplit.continued.normal"
:key="s.id"
class="activity-item"
@click="goToSession(s.id)"
>
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
</button>
<template v-if="daySessionsSplit.continued.noise.length">
<button class="noise-fold-row" :class="{ expanded: expandedNoise['day-continued'] }" @click="toggleNoise('day-continued')">
<svg class="chev" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 2.5l3 3.5-3 3.5"/></svg>
<span>{{ daySessionsSplit.continued.noise.length }} hidden likely test or throwaway runs</span>
</button>
<template v-if="expandedNoise['day-continued']">
<button
v-for="s in daySessionsSplit.continued.noise"
:key="s.id"
class="activity-item noise"
@click="goToSession(s.id)"
>
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
</button>
</template>
</template>
</div>
</div>
</div> </div>
</div> <ActivityLedger
v-if="!daySessionsSplit.isEmpty"
:block="daySessionsSplit"
:event-date="daySessionsSplit.eventDate"
@open-session="goToSession"
/>
<div v-else class="activity-empty">No sessions on {{ daySessionsSplit.eventDate }}.</div>
</section>
<!-- Monthly activity blocks --> <section class="session-activity" v-else>
<div class="day-sessions" v-if="!selectedDayKey"> <section
<template v-for="(block, bi) in monthBlocksSplit" :key="bi"> v-for="block in monthBlocksSplit"
<div class="day-sessions-header">{{ block.header }}</div> :key="block.header"
<div class="day-activity-timeline" v-if="!block.isEmpty"> class="activity-month-block"
<div class="activity-group" v-if="block.newWorkspaces.total"> >
<div class="activity-group-header"> <div class="activity-month-heading">
<span class="activity-icon workspace">&#9733;</span> <h2>{{ block.header }}</h2>
<span class="activity-group-title">Created {{ block.newWorkspaces.total }} new workspace{{ block.newWorkspaces.total > 1 ? 's' : '' }}</span> <span class="activity-month-rule"></span>
</div> <span class="activity-month-count">{{ block.sessionTotal }} session{{ block.sessionTotal === 1 ? '' : 's' }}</span>
<div class="activity-group-items">
<button
v-for="s in block.newWorkspaces.normal"
:key="s.id"
class="activity-item"
@click="goToSession(s.id)"
>
<span class="activity-item-name"><span class="activity-item-project">{{ projectLabel(s.project) }}</span> {{ s.title || '(untitled)' }}</span>
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ s.message_count || 0 }} msg</span>
</button>
<template v-if="block.newWorkspaces.noise.length">
<button class="noise-fold-row" :class="{ expanded: expandedNoise[`m${bi}-workspaces`] }" @click="toggleNoise(`m${bi}-workspaces`)">
<svg class="chev" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 2.5l3 3.5-3 3.5"/></svg>
<span>{{ block.newWorkspaces.noise.length }} hidden likely test or throwaway runs</span>
</button>
<template v-if="expandedNoise[`m${bi}-workspaces`]">
<button
v-for="s in block.newWorkspaces.noise"
:key="s.id"
class="activity-item noise"
@click="goToSession(s.id)"
>
<span class="activity-item-name"><span class="activity-item-project">{{ projectLabel(s.project) }}</span> {{ s.title || '(untitled)' }}</span>
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ s.message_count || 0 }} msg</span>
</button>
</template>
</template>
</div>
</div>
<div class="activity-group" v-if="block.newSessions.total">
<div class="activity-group-header">
<span class="activity-icon new">+</span>
<span class="activity-group-title">Started {{ block.newSessions.total }} session{{ block.newSessions.total > 1 ? 's' : '' }} in {{ newSessionProjectCount([...block.newSessions.normal, ...block.newSessions.noise]) }} project{{ newSessionProjectCount([...block.newSessions.normal, ...block.newSessions.noise]) > 1 ? 's' : '' }}</span>
</div>
<div class="activity-group-items">
<button
v-for="s in block.newSessions.normal"
:key="s.id"
class="activity-item"
@click="goToSession(s.id)"
>
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
</button>
<template v-if="block.newSessions.noise.length">
<button class="noise-fold-row" :class="{ expanded: expandedNoise[`m${bi}-sessions`] }" @click="toggleNoise(`m${bi}-sessions`)">
<svg class="chev" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 2.5l3 3.5-3 3.5"/></svg>
<span>{{ block.newSessions.noise.length }} hidden likely test or throwaway runs</span>
</button>
<template v-if="expandedNoise[`m${bi}-sessions`]">
<button
v-for="s in block.newSessions.noise"
:key="s.id"
class="activity-item noise"
@click="goToSession(s.id)"
>
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
</button>
</template>
</template>
</div>
</div>
<div class="activity-group continued" v-if="block.continued.total">
<div class="activity-group-header">
<span class="activity-icon continued">&#8627;</span>
<span class="activity-group-title">Continued {{ block.continued.total }} session{{ block.continued.total > 1 ? 's' : '' }}</span>
</div>
<div class="activity-group-items">
<button
v-for="s in block.continued.normal"
:key="s.id"
class="activity-item"
@click="goToSession(s.id)"
>
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
</button>
<template v-if="block.continued.noise.length">
<button class="noise-fold-row" :class="{ expanded: expandedNoise[`m${bi}-continued`] }" @click="toggleNoise(`m${bi}-continued`)">
<svg class="chev" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 2.5l3 3.5-3 3.5"/></svg>
<span>{{ block.continued.noise.length }} hidden likely test or throwaway runs</span>
</button>
<template v-if="expandedNoise[`m${bi}-continued`]">
<button
v-for="s in block.continued.noise"
:key="s.id"
class="activity-item noise"
@click="goToSession(s.id)"
>
<span class="activity-item-name">{{ s.title || '(untitled)' }}</span>
<span class="activity-item-meta"><span class="src-tag" :class="s.source || 'claude'" :title="(s.source === 'codex' ? 'via Codex' : 'via Claude Code')"><span class="src-dot"></span>{{ s.source === 'codex' ? 'Codex' : 'Claude' }}</span> · {{ projectLabel(s.project) }} · {{ s.message_count || 0 }} msg</span>
</button>
</template>
</template>
</div>
</div>
</div> </div>
<div v-else style="color:var(--muted);font-size:13px;padding:8px 0;">No sessions this month.</div> <ActivityLedger
</template> v-if="!block.isEmpty"
:block="block"
@open-session="goToSession"
/>
<div v-else class="activity-empty">No sessions this month.</div>
</section>
<button class="show-more-btn" @click="showNextMonth">Show more activity</button> <button class="show-more-btn" @click="showNextMonth">Show more activity</button>
</div> </section>
<!-- Tooltip --> <!-- Tooltip -->
<div <div
@@ -861,96 +673,55 @@ onMounted(async () => {
} }
.chart-tooltip.show { opacity: 1; } .chart-tooltip.show { opacity: 1; }
/* Day sessions panel */ /* Session activity ledger */
.day-sessions { margin-top: 24px; } .session-activity { margin-top: 34px; }
.day-sessions-header { .activity-month-block { margin-bottom: 54px; }
font-size: 14px; font-weight: 600; color: var(--fg);
margin-top: 28px; margin-bottom: 16px; padding-bottom: 10px;
border-bottom: 1px solid var(--hairline);
}
.day-sessions-header:first-child { margin-top: 0; }
.day-activity-timeline { .activity-month-heading {
display: flex; flex-direction: column; gap: 20px; display: grid;
padding-left: 16px; border-left: 2px solid var(--hairline); grid-template-columns: max-content minmax(48px, 1fr) max-content;
align-items: center;
gap: 18px;
margin-bottom: 26px;
} }
.activity-group { position: relative; } .activity-month-heading h2 {
.activity-group-header { margin: 0;
display: flex; align-items: center; gap: 10px; color: var(--fg);
margin-bottom: 8px; font-size: 14px; color: var(--fg); font-size: 14px;
font-weight: 500; font-weight: 650;
letter-spacing: -.01em;
} }
.activity-icon {
width: 24px; height: 24px; border-radius: 50%;
display: inline-flex; align-items: center; justify-content: center;
font-size: 12px; flex-shrink: 0;
margin-left: -28px;
border: 2px solid var(--bg);
}
.activity-icon.workspace { background: rgba(245,158,11,0.2); color: #f59e0b; }
.activity-icon.new { background: var(--accent-soft); color: var(--accent-2); }
.activity-icon.continued { background: var(--surface-strong); color: var(--muted); }
.activity-group-title { font-size: 13px; } .activity-month-rule { height: 1px; background: var(--hairline); }
.activity-group.continued .activity-group-title { color: var(--muted); } .activity-month-count {
color: var(--muted-2);
.activity-group-items { display: flex; flex-direction: column; gap: 3px; padding-left: 6px; } font: 10px/1 var(--font-mono);
.activity-item { letter-spacing: .04em;
display: flex; align-items: center; justify-content: space-between; white-space: nowrap;
padding: 8px 12px; border-radius: 5px;
background: transparent; border: 0;
cursor: pointer; transition: background 0.08s;
text-align: left; width: 100%;
font: inherit; color: inherit;
} }
.activity-item:hover { background: var(--surface-strong); }
.activity-item-name { font-size: 13px; color: var(--accent-2); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .activity-empty {
.activity-item-name .activity-item-project { color: var(--muted); font-weight: 400; margin-right: 4px; } padding: 4px 0 30px 74px;
.activity-item:hover .activity-item-name { text-decoration: underline; text-underline-offset: 2px; }
.activity-item-meta { font-size: 11px; color: var(--muted); font-family: var(--font-mono); flex-shrink: 0; display: inline-flex; align-items: center; gap: 6px; }
.activity-item-meta .src-tag {
display: inline-flex; align-items: center; gap: 4px;
font-size: 10px; letter-spacing: 0.02em;
color: var(--muted); color: var(--muted);
font-size: 12px;
} }
.activity-item-meta .src-tag .src-dot { width: 5px; height: 5px; border-radius: 50%; flex-shrink: 0; }
.activity-item-meta .src-tag.claude .src-dot { background: #d97757; box-shadow: 0 0 4px rgba(217,119,87,0.5); }
.activity-item-meta .src-tag.codex .src-dot { background: #10a37f; box-shadow: 0 0 4px rgba(16,163,127,0.5); }
.activity-item-meta .src-tag.claude { color: #d97757; }
.activity-item-meta .src-tag.codex { color: #10a37f; }
.activity-group.continued .activity-item-name { color: var(--fg-2); }
.noise-fold-row {
display: flex; align-items: center; gap: 8px;
padding: 6px 12px; margin-top: 2px;
border-radius: 5px; background: transparent; border: 0;
cursor: pointer; transition: background 0.08s;
text-align: left; width: 100%; font: inherit;
color: var(--muted); font-size: 11.5px;
font-family: var(--font-mono);
}
.noise-fold-row:hover { background: var(--surface-strong); color: var(--fg-2); }
.noise-fold-row .chev {
width: 9px; height: 9px; color: var(--muted-2);
transition: transform 0.15s; flex-shrink: 0;
}
.noise-fold-row.expanded .chev { transform: rotate(90deg); color: var(--accent-2); }
.activity-item.noise .activity-item-name {
color: var(--fg-2); font-style: italic; font-weight: 400;
}
.activity-item.noise .activity-item-project { color: var(--muted); }
.show-more-btn { .show-more-btn {
display: block; width: 100%; margin-top: 20px; display: block;
padding: 8px; border-radius: 4px; width: fit-content;
background: var(--accent-soft); border: 1px solid rgba(167,139,250,0.2); margin: -18px auto 16px;
color: var(--accent-2); font-size: 12px; font-family: var(--font-mono); padding: 8px 12px;
cursor: pointer; transition: all 0.1s; text-align: center; border: 1px solid var(--hairline);
border-radius: 6px;
background: transparent;
color: var(--muted);
font: 11px/1 var(--font-mono);
cursor: pointer;
transition: color .12s, background .12s, border-color .12s;
text-align: center;
} }
.show-more-btn:hover { background: rgba(167,139,250,0.2); border-color: var(--accent); } .show-more-btn:hover { color: var(--fg-2); background: var(--surface-strong); border-color: var(--hairline-strong); }
.empty { color: var(--muted); font-size: 13px; padding: 16px 0; text-align: center; } .empty { color: var(--muted); font-size: 13px; padding: 16px 0; text-align: center; }
</style> </style>
+5
View File
@@ -0,0 +1,5 @@
export interface SourceQueryOptions {
source?: string;
}
export type UsageStatsOptions = SourceQueryOptions;
+67
View File
@@ -0,0 +1,67 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
activityGroupHasMixedSources,
activitySessionMetaParts,
activitySourceLabel,
} from '../app/src/renderer/src/activity-ledger.mjs';
const claudeSession = {
source: 'claude',
project: '-Users-tomiya-Code-quiet-zero',
message_count: 2716,
};
const codexSession = {
source: 'codex',
project: '-Users-tomiya-Code-quiet-zero',
message_count: 2052,
};
test('single-source activity groups omit provider provenance', () => {
const split = { normal: [codexSession], noise: [{ ...codexSession, message_count: 12 }] };
assert.equal(activityGroupHasMixedSources(split), false);
assert.deepEqual(
activitySessionMetaParts(codexSession, {
mixedSources: false,
projectLabel: 'quiet-zero',
}),
[
{ kind: 'project', text: 'quiet-zero' },
{ kind: 'count', text: '2,052 msg' },
],
);
});
test('mixed activity groups expose provider before project and count', () => {
const split = { normal: [codexSession], noise: [claudeSession] };
assert.equal(activityGroupHasMixedSources(split), true);
assert.deepEqual(
activitySessionMetaParts(claudeSession, {
mixedSources: true,
projectLabel: 'quiet-zero',
}),
[
{ kind: 'source', text: 'Claude Code' },
{ kind: 'project', text: 'quiet-zero' },
{ kind: 'count', text: '2,716 msg' },
],
);
});
test('workspace activity omits redundant project scope', () => {
assert.deepEqual(
activitySessionMetaParts(codexSession, {
mixedSources: false,
projectLabel: 'quiet-zero',
includeProject: false,
}),
[{ kind: 'count', text: '2,052 msg' }],
);
});
test('unknown providers retain their own provenance label', () => {
assert.equal(activitySourceLabel({ source: 'opencode' }), 'Opencode');
});
+55
View File
@@ -0,0 +1,55 @@
import { test, mock } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const preloadUrl = new URL('../app/src/preload/index.ts', import.meta.url);
const preloadDir = fileURLToPath(new URL('.', preloadUrl));
function esmResolve(specifier) {
return execFileSync(
process.execPath,
['--input-type=module', '-e', `process.stdout.write(import.meta.resolve(${JSON.stringify(specifier)}))`],
{ cwd: preloadDir, encoding: 'utf8' },
).trim();
}
test('Activity requests usage across all indexed providers', () => {
const source = readFileSync(new URL('../app/src/renderer/src/views/Activity.vue', import.meta.url), 'utf8');
assert.match(source, /getUsageStats\(\{\s*source:\s*['"]all['"]\s*\}\)/);
assert.match(source, /onIndexUpdated\?\.\(\(\)\s*=>\s*\{?\s*(?:void\s+)?loadUsageStats\(\)/s);
assert.match(source, /Array\.from\(\{\s*length:\s*loadedMonths\.value\s*\}/);
assert.doesNotMatch(source, /monthBlocks\s*=\s*ref\(/);
});
test('preload forwards usage source options to the main process', async () => {
const calls = [];
let api;
const electron = mock.module(esmResolve('electron'), {
namedExports: {
contextBridge: {
exposeInMainWorld(_name, exposedApi) {
api = exposedApi;
},
},
ipcRenderer: {
invoke(...args) {
calls.push(args);
return Promise.resolve(null);
},
on() {},
removeListener() {},
},
},
});
try {
await import(`${preloadUrl.href}?activity-usage=${Date.now()}`);
await api.getUsageStats({ source: 'all' });
assert.deepEqual(calls.at(-1), ['db:getUsageStats', { source: 'all' }]);
} finally {
electron.restore();
mock.reset();
}
});
+75 -2
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import { createRequire } from 'node:module'; import { createRequire } from 'node:module';
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
@@ -405,7 +405,7 @@ test('session IPC hides Codex rows by default and supports explicit source opt-i
ipcHandlers.get('db:getProjects')(null, {}); ipcHandlers.get('db:getProjects')(null, {});
assert.match(queries.at(-1).sql, /COALESCE\(source, 'claude'\) = 'claude'/); assert.match(queries.at(-1).sql, /COALESCE\(source, 'claude'\) = 'claude'/);
ipcHandlers.get('db:getSessions')(null, { includeCodex: true }); ipcHandlers.get('db:getSessions')(null, { source: 'all' });
assert.doesNotMatch(queries.at(-1).sql, /COALESCE\(source, 'claude'\) = 'claude'/); assert.doesNotMatch(queries.at(-1).sql, /COALESCE\(source, 'claude'\) = 'claude'/);
ipcHandlers.get('db:getSessions')(null, { source: 'codex' }); ipcHandlers.get('db:getSessions')(null, { source: 'codex' });
@@ -426,6 +426,79 @@ test('session IPC hides Codex rows by default and supports explicit source opt-i
} }
}); });
test('usage IPC aggregates normalized tokens across all indexed providers', async () => {
const originalHome = process.env.HOME;
const home = join(tmpdir(), `obelisk-main-usage-${Date.now()}`);
const obeliskDir = join(home, '.obelisk');
mkdirSync(obeliskDir, { recursive: true });
process.env.HOME = home;
const dbPath = join(obeliskDir, 'obelisk.sqlite');
const setup = new DatabaseSync(dbPath);
setup.exec(readFileSync(new URL('../packages/core/src/schema.sql', import.meta.url), 'utf8'));
setup.prepare(`
INSERT INTO messages (
uuid, session_id, type, timestamp, role, text,
input_tokens, output_tokens, source
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run('claude-message', 'claude-session', 'assistant', '2026-07-10T10:00:00Z', 'assistant', 'ok', 60, 5, 'claude');
setup.prepare(`
INSERT INTO messages (
uuid, session_id, type, timestamp, role, text,
input_tokens, output_tokens, source
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run('codex-message', 'codex:session', 'assistant', '2026-07-10T11:00:00Z', 'assistant', 'ok', 100, 10, 'codex');
setup.close();
const ipcHandlers = new Map();
class FakeBrowserWindow {
constructor() {
this.webContents = { on() {}, setZoomLevel() {}, openDevTools() {}, send() {} };
}
loadFile() {}
loadURL() {}
close() {}
static getAllWindows() { return []; }
static fromWebContents() { return null; }
}
const restore = registerMocks([
[ELECTRON_URL, {
namedExports: electronNamespace({
BrowserWindow: FakeBrowserWindow,
ipcMain: {
handle(channel, handler) {
ipcHandlers.set(channel, handler);
},
},
}),
}],
[DATABASE_URL, { defaultExport: SqliteCompatDatabase }],
[CHOKIDAR_URL, { defaultExport: noopChokidar() }],
[INDEXER_URL, { namedExports: { writeHeartbeat() {} } }],
[INDEXER_SERVICE_URL, { namedExports: defaultIndexerService() }],
[INDEXER_WORKER_URL, { namedExports: defaultIndexerWorkerClient() }],
]);
try {
await importMain();
const claudeOnly = ipcHandlers.get('db:getUsageStats')(null, {});
assert.equal(claudeOnly.totalTokens, 65);
assert.equal(claudeOnly.daily[0].tokens, 65);
const allSources = ipcHandlers.get('db:getUsageStats')(null, { source: 'all' });
assert.equal(allSources.totalTokens, 175);
assert.equal(allSources.daily[0].tokens, 175);
assert.equal(allSources.peakDay.tokens, 175);
} finally {
restore();
process.env.HOME = originalHome;
rmSync(home, { recursive: true, force: true });
}
});
test('main process migrates an existing app database before source-filtered IPC queries', async () => { test('main process migrates an existing app database before source-filtered IPC queries', async () => {
const originalHome = process.env.HOME; const originalHome = process.env.HOME;
const home = join(tmpdir(), `obelisk-main-db-migration-${Date.now()}`); const home = join(tmpdir(), `obelisk-main-db-migration-${Date.now()}`);