diff --git a/src/api/__tests__/live-cycles.test.ts b/src/api/__tests__/live-cycles.test.ts new file mode 100644 index 0000000..bb34535 --- /dev/null +++ b/src/api/__tests__/live-cycles.test.ts @@ -0,0 +1,173 @@ +/** + * @jest-environment node + */ +import { loginAuthLoginPost } from '@/api/generated/endpoints/auth/auth'; +import { + completeCycleCyclesCycleIdCompletePost, + createCycleTeamsTeamIdCyclesPost, + deleteCycleCyclesCycleIdDelete, + getCycleCyclesCycleIdGet, + listCyclesTeamsTeamIdCyclesGet, + startCycleCyclesCycleIdStartPost, +} from '@/api/generated/endpoints/cycles/cycles'; +import { + createIssueTeamsTeamIdIssuesPost, + deleteIssueIssuesIssueIdDelete, + getIssueIssuesIssueIdGet, + listIssuesTeamsTeamIdIssuesGet, +} from '@/api/generated/endpoints/issues/issues'; +import { listStatusesTeamsTeamIdStatusesGet } from '@/api/generated/endpoints/statuses/statuses'; +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'; + +/** + * Cycles against a real instance. + * + * The acceptance criterion for #12 is that a cycle can be run start to finish + * from mobile, so this does exactly that: create, start, complete, and check + * where the unfinished work went. + */ +const LIVE_URL = process.env.SOFTTRACK_LIVE_URL; +const describeLive = LIVE_URL ? describe : describe.skip; + +const RUN = Date.now().toString(36).slice(-5); + +/** Fixed dates: a test that depends on today is a test that breaks on a Monday. */ +const FROM = '2031-01-06'; +const TO = '2031-01-20'; +const NEXT_FROM = '2031-01-21'; +const NEXT_TO = '2031-02-03'; + +describeLive('cycles against a live instance', () => { + let team: TeamRead; + const cycles: number[] = []; + const issues: number[] = []; + + 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]; + }); + + afterAll(async () => { + for (const id of issues) await deleteIssueIssuesIssueIdDelete(id).catch(() => undefined); + for (const id of cycles) await deleteCycleCyclesCycleIdDelete(id).catch(() => undefined); + }); + + it('numbers a cycle when it is given no name', async () => { + const cycle = await createCycleTeamsTeamIdCyclesPost(team.id, { + starts_at: FROM, + ends_at: TO, + }); + cycles.push(cycle.id); + + expect(cycle.name ?? null).toBeNull(); + // display_name is always present, which is why every label reads that. + expect(cycle.display_name).toContain(String(cycle.number)); + expect(cycle.state).toBe('upcoming'); + }); + + it('reports progress including work that was never sized', async () => { + const cycle = await createCycleTeamsTeamIdCyclesPost(team.id, { + name: `Progress ${RUN}`, + starts_at: FROM, + ends_at: TO, + }); + cycles.push(cycle.id); + + const sized = await createIssueTeamsTeamIdIssuesPost(team.id, { + title: `Sized ${RUN}`, + cycle_id: cycle.id, + estimate: 3, + }); + const unsized = await createIssueTeamsTeamIdIssuesPost(team.id, { + title: `Unsized ${RUN}`, + cycle_id: cycle.id, + }); + issues.push(sized.id, unsized.id); + + const refreshed = await getCycleCyclesCycleIdGet(cycle.id); + expect(refreshed.progress.issues_total).toBe(2); + expect(refreshed.progress.points_total).toBe(3); + // The count that stops a points total reading as the whole story. + expect(refreshed.progress.issues_unestimated).toBe(1); + }); + + it('runs a cycle start to finish and says where unfinished work went', async () => { + const statuses = await listStatusesTeamsTeamIdStatusesGet(team.id); + const done = statuses.find((s) => s.category === 'done'); + + const current = await createCycleTeamsTeamIdCyclesPost(team.id, { + name: `Run ${RUN}`, + starts_at: FROM, + ends_at: TO, + }); + const next = await createCycleTeamsTeamIdCyclesPost(team.id, { + name: `Next ${RUN}`, + starts_at: NEXT_FROM, + ends_at: NEXT_TO, + }); + cycles.push(current.id, next.id); + + const finished = await createIssueTeamsTeamIdIssuesPost(team.id, { + title: `Finished ${RUN}`, + cycle_id: current.id, + status_id: done?.id, + }); + const unfinished = await createIssueTeamsTeamIdIssuesPost(team.id, { + title: `Unfinished ${RUN}`, + cycle_id: current.id, + }); + issues.push(finished.id, unfinished.id); + + const started = await startCycleCyclesCycleIdStartPost(current.id); + expect(started.state).toBe('active'); + + const completion = await completeCycleCyclesCycleIdCompletePost(current.id); + expect(completion.cycle.state).toBe('completed'); + // One issue was not done, and the app reports this count because + // "completed" otherwise reads as though it had been finished. + expect(completion.carried_over).toBe(1); + + const moved = await getIssueIssuesIssueIdGet(unfinished.id); + if (completion.carried_into_cycle_id) { + expect(moved.cycle_id).toBe(completion.carried_into_cycle_id); + } else { + expect(moved.cycle_id ?? null).toBeNull(); + } + + // The finished one stays where it was. + expect((await getIssueIssuesIssueIdGet(finished.id)).cycle_id).toBe(current.id); + }); + + it('filters the board by cycle, which is the link between the two', async () => { + const cycle = await createCycleTeamsTeamIdCyclesPost(team.id, { + name: `Filter ${RUN}`, + starts_at: FROM, + ends_at: TO, + }); + cycles.push(cycle.id); + const issue = await createIssueTeamsTeamIdIssuesPost(team.id, { + title: `In cycle ${RUN}`, + cycle_id: cycle.id, + }); + issues.push(issue.id); + + const page = await listIssuesTeamsTeamIdIssuesGet(team.id, { + cycle_id: cycle.id, + limit: 200, + }); + expect(page.items.map((i) => i.id)).toEqual([issue.id]); + }); + + it('lists cycles for the team', async () => { + const listed = await listCyclesTeamsTeamIdCyclesGet(team.id); + for (const id of cycles) expect(listed.map((c) => c.id)).toContain(id); + }); +}); diff --git a/src/board/board-screen.tsx b/src/board/board-screen.tsx index e3c1db4..fbeeaae 100644 --- a/src/board/board-screen.tsx +++ b/src/board/board-screen.tsx @@ -11,6 +11,7 @@ import { fromParams, isEmpty, toParams, toQueryParams, type BoardFilters } from 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 { useListViewsTeamsTeamIdViewsGet } from '@/api/generated/endpoints/views/views'; import { useAuth } from '@/auth/auth-context'; import { fromViewFilters } from '@/views/saved-views'; @@ -54,6 +55,7 @@ export function BoardScreen() { const [view, setView] = useState('board'); const [filterOpen, setFilterOpen] = useState(false); const [viewsOpen, setViewsOpen] = useState(false); + const [cyclesOpen, setCyclesOpen] = useState(false); const [switching, setSwitching] = useState(false); const [creating, setCreating] = useState(false); const [moving, setMoving] = useState(null); @@ -193,6 +195,8 @@ export function BoardScreen() { + setCyclesOpen(true)} /> + setFilterOpen(true)} /> {issuesQuery.isPending ? ( @@ -213,6 +217,12 @@ export function BoardScreen() { /> )} + setCyclesOpen(false)} + team={team} + /> + setViewsOpen(false)} diff --git a/src/cycles/cycles-screen.tsx b/src/cycles/cycles-screen.tsx new file mode 100644 index 0000000..03376b3 --- /dev/null +++ b/src/cycles/cycles-screen.tsx @@ -0,0 +1,418 @@ +import { useState } from 'react'; +import { Alert as RNAlert, Pressable, View } from 'react-native'; +import { useQueryClient } from '@tanstack/react-query'; + +import { + completeCycleCyclesCycleIdCompletePost, + createCycleTeamsTeamIdCyclesPost, + deleteCycleCyclesCycleIdDelete, + startCycleCyclesCycleIdStartPost, + updateCycleCyclesCycleIdPatch, + useListCyclesTeamsTeamIdCyclesGet, +} from '@/api/generated/endpoints/cycles/cycles'; +import type { CycleRead, TeamRead } from '@/api/generated/models'; +import { errorDetail } from '@/api/errors'; +import { Icon } from '@/ui/icon'; +import { Alert, AppText, Button, Card, Field, Loading } from '@/ui/primitives'; +import { Sheet } from '@/ui/sheet'; +import { useTokens } from '@/ui/theme'; + +/** + * Cycles, per `docs/design/mobile/149-cycles.svg`. + * + * Progress is reported by the server, unsized issues included as their own + * count: a cycle whose points total looks complete while three issues were never + * sized is not finished, and saying so is the whole reason that field exists. + */ + +/** `YYYY-MM-DD` is what the API takes, and what a date field can produce. */ +function isoDate(value: string): string | null { + const trimmed = value.trim(); + if (!/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) return null; + const parsed = new Date(`${trimmed}T00:00:00Z`); + return Number.isNaN(parsed.getTime()) ? null : trimmed; +} + +function stateLabel(state: CycleRead['state']): string { + return state === 'active' ? 'Active' : state === 'upcoming' ? 'Upcoming' : 'Completed'; +} + +function CycleCard({ + cycle, + onManage, +}: { + cycle: CycleRead; + onManage: (cycle: CycleRead) => void; +}) { + const t = useTokens(); + const { progress } = cycle; + const done = progress.issues_total > 0 ? progress.issues_completed / progress.issues_total : 0; + + const color = + cycle.state === 'active' + ? t.status.progress + : cycle.state === 'upcoming' + ? t.status.todo + : t.status.done; + + return ( + onManage(cycle)} + > + + + + + {cycle.display_name} + + {stateLabel(cycle.state)} + + + + {cycle.starts_at.slice(0, 10)} → {cycle.ends_at.slice(0, 10)} + + + + + + + + {progress.issues_completed}/{progress.issues_total} issues ·{' '} + {progress.points_completed}/{progress.points_total} pts + {/* An unsized issue is not worth zero points, so the total is not the + whole story until this is nothing. */} + {progress.issues_unestimated > 0 + ? ` · ${progress.issues_unestimated} unsized` + : ''} + + + + ); +} + +export function CyclesSheet({ + visible, + onClose, + team, +}: { + visible: boolean; + onClose: () => void; + team: TeamRead; +}) { + const queryClient = useQueryClient(); + const cycles = useListCyclesTeamsTeamIdCyclesGet(team.id, { query: { enabled: visible } }); + + const [managing, setManaging] = useState(null); + const [creating, setCreating] = useState(false); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const items = cycles.data ?? []; + + async function run(action: () => Promise, fallback: string) { + setError(null); + setBusy(true); + try { + await action(); + await queryClient.invalidateQueries({ queryKey: [`/teams/${team.id}/cycles`] }); + // A cycle boundary moves issues, so the board has to re-read too. + void queryClient.invalidateQueries({ queryKey: [`/teams/${team.id}/issues`] }); + } catch (err) { + setError(errorDetail(err, fallback)); + } finally { + setBusy(false); + } + } + + return ( + + + {error ? {error} : null} + + {cycles.isPending ? : null} + + {items.map((cycle) => ( + + ))} + + {items.length === 0 && !cycles.isPending ? ( + + No cycles yet. + + ) : null} + + + + + setCreating(false)} + onRun={run} + /> + + setManaging(null)} + onRun={run} + /> + + ); +} + +function NewCycleSheet({ + visible, + teamId, + busy, + onClose, + onRun, +}: { + visible: boolean; + teamId: number; + busy: boolean; + onClose: () => void; + onRun: (action: () => Promise, fallback: string) => Promise; +}) { + const [name, setName] = useState(''); + const [starts, setStarts] = useState(''); + const [ends, setEnds] = useState(''); + const [invalid, setInvalid] = useState(null); + + if (!visible) return null; + + return ( + + + {invalid ? {invalid} : null} + + + + + + + + + ); +} + +function ManageCycleSheet({ + cycle, + busy, + onClose, + onRun, +}: { + cycle: CycleRead | null; + busy: boolean; + onClose: () => void; + onRun: (action: () => Promise, fallback: string) => Promise; +}) { + const [name, setName] = useState(''); + if (!cycle) return null; + + return ( + + + + + + {cycle.state === 'upcoming' ? ( + + ) : null} + + {cycle.state === 'active' ? ( + + ) : null} + + + + + ); +} + +/** The current cycle, shown above the board while one is running. */ +export function CycleBanner({ team, onOpen }: { team: TeamRead; onOpen: () => void }) { + const t = useTokens(); + const cycles = useListCyclesTeamsTeamIdCyclesGet(team.id); + const active = (cycles.data ?? []).find((cycle) => cycle.state === 'active'); + if (!active) return null; + + const { progress } = active; + const done = + progress.issues_total > 0 ? progress.issues_completed / progress.issues_total : 0; + + return ( + + + + {active.display_name} + + + {progress.issues_completed}/{progress.issues_total} + {progress.issues_unestimated > 0 ? ` · ${progress.issues_unestimated} unsized` : ''} + + + + + + + + ); +}