Prompt families to refresh photos and facts that have aged (#7)

This commit is contained in:
Ian Gulliver
2026-08-23 16:59:45 -07:00
parent c38084a7a4
commit e747510e11
11 changed files with 435 additions and 42 deletions
+6 -4
View File
@@ -60,7 +60,7 @@ The explosion turns each import row into one student record plus up to four adul
Canonical model columns, keyed by lowercased email, one row per person. Three kinds of content share the tab, distinguished only by authorship and one flag:
- **Corrections** (hand): fix anything the import gets wrong — swapped name fields, bad phone numbers — and carry person flags with no import source, like room-parent assignments and the new-to-Helios marker.
- **Self-service text** (app): facts, pronouns, preferred name, phone, address — the latter two hideable via the `-` clear — and the Opted Out flag. Every self-service edit warns that it doesn't affect the values shown in Veracross. The app writes these cells directly; moderating a contribution is the same act as any other correction. Photo and pronunciation uploads go straight to the media bucket and never touch the sheet.
- **Self-service text** (app): facts, pronouns, preferred name, phone, address — the latter two hideable via the `-` clear — and the Opted Out flag. Every self-service edit warns that it doesn't affect the values shown in Veracross. The app writes these cells directly; moderating a contribution is the same act as any other correction. A photo upload writes the object to the media bucket and stamps the matching updated date below; a pronunciation upload touches the bucket alone.
- **Additions** (hand, flagged): people with no import row at all. The flag inverts the source expectation.
Cell semantics are sparse: an empty cell contributes nothing, `-` clears the underlying value. An addition is just an override applied to an empty base record, so the merge logic is uniform; the flag selects the validation instead:
@@ -74,6 +74,8 @@ Flagged rows must supply every field the model requires; unflagged rows can be a
Family-level fields (address, family photo caption, family phone) ride on a parent's row and apply to that parent's household, so a two-household student's families are addressed independently through their respective adults.
Three columns carry refresh dates in ISO form, and any other value is fatal: `Photo Updated` and `Facts Updated` on the person's row, `Family Photo Updated` riding on a parent's row like the caption. The app stamps them on every facts edit and photo upload, and the directory reads them to prompt families whose content has aged (see `docs/directory.md`). They are seeded from the legacy Glide spreadsheet, which recorded the same dates for the years this app's history does not cover; `tools/importdates` performs that seeding, skipping people the directory no longer holds and collapsing a family's several parent rows to one date per household.
**Opted Out** removes the person entirely at load: their record, their membership in families and parent-contact lists, and any room-parent assignment all vanish from the model. Because viewing the directory requires being in it, opting out also locks the person out — they get a permissions error until the school clears the flag. People set it from their own page, parents set it for their kids (each with a confirmation spelling out the consequences), or an admin sets the cell by hand.
Every change to Overrides appends a Change Log row: timestamp, actor, the row's email, then the previous value of each column that changed — `-` marking a previously empty cell, untouched columns left blank. Media uploads are not logged here; bucket object versions are their history.
@@ -100,7 +102,7 @@ Student phone numbers are never shown, whatever any sheet says. The Veracross im
## Media blobs
Photos and pronunciation recordings are objects in the media bucket `gs://heliosian-media`, under a `people/` or `families/` prefix and named by convention: `<email local part>-photo` and `<email local part>-pronunciation` for people, `<family key hash>-photo` and `<family key hash>-pronunciation` for families, each keeping its source extension. Presence means existence — no sheet cell records a filename — and freshness comes from the object generation.
Photos and pronunciation recordings are objects in the media bucket `gs://heliosian-media`, under a `people/` or `families/` prefix and named by convention: `<email local part>-photo` and `<email local part>-pronunciation` for people, `<family key hash>-photo` and `<family key hash>-pronunciation` for families, each keeping its source extension. Presence means existence — no sheet cell records a filename. Freshness cannot come from the object generation, which the move into the bucket reset for every object at once; it comes from the updated-date columns in Overrides.
Every image is stored with a `-thumb.jpg` sibling written at the same time, so startup loads thumbnails instead of computing them; an image object with no stored thumbnail is fatal. Uploads overwrite the object and its thumbnail, and bucket versioning retains the superseded generations. An upload arriving in a different format also deletes the previous extension's object, so a key never resolves to two files.
@@ -161,11 +163,11 @@ The loader hard-fails — no fallbacks, server refuses to start — on:
- an unflagged Overrides row matching no person (orphaned override)
- a flagged Overrides row colliding with an imported person
- conflicting values for the same adult across import rows
- an invalid canonical value from any layer
- an invalid canonical value from any layer, including an Overrides refresh date that is not an ISO date
- a Preferences column the loader does not know, or a Preferences row with a malformed email, an unparseable timestamp, an unrecognized opt-in sentence, or an unrecognized permission item
The import procedure is manual today: `tools/writetab` writes the Veracross CSV export into the Veracross Import tab (header-checked), and `tools/loadcheck` re-runs the full pipeline against both sheets and prints a model summary, including the opt-in/opt-out/default split and the masked counts. A single import tool that also reports the local layer's health beyond the fatal checks — useless overrides (value identical to what the record has anyway), `-` on flagged rows, name mappings or additions that Veracross has since made redundant, and media files whose name matches no current person or family, including family blobs orphaned by a membership change — is planned (`docs/plan.md`). `tools/findsheet` lists the spreadsheets visible to the service account; `tools/sheets` dumps a sheet's tabs, headers, and rows.
## Sourcing
Records are imported from the school's systems and enriched by families themselves (photos, facts, pronunciation recordings, address preferences), with freshness read from media file timestamps and the change log so refresh cadence can be enforced.
Records are imported from the school's systems and enriched by families themselves (photos, facts, pronunciation recordings, address preferences), with freshness read from the recorded refresh dates so refresh cadence can be enforced.
+1
View File
@@ -54,5 +54,6 @@ Each runs as `go run ./tools/<name>`. The sheet, drive, and bucket tools authent
- `sheets` — dump a sheet's tabs, sizes, and header rows
- `dumptab` / `writetab` — copy one tab to a local CSV / write a local CSV into a tab, header-checked
- `createtabs` — create the directory sheet's local-layer tabs with their header rows
- `importdates` — seed the Overrides refresh-date columns from the legacy Glide spreadsheet, adding the columns if absent
- `setcell` — set one cell in a tab by key column, appending the row if missing
- `splash` — regenerate the iOS splash battery from the captured original page; see `docs/pwa.md`
+2 -1
View File
@@ -62,4 +62,5 @@ A copyable contact table for party planning and outreach: full name, email, role
- Favorites/bookmarks mark people and feed the email list's bookmark tab.
- Photos lazy-load; full-size view on click where the photo is the subject (family pages).
- All data is community-only, behind sign-in; opt-out removes a person on request.
- Self-service: viewing your own record, your kids', or your family page shows inline edit affordances — photo upload, pronunciation recording or upload, About Me text, preferred name, phone, address — plus opt-out for yourself or your kids. Media uploads overwrite the bucket object, which retains the previous version; sheet-backed edits write the Overrides tab and append a Change Log row (see `docs/data.md`).
- Self-service: viewing your own record, your kids', or your family page shows inline edit affordances — photo upload, pronunciation recording or upload, About Me text, preferred name, phone, address — plus opt-out for yourself or your kids. Media uploads overwrite the bucket object, which retains the previous version; sheet-backed edits write the Overrides tab and append a Change Log row (see `docs/data.md`). A facts edit or photo upload also stamps that item's updated date.
- Refresh reminders: every page carries a band listing the signed-in user's own, their kids', and their family's content that has aged past its window — a photo or About Me past 1.25 years, a family photo past 2. Each row links to that record's edit view, and an Update Family Info action leads to the family page. Only aged content is prompted: a record with no photo or no About Me raises nothing, and an item whose updated date is unknown counts as aged.
+36 -3
View File
@@ -72,6 +72,19 @@ var overrideColumns = []string{
"Is Student", "Is Parent", "Is Staff", "New to Helios", "Pronouns", "Facts",
"Grade", "Classroom", "Crew", "Phone", "Job Title", "Department", "Grade Band", "Room Parent",
"Address", "Family Phone", "Family Photo Caption", "Opted Out",
"Photo Updated", "Facts Updated", "Family Photo Updated",
}
const updatedFormat = "2006-01-02"
func checkUpdated(email, column, cell string) error {
if cell == "" || cell == "-" {
return nil
}
if _, err := time.Parse(updatedFormat, cell); err != nil {
return fmt.Errorf("overrides row %s has invalid %s %q", email, column, cell)
}
return nil
}
type BlobChecker interface {
@@ -115,8 +128,8 @@ type household struct {
}
type familyCells struct {
address, phone, caption string
hasAddress, hasPhone, hasCaption bool
address, phone, caption, photoUpdated string
hasAddress, hasPhone, hasCaption, hasPhotoUpdated bool
}
func exactColumns(table string, header, wanted []string) error {
@@ -447,6 +460,14 @@ func (l *loader) applyOverrides() error {
}
apply(row["Pronouns"], &p.Pronouns)
apply(row["Facts"], &p.Facts)
if err := checkUpdated(email, "Facts Updated", row["Facts Updated"]); err != nil {
return err
}
apply(row["Facts Updated"], &p.FactsUpdated)
if err := checkUpdated(email, "Photo Updated", row["Photo Updated"]); err != nil {
return err
}
apply(row["Photo Updated"], &p.PhotoUpdated)
if cell := row["Grade"]; cell != "" && cell != "-" && !added && gradeBands[cell] == "" {
return fmt.Errorf("overrides row %s has unknown grade %q", email, cell)
}
@@ -486,7 +507,16 @@ func (l *loader) applyOverrides() error {
cells.caption = cell
}
}
if cells.hasAddress || cells.hasPhone || cells.hasCaption {
if cell := row["Family Photo Updated"]; cell != "" {
if err := checkUpdated(email, "Family Photo Updated", cell); err != nil {
return err
}
cells.hasPhotoUpdated = true
if cell != "-" {
cells.photoUpdated = cell
}
}
if cells.hasAddress || cells.hasPhone || cells.hasCaption || cells.hasPhotoUpdated {
l.familyOverrides[email] = cells
}
@@ -558,6 +588,9 @@ func (l *loader) buildFamilies() error {
if cells.hasCaption {
family.PhotoCaption = cells.caption
}
if cells.hasPhotoUpdated {
family.PhotoUpdated = cells.photoUpdated
}
l.model.Families[key] = family
}
return nil
+3
View File
@@ -19,8 +19,10 @@ type Person struct {
IsNew bool `json:"isNew,omitempty"`
Pronouns string `json:"pronouns,omitempty"`
Facts string `json:"facts,omitempty"`
FactsUpdated string `json:"factsUpdated,omitempty"`
PronunciationURL string `json:"pronunciationUrl,omitempty"`
PhotoURL string `json:"photoUrl,omitempty"`
PhotoUpdated string `json:"photoUpdated,omitempty"`
Grade string `json:"grade,omitempty"`
Classroom string `json:"classroom,omitempty"`
Crew string `json:"crew,omitempty"`
@@ -45,6 +47,7 @@ type Family struct {
Lng float64 `json:"lng,omitempty"`
PhotoURL string `json:"photoUrl,omitempty"`
PhotoCaption string `json:"photoCaption,omitempty"`
PhotoUpdated string `json:"photoUpdated,omitempty"`
PronunciationURL string `json:"pronunciationUrl,omitempty"`
AdultEmails []string `json:"adultEmails,omitempty"`
KidEmails []string `json:"kidEmails,omitempty"`
+19 -4
View File
@@ -66,6 +66,10 @@ func RegisterUpload(mux *http.ServeMux, cache *Cache, sheet *data.Sheet, store *
mux.HandleFunc("POST /api/directory/edit", u.edit)
}
func today() string {
return time.Now().UTC().Format(updatedFormat)
}
func clearable(value string) string {
if value == "" {
return "-"
@@ -197,11 +201,13 @@ func (u uploader) facts(w http.ResponseWriter, r *http.Request) {
http.Error(w, "not allowed to edit this record", http.StatusForbidden)
return
}
old := ""
old, oldUpdated := "", ""
if p := model.Person(key); p != nil {
old = p.Facts
old, oldUpdated = p.Facts, p.FactsUpdated
}
if !u.applyOverride(w, me, key, "facts update", map[string]string{"Facts": facts}, map[string]string{"Facts": old}) {
cells := map[string]string{"Facts": facts, "Facts Updated": today()}
previous := map[string]string{"Facts": old, "Facts Updated": oldUpdated}
if !u.applyOverride(w, me, key, "facts update", cells, previous) {
return
}
log.Printf("facts: %s set %s (%d chars)", me, key, len(facts))
@@ -260,7 +266,16 @@ func (u uploader) upload(w http.ResponseWriter, r *http.Request) {
serverError(w, err)
return
}
if err := u.cache.Refresh(); err != nil {
if kind == "photo" {
row, column, previous := key, "Photo Updated", model.Person(key).PhotoUpdated
if target == "family" {
row, column, previous = strings.ToLower(me), "Family Photo Updated", model.Families[key].PhotoUpdated
}
if !u.applyOverride(w, me, row, "photo upload",
map[string]string{column: today()}, map[string]string{column: previous}) {
return
}
} else if err := u.cache.Refresh(); err != nil {
serverError(w, fmt.Errorf("refresh model after upload: %w", err))
return
}
+1 -1
View File
@@ -1 +1 @@
Timestamp,Actor,Email,Added,Full Name,Legal Name,Preferred Name,Is Student,Is Parent,Is Staff,New to Helios,Pronouns,Facts,Grade,Classroom,Crew,Phone,Job Title,Department,Grade Band,Room Parent,Address,Family Phone,Family Photo Caption,Opted Out
Timestamp,Actor,Email,Added,Full Name,Legal Name,Preferred Name,Is Student,Is Parent,Is Staff,New to Helios,Pronouns,Facts,Grade,Classroom,Crew,Phone,Job Title,Department,Grade Band,Room Parent,Address,Family Phone,Family Photo Caption,Opted Out,Photo Updated,Facts Updated,Family Photo Updated
1 Timestamp Actor Email Added Full Name Legal Name Preferred Name Is Student Is Parent Is Staff New to Helios Pronouns Facts Grade Classroom Crew Phone Job Title Department Grade Band Room Parent Address Family Phone Family Photo Caption Opted Out Photo Updated Facts Updated Family Photo Updated
+31 -29
View File
@@ -1,29 +1,31 @@
Email,Added,Full Name,Legal Name,Preferred Name,Is Student,Is Parent,Is Staff,New to Helios,Pronouns,Facts,Grade,Classroom,Crew,Phone,Job Title,Department,Grade Band,Room Parent,Address,Family Phone,Family Photo Caption,Opted Out
dana.hawkins@heliosschool.org,,,,,,,TRUE,,she/her,,,,,,Art Teacher,Co-Curriculars and Specialists,,,,,,
jordan.whitfield@heliosschool.org,,,,,,,,,they/them,,,,,,,,,Jayvens,,,,
asha.chandra@heliosschool.org,,,,,,,,,,,,,,,,,,Cospreys,,,,
harper.quinn@heliosschool.org,,,,,,,,,she/her,"Plays goalie in the coastside soccer league, and is building a solar-powered go-kart.",,,,,,,,,,,,
owen.park@heliosschool.org,,,,,,,,,,Can name every bird on the bluff trail. Future marine biologist.,,,,,,,,,,,,
felix.osei@heliosschool.org,,,,,,,,,he/him,,,,,,,,,,,,,
noor.haddad@heliosschool.org,,,,,,,,TRUE,,,,,,,,,,,,,,
layla.haddad@heliosschool.org,,,,,,,,TRUE,,,,,,,,,,,,,,
colin.quinn@heliosschool.org,,,,,,,,,,,,,,-,,,,,,,,
elena.torres@heliosschool.org,,,,,,,,,,,,,,,,,,,,,"Marco, Mia, and Elena at Mavericks Beach.",
deepa.natarajan@heliosschool.org,,,,,,,,,,,,,,,,,,,,650-555-0199,,
grace.kim@heliosschool.org,TRUE,Grace Kim,,,,,TRUE,,she/her,,,,,,Head of School,Admin and Office Staff,,,,,,
bill.ryder@heliosschool.org,TRUE,Bill Ryder,,,,,TRUE,,,,,,,,Office Manager,Admin and Office Staff,,,,,,
ruth.amari@heliosschool.org,TRUE,Ruth Amari,,,,,TRUE,,,"Twelve years teaching kindergarten, keeper of the class worm farm.",,Hummingbirds,,,Kindergarten Teacher,Classroom Teachers,Hummingbirds,,,,,
miguel.santos@heliosschool.org,TRUE,Miguel Santos,,,,,TRUE,,,,,Hawks,,,1st Grade Teacher,Classroom Teachers,Halcons,,,,,
alice.fontaine@heliosschool.org,TRUE,Alice Fontaine,,,,,TRUE,,,,,Falcons,,,2nd Grade Teacher,Classroom Teachers,Halcons,,,,,
peter.okafor@heliosschool.org,TRUE,Peter Okafor,,,,,TRUE,,,,,Jays,,,3rd Grade Teacher,Classroom Teachers,Jayvens,,,,,
susan.byrne@heliosschool.org,TRUE,Susan Byrne,,,,,TRUE,,,,,Ravens,,,4th Grade Teacher,Classroom Teachers,Jayvens,,,,,
hana.ito@heliosschool.org,TRUE,Hana Ito,,,,,TRUE,,,,,Condors,Pinnacles,,5th/6th Grade Teacher,Classroom Teachers,Cospreys,,,,,
marcus.bell@heliosschool.org,TRUE,Marcus Bell,,,,,TRUE,,,,,Condors,Big Sur,,5th/6th Grade Teacher,Classroom Teachers,Cospreys,,,,,
lena.vogel@heliosschool.org,TRUE,Lena Vogel,,,,,TRUE,,,,,Ospreys,River,,5th/6th Grade Teacher,Classroom Teachers,Cospreys,,,,,
tom.grady@heliosschool.org,TRUE,Tom Grady,,,,,TRUE,,,,,Ospreys,Sea,,5th/6th Grade Teacher,Classroom Teachers,Cospreys,,,,,
ivy.chen@heliosschool.org,TRUE,Ivy Chen,,,,,TRUE,,she/her,,,Egrets,Snowy,,Humanities Teacher,Classroom Teachers,Hegrets,,,,,
raj.malhotra@heliosschool.org,TRUE,Raj Malhotra,,,,,TRUE,,,,,Egrets,Great,,Science Teacher,Classroom Teachers,Hegrets,,,,,
kate.doyle@heliosschool.org,TRUE,Kate Doyle,,,,,TRUE,,,,,Herons,Great Blue,,Humanities Teacher,Classroom Teachers,Hegrets,,,,,
omar.farouk@heliosschool.org,TRUE,Omar Farouk,,,,,TRUE,,he/him,,,Herons,Green,,Science Teacher,Classroom Teachers,Hegrets,,,,,
noa.adler@heliosschool.org,TRUE,Noa Adler,,,,,TRUE,,,,,,,,Music Teacher,Co-Curriculars and Specialists,,,,,,
hank.morrow@heliosschool.org,TRUE,Hank Morrow,,,,,TRUE,,,,,,,,Facilities Manager,Facilities Staff,,,,,,
Email,Added,Full Name,Legal Name,Preferred Name,Is Student,Is Parent,Is Staff,New to Helios,Pronouns,Facts,Grade,Classroom,Crew,Phone,Job Title,Department,Grade Band,Room Parent,Address,Family Phone,Family Photo Caption,Opted Out,Photo Updated,Facts Updated,Family Photo Updated
dana.hawkins@heliosschool.org,,,,,,,TRUE,,she/her,,,,,,Art Teacher,Co-Curriculars and Specialists,,,,,,,,,
jordan.whitfield@heliosschool.org,,,,,,,,,they/them,,,,,,,,,Jayvens,,,,,,,2024-03-15
asha.chandra@heliosschool.org,,,,,,,,,,,,,,,,,,Cospreys,,,,,,,
harper.quinn@heliosschool.org,,,,,,,,,she/her,"Plays goalie in the coastside soccer league, and is building a solar-powered go-kart.",,,,,,,,,,,,,,2026-05-04,
owen.park@heliosschool.org,,,,,,,,,,Can name every bird on the bluff trail. Future marine biologist.,,,,,,,,,,,,,,2024-09-12,
felix.osei@heliosschool.org,,,,,,,,,he/him,,,,,,,,,,,,,,,,
noor.haddad@heliosschool.org,,,,,,,,TRUE,,,,,,,,,,,,,,,,,
layla.haddad@heliosschool.org,,,,,,,,TRUE,,,,,,,,,,,,,,,,,
colin.quinn@heliosschool.org,,,,,,,,,,,,,,-,,,,,,,,,,,
elena.torres@heliosschool.org,,,,,,,,,,,,,,,,,,,,,"Marco, Mia, and Elena at Mavericks Beach.",,,,
deepa.natarajan@heliosschool.org,,,,,,,,,,,,,,,,,,,,650-555-0199,,,,,
ella.whitfield@heliosschool.org,,,,,,,,,,"Building a tide-pool guide for the Fitzgerald reserve, and learning bass guitar.",,,,,,,,,,,,,,2024-06-02,
sam.whitfield@heliosschool.org,,,,,,,,,,Skateboards at the Half Moon Bay park and is learning to make ramen from scratch.,,,,,,,,,,,,,,2026-08-01,
grace.kim@heliosschool.org,TRUE,Grace Kim,,,,,TRUE,,she/her,,,,,,Head of School,Admin and Office Staff,,,,,,,,,
bill.ryder@heliosschool.org,TRUE,Bill Ryder,,,,,TRUE,,,,,,,,Office Manager,Admin and Office Staff,,,,,,,,,
ruth.amari@heliosschool.org,TRUE,Ruth Amari,,,,,TRUE,,,"Twelve years teaching kindergarten, keeper of the class worm farm.",,Hummingbirds,,,Kindergarten Teacher,Classroom Teachers,Hummingbirds,,,,,,,,
miguel.santos@heliosschool.org,TRUE,Miguel Santos,,,,,TRUE,,,,,Hawks,,,1st Grade Teacher,Classroom Teachers,Halcons,,,,,,,,
alice.fontaine@heliosschool.org,TRUE,Alice Fontaine,,,,,TRUE,,,,,Falcons,,,2nd Grade Teacher,Classroom Teachers,Halcons,,,,,,,,
peter.okafor@heliosschool.org,TRUE,Peter Okafor,,,,,TRUE,,,,,Jays,,,3rd Grade Teacher,Classroom Teachers,Jayvens,,,,,,,,
susan.byrne@heliosschool.org,TRUE,Susan Byrne,,,,,TRUE,,,,,Ravens,,,4th Grade Teacher,Classroom Teachers,Jayvens,,,,,,,,
hana.ito@heliosschool.org,TRUE,Hana Ito,,,,,TRUE,,,,,Condors,Pinnacles,,5th/6th Grade Teacher,Classroom Teachers,Cospreys,,,,,,,,
marcus.bell@heliosschool.org,TRUE,Marcus Bell,,,,,TRUE,,,,,Condors,Big Sur,,5th/6th Grade Teacher,Classroom Teachers,Cospreys,,,,,,,,
lena.vogel@heliosschool.org,TRUE,Lena Vogel,,,,,TRUE,,,,,Ospreys,River,,5th/6th Grade Teacher,Classroom Teachers,Cospreys,,,,,,,,
tom.grady@heliosschool.org,TRUE,Tom Grady,,,,,TRUE,,,,,Ospreys,Sea,,5th/6th Grade Teacher,Classroom Teachers,Cospreys,,,,,,,,
ivy.chen@heliosschool.org,TRUE,Ivy Chen,,,,,TRUE,,she/her,,,Egrets,Snowy,,Humanities Teacher,Classroom Teachers,Hegrets,,,,,,,,
raj.malhotra@heliosschool.org,TRUE,Raj Malhotra,,,,,TRUE,,,,,Egrets,Great,,Science Teacher,Classroom Teachers,Hegrets,,,,,,,,
kate.doyle@heliosschool.org,TRUE,Kate Doyle,,,,,TRUE,,,,,Herons,Great Blue,,Humanities Teacher,Classroom Teachers,Hegrets,,,,,,,,
omar.farouk@heliosschool.org,TRUE,Omar Farouk,,,,,TRUE,,he/him,,,Herons,Green,,Science Teacher,Classroom Teachers,Hegrets,,,,,,,,
noa.adler@heliosschool.org,TRUE,Noa Adler,,,,,TRUE,,,,,,,,Music Teacher,Co-Curriculars and Specialists,,,,,,,,,
hank.morrow@heliosschool.org,TRUE,Hank Morrow,,,,,TRUE,,,,,,,,Facilities Manager,Facilities Staff,,,,,,,,,
1 Email Added Full Name Legal Name Preferred Name Is Student Is Parent Is Staff New to Helios Pronouns Facts Grade Classroom Crew Phone Job Title Department Grade Band Room Parent Address Family Phone Family Photo Caption Opted Out Photo Updated Facts Updated Family Photo Updated
2 dana.hawkins@heliosschool.org TRUE she/her Art Teacher Co-Curriculars and Specialists
3 jordan.whitfield@heliosschool.org they/them Jayvens 2024-03-15
4 asha.chandra@heliosschool.org Cospreys
5 harper.quinn@heliosschool.org she/her Plays goalie in the coastside soccer league, and is building a solar-powered go-kart. 2026-05-04
6 owen.park@heliosschool.org Can name every bird on the bluff trail. Future marine biologist. 2024-09-12
7 felix.osei@heliosschool.org he/him
8 noor.haddad@heliosschool.org TRUE
9 layla.haddad@heliosschool.org TRUE
10 colin.quinn@heliosschool.org -
11 elena.torres@heliosschool.org Marco, Mia, and Elena at Mavericks Beach.
12 deepa.natarajan@heliosschool.org 650-555-0199
13 grace.kim@heliosschool.org ella.whitfield@heliosschool.org TRUE Grace Kim TRUE she/her Building a tide-pool guide for the Fitzgerald reserve, and learning bass guitar. Head of School Admin and Office Staff 2024-06-02
14 bill.ryder@heliosschool.org sam.whitfield@heliosschool.org TRUE Bill Ryder TRUE Skateboards at the Half Moon Bay park and is learning to make ramen from scratch. Office Manager Admin and Office Staff 2026-08-01
15 ruth.amari@heliosschool.org grace.kim@heliosschool.org TRUE Ruth Amari Grace Kim TRUE she/her Twelve years teaching kindergarten, keeper of the class worm farm. Hummingbirds Kindergarten Teacher Head of School Classroom Teachers Admin and Office Staff Hummingbirds
16 miguel.santos@heliosschool.org bill.ryder@heliosschool.org TRUE Miguel Santos Bill Ryder TRUE Hawks 1st Grade Teacher Office Manager Classroom Teachers Admin and Office Staff Halcons
17 alice.fontaine@heliosschool.org ruth.amari@heliosschool.org TRUE Alice Fontaine Ruth Amari TRUE Twelve years teaching kindergarten, keeper of the class worm farm. Falcons Hummingbirds 2nd Grade Teacher Kindergarten Teacher Classroom Teachers Halcons Hummingbirds
18 peter.okafor@heliosschool.org miguel.santos@heliosschool.org TRUE Peter Okafor Miguel Santos TRUE Jays Hawks 3rd Grade Teacher 1st Grade Teacher Classroom Teachers Jayvens Halcons
19 susan.byrne@heliosschool.org alice.fontaine@heliosschool.org TRUE Susan Byrne Alice Fontaine TRUE Ravens Falcons 4th Grade Teacher 2nd Grade Teacher Classroom Teachers Jayvens Halcons
20 hana.ito@heliosschool.org peter.okafor@heliosschool.org TRUE Hana Ito Peter Okafor TRUE Condors Jays Pinnacles 5th/6th Grade Teacher 3rd Grade Teacher Classroom Teachers Cospreys Jayvens
21 marcus.bell@heliosschool.org susan.byrne@heliosschool.org TRUE Marcus Bell Susan Byrne TRUE Condors Ravens Big Sur 5th/6th Grade Teacher 4th Grade Teacher Classroom Teachers Cospreys Jayvens
22 lena.vogel@heliosschool.org hana.ito@heliosschool.org TRUE Lena Vogel Hana Ito TRUE Ospreys Condors River Pinnacles 5th/6th Grade Teacher Classroom Teachers Cospreys
23 tom.grady@heliosschool.org marcus.bell@heliosschool.org TRUE Tom Grady Marcus Bell TRUE Ospreys Condors Sea Big Sur 5th/6th Grade Teacher Classroom Teachers Cospreys
24 ivy.chen@heliosschool.org lena.vogel@heliosschool.org TRUE Ivy Chen Lena Vogel TRUE she/her Egrets Ospreys Snowy River Humanities Teacher 5th/6th Grade Teacher Classroom Teachers Hegrets Cospreys
25 raj.malhotra@heliosschool.org tom.grady@heliosschool.org TRUE Raj Malhotra Tom Grady TRUE Egrets Ospreys Great Sea Science Teacher 5th/6th Grade Teacher Classroom Teachers Hegrets Cospreys
26 kate.doyle@heliosschool.org ivy.chen@heliosschool.org TRUE Kate Doyle Ivy Chen TRUE she/her Herons Egrets Great Blue Snowy Humanities Teacher Classroom Teachers Hegrets
27 omar.farouk@heliosschool.org raj.malhotra@heliosschool.org TRUE Omar Farouk Raj Malhotra TRUE he/him Herons Egrets Green Great Science Teacher Classroom Teachers Hegrets
28 noa.adler@heliosschool.org kate.doyle@heliosschool.org TRUE Noa Adler Kate Doyle TRUE Herons Great Blue Music Teacher Humanities Teacher Co-Curriculars and Specialists Classroom Teachers Hegrets
29 hank.morrow@heliosschool.org omar.farouk@heliosschool.org TRUE Hank Morrow Omar Farouk TRUE he/him Herons Green Facilities Manager Science Teacher Facilities Staff Classroom Teachers Hegrets
30 noa.adler@heliosschool.org TRUE Noa Adler TRUE Music Teacher Co-Curriculars and Specialists
31 hank.morrow@heliosschool.org TRUE Hank Morrow TRUE Facilities Manager Facilities Staff
+233
View File
@@ -0,0 +1,233 @@
// Command importdates seeds the Overrides updated-date columns from the legacy Glide spreadsheet.
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"
"google.golang.org/api/option"
"google.golang.org/api/sheets/v4"
"heliosian/internal/data"
"heliosian/internal/directory"
)
const (
overridesTab = "Overrides"
studentTab = "Student Images and Facts"
familyTab = "Family Images"
photoColumn = "Photo Updated"
factsColumn = "Facts Updated"
familyPhotoColumn = "Family Photo Updated"
glideFormat = "January 2, 2006"
isoFormat = "2006-01-02"
)
type staticFiles struct{}
func (staticFiles) Has(key string) bool {
_, err := os.Stat(filepath.Join("web/static", filepath.FromSlash(key)))
return err == nil
}
func quoteTab(title string) string {
return "'" + strings.ReplaceAll(title, "'", "''") + "'"
}
func indexOf(header []string, column string) int {
for i, h := range header {
if h == column {
return i
}
}
return -1
}
func cellAt(row []interface{}, i int) string {
if i < 0 || i >= len(row) {
return ""
}
return strings.TrimSpace(fmt.Sprint(row[i]))
}
func glideDate(email, column, cell string) string {
when, err := time.Parse(glideFormat, cell)
if err != nil {
log.Fatalf("[ERROR] %s has unparseable %s %q", email, column, cell)
}
return when.Format(isoFormat)
}
func main() {
sourceID := flag.String("source", "", "legacy glide spreadsheet id")
sheetID := flag.String("sheet", "", "directory spreadsheet id")
preferencesID := flag.String("preferences", "", "preferences spreadsheet id")
flag.Parse()
if *sourceID == "" || *sheetID == "" || *preferencesID == "" {
log.Fatal("[ERROR] -source, -sheet, and -preferences are required")
}
svc, err := sheets.NewService(context.Background(), option.WithScopes(sheets.SpreadsheetsScope))
if err != nil {
log.Fatalf("[ERROR] create sheets client: %v", err)
}
quoted := quoteTab(overridesTab)
resp, err := svc.Spreadsheets.Values.Get(*sheetID, quoted).Do()
if err != nil {
log.Fatalf("[ERROR] read %s: %v", overridesTab, err)
}
if len(resp.Values) == 0 {
log.Fatalf("[ERROR] %s has no header row", overridesTab)
}
header := []string{}
for i := range resp.Values[0] {
header = append(header, cellAt(resp.Values[0], i))
}
for _, column := range []string{photoColumn, factsColumn, familyPhotoColumn} {
if indexOf(header, column) >= 0 {
continue
}
log.Printf("adding column %q to %s", column, overridesTab)
header = append(header, column)
}
headerRow := make([]interface{}, len(header))
for i, h := range header {
headerRow[i] = h
}
if _, err := svc.Spreadsheets.Values.Update(*sheetID, quoted+"!A1", &sheets.ValueRange{
Values: [][]interface{}{headerRow},
}).ValueInputOption("RAW").Do(); err != nil {
log.Fatalf("[ERROR] write %s header: %v", overridesTab, err)
}
source, err := data.NewSheet(map[string]string{
"directory": *sheetID, "preferences": *preferencesID, "glide": *sourceID,
})
if err != nil {
log.Fatalf("[ERROR] sheet source: %v", err)
}
model, err := directory.LoadModel(source, nil, staticFiles{})
if err != nil {
log.Fatalf("[ERROR] load model: %v", err)
}
_, studentRows, err := source.Table("glide", studentTab)
if err != nil {
log.Fatalf("[ERROR] read %s: %v", studentTab, err)
}
_, familyRows, err := source.Table("glide", familyTab)
if err != nil {
log.Fatalf("[ERROR] read %s: %v", familyTab, err)
}
updates := map[string]map[string]string{}
set := func(email, column, value string) {
if updates[email] == nil {
updates[email] = map[string]string{}
}
if current, ok := updates[email][column]; ok && current >= value {
return
}
updates[email][column] = value
}
photos, facts, noEmail, gone := 0, 0, 0, 0
for _, row := range studentRows {
photo, fact := row["Last Updated Photo"], row["Last Updated Facts"]
if photo == "" && fact == "" {
continue
}
email := strings.ToLower(row["Student Email Lower"])
if email == "" {
noEmail++
continue
}
if model.Person(email) == nil {
gone++
continue
}
if photo != "" {
set(email, photoColumn, glideDate(email, "Last Updated Photo", photo))
photos++
}
if fact != "" {
set(email, factsColumn, glideDate(email, "Last Updated Facts", fact))
facts++
}
}
type owner struct{ email, date string }
byFamily := map[string]owner{}
familyGone := 0
for _, row := range familyRows {
cell := row["Last Updated"]
if cell == "" {
continue
}
email := strings.ToLower(row["Family Key Email"])
p := model.Person(email)
if p == nil || !p.IsParent || p.FamilyKey == "" {
familyGone++
continue
}
date := glideDate(email, "Last Updated", cell)
current, ok := byFamily[p.FamilyKey]
if !ok || date > current.date || (date == current.date && email < current.email) {
byFamily[p.FamilyKey] = owner{email: email, date: date}
}
}
for _, o := range byFamily {
set(o.email, familyPhotoColumn, o.date)
}
emailIdx := indexOf(header, "Email")
if emailIdx < 0 {
log.Fatalf("[ERROR] %s has no Email column", overridesTab)
}
rows := resp.Values[1:]
rowByEmail := map[string]int{}
for i, row := range rows {
if email := strings.ToLower(cellAt(row, emailIdx)); email != "" {
rowByEmail[email] = i
}
}
appended := 0
for email, cells := range updates {
i, ok := rowByEmail[email]
if !ok {
row := make([]interface{}, len(header))
for j := range row {
row[j] = ""
}
row[emailIdx] = email
rows = append(rows, row)
i = len(rows) - 1
rowByEmail[email] = i
appended++
}
for column, value := range cells {
idx := indexOf(header, column)
for len(rows[i]) <= idx {
rows[i] = append(rows[i], "")
}
rows[i][idx] = value
}
}
if _, err := svc.Spreadsheets.Values.Update(*sheetID, quoted+"!A2", &sheets.ValueRange{
Values: rows,
}).ValueInputOption("RAW").Do(); err != nil {
log.Fatalf("[ERROR] write %s rows: %v", overridesTab, err)
}
log.Printf("imported %d photo dates, %d facts dates, %d family photo dates across %d rows (%d appended)",
photos, facts, len(byFamily), len(updates), appended)
log.Printf("skipped %d student rows with no email, %d for people no longer in the directory, %d family rows with no current parent",
noEmail, gone, familyGone)
}
+58
View File
@@ -140,6 +140,60 @@ function optInBanner() {
return banner;
}
const staleYears = {photo: 1.25, facts: 1.25, familyPhoto: 2};
function agedPast(present, updated, years) {
if (!present) {
return false;
}
const when = Date.parse(updated);
return Number.isNaN(when) || Date.now() - when > years * 365.25 * 24 * 60 * 60 * 1000;
}
function staleItems() {
const me = byEmail[document.body.dataset.userEmail];
if (!me) {
return [];
}
const family = state.model.families[me.familyKey];
const kids = ((family && family.kidEmails) || []).map(e => byEmail[e]).filter(Boolean);
const items = [];
for (const p of [me, ...kids.filter(k => k.email !== me.email)]) {
const whose = p.email === me.email ? 'your' : `${p.fullName}'s`;
const href = personLink(p) + '&edit=1';
if (agedPast(p.photoUrl, p.photoUpdated, staleYears.photo)) {
items.push({text: `Update ${whose} photo for new year`, href});
}
if (agedPast(p.facts, p.factsUpdated, staleYears.facts)) {
items.push({text: `Update ${whose} facts for new year`, href});
}
}
if (family && agedPast(family.photoUrl, family.photoUpdated, staleYears.familyPhoto)) {
items.push({text: 'Update your family photo for new year', href: familyLink(family.key)});
}
return items;
}
function staleBanner(items) {
const banner = el('div', 'stale');
const inner = el('div', 'stale-inner container');
for (const item of items) {
const row = el('a', 'stale-row');
row.href = item.href;
row.append(el('div', 'stale-mark'));
row.append(el('div', 'stale-text', item.text));
const chev = el('div', 'member-chevron');
chev.append(svg('chevron-right'));
row.append(chev);
inner.append(row);
}
const action = el('a', 'stale-button', 'Update Family Info');
action.href = '/my-family';
inner.append(action);
banner.append(inner);
return banner;
}
function resetMain(...children) {
const main = document.querySelector('#main');
main.replaceChildren();
@@ -147,6 +201,10 @@ function resetMain(...children) {
if (me && me.optStatus === 'default') {
main.append(optInBanner());
}
const stale = staleItems();
if (stale.length) {
main.append(staleBanner(stale));
}
main.append(...children);
return main;
}
+45
View File
@@ -48,6 +48,51 @@
text-decoration: none;
}
.stale {
background: var(--label);
color: #fff;
}
.stale-inner {
padding-top: 10px;
padding-bottom: 24px;
}
.stale-row {
display: flex;
align-items: center;
gap: 16px;
padding: 16px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.16);
color: #fff;
text-decoration: none;
font-size: 15px;
}
.stale-mark {
width: 26px;
height: 26px;
border: 1.5px solid rgba(255, 255, 255, 0.45);
border-radius: 50%;
flex-shrink: 0;
}
.stale-text {
min-width: 0;
}
.stale-button {
display: inline-block;
margin-top: 22px;
padding: 14px 22px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.09);
color: #fff;
font-size: 15px;
font-weight: 700;
text-decoration: none;
}
* {
box-sizing: border-box;
}