Skip to content

Commit a82b8df

Browse files
scriptcodedclaude
andcommitted
feat: add first-attending-day column to roster export
Precompute each person's first attending day (ISO yyyy-mm-dd) at import time so it flows into the CSV/Excel roster export as a metadata column. Attendance logic is extracted from the special-needs step into a shared, framework-agnostic module so the export column and the on-screen day-by-day table derive from a single per-day presence generator. The specialNeeds enricher gains a `variant` option (adult/child) driving the computed firstAttendingDay field; wired up for the staff and staffChildren sources. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 598216e commit a82b8df

6 files changed

Lines changed: 481 additions & 205 deletions

File tree

packages/backend/config/dataSourceConfig.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ dataSources:
5858
specialNeeds:
5959
name: jamboree26:specialNeeds
6060
options:
61+
# Adult (funktionär) attendance model: drives the computed
62+
# firstAttendingDay export column (se plugins/jamboree26/src/specialNeeds/attendance.ts)
63+
variant: adult
6164
questions:
6265
# Vilka hela perioder personen önskar delta i (multiselect: Förläger/Lägerperiod/Efterläger) - se plugins/jamboree26/src/specialNeeds
6366
periodsAttending: "90174"
@@ -129,6 +132,9 @@ dataSources:
129132
specialNeeds:
130133
name: jamboree26:specialNeeds
131134
options:
135+
# Child (medföljande barn) attendance model: drives the computed
136+
# firstAttendingDay export column (se plugins/jamboree26/src/specialNeeds/attendance.ts)
137+
variant: child
132138
questions:
133139
# Attendance: single positive multiselect - vilka dagar barnet
134140
# deltar (inverse of the staff absence model, se variant: child)

plugins/jamboree26/src/enrichers/specialNeeds.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,86 @@ describe("jamboree26:specialNeeds enricher", () => {
280280
expect(result).toEqual({ absenceForlagerDays: [] });
281281
});
282282

