diff --git a/docs/dev.md b/docs/dev.md new file mode 100644 index 0000000..ab5ddd7 --- /dev/null +++ b/docs/dev.md @@ -0,0 +1,18 @@ +# Local development + +## Run + + go run . + +The server listens on http://localhost:8080 (override with `PORT`). Templates, static assets, and sample data are read from disk on every request — edit a file and refresh the browser; no restart needed. + +## Local data + +The server reads local data from `sampledata/`, mirroring the production Sheets layout: one directory per app, one CSV file per table, first row is the schema. It goes through the same data-source interface production backends implement, so app code never knows which backend it is talking to. + +## Layout + +- `main.go` — server entry point and app routing +- `internal/data` — data source interface and the CSV sample-data implementation +- `internal/directory` — directory app handlers +- `web/directory` — directory app page templates and static assets diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f54468d --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module heliosian + +go 1.26 diff --git a/internal/data/data.go b/internal/data/data.go new file mode 100644 index 0000000..d469cce --- /dev/null +++ b/internal/data/data.go @@ -0,0 +1,42 @@ +// Package data provides tabular app data sources. +package data + +import ( + "encoding/csv" + "fmt" + "os" + "path/filepath" +) + +type Source interface { + Table(app, name string) ([]map[string]string, error) +} + +type Dir struct { + Root string +} + +func (d Dir) Table(app, name string) ([]map[string]string, error) { + f, err := os.Open(filepath.Join(d.Root, app, name+".csv")) + if err != nil { + return nil, err + } + defer f.Close() + rows, err := csv.NewReader(f).ReadAll() + if err != nil { + return nil, err + } + if len(rows) == 0 { + return nil, fmt.Errorf("table %s/%s has no header row", app, name) + } + header := rows[0] + records := []map[string]string{} + for _, row := range rows[1:] { + record := map[string]string{} + for i, column := range header { + record[column] = row[i] + } + records = append(records, record) + } + return records, nil +} diff --git a/internal/directory/directory.go b/internal/directory/directory.go new file mode 100644 index 0000000..58c1391 --- /dev/null +++ b/internal/directory/directory.go @@ -0,0 +1,102 @@ +// Package directory serves the school directory app. +package directory + +import ( + "encoding/json" + "fmt" + "html/template" + "log" + "net/http" + "sort" + + "heliosian/internal/data" +) + +type app struct { + source data.Source +} + +func Register(mux *http.ServeMux, source data.Source) { + a := app{source: source} + mux.HandleFunc("GET /directory/{$}", a.index) + mux.HandleFunc("GET /directory/api/people", a.people) + mux.Handle("GET /directory/static/", http.StripPrefix("/directory/static/", http.FileServer(http.Dir("web/directory/static")))) +} + +func (a app) index(w http.ResponseWriter, r *http.Request) { + t, err := template.ParseFiles("web/directory/index.html") + if err != nil { + serverError(w, err) + return + } + if err := t.Execute(w, nil); err != nil { + log.Printf("[ERROR] render directory index: %v", err) + } +} + +type person struct { + ID string `json:"id"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + Role string `json:"role"` + Grade string `json:"grade"` + Email string `json:"email"` + Phone string `json:"phone"` + Pronunciation string `json:"pronunciation"` + FamilyName string `json:"familyName"` + Address string `json:"address"` + FamilyPhone string `json:"familyPhone"` +} + +func (a app) people(w http.ResponseWriter, r *http.Request) { + families, err := a.source.Table("directory", "families") + if err != nil { + serverError(w, err) + return + } + familiesByID := map[string]map[string]string{} + for _, family := range families { + familiesByID[family["id"]] = family + } + rows, err := a.source.Table("directory", "people") + if err != nil { + serverError(w, err) + return + } + people := []person{} + for _, row := range rows { + family, ok := familiesByID[row["family_id"]] + if !ok { + serverError(w, fmt.Errorf("person %s has unknown family_id %q", row["id"], row["family_id"])) + return + } + people = append(people, person{ + ID: row["id"], + FirstName: row["first_name"], + LastName: row["last_name"], + Role: row["role"], + Grade: row["grade"], + Email: row["email"], + Phone: row["phone"], + Pronunciation: row["pronunciation"], + FamilyName: family["name"], + Address: family["address"], + FamilyPhone: family["phone"], + }) + } + sort.Slice(people, func(i, j int) bool { + if people[i].LastName != people[j].LastName { + return people[i].LastName < people[j].LastName + } + return people[i].FirstName < people[j].FirstName + }) + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(people); err != nil { + log.Printf("[ERROR] encode people: %v", err) + } +} + +func serverError(w http.ResponseWriter, err error) { + log.Printf("[ERROR] %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..5f67ee0 --- /dev/null +++ b/main.go @@ -0,0 +1,23 @@ +// Heliosian serves the Helios school community apps. +package main + +import ( + "log" + "net/http" + "os" + + "heliosian/internal/data" + "heliosian/internal/directory" +) + +func main() { + mux := http.NewServeMux() + directory.Register(mux, data.Dir{Root: "sampledata"}) + mux.Handle("GET /{$}", http.RedirectHandler("/directory/", http.StatusFound)) + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + log.Printf("listening on http://localhost:%s", port) + log.Fatal(http.ListenAndServe(":"+port, mux)) +} diff --git a/web/directory/index.html b/web/directory/index.html new file mode 100644 index 0000000..b15016d --- /dev/null +++ b/web/directory/index.html @@ -0,0 +1,17 @@ + + + + + +Helios Directory + + + +
+

