diff --git a/docs/data.md b/docs/data.md index 64ec022..7008c2f 100644 --- a/docs/data.md +++ b/docs/data.md @@ -9,6 +9,7 @@ One record per person, all roles in one shape: - **Key**: school email, lowercased. Everyone has one, including the youngest students (their addresses exist but they don't have access yet). - **Names**: full name, legal name, preferred name. - **Roles**: student, parent, and staff booleans — combinations are valid (staff members are often also parents). Display strings derive from the flags. +- **New to Helios**: flag marking people who just joined the community; drives the matching filter toggle. - **Pronouns**: optional; a curated list plus a freeform escape hatch. - **Pronunciation**: optional audio recording of the person's name. - **Photo**: official portrait or personal upload; people may opt for an illustrated avatar instead. diff --git a/docs/dev.md b/docs/dev.md index 723da0f..6e9c63f 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -31,3 +31,4 @@ switches the directory app to the Google Sheets source. At startup the directory - `internal/directory` — directory app handlers - `web/directory` — directory app page templates and static assets - `tools/screenshot` — dev-site page capture, see [screenshots.md](screenshots.md) +- `tools/columns` — print the column names of each directory table in the configured source diff --git a/docs/directory.md b/docs/directory.md index 99a4b47..ba69290 100644 --- a/docs/directory.md +++ b/docs/directory.md @@ -66,7 +66,7 @@ Share the app by SMS or link, an explanation of why photos and facts are collect ## Behaviors - Everything is cross-linked: parents ↔ kids ↔ families ↔ classrooms; any person reference navigates to that person. -- Search is per-list and immediate; filters cover grade and classroom. +- Search is per-list and immediate; filters cover role, class, grade, city, pronouns, and new-to-Helios. - 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. diff --git a/docs/plan.md b/docs/plan.md index 290977b..8537b33 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -6,8 +6,6 @@ What remains to build. Current behavior is documented in `docs/dev.md`, `docs/da - Remaining sections: My Family, Map. - Mobile chrome: brand-teal top bar and bottom tab navigation on narrow screens (see `docs/directory.md`); today only the desktop chrome is faithful. -- Filter button behavior — currently visual only; the original filters by grade and classroom. -- Favorites: per-account storage; hearts on person pages and email-list rows feed the bookmarks tab, saved per-device in the browser. - Installable-app plumbing: manifest, icons, and meta tags per `docs/pwa.md`. - Self-service flows: photo and pronunciation upload, address update, opt-out. diff --git a/internal/directory/load.go b/internal/directory/load.go index 3c34b26..2b90d7c 100644 --- a/internal/directory/load.go +++ b/internal/directory/load.go @@ -50,6 +50,7 @@ func LoadModel(source data.Source) (*Model, error) { IsStaff: row["Is Staff?"] == "TRUE", IsParent: row["Is Parent?"] == "TRUE", IsStudent: row["Is Student?"] == "TRUE", + IsNew: row["Is Totally New"] == "TRUE", Pronouns: row["Pronouns"], Facts: row["Facts"], PronunciationURL: blobURL("people", email, "pronunciation", row["Pronunciation"]), diff --git a/internal/directory/model.go b/internal/directory/model.go index 0821b31..3d2214d 100644 --- a/internal/directory/model.go +++ b/internal/directory/model.go @@ -8,6 +8,7 @@ type Person struct { IsStaff bool `json:"isStaff"` IsParent bool `json:"isParent"` IsStudent bool `json:"isStudent"` + IsNew bool `json:"isNew,omitempty"` Pronouns string `json:"pronouns,omitempty"` Facts string `json:"facts,omitempty"` PronunciationURL string `json:"pronunciationUrl,omitempty"` diff --git a/tools/columns/main.go b/tools/columns/main.go new file mode 100644 index 0000000..73cbc4f --- /dev/null +++ b/tools/columns/main.go @@ -0,0 +1,50 @@ +// Command columns prints the column names of each directory table in the configured source. +package main + +import ( + "fmt" + "log" + "os" + "sort" + + "heliosian/internal/data" +) + +func main() { + sheetID := os.Getenv("DIRECTORY_SHEET") + var source data.Source + if sheetID == "" { + source = data.Dir{Root: "sampledata"} + } else { + s, err := data.NewSheet(map[string]string{"directory": sheetID}) + if err != nil { + log.Fatalf("[ERROR] load directory sheet: %v", err) + } + source = s + } + for _, name := range []string{"Basic Directory", "Staff Details", "Classrooms", "Schedules", "Grade Lookup", "Room Parents", "Departments"} { + rows, err := source.Table("directory", name) + if err != nil { + log.Fatalf("[ERROR] table %s: %v", name, err) + } + if len(rows) == 0 { + fmt.Printf("%s: no rows\n", name) + continue + } + seen := map[string]bool{} + columns := []string{} + for _, row := range rows { + for column := range row { + if !seen[column] { + seen[column] = true + columns = append(columns, column) + } + } + } + sort.Strings(columns) + fmt.Printf("%s:\n", name) + for _, column := range columns { + fmt.Printf(" %s\n", column) + } + } +} diff --git a/web/static/directory/app.js b/web/static/directory/app.js index 9624473..a1b6eb5 100644 --- a/web/static/directory/app.js +++ b/web/static/directory/app.js @@ -1,4 +1,4 @@ -const state = {model: null, tab: 'everyone', classTab: 'by-classroom', rosterTab: 'students', q: ''}; +const state = {model: null, tab: 'everyone', classTab: 'by-classroom', rosterTab: 'students', q: '', filterGrades: new Set(), filterClassrooms: new Set(), filterRoles: new Set(), filterCities: new Set(), filterPronouns: new Set(), filterNew: false}; let byEmail = {}; const favorites = new Set(JSON.parse(localStorage.getItem('favorites') || '[]')); @@ -169,7 +169,7 @@ function renderEveryone(grid) { const q = state.q; const matches = state.model.people.filter(p => { const family = state.model.families[p.familyKey]; - return `${p.fullName} ${family ? family.name : ''}`.toLowerCase().includes(q); + return `${p.fullName} ${family ? family.name : ''}`.toLowerCase().includes(q) && matchesFilters(p); }); for (const p of matches) { grid.append(personCard(p)); @@ -179,17 +179,21 @@ function renderEveryone(grid) { function renderStudents(grid) { grid.className = 'student-grid'; - const matches = state.model.people.filter(p => p.isStudent && p.fullName.toLowerCase().includes(state.q)); + const matches = state.model.people.filter(p => p.isStudent && p.fullName.toLowerCase().includes(state.q) && matchesFilters(p)); for (const p of matches) { const card = el('a', 'student-card'); card.href = personLink(p); - if (p.photoUrl) { - const img = el('img', 'student-photo'); - img.src = thumbUrl(p.photoUrl); - img.loading = 'lazy'; - img.alt = ''; - card.append(img); + const head = el('div', 'student-head'); + const family = state.model.families[p.familyKey]; + if (family && family.photoUrl) { + const bg = el('img', 'student-family-photo'); + bg.src = thumbUrl(family.photoUrl); + bg.loading = 'lazy'; + bg.alt = ''; + head.append(bg); } + head.append(photoOrInitials(p.photoUrl, p.fullName, 'student-photo')); + card.append(head); card.append(el('div', 'student-first', firstName(p.fullName))); card.append(el('div', 'student-last', p.fullName.replace(firstName(p.fullName), '').trim())); card.append(el('div', 'student-line', gradeChain(p))); @@ -222,6 +226,7 @@ function familyEntries() { members: [firstName(p.fullName)], photoUrl: p.photoUrl, href: personLink(p), + email: p.email, }); } } @@ -232,7 +237,8 @@ function familyEntries() { function renderFamilies(grid) { grid.className = 'family-grid'; const matches = familyEntries().filter(f => - `${f.name} ${f.members.join(' ')}`.toLowerCase().includes(state.q)); + `${f.name} ${f.members.join(' ')}`.toLowerCase().includes(state.q) && + (f.key ? familyMatchesFilters(f.key) : matchesFilters(byEmail[f.email]))); for (const f of matches) { const card = el('a', 'family-card'); card.href = f.href; @@ -248,7 +254,7 @@ function renderFamilies(grid) { function renderStaff(grid, autoFit) { grid.className = ''; const staff = state.model.people.filter(p => - p.isStaff && `${p.fullName} ${p.jobTitle || ''}`.toLowerCase().includes(state.q)); + p.isStaff && `${p.fullName} ${p.jobTitle || ''}`.toLowerCase().includes(state.q) && matchesFilters(p)); const departments = state.model.departments || []; const groups = new Map(); for (const p of staff) { @@ -322,9 +328,7 @@ function renderPeople() { renderGrid(); }); search.append(input); - const filter = el('button', 'filter-button'); - filter.append(svg('filter'), el('span', '', 'Filter'), svg('chevron')); - controls.append(search, filter); + controls.append(search, filterControl(renderGrid)); header.append(controls); content.append(header); @@ -342,6 +346,150 @@ function renderPeople() { input.focus(); } +function personFacets(p, field) { + if (p.isStudent) { + return p[field] ? [p[field]] : []; + } + if (p.isParent) { + const family = state.model.families[p.familyKey]; + return ((family && family.kidEmails) || []).map(e => byEmail[e]).filter(Boolean).map(k => k[field]).filter(Boolean); + } + return []; +} + +function cityOf(p) { + const family = state.model.families[p.familyKey]; + if (!family || !family.address) { + return ''; + } + const parts = family.address.split(',').map(s => s.trim()); + return parts.length >= 2 ? parts[parts.length - 2] : parts[0]; +} + +function anyFiltersActive() { + return Boolean(state.filterGrades.size || state.filterClassrooms.size || state.filterRoles.size || + state.filterCities.size || state.filterPronouns.size || state.filterNew); +} + +function matchesFilters(p) { + const gradeOK = !state.filterGrades.size || personFacets(p, 'grade').some(g => state.filterGrades.has(g)); + const classOK = !state.filterClassrooms.size || personFacets(p, 'classroom').some(c => state.filterClassrooms.has(c)); + const roleOK = !state.filterRoles.size || + (state.filterRoles.has('Student') && p.isStudent) || + (state.filterRoles.has('Parent') && p.isParent) || + (state.filterRoles.has('Staff') && p.isStaff); + const cityOK = !state.filterCities.size || state.filterCities.has(cityOf(p)); + const pronounsOK = !state.filterPronouns.size || state.filterPronouns.has(p.pronouns); + const newOK = !state.filterNew || p.isNew; + return gradeOK && classOK && roleOK && cityOK && pronounsOK && newOK; +} + +function familyMatchesFilters(key) { + const family = state.model.families[key]; + if (!family) { + return !anyFiltersActive(); + } + const members = [...(family.kidEmails || []), ...(family.adultEmails || [])].map(e => byEmail[e]).filter(Boolean); + return !anyFiltersActive() || members.some(matchesFilters); +} + +function gradeOptions() { + const present = new Set(state.model.people.filter(p => p.isStudent).map(p => p.grade).filter(Boolean)); + return state.model.grades.map(g => g.name).filter(n => present.has(n)); +} + +function cityOptions() { + return [...new Set(state.model.people.map(cityOf).filter(Boolean))].sort(); +} + +function pronounOptions() { + return [...new Set(state.model.people.map(p => p.pronouns).filter(Boolean))].sort(); +} + +function filterControl(rerender) { + const wrap = el('div', 'filter-wrap'); + const button = el('button', 'filter-button'); + button.append(svg('filter'), el('span', '', 'Filter'), svg('chevron')); + const panel = el('div', 'filter-panel'); + panel.hidden = true; + button.addEventListener('click', () => { + panel.hidden = !panel.hidden; + button.classList.toggle('open', !panel.hidden); + }); + + const sections = [ + {label: 'Role', values: ['Student', 'Parent', 'Staff'], set: state.filterRoles}, + {label: 'Class', values: state.model.classrooms.map(c => c.name), set: state.filterClassrooms}, + {label: 'Grade', values: gradeOptions(), set: state.filterGrades}, + {label: 'City', values: cityOptions(), set: state.filterCities}, + {label: 'Pronouns', values: pronounOptions(), set: state.filterPronouns}, + ]; + for (const s of sections) { + const head = el('div', 'filter-section'); + head.append(el('span', '', s.label), svg('chevron')); + const body = el('div', 'filter-options'); + body.hidden = true; + head.addEventListener('click', () => { + body.hidden = !body.hidden; + head.classList.toggle('open', !body.hidden); + }); + for (const v of s.values) { + const row = el('label', 'filter-option'); + const box = el('input'); + box.type = 'checkbox'; + box.checked = s.set.has(v); + box.addEventListener('change', () => { + if (box.checked) { + s.set.add(v); + } else { + s.set.delete(v); + } + rerender(); + }); + row.append(el('span', '', v), box); + body.append(row); + } + panel.append(head, body); + } + + const toggleRow = el('label', 'filter-toggle-row'); + toggleRow.append(el('span', '', 'New to Helios')); + const toggle = el('input', 'filter-switch'); + toggle.type = 'checkbox'; + toggle.checked = state.filterNew; + toggle.addEventListener('change', () => { + state.filterNew = toggle.checked; + rerender(); + }); + toggleRow.append(toggle); + panel.append(toggleRow); + + const footer = el('div', 'filter-footer'); + const clear = el('button', 'filter-clear', 'Clear all'); + clear.addEventListener('click', () => { + state.filterGrades.clear(); + state.filterClassrooms.clear(); + state.filterRoles.clear(); + state.filterCities.clear(); + state.filterPronouns.clear(); + state.filterNew = false; + for (const box of panel.querySelectorAll('input')) { + box.checked = false; + } + rerender(); + }); + const done = el('button', 'filter-done', 'Done'); + done.addEventListener('click', () => { + panel.hidden = true; + button.classList.remove('open'); + }); + footer.append(clear, done); + panel.append(footer); + + wrap.append(button, panel); + return wrap; +} + function referrerCrumbs() { if (!document.referrer) { return null; @@ -915,9 +1063,7 @@ function renderStaffPage() { renderList(); }); search.append(input); - const filter = el('button', 'filter-button'); - filter.append(svg('filter'), el('span', '', 'Filter'), svg('chevron')); - controls.append(search, filter); + controls.append(search, filterControl(renderList)); header.append(controls); content.append(header); @@ -1166,9 +1312,7 @@ function renderEmailListPage() { renderTable(); }); search.append(input); - const filter = el('button', 'filter-button'); - filter.append(svg('filter'), el('span', '', 'Filter'), svg('chevron')); - controls.append(search, filter); + controls.append(search, filterControl(renderTable)); header.append(controls); content.append(header); @@ -1179,7 +1323,7 @@ function renderEmailListPage() { function renderTable() { holder.replaceChildren(); const rows = emailEntries(state.emailTab) - .filter(r => r.p.fullName.toLowerCase().includes(state.q) || r.p.email.toLowerCase().includes(state.q)); + .filter(r => (r.p.fullName.toLowerCase().includes(state.q) || r.p.email.toLowerCase().includes(state.q)) && matchesFilters(r.p)); if (!rows.length) { holder.append(el('div', 'empty', state.emailTab === 'bookmarks' ? 'No bookmarks yet.' : 'No matches.')); return; @@ -1291,12 +1435,26 @@ document.querySelector('#user').addEventListener('click', e => { e.stopPropagation(); userMenu.hidden = !userMenu.hidden; }); -document.addEventListener('click', () => { +function closeFilterPanels() { + for (const panel of document.querySelectorAll('.filter-panel')) { + panel.hidden = true; + panel.parentElement.querySelector('.filter-button').classList.remove('open'); + } +} + +document.addEventListener('click', e => { userMenu.hidden = true; + for (const panel of document.querySelectorAll('.filter-panel')) { + if (!panel.hidden && !panel.parentElement.contains(e.target)) { + panel.hidden = true; + panel.parentElement.querySelector('.filter-button').classList.remove('open'); + } + } }); document.addEventListener('keydown', e => { if (e.key === 'Escape') { userMenu.hidden = true; + closeFilterPanels(); } }); diff --git a/web/static/directory/style.css b/web/static/directory/style.css index f723aae..02c98bd 100644 --- a/web/static/directory/style.css +++ b/web/static/directory/style.css @@ -246,6 +246,148 @@ main { margin: 0; } +.filter-wrap { + position: relative; +} + +.filter-button.open { + box-shadow: 0 0 0 2px var(--sidebar-active); +} + +.filter-panel { + position: absolute; + right: 0; + top: calc(100% + 8px); + width: 300px; + background: #fff; + border-radius: 12px; + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.18); + z-index: 20; + overflow: hidden; +} + +.filter-section { + display: flex; + align-items: center; + justify-content: space-between; + padding: 15px 18px; + font-size: 15px; + font-weight: 600; + border-bottom: 1px solid var(--line); + cursor: pointer; +} + +.filter-section svg { + width: 18px; + height: 18px; + stroke: #9aa4ab; + fill: none; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; + transform: rotate(-90deg); +} + +.filter-section.open svg { + transform: none; +} + +.filter-options { + max-height: 250px; + overflow-y: auto; + border-bottom: 1px solid var(--line); +} + +.filter-option { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 18px; + font-size: 14px; + cursor: pointer; +} + +.filter-option:hover { + background: #f6f6f7; +} + +.filter-option input { + width: 17px; + height: 17px; + accent-color: var(--brand); +} + +.filter-toggle-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 15px 18px; + font-size: 15px; + font-weight: 600; + border-bottom: 1px solid var(--line); + cursor: pointer; +} + +.filter-switch { + appearance: none; + width: 40px; + height: 24px; + border-radius: 12px; + background: #d5dadd; + position: relative; + cursor: pointer; + transition: background 0.15s; +} + +.filter-switch::before { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 20px; + height: 20px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); + transition: left 0.15s; +} + +.filter-switch:checked { + background: var(--brand); +} + +.filter-switch:checked::before { + left: 18px; +} + +.filter-footer { + display: flex; + gap: 12px; + padding: 14px 16px; +} + +.filter-footer button { + flex: 1; + padding: 11px 0; + border-radius: 10px; + font: inherit; + font-size: 14px; + font-weight: 600; + cursor: pointer; +} + +.filter-clear { + border: 1px solid var(--line); + background: #fff; + color: var(--ink); +} + +.filter-done { + border: 0; + background: var(--brand); + color: #fff; +} + .email-hint { color: var(--muted); font-size: 14px; @@ -459,8 +601,9 @@ div.person-photo { .student-grid { display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 24px; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 28px 24px; + align-items: start; } .student-card { @@ -468,16 +611,43 @@ div.person-photo { border-radius: 10px; overflow: hidden; text-align: center; - padding-bottom: 20px; + padding-bottom: 28px; background: #fff; } -.student-photo { +.student-head { + position: relative; + height: 150px; + margin-bottom: 50px; +} + +.student-family-photo { width: 100%; - aspect-ratio: 1.35; + height: 100%; object-fit: cover; display: block; - background: #fff; +} + +.student-photo { + position: absolute; + left: 50%; + bottom: -50px; + transform: translateX(-50%); + width: 100px; + height: 100px; + border-radius: 50%; + object-fit: cover; + border: 4px solid #fff; + background: #eef1f3; + color: #fff; +} + +div.student-photo { + display: flex; + align-items: center; + justify-content: center; + font-size: 1.8rem; + font-weight: 600; } .student-first {