paseo-beads 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +171 -0
- package/client/board-view.tsx +290 -0
- package/client/board.ts +160 -0
- package/client/focus.ts +51 -0
- package/client/format.ts +227 -0
- package/client/markdown-view.tsx +156 -0
- package/client/markdown.ts +435 -0
- package/client/overview-view.tsx +462 -0
- package/client/panel.tsx +961 -0
- package/client/project.ts +603 -0
- package/client/rows.tsx +666 -0
- package/client/styles.ts +557 -0
- package/images/board.png +0 -0
- package/images/overview.png +0 -0
- package/images/plan.png +0 -0
- package/images/risks.png +0 -0
- package/index.client.tsx +68 -0
- package/index.server.ts +21 -0
- package/package.json +53 -0
- package/paseo-plugin.json +6 -0
- package/server/attachments.ts +166 -0
- package/server/bv.ts +213 -0
- package/server/cache.ts +51 -0
- package/server/command.ts +343 -0
- package/server/dashboard.ts +244 -0
- package/server/issue.ts +59 -0
- package/server/normalize.ts +687 -0
- package/server/search.ts +40 -0
- package/server/tracker.ts +122 -0
- package/server/workspace.ts +153 -0
- package/shared/beads.ts +325 -0
- package/shared/rpc.ts +129 -0
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
import type { PluginTheme } from "@getpaseo/plugin";
|
|
2
|
+
import { Pressable, Text, View } from "react-native";
|
|
3
|
+
import type { ProjectHealth, Recommendation } from "../shared/beads";
|
|
4
|
+
import { percentDone, stateDescription, toneColor } from "./format";
|
|
5
|
+
import { workIn, type ProjectModel, type WorkPackage, type WorkRoot, type WorkState } from "./project";
|
|
6
|
+
import { Empty, facetContext, ProgressBar, RecommendationRow, SectionHeader, WorkRow } from "./rows";
|
|
7
|
+
import type { PanelStyles } from "./styles";
|
|
8
|
+
|
|
9
|
+
/** Rows per work list before the rest is left to the Board. */
|
|
10
|
+
const LIST_LIMIT = 8;
|
|
11
|
+
|
|
12
|
+
/** Labels listed before the rest is summarised. */
|
|
13
|
+
const LABEL_LIMIT = 12;
|
|
14
|
+
|
|
15
|
+
/** Steps of the critical chain listed before it is summarised. */
|
|
16
|
+
const CHAIN_LIMIT = 12;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The default view, built around the questions a reader opens the panel with:
|
|
20
|
+
* how far along is this, what is moving, what can start now, how the project's
|
|
21
|
+
* own labels spread over open work, and how long the remaining chain of
|
|
22
|
+
* dependencies is.
|
|
23
|
+
*/
|
|
24
|
+
export function OverviewView({
|
|
25
|
+
styles,
|
|
26
|
+
theme,
|
|
27
|
+
project,
|
|
28
|
+
health,
|
|
29
|
+
recommendations,
|
|
30
|
+
selectedId,
|
|
31
|
+
onSelect,
|
|
32
|
+
}: {
|
|
33
|
+
readonly styles: PanelStyles;
|
|
34
|
+
readonly theme: PluginTheme;
|
|
35
|
+
readonly project: ProjectModel;
|
|
36
|
+
readonly health: ProjectHealth | null;
|
|
37
|
+
readonly recommendations: readonly Recommendation[];
|
|
38
|
+
readonly selectedId: string | null;
|
|
39
|
+
readonly onSelect: (issueId: string) => void;
|
|
40
|
+
}) {
|
|
41
|
+
if (!project.complete) {
|
|
42
|
+
// Without the graph there is no structure to report progress against, so
|
|
43
|
+
// the view says so and falls back to bv's own picks.
|
|
44
|
+
return (
|
|
45
|
+
<View style={styles.listGroup}>
|
|
46
|
+
<Text style={styles.muted}>
|
|
47
|
+
The whole-project graph could not be read, so progress and groups are unavailable. These are bv's
|
|
48
|
+
triage picks.
|
|
49
|
+
</Text>
|
|
50
|
+
<SectionHeader styles={styles} theme={theme} title="Triage picks" meta={`${recommendations.length}`} />
|
|
51
|
+
{recommendations.length === 0 ? (
|
|
52
|
+
<Empty styles={styles} theme={theme} message="bv returned no triage recommendation." />
|
|
53
|
+
) : (
|
|
54
|
+
recommendations.map((recommendation) => (
|
|
55
|
+
<RecommendationRow
|
|
56
|
+
key={recommendation.id}
|
|
57
|
+
styles={styles}
|
|
58
|
+
theme={theme}
|
|
59
|
+
recommendation={recommendation}
|
|
60
|
+
selected={selectedId === recommendation.id}
|
|
61
|
+
onSelect={onSelect}
|
|
62
|
+
/>
|
|
63
|
+
))
|
|
64
|
+
)}
|
|
65
|
+
</View>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (project.work.length === 0) {
|
|
70
|
+
return (
|
|
71
|
+
<Empty
|
|
72
|
+
styles={styles}
|
|
73
|
+
theme={theme}
|
|
74
|
+
message={
|
|
75
|
+
project.byId.size === 0
|
|
76
|
+
? "bv reported no issues in this project yet."
|
|
77
|
+
: "Every issue here contains others, so there is no work item to report on."
|
|
78
|
+
}
|
|
79
|
+
/>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const active = workIn(project, "active");
|
|
84
|
+
const held = workIn(project, "held");
|
|
85
|
+
const ready = workIn(project, "ready");
|
|
86
|
+
const context = facetContext(project);
|
|
87
|
+
|
|
88
|
+
const list = (title: string, items: typeof ready, empty: string | null, showState: boolean) =>
|
|
89
|
+
items.length === 0 && empty === null ? null : (
|
|
90
|
+
<View style={styles.listGroup}>
|
|
91
|
+
<SectionHeader styles={styles} theme={theme} title={title} meta={`${items.length}`} />
|
|
92
|
+
{items.length === 0 && empty !== null ? <Empty styles={styles} theme={theme} message={empty} /> : null}
|
|
93
|
+
{items.slice(0, LIST_LIMIT).map((item) => (
|
|
94
|
+
<WorkRow
|
|
95
|
+
key={item.id}
|
|
96
|
+
styles={styles}
|
|
97
|
+
theme={theme}
|
|
98
|
+
item={item}
|
|
99
|
+
showState={showState}
|
|
100
|
+
context={context}
|
|
101
|
+
note={item.state === "ready" ? item.action : null}
|
|
102
|
+
selected={selectedId === item.id}
|
|
103
|
+
onSelect={onSelect}
|
|
104
|
+
/>
|
|
105
|
+
))}
|
|
106
|
+
{items.length > LIST_LIMIT ? (
|
|
107
|
+
<Text style={styles.muted}>+{items.length - LIST_LIMIT} more on the Board.</Text>
|
|
108
|
+
) : null}
|
|
109
|
+
</View>
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<View style={styles.overviewColumns}>
|
|
114
|
+
<View style={styles.overviewColumn}>
|
|
115
|
+
<Summary styles={styles} theme={theme} project={project} health={health} />
|
|
116
|
+
<Groups styles={styles} theme={theme} project={project} selectedId={selectedId} onSelect={onSelect} />
|
|
117
|
+
</View>
|
|
118
|
+
<View style={styles.overviewColumn}>
|
|
119
|
+
{list("In progress", active, null, false)}
|
|
120
|
+
{list("Held", held, null, true)}
|
|
121
|
+
{list(
|
|
122
|
+
"Ready now",
|
|
123
|
+
ready,
|
|
124
|
+
project.counts.done === project.work.length
|
|
125
|
+
? "All work is done."
|
|
126
|
+
: active.length > 0
|
|
127
|
+
? "Nothing else can start until current work lands."
|
|
128
|
+
: "Nothing can start right now.",
|
|
129
|
+
false,
|
|
130
|
+
)}
|
|
131
|
+
{list("Other status", workIn(project, "other"), null, true)}
|
|
132
|
+
<Labels styles={styles} project={project} />
|
|
133
|
+
<Chain styles={styles} theme={theme} project={project} selectedId={selectedId} onSelect={onSelect} />
|
|
134
|
+
</View>
|
|
135
|
+
</View>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function Summary({
|
|
140
|
+
styles,
|
|
141
|
+
theme,
|
|
142
|
+
project,
|
|
143
|
+
health,
|
|
144
|
+
}: {
|
|
145
|
+
readonly styles: PanelStyles;
|
|
146
|
+
readonly theme: PluginTheme;
|
|
147
|
+
readonly project: ProjectModel;
|
|
148
|
+
readonly health: ProjectHealth | null;
|
|
149
|
+
}) {
|
|
150
|
+
const { counts } = project;
|
|
151
|
+
const total = project.work.length;
|
|
152
|
+
const pace =
|
|
153
|
+
health?.closedLast7Days == null
|
|
154
|
+
? null
|
|
155
|
+
: `${health.closedLast7Days} closed in the last 7 days${
|
|
156
|
+
health.closedLast30Days == null ? "" : `, ${health.closedLast30Days} in 30`
|
|
157
|
+
}${health.velocityEstimated ? " (bv estimate)" : ""}`;
|
|
158
|
+
const figures: readonly {
|
|
159
|
+
readonly label: string;
|
|
160
|
+
readonly value: number;
|
|
161
|
+
readonly color: string;
|
|
162
|
+
readonly state: WorkState;
|
|
163
|
+
}[] = [
|
|
164
|
+
{ label: "ready", value: counts.ready, color: toneColor(theme, "success"), state: "ready" },
|
|
165
|
+
{ label: "waiting", value: counts.waiting, color: theme.colors.foregroundMuted, state: "waiting" },
|
|
166
|
+
{ label: "in progress", value: counts.active, color: toneColor(theme, "accent"), state: "active" },
|
|
167
|
+
...(counts.held === 0
|
|
168
|
+
? []
|
|
169
|
+
: [{ label: "held", value: counts.held, color: toneColor(theme, "danger"), state: "held" as const }]),
|
|
170
|
+
];
|
|
171
|
+
return (
|
|
172
|
+
<View style={styles.summaryBlock}>
|
|
173
|
+
<View style={styles.summaryHeadRow}>
|
|
174
|
+
<Text style={styles.summaryHeadline}>
|
|
175
|
+
{counts.done} of {total} done
|
|
176
|
+
</Text>
|
|
177
|
+
<Text style={styles.summaryPercent}>{percentDone(counts.done, total)}%</Text>
|
|
178
|
+
</View>
|
|
179
|
+
<ProgressBar styles={styles} theme={theme} done={counts.done} total={total} wide />
|
|
180
|
+
<View style={styles.summaryFigures}>
|
|
181
|
+
{figures.map((figure) => (
|
|
182
|
+
<View
|
|
183
|
+
key={figure.label}
|
|
184
|
+
style={styles.summaryFigure}
|
|
185
|
+
accessibilityLabel={`${figure.value} ${figure.label}. ${stateDescription(figure.state)}`}
|
|
186
|
+
>
|
|
187
|
+
<Text style={[styles.summaryFigureValue, { color: figure.value === 0 ? theme.colors.foregroundMuted : figure.color }]}>
|
|
188
|
+
{figure.value}
|
|
189
|
+
</Text>
|
|
190
|
+
<Text style={styles.summaryFigureLabel}>{figure.label}</Text>
|
|
191
|
+
</View>
|
|
192
|
+
))}
|
|
193
|
+
</View>
|
|
194
|
+
<Text style={styles.muted}>
|
|
195
|
+
Ready: {stateDescription("ready")} Waiting: {stateDescription("waiting")}
|
|
196
|
+
</Text>
|
|
197
|
+
{pace === null ? null : <Text style={styles.muted}>{pace}</Text>}
|
|
198
|
+
{project.truncated ? (
|
|
199
|
+
<Text style={styles.muted}>Some closed issues were left out to bound the payload, so “done” undercounts.</Text>
|
|
200
|
+
) : null}
|
|
201
|
+
</View>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function Groups({
|
|
206
|
+
styles,
|
|
207
|
+
theme,
|
|
208
|
+
project,
|
|
209
|
+
selectedId,
|
|
210
|
+
onSelect,
|
|
211
|
+
}: {
|
|
212
|
+
readonly styles: PanelStyles;
|
|
213
|
+
readonly theme: PluginTheme;
|
|
214
|
+
readonly project: ProjectModel;
|
|
215
|
+
readonly selectedId: string | null;
|
|
216
|
+
readonly onSelect: (issueId: string) => void;
|
|
217
|
+
}) {
|
|
218
|
+
const live = project.roots.filter((root) => !root.settled);
|
|
219
|
+
const finished = project.roots.length - live.length;
|
|
220
|
+
if (live.length === 0 && finished === 0) return null;
|
|
221
|
+
return (
|
|
222
|
+
<View style={styles.listGroup}>
|
|
223
|
+
<SectionHeader
|
|
224
|
+
styles={styles}
|
|
225
|
+
theme={theme}
|
|
226
|
+
title="Progress by group"
|
|
227
|
+
meta={finished === 0 ? `${live.length}` : `${live.length} open · ${finished} finished`}
|
|
228
|
+
/>
|
|
229
|
+
{live.map((root) => (
|
|
230
|
+
<RootBlock key={root.key} styles={styles} theme={theme} root={root} selectedId={selectedId} onSelect={onSelect} />
|
|
231
|
+
))}
|
|
232
|
+
</View>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* An outermost container and its packages. When the container holds its work
|
|
238
|
+
* directly there is nothing to break down, so it is a single row.
|
|
239
|
+
*/
|
|
240
|
+
function RootBlock({
|
|
241
|
+
styles,
|
|
242
|
+
theme,
|
|
243
|
+
root,
|
|
244
|
+
selectedId,
|
|
245
|
+
onSelect,
|
|
246
|
+
}: {
|
|
247
|
+
readonly styles: PanelStyles;
|
|
248
|
+
readonly theme: PluginTheme;
|
|
249
|
+
readonly root: WorkRoot;
|
|
250
|
+
readonly selectedId: string | null;
|
|
251
|
+
readonly onSelect: (issueId: string) => void;
|
|
252
|
+
}) {
|
|
253
|
+
const only = root.packages.length === 1 ? root.packages[0] : undefined;
|
|
254
|
+
const flat = only !== undefined && only.id === root.id;
|
|
255
|
+
return (
|
|
256
|
+
<View style={styles.rootBlock}>
|
|
257
|
+
<GroupRow
|
|
258
|
+
styles={styles}
|
|
259
|
+
theme={theme}
|
|
260
|
+
id={root.id}
|
|
261
|
+
title={root.title}
|
|
262
|
+
done={root.done}
|
|
263
|
+
total={root.total}
|
|
264
|
+
counts={root.counts}
|
|
265
|
+
strong
|
|
266
|
+
selected={root.id !== null && selectedId === root.id}
|
|
267
|
+
onSelect={onSelect}
|
|
268
|
+
/>
|
|
269
|
+
{flat
|
|
270
|
+
? null
|
|
271
|
+
: root.packages.map((pkg) => (
|
|
272
|
+
<PackageRow key={pkg.key} styles={styles} theme={theme} pkg={pkg} root={root} selectedId={selectedId} onSelect={onSelect} />
|
|
273
|
+
))}
|
|
274
|
+
</View>
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function PackageRow({
|
|
279
|
+
styles,
|
|
280
|
+
theme,
|
|
281
|
+
pkg,
|
|
282
|
+
root,
|
|
283
|
+
selectedId,
|
|
284
|
+
onSelect,
|
|
285
|
+
}: {
|
|
286
|
+
readonly styles: PanelStyles;
|
|
287
|
+
readonly theme: PluginTheme;
|
|
288
|
+
readonly pkg: WorkPackage;
|
|
289
|
+
readonly root: WorkRoot;
|
|
290
|
+
readonly selectedId: string | null;
|
|
291
|
+
readonly onSelect: (issueId: string) => void;
|
|
292
|
+
}) {
|
|
293
|
+
return (
|
|
294
|
+
<View style={styles.packageIndent}>
|
|
295
|
+
<GroupRow
|
|
296
|
+
styles={styles}
|
|
297
|
+
theme={theme}
|
|
298
|
+
id={pkg.id}
|
|
299
|
+
// Work filed directly on the top-level issue, beside its sub-groups.
|
|
300
|
+
title={pkg.id !== null && pkg.id === root.id ? `Directly under ${root.id}` : pkg.title}
|
|
301
|
+
done={pkg.done}
|
|
302
|
+
total={pkg.total}
|
|
303
|
+
counts={pkg.counts}
|
|
304
|
+
strong={false}
|
|
305
|
+
selected={pkg.id !== null && selectedId === pkg.id}
|
|
306
|
+
onSelect={onSelect}
|
|
307
|
+
/>
|
|
308
|
+
</View>
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function GroupRow({
|
|
313
|
+
styles,
|
|
314
|
+
theme,
|
|
315
|
+
id,
|
|
316
|
+
title,
|
|
317
|
+
done,
|
|
318
|
+
total,
|
|
319
|
+
counts,
|
|
320
|
+
strong,
|
|
321
|
+
selected,
|
|
322
|
+
onSelect,
|
|
323
|
+
}: {
|
|
324
|
+
readonly styles: PanelStyles;
|
|
325
|
+
readonly theme: PluginTheme;
|
|
326
|
+
readonly id: string | null;
|
|
327
|
+
readonly title: string;
|
|
328
|
+
readonly done: number;
|
|
329
|
+
readonly total: number;
|
|
330
|
+
readonly counts: WorkPackage["counts"];
|
|
331
|
+
readonly strong: boolean;
|
|
332
|
+
readonly selected: boolean;
|
|
333
|
+
readonly onSelect: (issueId: string) => void;
|
|
334
|
+
}) {
|
|
335
|
+
const detail = [
|
|
336
|
+
counts.ready === 0 ? null : `${counts.ready} ready`,
|
|
337
|
+
counts.waiting === 0 ? null : `${counts.waiting} waiting`,
|
|
338
|
+
counts.active === 0 ? null : `${counts.active} in progress`,
|
|
339
|
+
counts.held === 0 ? null : `${counts.held} held`,
|
|
340
|
+
done === total ? "all done" : null,
|
|
341
|
+
]
|
|
342
|
+
.filter((part): part is string => part !== null)
|
|
343
|
+
.join(" · ");
|
|
344
|
+
const body = (
|
|
345
|
+
<>
|
|
346
|
+
<View style={styles.groupRowHead}>
|
|
347
|
+
<Text style={strong ? styles.groupTitleStrong : styles.groupTitle} numberOfLines={1}>
|
|
348
|
+
{title}
|
|
349
|
+
</Text>
|
|
350
|
+
<Text style={styles.progressText}>
|
|
351
|
+
{done}/{total}
|
|
352
|
+
</Text>
|
|
353
|
+
</View>
|
|
354
|
+
<ProgressBar styles={styles} theme={theme} done={done} total={total} wide />
|
|
355
|
+
<Text style={styles.groupMeta}>{[id, detail].filter((part) => part !== null && part.length > 0).join(" · ")}</Text>
|
|
356
|
+
</>
|
|
357
|
+
);
|
|
358
|
+
if (id === null) return <View style={styles.groupRow}>{body}</View>;
|
|
359
|
+
return (
|
|
360
|
+
<Pressable
|
|
361
|
+
accessibilityRole="button"
|
|
362
|
+
accessibilityState={{ selected }}
|
|
363
|
+
accessibilityLabel={`${title}, ${done} of ${total} done${detail.length === 0 ? "" : `, ${detail}`}`}
|
|
364
|
+
onPress={() => onSelect(id)}
|
|
365
|
+
style={({ pressed }) => [styles.groupRow, selected || pressed ? styles.railRowSelected : null]}
|
|
366
|
+
>
|
|
367
|
+
{body}
|
|
368
|
+
</Pressable>
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function Chain({
|
|
373
|
+
styles,
|
|
374
|
+
theme,
|
|
375
|
+
project,
|
|
376
|
+
selectedId,
|
|
377
|
+
onSelect,
|
|
378
|
+
}: {
|
|
379
|
+
readonly styles: PanelStyles;
|
|
380
|
+
readonly theme: PluginTheme;
|
|
381
|
+
readonly project: ProjectModel;
|
|
382
|
+
readonly selectedId: string | null;
|
|
383
|
+
readonly onSelect: (issueId: string) => void;
|
|
384
|
+
}) {
|
|
385
|
+
const { chain } = project;
|
|
386
|
+
if (project.chainCycle) {
|
|
387
|
+
return (
|
|
388
|
+
<View style={styles.listGroup}>
|
|
389
|
+
<SectionHeader styles={styles} theme={theme} title="Critical chain" meta="unmeasurable" />
|
|
390
|
+
<Text style={styles.danger}>
|
|
391
|
+
Open work depends on itself in a cycle, so no order of work can finish it. See Risks.
|
|
392
|
+
</Text>
|
|
393
|
+
</View>
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
if (chain.length === 0) return null;
|
|
397
|
+
return (
|
|
398
|
+
<View style={styles.listGroup}>
|
|
399
|
+
<SectionHeader
|
|
400
|
+
styles={styles}
|
|
401
|
+
theme={theme}
|
|
402
|
+
title="Critical chain"
|
|
403
|
+
meta={`${chain.length} steps in sequence`}
|
|
404
|
+
/>
|
|
405
|
+
<Text style={styles.muted}>
|
|
406
|
+
The longest run of open dependencies: however many agents work in parallel, at least this many issues
|
|
407
|
+
must land one after another.
|
|
408
|
+
</Text>
|
|
409
|
+
{chain.slice(0, CHAIN_LIMIT).map((item) => (
|
|
410
|
+
<WorkRow
|
|
411
|
+
key={item.id}
|
|
412
|
+
styles={styles}
|
|
413
|
+
theme={theme}
|
|
414
|
+
item={{ ...item, critical: false }}
|
|
415
|
+
showState
|
|
416
|
+
context={{ ...facetContext(project), showPriority: false }}
|
|
417
|
+
selected={selectedId === item.id}
|
|
418
|
+
onSelect={onSelect}
|
|
419
|
+
/>
|
|
420
|
+
))}
|
|
421
|
+
{chain.length > CHAIN_LIMIT ? (
|
|
422
|
+
<Text style={styles.muted}>+{chain.length - CHAIN_LIMIT} more steps.</Text>
|
|
423
|
+
) : null}
|
|
424
|
+
</View>
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* The project's own labels over open work, as written. What a label means is
|
|
430
|
+
* the project's business, so each is only counted: how much open work carries
|
|
431
|
+
* it and how much of that can start now.
|
|
432
|
+
*/
|
|
433
|
+
function Labels({ styles, project }: { readonly styles: PanelStyles; readonly project: ProjectModel }) {
|
|
434
|
+
if (project.labels.length === 0) return null;
|
|
435
|
+
const common = [...project.commonLabels].sort();
|
|
436
|
+
return (
|
|
437
|
+
<View style={styles.listGroup}>
|
|
438
|
+
<View style={styles.sectionHeader}>
|
|
439
|
+
<Text style={styles.sectionTitle}>Labels on open work</Text>
|
|
440
|
+
<Text style={styles.sectionMeta}>{project.labels.length}</Text>
|
|
441
|
+
</View>
|
|
442
|
+
{project.labels.slice(0, LABEL_LIMIT).map((stat) => (
|
|
443
|
+
<View
|
|
444
|
+
key={stat.label}
|
|
445
|
+
style={styles.labelRow}
|
|
446
|
+
accessibilityLabel={`label ${stat.label}, ${stat.live} open, ${stat.ready} ready`}
|
|
447
|
+
>
|
|
448
|
+
<Text style={styles.labelFacet}>{stat.label}</Text>
|
|
449
|
+
<Text style={styles.groupMeta}>
|
|
450
|
+
{stat.live} open{stat.ready === 0 ? "" : ` · ${stat.ready} ready`}
|
|
451
|
+
</Text>
|
|
452
|
+
</View>
|
|
453
|
+
))}
|
|
454
|
+
{project.labels.length > LABEL_LIMIT ? (
|
|
455
|
+
<Text style={styles.muted}>+{project.labels.length - LABEL_LIMIT} more; group the Board by label to see them.</Text>
|
|
456
|
+
) : null}
|
|
457
|
+
{common.length === 0 ? null : (
|
|
458
|
+
<Text style={styles.muted}>On every open item, so not listed: {common.join(", ")}.</Text>
|
|
459
|
+
)}
|
|
460
|
+
</View>
|
|
461
|
+
);
|
|
462
|
+
}
|