Prompt families to refresh photos and facts that have aged (#7)
This commit is contained in:
+6
-4
@@ -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:
|
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.
|
- **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.
|
- **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:
|
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.
|
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.
|
**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.
|
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
|
## 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.
|
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)
|
- an unflagged Overrides row matching no person (orphaned override)
|
||||||
- a flagged Overrides row colliding with an imported person
|
- a flagged Overrides row colliding with an imported person
|
||||||
- conflicting values for the same adult across import rows
|
- 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
|
- 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.
|
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
|
## 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.
|
||||||
|
|||||||
@@ -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
|
- `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
|
- `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
|
- `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
|
- `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`
|
- `splash` — regenerate the iOS splash battery from the captured original page; see `docs/pwa.md`
|
||||||
|
|||||||
+2
-1
@@ -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.
|
- 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).
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -72,6 +72,19 @@ var overrideColumns = []string{
|
|||||||
"Is Student", "Is Parent", "Is Staff", "New to Helios", "Pronouns", "Facts",
|
"Is Student", "Is Parent", "Is Staff", "New to Helios", "Pronouns", "Facts",
|
||||||
"Grade", "Classroom", "Crew", "Phone", "Job Title", "Department", "Grade Band", "Room Parent",
|
"Grade", "Classroom", "Crew", "Phone", "Job Title", "Department", "Grade Band", "Room Parent",
|
||||||
"Address", "Family Phone", "Family Photo Caption", "Opted Out",
|
"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 {
|
type BlobChecker interface {
|
||||||
@@ -115,8 +128,8 @@ type household struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type familyCells struct {
|
type familyCells struct {
|
||||||
address, phone, caption string
|
address, phone, caption, photoUpdated string
|
||||||
hasAddress, hasPhone, hasCaption bool
|
hasAddress, hasPhone, hasCaption, hasPhotoUpdated bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func exactColumns(table string, header, wanted []string) error {
|
func exactColumns(table string, header, wanted []string) error {
|
||||||
@@ -447,6 +460,14 @@ func (l *loader) applyOverrides() error {
|
|||||||
}
|
}
|
||||||
apply(row["Pronouns"], &p.Pronouns)
|
apply(row["Pronouns"], &p.Pronouns)
|
||||||
apply(row["Facts"], &p.Facts)
|
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] == "" {
|
if cell := row["Grade"]; cell != "" && cell != "-" && !added && gradeBands[cell] == "" {
|
||||||
return fmt.Errorf("overrides row %s has unknown grade %q", email, 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
|
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
|
l.familyOverrides[email] = cells
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -558,6 +588,9 @@ func (l *loader) buildFamilies() error {
|
|||||||
if cells.hasCaption {
|
if cells.hasCaption {
|
||||||
family.PhotoCaption = cells.caption
|
family.PhotoCaption = cells.caption
|
||||||
}
|
}
|
||||||
|
if cells.hasPhotoUpdated {
|
||||||
|
family.PhotoUpdated = cells.photoUpdated
|
||||||
|
}
|
||||||
l.model.Families[key] = family
|
l.model.Families[key] = family
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ type Person struct {
|
|||||||
IsNew bool `json:"isNew,omitempty"`
|
IsNew bool `json:"isNew,omitempty"`
|
||||||
Pronouns string `json:"pronouns,omitempty"`
|
Pronouns string `json:"pronouns,omitempty"`
|
||||||
Facts string `json:"facts,omitempty"`
|
Facts string `json:"facts,omitempty"`
|
||||||
|
FactsUpdated string `json:"factsUpdated,omitempty"`
|
||||||
PronunciationURL string `json:"pronunciationUrl,omitempty"`
|
PronunciationURL string `json:"pronunciationUrl,omitempty"`
|
||||||
PhotoURL string `json:"photoUrl,omitempty"`
|
PhotoURL string `json:"photoUrl,omitempty"`
|
||||||
|
PhotoUpdated string `json:"photoUpdated,omitempty"`
|
||||||
Grade string `json:"grade,omitempty"`
|
Grade string `json:"grade,omitempty"`
|
||||||
Classroom string `json:"classroom,omitempty"`
|
Classroom string `json:"classroom,omitempty"`
|
||||||
Crew string `json:"crew,omitempty"`
|
Crew string `json:"crew,omitempty"`
|
||||||
@@ -45,6 +47,7 @@ type Family struct {
|
|||||||
Lng float64 `json:"lng,omitempty"`
|
Lng float64 `json:"lng,omitempty"`
|
||||||
PhotoURL string `json:"photoUrl,omitempty"`
|
PhotoURL string `json:"photoUrl,omitempty"`
|
||||||
PhotoCaption string `json:"photoCaption,omitempty"`
|
PhotoCaption string `json:"photoCaption,omitempty"`
|
||||||
|
PhotoUpdated string `json:"photoUpdated,omitempty"`
|
||||||
PronunciationURL string `json:"pronunciationUrl,omitempty"`
|
PronunciationURL string `json:"pronunciationUrl,omitempty"`
|
||||||
AdultEmails []string `json:"adultEmails,omitempty"`
|
AdultEmails []string `json:"adultEmails,omitempty"`
|
||||||
KidEmails []string `json:"kidEmails,omitempty"`
|
KidEmails []string `json:"kidEmails,omitempty"`
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ func RegisterUpload(mux *http.ServeMux, cache *Cache, sheet *data.Sheet, store *
|
|||||||
mux.HandleFunc("POST /api/directory/edit", u.edit)
|
mux.HandleFunc("POST /api/directory/edit", u.edit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func today() string {
|
||||||
|
return time.Now().UTC().Format(updatedFormat)
|
||||||
|
}
|
||||||
|
|
||||||
func clearable(value string) string {
|
func clearable(value string) string {
|
||||||
if value == "" {
|
if value == "" {
|
||||||
return "-"
|
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)
|
http.Error(w, "not allowed to edit this record", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
old := ""
|
old, oldUpdated := "", ""
|
||||||
if p := model.Person(key); p != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
log.Printf("facts: %s set %s (%d chars)", me, key, len(facts))
|
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)
|
serverError(w, err)
|
||||||
return
|
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))
|
serverError(w, fmt.Errorf("refresh model after upload: %w", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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
|
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,,,,,,
|
dana.hawkins@heliosschool.org,,,,,,,TRUE,,she/her,,,,,,Art Teacher,Co-Curriculars and Specialists,,,,,,,,,
|
||||||
jordan.whitfield@heliosschool.org,,,,,,,,,they/them,,,,,,,,,Jayvens,,,,
|
jordan.whitfield@heliosschool.org,,,,,,,,,they/them,,,,,,,,,Jayvens,,,,,,,2024-03-15
|
||||||
asha.chandra@heliosschool.org,,,,,,,,,,,,,,,,,,Cospreys,,,,
|
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.",,,,,,,,,,,,
|
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.,,,,,,,,,,,,
|
owen.park@heliosschool.org,,,,,,,,,,Can name every bird on the bluff trail. Future marine biologist.,,,,,,,,,,,,,,2024-09-12,
|
||||||
felix.osei@heliosschool.org,,,,,,,,,he/him,,,,,,,,,,,,,
|
felix.osei@heliosschool.org,,,,,,,,,he/him,,,,,,,,,,,,,,,,
|
||||||
noor.haddad@heliosschool.org,,,,,,,,TRUE,,,,,,,,,,,,,,
|
noor.haddad@heliosschool.org,,,,,,,,TRUE,,,,,,,,,,,,,,,,,
|
||||||
layla.haddad@heliosschool.org,,,,,,,,TRUE,,,,,,,,,,,,,,
|
layla.haddad@heliosschool.org,,,,,,,,TRUE,,,,,,,,,,,,,,,,,
|
||||||
colin.quinn@heliosschool.org,,,,,,,,,,,,,,-,,,,,,,,
|
colin.quinn@heliosschool.org,,,,,,,,,,,,,,-,,,,,,,,,,,
|
||||||
elena.torres@heliosschool.org,,,,,,,,,,,,,,,,,,,,,"Marco, Mia, and Elena at Mavericks Beach.",
|
elena.torres@heliosschool.org,,,,,,,,,,,,,,,,,,,,,"Marco, Mia, and Elena at Mavericks Beach.",,,,
|
||||||
deepa.natarajan@heliosschool.org,,,,,,,,,,,,,,,,,,,,650-555-0199,,
|
deepa.natarajan@heliosschool.org,,,,,,,,,,,,,,,,,,,,650-555-0199,,,,,
|
||||||
grace.kim@heliosschool.org,TRUE,Grace Kim,,,,,TRUE,,she/her,,,,,,Head of School,Admin and Office Staff,,,,,,
|
ella.whitfield@heliosschool.org,,,,,,,,,,"Building a tide-pool guide for the Fitzgerald reserve, and learning bass guitar.",,,,,,,,,,,,,,2024-06-02,
|
||||||
bill.ryder@heliosschool.org,TRUE,Bill Ryder,,,,,TRUE,,,,,,,,Office Manager,Admin and Office Staff,,,,,,
|
sam.whitfield@heliosschool.org,,,,,,,,,,Skateboards at the Half Moon Bay park and is learning to make ramen from scratch.,,,,,,,,,,,,,,2026-08-01,
|
||||||
ruth.amari@heliosschool.org,TRUE,Ruth Amari,,,,,TRUE,,,"Twelve years teaching kindergarten, keeper of the class worm farm.",,Hummingbirds,,,Kindergarten Teacher,Classroom Teachers,Hummingbirds,,,,,
|
grace.kim@heliosschool.org,TRUE,Grace Kim,,,,,TRUE,,she/her,,,,,,Head of School,Admin and Office Staff,,,,,,,,,
|
||||||
miguel.santos@heliosschool.org,TRUE,Miguel Santos,,,,,TRUE,,,,,Hawks,,,1st Grade Teacher,Classroom Teachers,Halcons,,,,,
|
bill.ryder@heliosschool.org,TRUE,Bill Ryder,,,,,TRUE,,,,,,,,Office Manager,Admin and Office Staff,,,,,,,,,
|
||||||
alice.fontaine@heliosschool.org,TRUE,Alice Fontaine,,,,,TRUE,,,,,Falcons,,,2nd Grade Teacher,Classroom Teachers,Halcons,,,,,
|
ruth.amari@heliosschool.org,TRUE,Ruth Amari,,,,,TRUE,,,"Twelve years teaching kindergarten, keeper of the class worm farm.",,Hummingbirds,,,Kindergarten Teacher,Classroom Teachers,Hummingbirds,,,,,,,,
|
||||||
peter.okafor@heliosschool.org,TRUE,Peter Okafor,,,,,TRUE,,,,,Jays,,,3rd Grade Teacher,Classroom Teachers,Jayvens,,,,,
|
miguel.santos@heliosschool.org,TRUE,Miguel Santos,,,,,TRUE,,,,,Hawks,,,1st Grade Teacher,Classroom Teachers,Halcons,,,,,,,,
|
||||||
susan.byrne@heliosschool.org,TRUE,Susan Byrne,,,,,TRUE,,,,,Ravens,,,4th Grade Teacher,Classroom Teachers,Jayvens,,,,,
|
alice.fontaine@heliosschool.org,TRUE,Alice Fontaine,,,,,TRUE,,,,,Falcons,,,2nd Grade Teacher,Classroom Teachers,Halcons,,,,,,,,
|
||||||
hana.ito@heliosschool.org,TRUE,Hana Ito,,,,,TRUE,,,,,Condors,Pinnacles,,5th/6th Grade Teacher,Classroom Teachers,Cospreys,,,,,
|
peter.okafor@heliosschool.org,TRUE,Peter Okafor,,,,,TRUE,,,,,Jays,,,3rd Grade Teacher,Classroom Teachers,Jayvens,,,,,,,,
|
||||||
marcus.bell@heliosschool.org,TRUE,Marcus Bell,,,,,TRUE,,,,,Condors,Big Sur,,5th/6th Grade Teacher,Classroom Teachers,Cospreys,,,,,
|
susan.byrne@heliosschool.org,TRUE,Susan Byrne,,,,,TRUE,,,,,Ravens,,,4th Grade Teacher,Classroom Teachers,Jayvens,,,,,,,,
|
||||||
lena.vogel@heliosschool.org,TRUE,Lena Vogel,,,,,TRUE,,,,,Ospreys,River,,5th/6th Grade Teacher,Classroom Teachers,Cospreys,,,,,
|
hana.ito@heliosschool.org,TRUE,Hana Ito,,,,,TRUE,,,,,Condors,Pinnacles,,5th/6th Grade Teacher,Classroom Teachers,Cospreys,,,,,,,,
|
||||||
tom.grady@heliosschool.org,TRUE,Tom Grady,,,,,TRUE,,,,,Ospreys,Sea,,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,,,,,,,,
|
||||||
ivy.chen@heliosschool.org,TRUE,Ivy Chen,,,,,TRUE,,she/her,,,Egrets,Snowy,,Humanities Teacher,Classroom Teachers,Hegrets,,,,,
|
lena.vogel@heliosschool.org,TRUE,Lena Vogel,,,,,TRUE,,,,,Ospreys,River,,5th/6th Grade Teacher,Classroom Teachers,Cospreys,,,,,,,,
|
||||||
raj.malhotra@heliosschool.org,TRUE,Raj Malhotra,,,,,TRUE,,,,,Egrets,Great,,Science Teacher,Classroom Teachers,Hegrets,,,,,
|
tom.grady@heliosschool.org,TRUE,Tom Grady,,,,,TRUE,,,,,Ospreys,Sea,,5th/6th Grade Teacher,Classroom Teachers,Cospreys,,,,,,,,
|
||||||
kate.doyle@heliosschool.org,TRUE,Kate Doyle,,,,,TRUE,,,,,Herons,Great Blue,,Humanities Teacher,Classroom Teachers,Hegrets,,,,,
|
ivy.chen@heliosschool.org,TRUE,Ivy Chen,,,,,TRUE,,she/her,,,Egrets,Snowy,,Humanities Teacher,Classroom Teachers,Hegrets,,,,,,,,
|
||||||
omar.farouk@heliosschool.org,TRUE,Omar Farouk,,,,,TRUE,,he/him,,,Herons,Green,,Science Teacher,Classroom Teachers,Hegrets,,,,,
|
raj.malhotra@heliosschool.org,TRUE,Raj Malhotra,,,,,TRUE,,,,,Egrets,Great,,Science Teacher,Classroom Teachers,Hegrets,,,,,,,,
|
||||||
noa.adler@heliosschool.org,TRUE,Noa Adler,,,,,TRUE,,,,,,,,Music Teacher,Co-Curriculars and Specialists,,,,,,
|
kate.doyle@heliosschool.org,TRUE,Kate Doyle,,,,,TRUE,,,,,Herons,Great Blue,,Humanities Teacher,Classroom Teachers,Hegrets,,,,,,,,
|
||||||
hank.morrow@heliosschool.org,TRUE,Hank Morrow,,,,,TRUE,,,,,,,,Facilities Manager,Facilities Staff,,,,,,
|
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,,,,,,,,,
|
||||||
|
|||||||
|
@@ -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)
|
||||||
|
}
|
||||||
@@ -140,6 +140,60 @@ function optInBanner() {
|
|||||||
return banner;
|
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) {
|
function resetMain(...children) {
|
||||||
const main = document.querySelector('#main');
|
const main = document.querySelector('#main');
|
||||||
main.replaceChildren();
|
main.replaceChildren();
|
||||||
@@ -147,6 +201,10 @@ function resetMain(...children) {
|
|||||||
if (me && me.optStatus === 'default') {
|
if (me && me.optStatus === 'default') {
|
||||||
main.append(optInBanner());
|
main.append(optInBanner());
|
||||||
}
|
}
|
||||||
|
const stale = staleItems();
|
||||||
|
if (stale.length) {
|
||||||
|
main.append(staleBanner(stale));
|
||||||
|
}
|
||||||
main.append(...children);
|
main.append(...children);
|
||||||
return main;
|
return main;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,51 @@
|
|||||||
text-decoration: none;
|
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;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user