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 @@ + + +
+ + +