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
104 changes: 104 additions & 0 deletions src/api/__tests__/live-reports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* @jest-environment node
*/
import { loginAuthLoginPost } from '@/api/generated/endpoints/auth/auth';
import { listCyclesTeamsTeamIdCyclesGet } from '@/api/generated/endpoints/cycles/cycles';
import {
cycleBurndownCyclesCycleIdBurndownGet,
teamCreatedVsResolvedTeamsTeamIdCreatedVsResolvedGet,
teamCumulativeFlowTeamsTeamIdCumulativeFlowGet,
teamVelocityTeamsTeamIdVelocityGet,
} from '@/api/generated/endpoints/reports/reports';
import { listMyTeamsTeamsGet } from '@/api/generated/endpoints/teams/teams';
import type { TeamRead } from '@/api/generated/models';
import { setInstanceUrl } from '@/api/instance';
import { persistToken } from '@/auth/session';
import { makeScale } from '@/reports/chart';

/**
* The four reports against a real instance.
*
* The acceptance criterion for #13 is that the numbers match web exactly, which
* they do by construction -- every figure is read from these endpoints and none
* is recomputed on the client. What is worth checking is that the shapes the
* charts index into are what arrives, and that a chart can be scaled from them
* without dividing by zero on an empty instance.
*/
const LIVE_URL = process.env.SOFTTRACK_LIVE_URL;
const describeLive = LIVE_URL ? describe : describe.skip;

