diff --git a/docs/dev.md b/docs/dev.md index 6e9c63f..de1984e 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -14,6 +14,13 @@ Everything — pages, static assets, and the API — sits behind Google sign-in To capture authenticated pages with the screenshot tooling, launch the capture browser (`go run ./tools/capturebrowser`), sign in to the local server there once, and use `tools/browse` or `tools/screenshot -remote` — the session cookie lives in the capture profile. Plain `tools/screenshot` runs a fresh headless browser with no session and captures the login page. +## Maps + +The map section geocodes family addresses server-side via the Google Geocoding API (results cached in memory per address) and renders in the browser with the Maps JavaScript API. Two API keys from a project with those APIs enabled, both required at startup: + +- Server key — Geocoding API; restrict by server IP (or leave unrestricted for dev). Never rendered into pages. Read from `creds/geocoding.key`, or `GOOGLE_MAPS_SERVER_KEY` when set. +- Browser key — Maps JavaScript API; rendered into the page, so restrict by HTTP referer (localhost and the serving domain). Read from `creds/maps.key`, or `GOOGLE_MAPS_BROWSER_KEY` when set. + ## 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. diff --git a/docs/directory.md b/docs/directory.md index ba69290..fe1b42f 100644 --- a/docs/directory.md +++ b/docs/directory.md @@ -49,7 +49,7 @@ The staff list as a top-level section — same content as the People staff tab. ### Map -A map of family locations, plus an "update my address" self-service action. +A Google map of family locations: one brand-teal pin per geocoded family address, a popup card (family photo, name, address, family-page link) on pin click, and search and filters narrowing the pins. Below the map, an "update my address" self-service action. ### Email List diff --git a/docs/plan.md b/docs/plan.md index 8537b33..90b54d8 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -4,7 +4,7 @@ What remains to build. Current behavior is documented in `docs/dev.md`, `docs/da ## Directory app -- Remaining sections: My Family, Map. +- Remaining sections: My Family. - Mobile chrome: brand-teal top bar and bottom tab navigation on narrow screens (see `docs/directory.md`); today only the desktop chrome is faithful. - 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/cache.go b/internal/directory/cache.go index 2a7df8e..1a2b7bc 100644 --- a/internal/directory/cache.go +++ b/internal/directory/cache.go @@ -6,18 +6,20 @@ import ( "time" "heliosian/internal/data" + "heliosian/internal/geocode" ) const refreshInterval = 5 * time.Minute type Cache struct { - source data.Source - mu sync.RWMutex - model *Model + source data.Source + geocoder *geocode.Client + mu sync.RWMutex + model *Model } -func NewCache(source data.Source) (*Cache, error) { - c := &Cache{source: source} +func NewCache(source data.Source, geocoder *geocode.Client) (*Cache, error) { + c := &Cache{source: source, geocoder: geocoder} if err := c.refresh(); err != nil { return nil, err } @@ -45,6 +47,7 @@ func (c *Cache) refresh() error { if err != nil { return err } + c.geocodeFamilies(model) c.mu.Lock() c.model = model c.mu.Unlock() @@ -53,3 +56,45 @@ func (c *Cache) refresh() error { time.Since(start).Round(time.Millisecond)) return nil } + +func (c *Cache) geocodeFamilies(model *Model) { + start := time.Now() + type job struct { + key string + address string + } + jobs := make(chan job) + var wg sync.WaitGroup + var mu sync.Mutex + located := 0 + for range 8 { + wg.Add(1) + go func() { + defer wg.Done() + for j := range jobs { + point, err := c.geocoder.Lookup(j.address) + if err != nil { + log.Printf("[ERROR] %v", err) + continue + } + mu.Lock() + family := model.Families[j.key] + family.Lat = point.Lat + family.Lng = point.Lng + model.Families[j.key] = family + located++ + mu.Unlock() + } + }() + } + total := 0 + for key, family := range model.Families { + if family.Address != "" { + total++ + jobs <- job{key: key, address: family.Address} + } + } + close(jobs) + wg.Wait() + log.Printf("geocoded %d of %d family addresses in %s", located, total, time.Since(start).Round(time.Millisecond)) +} diff --git a/internal/directory/directory.go b/internal/directory/directory.go index 8c24805..83c61c6 100644 --- a/internal/directory/directory.go +++ b/internal/directory/directory.go @@ -23,11 +23,12 @@ var legacy = map[string]string{ } type app struct { - cache *Cache + cache *Cache + mapsKey string } -func Register(mux *http.ServeMux, cache *Cache) { - a := app{cache: cache} +func Register(mux *http.ServeMux, cache *Cache, mapsKey string) { + a := app{cache: cache, mapsKey: mapsKey} for _, section := range sections { mux.HandleFunc("GET /"+section, a.page) } @@ -60,6 +61,7 @@ func (a app) page(w http.ResponseWriter, r *http.Request) { "UserName": name, "UserInitial": strings.ToUpper(name[:1]), "UserEmail": auth.Email(r), + "MapsKey": a.mapsKey, } if err := t.Execute(w, data); err != nil { log.Printf("[ERROR] render directory page: %v", err) diff --git a/internal/directory/model.go b/internal/directory/model.go index 3d2214d..07e204f 100644 --- a/internal/directory/model.go +++ b/internal/directory/model.go @@ -28,6 +28,8 @@ type Family struct { Key string `json:"key"` Name string `json:"name,omitempty"` Address string `json:"address,omitempty"` + Lat float64 `json:"lat,omitempty"` + Lng float64 `json:"lng,omitempty"` PhotoURL string `json:"photoUrl,omitempty"` PhotoCaption string `json:"photoCaption,omitempty"` PronunciationURL string `json:"pronunciationUrl,omitempty"` diff --git a/internal/geocode/geocode.go b/internal/geocode/geocode.go new file mode 100644 index 0000000..9c15f6d --- /dev/null +++ b/internal/geocode/geocode.go @@ -0,0 +1,62 @@ +// Package geocode resolves street addresses to coordinates via the google geocoding api. +package geocode + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "sync" +) + +type Point struct { + Lat float64 + Lng float64 +} + +type Client struct { + key string + mu sync.Mutex + cache map[string]Point +} + +func New(key string) *Client { + return &Client{key: key, cache: map[string]Point{}} +} + +func (c *Client) Lookup(address string) (Point, error) { + c.mu.Lock() + point, ok := c.cache[address] + c.mu.Unlock() + if ok { + return point, nil + } + resp, err := http.Get("https://maps.googleapis.com/maps/api/geocode/json?address=" + + url.QueryEscape(address) + "&key=" + url.QueryEscape(c.key)) + if err != nil { + return Point{}, err + } + defer resp.Body.Close() + var parsed struct { + Status string `json:"status"` + Results []struct { + Geometry struct { + Location struct { + Lat float64 `json:"lat"` + Lng float64 `json:"lng"` + } `json:"location"` + } `json:"geometry"` + } `json:"results"` + } + if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { + return Point{}, err + } + if parsed.Status != "OK" || len(parsed.Results) == 0 { + return Point{}, fmt.Errorf("geocode %q: %s", address, parsed.Status) + } + point = Point{Lat: parsed.Results[0].Geometry.Location.Lat, Lng: parsed.Results[0].Geometry.Location.Lng} + c.mu.Lock() + c.cache[address] = point + c.mu.Unlock() + return point, nil +} diff --git a/main.go b/main.go index 8e81cf5..0ed9dc0 100644 --- a/main.go +++ b/main.go @@ -7,11 +7,13 @@ import ( "log" "net/http" "os" + "strings" "heliosian/internal/auth" "heliosian/internal/blob" "heliosian/internal/data" "heliosian/internal/directory" + "heliosian/internal/geocode" ) func directorySource() data.Source { @@ -64,15 +66,32 @@ func noCache(next http.Handler) http.Handler { }) } +func mapsKey(envName, file string) string { + if key := os.Getenv(envName); key != "" { + return key + } + raw, err := os.ReadFile(file) + if err != nil { + log.Fatalf("[ERROR] read %s (or set %s): %v", file, envName, err) + } + key := strings.TrimSpace(string(raw)) + if key == "" { + log.Fatalf("[ERROR] %s is empty", file) + } + return key +} + func main() { authn := auth.New(clientID(), sessionKey()) - cache, err := directory.NewCache(directorySource()) + serverKey := mapsKey("GOOGLE_MAPS_SERVER_KEY", "creds/geocoding.key") + browserKey := mapsKey("GOOGLE_MAPS_BROWSER_KEY", "creds/maps.key") + cache, err := directory.NewCache(directorySource(), geocode.New(serverKey)) if err != nil { log.Fatalf("[ERROR] load directory data: %v", err) } mux := http.NewServeMux() authn.Register(mux) - directory.Register(mux, cache) + directory.Register(mux, cache, browserKey) if os.Getenv("DIRECTORY_SHEET") != "" { store, err := blob.New() if err != nil { diff --git a/web/directory/index.html b/web/directory/index.html index 12718b6..e0d8fbb 100644 --- a/web/directory/index.html +++ b/web/directory/index.html @@ -9,7 +9,7 @@ - +