From c57066c17c6e4ebd47623a27a82edc0776e64d67 Mon Sep 17 00:00:00 2001 From: Nathan Wang <149958172+nwang783@users.noreply.github.com> Date: Sat, 11 Apr 2026 12:48:15 -0400 Subject: [PATCH 1/2] Add gsync reports and plan-driven goals --- README.md | 19 ++- SKILL.md | 41 ++++-- dashboard/src/App.css | 112 ++++++++++++++++ dashboard/src/components/GoalBar.jsx | 36 ++--- dashboard/src/components/GoalBar.test.jsx | 95 +++++++------ dashboard/src/components/Sidebar.jsx | 1 + dashboard/src/lib/planTags.js | 90 +------------ dashboard/src/pages/Dashboard.jsx | 7 + dashboard/src/pages/ReportsPage.jsx | 88 ++++++++++++ dashboard/src/pages/ReportsPage.test.jsx | 81 +++++++++++ firestore.rules | 5 + functions/index.js | 156 +++++++++++++++++++++- src/cli.js | 152 +++++++++++++++------ src/context.js | 6 +- src/firestore.js | 14 +- test/join-flow-api.test.cjs | 43 ++++++ 16 files changed, 717 insertions(+), 229 deletions(-) create mode 100644 dashboard/src/pages/ReportsPage.jsx create mode 100644 dashboard/src/pages/ReportsPage.test.jsx diff --git a/README.md b/README.md index c293ae1..8c4750c 100644 --- a/README.md +++ b/README.md @@ -48,8 +48,8 @@ It is designed to solve three problems: `gsync` creates a shared coordination loop: -- the team sets a `2-week` goal for higher-level direction -- the team sets a `3-day` target for short-term focus +- the team sets a `2-week` goal by pushing a plan with `--goal 2week` +- the team sets a `3-day` target by pushing a plan with `--goal 3day` - each person publishes active plans with summaries, ownership, touched surfaces, and status - teammates can pull and inspect each other's plans - agents can ingest synced context before generating new plans @@ -129,8 +129,11 @@ gsync login --key gsync sync --last 20 cat ~/.gsync/CONTEXT.md gsync memory reviewer-context # compiled memory bundle; fails closed if sync is stale +gsync report bug --title "Login copy is confusing" --body "The error did not tell me whether the seat key or network was wrong." gsync plan pull # only if a summary looks relevant gsync plan push my-plan.md # create or update canonical plan +gsync plan push my-plan.md --goal 3day # push plan and set as 3-day target +gsync plan push my-plan.md --goal 2week # push plan and set as 2-week goal gsync plan update --note "milestone or blocker" gsync plan review --pr https://github.com/org/repo/pull/123 gsync plan merged @@ -158,3 +161,15 @@ Key behavior: - Memories are append-only markdown entries; older entries stay visible in the dashboard and compiled reviewer context. - `gsync sync` recompiles the compiled memory bundle. - If memory changes after your last sync, `gsync memory reviewer-context` fails closed until you sync again. + +## Product Feedback + +gsync also has a built-in feedback queue for bugs and feature requests about gsync itself. This is not meant to replace your main GitHub issue tracker; it is an in-product inbox for early product feedback while the coordination workflow is still evolving. + +```bash +gsync report bug --title "Join flow is unclear" --body "I expected the CLI to tell me whether the join code expired or was mistyped." --severity medium +gsync report feature --title "Show plan diffs" --body "I want to compare plan revisions before pulling the latest markdown." +gsync report list +``` + +The dashboard exposes the same queue in the `reports` tab so the team has one internal source of truth for product feedback coming from active use. diff --git a/SKILL.md b/SKILL.md index 23bed7c..76178c4 100644 --- a/SKILL.md +++ b/SKILL.md @@ -159,6 +159,19 @@ Use this to: - Check if any active plan already touches the files you're about to edit — if so, coordinate before starting - Avoid duplicating work that's already in progress or just merged +If gsync itself feels broken, confusing, or unintuitive while you are using it, file feedback right away: + +```bash +gsync report bug --title "" --body "" +gsync report feature --title "" --body "" +``` + +Guidance for agents: +- Be honest. If something feels rough, say so plainly. +- Be lenient in tone. The goal is useful product feedback, not venting. +- Report product issues in gsync itself, not bugs in the user's application or repo. +- Prefer specific reproduction details over vague complaints. + Important: `gsync sync` no longer mirrors every full plan file locally. It gives you a summary index first. Pull full markdown plans only when they are relevant. Important: approved company memory is now compiled separately from the summary context. `gsync memory reviewer-context` prints the approval-gated company brief, project brief, decision log, and plan context bundle that reviewer agents should use. If approved memory changed after your last sync, this command fails closed and tells you to rerun `gsync sync` first. @@ -227,6 +240,9 @@ Now take the output of your gstack plan and register it with gsync so your teamm ```bash gsync plan push my-plan.md +# or, to also set this plan as the 3-day target or 2-week goal: +gsync plan push my-plan.md --goal 3day +gsync plan push my-plan.md --goal 2week ``` Recommended `my-plan.md` shape: @@ -348,22 +364,18 @@ Do this before starting the next task. Stale "review" plans pollute the team's C ## Goal Management -Goals should be updated when the team explicitly decides to change direction — not unilaterally by one agent or engineer. When you do update them, use specific, measurable language: +Goals are set by pushing a plan with `--goal 3day` or `--goal 2week`. Goals should be updated when the team explicitly decides to change direction — not unilaterally by one agent or engineer. When you do update them, use specific, measurable language in the plan summary: ```bash -# Too vague — bad -gsync goals set-2week --goal "improve the product" - -# Concrete and measurable — good -gsync goals set-2week --goal "Ship multiplayer beta to 50 invite-only users with real-time presence, cursor tracking, and conflict-free editing by April 18" +# Push a plan and set it as the 3-day target +gsync plan push my-plan.md --goal 3day -# Too vague — bad -gsync goals set-3day --goal "work on the backend" - -# Specific enough to coordinate around — good -gsync goals set-3day --goal "Merge WebSocket presence layer into staging and get sign-off from design on the cursor UI by Wednesday EOD" +# Push a plan and set it as the 2-week goal +gsync plan push my-plan.md --goal 2week ``` +The plan summary becomes the goal text visible to teammates and in CONTEXT.md. The full plan body is available via `gsync plan pull `. + --- ## Reference: All Commands @@ -381,14 +393,17 @@ gsync plan pull # fetch full markdown plan into ~/.gsync/plans/ gsync plan pull --metadata-only # print summary metadata only gsync plan pull --stdout # print summary metadata + canonical markdown body gsync plan push my-plan.md # create/update canonical markdown plan from file +gsync plan push my-plan.md --goal 3day # push plan and set as 3-day target +gsync plan push my-plan.md --goal 2week # push plan and set as 2-week goal gsync plan update ... # add a progress note (see Step 3) gsync plan review --pr # link plan to PR, move to review status gsync plan merged # close a plan after PR merges -gsync goals set-2week --goal "..." # update 2-week goal -gsync goals set-3day --goal "..." # update 3-day target gsync memory draft --title "..." --body "..." # create a planning conversation draft gsync memory approve --to companyBrief|projectBrief|decisionLog # promote approved memory gsync memory reviewer-context # print the compiled, fail-closed approved-memory bundle +gsync report bug --title "..." --body "..." --severity medium # report a gsync bug +gsync report feature --title "..." --body "..." # report a gsync feature request +gsync report list # inspect recent gsync product feedback ``` --- diff --git a/dashboard/src/App.css b/dashboard/src/App.css index 9605533..e58031d 100644 --- a/dashboard/src/App.css +++ b/dashboard/src/App.css @@ -1119,6 +1119,118 @@ button.feed-item { background: rgba(59, 47, 30, 0.03); } +.reports-page { + display: flex; + flex-direction: column; + gap: 16px; +} + +.reports-kicker { + font-family: var(--font-display); + font-size: 12px; + letter-spacing: 0.4px; + color: var(--text-muted); +} + +.reports-list { + display: grid; + gap: 14px; +} + +.report-card { + border: 1px solid var(--border); + border-radius: var(--radius); + background: rgba(250, 247, 240, 0.9); + box-shadow: var(--shadow-sm); + padding: 18px 18px 16px; +} + +.report-card__meta, +.report-card__footer { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.report-card__title { + font-family: var(--font-display); + font-size: 16px; + font-weight: 500; + color: var(--text); + margin: 10px 0 8px; +} + +.report-card__body { + color: var(--text); + white-space: pre-wrap; +} + +.report-card__footer { + margin-top: 14px; + font-size: 12px; + color: var(--text-muted); + justify-content: space-between; +} + +.report-kind, +.report-severity { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + padding: 3px 8px; + font-family: var(--font-display); + font-size: 10px; + letter-spacing: 0.7px; + text-transform: uppercase; +} + +.report-kind--bug { + color: #9d2d19; + background: rgba(196, 67, 43, 0.12); + border: 1px solid rgba(196, 67, 43, 0.24); +} + +.report-kind--feature { + color: #175d78; + background: rgba(23, 93, 120, 0.1); + border: 1px solid rgba(23, 93, 120, 0.22); +} + +.report-severity--low { + color: var(--text-muted); + background: rgba(138, 125, 107, 0.12); + border: 1px solid rgba(138, 125, 107, 0.18); +} + +.report-severity--medium { + color: #8a4f08; + background: rgba(240, 122, 58, 0.12); + border: 1px solid rgba(240, 122, 58, 0.24); +} + +.report-severity--high { + color: #9d2d19; + background: rgba(232, 93, 38, 0.12); + border: 1px solid rgba(232, 93, 38, 0.28); +} + +.report-time { + font-family: var(--font-display); + font-size: 11px; + color: var(--text-muted); + margin-left: auto; +} + +.reports-empty { + border: 1px dashed var(--border); + border-radius: var(--radius); + background: rgba(250, 247, 240, 0.7); + padding: 20px; + color: var(--text-muted); +} + /* Memory panel header row */ .memory-panel__header { display: flex; diff --git a/dashboard/src/components/GoalBar.jsx b/dashboard/src/components/GoalBar.jsx index 7c8cfdf..f5d59ca 100644 --- a/dashboard/src/components/GoalBar.jsx +++ b/dashboard/src/components/GoalBar.jsx @@ -1,13 +1,11 @@ import { useState, useEffect } from 'react'; -import { collection, doc, onSnapshot } from 'firebase/firestore'; +import { doc, onSnapshot } from 'firebase/firestore'; import { db } from '../firebase.js'; import { relativeTime } from '../utils.js'; -import { findGoalLinkedPlan } from '../lib/planTags.js'; export default function GoalBar({ teamId, onSelectPlan }) { const [twoWeek, setTwoWeek] = useState(null); const [threeDay, setThreeDay] = useState(null); - const [plans, setPlans] = useState([]); const [error, setError] = useState(null); const [selected, setSelected] = useState(null); const [, setTick] = useState(0); @@ -34,23 +32,11 @@ export default function GoalBar({ teamId, onSelectPlan }) { }; }, [teamId]); - useEffect(() => { - const unsubPlans = onSnapshot( - collection(db, 'teams', teamId, 'plans'), - (snap) => setPlans(snap.docs.map((d) => ({ id: d.id, ...d.data() }))), - (err) => setError(err.message), - ); - - return unsubPlans; - }, [teamId]); - - function openGoal(type, label, data) { - const linkedPlan = findGoalLinkedPlan(plans, type, data?.content); - if (linkedPlan?.id && onSelectPlan) { - onSelectPlan(linkedPlan.id); + function openGoal(label, data) { + if (data?.planId && onSelectPlan) { + onSelectPlan(data.planId); return; } - setSelected({ label, data }); } @@ -58,8 +44,8 @@ export default function GoalBar({ teamId, onSelectPlan }) { <>
{error &&
{error}
} - openGoal('2week', '2-week goal', twoWeek)} /> - openGoal('3day', '3-day target', threeDay)} /> + openGoal('2-week goal', twoWeek)} /> + openGoal('3-day target', threeDay)} />
{selected && ( setSelected(null)} /> @@ -74,7 +60,7 @@ function GoalCard({ label, variant, data, onClick }) {
{label}
{data ? ( <> -
{data.content}
+
{data.summary}
Updated {relativeTime(data.updatedAt)} {data.updatedBy && ` by ${data.updatedBy}`} @@ -99,12 +85,8 @@ function GoalDetail({ label, data, onClose }) { {data ? ( <>
-
content
-
{data.content}
-
-
-
linked plan
-
No matching canonical plan was found for this goal yet.
+
summary
+
{data.summary}
last updated
diff --git a/dashboard/src/components/GoalBar.test.jsx b/dashboard/src/components/GoalBar.test.jsx index 593667b..6353adf 100644 --- a/dashboard/src/components/GoalBar.test.jsx +++ b/dashboard/src/components/GoalBar.test.jsx @@ -6,7 +6,6 @@ const snapshotHandlers = []; vi.mock('firebase/firestore', () => ({ doc: (...parts) => ({ kind: 'doc', path: parts.join('/') }), - collection: (...parts) => ({ kind: 'collection', path: parts.join('/') }), onSnapshot: (ref, onNext) => { snapshotHandlers.push({ ref, onNext }); return () => {}; @@ -25,38 +24,23 @@ describe('GoalBar', () => { cleanup(); }); - it('routes the 2-week goal card to the linked canonical plan when alignment matches', async () => { + it('opens the linked plan directly when planId is set on the meta doc', async () => { const onSelectPlan = vi.fn(); render(); const twoWeekHandler = snapshotHandlers.find((entry) => entry.ref.path.includes('/meta/2week')); const threeDayHandler = snapshotHandlers.find((entry) => entry.ref.path.includes('/meta/3day')); - const plansHandler = snapshotHandlers.find((entry) => entry.ref.path.includes('/plans')); twoWeekHandler.onNext({ exists: () => true, data: () => ({ - content: 'Enable self-serve onboarding by April 22', + planId: 'plan-2w', + summary: 'Enable self-serve onboarding by April 22', updatedAt: new Date(), updatedBy: 'nathan-laptop', }), }); - threeDayHandler.onNext({ - exists: () => false, - }); - plansHandler.onNext({ - docs: [ - { - id: 'plan-2w', - data: () => ({ - slug: 'enable-self-serve', - summary: 'Enable creators to launch without manual help', - alignment: 'Establishes the 2-week goal by shipping the fastest path to first product', - updatedAt: new Date(), - }), - }, - ], - }); + threeDayHandler.onNext({ exists: () => false }); await waitFor(() => expect(screen.getAllByRole('button', { name: /2-week goal/i })).toHaveLength(1)); fireEvent.click(screen.getByRole('button', { name: /2-week goal/i })); @@ -64,49 +48,64 @@ describe('GoalBar', () => { expect(onSelectPlan).toHaveBeenCalledWith('plan-2w'); }); - it('prefers the newest created goal-linked plan when multiple matches tie', async () => { + it('opens the inline detail modal when no planId is set', async () => { const onSelectPlan = vi.fn(); render(); const twoWeekHandler = snapshotHandlers.find((entry) => entry.ref.path.includes('/meta/2week')); - const plansHandler = snapshotHandlers.find((entry) => entry.ref.path.includes('/plans')); + const threeDayHandler = snapshotHandlers.find((entry) => entry.ref.path.includes('/meta/3day')); twoWeekHandler.onNext({ exists: () => true, data: () => ({ - content: 'Ship the unified company memory timeline and keep reviewer context fresh', + summary: 'Enable self-serve onboarding by April 22', updatedAt: new Date(), - updatedBy: 'agent-admin', + updatedBy: 'nathan-laptop', }), }); - 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'), - }), - }, - ], + threeDayHandler.onNext({ exists: () => false }); + + 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).not.toHaveBeenCalled(); + expect(screen.getByText('Enable self-serve onboarding by April 22')).toBeInTheDocument(); + }); + + it('shows "not set" when goal meta doc does not exist', async () => { + render(); + + const twoWeekHandler = snapshotHandlers.find((entry) => entry.ref.path.includes('/meta/2week')); + const threeDayHandler = snapshotHandlers.find((entry) => entry.ref.path.includes('/meta/3day')); + + twoWeekHandler.onNext({ exists: () => false }); + threeDayHandler.onNext({ exists: () => false }); + + await waitFor(() => expect(screen.getAllByText('not set')).toHaveLength(2)); + }); + + it('modal label stays correct when Firestore re-pushes a new object reference', async () => { + const onSelectPlan = vi.fn(); + render(); + + const twoWeekHandler = snapshotHandlers.find((entry) => entry.ref.path.includes('/meta/2week')); + const threeDayHandler = snapshotHandlers.find((entry) => entry.ref.path.includes('/meta/3day')); + + const goalData = () => ({ + summary: 'Ship WebSocket layer', + updatedAt: new Date(), + updatedBy: 'nathan-laptop', }); + twoWeekHandler.onNext({ exists: () => true, data: goalData }); + threeDayHandler.onNext({ exists: () => false }); + 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'); + // Simulate Firestore re-pushing a new object reference + twoWeekHandler.onNext({ exists: () => true, data: goalData }); + + await waitFor(() => expect(screen.getByText('2-week goal')).toBeInTheDocument()); }); }); diff --git a/dashboard/src/components/Sidebar.jsx b/dashboard/src/components/Sidebar.jsx index 5dbf178..b367452 100644 --- a/dashboard/src/components/Sidebar.jsx +++ b/dashboard/src/components/Sidebar.jsx @@ -3,6 +3,7 @@ import { useState } from 'react'; const NAV_ITEMS = [ { id: 'overview', label: 'overview', icon: '~' }, { id: 'company', label: 'company', icon: '◈' }, + { id: 'reports', label: 'reports', icon: '!' }, { id: 'me', label: 'me', icon: '>' }, ]; diff --git a/dashboard/src/lib/planTags.js b/dashboard/src/lib/planTags.js index 801bec6..129defe 100644 --- a/dashboard/src/lib/planTags.js +++ b/dashboard/src/lib/planTags.js @@ -1,89 +1,5 @@ -function normalizeText(value) { - return String(value || '').trim().toLowerCase(); -} - -function includesNormalized(haystack, needle) { - if (!haystack || !needle) return false; - return normalizeText(haystack).includes(normalizeText(needle)); -} - -function getUpdatedAtMs(plan) { - const updatedAt = plan?.updatedAt; - if (!updatedAt) return 0; - if (updatedAt instanceof Date) return updatedAt.getTime(); - if (typeof updatedAt.toDate === 'function') return updatedAt.toDate().getTime(); - if (typeof updatedAt.seconds === 'number') return updatedAt.seconds * 1000; - if (typeof updatedAt === 'number') return updatedAt; - return 0; -} - -function getCreatedAtMs(plan) { - const createdAt = plan?.createdAt; - if (!createdAt) return 0; - if (createdAt instanceof Date) return createdAt.getTime(); - if (typeof createdAt.toDate === 'function') return createdAt.toDate().getTime(); - if (typeof createdAt.seconds === 'number') return createdAt.seconds * 1000; - if (typeof createdAt === 'number') return createdAt; - return 0; -} - export function getPlanGoalTags(plan) { - const alignment = normalizeText(plan?.alignment); - const summary = normalizeText(plan?.summary); - const slug = normalizeText(plan?.slug || plan?.id); - const tags = []; - - if ( - alignment.includes('2-week goal') || - alignment.includes('two-week goal') || - summary.includes('2-week goal') || - slug.includes('2week') - ) { - tags.push('2-week goal'); - } - - if ( - alignment.includes('3-day target') || - alignment.includes('three-day target') || - summary.includes('3-day target') || - slug.includes('3day') - ) { - tags.push('3-day target'); - } - - return tags; -} - -export function findGoalLinkedPlan(plans, goalType, goalContent) { - const label = goalType === '2week' ? '2-week goal' : '3-day target'; - const alternate = goalType === '2week' ? 'two-week goal' : 'three-day target'; - const goalText = normalizeText(goalContent); - - const scored = (plans || []) - .map((plan) => { - const alignment = normalizeText(plan.alignment); - const summary = normalizeText(plan.summary); - const slug = normalizeText(plan.slug || plan.id); - let score = 0; - - if (alignment.includes(label)) score += 6; - if (alignment.includes(alternate)) score += 6; - if (summary.includes(label)) score += 3; - if (summary.includes(alternate)) score += 3; - if (slug.includes(goalType)) score += 2; - if (goalText && includesNormalized(plan.alignment, goalText)) score += 8; - if (goalText && includesNormalized(plan.summary, goalText)) score += 2; - - return { plan, score }; - }) - .filter((entry) => entry.score > 0); - - scored.sort((a, b) => { - if (b.score !== a.score) return b.score - a.score; - const createdDelta = getCreatedAtMs(b.plan) - getCreatedAtMs(a.plan); - if (createdDelta !== 0) return createdDelta; - return getUpdatedAtMs(b.plan) - getUpdatedAtMs(a.plan); - }); - - return scored[0]?.plan || null; + if (plan?.goalType === '2week') return ['2-week goal']; + if (plan?.goalType === '3day') return ['3-day target']; + return []; } diff --git a/dashboard/src/pages/Dashboard.jsx b/dashboard/src/pages/Dashboard.jsx index 9f85d91..8b254dd 100644 --- a/dashboard/src/pages/Dashboard.jsx +++ b/dashboard/src/pages/Dashboard.jsx @@ -6,10 +6,12 @@ import TeamColumns from '../components/TeamColumns.jsx'; import UpdateFeed from '../components/UpdateFeed.jsx'; import PlanDetail from '../components/PlanDetail.jsx'; import CompanyPage from './CompanyPage.jsx'; +import ReportsPage from './ReportsPage.jsx'; const PAGE_TITLES = { overview: '# overview', company: '# company', + reports: '# reports', me: '# me', activity: '# activity', }; @@ -35,6 +37,7 @@ export default function Dashboard() {

{PAGE_TITLES[activePage] ?? '# overview'}

+ {teamId}
{activePage === 'overview' && ( @@ -54,6 +57,10 @@ export default function Dashboard() { )} + {activePage === 'reports' && ( + + )} + {activePage === 'me' && (
...
diff --git a/dashboard/src/pages/ReportsPage.jsx b/dashboard/src/pages/ReportsPage.jsx new file mode 100644 index 0000000..176c281 --- /dev/null +++ b/dashboard/src/pages/ReportsPage.jsx @@ -0,0 +1,88 @@ +import { useEffect, useMemo, useState } from 'react'; +import { collection, limit, onSnapshot, orderBy, query } from 'firebase/firestore'; +import { db } from '../firebase.js'; +import { relativeTime, toDate } from '../utils.js'; + +function reportTimeMs(report) { + return toDate(report.createdAt)?.getTime() ?? 0; +} + +function ReportCard({ report }) { + const createdAt = toDate(report.createdAt); + + return ( +
+
+ {report.kind || 'feature'} + {report.severity && {report.severity}} + {createdAt ? relativeTime(createdAt) : 'unknown'} +
+

{report.title || 'Untitled report'}

+

{report.body || ''}

+
+ {report.createdBySeatName || 'unknown seat'} + {report.source || 'cli'} +
+
+ ); +} + +export default function ReportsPage({ teamId }) { + const [reports, setReports] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const reportsQuery = query( + collection(db, 'teams', teamId, 'reports'), + orderBy('createdAt', 'desc'), + limit(50), + ); + const unsub = onSnapshot( + reportsQuery, + (snap) => { + setReports(snap.docs.map((entry) => ({ id: entry.id, ...entry.data() }))); + setLoading(false); + }, + (err) => { + setError(err.message); + setLoading(false); + }, + ); + + return unsub; + }, [teamId]); + + const sortedReports = useMemo( + () => [...reports].sort((left, right) => reportTimeMs(right) - reportTimeMs(left)), + [reports], + ); + + if (loading) { + return
loading reports...
; + } + + if (error) { + return
{error}
; + } + + if (sortedReports.length === 0) { + return ( +
+

No reports yet.

+

Use `gsync report bug` or `gsync report feature` to push feedback into this queue.

+
+ ); + } + + return ( +
+
Honest, lenient feedback about the gsync product itself.
+
+ {sortedReports.map((report) => ( + + ))} +
+
+ ); +} diff --git a/dashboard/src/pages/ReportsPage.test.jsx b/dashboard/src/pages/ReportsPage.test.jsx new file mode 100644 index 0000000..b67cc8e --- /dev/null +++ b/dashboard/src/pages/ReportsPage.test.jsx @@ -0,0 +1,81 @@ +import '@testing-library/jest-dom/vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { vi, describe, it, beforeEach, expect } from 'vitest'; + +const snapshotCallbacks = new Map(); + +vi.mock('firebase/firestore', () => ({ + collection: (...parts) => ({ path: parts.filter((part) => typeof part === 'string').join('/') }), + query: (ref) => ref, + orderBy: () => ({}), + limit: () => ({}), + onSnapshot: (_ref, onNext) => { + snapshotCallbacks.set(_ref.path, onNext); + return () => {}; + }, +})); + +vi.mock('../firebase.js', () => ({ + db: {}, +})); + +import ReportsPage from './ReportsPage.jsx'; + +describe('ReportsPage', () => { + beforeEach(() => { + snapshotCallbacks.clear(); + }); + + it('renders recent gsync reports in newest-first order', async () => { + render(); + + await waitFor(() => expect(snapshotCallbacks.has('teams/team-1/reports')).toBe(true)); + + snapshotCallbacks.get('teams/team-1/reports')({ + docs: [ + { + id: 'report-1', + data: () => ({ + kind: 'feature', + title: 'Add plan diff previews', + body: 'I wanted to compare revisions before pulling a plan.', + source: 'cli', + createdBySeatName: 'agent-alpha', + createdAt: new Date(Date.now() - 10 * 60_000), + }), + }, + { + id: 'report-2', + data: () => ({ + kind: 'bug', + title: 'Join code errors are too vague', + body: 'The failure path does not explain whether the code expired or was mistyped.', + severity: 'high', + source: 'cli', + createdBySeatName: 'agent-beta', + createdAt: new Date(Date.now() - 1 * 60_000), + }), + }, + ], + }); + + await waitFor(() => expect(screen.getByText('Join code errors are too vague')).toBeInTheDocument()); + + const headings = screen.getAllByRole('heading', { level: 2 }); + expect(headings[0]).toHaveTextContent('Join code errors are too vague'); + expect(headings[1]).toHaveTextContent('Add plan diff previews'); + expect(screen.getByText('high')).toBeInTheDocument(); + expect(screen.getByText('agent-beta')).toBeInTheDocument(); + }); + + it('shows the empty state when no reports exist', async () => { + render(); + + await waitFor(() => expect(snapshotCallbacks.has('teams/team-1/reports')).toBe(true)); + + snapshotCallbacks.get('teams/team-1/reports')({ docs: [] }); + + expect(await screen.findByText(/No reports yet/i)).toBeInTheDocument(); + expect(screen.getByText(/gsync report bug/i)).toBeInTheDocument(); + }); +}); diff --git a/firestore.rules b/firestore.rules index ad9aa3b..cb4a4fd 100644 --- a/firestore.rules +++ b/firestore.rules @@ -59,6 +59,11 @@ service cloud.firestore { allow write: if false; } + match /reports/{reportId} { + allow read: if teamMember(teamId); + allow write: if false; + } + match /memberships/{seatId} { allow read: if teamMember(teamId); allow write: if false; diff --git a/functions/index.js b/functions/index.js index b412878..158ba31 100644 --- a/functions/index.js +++ b/functions/index.js @@ -44,7 +44,7 @@ function sendApiError(res, err, { fallbackMessage, status = 500 }) { return res.status(status).json({ error: fallbackMessage }); } -async function requireTeamAdmin(req, { adminClient = admin, dbClient = db } = {}) { +async function requireTeamMember(req, { adminClient = admin, dbClient = db } = {}) { const authHeader = req.get("authorization") || ""; const match = authHeader.match(/^Bearer\s+(.+)$/i); if (!match) { @@ -70,16 +70,38 @@ async function requireTeamAdmin(req, { adminClient = admin, dbClient = db } = {} throw err; } - if (membershipSnap.data().role !== "admin") { + const membership = membershipSnap.data(); + + return { + teamId, + seatId, + seatName: membership.seatName || decoded.name || seatId, + role: membership.role || "member", + }; +} + +async function requireScopedTeamMember(req, requestedTeamId, deps = {}) { + const auth = await requireTeamMember(req, deps); + if (requestedTeamId && requestedTeamId !== auth.teamId) { + const err = new Error("Seats can only submit reports for their own team"); + err.status = 403; + throw err; + } + return auth; +} + +async function requireTeamAdmin(req, deps = {}) { + const auth = await requireTeamMember(req, deps); + if (auth.role !== "admin") { const err = new Error("Only admins can create join codes"); err.status = 403; throw err; } return { - teamId, - seatId, - seatName: membershipSnap.data().seatName || decoded.name || seatId, + teamId: auth.teamId, + seatId: auth.seatId, + seatName: auth.seatName, }; } @@ -127,6 +149,99 @@ async function issueJoinCodeForTeam({ dbClient = db, teamId, seatId, seatName }) }; } +const VALID_REPORT_KINDS = new Set(["bug", "feature"]); +const VALID_REPORT_SEVERITIES = new Set(["low", "medium", "high"]); + +function normalizeOptionalText(value) { + const text = String(value || "").trim(); + return text || null; +} + +function validateReportPayload(payload = {}) { + const kind = String(payload.kind || "").trim().toLowerCase(); + const title = String(payload.title || "").trim(); + const body = String(payload.body || "").trim(); + const severityRaw = normalizeOptionalText(payload.severity); + const severity = severityRaw ? severityRaw.toLowerCase() : null; + + if (!VALID_REPORT_KINDS.has(kind)) { + const err = new Error("Report kind must be `bug` or `feature`"); + err.status = 400; + throw err; + } + + if (!title) { + const err = new Error("Report title is required"); + err.status = 400; + throw err; + } + + if (!body) { + const err = new Error("Report body is required"); + err.status = 400; + throw err; + } + + if (title.length > 160) { + const err = new Error("Report title must be 160 characters or fewer"); + err.status = 400; + throw err; + } + + if (body.length > 4000) { + const err = new Error("Report body must be 4000 characters or fewer"); + err.status = 400; + throw err; + } + + if (severity && !VALID_REPORT_SEVERITIES.has(severity)) { + const err = new Error("Report severity must be low, medium, or high"); + err.status = 400; + throw err; + } + + return { + kind, + title, + body, + severity, + }; +} + +async function createTeamReport({ + dbClient = db, + teamId, + seatId, + seatName, + role = "member", + source = "cli", + payload, +}) { + const report = validateReportPayload(payload); + const reportRef = dbClient.doc(`teams/${teamId}/reports/${uuidv4()}`); + + await reportRef.set({ + kind: report.kind, + title: report.title, + body: report.body, + severity: report.severity, + source, + createdAt: FieldValue.serverTimestamp(), + createdBySeatId: seatId, + createdBySeatName: seatName, + createdByRole: role, + }); + + return { + id: reportRef.id, + ...report, + source, + createdBySeatId: seatId, + createdBySeatName: seatName, + createdByRole: role, + }; +} + async function joinTeamWithCode({ dbClient = db, adminClient = admin, joinCode, seatName }) { const codeHash = sha256(joinCode); @@ -212,8 +327,8 @@ async function loadActivitySummarySource(teamId) { return buildActivitySummarySource({ teamId, teamName, - twoWeekGoal: twoWeekSnap.exists ? twoWeekSnap.data().content || null : null, - threeDayGoal: threeDaySnap.exists ? threeDaySnap.data().content || null : null, + twoWeekGoal: twoWeekSnap.exists ? twoWeekSnap.data().summary || null : null, + threeDayGoal: threeDaySnap.exists ? threeDaySnap.data().summary || null : null, plans: plansSnap.docs.map((doc) => ({ id: doc.id, ...doc.data() })), }); } @@ -438,6 +553,30 @@ router.post("/agent/login", async (req, res) => { } }); +router.post("/teams/:teamId/reports", async (req, res) => { + try { + const requestedTeamId = req.params.teamId || null; + const auth = await requireScopedTeamMember(req, requestedTeamId); + const report = await createTeamReport({ + dbClient: db, + teamId: auth.teamId, + seatId: auth.seatId, + seatName: auth.seatName, + role: auth.role, + source: "cli", + payload: req.body || {}, + }); + + return res.status(201).json({ + teamId: auth.teamId, + report, + }); + } catch (err) { + console.error("POST /teams/:teamId/reports error:", err); + return sendApiError(res, err, { fallbackMessage: "Could not submit report" }); + } +}); + async function handleActivitySummaryRefresh(req, res) { try { const requestedTeamId = req.params.teamId || req.body.teamId || null; @@ -483,8 +622,11 @@ exports.onTeamGoalWriteActivitySummary = functions.firestore }); exports.api = functions.https.onRequest(app); +module.exports.requireTeamMember = requireTeamMember; +module.exports.requireScopedTeamMember = requireScopedTeamMember; module.exports.requireTeamAdmin = requireTeamAdmin; module.exports.requireScopedTeamAdmin = requireScopedTeamAdmin; module.exports.issueJoinCodeForTeam = issueJoinCodeForTeam; module.exports.joinTeamWithCode = joinTeamWithCode; +module.exports.createTeamReport = createTeamReport; module.exports.refreshActivitySummary = refreshActivitySummary; diff --git a/src/cli.js b/src/cli.js index 548be92..299bfc5 100644 --- a/src/cli.js +++ b/src/cli.js @@ -5,7 +5,7 @@ import path from 'path'; import { fileURLToPath } from 'url'; import { loadConfig, saveConfig, loadSession, saveSession, clearSession, ensureDirs, PLANS_DIR, CONFIG_FILE, CONTEXT_FILE, INDEX_FILE, SKILL_FILE, getDefaultConfig, hasConfigFile } from './config.js'; -import { initFirebase, cleanup, getTeamMeta, setTeamMeta, getPlanContent, getPlanSummary, getRecentPlans, updatePlanNote, updatePlanStatus, getActivePlans, upsertPlanContent, createMemoryEntry, getMemoryTimeline, saveCompiledContextPack, getCompiledContextPack, getMemoryState } from './firestore.js'; +import { initFirebase, cleanup, getTeamMeta, setTeamMeta, getPlanContent, getPlanSummary, getRecentPlans, getRecentReports, updatePlanNote, updatePlanStatus, getActivePlans, upsertPlanContent, createMemoryEntry, getMemoryTimeline, saveCompiledContextPack, getCompiledContextPack, getMemoryState } from './firestore.js'; import { buildSyncContextContent, assertReviewerContextReady } from './context.js'; import { formatPlanSummary, formatPlanSummaryDetail, formatRelativeTime, parseDuration } from './format.js'; import { buildPulledPlanFile, normalizeTouches, parsePlanFile } from './plan-file.js'; @@ -312,6 +312,7 @@ planCmd .option('--out-of-scope ', 'out of scope override') .option('--touches ', 'comma-separated touched paths override') .option('--status ', 'status override') + .option('--goal ', 'set this plan as the team goal: 3day or 2week') .action(async (file, opts) => { try { const { session } = await requireConfig(); @@ -322,6 +323,10 @@ planCmd throw new Error('Plan summary is required. Add `summary:` to frontmatter or pass `--summary`.'); } + if (opts.goal && opts.goal !== '3day' && opts.goal !== '2week') { + throw new Error('--goal must be "3day" or "2week".'); + } + const planId = opts.id || parsed.metadata.id || null; const touches = normalizeTouches(opts.touches || parsed.metadata.touches || ''); const revision = parsed.metadata.revision == null ? null : Number.parseInt(String(parsed.metadata.revision), 10); @@ -335,6 +340,7 @@ planCmd author: parsed.metadata.author || session.seatName, status: opts.status || parsed.metadata.status || 'in-progress', prUrl: parsed.metadata.prUrl || null, + goalType: opts.goal || parsed.metadata.goalType || null, }; const id = await upsertPlanContent( @@ -350,7 +356,12 @@ planCmd const savedContent = await getPlanContent(session.teamId, id); const localPath = writeLocalPlanFile(savedSummary, savedContent); - console.log(chalk.green(`✓ Plan pushed: ${savedSummary.slug}`)); + if (opts.goal) { + await setTeamMeta(session.teamId, opts.goal, id, savedSummary.summary, session.seatName); + console.log(chalk.green(`✓ Plan pushed and set as ${opts.goal} goal: ${savedSummary.slug}`)); + } else { + console.log(chalk.green(`✓ Plan pushed: ${savedSummary.slug}`)); + } console.log(chalk.cyan(` ID: ${id}`)); console.log(chalk.cyan(` Revision: ${savedContent?.revision || savedSummary.revision || 0}`)); console.log(chalk.cyan(` Cached at: ${localPath}`)); @@ -445,45 +456,6 @@ planCmd } }); -// --- gsync goals --- -const goalsCmd = program - .command('goals') - .description('Manage team goals'); - -goalsCmd - .command('set-2week') - .description('Set the 2-week goal') - .requiredOption('--goal ', 'the 2-week goal') - .action(async (opts) => { - try { - const { session } = await requireConfig(); - verbose('Setting 2-week goal'); - await setTeamMeta(session.teamId, '2week', opts.goal, session.seatName); - console.log(chalk.green('✓ 2-week goal updated')); - } catch (err) { - console.error(chalk.red(`Set 2-week goal failed: ${friendlyError(err)}`)); - if (program.opts().verbose) console.error(err); - process.exit(1); - } - }); - -goalsCmd - .command('set-3day') - .description('Set the 3-day target') - .requiredOption('--goal ', 'the 3-day target') - .action(async (opts) => { - try { - const { session } = await requireConfig(); - verbose('Setting 3-day target'); - await setTeamMeta(session.teamId, '3day', opts.goal, session.seatName); - console.log(chalk.green('✓ 3-day target updated')); - } catch (err) { - console.error(chalk.red(`Set 3-day target failed: ${friendlyError(err)}`)); - if (program.opts().verbose) console.error(err); - process.exit(1); - } - }); - // --- gsync memory --- const memoryCmd = program .command('memory') @@ -555,6 +527,104 @@ memoryCmd } }); +const reportCmd = program + .command('report') + .description('Submit and inspect gsync product feedback'); + +async function submitReport(payload, successLabel) { + const { session, config } = await requireConfig(); + const authToken = await getCurrentFirebaseIdToken(); + const { data, config: activeConfig, fellBackToHosted } = await apiPostWithFallback( + config, + `/teams/${session.teamId}/reports`, + { + ...payload, + source: 'cli', + }, + { authToken }, + ); + + saveConfig(activeConfig); + + console.log(chalk.green(`✓ ${successLabel}`)); + if (fellBackToHosted) { + console.log(chalk.yellow(` Switched onboarding to hosted backend: ${activeConfig.apiBaseUrl}`)); + } + console.log(chalk.cyan(` Report ID: ${data.report.id}`)); + console.log(chalk.cyan(` Kind: ${data.report.kind}`)); + if (data.report.severity) { + console.log(chalk.cyan(` Severity: ${data.report.severity}`)); + } +} + +reportCmd + .command('bug') + .description('Submit a gsync bug report') + .requiredOption('--title ', 'short bug title') + .requiredOption('--body ', 'what broke, why it was confusing, and what should have happened') + .option('--severity ', 'low, medium, or high', 'medium') + .action(async (opts) => { + try { + await submitReport({ + kind: 'bug', + title: opts.title, + body: opts.body, + severity: opts.severity, + }, 'Bug report submitted'); + } catch (err) { + console.error(chalk.red(`Bug report failed: ${friendlyError(err)}`)); + if (program.opts().verbose) console.error(err); + process.exit(1); + } + }); + +reportCmd + .command('feature') + .description('Submit a gsync feature request') + .requiredOption('--title ', 'short feature title') + .requiredOption('--body ', 'what you wanted to do, what was missing, and why it matters') + .action(async (opts) => { + try { + await submitReport({ + kind: 'feature', + title: opts.title, + body: opts.body, + }, 'Feature request submitted'); + } catch (err) { + console.error(chalk.red(`Feature request failed: ${friendlyError(err)}`)); + if (program.opts().verbose) console.error(err); + process.exit(1); + } + }); + +reportCmd + .command('list') + .description('List recent gsync bug reports and feature requests') + .option('--last ', 'number of reports to show', '20') + .action(async (opts) => { + try { + const { session } = await requireConfig(); + const count = Number.parseInt(opts.last ?? '20', 10); + const reports = await getRecentReports(session.teamId, Number.isNaN(count) ? 20 : count); + + if (reports.length === 0) { + console.log(chalk.yellow('No reports yet.')); + return; + } + + console.log(chalk.cyan(`Reports (${reports.length}):`)); + for (const report of reports) { + const when = report.createdAt ? formatRelativeTime(report.createdAt) : 'unknown'; + const severity = report.severity ? ` · ${report.severity}` : ''; + console.log(` - [${report.kind}] ${report.title}${severity} — ${report.createdBySeatName || 'unknown'} · ${when}`); + } + } catch (err) { + console.error(chalk.red(`Report list failed: ${friendlyError(err)}`)); + if (program.opts().verbose) console.error(err); + process.exit(1); + } + }); + // --- gsync status --- program .command('status') diff --git a/src/context.js b/src/context.js index 53e7ab4..480dcab 100644 --- a/src/context.js +++ b/src/context.js @@ -10,12 +10,14 @@ export function generateContext(twoWeek, threeDay, activePlans, recentPlans) { // 2-Week Goal lines.push('## 2-Week Goal'); - lines.push(twoWeek?.content || '(not set)'); + lines.push(twoWeek?.summary || '(not set)'); + if (twoWeek?.planId) lines.push(`Plan: ${twoWeek.planId}`); lines.push(''); // 3-Day Target lines.push('## 3-Day Target'); - lines.push(threeDay?.content || '(not set)'); + lines.push(threeDay?.summary || '(not set)'); + if (threeDay?.planId) lines.push(`Plan: ${threeDay.planId}`); lines.push(''); // Active Plans diff --git a/src/firestore.js b/src/firestore.js index 43771ee..d421d2f 100644 --- a/src/firestore.js +++ b/src/firestore.js @@ -60,10 +60,11 @@ export async function getTeamMeta(teamId, type) { return snap.data(); } -export async function setTeamMeta(teamId, type, content, userName) { +export async function setTeamMeta(teamId, type, planId, summary, userName) { const ref = doc(getDb(), 'teams', teamId, 'meta', type); await setDoc(ref, { - content, + planId, + summary, updatedAt: serverTimestamp(), updatedBy: userName, }); @@ -403,6 +404,7 @@ export async function upsertPlanContent(teamId, planId, summaryData, markdown, u author: summaryData.author, status: summaryData.status || 'in-progress', prUrl: summaryData.prUrl || null, + goalType: summaryData.goalType || null, createdAt: serverTimestamp(), updatedAt: serverTimestamp(), revision: 1, @@ -441,6 +443,7 @@ export async function upsertPlanContent(teamId, planId, summaryData, markdown, u touches: summaryData.touches ?? existing.touches ?? [], status: summaryData.status || existing.status || 'in-progress', prUrl: summaryData.prUrl ?? existing.prUrl ?? null, + goalType: summaryData.goalType ?? existing.goalType ?? null, updatedAt: serverTimestamp(), revision: nextRevision, latestBodyUpdatedAt: serverTimestamp(), @@ -516,6 +519,13 @@ export async function getRecentPlans(teamId, count = 20) { return snap.docs.map((d) => ({ id: d.id, ...d.data() })); } +export async function getRecentReports(teamId, count = 20) { + const col = collection(getDb(), 'teams', teamId, 'reports'); + const q = query(col, orderBy('createdAt', 'desc'), limitDocs(count)); + const snap = await getDocs(q); + return snap.docs.map((d) => ({ id: d.id, ...d.data() })); +} + function toMillis(timestamp) { if (!timestamp) return 0; if (timestamp.toMillis) return timestamp.toMillis(); diff --git a/test/join-flow-api.test.cjs b/test/join-flow-api.test.cjs index 8de8fa1..0786d7f 100644 --- a/test/join-flow-api.test.cjs +++ b/test/join-flow-api.test.cjs @@ -2,6 +2,8 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const { sha256 } = require('../functions/join-codes'); const { + createTeamReport, + requireScopedTeamMember, requireTeamAdmin, requireScopedTeamAdmin, issueJoinCodeForTeam, @@ -39,6 +41,9 @@ function createMemoryDb(seed = {}) { ref: makeDocRef(path), }; }, + async set(data, opts = {}) { + applySet(path, data, Boolean(opts.merge)); + }, }; } @@ -169,6 +174,18 @@ test('requireScopedTeamAdmin rejects refreshing a different team', async () => { ); }); +test('requireScopedTeamMember rejects submitting reports to a different team', async () => { + const db = createMemoryDb({ + 'teams/team-1/memberships/seat-member': { role: 'member', seatName: 'Guest Seat' }, + }); + const req = { get: () => 'Bearer token-member' }; + + await assert.rejects( + () => requireScopedTeamMember(req, 'team-2', { adminClient: createAdminClient(), dbClient: db }), + /Seats can only submit reports for their own team/, + ); +}); + test('issueJoinCodeForTeam always stores member invites', async () => { const db = createMemoryDb(); const result = await issueJoinCodeForTeam({ @@ -213,3 +230,29 @@ test('joinTeamWithCode lands the new seat on the same team', async () => { assert.equal(seatEntries.length, 1); assert.equal(seatEntries[0][1].homeTeamId, 'team-1'); }); + +test('createTeamReport stores bug reports with seat metadata', async () => { + const db = createMemoryDb(); + + const report = await createTeamReport({ + dbClient: db, + teamId: 'team-1', + seatId: 'seat-member', + seatName: 'Guest Seat', + role: 'member', + payload: { + kind: 'bug', + title: 'Login copy is confusing', + body: 'The CLI told me to retry, but it did not say whether my seat key or network was wrong.', + severity: 'high', + source: 'cli', + }, + }); + + const stored = db._store.get(`teams/team-1/reports/${report.id}`); + assert.equal(stored.kind, 'bug'); + assert.equal(stored.title, 'Login copy is confusing'); + assert.equal(stored.severity, 'high'); + assert.equal(stored.createdBySeatName, 'Guest Seat'); + assert.equal(stored.createdByRole, 'member'); +}); From 7127c7976e8abf156d881e85b62e8c2097a7de80 Mon Sep 17 00:00:00 2001 From: Nathan Wang <149958172+nwang783@users.noreply.github.com> Date: Sat, 11 Apr 2026 12:50:57 -0400 Subject: [PATCH 2/2] Fix dashboard goal tag regressions --- dashboard/src/components/GoalBar.test.jsx | 12 ++++++++++-- dashboard/src/lib/planTags.js | 3 +++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/dashboard/src/components/GoalBar.test.jsx b/dashboard/src/components/GoalBar.test.jsx index 6353adf..0622377 100644 --- a/dashboard/src/components/GoalBar.test.jsx +++ b/dashboard/src/components/GoalBar.test.jsx @@ -69,7 +69,10 @@ describe('GoalBar', () => { fireEvent.click(screen.getByRole('button', { name: /2-week goal/i })); expect(onSelectPlan).not.toHaveBeenCalled(); - expect(screen.getByText('Enable self-serve onboarding by April 22')).toBeInTheDocument(); + const dialog = document.querySelector('.modal-content'); + expect(dialog).not.toBeNull(); + expect(dialog).toHaveTextContent('summary'); + expect(dialog).toHaveTextContent('Enable self-serve onboarding by April 22'); }); it('shows "not set" when goal meta doc does not exist', async () => { @@ -106,6 +109,11 @@ describe('GoalBar', () => { // Simulate Firestore re-pushing a new object reference twoWeekHandler.onNext({ exists: () => true, data: goalData }); - await waitFor(() => expect(screen.getByText('2-week goal')).toBeInTheDocument()); + await waitFor(() => { + const dialog = document.querySelector('.modal-content'); + expect(dialog).not.toBeNull(); + expect(dialog).toHaveTextContent('2-week goal'); + expect(dialog).toHaveTextContent('Ship WebSocket layer'); + }); }); }); diff --git a/dashboard/src/lib/planTags.js b/dashboard/src/lib/planTags.js index 129defe..3a9cf01 100644 --- a/dashboard/src/lib/planTags.js +++ b/dashboard/src/lib/planTags.js @@ -1,5 +1,8 @@ export function getPlanGoalTags(plan) { if (plan?.goalType === '2week') return ['2-week goal']; if (plan?.goalType === '3day') return ['3-day target']; + const alignment = typeof plan?.alignment === 'string' ? plan.alignment.toLowerCase() : ''; + if (alignment.includes('2-week goal')) return ['2-week goal']; + if (alignment.includes('3-day target')) return ['3-day target']; return []; }