diff --git a/src/api/__tests__/live-reports.test.ts b/src/api/__tests__/live-reports.test.ts new file mode 100644 index 0000000..bf2932f --- /dev/null +++ b/src/api/__tests__/live-reports.test.ts @@ -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); + }); +}); diff --git a/src/board/board-screen.tsx b/src/board/board-screen.tsx index fbeeaae..deccf74 100644 --- a/src/board/board-screen.tsx +++ b/src/board/board-screen.tsx @@ -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'; @@ -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(null); @@ -158,6 +160,22 @@ export function BoardScreen() { + setReportsOpen(true)} + style={{ + width: 36, + height: 36, + borderRadius: t.radius.control, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: t.line.ghost, + }} + > + + + )} + setReportsOpen(false)} + team={team} + /> + setCyclesOpen(false)} diff --git a/src/reports/__tests__/chart.test.ts b/src/reports/__tests__/chart.test.ts new file mode 100644 index 0000000..215e2da --- /dev/null +++ b/src/reports/__tests__/chart.test.ts @@ -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(''); + }); +}); diff --git a/src/reports/chart.tsx b/src/reports/chart.tsx new file mode 100644 index 0000000..af871e4 --- /dev/null +++ b/src/reports/chart.tsx @@ -0,0 +1,270 @@ +import { useState, type ReactNode } from 'react'; +import { Pressable, View } from 'react-native'; +import Svg, { Circle, Line, Path, Rect } from 'react-native-svg'; + +import { AppText } from '@/ui/primitives'; +import { useTokens } from '@/ui/theme'; + +/** + * The chart primitives the four reports are drawn with. + * + * Hand-drawn in react-native-svg, which is already here for the icon set, rather + * than pulling in a charting library: four fixed chart shapes need a fraction of + * what one provides, and tapping for a value is easier to get right on a touch + * screen when the hit areas are ours. + */ +export const CHART_HEIGHT = 180; +const PADDING = { top: 12, right: 12, bottom: 22, left: 34 }; + +export type Scale = { + x: (index: number) => number; + y: (value: number) => number; + width: number; + height: number; + innerWidth: number; + innerHeight: number; +}; + +export function makeScale(width: number, count: number, max: number): Scale { + const innerWidth = Math.max(1, width - PADDING.left - PADDING.right); + const innerHeight = CHART_HEIGHT - PADDING.top - PADDING.bottom; + const steps = Math.max(1, count - 1); + const top = max <= 0 ? 1 : max; + + return { + x: (index) => PADDING.left + (index / steps) * innerWidth, + y: (value) => PADDING.top + innerHeight - (value / top) * innerHeight, + width, + height: CHART_HEIGHT, + innerWidth, + innerHeight, + }; +} + +export function linePath(values: number[], scale: Scale): string { + if (values.length === 0) return ''; + return values + .map((value, index) => `${index === 0 ? 'M' : 'L'}${scale.x(index)},${scale.y(value)}`) + .join(' '); +} + +/** + * A chart with a title, an optional note, and a tap-anywhere readout. + * + * `onProbe` receives the index nearest the touch, because a fingertip covers + * several days at this width and asking someone to hit an exact point would + * make the values unreachable. + */ +export function Chart({ + title, + subtitle, + count, + width, + onProbe, + readout, + children, +}: { + title: string; + subtitle?: string; + count: number; + width: number; + onProbe: (index: number | null) => void; + readout?: ReactNode; + children: ReactNode; +}) { + const t = useTokens(); + + return ( + + {title} + {subtitle ? {subtitle} : null} + + onProbe(null)} + onPress={(event) => { + const x = event.nativeEvent.locationX; + const steps = Math.max(1, count - 1); + const innerWidth = Math.max(1, width - PADDING.left - PADDING.right); + const ratio = (x - PADDING.left) / innerWidth; + onProbe(Math.max(0, Math.min(count - 1, Math.round(ratio * steps)))); + }} + style={{ + height: CHART_HEIGHT, + backgroundColor: t.surface.card, + borderRadius: t.radius.card, + }} + > + + {children} + + + + {/* Reserved rather than conditional, so the layout does not jump as a + finger moves across the chart. */} + {readout} + + ); +} + +export function Axes({ scale }: { scale: Scale }) { + const t = useTokens(); + return ( + <> + + + + ); +} + +export function Series({ + values, + scale, + color, + dashed = false, +}: { + values: number[]; + scale: Scale; + color: string; + dashed?: boolean; +}) { + return ( + + ); +} + +export function Marker({ + index, + value, + scale, + color, +}: { + index: number; + value: number; + scale: Scale; + color: string; +}) { + return ; +} + +export function Bars({ + values, + scale, + color, + offset = 0, + slots = 1, +}: { + values: number[]; + scale: Scale; + color: string; + offset?: number; + slots?: number; +}) { + const groupWidth = scale.innerWidth / Math.max(1, values.length); + const barWidth = Math.max(2, (groupWidth * 0.7) / slots); + const base = scale.y(0); + + return ( + <> + {values.map((value, index) => { + const top = scale.y(value); + const left = + PADDING.left + index * groupWidth + groupWidth * 0.15 + offset * barWidth; + return ( + + ); + })} + + ); +} + +/** Stacked areas, for the cumulative flow diagram. */ +export function StackedAreas({ + series, + scale, +}: { + series: { values: number[]; color: string }[]; + scale: Scale; +}) { + const running = series.length > 0 ? series[0].values.map(() => 0) : []; + + return ( + <> + {series.map((layer, layerIndex) => { + const lower = [...running]; + layer.values.forEach((value, index) => { + running[index] += value; + }); + const upper = [...running]; + + const forward = upper + .map((value, index) => `${index === 0 ? 'M' : 'L'}${scale.x(index)},${scale.y(value)}`) + .join(' '); + const back = lower + .map((value, index) => `L${scale.x(lower.length - 1 - index)},${scale.y(lower[lower.length - 1 - index])}`) + .join(' '); + + return ( + + ); + })} + + ); +} + +export function Legend({ items }: { items: { label: string; color: string }[] }) { + return ( + + {items.map((item) => ( + + + {item.label} + + ))} + + ); +} + +/** Hook for the shared "which point is being probed" state. */ +export function useProbe() { + const [index, setIndex] = useState(null); + return { index, setIndex }; +} diff --git a/src/reports/reports-sheet.tsx b/src/reports/reports-sheet.tsx new file mode 100644 index 0000000..a0f8f09 --- /dev/null +++ b/src/reports/reports-sheet.tsx @@ -0,0 +1,345 @@ +import { useState } from 'react'; +import { Pressable, useWindowDimensions, View } from 'react-native'; + +import { useListCyclesTeamsTeamIdCyclesGet } from '@/api/generated/endpoints/cycles/cycles'; +import { + useCycleBurndownCyclesCycleIdBurndownGet, + useTeamCreatedVsResolvedTeamsTeamIdCreatedVsResolvedGet, + useTeamCumulativeFlowTeamsTeamIdCumulativeFlowGet, + useTeamVelocityTeamsTeamIdVelocityGet, +} from '@/api/generated/endpoints/reports/reports'; +import type { StatusCategory, TeamRead } from '@/api/generated/models'; +import { + Axes, + Bars, + Chart, + Legend, + Marker, + makeScale, + Series, + StackedAreas, + useProbe, +} from '@/reports/chart'; +import { AppText, Card, Loading } from '@/ui/primitives'; +import { Sheet } from '@/ui/sheet'; +import { useTokens } from '@/ui/theme'; + +/** + * The four reports, per `docs/design/mobile/150-reports.svg`. + * + * Every number comes from the same event-history endpoints the web charts, so + * the figures match rather than being recomputed here. Portrait-first: one chart + * per row, full width, with a tap-anywhere readout instead of a hover tooltip. + */ +const CATEGORY_ORDER: StatusCategory[] = [ + 'backlog', + 'unstarted', + 'started', + 'done', + 'cancelled', +]; + +/** `2026-01-06` -> `6 Jan`, which is all the axis has room for. */ +function shortDay(day: string): string { + const parsed = new Date(`${day}T00:00:00Z`); + if (Number.isNaN(parsed.getTime())) return day; + return `${parsed.getUTCDate()} ${parsed.toLocaleString('en', { month: 'short', timeZone: 'UTC' })}`; +} + +export function ReportsSheet({ + visible, + onClose, + team, +}: { + visible: boolean; + onClose: () => void; + team: TeamRead; +}) { + const t = useTokens(); + const { width: screenWidth } = useWindowDimensions(); + const width = Math.min(screenWidth, 520) - 72; + + const cycles = useListCyclesTeamsTeamIdCyclesGet(team.id, { query: { enabled: visible } }); + const [cycleId, setCycleId] = useState(null); + + // Whichever cycle is running, else the most recent one -- a burndown of + // nothing is not worth the screen. + const chosenCycle = + cycleId ?? + (cycles.data ?? []).find((cycle) => cycle.state === 'active')?.id ?? + (cycles.data ?? [])[0]?.id ?? + null; + + return ( + + + {/* The same notice the web shows: nothing can be drawn for the period + before the instance started recording history. */} + + Charts are drawn from recorded history. Anything that happened before + this instance began tracking cannot be reconstructed. + + + {cycles.isPending ? : null} + + + + {(cycles.data ?? []).length > 1 ? ( + + {(cycles.data ?? []).map((cycle) => { + const active = chosenCycle === cycle.id; + return ( + setCycleId(cycle.id)} + style={{ + paddingHorizontal: 10, + paddingVertical: 5, + borderRadius: t.radius.pill, + backgroundColor: active ? t.line.navActive : t.line.well, + }} + > + + {cycle.display_name} + + + ); + })} + + ) : null} + + + + + + + ); +} + +function BurndownChart({ cycleId, width }: { cycleId: number | null; width: number }) { + const t = useTokens(); + const probe = useProbe(); + const burndown = useCycleBurndownCyclesCycleIdBurndownGet(cycleId ?? 0, { + query: { enabled: cycleId !== null }, + }); + + const points = burndown.data?.points ?? []; + if (cycleId === null || points.length === 0) { + return ; + } + + const remaining = points.map((point) => point.points_remaining); + const ideal = points.map((point) => point.ideal_remaining); + const max = Math.max(...remaining, ...ideal, 1); + const scale = makeScale(width, points.length, max); + const at = probe.index !== null ? points[probe.index] : null; + + return ( + + {shortDay(at.day)} · {at.points_remaining} remaining of {at.points_total} + {' · ideal '} + {Math.round(at.ideal_remaining)} + + ) : ( + + ) + } + > + + + + {at && probe.index !== null ? ( + + ) : null} + + ); +} + +function VelocityChart({ teamId, width }: { teamId: number; width: number }) { + const t = useTokens(); + const probe = useProbe(); + const velocity = useTeamVelocityTeamsTeamIdVelocityGet(teamId); + + const cycles = velocity.data?.cycles ?? []; + if (cycles.length === 0) { + return ; + } + + const committed = cycles.map((cycle) => cycle.points_committed); + const completed = cycles.map((cycle) => cycle.points_completed); + const scale = makeScale(width, cycles.length, Math.max(...committed, ...completed, 1)); + const at = probe.index !== null ? cycles[probe.index] : null; + + return ( + + {at.cycle_name} · {at.points_completed} delivered of {at.points_committed}{' '} + committed + + ) : ( + + ) + } + > + + + + + ); +} + +function FlowChart({ teamId, width }: { teamId: number; width: number }) { + const t = useTokens(); + const probe = useProbe(); + const flow = useTeamCumulativeFlowTeamsTeamIdCumulativeFlowGet(teamId); + + const days = flow.data?.days ?? []; + if (days.length === 0) { + return ; + } + + const colorFor: Record = { + backlog: t.status.backlog, + unstarted: t.status.todo, + started: t.status.progress, + done: t.status.done, + cancelled: t.status.cancelled, + }; + + const series = CATEGORY_ORDER.map((category) => ({ + color: colorFor[category], + values: days.map((day) => day.counts[category] ?? 0), + })); + + const totals = days.map((day) => + CATEGORY_ORDER.reduce((sum, category) => sum + (day.counts[category] ?? 0), 0), + ); + const scale = makeScale(width, days.length, Math.max(...totals, 1)); + const at = probe.index !== null ? days[probe.index] : null; + + return ( + + {shortDay(at.day)} ·{' '} + {CATEGORY_ORDER.filter((category) => (at.counts[category] ?? 0) > 0) + .map((category) => `${category} ${at.counts[category]}`) + .join(', ')} + + ) : ( + ({ + label: category, + color: colorFor[category], + }))} + /> + ) + } + > + + + + ); +} + +function CreatedResolvedChart({ teamId, width }: { teamId: number; width: number }) { + const t = useTokens(); + const probe = useProbe(); + const report = useTeamCreatedVsResolvedTeamsTeamIdCreatedVsResolvedGet(teamId); + + const days = report.data?.days ?? []; + if (days.length === 0) { + return ; + } + + const created = days.map((day) => day.created); + const resolved = days.map((day) => day.resolved); + const scale = makeScale(width, days.length, Math.max(...created, ...resolved, 1)); + const at = probe.index !== null ? days[probe.index] : null; + + return ( + + {shortDay(at.day)} · {at.created} created, {at.resolved} resolved ·{' '} + {at.open_at_end_of_day} open + + ) : ( + + ) + } + > + + + + + ); +} + +function Empty({ title, reason }: { title: string; reason: string }) { + return ( + + {title} + + {reason} + + + ); +} diff --git a/src/ui/icon.tsx b/src/ui/icon.tsx index 5faf38d..9cca3fc 100644 --- a/src/ui/icon.tsx +++ b/src/ui/icon.tsx @@ -22,7 +22,8 @@ export type IconName = | 'check' | 'logout' | 'trash' - | 'plus'; + | 'plus' + | 'chart'; export function Icon({ name, @@ -113,6 +114,10 @@ export function Icon({ {name === 'check' ? : null} + {name === 'chart' ? ( + + ) : null} + {name === 'plus' ? : null} {name === 'trash' ? (