From e747510e11565d4af598eb2080ffc6ed715ecca2 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Sun, 23 Aug 2026 16:59:45 -0700 Subject: [PATCH] Prompt families to refresh photos and facts that have aged (#7) --- docs/data.md | 10 +- docs/dev.md | 1 + docs/directory.md | 3 +- internal/directory/load.go | 39 ++++- internal/directory/model.go | 3 + internal/directory/upload.go | 23 ++- sampledata/directory/Change Log.csv | 2 +- sampledata/directory/Overrides.csv | 60 +++---- tools/importdates/main.go | 233 ++++++++++++++++++++++++++++ web/static/directory/app.js | 58 +++++++ web/static/directory/style.css | 45 ++++++ 11 files changed, 435 insertions(+), 42 deletions(-) create mode 100644 tools/importdates/main.go diff --git a/docs/data.md b/docs/data.md index e59bcfb..ec2ecc2 100644 --- a/docs/data.md +++ b/docs/data.md @@ -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: `-photo` and `-pronunciation` for people, `-photo` and `-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: `-photo` and `-pronunciation` for people, `-photo` and `-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. diff --git a/docs/dev.md b/docs/dev.md index 40807ad..3619ee8 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -54,5 +54,6 @@ Each runs as `go run ./tools/`. 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` diff --git a/docs/directory.md b/docs/directory.md index f3ef3df..4238c01 100644 --- a/docs/directory.md +++ b/docs/directory.md @@ -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. diff --git a/internal/directory/load.go b/internal/directory/load.go index 6c972e0..845511f 100644 --- a/internal/directory/load.go +++ b/internal/directory/load.go @@ -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 diff --git a/internal/directory/model.go b/internal/directory/model.go index cbc56d2..1af648d 100644 --- a/internal/directory/model.go +++ b/internal/directory/model.go @@ -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"` diff --git a/internal/directory/upload.go b/internal/directory/upload.go index f7a955c..0282e29 100644 --- a/internal/directory/upload.go +++ b/internal/directory/upload.go @@ -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 } diff --git a/sampledata/directory/Change Log.csv b/sampledata/directory/Change Log.csv index 63b059f..6fe5e4b 100644 --- a/sampledata/directory/Change Log.csv +++ b/sampledata/directory/Change Log.csv @@ -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 diff --git a/sampledata/directory/Overrides.csv b/sampledata/directory/Overrides.csv index d65c9e7..6e96283 100644 --- a/sampledata/directory/Overrides.csv +++ b/sampledata/directory/Overrides.csv @@ -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,,,,,,,,, diff --git a/tools/importdates/main.go b/tools/importdates/main.go new file mode 100644 index 0000000..89f0d14 --- /dev/null +++ b/tools/importdates/main.go @@ -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) +} diff --git a/web/static/directory/app.js b/web/static/directory/app.js index f2f9fbf..4a56a08 100644 --- a/web/static/directory/app.js +++ b/web/static/directory/app.js @@ -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; } diff --git a/web/static/directory/style.css b/web/static/directory/style.css index 9ca117b..9e46d02 100644 --- a/web/static/directory/style.css +++ b/web/static/directory/style.css @@ -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; }