refactor: aufgeraeumt — Pakete hinten, Widgets vorne

Go: store, auth, weather und config sind eigene Pakete unter internal/. Vorher
lagen fuenf Dateien in einem Topf, und jede kannte jede.

Web: os.js hatte 1425 Zeilen und alles darin. Jetzt core.js (Zustand, Server,
Einstellungen), glyphs.js (die Pixelraster), js/widgets/ mit einem Widget je
Datei, os.js nur noch die Ansichten. Stylesheet nach css/.

Die HoneyBar bleibt eine einzelne Datei ohne Importe — sie wird von fremden
Seiten mit einer Zeile eingebunden, da darf nichts nachladen.

Nebenbei: Das gebaute Binary lag mit 9,8 MB im Repo und ist jetzt ignoriert.
Und der Test gegen eine zweite Versionsnummer geht nicht mehr auf os.js, sondern
durch alle .js — sonst haette ihn dieser Umbau still ausgehebelt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-29 20:51:21 +02:00
1 parent d08b0a229c
commit 52deffa30e
20 files changed
+780 -624

No files matched your search

+37
View File
@@ -0,0 +1,37 @@
package weather
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// FakeFuerTest gibt einen Dienst, der einen erfundenen Wetterserver befragt.
//
// Steht bewusst nicht in der _test.go: Auch die Server-Tests im Wurzelpaket
// brauchen ihn, und kein Test in diesem Projekt fragt je wirklich bei
// Open-Meteo an — das wäre langsam, unzuverlässig und benutzte einen fremden
// Dienst für unsere Testläufe.
func FakeFuerTest(t *testing.T, code int, grad float64) *Dienst {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "search") {
json.NewEncoder(w).Encode(map[string]any{"results": []map[string]any{
{"name": "Köln", "country": "Deutschland", "admin1": "Nordrhein-Westfalen",
"latitude": 50.938, "longitude": 6.96},
}})
return
}
json.NewEncoder(w).Encode(map[string]any{"current": map[string]any{
"temperature_2m": grad, "weather_code": code,
}})
}))
t.Cleanup(srv.Close)
d := Neu()
d.wetterURL = srv.URL + "/forecast"
d.sucheURL = srv.URL + "/search"
return d
}
+212
View File
@@ -0,0 +1,212 @@
// Package weather holt das Wetter — serverseitig, damit nicht jede Seite in
// jeder App selbst beim Wetterdienst anfragt.
package weather
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
)
// Wetter kommt vom Server, nicht aus dem Browser.
//
// Die Leiste läuft auf jeder Seite jeder App. Fragte sie selbst beim
// Wetterdienst an, ginge bei jedem Seitenaufruf aus jeder App eine Anfrage samt
// IP dorthin — und die CSP jeder App müsste den fremden Ursprung erlauben. Holt
// HoneyOS es, sieht der Dienst einen Server, und die Apps bleiben unberührt.
//
// Open-Meteo braucht keinen Schlüssel und keine Anmeldung. Das ist der Grund für
// die Wahl: ein Zugangsschlüssel in der .env wäre eine Sache mehr, die jemand
// einrichten muss, bevor irgendetwas zu sehen ist.
const (
wetterFrisch = 10 * time.Minute // so lange gilt eine Antwort
wetterTimeout = 6 * time.Second // ein hängender Dienst darf nichts aufhalten
)
type Wetter struct {
Ort string `json:"ort"`
Grad float64 `json:"grad"`
Code int `json:"code"`
Text string `json:"text"`
Glyph string `json:"glyph"`
Stand string `json:"stand"`
}
type Ort struct {
Name string `json:"name"`
Land string `json:"land"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
}
type Dienst struct {
mu sync.Mutex
cache map[string]wetterEintrag
client *http.Client
// Adressen als Feld, damit der Test einen eigenen Server unterschieben kann.
wetterURL string
sucheURL string
}
type wetterEintrag struct {
w Wetter
bis time.Time
}
func Neu() *Dienst {
return &Dienst{
cache: map[string]wetterEintrag{},
client: &http.Client{Timeout: wetterTimeout},
wetterURL: "https://api.open-meteo.com/v1/forecast",
sucheURL: "https://geocoding-api.open-meteo.com/v1/search",
}
}
// wmo übersetzt den Wettercode in ein Pixelsymbol und einen kurzen Text.
// Die Skala ist die der Weltorganisation für Meteorologie; Open-Meteo liefert
// sie so. Zusammengefasst auf sechs Symbole — feiner lässt sich in zwölf mal
// zwölf Pixeln ohnehin nichts unterscheiden.
func wmo(code int) (glyph, text string) {
switch {
case code == 0:
return "sonne", "klar"
case code <= 2:
return "heiter", "heiter"
case code == 3:
return "wolke", "bedeckt"
case code == 45 || code == 48:
return "nebel", "nebel"
case code >= 51 && code <= 67, code >= 80 && code <= 82:
return "regen", "regen"
case code >= 71 && code <= 77, code == 85 || code == 86:
return "schnee", "schnee"
case code >= 95:
return "gewitter", "gewitter"
}
return "wolke", "wolken"
}
// Jetzt liefert das Wetter für einen Ort — aus dem Zwischenspeicher, solange er
// frisch ist. Das Wetter ändert sich langsamer, als Seiten neu geladen werden;
// ohne diesen Speicher stellte jeder Seitenaufruf in jeder App eine Anfrage
// nach draußen.
func (d *Dienst) Jetzt(ctx context.Context, ort string, lat, lon float64) (Wetter, error) {
schlüssel := fmt.Sprintf("%.3f,%.3f", lat, lon)
d.mu.Lock()
if e, ok := d.cache[schlüssel]; ok && time.Now().Before(e.bis) {
d.mu.Unlock()
e.w.Ort = ort // der Name gehört den Einstellungen, nicht dem Dienst
return e.w, nil
}
d.mu.Unlock()
u, _ := url.Parse(d.wetterURL)
q := u.Query()
q.Set("latitude", strconv.FormatFloat(lat, 'f', 4, 64))
q.Set("longitude", strconv.FormatFloat(lon, 'f', 4, 64))
q.Set("current", "temperature_2m,weather_code")
q.Set("timezone", "auto")
u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return Wetter{}, err
}
res, err := d.client.Do(req)
if err != nil {
return Wetter{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return Wetter{}, fmt.Errorf("wetterdienst antwortet %d", res.StatusCode)
}
var antwort struct {
Current struct {
Temp float64 `json:"temperature_2m"`
Code int `json:"weather_code"`
} `json:"current"`
}
if err := json.NewDecoder(res.Body).Decode(&antwort); err != nil {
return Wetter{}, err
}
glyph, text := wmo(antwort.Current.Code)
w := Wetter{
Ort: ort,
Grad: antwort.Current.Temp,
Code: antwort.Current.Code,
Text: text,
Glyph: glyph,
Stand: time.Now().Format(time.RFC3339),
}
d.mu.Lock()
d.cache[schlüssel] = wetterEintrag{w: w, bis: time.Now().Add(wetterFrisch)}
d.mu.Unlock()
return w, nil
}
// Suche findet Orte zu einem Namen. Nur für die Einstellungen: Der gefundene
// Ort wird mit seinen Koordinaten gespeichert, damit die spätere Abfrage nicht
// jedes Mal erneut sucht.
func (d *Dienst) Suche(ctx context.Context, name string) ([]Ort, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, fmt.Errorf("kein Suchbegriff")
}
u, _ := url.Parse(d.sucheURL)
q := u.Query()
q.Set("name", name)
q.Set("count", "6")
q.Set("language", "de")
q.Set("format", "json")
u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
}
res, err := d.client.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("ortssuche antwortet %d", res.StatusCode)
}
var antwort struct {
Results []struct {
Name string `json:"name"`
Country string `json:"country"`
Admin1 string `json:"admin1"`
Lat float64 `json:"latitude"`
Lon float64 `json:"longitude"`
} `json:"results"`
}
if err := json.NewDecoder(res.Body).Decode(&antwort); err != nil {
return nil, err
}
orte := make([]Ort, 0, len(antwort.Results))
for _, r := range antwort.Results {
land := r.Country
// „Köln, Nordrhein-Westfalen" trennt zwei Treffer besser als
// „Köln, Deutschland" — davon gibt es mehrere.
if r.Admin1 != "" && r.Admin1 != r.Name {
land = r.Admin1 + ", " + r.Country
}
orte = append(orte, Ort{Name: r.Name, Land: land, Lat: r.Lat, Lon: r.Lon})
}
return orte, nil
}
+116
View File
@@ -0,0 +1,116 @@
package weather
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
)
// Ein erfundener Wetterdienst. Kein Test in diesem Projekt fragt je wirklich
// bei Open-Meteo an: Das wäre langsam, unzuverlässig und würde einen fremden
// Dienst für unsere Testläufe benutzen.
func fakeWetter(t *testing.T, code int, grad float64, aufrufe *int32) *Dienst {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if aufrufe != nil {
atomic.AddInt32(aufrufe, 1)
}
if strings.Contains(r.URL.Path, "search") {
json.NewEncoder(w).Encode(map[string]any{"results": []map[string]any{
{"name": "Köln", "country": "Deutschland", "admin1": "Nordrhein-Westfalen",
"latitude": 50.938, "longitude": 6.96},
}})
return
}
json.NewEncoder(w).Encode(map[string]any{"current": map[string]any{
"temperature_2m": grad, "weather_code": code,
}})
}))
t.Cleanup(srv.Close)
d := Neu()
d.wetterURL = srv.URL + "/forecast"
d.sucheURL = srv.URL + "/search"
return d
}
func TestWetterKommtAn(t *testing.T) {
d := fakeWetter(t, 61, 12.4, nil)
w, err := d.Jetzt(context.Background(), "Köln", 50.9, 6.9)
if err != nil {
t.Fatalf("unerwartet: %v", err)
}
if w.Ort != "Köln" || w.Grad != 12.4 {
t.Fatalf("falsche Werte: %+v", w)
}
if w.Glyph != "regen" || w.Text != "regen" {
t.Fatalf("Code 61 ist Regen, nicht %q/%q", w.Glyph, w.Text)
}
}
// Der Zwischenspeicher ist der Grund, warum die Leiste auf jeder Seite jeder App
// stehen darf: Ohne ihn ginge bei jedem Seitenaufruf eine Anfrage nach draußen.
func TestZweiteFrageGehtNichtNachDraussen(t *testing.T) {
var aufrufe int32
d := fakeWetter(t, 0, 21, &aufrufe)
for i := 0; i < 5; i++ {
if _, err := d.Jetzt(context.Background(), "Köln", 50.9, 6.9); err != nil {
t.Fatalf("unerwartet: %v", err)
}
}
if aufrufe != 1 {
t.Fatalf("%d Anfragen nach draußen, erwartet 1", aufrufe)
}
}
func TestAndererOrtWirdGetrenntGehalten(t *testing.T) {
var aufrufe int32
d := fakeWetter(t, 0, 21, &aufrufe)
d.Jetzt(context.Background(), "Köln", 50.9, 6.9)
d.Jetzt(context.Background(), "Wien", 48.2, 16.4)
if aufrufe != 2 {
t.Fatalf("zwei Orte, %d Anfragen", aufrufe)
}
}
func TestWettercodesWerdenZuSymbolen(t *testing.T) {
fälle := map[int]string{
0: "sonne", 2: "heiter", 3: "wolke", 45: "nebel",
61: "regen", 80: "regen", 73: "schnee", 86: "schnee", 95: "gewitter",
}
for code, will := range fälle {
if g, _ := wmo(code); g != will {
t.Errorf("Code %d → %q, erwartet %q", code, g, will)
}
}
}
func TestOrtssucheGibtKoordinatenZurueck(t *testing.T) {
d := fakeWetter(t, 0, 20, nil)
orte, err := d.Suche(context.Background(), "Köln")
if err != nil {
t.Fatalf("unerwartet: %v", err)
}
if len(orte) != 1 || orte[0].Lat == 0 || orte[0].Lon == 0 {
t.Fatalf("ohne Koordinaten müsste jede Abfrage neu suchen: %+v", orte)
}
// Bundesland statt Land: „Köln, Deutschland" gibt es mehrfach.
if !strings.Contains(orte[0].Land, "Nordrhein-Westfalen") {
t.Fatalf("Land zu grob: %q", orte[0].Land)
}
}
func TestSucheOhneBegriffFragtNichtAn(t *testing.T) {
var aufrufe int32
d := fakeWetter(t, 0, 20, &aufrufe)
if _, err := d.Suche(context.Background(), " "); err == nil {
t.Fatal("leerer Begriff müsste abgelehnt werden")
}
if aufrufe != 0 {
t.Fatalf("leerer Begriff ging trotzdem nach draußen (%d)", aufrufe)
}
}