feat(activity): add contribution ledger
This commit is contained in:
@@ -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>
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue';
|
||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { state } from '../store.js';
|
||||
import { fmtTokens, fmtDuration, fmtTooltipDate, positionTooltip, escapeHTML, formatProjectLabel } from '../utils.js';
|
||||
import ActivityLedger from '../components/ActivityLedger.vue';
|
||||
|
||||
defineOptions({ name: 'Activity' });
|
||||
|
||||
@@ -14,7 +15,6 @@ const loading = ref(true);
|
||||
const usageData = reactive({ daily: [], totalTokens: 0, peakDay: null, longestTurn: null });
|
||||
const selectedDayKey = ref(null);
|
||||
const loadedMonths = ref(0);
|
||||
const monthBlocks = ref([]);
|
||||
|
||||
// Tooltip
|
||||
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 };
|
||||
}
|
||||
|
||||
const expandedNoise = reactive({});
|
||||
function toggleNoise(key) {
|
||||
expandedNoise[key] = !expandedNoise[key];
|
||||
}
|
||||
|
||||
function localDateStr(d) {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
@@ -250,7 +245,9 @@ const daySessions = computed(() => {
|
||||
|
||||
return {
|
||||
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'),
|
||||
newSessions: classified.filter(s => s.kind === 'new-session'),
|
||||
continued: classified.filter(s => s.kind === 'continued'),
|
||||
@@ -269,13 +266,17 @@ const daySessionsSplit = computed(() => {
|
||||
});
|
||||
|
||||
const monthBlocksSplit = computed(() =>
|
||||
monthBlocks.value.map((b, bi) => ({
|
||||
...b,
|
||||
bi,
|
||||
newWorkspaces: splitNoise(b.newWorkspaces),
|
||||
newSessions: splitNoise(b.newSessions),
|
||||
continued: splitNoise(b.continued),
|
||||
}))
|
||||
Array.from({ length: loadedMonths.value }, (_, offset) => {
|
||||
const today = new Date();
|
||||
const targetDate = new Date(today.getFullYear(), today.getMonth() - offset, 1);
|
||||
const block = buildMonthBlock(targetDate.getFullYear(), targetDate.getMonth());
|
||||
return {
|
||||
...block,
|
||||
newWorkspaces: splitNoise(block.newWorkspaces),
|
||||
newSessions: splitNoise(block.newSessions),
|
||||
continued: splitNoise(block.continued),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// --- Methods ---
|
||||
@@ -349,6 +350,7 @@ function buildMonthBlock(year, month) {
|
||||
|
||||
return {
|
||||
header: `${MONTHS_FULL[month]} ${year}`,
|
||||
sessionTotal: classified.length,
|
||||
newWorkspaces: classified.filter(s => s.kind === 'new-workspace'),
|
||||
newSessions: classified.filter(s => s.kind === 'new-session'),
|
||||
continued: classified.filter(s => s.kind === 'continued'),
|
||||
@@ -357,26 +359,12 @@ function buildMonthBlock(year, month) {
|
||||
}
|
||||
|
||||
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++;
|
||||
}
|
||||
|
||||
function projectLabel(project) {
|
||||
return formatProjectLabel(project);
|
||||
}
|
||||
|
||||
function newSessionProjectCount(sessions) {
|
||||
const projects = new Set(sessions.map(s => s.project || '(none)'));
|
||||
return projects.size;
|
||||
}
|
||||
|
||||
// --- Lifecycle ---
|
||||
onMounted(async () => {
|
||||
async function loadUsageStats() {
|
||||
try {
|
||||
const data = await window.obelisk.getUsageStats();
|
||||
const data = await window.obelisk.getUsageStats({ source: 'all' });
|
||||
usageData.daily = data.daily || [];
|
||||
usageData.totalTokens = data.totalTokens || 0;
|
||||
usageData.peakDay = data.peakDay || null;
|
||||
@@ -384,9 +372,21 @@ onMounted(async () => {
|
||||
} catch (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;
|
||||
showNextMonth();
|
||||
});
|
||||
|
||||
onUnmounted(() => stopUsageUpdates());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -544,230 +544,42 @@ onMounted(async () => {
|
||||
<div v-else class="empty">No data</div>
|
||||
</div>
|
||||
|
||||
<!-- Day sessions panel (from heatmap click) -->
|
||||
<div class="day-sessions" v-if="daySessionsSplit">
|
||||
<div class="day-sessions-header">{{ daySessionsSplit.dateLabel }}<template v-if="daySessionsSplit.isEmpty"> — no sessions</template></div>
|
||||
<div class="day-activity-timeline" v-if="!daySessionsSplit.isEmpty">
|
||||
<!-- New workspaces -->
|
||||
<div class="activity-group" v-if="daySessionsSplit.newWorkspaces.total">
|
||||
<div class="activity-group-header">
|
||||
<span class="activity-icon workspace">★</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">↳</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>
|
||||
<!-- Session activity ledger -->
|
||||
<section class="session-activity" v-if="daySessionsSplit">
|
||||
<div class="activity-month-heading">
|
||||
<h2>{{ daySessionsSplit.header }}</h2>
|
||||
<span class="activity-month-rule"></span>
|
||||
<span class="activity-month-count">{{ daySessionsSplit.sessionTotal }} session{{ daySessionsSplit.sessionTotal === 1 ? '' : 's' }}</span>
|
||||
</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 -->
|
||||
<div class="day-sessions" v-if="!selectedDayKey">
|
||||
<template v-for="(block, bi) in monthBlocksSplit" :key="bi">
|
||||
<div class="day-sessions-header">{{ block.header }}</div>
|
||||
<div class="day-activity-timeline" v-if="!block.isEmpty">
|
||||
<div class="activity-group" v-if="block.newWorkspaces.total">
|
||||
<div class="activity-group-header">
|
||||
<span class="activity-icon workspace">★</span>
|
||||
<span class="activity-group-title">Created {{ block.newWorkspaces.total }} new workspace{{ block.newWorkspaces.total > 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
<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">↳</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>
|
||||
<section class="session-activity" v-else>
|
||||
<section
|
||||
v-for="block in monthBlocksSplit"
|
||||
:key="block.header"
|
||||
class="activity-month-block"
|
||||
>
|
||||
<div class="activity-month-heading">
|
||||
<h2>{{ block.header }}</h2>
|
||||
<span class="activity-month-rule"></span>
|
||||
<span class="activity-month-count">{{ block.sessionTotal }} session{{ block.sessionTotal === 1 ? '' : 's' }}</span>
|
||||
</div>
|
||||
<div v-else style="color:var(--muted);font-size:13px;padding:8px 0;">No sessions this month.</div>
|
||||
</template>
|
||||
<ActivityLedger
|
||||
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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Tooltip -->
|
||||
<div
|
||||
@@ -861,96 +673,55 @@ onMounted(async () => {
|
||||
}
|
||||
.chart-tooltip.show { opacity: 1; }
|
||||
|
||||
/* Day sessions panel */
|
||||
.day-sessions { margin-top: 24px; }
|
||||
.day-sessions-header {
|
||||
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; }
|
||||
/* Session activity ledger */
|
||||
.session-activity { margin-top: 34px; }
|
||||
.activity-month-block { margin-bottom: 54px; }
|
||||
|
||||
.day-activity-timeline {
|
||||
display: flex; flex-direction: column; gap: 20px;
|
||||
padding-left: 16px; border-left: 2px solid var(--hairline);
|
||||
.activity-month-heading {
|
||||
display: grid;
|
||||
grid-template-columns: max-content minmax(48px, 1fr) max-content;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
|
||||
.activity-group { position: relative; }
|
||||
.activity-group-header {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
margin-bottom: 8px; font-size: 14px; color: var(--fg);
|
||||
font-weight: 500;
|
||||
.activity-month-heading h2 {
|
||||
margin: 0;
|
||||
color: var(--fg);
|
||||
font-size: 14px;
|
||||
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-group.continued .activity-group-title { color: var(--muted); }
|
||||
|
||||
.activity-group-items { display: flex; flex-direction: column; gap: 3px; padding-left: 6px; }
|
||||
.activity-item {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
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-month-rule { height: 1px; background: var(--hairline); }
|
||||
.activity-month-count {
|
||||
color: var(--muted-2);
|
||||
font: 10px/1 var(--font-mono);
|
||||
letter-spacing: .04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.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-item-name .activity-item-project { color: var(--muted); font-weight: 400; margin-right: 4px; }
|
||||
.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;
|
||||
|
||||
.activity-empty {
|
||||
padding: 4px 0 30px 74px;
|
||||
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 {
|
||||
display: block; width: 100%; margin-top: 20px;
|
||||
padding: 8px; border-radius: 4px;
|
||||
background: var(--accent-soft); border: 1px solid rgba(167,139,250,0.2);
|
||||
color: var(--accent-2); font-size: 12px; font-family: var(--font-mono);
|
||||
cursor: pointer; transition: all 0.1s; text-align: center;
|
||||
display: block;
|
||||
width: fit-content;
|
||||
margin: -18px auto 16px;
|
||||
padding: 8px 12px;
|
||||
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; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user