Helios Directory

+ +
+
+ + + diff --git a/web/directory/static/app.js b/web/directory/static/app.js new file mode 100644 index 0000000..bb41c0a --- /dev/null +++ b/web/directory/static/app.js @@ -0,0 +1,68 @@ +const state = {people: []}; + +function hue(text) { + let h = 0; + for (const c of text) { + h = (h * 31 + c.codePointAt(0)) % 360; + } + return h; +} + +function el(tag, className, text) { + const node = document.createElement(tag); + if (className) { + node.className = className; + } + if (text) { + node.textContent = text; + } + return node; +} + +function card(p) { + const root = el('div', 'card'); + const avatar = el('div', 'avatar', (p.firstName[0] || '') + (p.lastName[0] || '')); + avatar.style.background = `hsl(${hue(p.firstName + p.lastName)} 60% 45%)`; + root.append(avatar); + const info = el('div'); + info.append(el('div', 'name', `${p.firstName} ${p.lastName}`)); + if (p.pronunciation) { + info.append(el('div', 'pronunciation', p.pronunciation)); + } + const who = p.role === 'student' ? `Student, grade ${p.grade}` : 'Parent'; + info.append(el('div', 'detail', `${who} · ${p.familyName}`)); + info.append(el('div', 'detail', p.address)); + const contact = [p.phone || p.familyPhone, p.email].filter(Boolean).join(' · '); + if (contact) { + info.append(el('div', 'detail', contact)); + } + root.append(info); + return root; +} + +function render() { + const q = document.querySelector('#search').value.trim().toLowerCase(); + const list = document.querySelector('#people'); + list.replaceChildren(); + const matches = state.people.filter(p => + `${p.firstName} ${p.lastName} ${p.familyName}`.toLowerCase().includes(q)); + if (matches.length === 0) { + list.append(el('div', 'empty', 'No matches.')); + return; + } + for (const p of matches) { + list.append(card(p)); + } +} + +async function load() { + const res = await fetch('/directory/api/people'); + if (!res.ok) { + throw new Error(`loading people failed: ${res.status}`); + } + state.people = await res.json(); + render(); +} + +document.querySelector('#search').addEventListener('input', render); +load(); diff --git a/web/directory/static/style.css b/web/directory/static/style.css new file mode 100644 index 0000000..278c6d7 --- /dev/null +++ b/web/directory/static/style.css @@ -0,0 +1,96 @@ +:root { + --bg: #f6f7f9; + --card: #ffffff; + --line: #e2e6eb; + --text: #1c2430; + --muted: #5b6572; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: system-ui, sans-serif; + background: var(--bg); + color: var(--text); +} + +header { + position: sticky; + top: 0; + background: var(--card); + border-bottom: 1px solid var(--line); + padding: 1rem 1.5rem; + display: flex; + align-items: center; + gap: 1.5rem; +} + +header h1 { + font-size: 1.25rem; + margin: 0; +} + +#search { + flex: 1; + max-width: 24rem; + padding: 0.5rem 0.75rem; + border: 1px solid #cdd3db; + border-radius: 0.5rem; + font-size: 1rem; +} + +#people { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr)); + gap: 1rem; + padding: 1.5rem; + max-width: 72rem; + margin: 0 auto; +} + +.card { + background: var(--card); + border: 1px solid var(--line); + border-radius: 0.75rem; + padding: 1rem; + display: flex; + gap: 0.75rem; +} + +.avatar { + width: 3rem; + height: 3rem; + border-radius: 50%; + color: white; + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + flex-shrink: 0; +} + +.name { + font-weight: 600; +} + +.pronunciation { + color: var(--muted); + font-style: italic; + font-size: 0.875rem; +} + +.detail { + color: var(--muted); + font-size: 0.875rem; + margin-top: 0.25rem; +} + +.empty { + color: var(--muted); + padding: 3rem; + text-align: center; + grid-column: 1 / -1; +}