| 1 | import { router } from "expo-router"; |
| 2 | import { ListTodo, Users } from "lucide-react-native"; |
| 3 | import { useState } from "react"; |
| 4 | import { Pressable, RefreshControl, SectionList, StyleSheet, View } from "react-native"; |
| 5 | |
| 6 | import { Empty, ErrorView, Loading, SectionHeader, Text } from "@/components/ui"; |
| 7 | import { Chip, ItemIcon, RepoChips } from "@/components/work"; |
| 8 | import { type Card, STAGES, type Stage, stageName } from "@/lib/board"; |
| 9 | import { harnessName } from "@/lib/sessions"; |
| 10 | import { ago } from "@/lib/time"; |
| 11 | import { type ApiWorkItem, useBoard } from "@/lib/work"; |
| 12 | import { space, useColors } from "@/theme"; |
| 13 | |
| 14 | /** |
| 15 | * Work's board on a phone: the work as it moves, a stage under the next |
| 16 | * (Backlog, In progress, In review, Landing, Done), and chips above to |
| 17 | * show one stage alone. Pull down to read again; while a session is live |
| 18 | * and the board is in view, who is on what is read again every 15 s. |
| 19 | */ |
| 20 | export function Board({ workspace, focused }: { workspace: string; focused: boolean }) { |
| 21 | const colors = useColors(); |
| 22 | const board = useBoard(workspace, focused); |
| 23 | const [stage, setStage] = useState<Stage | null>(null); |
| 24 | |
| 25 | if (board.isPending) return <Loading />; |
| 26 | if (board.error) return <ErrorView error={board.error} retry={() => void board.refetch()} />; |
| 27 | |
| 28 | const total = STAGES.reduce((sum, one) => sum + board.board[one].length, 0); |
| 29 | const sections = (stage ? [stage] : STAGES) |
| 30 | .map((one) => ({ stage: one, title: `${stageName(one)} · ${board.board[one].length}`, data: board.board[one] })) |
| 31 | .filter((section) => stage !== null || section.data.length > 0); |
| 32 | |
| 33 | return ( |
| 34 | <SectionList |
| 35 | sections={sections} |
| 36 | keyExtractor={(card) => card.item.id} |
| 37 | stickySectionHeadersEnabled={false} |
| 38 | refreshControl={<RefreshControl refreshing={board.isRefetching} onRefresh={() => void board.refetch()} tintColor={colors.accent} />} |
| 39 | contentContainerStyle={{ paddingBottom: 96, flexGrow: 1 }} |
| 40 | ListHeaderComponent={ |
| 41 | <View style={[styles.stages, { borderBottomColor: colors.line }]}> |
| 42 | <Chip label={`All ${total}`} tone={stage === null ? "accent" : "neutral"} onPress={() => setStage(null)} /> |
| 43 | {STAGES.map((one) => ( |
| 44 | <Chip |
| 45 | key={one} |
| 46 | label={`${stageName(one)} ${board.board[one].length}`} |
| 47 | tone={stage === one ? "accent" : "neutral"} |
| 48 | onPress={() => setStage(stage === one ? null : one)} |
| 49 | /> |
| 50 | ))} |
| 51 | </View> |
| 52 | } |
| 53 | renderSectionHeader={({ section }) => <SectionHeader title={section.title} />} |
| 54 | renderSectionFooter={({ section }) => |
| 55 | section.data.length === 0 ? ( |
| 56 | <Text size="small" tone="faint" style={{ paddingHorizontal: space.lg }}> |
| 57 | {section.stage === "done" ? "Nothing closed in the last two weeks." : "Nothing here."} |
| 58 | </Text> |
| 59 | ) : null |
| 60 | } |
| 61 | renderItem={({ item }) => <BoardCard card={item} />} |
| 62 | ListEmptyComponent={<Empty icon={ListTodo} title="No work yet" body="Work items are any tracked work: a feature, a bug, a chore." />} |
| 63 | /> |
| 64 | ); |
| 65 | } |
| 66 | |
| 67 | /** A work item on the board: its number and title, who is on it, and its repositories. */ |
| 68 | function BoardCard({ card }: { card: Card<ApiWorkItem> }) { |
| 69 | const colors = useColors(); |
| 70 | const { item, people } = card; |
| 71 | const who = people.map((one) => (one.harness ? `@${one.name} (${harnessName(one.harness)})` : `@${one.name}`)).join(", "); |
| 72 | return ( |
| 73 | <Pressable |
| 74 | onPress={() => router.push({ pathname: "/work/[number]", params: { number: String(item.number), workspace: item.workspace } })} |
| 75 | style={({ pressed }) => [styles.row, { borderBottomColor: colors.line, backgroundColor: pressed ? colors.raised : "transparent" }]} |
| 76 | > |
| 77 | <View style={{ paddingTop: 1 }}> |
| 78 | <ItemIcon item={item} size={18} /> |
| 79 | </View> |
| 80 | <View style={{ flex: 1, gap: 6 }}> |
| 81 | <Text numberOfLines={2} style={{ fontWeight: "600" }}> |
| 82 | {item.title} |
| 83 | </Text> |
| 84 | {who ? ( |
| 85 | <View style={styles.meta}> |
| 86 | <Users size={13} color={colors.faint} /> |
| 87 | <Text size="small" tone="muted" numberOfLines={1} style={{ flexShrink: 1 }}> |
| 88 | {who} |
| 89 | </Text> |
| 90 | </View> |
| 91 | ) : null} |
| 92 | <View style={styles.meta}> |
| 93 | <Text size="small" tone="muted"> |
| 94 | #{item.number} · {ago(item.closed_at ?? item.updated_at)} |
| 95 | </Text> |
| 96 | <RepoChips repos={item.repos} workspace={item.workspace} /> |
| 97 | </View> |
| 98 | </View> |
| 99 | </Pressable> |
| 100 | ); |
| 101 | } |
| 102 | |
| 103 | const styles = StyleSheet.create({ |
| 104 | stages: { flexDirection: "row", flexWrap: "wrap", gap: 6, paddingHorizontal: space.lg, paddingVertical: space.md, borderBottomWidth: StyleSheet.hairlineWidth }, |
| 105 | row: { flexDirection: "row", gap: space.md, paddingHorizontal: space.lg, paddingVertical: space.md, borderBottomWidth: StyleSheet.hairlineWidth }, |
| 106 | meta: { flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 6 }, |
| 107 | }); |