Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
295 changes: 168 additions & 127 deletions .claude/skills/import-people/SKILL.md

Large diffs are not rendered by default.

60 changes: 35 additions & 25 deletions .claude/skills/import-people/mapping.md

Large diffs are not rendered by default.

73 changes: 73 additions & 0 deletions .claude/skills/import-people/read_xlsx.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""Read an .xlsx with the Python standard library only, for the import-people
skill. Writes one JSON file, {sheet name: [{"row": n, "cells": {"A": value}}]},
and prints each sheet's row count. Date-formatted numbers become YYYY-MM-DD.

Usage: python3 -I read_xlsx.py <downloaded.xlsx> <out.json>

The JSON holds every cell of the sheet, minors' personal data included: write
it only to the session scratchpad, never into the repo, and delete it when the
import is done."""
import json, re, sys, zipfile, datetime
import xml.etree.ElementTree as ET

NS = {"m": "http://schemas.openxmlformats.org/spreadsheetml/2006/main",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships"}
REL = "{http://schemas.openxmlformats.org/package/2006/relationships}"
DATE_IDS = set(range(14, 23)) | {45, 46, 47}

def text(el):
return "".join(t.text or "" for t in el.iter("{%s}t" % NS["m"]))

def main(src, out):
z = zipfile.ZipFile(src)
shared = []
if "xl/sharedStrings.xml" in z.namelist():
for si in ET.fromstring(z.read("xl/sharedStrings.xml")).findall("m:si", NS):
shared.append(text(si))
date_styles = set()
st = ET.fromstring(z.read("xl/styles.xml"))
custom = {}
for nf in st.findall("m:numFmts/m:numFmt", NS):
custom[int(nf.get("numFmtId"))] = nf.get("formatCode", "")
for i, xf in enumerate(st.findall("m:cellXfs/m:xf", NS)):
fid = int(xf.get("numFmtId", "0"))
code = custom.get(fid, "")
if fid in DATE_IDS or (code and re.search(r"[yd]", re.sub(r'"[^"]*"', "", code), re.I)):
date_styles.add(i)
wb = ET.fromstring(z.read("xl/workbook.xml"))
rels = {r.get("Id"): r.get("Target") for r in ET.fromstring(z.read("xl/_rels/workbook.xml.rels")).iter(REL + "Relationship")}
result = {}
for sh in wb.findall("m:sheets/m:sheet", NS):
target = rels[sh.get("{%s}id" % NS["r"])].lstrip("/")
path = target if target.startswith("xl/") else "xl/" + target
rows = []
for r in ET.fromstring(z.read(path)).iter("{%s}row" % NS["m"]):
cells = {}
for c in r.findall("m:c", NS):
col = re.match(r"[A-Z]+", c.get("r")).group(0)
t, s = c.get("t"), int(c.get("s", "0"))
v = c.find("m:v", NS)
if t == "s" and v is not None:
val = shared[int(v.text)]
elif t == "inlineStr":
val = text(c)
elif v is None:
continue
elif t in ("str", "b", "e"):
val = v.text or ""
elif s in date_styles:
val = (datetime.date(1899, 12, 30) + datetime.timedelta(days=float(v.text))).isoformat()
else:
n = float(v.text)
val = str(int(n)) if n.is_integer() else v.text
if val != "":
cells[col] = val
if cells:
rows.append({"row": int(r.get("r")), "cells": cells})
result[sh.get("name")] = rows
json.dump(result, open(out, "w"), ensure_ascii=False, indent=1)
for name, rows in result.items():
last = rows[-1]["row"] if rows else 0
print(f"{name}: {len(rows)} non-empty rows, last row {last}")

main(sys.argv[1], sys.argv[2])
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -384,7 +384,9 @@ double-counting — both adults in a group DM also deliver the same event twice.
(`rules/screening.ts`). Three items block: Youth Protection Training
(`training_expires_on`, annual), Background Screening (`screening_expires_on`,
3 years — it was 4, FRC2713/hawk-mod#17) and CORI + fingerprints
(`cori_expires_on`, 3 years, state law). Consent & Release, Data Privacy and
(`cori_expires_on`, 3 years, state law, counted in school years: the team enters
1 September after the third, so `latestCoriExpiry` lets a summer check's date
run up to two months past three years without reading as a typo). Consent & Release, Data Privacy and
Mentor Ready are reported and **never block** — gating on them would flag
adults who have done everything the safety rules actually ask. FIRST expires
its annual items on **1 August**, not a year after completion, so any
Expand Down
29 changes: 15 additions & 14 deletions docs/lifecycle-sheet.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,9 @@ The `_Instructions` tab inside the sheet is a living quick-reference for anyone
- Blank = not on file / not started yet.
- The one exception: `Melrose Media Release Expiry` on `Student_Details`, where the literal text `N/A` means the form does not apply to that student (they're not in the Melrose school district).
- FIRST's annual items (Youth Protection Training, Consent & Release, Data Privacy for Mentors) all expire on **August 1**, the FIRST season rollover — not exactly one year after completion. The expiry date is always copied verbatim from what FIRST's system shows; it is never calculated in-sheet.
- Background Screening and CORI expire every **3 years**; that date is likewise always copied from the state/vendor record, never calculated.
- **Dates are never invented or calculated by whoever/whatever is editing the sheet.** Every date comes from FIRST's or the state's records (or the relevant form), entered by a human. New/blank date cells are left blank, not backfilled with a guess.
- Background Screening expires every **3 years**; that date is likewise always copied from the vendor record.
- **CORI counts in school years.** A CORI check covers the school year it was done in (July 1 to June 30) and the next two, and its `CORI Expiry` is **September 1** after the third, about the first day of school. Work it out from the date the check was done: a check in September 2023 or in May 2024 (both school year 2023-24) expires `2026-09-01`; a check over the summer of 2026 counts toward 2026-27 and expires `2029-09-01`. This is the same for mentors (`Mentor_Details`) and contacts (`Emergency_Contacts`).
- **The sheet stores the expiry date, and that is what everything triggers on.** Enter the date a requirement stops being valid, not the date it was done, so that nothing has to work out an expiry later. Every date comes from FIRST's or the state's records (or the relevant form), entered by a human; the CORI rule above is the one way an expiry is worked out, and it is worked out when it is entered. A "yes" or a checkbox is not a date: new/blank date cells are left blank, not backfilled with a guess.
- **Slack User ID columns** (on `Mentor_Details` and `Student_Details`) are normally filled in **by hawk-mod** once the person joins Slack — `/hawkmod lifecycle slack-ids apply` in Slack. hawk-mod only fills a blank cell, and only on an exact match between the Slack account's email and the RHR Email (mentors) or School Email (students). Typing one in by hand is allowed when needed, typically when someone's Slack email is not that address; hawk-mod trusts a typed ID unless it is not a live Slack account or appears on two rows.
- **hawk-mod reads this sheet by exact tab name and column header text.** Column _order_ does not matter, but renaming, adding, or removing a tab or header without first updating hawk-mod will break the sync with an error.

Expand Down Expand Up @@ -115,16 +116,16 @@ A person can have multiple rows (multiple roles). Roles are additive/historical

### `Emergency_Contacts` (PK: `Person ID`, one row per contact — applies to every human, any role)

| Column | Notes |
| ------------------ | -------------------------------------------------------------------------------------------------------- |
| Person ID | Whose contact this is |
| Contact First Name | |
| Contact Last Name | |
| Cell Phone | |
| Email | |
| Relationship | Dropdown (`Reference_Lists!C`): `Parent/Guardian`, `Grandparent`, `Spouse`, `Sibling`, `Friend`, `Other` |
| Rank | Integer; `1` = primary contact (called first). `99` = on file but not to be contacted. |
| CORI Expiry | Date. Any contact, though mostly parents. Copied from the state record, never calculated. |
| Column | Notes |
| ------------------ | ----------------------------------------------------------------------------------------------------------- |
| Person ID | Whose contact this is |
| Contact First Name | |
| Contact Last Name | |
| Cell Phone | |
| Email | |
| Relationship | Dropdown (`Reference_Lists!C`): `Parent/Guardian`, `Grandparent`, `Spouse`, `Sibling`, `Friend`, `Other` |
| Rank | Integer; `1` = primary contact (called first). `99` = on file but not to be contacted. |
| CORI Expiry | Date. Any contact, though mostly parents. September 1 after the third school year of the check (Section 2). |

For a **student**, a `Parent/Guardian` contact whose `Rank` is not `99` is on `grp-parents` while the student is Active. hawk-mod reads only `Person ID`, `Email`, `Relationship`, `Rank` and `CORI Expiry` here — never a contact's name or phone.

Expand All @@ -143,7 +144,7 @@ Current header order:
| Mentor Ready Completed | Date | One-time badge/certification — completion date, not an expiry. Column was renamed from "Mentor Ready Expiry"; old expiry-style values were cleared when it became a completion-date column. |
| YPT Expiry | Date | Youth Protection Training; annual, expires Aug 1. (Column was renamed from "YPP Expiry" — same underlying certification.) |
| Background Screening Expiry | Date | Every 3 years |
| CORI Expiry | Date | Every 3 years |
| CORI Expiry | Date | September 1 after the third school year of the check (Section 2) |
| Data Privacy Expiry | Date | Annual (FIRST's Data Privacy for Mentors); expires Aug 1 |
| RHR Media Release Completed | Date | One-time form — completion date |
| Media Release Scope | Dropdown | `Reference_Lists!F`: `No recognizable image of me`, `Any image as long as I am not named`, `Any image containing me (unrestricted)` |
Expand Down Expand Up @@ -261,7 +262,7 @@ One column per dropdown, values top-to-bottom. This is the **only** place free-t
1. Check `People` first — a returning alum or former student becoming a mentor should get a new _role_ row, not a new `Person ID`.
2. If genuinely new, generate the next `Person ID` and add a `People` row, including `Rookie Year`. Emergency contact info for mentors goes in `Emergency_Contacts`, not on the `People` row.
3. Add a `People_Roles` row: `Role = Mentor`.
4. Agree the mentor's `@redhawkrobotics.org` address with them (not everyone wants `firstname@`), and add a `Mentor_Details` row with it as `RHR Email`. hawk-mod then asks, in the onboarding channel, for a Super Admin to create the Google account at that address; the request closes once the account exists. If the address is not agreed yet, leave `RHR Email` blank and hawk-mod asks for both. Fill in each requirement date as it's completed (blank = not started yet): `YPT Expiry`, `Background Screening Expiry`, `CORI Expiry`, `Consent & Release Expiry`, `Data Privacy Expiry` (each copied from FIRST or the state, never calculated), and `Mentor Ready Completed` (the date the badge was earned). `RHR Media Release Completed` is the date the one-time form was done; set `Media Release Scope` from what they selected on that form. Set `Mentor Lead (Y/N)` and `RA (Y/N)` — both default to `N` unless told otherwise; `RA` is a manual judgment call, not derived from anything else. Leave `Slack User ID` blank — hawk-mod fills it in. The mentor joins Google Groups and is invited to Slack only once their CORI is current; hawk-mod posts the invite request then.
4. Agree the mentor's `@redhawkrobotics.org` address with them (not everyone wants `firstname@`), and add a `Mentor_Details` row with it as `RHR Email`. hawk-mod then asks, in the onboarding channel, for a Super Admin to create the Google account at that address; the request closes once the account exists. If the address is not agreed yet, leave `RHR Email` blank and hawk-mod asks for both. Fill in each requirement date as it's completed (blank = not started yet): `YPT Expiry`, `Background Screening Expiry`, `CORI Expiry`, `Consent & Release Expiry`, `Data Privacy Expiry` (each copied from FIRST or the state, except `CORI Expiry`, which follows the school-year rule in Section 2), and `Mentor Ready Completed` (the date the badge was earned). `RHR Media Release Completed` is the date the one-time form was done; set `Media Release Scope` from what they selected on that form. Set `Mentor Lead (Y/N)` and `RA (Y/N)` — both default to `N` unless told otherwise; `RA` is a manual judgment call, not derived from anything else. Leave `Slack User ID` blank — hawk-mod fills it in. The mentor joins Google Groups and is invited to Slack only once their CORI is current; hawk-mod posts the invite request then.
5. Only add a `Mentor_Admin_Roles` row if they're granted a delegated admin role — most mentors don't get one.

### New volunteer
Expand Down
33 changes: 26 additions & 7 deletions src/domain/rules/screening.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,31 @@ export const SCREENING_VALID_YEARS = 3;
/**
* CORI and national fingerprint checks every three years for volunteers with
* direct and unmonitored contact with students — M.G.L. c. 71 §38R and
* 603 CMR 51.00. State law, independent of anything FIRST asks for.
* 603 CMR 51.00. State law, independent of anything FIRST asks for. The team
* counts them in school years: a check covers the school year it was done in
* (July to June) and the next two, and its expiry is entered as 1 September
* after the third, about the first day of school (lifecycle-sheet.md §2).
*/
export const CORI_VALID_YEARS = 3;

/** When a CORI check's coverage ends: about the first day of school. */
export const CORI_EXPIRES = "09-01";

/**
* The latest CORI expiry that can be real on `asOf`: three years out, or —
* for a check done over the summer, which counts toward the coming school
* year — the 1 September at the end of that year's third school year, up to
* two months further. Never earlier than three years, so nothing that was
* current before this rule reads as a typo now.
*/
export function latestCoriExpiry(asOf: IsoDate): IsoDate {
const year = Number(asOf.slice(0, 4));
const schoolYear = asOf.slice(5) >= "07-01" ? year : year - 1;
const sept = `${schoolYear + CORI_VALID_YEARS}-${CORI_EXPIRES}`;
const plain = addYears(asOf, CORI_VALID_YEARS);
return sept > plain ? sept : plain;
}

/** FIRST's season rollover, when its annual items expire. */
export const ROLLOVER = "08-01";

Expand Down Expand Up @@ -79,7 +100,7 @@ function blocking(p: RequirementDates): Requirement[] {
{
item: "CORI + fingerprints",
expiresOn: p.cori_expires_on,
latest: (asOf) => addYears(asOf, CORI_VALID_YEARS),
latest: latestCoriExpiry,
},
];
}
Expand Down Expand Up @@ -196,9 +217,7 @@ export function mayHaveAccess(
asOf: IsoDate
): boolean {
const e = p.cori_expires_on;
return (
e !== null && notExpired(e, asOf) && e <= addYears(asOf, CORI_VALID_YEARS)
);
return e !== null && notExpired(e, asOf) && e <= latestCoriExpiry(asOf);
}

/**
Expand All @@ -221,8 +240,8 @@ export function screeningEntryErrors(
],
[
"cori_expires_on",
`${CORI_VALID_YEARS} years`,
addYears(asOf, CORI_VALID_YEARS),
`${CORI_VALID_YEARS} school years`,
latestCoriExpiry(asOf),
],
["consent_release_expires_on", "a year", latestAnnualExpiry(asOf)],
["data_privacy_expires_on", "a year", latestAnnualExpiry(asOf)],
Expand Down
37 changes: 37 additions & 0 deletions test/rules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { consentStatus, mayHoldAccount } from "../src/domain/rules/consent.js";
import {
isScreenedAdult,
latestAnnualExpiry,
latestCoriExpiry,
mayHaveAccess,
screeningEntryErrors,
screeningStatus,
} from "../src/domain/rules/screening.js";
Expand Down Expand Up @@ -160,6 +162,30 @@ describe("screening", () => {
assert.equal(screeningStatus(p, AS_OF).current, false);
});

/**
* CORI covers the school year of the check (July to June) and the next two,
* and expires on 1 September after the third. A check done over the summer
* counts toward the coming year, so its expiry can be up to two months past
* three years out — and must not read as a typo.
*/
it("allows a summer CORI check's 1 September expiry", () => {
assert.equal(latestCoriExpiry("2026-07-01"), "2029-09-01");
assert.equal(latestCoriExpiry("2026-08-19"), "2029-09-01");
assert.equal(latestCoriExpiry("2026-09-26"), "2029-09-26");
assert.equal(latestCoriExpiry("2027-06-30"), "2030-06-30");
assert.equal(
mayHaveAccess({ cori_expires_on: "2029-09-01" }, "2026-07-15"),
true
);
assert.equal(
mayHaveAccess({ cori_expires_on: "2029-09-02" }, "2026-07-15"),
false
);
const p = screened();
p.cori_expires_on = "2029-09-01";
assert.equal(screeningStatus(p, "2026-07-15").current, true);
});

it("finds the rollover on the right side of 1 August", () => {
assert.equal(latestAnnualExpiry("2026-07-31"), "2027-08-01");
assert.equal(latestAnnualExpiry("2026-08-01"), "2028-08-01");
Expand Down Expand Up @@ -218,6 +244,17 @@ describe("recording screening dates", () => {
]);
});

it("accepts the 1 September expiry of a CORI check done over the summer", () => {
assert.deepEqual(
screeningEntryErrors({ cori_expires_on: "2029-09-01" }, "2026-07-15"),
{}
);
assert.ok(
screeningEntryErrors({ cori_expires_on: "2029-09-02" }, "2026-07-15")
.cori_expires_on
);
});

it("refuses a Mentor Ready completion in the future", () => {
assert.ok(
screeningEntryErrors({ mentor_ready_completed_on: "2026-09-27" }, AS_OF)
Expand Down
Loading