Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ node_modules/
dashboard/node_modules/
dashboard/dist/
.env
.env.local
dashboard/.env
.agents
.DS_Store
.gstack/
*.log
.firebase/
.dev/
nomergeconflicts-firebase-adminsdk-fbsvc-1155d26ddc.json
nomergeconflicts-firebase-adminsdk-fbsvc-1155d26ddc.json
118 changes: 116 additions & 2 deletions dashboard/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,10 @@ body::before {
font-weight: 600;
color: var(--text);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}

.goal-card .goal-meta {
Expand Down Expand Up @@ -979,9 +983,119 @@ button.feed-item {
color: var(--text-muted);
}

.feed-item .feed-note {
.activity-stats {
display: flex;
gap: 20px;
margin-bottom: 16px;
flex-wrap: wrap;
}

.stat-chip {
font-family: var(--font-display);
font-size: 11px;
color: var(--text-muted);
letter-spacing: 0.3px;
}

.stat-chip strong {
color: var(--text);
margin-left: 4px;
font-weight: 600;
}

.insight-card {
border: 1px solid var(--border-light);
background: linear-gradient(180deg, rgba(250, 247, 240, 0.96) 0%, rgba(246, 241, 228, 0.92) 100%);
border-radius: var(--radius);
padding: 18px 18px 16px;
margin-bottom: 20px;
box-shadow: var(--shadow-sm);
}

.insight-card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}

.insight-card__header h3 {
font-family: var(--font-display);
font-size: 12px;
font-weight: 500;
letter-spacing: 0.8px;
text-transform: uppercase;
}

.insight-card__status {
font-family: var(--font-display);
font-size: 10px;
letter-spacing: 0.8px;
text-transform: uppercase;
color: var(--accent);
border: 1px solid rgba(232, 93, 38, 0.22);
background: rgba(232, 93, 38, 0.08);
border-radius: 999px;
padding: 3px 8px;
}

.insight-card__headline {
font-size: 14px;
font-weight: 600;
color: var(--text);
margin-bottom: 10px;
}

.insight-card__list {
padding-left: 18px;
margin: 0 0 10px;
color: var(--text-muted);
line-height: 1.55;
font-size: 13px;
}

.insight-card__list--compact {
margin-bottom: 0;
}

.insight-card__split {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
margin-top: 8px;
}

.insight-card__group {
min-width: 0;
}

.insight-card__group-label {
font-family: var(--font-display);
font-size: 10px;
letter-spacing: 0.8px;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 6px;
}

.insight-card__meta {
margin-top: 12px;
font-family: var(--font-display);
font-size: 10px;
color: var(--text-muted);
letter-spacing: 0.2px;
}

.insight-card__loading,
.insight-card__empty,
.insight-card__error {
font-size: 13px;
color: var(--text-muted);
line-height: 1.6;
}

.insight-card__error {
color: #a33a2c;
}

.feed-load-more {
Expand Down
65 changes: 65 additions & 0 deletions dashboard/src/components/ActivitySummary.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { useEffect, useState } from 'react';
import { doc, onSnapshot } from 'firebase/firestore';
import { db } from '../firebase.js';
import { relativeTime, toDate } from '../utils.js';
import InsightCard from './InsightCard.jsx';

const FEATURE_KEY = 'activity-summary';

export default function ActivitySummary({ teamId }) {
const [summary, setSummary] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);

useEffect(() => {
setLoading(true);
setError(null);
const unsub = onSnapshot(
doc(db, 'teams', teamId, 'insights', FEATURE_KEY),
(snap) => {
setSummary(snap.exists() ? snap.data() : null);
setLoading(false);
},
(err) => {
setError(err.message);
setLoading(false);
},
);
return unsub;
}, [teamId]);

const generatedAt = toDate(summary?.generatedAt || summary?.updatedAt);
const modelLabel = summary?.model || 'google/gemini-3.1-flash-lite-preview';
const confidence = typeof summary?.confidence === 'number'
? `${Math.round(summary.confidence * 100)}% confidence`
: null;
const statusLabel = summary?.status === 'error'
? 'stale'
: summary?.status === 'ready'
? 'live'
: null;

const metaParts = [];
if (generatedAt) metaParts.push(`refreshed ${relativeTime(generatedAt)}`);
if (modelLabel) metaParts.push(modelLabel);
if (confidence) metaParts.push(confidence);
if (summary?.sourceWindow?.recentActivityCount != null) {
metaParts.push(`${summary.sourceWindow.recentActivityCount} events`);
}
const meta = metaParts.join(' · ');

return (
<InsightCard
title="## ai summary"
statusLabel={statusLabel}
loading={loading}
error={error || summary?.error || null}
emptyText="The summary will appear after the first activity update."
headline={summary?.headline || null}
bullets={Array.isArray(summary?.summaryBullets) ? summary.summaryBullets : []}
riskFlags={Array.isArray(summary?.riskFlags) ? summary.riskFlags : []}
nextActions={Array.isArray(summary?.nextActions) ? summary.nextActions : []}
meta={meta}
/>
);
}
51 changes: 49 additions & 2 deletions dashboard/src/components/GoalBar.test.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import '@testing-library/jest-dom/vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { vi, describe, it, beforeEach, expect } from 'vitest';

const snapshotHandlers = [];
Expand All @@ -22,6 +22,7 @@ import GoalBar from './GoalBar.jsx';
describe('GoalBar', () => {
beforeEach(() => {
snapshotHandlers.length = 0;
cleanup();
});

it('routes the 2-week goal card to the linked canonical plan when alignment matches', async () => {
Expand Down Expand Up @@ -57,9 +58,55 @@ describe('GoalBar', () => {
],
});

await waitFor(() => expect(screen.getByRole('button', { name: /2-week goal/i })).toBeInTheDocument());
await waitFor(() => expect(screen.getAllByRole('button', { name: /2-week goal/i })).toHaveLength(1));
fireEvent.click(screen.getByRole('button', { name: /2-week goal/i }));

expect(onSelectPlan).toHaveBeenCalledWith('plan-2w');
});

it('prefers the newest created goal-linked plan when multiple matches tie', async () => {
const onSelectPlan = vi.fn();
render(<GoalBar teamId="team1" onSelectPlan={onSelectPlan} />);

const twoWeekHandler = snapshotHandlers.find((entry) => entry.ref.path.includes('/meta/2week'));
const plansHandler = snapshotHandlers.find((entry) => entry.ref.path.includes('/plans'));

twoWeekHandler.onNext({
exists: () => true,
data: () => ({
content: 'Ship the unified company memory timeline and keep reviewer context fresh',
updatedAt: new Date(),
updatedBy: 'agent-admin',
}),
});
plansHandler.onNext({
docs: [
{
id: 'older-plan',
data: () => ({
slug: 'older-plan',
summary: 'Older summary',
alignment: 'Establishes the 2-week goal by shipping the fastest path to first product',
createdAt: new Date('2026-04-11T10:00:00Z'),
updatedAt: new Date('2026-04-11T10:00:00Z'),
}),
},
{
id: 'newer-plan',
data: () => ({
slug: 'newer-plan',
summary: 'Newer summary',
alignment: 'Establishes the 2-week goal by shipping the fastest path to first product',
createdAt: new Date('2026-04-11T10:01:00Z'),
updatedAt: new Date('2026-04-11T10:00:00Z'),
}),
},
],
});

await waitFor(() => expect(screen.getByRole('button', { name: /2-week goal/i })).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: /2-week goal/i }));

expect(onSelectPlan).toHaveBeenCalledWith('newer-plan');
});
});
89 changes: 89 additions & 0 deletions dashboard/src/components/InsightCard.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
export default function InsightCard({
title,
statusLabel = null,
loading = false,
error = null,
emptyText = 'No insight available yet.',
headline = null,
bullets = [],
riskFlags = [],
nextActions = [],
meta = null,
}) {
if (loading) {
return (
<section className="insight-card" aria-live="polite">
<div className="insight-card__header">
<h3>{title}</h3>
</div>
<div className="insight-card__loading">loading summary...</div>
</section>
);
}

if (error && !headline) {
return (
<section className="insight-card" aria-live="polite">
<div className="insight-card__header">
<h3>{title}</h3>
{statusLabel && <span className="insight-card__status">{statusLabel}</span>}
</div>
<div className="insight-card__error">{error}</div>
</section>
);
}

if (!headline && bullets.length === 0 && riskFlags.length === 0 && nextActions.length === 0) {
return (
<section className="insight-card" aria-live="polite">
<div className="insight-card__header">
<h3>{title}</h3>
{statusLabel && <span className="insight-card__status">{statusLabel}</span>}
</div>
<div className="insight-card__empty">{emptyText}</div>
</section>
);
}

return (
<section className="insight-card" aria-live="polite">
<div className="insight-card__header">
<h3>{title}</h3>
{statusLabel && <span className="insight-card__status">{statusLabel}</span>}
</div>
{headline && <p className="insight-card__headline">{headline}</p>}
{bullets.length > 0 && (
<ul className="insight-card__list">
{bullets.map((bullet, index) => (
<li key={`${index}-${bullet}`}>{bullet}</li>
))}
</ul>
)}
{(riskFlags.length > 0 || nextActions.length > 0) && (
<div className="insight-card__split">
{riskFlags.length > 0 && (
<div className="insight-card__group">
<div className="insight-card__group-label">risks</div>
<ul className="insight-card__list insight-card__list--compact">
{riskFlags.map((flag, index) => (
<li key={`${index}-${flag}`}>{flag}</li>
))}
</ul>
</div>
)}
{nextActions.length > 0 && (
<div className="insight-card__group">
<div className="insight-card__group-label">next</div>
<ul className="insight-card__list insight-card__list--compact">
{nextActions.map((action, index) => (
<li key={`${index}-${action}`}>{action}</li>
))}
</ul>
</div>
)}
</div>
)}
{meta && <div className="insight-card__meta">{meta}</div>}
</section>
);
}
3 changes: 0 additions & 3 deletions dashboard/src/components/MemoryPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -201,8 +201,6 @@ function MemoryTimelineItem({ entry }) {
const [showModal, setShowModal] = useState(false);
const timestamp = toDate(memoryTimestamp(entry));
const timeLabel = relativeTime(timestamp) || 'unknown';
const rawPreview = String(entry.content || '').replace(/\s+/g, ' ').trim();
const preview = rawPreview.length > 180 ? `${rawPreview.slice(0, 180).trimEnd()}…` : rawPreview;

return (
<>
Expand All @@ -214,7 +212,6 @@ function MemoryTimelineItem({ entry }) {
<span className="feed-action">added</span>{' '}
<span className="feed-slug">{entry.title || 'Untitled'}</span>
</span>
{preview && <span className="feed-note"> -- {preview}</span>}
{entry.tags.length > 0 && (
<span className="memory-tag-row" aria-label="memory tags">
{entry.tags.map((tag) => (
Expand Down
Loading
Loading