UX
Indication
The free weather API is pretty slow, so users need some feedback.
We will use the indication API on the dashboard nav, but with a nuance: switching units should show indication only on two of our charts.
Prepare indication target:
type chart struct {
title string
svg func() []byte
// New property for units-dependent charts.
unitsLoader bool
}
elem (c chart) Main() {
<article>
<header>
~(c.title, " ")
~// Target for our indication.
<span
class=func {
if c.unitsLoader {
return "chart-loader chart-loader-units"
}
return "chart-loader"
}>
</span>
</header>
<img height="auto" width="100%" src=(c.svg()) type="image/svg+xml"/>
</article>
}
Wire it up in charts where it depends on units:
elem (d dashboard) charts(city driver.City) {
<div class="grid">
<div>
~>(new(doors.Door)) ~func {
days, _ := d.days.Effect(ctx)
units, ok := d.units.Effect(ctx)
/* ... */
return chart{
/* ... */
unitsLoader: true,
}
}
~/* ... */
</div>
<div>
~/* ... */
~>(new(doors.Door)) ~func {
days, _ := d.days.Effect(ctx)
units, ok := d.units.Effect(ctx)
/* ... */
return chart{
title: "Wind Speed",
/* ... */
unitsLoader: true,
}
}
</div>
</div>
}
Trigger it from the link:
type navLink struct {
city int
days int
units driver.Units
text any
// New field.
unitsLoader bool
}
elem (l navLink) Main() {
~{
target := ".chart-loader"
if l.unitsLoader {
target = ".chart-loader-units"
}
}
<a
class="secondary"
(doors.ALink{
Indicator: doors.IndicateAttrQueryAll(target, "aria-busy", "true"),
Model: Path{
Route: Dashboard,
CityID: l.city,
Days: daysQuery(l.days),
Units: unitsQuery(l.units),
},
})>
~(l.text)
</a>
}
Finally, wire it up in the units nav:
elem (d dashboard) unitNav(days int) {
<ul>
~(for _, units := range []driver.Units{driver.Metric, driver.Imperial} {
<li>
~navLink{
city: d.cityID,
days: days,
units: units,
text: units,
// Show the units loader.
unitsLoader: true,
}
</li>
})
</ul>
}
Take a look at the result with simulated throttling:

It's better, but jumping images are not good. Let's add a simple image preloader.
Styles
At this point we also need a bit of CSS.
Put this file in the project root:
style.css
.img-wrapper {
position: relative;
aspect-ratio: 3 / 2;
width: 100%;
}
.img-loader {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 0;
}
.img-wrapper img {
position: relative;
z-index: 1;
}
Next, embed and serve it from the app:
//go:embed style.css
var styles []byte
elem (a App) Main() {
~/* ... */
<head>
/* ... */
<link href=(styles) rel="stylesheet">
</head>
~/* ... */
}
The embedded style will be included in the compiled binary, which is very convenient.
For stylesheet resources, see Styles.
Add a preloader for SVGs:
dashboard.gox
elem (c chart) Main() {
<article>
<header>
~(c.title, " ")
<span
class=func {
if c.unitsLoader {
return "chart-loader chart-loader-units"
}
return "chart-loader"
}>
</span>
</header>
<div class="img-wrapper">
<img height="auto" width="100%" src=(c.svg()) type="image/svg+xml"/>
<div class="img-loader" aria-busy="true"></div>
</div>
</article>
}
Result:

Lazy Loading
Querying weather data takes some time, so the initial dashboard load is still a bit slower than it needs to be.

We can improve the perceived loading time by generating the SVG in the background:
elem (c chart) Main() {
~{
// Create a dynamic placeholder.
door := new(doors.Door)
// Replace it with the static content after it is rendered.
defer door.Static(ctx, <img height="auto" width="100%" src=(c.svg()) type="image/svg+xml"/>)
}
<article>
<header>
~(c.title, " ")
<span
class=func {
if c.unitsLoader {
return "chart-loader chart-loader-units"
}
return "chart-loader"
}>
</span>
</header>
<div class="img-wrapper">
~(door)
<div class="img-loader" aria-busy="true"></div>
</div>
</article>
}
This trick works because doors.Door was rendered before the static replace operation was triggered.
Result:

