Skip to content

Commit df4194d

Browse files
authored
Improve WeatherClient UI (#194)
* Show weather data in WeatherClient UI * Enable creating WeatherClient in the UI * Fix unit tests
1 parent c1ab2a3 commit df4194d

14 files changed

+307
-83
lines changed

garden-app/pkg/weather/client.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"maps"
77
"net/http"
8+
"strings"
89
"time"
910

1011
"github.com/calvinmclean/automated-garden/garden-app/clock"
@@ -80,7 +81,7 @@ func (wc *Config) Bind(r *http.Request) error {
8081
// NewClient will use the config to create and return the correct type of weather client. If no type is provided, this will
8182
// return a nil client rather than an error since Weather client is not required
8283
func NewClient(c *Config, storageCallback func(map[string]any) error) (client Client, err error) {
83-
switch c.Type {
84+
switch strings.ToLower(c.Type) {
8485
case "netatmo":
8586
client, err = netatmo.NewClient(c.Options, storageCallback)
8687
case "fake":

garden-app/pkg/weather/fake/client.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ type Client struct {
2929
func NewClient(options map[string]any) (*Client, error) {
3030
client := &Client{}
3131

32-
err := mapstructure.Decode(options, &client.Config)
32+
err := mapstructure.WeakDecode(options, &client.Config)
3333
if err != nil {
3434
return nil, err
3535
}

garden-app/pkg/weather/netatmo/client.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,13 @@ var DefaultClient = http.DefaultClient
6060
// If RainModuleID is not provided, RainModuleName is used to get it from the API
6161
// For Authentication, AccessToken, RefreshToken, ClientID and ClientSecret are required
6262
func NewClient(options map[string]any, storageCallback func(map[string]any) error) (*Client, error) {
63-
client := &Client{Client: DefaultClient, storageCallback: storageCallback}
63+
client := &Client{
64+
Client: DefaultClient,
65+
storageCallback: storageCallback,
66+
Config: &Config{},
67+
}
6468

65-
err := mapstructure.Decode(options, &client.Config)
69+
err := mapstructure.WeakDecode(options, &client.Config)
6670
if err != nil {
6771
return nil, err
6872
}
@@ -193,8 +197,11 @@ func (c *Client) setDeviceIDs() error {
193197
}
194198

195199
func (c *Client) refreshToken() error {
196-
// It's safe to ignore the time.Parse error because knowing the expiration is an optional early exit
197-
expiry, _ := time.Parse(time.RFC3339Nano, c.Config.Authentication.ExpirationDate)
200+
expiry := clock.Now().AddDate(0, 0, -1)
201+
if c.Config.Authentication != nil {
202+
// It's safe to ignore the time.Parse error because knowing the expiration is an optional early exit
203+
expiry, _ = time.Parse(time.RFC3339Nano, c.Config.Authentication.ExpirationDate)
204+
}
198205

199206
// Exit early if token is not expired
200207
if clock.Now().Before(expiry) {

garden-app/server/templates.go

Lines changed: 29 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,24 +20,26 @@ import (
2020
var templates embed.FS
2121

2222
const (
23-
gardensPageTemplate html.Template = "GardensPage"
24-
gardensTemplate html.Template = "Gardens"
25-
gardenModalTemplate html.Template = "GardenModal"
26-
zonesPageTemplate html.Template = "ZonesPage"
27-
zonesTemplate html.Template = "Zones"
28-
zoneDetailsTemplate html.Template = "ZoneDetails"
29-
waterSchedulesPageTemplate html.Template = "WaterSchedulesPage"
30-
waterSchedulesTemplate html.Template = "WaterSchedules"
31-
waterScheduleModalTemplate html.Template = "WaterScheduleModal"
32-
waterScheduleDetailModalTemplate html.Template = "WaterScheduleDetailModal"
33-
zoneModalTemplate html.Template = "ZoneModal"
34-
zoneActionModalTemplate html.Template = "ZoneActionModal"
35-
weatherClientsPageTemplate html.Template = "WeatherClientsPage"
36-
weatherClientsTemplate html.Template = "WeatherClients"
37-
weatherClientModalTemplate html.Template = "WeatherClientModal"
38-
waterRoutinesPageTemplate html.Template = "WaterRoutinesPage"
39-
waterRoutinesTemplate html.Template = "WaterRoutines"
40-
waterRoutineModalTemplate html.Template = "WaterRoutineModal"
23+
gardensPageTemplate html.Template = "GardensPage"
24+
gardensTemplate html.Template = "Gardens"
25+
gardenModalTemplate html.Template = "GardenModal"
26+
zonesPageTemplate html.Template = "ZonesPage"
27+
zonesTemplate html.Template = "Zones"
28+
zoneDetailsTemplate html.Template = "ZoneDetails"
29+
waterSchedulesPageTemplate html.Template = "WaterSchedulesPage"
30+
waterSchedulesTemplate html.Template = "WaterSchedules"
31+
waterScheduleModalTemplate html.Template = "WaterScheduleModal"
32+
waterScheduleDetailModalTemplate html.Template = "WaterScheduleDetailModal"
33+
zoneModalTemplate html.Template = "ZoneModal"
34+
zoneActionModalTemplate html.Template = "ZoneActionModal"
35+
weatherClientsPageTemplate html.Template = "WeatherClientsPage"
36+
weatherClientsTemplate html.Template = "WeatherClients"
37+
weatherClientModalTemplate html.Template = "WeatherClientModal"
38+
weatherClientNetatmoConfigTemplate html.Template = "WeatherClientNetatmoConfig"
39+
weatherClientFakeConfigTemplate html.Template = "WeatherClientFakeConfig"
40+
waterRoutinesPageTemplate html.Template = "WaterRoutinesPage"
41+
waterRoutinesTemplate html.Template = "WaterRoutines"
42+
waterRoutineModalTemplate html.Template = "WaterRoutineModal"
4143
)
4244

4345
func templateFuncs(r *http.Request) map[string]any {
@@ -83,6 +85,15 @@ func templateFuncs(r *http.Request) map[string]any {
8385
"CelsiusToFahrenheit": func(c float64) float64 {
8486
return c*1.8 + 32
8587
},
88+
"IsMetric": func() bool {
89+
return getUnitsFromRequest(r) == "metric"
90+
},
91+
"DerefFloat32": func(f *float32) float32 {
92+
if f == nil {
93+
return 0
94+
}
95+
return *f
96+
},
8697
"timeNow": func() time.Time {
8798
return clock.Now()
8899
},

garden-app/server/templates/base.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
<li {{ if URLContains "/water_routines" }}class="uk-active" {{ end }}><a
3636
href="/water_routines">Water Routines</a></li>
3737
<li {{ if URLContains "/weather_clients" }}class="uk-active" {{ end }}><a
38-
href="/weather_clients">Weather Clients</a></li>
38+
href="/weather_clients?units=imperial">Weather Clients</a></li>
3939
<li>
4040
</li>
4141
</ul>
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
{{ define "WeatherClientNetatmoConfig" }}
2+
<div class="uk-margin">
3+
<label class="uk-form-label" for="station-id">Station ID</label>
4+
<input id="station-id" class="uk-input" value="{{ .Options.station_id }}" placeholder="Station ID" name="Options.station_id">
5+
</div>
6+
<div class="uk-margin">
7+
<label class="uk-form-label" for="station-name">Station Name</label>
8+
<input id="station-name" class="uk-input" value="{{ .Options.station_name }}" placeholder="Station Name" name="Options.station_name">
9+
</div>
10+
<div class="uk-margin">
11+
<label class="uk-form-label" for="rain-module-id">Rain Module ID</label>
12+
<input id="rain-module-id" class="uk-input" value="{{ .Options.rain_module_id }}" placeholder="Rain Module ID" name="Options.rain_module_id">
13+
</div>
14+
<div class="uk-margin">
15+
<label class="uk-form-label" for="rain-module-name">Rain Module Name</label>
16+
<input id="rain-module-name" class="uk-input" value="{{ .Options.rain_module_name }}" placeholder="Rain Module Name" name="Options.rain_module_name">
17+
</div>
18+
<div class="uk-margin">
19+
<label class="uk-form-label" for="outdoor-module-id">Outdoor Module ID</label>
20+
<input id="outdoor-module-id" class="uk-input" value="{{ .Options.outdoor_module_id }}" placeholder="Outdoor Module ID" name="Options.outdoor_module_id">
21+
</div>
22+
<div class="uk-margin">
23+
<label class="uk-form-label" for="outdoor-module-name">Outdoor Module Name</label>
24+
<input id="outdoor-module-name" class="uk-input" value="{{ .Options.outdoor_module_name }}" placeholder="Outdoor Module Name" name="Options.outdoor_module_name">
25+
</div>
26+
<div class="uk-margin">
27+
<label class="uk-form-label" for="client-id">Client ID</label>
28+
<input id="client-id" class="uk-input" value="{{ .Options.client_id }}" placeholder="Client ID" name="Options.client_id">
29+
</div>
30+
<div class="uk-margin">
31+
<label class="uk-form-label" for="client-secret">Client Secret</label>
32+
<input id="client-secret" class="uk-input" type="password" value="{{ .Options.client_secret }}" placeholder="Client Secret" name="Options.client_secret">
33+
</div>
34+
{{ end }}
35+
36+
{{ define "WeatherClientFakeConfig" }}
37+
<div class="uk-margin">
38+
<label class="uk-form-label" for="rain-mm">Rain (mm)</label>
39+
<input id="rain-mm" class="uk-input" type="number" step="0.1" value="{{ .Options.rain_mm }}" placeholder="Rain (mm)" name="Options.rain_mm">
40+
</div>
41+
<div class="uk-margin">
42+
<label class="uk-form-label" for="rain-interval">Rain Interval</label>
43+
<input id="rain-interval" class="uk-input" value="{{ .Options.rain_interval }}" placeholder="e.g., 1h, 30m" name="Options.rain_interval">
44+
</div>
45+
<div class="uk-margin">
46+
<label class="uk-form-label" for="avg-high-temp">Average High Temperature (°C)</label>
47+
<input id="avg-high-temp" class="uk-input" type="number" step="0.1" value="{{ .Options.avg_high_temperature }}" placeholder="Average High Temperature" name="Options.avg_high_temperature">
48+
</div>
49+
<div class="uk-margin">
50+
<label class="uk-form-label" for="error">Error (optional)</label>
51+
<input id="error" class="uk-input" value="{{ .Options.error }}" placeholder="Error message to simulate" name="Options.error">
52+
</div>
53+
{{ end }}

garden-app/server/templates/weather_client_modal.html

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,34 @@ <h3 class="uk-modal-title">{{ if .Type }}{{ .Type }}{{ else }}Create Weather Cli
66
<form _="on submit take .uk-open from #modal" hx-put="/weather_clients/{{ .ID }}"
77
hx-headers='{"Accept": "text/html"}' hx-swap="none">
88
<input type="hidden" value="{{ .ID }}" name="ID">
9+
10+
{{ if .Type }}
11+
<input type="hidden" value="{{ .Type }}" name="Type">
12+
<div class="uk-margin uk-text-left">
13+
<label class="uk-form-label"><strong>Type:</strong> {{ .Type }}</label>
14+
</div>
15+
<div id="config-form-container">
16+
{{ if eq .Type "netatmo" }}
17+
{{ template "WeatherClientNetatmoConfig" . }}
18+
{{ else if eq .Type "fake" }}
19+
{{ template "WeatherClientFakeConfig" . }}
20+
{{ end }}
21+
</div>
22+
{{ else }}
923
<div class="uk-margin">
10-
<input class="uk-input" value="{{ .Type }}" placeholder="Type" name="Type">
24+
<label class="uk-form-label" for="weather-client-type">Type</label>
25+
<select id="weather-client-type" class="uk-select" name="Type"
26+
hx-get="/weather_clients/components?type=config_form"
27+
hx-target="#config-form-container"
28+
hx-trigger="change"
29+
hx-swap="innerHTML">
30+
<option value="" selected disabled>Select Type...</option>
31+
<option value="netatmo">Netatmo</option>
32+
<option value="fake">Fake</option>
33+
</select>
1134
</div>
12-
13-
<!-- TODO: use select/dropdown for Type and use selection to render a form with relevant inputs -->
35+
<div id="config-form-container"></div>
36+
{{ end }}
1437

1538
{{ template "modalSubmitButton" }}
1639
{{ if .Type }}

garden-app/server/templates/weather_clients.html

Lines changed: 73 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,27 +5,81 @@
55
{{ end }}
66

77
{{ define "WeatherClients" }}
8-
<div hx-swap="outerHTML" hx-get="/weather_clients?refresh=true" hx-headers='{"Accept": "text/html"}'
9-
hx-trigger="{{ if NotRefresh }}load, {{ end }}newWeatherClient from:body" uk-grid>
10-
{{ range .Items }}
11-
{{ template "weatherClientCard" . }}
12-
{{ end }}
13-
</div>
14-
<div id="edit-modal-here"></div>
15-
{{ end }}
16-
17-
{{ define "weatherClientCard" }}
18-
<div class="uk-width-1-2@m" id="weather-client-card-{{ .ID }}">
19-
<div class="uk-card uk-card-default" style="margin: 5%;">
20-
<div class="uk-card-header uk-text-center">
21-
<h3 class="uk-card-title uk-margin-remove-bottom">
22-
{{ .ID }}
23-
</h3>
24-
{{ template "cardEditButton" (print "/weather_clients/" .ID "/components?type=edit_modal") }}
8+
<div hx-swap="outerHTML" hx-get="/weather_clients?refresh=true&units={{ .Units }}&duration={{ .Duration }}" hx-headers='{"Accept": "text/html"}'
9+
hx-trigger="{{ if NotRefresh }}load, {{ end }}newWeatherClient from:body">
10+
<div class="uk-margin-small-top uk-margin-small-bottom uk-text-center">
11+
<div class="uk-button-group" style="margin-right: 10px;">
12+
<button class="uk-button uk-button-small {{ if IsMetric }}uk-button-primary{{ else }}uk-button-default{{ end }}"
13+
hx-get="/weather_clients?refresh=true&units=metric&duration={{ .Duration }}"
14+
hx-headers='{"Accept": "text/html"}'
15+
hx-target="closest div[hx-get]"
16+
hx-swap="outerHTML"
17+
hx-push-url="/weather_clients?units=metric&duration={{ .Duration }}">
18+
Metric
19+
</button>
20+
<button class="uk-button uk-button-small {{ if not IsMetric }}uk-button-primary{{ else }}uk-button-default{{ end }}"
21+
hx-get="/weather_clients?refresh=true&units=imperial&duration={{ .Duration }}"
22+
hx-headers='{"Accept": "text/html"}'
23+
hx-target="closest div[hx-get]"
24+
hx-swap="outerHTML"
25+
hx-push-url="/weather_clients?units=imperial&duration={{ .Duration }}">
26+
Imperial
27+
</button>
2528
</div>
26-
<div class="uk-card-body">
27-
{{ .Type }}
29+
<select class="uk-select uk-form-small" style="width: auto; min-width: 90px; display: inline-block;"
30+
hx-get="/weather_clients?refresh=true&units={{ .Units }}"
31+
hx-headers='{"Accept": "text/html"}'
32+
hx-target="closest div[hx-get]"
33+
hx-swap="outerHTML"
34+
hx-trigger="change"
35+
hx-vals="js:{duration: event.target.value}"
36+
hx-push-url="js:'/weather_clients?units={{ .Units }}&duration=' + event.target.value">
37+
<option value="24h" {{ if eq .Duration.String "24h0m0s" }}selected{{ end }}>24h</option>
38+
<option value="48h" {{ if eq .Duration.String "48h0m0s" }}selected{{ end }}>48h</option>
39+
<option value="72h" {{ if eq .Duration.String "72h0m0s" }}selected{{ end }}>72h</option>
40+
<option value="168h" {{ if eq .Duration.String "168h0m0s" }}selected{{ end }}>1 week</option>
41+
</select>
42+
</div>
43+
<div uk-grid>
44+
{{ range .Items }}
45+
<div class="uk-width-1-2@m" id="weather-client-card-{{ .ID }}">
46+
<div class="uk-card uk-card-default" style="margin: 5%;">
47+
<div class="uk-card-header uk-text-center">
48+
<h3 class="uk-card-title uk-margin-remove-bottom">
49+
{{ .Type }}
50+
</h3>
51+
{{ template "cardEditButton" (print "/weather_clients/" .ID "/components?type=edit_modal") }}
52+
</div>
53+
<div class="uk-card-body">
54+
{{ if .WeatherData }}
55+
<div class="uk-flex uk-flex-center uk-flex-middle uk-grid-small" uk-grid>
56+
{{ if .WeatherData.Temperature }}
57+
<div class="uk-flex uk-flex-middle">
58+
<span uk-icon="thermometer" class="uk-margin-small-right" style="color: #faa05a;"></span>
59+
{{ if IsMetric }}
60+
<span class="uk-text-bold" style="color: #faa05a;">{{ printf "%.1f°C" .WeatherData.Temperature.Celsius }}</span>
61+
{{ else }}
62+
<span class="uk-text-bold" style="color: #faa05a;">{{ printf "%.1f°F" .WeatherData.Temperature.Fahrenheit }}</span>
63+
{{ end }}
64+
</div>
65+
{{ end }}
66+
{{ if .WeatherData.Rain }}
67+
<div class="uk-flex uk-flex-middle">
68+
<span uk-icon="cloud-rain" class="uk-margin-small-right uk-text-primary"></span>
69+
{{ if IsMetric }}
70+
<span class="uk-text-bold uk-text-primary">{{ printf "%.1f mm" (DerefFloat32 .WeatherData.Rain.MM) }}</span>
71+
{{ else }}
72+
<span class="uk-text-bold uk-text-primary">{{ printf "%.2f in" (DerefFloat32 .WeatherData.Rain.Inches) }}</span>
73+
{{ end }}
74+
</div>
75+
{{ end }}
76+
</div>
77+
{{ end }}
78+
</div>
79+
</div>
2880
</div>
81+
{{ end }}
2982
</div>
3083
</div>
84+
<div id="edit-modal-here"></div>
3185
{{ end }}

garden-app/server/water_schedule_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ func TestGetWaterSchedule(t *testing.T) {
7676
},
7777
},
7878
},
79-
`{"id":"c5cvhpcbcv45e8bp16dg","duration":"1h0m0s","interval":"24h0m0s","start_date":"\d{4}-\d{2}-\d\dT\d\d:\d\d:\d\d(\.\d+)?(-07:00|Z)","start_time":"11:24:52-07:00","weather_control":{"rain_control":{"baseline_value":0,"factor":0,"range":25.4,"client_id":"c5cvhpcbcv45e8bp16dg"},"temperature_control":{"baseline_value":30,"factor":0.5,"range":10,"client_id":"c5cvhpcbcv45e8bp16dg"}},"weather_data":{"rain":{"mm":25.4,"scale_factor":0},"average_temperature":{"celsius":80,"scale_factor":1.5}},"next_water":{"time":"\d\d\d\d-\d\d-\d\dT11:24:52-07:00","duration":"0s"},"links":\[{"rel":"self","href":"/water_schedules/c5cvhpcbcv45e8bp16dg"}\]}`,
79+
`{"id":"c5cvhpcbcv45e8bp16dg","duration":"1h0m0s","interval":"24h0m0s","start_date":"\d{4}-\d{2}-\d\dT\d\d:\d\d:\d\d(\.\d+)?(-07:00|Z)","start_time":"11:24:52-07:00","weather_control":{"rain_control":{"baseline_value":0,"factor":0,"range":25.4,"client_id":"c5cvhpcbcv45e8bp16dg"},"temperature_control":{"baseline_value":30,"factor":0.5,"range":10,"client_id":"c5cvhpcbcv45e8bp16dg"}},"weather_data":{"rain":{"mm":25.4,"inches":1.0000006},"temperature":{"celsius":80,"fahrenheit":176}},"next_water":{"time":"\d\d\d\d-\d\d-\d\dT11:24:52-07:00","duration":"0s"},"links":\[{"rel":"self","href":"/water_schedules/c5cvhpcbcv45e8bp16dg"}\]}`,
8080
},
8181
{
8282
"SuccessfulWithRainAndTemperatureDataButWeatherDataExcluded",

garden-app/server/weather_client_responses.go

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,11 @@ func (resp *WeatherClientTestResponse) Render(_ http.ResponseWriter, _ *http.Req
2121

2222
type WeatherClientResponse struct {
2323
*weather.Config
24+
WeatherData *WeatherData `json:"weather_data,omitempty"`
2425

2526
Links []Link `json:"links,omitempty"`
27+
28+
api *WeatherClientsAPI
2629
}
2730

2831
// Render ...
@@ -36,6 +39,15 @@ func (resp *WeatherClientResponse) Render(w http.ResponseWriter, r *http.Request
3639
)
3740
}
3841

42+
if resp.api != nil && resp.Config != nil {
43+
units := getUnitsFromRequest(r)
44+
duration := getDurationFromRequest(r)
45+
weatherData, err := resp.api.getWeatherData(r.Context(), resp.Config, units, duration)
46+
if err == nil {
47+
resp.WeatherData = &weatherData
48+
}
49+
}
50+
3951
if render.GetAcceptedContentType(r) == render.ContentTypeHTML && r.Method == http.MethodPut {
4052
w.Header().Add("HX-Trigger", "newWeatherClient")
4153
}
@@ -56,9 +68,17 @@ func (aws AllWeatherClientsResponse) HTML(_ http.ResponseWriter, r *http.Request
5668
return strings.Compare(w.Type, x.Type)
5769
})
5870

71+
units := getUnitsFromRequest(r)
72+
duration := getDurationFromRequest(r)
73+
data := map[string]any{
74+
"Items": aws.Items,
75+
"Units": units,
76+
"Duration": duration,
77+
}
78+
5979
if r.URL.Query().Get("refresh") == "true" {
60-
return weatherClientsTemplate.Render(r, aws)
80+
return weatherClientsTemplate.Render(r, data)
6181
}
6282

63-
return weatherClientsPageTemplate.Render(r, aws)
83+
return weatherClientsPageTemplate.Render(r, data)
6484
}

0 commit comments

Comments
 (0)