describeLive('reports against a live instance', () => {
let team: TeamRead;

beforeAll(async () => {
await setInstanceUrl(LIVE_URL!);
const token = await loginAuthLoginPost({
username: process.env.SOFTTRACK_LIVE_EMAIL ?? 'demo@softtrack.dev',
password: process.env.SOFTTRACK_LIVE_PASSWORD ?? 'password123',
});
await persistToken(token.access_token);
team = (await listMyTeamsTeamsGet())[0];
});

it('returns a burndown with an ideal line to draw against', async () => {
const cycles = await listCyclesTeamsTeamIdCyclesGet(team.id);
if (cycles.length === 0) return;

const burndown = await cycleBurndownCyclesCycleIdBurndownGet(cycles[0].id);
expect(burndown.cycle_id).toBe(cycles[0].id);

for (const point of burndown.points) {
expect(point.day).toMatch(/^\d{4}-\d{2}-\d{2}/);
expect(typeof point.points_remaining).toBe('number');
// Both lines come from the server; the client draws, it does not model.
expect(typeof point.ideal_remaining).toBe('number');
expect(point.points_remaining).toBeLessThanOrEqual(point.points_total);
}
});

it('returns velocity as committed against delivered', async () => {
const velocity = await teamVelocityTeamsTeamIdVelocityGet(team.id);
expect(Array.isArray(velocity.cycles)).toBe(true);
for (const cycle of velocity.cycles) {
expect(typeof cycle.points_committed).toBe('number');
expect(typeof cycle.points_completed).toBe('number');
expect(cycle.cycle_name).toEqual(expect.any(String));
}
});

it('returns cumulative flow keyed by the categories the chart stacks', async () => {
const flow = await teamCumulativeFlowTeamsTeamIdCumulativeFlowGet(team.id);
const known = ['backlog', 'unstarted', 'started', 'done', 'cancelled'];

for (const day of flow.days) {
expect(day.day).toMatch(/^\d{4}-\d{2}-\d{2}/);
// Unknown keys would silently vanish from a stacked area, so the chart's
// fixed order has to cover everything that can arrive.
for (const key of Object.keys(day.counts)) expect(known).toContain(key);
}
});

it('returns created against resolved with a running open count', async () => {
const report = await teamCreatedVsResolvedTeamsTeamIdCreatedVsResolvedGet(team.id);
expect(typeof report.total_created).toBe('number');
expect(typeof report.total_resolved).toBe('number');

for (const day of report.days) {
expect(typeof day.created).toBe('number');
expect(typeof day.resolved).toBe('number');
expect(typeof day.open_at_end_of_day).toBe('number');
}
});

it('scales whatever the instance actually returns', async () => {
// An instance with no history is the common case for a fresh install, and
// the charts have to draw an axis rather than divide by zero.
const flow = await teamCumulativeFlowTeamsTeamIdCumulativeFlowGet(team.id);
const totals = flow.days.map((day) =>
Object.values(day.counts).reduce((sum, n) => sum + (n ?? 0), 0),
);
const scale = makeScale(300, flow.days.length, Math.max(...totals, 1));
expect(Number.isFinite(scale.y(0))).toBe(true);
expect(Number.isFinite(scale.x(0))).toBe(true);
});
});
24 changes: 24 additions & 0 deletions src/board/board-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { IssueList } from '@/board/issue-list';
import { KanbanBoard } from '@/board/kanban-board';
import { useStatusChange } from '@/board/use-status-change';
import { CycleBanner, CyclesSheet } from '@/cycles/cycles-screen';
import { ReportsSheet } from '@/reports/reports-sheet';
import { useListViewsTeamsTeamIdViewsGet } from '@/api/generated/endpoints/views/views';
import { useAuth } from '@/auth/auth-context';
import { fromViewFilters } from '@/views/saved-views';
Expand Down Expand Up @@ -56,6 +57,7 @@ export function BoardScreen() {
const [filterOpen, setFilterOpen] = useState(false);
const [viewsOpen, setViewsOpen] = useState(false);
const [cyclesOpen, setCyclesOpen] = useState(false);
const [reportsOpen, setReportsOpen] = useState(false);
const [switching, setSwitching] = useState(false);
const [creating, setCreating] = useState(false);
const [moving, setMoving] = useState<IssueRead | null>(null);
Expand Down Expand Up @@ -158,6 +160,22 @@ export function BoardScreen() {

<Segmented value={view} onChange={setView} />

<Pressable
accessibilityRole="button"
accessibilityLabel="Reports"
onPress={() => setReportsOpen(true)}
style={{
width: 36,
height: 36,
borderRadius: t.radius.control,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: t.line.ghost,
}}
>
<Icon name="chart" size={16} color={t.neutral[600]} />
</Pressable>

<Pressable
accessibilityRole="button"
accessibilityLabel="Saved views"
Expand Down Expand Up @@ -217,6 +235,12 @@ export function BoardScreen() {
/>
)}

<ReportsSheet
visible={reportsOpen}
onClose={() => setReportsOpen(false)}
team={team}
/>

<CyclesSheet
visible={cyclesOpen}
onClose={() => setCyclesOpen(false)}
Expand Down
41 changes: 41 additions & 0 deletions src/reports/__tests__/chart.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { linePath, makeScale } from '@/reports/chart';

describe('makeScale', () => {
const scale = makeScale(200, 5, 10);

it('puts the first point on the axis and the last at the right edge', () => {
expect(scale.x(0)).toBeCloseTo(34, 5);
expect(scale.x(4)).toBeCloseTo(34 + scale.innerWidth, 5);
});

it('draws zero on the baseline and the max at the top', () => {
expect(scale.y(0)).toBeCloseTo(12 + scale.innerHeight, 5);
expect(scale.y(10)).toBeCloseTo(12, 5);
});

it('survives a chart whose values are all zero', () => {
// A cycle with nothing in it still has to draw an axis rather than divide
// by zero.
const flat = makeScale(200, 3, 0);
expect(Number.isFinite(flat.y(0))).toBe(true);
expect(flat.y(0)).toBeCloseTo(12 + flat.innerHeight, 5);
});

it('survives a single-point series', () => {
const single = makeScale(200, 1, 5);
expect(Number.isFinite(single.x(0))).toBe(true);
});
});

describe('linePath', () => {
it('moves to the first point and lines to the rest', () => {
const scale = makeScale(200, 3, 10);
const path = linePath([10, 5, 0], scale);
expect(path.startsWith('M')).toBe(true);
expect(path.match(/L/g)).toHaveLength(2);
});

it('is empty for no data, rather than a malformed path', () => {
expect(linePath([], makeScale(200, 0, 1))).toBe('');
});
});
Loading
Loading