Next: Authentication
Code
dashboard.gox
package main
import (
"github.com/doors-dev/doors"
"github.com/doors-dev/gox"
"github.com/doors-dev/tutorial/driver"
)
func WeatherDashboard(path doors.Source[Path]) gox.Comp {
city := doors.DeriveBeam(path, func(p Path) int {
return p.CityID
})
days := doors.DeriveBeam(path, func(p Path) int {
return p.days()
})
units := doors.DeriveBeam(path, func(p Path) driver.Units {
return p.units()
})
return city.Bind(elem(cityID int) {
~dashboard{
cityID: cityID,
days: days,
units: units,
}
})
}
type dashboard struct {
cityID int
days doors.Beam[int]
units doors.Beam[driver.Units]
}
elem (d dashboard) Main() {
~{
city, _ := driver.Locations.CitiesGet(d.cityID)
}
~(if !city.IsValid() {
~(doors.Status(404))
<title>Not Found</title>
<h1>City Not Found</h1>
} else {
<title>~(city.Name) Weather</title>
<h1>Weather in ~(city.Name, ", ", city.Country.Name)</h1>
})
<section>
~(d.change())
~(d.menu())
</section>
~(if city.IsValid() {
<section>
~(d.charts(city))
</section>
})
}
elem (d dashboard) change() {
~>(new(doors.Door)) <>
~{
days, _ := d.days.Effect(ctx)
units, ok := d.units.Effect(ctx)
}
~(if ok {
<a
class="secondary"
(doors.ALink{
Model: Path{
Route: Selector,
Days: daysQuery(days),
Units: unitsQuery(units),
},
})
role="button">
Change
</a>
})
</>
}
elem (d dashboard) menu() {
<nav>
~(d.units.Bind(d.daysNav))
~(d.days.Bind(d.unitNav))
</nav>
}
elem (d dashboard) daysNav(units driver.Units) {
<ul>
~(for i := range 7 {
~{
days := i + 1
}
<li>
~navLink{
city: d.cityID,
days: days,
units: units,
text: <>
~(days)
~(if days == 1 {
day
} else {
days
})
</>,
}
</li>
})
</ul>
}
elem (d dashboard) unitNav(days int) {
<ul>
~(for _, units := range []driver.Units{driver.Metric, driver.Imperial} {
<li>
~navLink{
city: d.cityID,
days: days,
units: units,
text: units,
unitsLoader: true,
}
</li>
})
</ul>
}
type navLink struct {
city int
days int
units driver.Units
text any
unitsLoader bool
}
elem (l navLink) Main() {
~{
target := ".chart-loader"
if l.unitsLoader {
target = ".chart-loader-units"
}
}
<a
class="secondary"
(doors.ALink{
Indicator: doors.IndicateAttrQueryAll(target, "aria-busy", "true"),
Model: Path{
Route: Dashboard,
CityID: l.city,
Days: daysQuery(l.days),
Units: unitsQuery(l.units),
},
})>
~(l.text)
</a>
}
elem (d dashboard) charts(city driver.City) {
<div class="grid">
<div>
~>(new(doors.Door)) ~func {
days, _ := d.days.Effect(ctx)
units, ok := d.units.Effect(ctx)
if !ok {
return nil
}
return chart{
title: "Temperature",
svg: func() []byte {
values, _ := driver.Weather.Temperature(ctx, city, units, days)
svg, _ := driver.ChartLine(values.Values, values.Labels, units.Temperature())
return svg
},
unitsLoader: true,
}
}
~>(new(doors.Door)) ~func {
days, ok := d.days.Effect(ctx)
if !ok {
return nil
}
return chart{
title: "Humidity",
svg: func() []byte {
values, _ := driver.Weather.Humidity(ctx, city, days)
svg, _ := driver.ChartLine(values.Values, values.Labels, "%")
return svg
},
}
}
</div>
<div>
~>(new(doors.Door)) ~func {
days, ok := d.days.Effect(ctx)
if !ok {
return nil
}
return chart{
title: "Weather",
svg: func() []byte {
values, _ := driver.Weather.Code(ctx, city, days)
svg, _ := driver.ChartPie(values.Values)
return svg
},
}
}
~>(new(doors.Door)) ~func {
days, _ := d.days.Effect(ctx)
units, ok := d.units.Effect(ctx)
if !ok {
return nil
}
return chart{
title: "Wind Speed",
svg: func() []byte {
values, _ := driver.Weather.WindSpeed(ctx, city, units, days)
svg, _ := driver.ChartLine(values.Values, values.Labels, units.WindSpeed())
return svg
},
unitsLoader: true,
}
}
</div>
</div>
}
type chart struct {
title string
svg func() []byte
unitsLoader bool
}
elem (c chart) Main() {
~{
door := new(doors.Door)
defer door.Static(ctx, <img height="auto" width="100%" src=(c.svg()) type="image/svg+xml"/>)
}
<article>
<header>
~(c.title, " ")
<span
class=func {
if c.unitsLoader {
return "chart-loader chart-loader-units"
}
return "chart-loader"
}>
</span>
</header>
<div class="img-wrapper">
~(door)
<div class="img-loader" aria-busy="true"></div>
</div>
</article>
}
app.gox
package main
import (
_"embed"
"context"
"github.com/doors-dev/doors"
"github.com/doors-dev/gox"
"github.com/doors-dev/tutorial/driver"
)
//go:embed style.css
var styles []byte
type Path struct {
Route Route `path:"/ | /:CityID"`
CityID int
Units *driver.Units `query:"units"`
Days *int `query:"days"`
}
func (p Path) days() int {
if p.Days == nil {
return 7
}
return min(max(*p.Days, 1), 7)
}
func daysQuery(days int) *int {
if days == 7 {
return nil
}
return &days
}
func (p Path) units() driver.Units {
if p.Units == nil || *p.Units != driver.Imperial {
return driver.Metric
}
return driver.Imperial
}
func unitsQuery(units driver.Units) *driver.Units {
if units == driver.Metric {
return nil
}
return &units
}
type Route int
const (
Selector Route = iota
Dashboard
)
type App struct{}
elem (a App) Main() {
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link
(doors.ResourceExternal("https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"))
rel="stylesheet">
<link href=(styles) rel="stylesheet">
</head>
<body>
<main class="container">
~(doors.Route(
doors.RouteModel(a.content),
doors.RouteLocationDefaultComp(<>
<title>Not Found</title>
~(doors.Status(404))
<h1>Location Not Found</h1>
</>),
))
</main>
</body>
</html>
}
elem (a App) content(path doors.Source[Path]) {
~(path.Route(
doors.RouteMatch(func(p Path) bool {
return p.Route == Selector
}).Comp(<>
<title>Select Location</title>
~(LocationSelector(func(ctx context.Context, city int) {
path.Update(ctx, Path{
Route: Dashboard,
CityID: city,
})
}))
</>),
doors.RouteDefault(WeatherDashboard),
))
}
style.css
.img-wrapper {
position: relative;
aspect-ratio: 3 / 2;
width: 100%;
}
.img-loader {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 0;
}
.img-wrapper img {
position: relative;
z-index: 1;
}