@wrenautomation/vireo 0.11.0 → 0.13.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/README.md +17 -4
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +35 -5
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/schema.d.ts +121 -21
- package/dist/schema.js +26 -6
- package/dist/schema.js.map +1 -1
- package/dist/store.d.ts +324 -21
- package/dist/store.js +300 -27
- package/dist/store.js.map +1 -1
- package/drizzle/0007_batch_title.sql +1 -0
- package/drizzle/0008_calls.sql +24 -0
- package/drizzle/meta/0007_snapshot.json +1171 -0
- package/drizzle/meta/0008_snapshot.json +1256 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +1 -1
package/dist/store.js
CHANGED
|
@@ -7,12 +7,15 @@ import { PgTransaction } from "drizzle-orm/pg-core";
|
|
|
7
7
|
import { collect } from "./collect.js";
|
|
8
8
|
import { atomic, VireoError } from "./db.js";
|
|
9
9
|
import { live } from "./live.js";
|
|
10
|
-
import { batches, guides, lineage, notes, samples, ships, steers, subjects, TAP, VERDICTS, verdictOf, } from "./schema.js";
|
|
10
|
+
import { batches, calls, guides, lineage, notes, samples, ships, steers, subjects, TAP, VERDICTS, verdictOf, } from "./schema.js";
|
|
11
11
|
import { fitPlackettLuce, rateSplit, tiersToChoices } from "./stats.js";
|
|
12
12
|
import { isSource } from "./sync.js";
|
|
13
13
|
/** `s3://` (the bucket signs it) or `https://` (served as is: a live page on its own origin). */
|
|
14
14
|
export const isMediaUri = (uri) => /^(s3|https):\/\/[^\s/]+/i.test(uri);
|
|
15
15
|
const parentOf = (p) => typeof p === "number" ? { id: p, took: null } : { id: p.id, took: p.took?.trim() || null };
|
|
16
|
+
/** `by` of calls made before calls had a judge (migration 0008); a host may name them after. */
|
|
17
|
+
export const EARLIER_JUDGE = "person";
|
|
18
|
+
export const BATCH_SORTS = ["waiting", "newest", "oldest", "subject", "good"];
|
|
16
19
|
/** `roots` and every sample that grew from them, over any parent edge. */
|
|
17
20
|
function grownFrom(roots, edges) {
|
|
18
21
|
const kids = new Map();
|
|
@@ -47,26 +50,72 @@ const shuffled = (xs) => {
|
|
|
47
50
|
return a;
|
|
48
51
|
};
|
|
49
52
|
/**
|
|
50
|
-
*
|
|
51
|
-
*
|
|
53
|
+
* One sheet per (batch, person) who called in it: each sample of the batch with that person's
|
|
54
|
+
* call, or none (unmarked). The unit of every rank and rate (D11).
|
|
52
55
|
*/
|
|
53
|
-
export function
|
|
54
|
-
const
|
|
56
|
+
export function sheets(rows, cs) {
|
|
57
|
+
const inBatch = new Map();
|
|
55
58
|
for (const r of rows)
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
const
|
|
59
|
+
inBatch.set(r.batchId, [...(inBatch.get(r.batchId) ?? []), r.id]);
|
|
60
|
+
const batchOf = new Map(rows.map((r) => [r.id, r.batchId]));
|
|
61
|
+
const out = new Map();
|
|
62
|
+
for (const c of cs) {
|
|
63
|
+
const b = batchOf.get(c.sampleId);
|
|
64
|
+
if (b == null)
|
|
65
|
+
continue;
|
|
66
|
+
const k = `${b}\u0000${c.by}`;
|
|
67
|
+
const sheet = out.get(k) ?? { batchId: b, by: c.by, got: new Map() };
|
|
68
|
+
sheet.got.set(c.sampleId, c);
|
|
69
|
+
out.set(k, sheet);
|
|
70
|
+
}
|
|
71
|
+
return [...out.values()].map((x) => ({
|
|
72
|
+
batchId: x.batchId,
|
|
73
|
+
by: x.by,
|
|
74
|
+
marks: (inBatch.get(x.batchId) ?? []).map((id) => ({ id, call: x.got.get(id) ?? null })),
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Strength per judged sample: each sheet is a partial ranking by score, 10 down to 1, ties
|
|
79
|
+
* unordered; unmarked reads as 5. Wins from 9 and up count double.
|
|
80
|
+
*/
|
|
81
|
+
export function strengths(rows, cs) {
|
|
82
|
+
const ss = sheets(rows, cs);
|
|
83
|
+
const ids = [...new Set(ss.flatMap((x) => x.marks.map((m) => String(m.id))))];
|
|
59
84
|
const scores = [10, 9, 8, 7, 6, 5, 4, 3, 2, 1];
|
|
60
|
-
const choices =
|
|
85
|
+
const choices = ss.flatMap((x) => tiersToChoices(scores.map((n) => x.marks.filter((m) => (m.call?.score ?? UNMARKED) === n).map((m) => String(m.id))), scores.map((n) => (n >= TAP.amazing ? 2 : 1))));
|
|
61
86
|
return fitPlackettLuce(ids, choices);
|
|
62
87
|
}
|
|
63
|
-
/**
|
|
64
|
-
export function
|
|
88
|
+
/** Good or better over every (sample, person) mark of these sheets; null with none. */
|
|
89
|
+
export function goodRateOf(ss) {
|
|
90
|
+
const marks = ss.flatMap((x) => x.marks);
|
|
91
|
+
return marks.length
|
|
92
|
+
? marks.filter((m) => GOOD.includes(m.call?.verdict ?? null)).length / marks.length
|
|
93
|
+
: null;
|
|
94
|
+
}
|
|
95
|
+
/** With-vs-without good rate per guide, over every mark in judged batches. */
|
|
96
|
+
export function guideStats(guideRows, judged, cs) {
|
|
97
|
+
const guidesOf = new Map(judged.map((r) => [r.id, r.guides]));
|
|
98
|
+
const marks = sheets(judged, cs).flatMap((x) => x.marks);
|
|
65
99
|
return guideRows.map((g) => ({
|
|
66
100
|
...g,
|
|
67
|
-
effect: rateSplit(
|
|
101
|
+
effect: rateSplit(marks.map((m) => ({
|
|
102
|
+
has: (guidesOf.get(m.id) ?? []).includes(g.id),
|
|
103
|
+
good: GOOD.includes(m.call?.verdict ?? null),
|
|
104
|
+
}))),
|
|
68
105
|
}));
|
|
69
106
|
}
|
|
107
|
+
/** Mean of the calls a reader may see, and its zone; null with none. */
|
|
108
|
+
const pooled = (cs) => {
|
|
109
|
+
if (!cs.length)
|
|
110
|
+
return { score: null, verdict: null };
|
|
111
|
+
const mean = cs.reduce((a, c) => a + c.score, 0) / cs.length;
|
|
112
|
+
return { score: Math.round(mean * 10) / 10, verdict: verdictOf(Math.round(mean)) };
|
|
113
|
+
};
|
|
114
|
+
/**
|
|
115
|
+
* What a reader may see of an open batch (D11). `viewer` = a person: while open, only their own
|
|
116
|
+
* calls and notes, and none of the agent's words. Null = the agent: everything.
|
|
117
|
+
*/
|
|
118
|
+
const blindFor = (viewer, open) => viewer != null && open;
|
|
70
119
|
/** Waits on the person: open and its subject's newest. A newer batch replaces an unmarked one. */
|
|
71
120
|
const WAITS = and(eq(batches.state, "open"), sql `${batches.n} = (select max(b.n) from vireo.batches b where b.subject_id = ${batches.subjectId})`);
|
|
72
121
|
/** How people and agents name a batch: its number within the subject. The id is secondary. */
|
|
@@ -243,6 +292,12 @@ export class Vireo {
|
|
|
243
292
|
async addBatch(ref, b) {
|
|
244
293
|
if (!b.why.trim())
|
|
245
294
|
throw new VireoError("say why: what changed and what it tests");
|
|
295
|
+
const title = b.title?.trim() || null;
|
|
296
|
+
// appending keeps the batch's title
|
|
297
|
+
if (b.into == null && !title)
|
|
298
|
+
throw new VireoError("a title: one line, what this batch is");
|
|
299
|
+
if (title && title.length > 120)
|
|
300
|
+
throw new VireoError("a title is 120 characters at most");
|
|
246
301
|
if (!b.samples.length)
|
|
247
302
|
throw new VireoError("a batch needs samples");
|
|
248
303
|
for (const s of b.samples)
|
|
@@ -319,6 +374,7 @@ export class Vireo {
|
|
|
319
374
|
.values({
|
|
320
375
|
subjectId: subject.id,
|
|
321
376
|
n: (top?.n ?? 0) + 1,
|
|
377
|
+
title,
|
|
322
378
|
why: b.why,
|
|
323
379
|
agent: b.agent ?? null,
|
|
324
380
|
})
|
|
@@ -493,8 +549,11 @@ export class Vireo {
|
|
|
493
549
|
const pruned = await this.prunedIn(this.db, s.id, live);
|
|
494
550
|
return { steers: live, pruned: [...pruned.keys()].sort((a, b) => a - b) };
|
|
495
551
|
}
|
|
496
|
-
/**
|
|
497
|
-
|
|
552
|
+
/**
|
|
553
|
+
* Batches that wait on the person, oldest first, with samples and notes: what they have to pick.
|
|
554
|
+
* `viewer`: a person sees only their own calls and notes (D11).
|
|
555
|
+
*/
|
|
556
|
+
async queue(ref, viewer = null) {
|
|
498
557
|
// a subject, or one batch as `<subject>:<n>`
|
|
499
558
|
const one = typeof ref === "string" && /:\d+$/.test(ref) ? await this.batchOf(ref) : null;
|
|
500
559
|
const subjectId = ref == null || one ? null : (await this.subjectOf(ref)).id;
|
|
@@ -511,15 +570,30 @@ export class Vireo {
|
|
|
511
570
|
.from(samples)
|
|
512
571
|
.where(inArray(samples.batchId, open.map((o) => o.batch.id)))
|
|
513
572
|
.orderBy(asc(samples.position));
|
|
514
|
-
const
|
|
573
|
+
const ids = rows.map((r) => r.id);
|
|
574
|
+
const mine = (x) => viewer == null || x.by === viewer;
|
|
575
|
+
const ns = (await this.notesOf(ids)).filter(mine);
|
|
576
|
+
const cs = (await this.callsOf(ids)).filter(mine);
|
|
515
577
|
return open.map((o) => ({
|
|
516
578
|
batch: { ref: refOf(o.subject.slug, o.batch.n), ...o.batch },
|
|
517
579
|
subject: o.subject,
|
|
518
580
|
samples: rows
|
|
519
581
|
.filter((r) => r.batchId === o.batch.id)
|
|
520
|
-
.map((r) =>
|
|
582
|
+
.map((r) => {
|
|
583
|
+
const own = cs.filter((c) => c.sampleId === r.id);
|
|
584
|
+
return { ...r, ...pooled(own), calls: own, notes: ns.filter((n) => n.sampleId === r.id) };
|
|
585
|
+
}),
|
|
521
586
|
}));
|
|
522
587
|
}
|
|
588
|
+
async callsOf(sampleIds) {
|
|
589
|
+
if (!sampleIds.length)
|
|
590
|
+
return [];
|
|
591
|
+
return this.db
|
|
592
|
+
.select()
|
|
593
|
+
.from(calls)
|
|
594
|
+
.where(inArray(calls.sampleId, sampleIds))
|
|
595
|
+
.orderBy(asc(calls.at));
|
|
596
|
+
}
|
|
523
597
|
async notesOf(sampleIds) {
|
|
524
598
|
if (!sampleIds.length)
|
|
525
599
|
return [];
|
|
@@ -539,20 +613,37 @@ export class Vireo {
|
|
|
539
613
|
.where(inArray(lineage.childId, childIds))
|
|
540
614
|
.orderBy(asc(lineage.childId), asc(lineage.position));
|
|
541
615
|
}
|
|
542
|
-
/**
|
|
616
|
+
/**
|
|
617
|
+
* `by`'s call: a 1-10 score, a verdict (its tap score) or null to clear. The verdict follows.
|
|
618
|
+
* One call per person per sample (D11).
|
|
619
|
+
*/
|
|
543
620
|
async judge(sampleId, call, by) {
|
|
621
|
+
if (!by)
|
|
622
|
+
throw new VireoError("a call needs who made it", 400);
|
|
544
623
|
const score = typeof call === "string" ? TAP[call] : call;
|
|
545
624
|
if (score != null && !(Number.isInteger(score) && score >= 1 && score <= 10))
|
|
546
625
|
throw new VireoError(`score ${score}: 1-10, or ${VERDICTS.join("|")}`, 400);
|
|
547
626
|
return this.write(by, async (tx) => {
|
|
548
627
|
const [row] = await tx
|
|
549
|
-
.
|
|
550
|
-
.
|
|
551
|
-
.where(eq(samples.id, sampleId))
|
|
552
|
-
.returning({ id: samples.id, score: samples.score, verdict: samples.verdict });
|
|
628
|
+
.select({ id: samples.id })
|
|
629
|
+
.from(samples)
|
|
630
|
+
.where(eq(samples.id, sampleId));
|
|
553
631
|
if (!row)
|
|
554
632
|
throw new VireoError(`no sample ${sampleId}`, 404);
|
|
555
|
-
|
|
633
|
+
const mine = and(eq(calls.sampleId, sampleId), eq(calls.by, by));
|
|
634
|
+
if (score == null) {
|
|
635
|
+
await tx.delete(calls).where(mine);
|
|
636
|
+
return { id: sampleId, by, score: null, verdict: null };
|
|
637
|
+
}
|
|
638
|
+
const verdict = verdictOf(score);
|
|
639
|
+
await tx
|
|
640
|
+
.insert(calls)
|
|
641
|
+
.values({ sampleId, by, score, verdict })
|
|
642
|
+
.onConflictDoUpdate({
|
|
643
|
+
target: [calls.sampleId, calls.by],
|
|
644
|
+
set: { score, verdict, at: sql `now()` },
|
|
645
|
+
});
|
|
646
|
+
return { id: sampleId, by, score, verdict };
|
|
556
647
|
});
|
|
557
648
|
}
|
|
558
649
|
async addNote(sampleId, n, by) {
|
|
@@ -620,6 +711,25 @@ export class Vireo {
|
|
|
620
711
|
return row;
|
|
621
712
|
});
|
|
622
713
|
}
|
|
714
|
+
/** A batch's title (id or `<subject>:<n>`); blank refused. */
|
|
715
|
+
async renameBatch(ref, title, by) {
|
|
716
|
+
const to = title.trim();
|
|
717
|
+
if (!to)
|
|
718
|
+
throw new VireoError("a title, not blank");
|
|
719
|
+
if (to.length > 120)
|
|
720
|
+
throw new VireoError("a title is 120 characters at most");
|
|
721
|
+
return this.write(by, async (tx) => {
|
|
722
|
+
const id = await this.batchOf(ref, tx);
|
|
723
|
+
const [row] = await tx
|
|
724
|
+
.update(batches)
|
|
725
|
+
.set({ title: to })
|
|
726
|
+
.where(eq(batches.id, id))
|
|
727
|
+
.returning({ id: batches.id, n: batches.n, title: batches.title });
|
|
728
|
+
if (!row)
|
|
729
|
+
throw new VireoError(`no batch ${ref}`, 404);
|
|
730
|
+
return row;
|
|
731
|
+
});
|
|
732
|
+
}
|
|
623
733
|
/** A sample's short name; blank clears it. */
|
|
624
734
|
async renameSample(sampleId, name, by) {
|
|
625
735
|
const to = name?.trim() || null;
|
|
@@ -690,10 +800,11 @@ export class Vireo {
|
|
|
690
800
|
.orderBy(asc(samples.batchId), asc(samples.position));
|
|
691
801
|
const ns = await this.notesOf(ss.map((r) => r.id));
|
|
692
802
|
const es = await this.lineageOf(ss.map((r) => r.id));
|
|
803
|
+
const cs = await this.callsOf(ss.map((r) => r.id));
|
|
693
804
|
const judgedBatches = new Set(bs.filter((b) => b.state === "closed").map((b) => b.id));
|
|
694
805
|
const judged = ss.filter((r) => judgedBatches.has(r.batchId));
|
|
695
|
-
const strength = new Map(strengths(ss).map((x) => [Number(x.id), x]));
|
|
696
|
-
const gs = guideStats(await this.guidesFor(s), judged);
|
|
806
|
+
const strength = new Map(strengths(ss, cs).map((x) => [Number(x.id), x]));
|
|
807
|
+
const gs = guideStats(await this.guidesFor(s), judged, cs);
|
|
697
808
|
return {
|
|
698
809
|
subject: s,
|
|
699
810
|
guides: gs,
|
|
@@ -702,11 +813,14 @@ export class Vireo {
|
|
|
702
813
|
return {
|
|
703
814
|
ref: refOf(s.slug, b.n),
|
|
704
815
|
...b,
|
|
705
|
-
goodRate: mine
|
|
706
|
-
|
|
707
|
-
|
|
816
|
+
goodRate: goodRateOf(sheets(mine, cs)),
|
|
817
|
+
judges: [
|
|
818
|
+
...new Set(cs.filter((c) => mine.some((r) => r.id === c.sampleId)).map((c) => c.by)),
|
|
819
|
+
],
|
|
708
820
|
samples: mine.map((r) => ({
|
|
709
821
|
...r,
|
|
822
|
+
...pooled(cs.filter((c) => c.sampleId === r.id)),
|
|
823
|
+
calls: cs.filter((c) => c.sampleId === r.id),
|
|
710
824
|
strength: strength.get(r.id) ?? null,
|
|
711
825
|
notes: ns.filter((n) => n.sampleId === r.id),
|
|
712
826
|
parents: es
|
|
@@ -779,6 +893,165 @@ export class Vireo {
|
|
|
779
893
|
return out;
|
|
780
894
|
}
|
|
781
895
|
/** Kept samples, newest first, with their subject. */
|
|
896
|
+
/**
|
|
897
|
+
* Batches as a page: filters, a sort and an offset (D11). `viewer` = the person reading: an open
|
|
898
|
+
* batch shows only their own marks, and none of the agent's words.
|
|
899
|
+
*/
|
|
900
|
+
async batches(q = {}, viewer = null) {
|
|
901
|
+
const limit = Math.min(Math.max(q.limit ?? 30, 1), 100);
|
|
902
|
+
const after = Math.max(q.after ?? 0, 0);
|
|
903
|
+
const subjectId = q.subject == null ? null : (await this.subjectOf(q.subject)).id;
|
|
904
|
+
const good = sql `${calls.verdict} in ('good', 'amazing')`;
|
|
905
|
+
const n = sql `count(distinct ${samples.id})::int`;
|
|
906
|
+
const judges = sql `count(distinct ${calls.by})::int`;
|
|
907
|
+
// an open batch has no rate: partial, and a sort on it would show others' marks
|
|
908
|
+
const rate = sql `case when ${batches.state} = 'closed' then (count(${calls.id}) filter (where ${good}))::float / nullif((${judges}) * (${n}), 0) end`;
|
|
909
|
+
const waits = sql `(${WAITS})`;
|
|
910
|
+
const tally = (v) => sql `(count(${calls.id}) filter (where ${calls.verdict} = ${v}))::int`;
|
|
911
|
+
const where = and(q.subjects
|
|
912
|
+
? inArray(batches.subjectId, q.subjects.length ? [...q.subjects] : [-1])
|
|
913
|
+
: undefined, subjectId ? eq(batches.subjectId, subjectId) : undefined, q.kind ? eq(subjects.kind, q.kind) : undefined, q.state === "waits" ? WAITS : q.state ? eq(batches.state, q.state) : undefined, q.who
|
|
914
|
+
? sql `exists (select 1 from vireo.calls c join vireo.samples x on x.id = c.sample_id where x.batch_id = ${batches.id} and c.by = ${q.who})`
|
|
915
|
+
: undefined);
|
|
916
|
+
const order = {
|
|
917
|
+
newest: [desc(batches.createdAt), desc(batches.id)],
|
|
918
|
+
oldest: [asc(batches.createdAt), asc(batches.id)],
|
|
919
|
+
waiting: [desc(waits), desc(batches.createdAt), desc(batches.id)],
|
|
920
|
+
subject: [asc(subjects.name), desc(batches.n)],
|
|
921
|
+
good: [sql `${rate} desc nulls last`, desc(batches.createdAt), desc(batches.id)],
|
|
922
|
+
}[q.sort ?? "waiting"];
|
|
923
|
+
const got = await this.db
|
|
924
|
+
.select({
|
|
925
|
+
batch: batches,
|
|
926
|
+
subject: subjects,
|
|
927
|
+
samples: n,
|
|
928
|
+
kept: sql `(count(distinct ${samples.id}) filter (where ${samples.keptAt} is not null))::int`,
|
|
929
|
+
judges,
|
|
930
|
+
mine: viewer
|
|
931
|
+
? sql `(count(${calls.id}) filter (where ${calls.by} = ${viewer}))::int`
|
|
932
|
+
: sql `0`,
|
|
933
|
+
rate,
|
|
934
|
+
waits,
|
|
935
|
+
amazing: tally("amazing"),
|
|
936
|
+
good: tally("good"),
|
|
937
|
+
okay: tally("okay"),
|
|
938
|
+
bad: tally("bad"),
|
|
939
|
+
})
|
|
940
|
+
.from(batches)
|
|
941
|
+
.innerJoin(subjects, eq(subjects.id, batches.subjectId))
|
|
942
|
+
.leftJoin(samples, eq(samples.batchId, batches.id))
|
|
943
|
+
.leftJoin(calls, eq(calls.sampleId, samples.id))
|
|
944
|
+
.where(where)
|
|
945
|
+
.groupBy(batches.id, subjects.id)
|
|
946
|
+
.orderBy(...order)
|
|
947
|
+
.limit(limit + 1)
|
|
948
|
+
.offset(after);
|
|
949
|
+
const page = got.slice(0, limit);
|
|
950
|
+
const [count] = await this.db
|
|
951
|
+
.select({ waiting: sql `(count(*) filter (where ${WAITS}))::int` })
|
|
952
|
+
.from(batches)
|
|
953
|
+
.innerJoin(subjects, eq(subjects.id, batches.subjectId))
|
|
954
|
+
.where(where);
|
|
955
|
+
// thumbs: each batch's posters in made order, with the calls a reader may see
|
|
956
|
+
const thumbs = page.length
|
|
957
|
+
? await this.db
|
|
958
|
+
.select({ id: samples.id, batchId: samples.batchId, poster: samples.poster })
|
|
959
|
+
.from(samples)
|
|
960
|
+
.where(and(inArray(samples.batchId, page.map((r) => r.batch.id)), isNotNull(samples.poster)))
|
|
961
|
+
.orderBy(asc(samples.id))
|
|
962
|
+
: [];
|
|
963
|
+
const cs = await this.callsOf(thumbs.map((t) => t.id));
|
|
964
|
+
return {
|
|
965
|
+
rows: page.map((r) => {
|
|
966
|
+
const blind = blindFor(viewer, r.batch.state === "open");
|
|
967
|
+
const tallied = r.judges * r.samples;
|
|
968
|
+
return {
|
|
969
|
+
ref: refOf(r.subject.slug, r.batch.n),
|
|
970
|
+
id: r.batch.id,
|
|
971
|
+
n: r.batch.n,
|
|
972
|
+
title: r.batch.title,
|
|
973
|
+
state: r.batch.state,
|
|
974
|
+
note: r.batch.note,
|
|
975
|
+
why: blind ? null : r.batch.why,
|
|
976
|
+
createdAt: r.batch.createdAt,
|
|
977
|
+
subject: r.subject,
|
|
978
|
+
waits: r.waits,
|
|
979
|
+
samples: r.samples,
|
|
980
|
+
kept: r.kept,
|
|
981
|
+
// while open a person sees only their own count
|
|
982
|
+
marked: blind ? r.mine : r.amazing + r.good + r.okay + r.bad,
|
|
983
|
+
judges: blind ? null : r.judges,
|
|
984
|
+
goodRate: blind ? null : r.rate,
|
|
985
|
+
tally: blind
|
|
986
|
+
? null
|
|
987
|
+
: {
|
|
988
|
+
amazing: r.amazing,
|
|
989
|
+
good: r.good,
|
|
990
|
+
okay: r.okay,
|
|
991
|
+
bad: r.bad,
|
|
992
|
+
unmarked: tallied ? tallied - (r.amazing + r.good + r.okay + r.bad) : r.samples,
|
|
993
|
+
},
|
|
994
|
+
thumbs: thumbs
|
|
995
|
+
.filter((t) => t.batchId === r.batch.id)
|
|
996
|
+
.map((t) => ({
|
|
997
|
+
id: t.id,
|
|
998
|
+
poster: t.poster,
|
|
999
|
+
verdict: blind ? null : pooled(cs.filter((c) => c.sampleId === t.id)).verdict,
|
|
1000
|
+
})),
|
|
1001
|
+
};
|
|
1002
|
+
}),
|
|
1003
|
+
next: got.length > limit ? after + limit : null,
|
|
1004
|
+
waiting: count?.waiting ?? 0,
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
/** One batch whole, for `viewer` (see `batches`); `newer` = the subject's newer open batch. */
|
|
1008
|
+
async batch(ref, viewer = null) {
|
|
1009
|
+
const id = await this.batchOf(ref);
|
|
1010
|
+
const [o] = await this.db
|
|
1011
|
+
.select({ batch: batches, subject: subjects })
|
|
1012
|
+
.from(batches)
|
|
1013
|
+
.innerJoin(subjects, eq(subjects.id, batches.subjectId))
|
|
1014
|
+
.where(eq(batches.id, id));
|
|
1015
|
+
if (!o)
|
|
1016
|
+
throw new VireoError(`no batch ${ref}`, 404);
|
|
1017
|
+
const blind = blindFor(viewer, o.batch.state === "open");
|
|
1018
|
+
const rows = await this.db
|
|
1019
|
+
.select()
|
|
1020
|
+
.from(samples)
|
|
1021
|
+
.where(eq(samples.batchId, id))
|
|
1022
|
+
.orderBy(asc(samples.id));
|
|
1023
|
+
const ids = rows.map((r) => r.id);
|
|
1024
|
+
const seen = (x) => !blind || x.by === viewer;
|
|
1025
|
+
const ns = (await this.notesOf(ids)).filter(seen);
|
|
1026
|
+
const cs = (await this.callsOf(ids)).filter(seen);
|
|
1027
|
+
const es = blind ? [] : await this.lineageOf(ids);
|
|
1028
|
+
const [newer] = await this.db
|
|
1029
|
+
.select({ n: batches.n })
|
|
1030
|
+
.from(batches)
|
|
1031
|
+
.where(and(eq(batches.subjectId, o.batch.subjectId), eq(batches.state, "open"), sql `${batches.n} > ${o.batch.n}`))
|
|
1032
|
+
.orderBy(desc(batches.n))
|
|
1033
|
+
.limit(1);
|
|
1034
|
+
return {
|
|
1035
|
+
ref: refOf(o.subject.slug, o.batch.n),
|
|
1036
|
+
...o.batch,
|
|
1037
|
+
why: blind ? null : o.batch.why,
|
|
1038
|
+
subject: o.subject,
|
|
1039
|
+
goodRate: blind ? null : goodRateOf(sheets(rows, cs)),
|
|
1040
|
+
judges: blind ? null : [...new Set(cs.map((c) => c.by))],
|
|
1041
|
+
newer: newer ? { n: newer.n, ref: refOf(o.subject.slug, newer.n) } : null,
|
|
1042
|
+
samples: rows.map((r) => ({
|
|
1043
|
+
...r,
|
|
1044
|
+
name: blind ? null : r.name,
|
|
1045
|
+
change: blind ? null : r.change,
|
|
1046
|
+
...pooled(cs.filter((c) => c.sampleId === r.id)),
|
|
1047
|
+
calls: cs.filter((c) => c.sampleId === r.id),
|
|
1048
|
+
notes: ns.filter((x) => x.sampleId === r.id),
|
|
1049
|
+
parents: es
|
|
1050
|
+
.filter((e) => e.childId === r.id)
|
|
1051
|
+
.map((e) => ({ id: e.parentId, took: e.took })),
|
|
1052
|
+
})),
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
782
1055
|
async catalog(opts = {}) {
|
|
783
1056
|
const subjectId = opts.subject == null ? null : (await this.subjectOf(opts.subject)).id;
|
|
784
1057
|
return this.db
|