feat(ri-050): RI-5-001 typed freshness states and stale-safe Mission Control surfaces (#1275) (#1300)
ci/woodpecker/push/publish Pipeline was canceled

This commit was merged in pull request #1300.
This commit is contained in:
2026-08-18 05:56:58 +00:00
parent d92de53399
commit 7c7dab3898
15 changed files with 2525 additions and 188 deletions
+171 -27
View File
@@ -35,6 +35,7 @@ afterEach(async () => {
document.body.replaceChildren();
root = null;
apiMock.mockReset();
sessionStorage.clear();
});
async function renderProjectDetailPage(): Promise<ReturnType<typeof createMemoryRouter>> {
@@ -64,21 +65,49 @@ function clickButtonByText(text: string): void {
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
}
async function flushAct(): Promise<void> {
await act(async () => {
await Promise.resolve();
});
}
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
}
function createDeferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}
const projectOneTasks = taskFixtures.filter((task) => task.projectId === 'project-1');
function mockHealthyLoad(): void {
apiMock
.mockResolvedValueOnce(projectFixtures[0])
.mockResolvedValueOnce(missionFixtures)
.mockResolvedValueOnce(projectOneTasks);
}
describe('ProjectDetailPage', () => {
it('loads the project, tasks, missions, and optional PRD content for the active project', async () => {
apiMock
.mockResolvedValueOnce(projectFixtures[0])
.mockResolvedValueOnce(missionFixtures)
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
mockHealthyLoad();
await renderProjectDetailPage();
expect(apiMock.mock.calls).toEqual([
['/api/projects/project-1'],
['/api/missions'],
['/api/tasks?projectId=project-1'],
expect(apiMock.mock.calls.map((call) => call[0])).toEqual([
'/api/projects/project-1',
'/api/missions',
'/api/tasks?projectId=project-1',
]);
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'current',
);
expect(container.textContent).toContain('Mosaic Stack');
expect(container.textContent).toContain('Route /projects/:id');
expect(container.textContent).toContain('Tasks');
@@ -101,10 +130,7 @@ describe('ProjectDetailPage', () => {
});
it('opens and closes the existing read-only task modal from the tasks tab', async () => {
apiMock
.mockResolvedValueOnce(projectFixtures[0])
.mockResolvedValueOnce(missionFixtures)
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
mockHealthyLoad();
await renderProjectDetailPage();
@@ -134,35 +160,153 @@ describe('ProjectDetailPage', () => {
expect(container.querySelector('[role="dialog"]')).toBeNull();
});
it('renders the project with an empty missions tab when the missions request fails', async () => {
apiMock
.mockResolvedValueOnce(projectFixtures[0])
.mockRejectedValueOnce(new Error('Missions request failed'))
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
it('shows verified completion verdicts when the task collection is current', async () => {
mockHealthyLoad();
await renderProjectDetailPage();
expect(container.textContent).toContain('Mosaic Stack');
expect(container.querySelector('[role="alert"]')).toBeNull();
await act(async () => {
clickButtonByText('Missions (0)');
});
expect(container.textContent).toContain('No missions for this project');
const doneCard = [...container.querySelectorAll('div')].find(
(candidate) => candidate.textContent === 'Done1',
);
expect(doneCard).toBeTruthy();
const inProgressCard = [...container.querySelectorAll('div')].find(
(candidate) => candidate.textContent === 'In Progress1',
);
expect(inProgressCard).toBeTruthy();
});
it('renders a visible alert when the project request fails and lets the user navigate back', async () => {
it('renders an explicit unavailable missions tab when the missions request fails (partial, not empty)', async () => {
apiMock
.mockResolvedValueOnce(projectFixtures[0])
.mockRejectedValueOnce(new Error('Missions request failed'))
.mockResolvedValueOnce(projectOneTasks);
await renderProjectDetailPage();
// Secondary failure degrades the surface to partial; the project itself
// still renders.
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'partial',
);
expect(container.textContent).toContain('Mosaic Stack');
const partial = container.querySelector('[role="status"]');
expect(partial?.textContent).toContain('Missions');
expect(partial?.textContent).toContain('unavailable');
await act(async () => {
clickButtonByText('Missions (?)');
});
const alert = container.querySelector('[role="alert"]');
expect(alert?.textContent).toContain('Missions request failed');
// Negative control: a failed fetch must not look like an empty list.
expect(container.textContent).not.toContain('No missions for this project');
});
it('marks derived verdicts unknown when the tasks collection is unavailable', async () => {
apiMock
.mockResolvedValueOnce(projectFixtures[0])
.mockResolvedValueOnce(missionFixtures)
.mockRejectedValueOnce(new Error('Tasks request failed'));
await renderProjectDetailPage();
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'partial',
);
// Completion verdicts become unknown ('?') — never green counts.
for (const label of ['Done', 'In Progress', 'Blocked', 'Tasks']) {
const unknownCard = [...container.querySelectorAll('div')].find(
(candidate) => candidate.textContent === `${label}?`,
);
expect(unknownCard, `expected ${label} card to render ?`).toBeTruthy();
}
// Negative control: no green "Done 1" verdict anywhere.
expect(
[...container.querySelectorAll('div')].some((candidate) => candidate.textContent === 'Done1'),
).toBe(false);
await act(async () => {
clickButtonByText('Tasks (?)');
});
const alert = container.querySelector('[role="alert"]');
expect(alert?.textContent).toContain('Tasks request failed');
// Negative control: no healthy empty task list from a failed fetch.
expect(container.textContent).not.toContain('No tasks found');
expect(container.querySelector('table')).toBeNull();
});
it('recovers a partial surface to current after revalidation', async () => {
apiMock
.mockResolvedValueOnce(projectFixtures[0])
.mockResolvedValueOnce(missionFixtures)
.mockRejectedValueOnce(new Error('Tasks request failed'))
.mockResolvedValueOnce(projectFixtures[0])
.mockResolvedValueOnce(missionFixtures)
.mockResolvedValueOnce(projectOneTasks);
await renderProjectDetailPage();
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'partial',
);
await act(async () => {
clickButtonByText('Revalidate');
});
await flushAct();
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'current',
);
expect(
[...container.querySelectorAll('div')].some((candidate) => candidate.textContent === 'Done1'),
).toBe(true);
});
it("never shows one project's data on another project's route after navigation", async () => {
mockHealthyLoad();
const router = await renderProjectDetailPage();
expect(container.textContent).toContain('Mosaic Stack');
const deferred = createDeferred<(typeof projectFixtures)[number]>();
apiMock
.mockResolvedValueOnce(deferred.promise)
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
await act(async () => {
await router.navigate('/projects/project-2');
});
// While project-2 loads, nothing from project-1 may render on its route.
expect(container.textContent).toContain('Loading project...');
expect(container.textContent).not.toContain('Mosaic Stack');
expect(container.textContent).not.toContain('Route /projects/:id');
await act(async () => {
deferred.resolve(projectFixtures[1]!);
await deferred.promise;
});
expect(container.textContent).toContain('Agent Runtime');
expect(apiMock.mock.calls[3]?.[0]).toBe('/api/projects/project-2');
});
it('renders a visible unavailable state when the project request fails and lets the user navigate back', async () => {
apiMock
.mockRejectedValueOnce(new Error('Project request failed'))
.mockResolvedValueOnce(missionFixtures)
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
.mockResolvedValueOnce(projectOneTasks);
const router = await renderProjectDetailPage();
const alert = container.querySelector('[role="alert"]');
expect(alert).toBeTruthy();
expect(alert?.textContent).toContain('Project request failed');
expect(alert?.textContent).toContain('not an empty result');
expect(container.textContent).not.toContain('Mosaic Stack');
await act(async () => {
+203 -90
View File
@@ -1,14 +1,30 @@
import { useEffect, useState, type ReactElement } from 'react';
import { useState, type ReactElement } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { MissionTimeline } from '@/components/projects/mission-timeline';
import { PrdViewer } from '@/components/projects/prd-viewer';
import { TaskDetailModal } from '@/components/tasks/task-detail-modal';
import { TaskListView } from '@/components/tasks/task-list-view';
import { TaskStatusSummary } from '@/components/tasks/task-status-summary';
import {
PartialDataNotice,
StaleDataNotice,
UnavailableDataNotice,
} from '@/components/freshness/freshness-notices';
import { api } from '@/lib/api';
import { cn } from '@/lib/cn';
import type { Mission, Project, Task, TaskStatus } from '@/lib/types';
import { getErrorMessage } from './page-errors';
import {
combineFreshness,
UNKNOWN_VERDICT,
verdictValue,
type FreshSnapshot,
} from '@/lib/freshness/model';
import { describeFailure, useFreshCollection } from '@/lib/freshness/use-fresh-collection';
import {
validateMissionCollection,
validateProjectEntity,
validateTaskCollection,
} from '@/lib/freshness/validators';
type Tab = 'overview' | 'tasks' | 'missions' | 'prd';
@@ -51,73 +67,62 @@ function TabButton({ id, label, activeTab, onClick }: TabButtonProps): ReactElem
);
}
/** Remounts per project id so no state from one project renders for another. */
export function ProjectDetailPage(): ReactElement {
const { id = '' } = useParams();
return <ProjectDetail id={id} key={id} />;
}
function ProjectDetail({ id }: { id: string }): ReactElement {
const navigate = useNavigate();
const [project, setProject] = useState<Project | null>(null);
const [missions, setMissions] = useState<Mission[]>([]);
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const enabled = id.length > 0;
// Primary collection gates the surface; missions and tasks are secondaries
// whose failures degrade the surface to `partial` instead of rendering
// empty healthy lists.
const project = useFreshCollection<Project>({
source: `gateway:/api/projects/${id}`,
fetcher: (signal) => api<unknown>(`/api/projects/${id}`, { signal }),
validate: validateProjectEntity,
// No last-known restore: the entity carries workspace identity that
// cannot be scope-checked before display (see ProjectsPage note).
enabled,
});
const missions = useFreshCollection<Mission[]>({
source: 'gateway:/api/missions',
fetcher: (signal) => api<unknown>('/api/missions', { signal }),
validate: validateMissionCollection,
cacheKey: enabled ? 'missions' : null,
enabled,
});
const tasks = useFreshCollection<Task[]>({
source: `gateway:/api/tasks?projectId=${id}`,
fetcher: (signal) => api<unknown>(`/api/tasks?projectId=${id}`, { signal }),
validate: validateTaskCollection,
cacheKey: enabled ? `project-tasks:${id}` : null,
enabled,
});
const [activeTab, setActiveTab] = useState<Tab>('overview');
const [taskFilter, setTaskFilter] = useState<TaskStatus | 'all'>('all');
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
useEffect(() => {
if (!id) {
setError('Project id is missing.');
setLoading(false);
return;
}
const surface = combineFreshness(project.freshness, [missions.freshness, tasks.freshness]);
const tasksVerified = tasks.freshness === 'current';
const projectMissions = missions.data?.filter((mission) => mission.projectId === id) ?? null;
let cancelled = false;
setLoading(true);
setError(null);
const retryAll = (): void => {
void Promise.all([project.revalidate(), missions.revalidate(), tasks.revalidate()]);
};
void Promise.all([
api<Project>('/api/projects/' + id),
api<Mission[]>('/api/missions').catch(() => [] as Mission[]),
api<Task[]>('/api/tasks?projectId=' + id).catch(() => [] as Task[]),
])
.then(([loadedProject, allMissions, loadedTasks]) => {
if (cancelled) return;
setProject(loadedProject);
setMissions(allMissions.filter((mission) => mission.projectId === id));
setTasks(loadedTasks);
})
.catch((caught: unknown) => {
if (cancelled) return;
setError(getErrorMessage(caught, 'Failed to load project.'));
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, [id]);
if (loading) {
return (
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
<header className="mb-6 border-b px-1 pb-3">
<h1 className="text-2xl font-semibold">Project</h1>
</header>
<p className="py-16 text-center text-sm text-text-muted">Loading project...</p>
</div>
);
}
if (error || !project) {
if (!enabled) {
return (
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
<header className="mb-6 border-b px-1 pb-3">
<h1 className="text-2xl font-semibold">Project</h1>
</header>
<div role="alert" className="rounded-lg border border-error/40 px-4 py-3 text-sm">
{error ?? 'Project not found.'}
Project id is missing.
</div>
<button
type="button"
@@ -130,18 +135,81 @@ export function ProjectDetailPage(): ReactElement {
);
}
if (project.freshness === 'unknown') {
return (
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
<header className="mb-6 border-b px-1 pb-3">
<h1 className="text-2xl font-semibold">Project</h1>
</header>
<p className="py-16 text-center text-sm text-text-muted">Loading project...</p>
</div>
);
}
if (project.freshness === 'unavailable' || project.data === null) {
return (
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
<header className="mb-6 border-b px-1 pb-3">
<h1 className="text-2xl font-semibold">Project</h1>
</header>
<UnavailableDataNotice
title="This project"
detail={describeFailure(project.failure)}
onRetry={retryAll}
/>
<button
type="button"
onClick={() => navigate('/projects')}
className="mt-4 w-fit text-sm underline"
>
Back to projects
</button>
</div>
);
}
const projectTasks = tasks.data ?? null;
const filteredTasks =
taskFilter === 'all' ? tasks : tasks.filter((task) => task.status === taskFilter);
const prdContent = getPrdContent(project);
projectTasks === null
? []
: taskFilter === 'all'
? projectTasks
: projectTasks.filter((task) => task.status === taskFilter);
// Derived completion verdicts: unknown (never green) unless the task
// collection is verified current.
const doneCount = projectTasks?.filter((task) => task.status === 'done').length ?? 0;
const inProgressCount = projectTasks?.filter((task) => task.status === 'in-progress').length ?? 0;
const blockedCount = projectTasks?.filter((task) => task.status === 'blocked').length ?? 0;
const prdContent = getPrdContent(project.data);
const tabs: Array<{ id: Tab; label: string }> = [
{ id: 'overview', label: 'Overview' },
{ id: 'tasks', label: `Tasks (${tasks.length})` },
{ id: 'missions', label: `Missions (${missions.length})` },
{
id: 'tasks',
label: `Tasks (${projectTasks === null ? UNKNOWN_VERDICT : projectTasks.length})`,
},
{
id: 'missions',
label: `Missions (${projectMissions === null ? UNKNOWN_VERDICT : projectMissions.length})`,
},
...(prdContent ? [{ id: 'prd' as const, label: 'PRD' }] : []),
];
const staleSnapshot: FreshSnapshot<unknown> | null =
project.freshness === 'stale'
? project.snapshot
: missions.freshness === 'stale'
? missions.snapshot
: tasks.freshness === 'stale'
? tasks.snapshot
: null;
const missingSections: string[] = [];
if (missions.freshness === 'unavailable') missingSections.push('Missions');
if (tasks.freshness === 'unavailable') missingSections.push('Tasks');
return (
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
<div data-freshness={surface} className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
<header className="mb-6 border-b px-1 pb-3">
<nav className="mb-4 flex items-center gap-2 text-sm text-text-muted">
<button
@@ -152,49 +220,64 @@ export function ProjectDetailPage(): ReactElement {
Projects
</button>
<span>/</span>
<span className="text-text-primary">{project.name}</span>
<span className="text-text-primary">{project.data.name}</span>
</nav>
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold text-text-primary">{project.name}</h1>
<h1 className="text-2xl font-semibold text-text-primary">{project.data.name}</h1>
<span
className={cn(
'rounded-full px-2 py-0.5 text-xs',
projectStatusColors[project.status] ?? 'bg-gray-600/20 text-gray-400',
projectStatusColors[project.data.status] ?? 'bg-gray-600/20 text-gray-400',
)}
>
{project.status}
{project.data.status}
</span>
</div>
{project.description ? (
<p className="mt-1 text-sm text-text-muted">{project.description}</p>
{project.data.description ? (
<p className="mt-1 text-sm text-text-muted">{project.data.description}</p>
) : null}
<p className="mt-2 text-xs text-text-muted">
Created {new Date(project.createdAt).toLocaleDateString()} · Updated{' '}
{new Date(project.updatedAt).toLocaleDateString()}
Created {new Date(project.data.createdAt).toLocaleDateString()} · Updated{' '}
{new Date(project.data.updatedAt).toLocaleDateString()}
</p>
</div>
</div>
</header>
{staleSnapshot !== null ? (
<div className="mb-6">
<StaleDataNotice label={staleSnapshot} onRetry={retryAll} />
</div>
) : null}
{missingSections.length > 0 ? (
<div className="mb-6">
<PartialDataNotice missing={missingSections} onRetry={retryAll} />
</div>
) : null}
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard label="Tasks" value={String(tasks.length)} />
<StatCard
label="Tasks"
value={projectTasks === null ? UNKNOWN_VERDICT : String(projectTasks.length)}
/>
<StatCard
label="Done"
value={String(tasks.filter((task) => task.status === 'done').length)}
valueClass="text-success"
value={verdictValue(tasksVerified, String(doneCount))}
valueClass={tasksVerified ? 'text-success' : undefined}
/>
<StatCard
label="In Progress"
value={String(tasks.filter((task) => task.status === 'in-progress').length)}
valueClass="text-blue-400"
value={verdictValue(tasksVerified, String(inProgressCount))}
valueClass={tasksVerified ? 'text-blue-400' : undefined}
/>
<StatCard
label="Blocked"
value={String(tasks.filter((task) => task.status === 'blocked').length)}
valueClass={tasks.some((task) => task.status === 'blocked') ? 'text-error' : undefined}
value={verdictValue(tasksVerified, String(blockedCount))}
valueClass={tasksVerified && blockedCount > 0 ? 'text-error' : undefined}
/>
</div>
@@ -211,23 +294,43 @@ export function ProjectDetailPage(): ReactElement {
</div>
{activeTab === 'overview' ? (
<OverviewTab project={project} missions={missions} tasks={tasks} />
<OverviewTab project={project.data} missions={projectMissions} tasks={projectTasks} />
) : null}
{activeTab === 'tasks' ? (
<div>
<div className="mb-4">
<TaskStatusSummary
tasks={tasks}
activeFilter={taskFilter}
onFilterChange={setTaskFilter}
{projectTasks === null ? (
<UnavailableDataNotice
title="Tasks"
detail={describeFailure(tasks.failure)}
onRetry={retryAll}
/>
</div>
<TaskListView tasks={filteredTasks} onTaskClick={setSelectedTask} />
) : (
<>
<div className="mb-4">
<TaskStatusSummary
tasks={projectTasks}
activeFilter={taskFilter}
onFilterChange={setTaskFilter}
/>
</div>
<TaskListView tasks={filteredTasks} onTaskClick={setSelectedTask} />
</>
)}
</div>
) : null}
{activeTab === 'missions' ? <MissionTimeline missions={missions} /> : null}
{activeTab === 'missions' ? (
projectMissions === null ? (
<UnavailableDataNotice
title="Missions"
detail={describeFailure(missions.failure)}
onRetry={retryAll}
/>
) : (
<MissionTimeline missions={projectMissions} />
)
) : null}
{activeTab === 'prd' && prdContent ? (
<div className="rounded-lg border border-surface-border bg-surface-card p-6">
@@ -248,18 +351,26 @@ function OverviewTab({
tasks,
}: {
project: Project;
missions: Mission[];
tasks: Task[];
missions: Mission[] | null;
tasks: Task[] | null;
}): ReactElement {
const recentTasks = [...tasks]
.sort((left, right) => new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime())
.slice(0, 5);
const recentTasks =
tasks === null
? null
: [...tasks]
.sort(
(left, right) =>
new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime(),
)
.slice(0, 5);
return (
<div className="grid gap-6 lg:grid-cols-2">
<section>
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Recent Tasks</h2>
{recentTasks.length === 0 ? (
{recentTasks === null ? (
<UnavailableDataNotice title="Tasks" />
) : recentTasks.length === 0 ? (
<div className="rounded-lg border border-surface-border bg-surface-card p-4 text-center">
<p className="text-sm text-text-muted">No tasks yet</p>
</div>
@@ -287,7 +398,9 @@ function OverviewTab({
<section>
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Missions</h2>
{missions.length === 0 ? (
{missions === null ? (
<UnavailableDataNotice title="Missions" />
) : missions.length === 0 ? (
<div className="rounded-lg border border-surface-border bg-surface-card p-4 text-center">
<p className="text-sm text-text-muted">No missions yet</p>
</div>
+69 -3
View File
@@ -51,6 +51,7 @@ afterEach(async () => {
document.body.replaceChildren();
root = null;
apiMock.mockReset();
sessionStorage.clear();
});
async function renderProjectsPage(): Promise<ReturnType<typeof createMemoryRouter>> {
@@ -71,6 +72,22 @@ async function renderProjectsPage(): Promise<ReturnType<typeof createMemoryRoute
return router;
}
function clickButtonByText(text: string): void {
const button = [...container.querySelectorAll('button')].find((candidate) =>
candidate.textContent?.includes(text),
);
if (!button) {
throw new Error(`Button containing "${text}" not found`);
}
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
}
async function flushAct(): Promise<void> {
await act(async () => {
await Promise.resolve();
});
}
describe('ProjectsPage', () => {
it('shows a visible loading state while the project request is in flight', async () => {
const deferred = createDeferred<typeof projectFixtures>();
@@ -91,7 +108,7 @@ describe('ProjectsPage', () => {
const router = await renderProjectsPage();
expect(apiMock).toHaveBeenCalledWith('/api/projects');
expect(apiMock.mock.calls[0]?.[0]).toBe('/api/projects');
expect(container.textContent).toContain('Mosaic Stack');
expect(container.textContent).toContain('Agent Runtime');
@@ -108,7 +125,7 @@ describe('ProjectsPage', () => {
expect(container.textContent).toContain('Project detail target');
});
it('renders the empty state when the API returns no projects', async () => {
it('renders the empty state only for a verified empty collection', async () => {
apiMock.mockResolvedValueOnce([]);
await renderProjectsPage();
@@ -117,9 +134,12 @@ describe('ProjectsPage', () => {
expect(container.textContent).toContain(
'Projects will appear here when created via the gateway API',
);
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'current',
);
});
it('renders a visible alert when the projects request fails', async () => {
it('renders a failed fetch as an explicit unavailable state, never an empty collection', async () => {
apiMock.mockRejectedValueOnce(new Error('Projects are unavailable'));
await renderProjectsPage();
@@ -127,5 +147,51 @@ describe('ProjectsPage', () => {
const alert = container.querySelector('[role="alert"]');
expect(alert).toBeTruthy();
expect(alert?.textContent).toContain('Projects are unavailable');
expect(alert?.textContent).toContain('not an empty result');
// Negative controls: no healthy empty state and no project cards render
// from a failed fetch.
expect(container.textContent).not.toContain('No projects yet');
expect(container.textContent).not.toContain('Mosaic Stack');
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'unavailable',
);
});
it('renders an auth failure as unavailable and recovers after retry', async () => {
apiMock
.mockRejectedValueOnce(Object.assign(new Error('Unauthorized'), { statusCode: 401 }))
.mockResolvedValueOnce(projectFixtures);
await renderProjectsPage();
const alert = container.querySelector('[role="alert"]');
expect(alert?.textContent).toContain('Unauthorized');
expect(container.textContent).not.toContain('No projects yet');
await act(async () => {
clickButtonByText('Retry');
});
await flushAct();
expect(container.querySelector('[role="alert"]')).toBeNull();
expect(container.textContent).toContain('Mosaic Stack');
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'current',
);
});
it('renders a schema-mismatched response as unavailable, never as data', async () => {
apiMock.mockResolvedValueOnce({ results: projectFixtures });
await renderProjectsPage();
const alert = container.querySelector('[role="alert"]');
expect(alert?.textContent).toContain('not an empty result');
expect(container.textContent).not.toContain('Mosaic Stack');
expect(container.textContent).not.toContain('No projects yet');
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'unavailable',
);
});
});
+32 -34
View File
@@ -1,53 +1,51 @@
import { useEffect, useState, type ReactElement } from 'react';
import { type ReactElement } from 'react';
import { useNavigate } from 'react-router-dom';
import { ProjectCard } from '@/components/projects/project-card';
import { StaleDataNotice, UnavailableDataNotice } from '@/components/freshness/freshness-notices';
import { api } from '@/lib/api';
import type { Project } from '@/lib/types';
import { getErrorMessage } from './page-errors';
import { useFreshCollection, describeFailure } from '@/lib/freshness/use-fresh-collection';
import { validateProjectCollection } from '@/lib/freshness/validators';
export function ProjectsPage(): ReactElement {
const navigate = useNavigate();
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
void api<Project[]>('/api/projects')
.then((response) => {
if (cancelled) return;
setProjects(response);
})
.catch((caught: unknown) => {
if (cancelled) return;
setError(getErrorMessage(caught, 'Failed to load projects.'));
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
const projects = useFreshCollection<Project[]>({
source: 'gateway:/api/projects',
fetcher: (signal) => api<unknown>('/api/projects', { signal }),
validate: validateProjectCollection,
// Projects carry workspace identity (userId) that is only knowable from
// the payload itself, so a restored entry cannot be scope-checked before
// display. Conservative choice: no last-known restore for this surface;
// cross-workspace switching is still invalidated at verification time.
});
const retry = (): void => {
void projects.revalidate();
};
return (
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
<div
data-freshness={projects.freshness}
className="flex min-h-screen flex-col px-4 py-6 sm:px-6"
>
<header className="mb-6 border-b px-1 pb-3">
<h1 className="text-2xl font-semibold">Projects</h1>
</header>
{error ? (
<div role="alert" className="mb-6 rounded-lg border border-error/40 px-4 py-3 text-sm">
{error}
{projects.freshness === 'stale' && projects.snapshot ? (
<div className="mb-6">
<StaleDataNotice label={projects.snapshot} onRetry={retry} />
</div>
) : null}
{loading ? (
{projects.freshness === 'unknown' ? (
<p className="py-8 text-center text-sm text-text-muted">Loading projects...</p>
) : projects.length === 0 ? (
) : projects.freshness === 'unavailable' ? (
<UnavailableDataNotice
title="Projects"
detail={describeFailure(projects.failure)}
onRetry={retry}
/>
) : projects.data !== null && projects.data.length === 0 ? (
<div className="py-12 text-center">
<h2 className="text-lg font-medium text-text-secondary">No projects yet</h2>
<p className="mt-1 text-sm text-text-muted">
@@ -56,7 +54,7 @@ export function ProjectsPage(): ReactElement {
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => (
{(projects.data ?? []).map((project) => (
<ProjectCard
key={project.id}
project={project}
+87 -1
View File
@@ -3,6 +3,9 @@ import { createRoot, type Root } from 'react-dom/client';
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { taskFixtures } from './page-fixtures';
import { acceptSnapshot, DEFAULT_FRESHNESS_POLICY } from '@/lib/freshness/model';
import { writeSnapshotCache } from '@/lib/freshness/snapshot-cache';
import { validateTaskCollection } from '@/lib/freshness/validators';
const { apiMock } = vi.hoisted(() => ({
apiMock: vi.fn(),
@@ -48,6 +51,7 @@ afterEach(async () => {
document.body.replaceChildren();
root = null;
apiMock.mockReset();
sessionStorage.clear();
});
async function renderTasksPage(): Promise<void> {
@@ -72,6 +76,13 @@ function clickButtonByText(text: string): void {
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
}
/** Flush pending promise callbacks inside the act environment. */
async function flushAct(): Promise<void> {
await act(async () => {
await Promise.resolve();
});
}
describe('TasksPage', () => {
it('shows a visible loading state before the tasks request settles', async () => {
const deferred = createDeferred<typeof taskFixtures>();
@@ -132,7 +143,7 @@ describe('TasksPage', () => {
expect(container.textContent).toContain('Wire list and kanban modal interactions');
});
it('renders a visible alert when the tasks request fails', async () => {
it('renders a failed fetch as an explicit unavailable state, never an empty healthy board', async () => {
apiMock.mockRejectedValueOnce(new Error('Tasks request failed'));
await renderTasksPage();
@@ -140,5 +151,80 @@ describe('TasksPage', () => {
const alert = container.querySelector('[role="alert"]');
expect(alert).toBeTruthy();
expect(alert?.textContent).toContain('Tasks request failed');
expect(alert?.textContent).toContain('not an empty result');
// Negative controls: no board, no healthy empty-state markers, and the
// surface is marked unavailable rather than current.
expect(container.textContent).not.toContain('Not Started');
expect(container.textContent).not.toContain('No tasks');
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'unavailable',
);
});
it('recovers to a current board after retrying a failed fetch', async () => {
apiMock
.mockRejectedValueOnce(new Error('Tasks request failed'))
.mockResolvedValueOnce(taskFixtures);
await renderTasksPage();
expect(container.querySelector('[role="alert"]')).toBeTruthy();
await act(async () => {
clickButtonByText('Retry');
});
await flushAct();
expect(container.querySelector('[role="alert"]')).toBeNull();
expect(container.textContent).toContain('Not Started');
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'current',
);
});
it('labels restored last-known data as stale with source, version, and age until verified', async () => {
// Seed a last-known snapshot fetched five minutes ago; the page must
// render it only under an explicit staleness label while the fetch is
// still in flight.
const restored = acceptSnapshot({
value: taskFixtures,
validate: validateTaskCollection,
previous: null,
policy: DEFAULT_FRESHNESS_POLICY,
source: 'gateway:/api/tasks',
now: Date.now() - 5 * 60_000,
});
if (restored.outcome !== 'accepted') throw new Error('fixture setup failed');
writeSnapshotCache('tasks', restored.snapshot);
const deferred = createDeferred<typeof taskFixtures>();
apiMock.mockReturnValueOnce(deferred.promise);
await renderTasksPage();
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'stale',
);
const banner = container.querySelector('[role="status"]');
expect(banner?.textContent).toContain('last-known');
expect(banner?.textContent).toContain('may be out of date');
expect(banner?.textContent).toContain('gateway:/api/tasks');
expect(banner?.textContent).toContain('snapshot v1');
expect(banner?.textContent).toContain('5m ago');
// Last-known data still renders as situational awareness under the label.
expect(container.textContent).toContain('Route /tasks');
expect(container.textContent).not.toContain('Loading tasks...');
// Verification lands: the banner clears and the surface becomes current.
await act(async () => {
deferred.resolve(taskFixtures);
await deferred.promise;
});
expect(container.querySelector('[role="status"]')).toBeNull();
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'current',
);
});
});
+26 -33
View File
@@ -1,45 +1,32 @@
import { useEffect, useState, type ReactElement } from 'react';
import { useState, type ReactElement } from 'react';
import { KanbanBoard } from '@/components/tasks/kanban-board';
import { TaskDetailModal } from '@/components/tasks/task-detail-modal';
import { TaskListView } from '@/components/tasks/task-list-view';
import { StaleDataNotice, UnavailableDataNotice } from '@/components/freshness/freshness-notices';
import { api } from '@/lib/api';
import { cn } from '@/lib/cn';
import type { Task } from '@/lib/types';
import { getErrorMessage } from './page-errors';
import { useFreshCollection, describeFailure } from '@/lib/freshness/use-fresh-collection';
import { validateTaskCollection } from '@/lib/freshness/validators';
type ViewMode = 'list' | 'kanban';
export function TasksPage(): ReactElement {
const [tasks, setTasks] = useState<Task[]>([]);
const tasks = useFreshCollection<Task[]>({
source: 'gateway:/api/tasks',
fetcher: (signal) => api<unknown>('/api/tasks', { signal }),
validate: validateTaskCollection,
cacheKey: 'tasks',
});
const [view, setView] = useState<ViewMode>('kanban');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
useEffect(() => {
let cancelled = false;
void api<Task[]>('/api/tasks')
.then((response) => {
if (cancelled) return;
setTasks(response);
})
.catch((caught: unknown) => {
if (cancelled) return;
setError(getErrorMessage(caught, 'Failed to load tasks.'));
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
const retry = (): void => {
void tasks.revalidate();
};
return (
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
<div data-freshness={tasks.freshness} className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
<header className="mb-6 flex items-center justify-between gap-4 border-b px-1 pb-3">
<h1 className="text-2xl font-semibold">Tasks</h1>
<div className="flex rounded-lg border border-surface-border">
@@ -70,18 +57,24 @@ export function TasksPage(): ReactElement {
</div>
</header>
{error ? (
<div role="alert" className="mb-6 rounded-lg border border-error/40 px-4 py-3 text-sm">
{error}
{tasks.freshness === 'stale' && tasks.snapshot ? (
<div className="mb-6">
<StaleDataNotice label={tasks.snapshot} onRetry={retry} />
</div>
) : null}
{loading ? (
{tasks.freshness === 'unknown' ? (
<p className="py-8 text-center text-sm text-text-muted">Loading tasks...</p>
) : tasks.freshness === 'unavailable' ? (
<UnavailableDataNotice
title="Tasks"
detail={describeFailure(tasks.failure)}
onRetry={retry}
/>
) : view === 'kanban' ? (
<KanbanBoard tasks={tasks} onTaskClick={setSelectedTask} />
<KanbanBoard tasks={tasks.data ?? []} onTaskClick={setSelectedTask} />
) : (
<TaskListView tasks={tasks} onTaskClick={setSelectedTask} />
<TaskListView tasks={tasks.data ?? []} onTaskClick={setSelectedTask} />
)}
{selectedTask ? (