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:
dashboard.gox
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=({
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:
dashboard.gox
elem (d dashboard) charts(city driver.City) {
<div class="grid">
<div>
~>(new(doors.Door)) ~({
days, _ := d.days.Effect(ctx)
units, ok := d.units.Effect(ctx)
/* ... */
return chart{
/* ... */
unitsLoader: true,
}
})
~/* ... */
</div>
<div>
~/* ... */
~>(new(doors.Door)) ~({
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:
dashboard.gox
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"),
Active: doors.Active{
Indicator: doors.IndicateAttr("aria-current", "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:
dashboard.gox
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:
app.gox
import _ "embed"
//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=({
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:

Deferred Operations
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:
See Door for deferred operations.
dashboard.gox
elem (c chart) Main() {
<article>
<header>
~(c.title, " ")
<span
class=({
if c.unitsLoader {
return "chart-loader chart-loader-units"
}
return "chart-loader"
})>
</span>
</header>
<div class="img-wrapper">
~// Give the image a dynamic container of its own.
~>(new(doors.Door)) <>
~~
// Replace the empty container with the image
// once the container is rendered.
doors.DeferredStatic(ctx, <img height="auto" width="100%" src=(c.svg()) type="image/svg+xml"/>)
~~
</>
<div class="img-loader" aria-busy="true"></div>
</div>
</article>
}
DeferredStatic works like Static, but waits until the Door's current content is rendered and scheduled for delivery. As a package function, it targets the Door that ctx renders in: here, the new Door around it. That Door's content is empty, so the dashboard goes out right away with the preloaders in place, and each chart replaces its container once its image is rendered. The weather request and the SVG generation in c.svg() run when the <img> renders, after the placeholder is on its way.
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.Elem {
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"),
Active: doors.Active{
Indicator: doors.IndicateAttr("aria-current", "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)) ~({
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)) ~({
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)) ~({
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)) ~({
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() {
<article>
<header>
~(c.title, " ")
<span
class=({
if c.unitsLoader {
return "chart-loader chart-loader-units"
}
return "chart-loader"
})>
</span>
</header>
<div class="img-wrapper">
~>(new(doors.Door)) <>
~~
doors.DeferredStatic(ctx, <img height="auto" width="100%" src=(c.svg()) type="image/svg+xml"/>)
~~
</>
<div class="img-loader" aria-busy="true"></div>
</div>
</article>
}
app.gox
package main
import (
"context"
_ "embed"
"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 `/:" | :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[Path](doors.RouteSource(a.content)),
doors.RouteComp[doors.Location](<>
<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
},
doors.RouteComp[Path](<>
<title>Select Location</title>
~(LocationSelector(func(ctx context.Context, city int) {
path.Mutate(ctx, func(p Path) Path {
p.Route = Dashboard
p.CityID = city
return p
})
}))
</>),
),
doors.RouteSource(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;
}