283+
it("computes firstAttendingDay (yyyy-mm-dd) for the adult variant from the resolved period/absence labels", () => {
284+
const result = specialNeeds.enrich(
285+
entity(),
286+
ctx(
287+
{
288+
variant: "adult",
289+
questions: {
290+
periodsAttending: "90174",
291+
absenceForlagerDays: "90176",
292+
},
293+
},
294+
{
295+
questions: {
296+
"90174": ["61759"],
297+
"90176": ["61760"],
298+
},
299+
},
300+
{
301+
"90174": { "61759": "Förlägret (före 22 juli)" },
302+
"90176": { "61760": "Lördag 11 juli" },
303+
},
304+
),
305+
);
306+
307+
expect(result).toEqual({
308+
periodsAttending: ["Förlägret (före 22 juli)"],
309+
absenceForlagerDays: ["Lördag 11 juli"],
310+
// 11 juli is absent, so the first attending day is 12 juli.
311+
firstAttendingDay: "2026-07-12",
312+
});
313+
});
314+
315+
it("computes firstAttendingDay for the child variant from the positive attend-list", () => {
316+
const result = specialNeeds.enrich(
317+
entity(),
318+
ctx(
319+
{ variant: "child", questions: { attendanceDays: "91058" } },
320+
{ questions: { "91058": ["61001", "61002"] } },
321+
{
322+
"91058": {
323+
"61001": "Fredag 24 juli",
324+
"61002": "Torsdag 23 juli",
325+
},
326+
},
327+
),
328+
);
329+
330+
expect(result).toEqual({
331+
attendanceDays: ["Fredag 24 juli", "Torsdag 23 juli"],
332+
firstAttendingDay: "2026-07-23",
333+
});
334+
});
335+
336+
it("omits firstAttendingDay when no variant is configured", () => {
337+
const result = specialNeeds.enrich(
338+
entity(),
339+
ctx(
340+
{ questions: { periodsAttending: "90174" } },
341+
{ questions: { "90174": ["61759"] } },
342+
{ "90174": { "61759": "Förlägret (före 22 juli)" } },
343+
),
344+
);
345+
346+
expect(result).toEqual({
347+
periodsAttending: ["Förlägret (före 22 juli)"],
348+
});
349+
});
350+
351+
it("omits firstAttendingDay when a variant is set but the person attends no day", () => {
352+
const result = specialNeeds.enrich(
353+
entity(),
354+
ctx(
355+
{ variant: "adult", questions: { periodsAttending: "90174" } },
356+
{ questions: { "90174": [] } },
357+
),
358+
);
359+
360+
expect(result).toEqual({ periodsAttending: [] });
361+
});
362+
283363
it("regression: an unrelated multiselect answer elsewhere in the participant's form must not null out every configured field (real bug - 93% of a real project's participants hit this)", () => {
284364
const result = specialNeeds.enrich(
285365
entity(),

plugins/jamboree26/src/enrichers/specialNeeds.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,29 @@
11
import type { ImportEnricher } from "@scouterna/scoutin-plugin-api/backend";
22
import { type } from "arktype";
3+
import { firstAttendingDate } from "../specialNeeds/attendance.ts";
34

45
// Static per-event config passed via the data source's `enrichWith` entry
56
// (object form): a flat map of metadata field name -> Scoutnet registration
67
// question ID. Question IDs (and which questions exist at all) are specific
78
// to a project's registration form, so they're configured per event rather
8-
// than hardcoded here. This enricher has no domain knowledge of what the
9-
// fields mean (diet vs medical vs absence, checkbox vs multiselect vs text) -
10-
// it just copies each configured question's raw answer under its field name.
11-
// Grouping/interpreting by field name happens in the jamboree26:specialNeeds
12-
// step, which is the code contract these field names must match.
9+
// than hardcoded here. Beyond the single computed `firstAttendingDay` field
10+
// (below), this enricher has no domain knowledge of what the fields mean (diet
11+
// vs medical vs absence, checkbox vs multiselect vs text) - it just copies each
12+
// configured question's raw answer under its field name. Grouping/interpreting
13+
// by field name happens in the jamboree26:specialNeeds step, which is the code
14+
// contract these field names must match.
15+
//
16+
// `variant` is the one bit of domain config this enricher takes: it selects the
17+
// attendance model (see ../specialNeeds/attendance.ts) used to derive the single
18+
// computed `firstAttendingDay` field (an ISO yyyy-mm-dd string). It mirrors the
19+
// jamboree26:specialNeeds step's own `variant` input - `adult` for the funktionär
20+
// form's period-gate/absence model, `child` for the "medföljande barn" form's
21+
// positive attend-list. Omit it and no firstAttendingDay is written (the raw
22+
// answer fields are still copied as before), so sources with no attendance data
23+
// (e.g. groups) stay unaffected.
1324
const Options = type({
1425
"questions?": type.Record("string", "string"),
26+
"variant?": "'adult' | 'child'",
1527
});
1628

1729
// Structural subset of the raw Scoutnet member record this enricher reads.
@@ -42,6 +54,8 @@ export const specialNeeds: ImportEnricher = {
4254
const options = Options(ctx.options ?? {});
4355
const questionMap =
4456
options instanceof type.errors ? undefined : options.questions;
57+
const variant =
58+
options instanceof type.errors ? undefined : options.variant;
4559

4660
if (!questionMap || Object.keys(questionMap).length === 0) {
4761
// No question IDs configured for this event - nothing to enrich, and
@@ -97,6 +111,20 @@ export const specialNeeds: ImportEnricher = {
97111
result[fieldName] = rawAnswer;
98112
}
99113
}
114+
115+
// Derive the single computed convenience field: the first day this person is
116+
// actually attending, as an ISO yyyy-mm-dd string, for the roster export's
117+
// "first attending day" column. Uses the just-resolved labels in `result`
118+
// (periodsAttending/absence* for adults, attendanceDays for children) so it
119+
// needs no separate question config or provider lookup. Only written when a
120+
// `variant` is configured and a first day can be determined - otherwise the
121+
// key is omitted rather than set to null, keeping it out of the export for
122+
// sources with no attendance data.
123+
if (variant) {
124+
const firstDay = firstAttendingDate(result, variant);
125+
if (firstDay != null) result.firstAttendingDay = firstDay;
126+
}
127+
100128
return result;
101129
},
102130
};
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { describe, expect, it } from "vitest";
2+
import { firstAttendingDate } from "./attendance.ts";
3+
4+
describe("firstAttendingDate (adult)", () => {
5+
it("returns the first day of the earliest selected period when no days are marked absent", () => {
6+
expect(
7+
firstAttendingDate(
8+
{ periodsAttending: ["Förlägret (före 22 juli)"] },
9+
"adult",
10+
),
11+
).toBe("2026-07-11");
12+
});
13+
14+
it("skips days marked absent at the start of an attended period", () => {
15+
expect(
16+
firstAttendingDate(
17+
{
18+
periodsAttending: ["Förlägret (före 22 juli)"],
19+
absenceForlagerDays: ["Lördag 11 juli", "Söndag 12 juli"],
20+
},
21+
"adult",
22+
),
23+
).toBe("2026-07-13");
24+
});
25+
26+
it("uses the raw choice ID as a period matcher when the label wasn't resolved", () => {
27+
expect(firstAttendingDate({ periodsAttending: ["61760"] }, "adult")).toBe(
28+
"2026-07-22",
29+
);
30+
});
31+
32+
it("falls through to the next attended period when an earlier one isn't selected", () => {
33+
expect(
34+
firstAttendingDate(
35+
{ periodsAttending: ["Post-camp (after August 3)"] },
36+
"adult",
37+
),
38+
).toBe("2026-08-03");
39+
});
40+
41+
it("returns an earlier attended period's present day even when a later period is also selected", () => {
42+
expect(
43+
firstAttendingDate(
44+
{
45+
periodsAttending: [
46+
"Förlägret (före 22 juli)",
47+
"Lägerperioden (22 juli - 3 augusti)",
48+
],
49+
absenceForlagerDays: ["Lördag 11 juli"],
50+
},
51+
"adult",
52+
),
53+
).toBe("2026-07-12");
54+
});
55+
56+
it("returns null when no period is selected", () => {
57+
expect(firstAttendingDate({}, "adult")).toBeNull();
58+
expect(
59+
firstAttendingDate({ absenceForlagerDays: ["Lördag 11 juli"] }, "adult"),
60+
).toBeNull();
61+
});
62+
});
63+
64+
describe("firstAttendingDate (child)", () => {
65+
it("returns the earliest explicitly attended day", () => {
66+
expect(
67+
firstAttendingDate(
68+
{ attendanceDays: ["Torsdag 23 juli", "Fredag 24 juli"] },
69+
"child",
70+
),
71+
).toBe("2026-07-23");
72+
});
73+
74+
it("finds the earliest day regardless of answer order", () => {
75+
expect(
76+
firstAttendingDate(
77+
{ attendanceDays: ["Fredag 24 juli", "Torsdag 23 juli"] },
78+
"child",
79+
),
80+
).toBe("2026-07-23");
81+
});
82+
83+
it("ignores unparseable labels (e.g. an untranslated raw choice ID)", () => {
84+
expect(
85+
firstAttendingDate(
86+
{ attendanceDays: ["99999", "Onsdag 5 augusti (inget lägis)"] },
87+
"child",
88+
),
89+
).toBe("2026-08-05");
90+
});
91+
92+
it("returns null for an empty or missing attend-list", () => {
93+
expect(firstAttendingDate({ attendanceDays: [] }, "child")).toBeNull();
94+
expect(firstAttendingDate({}, "child")).toBeNull();
95+
});
96+
});

0 commit comments

Comments
 (0)