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:

Dashboard with indication

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:

Dashboard with preloader

Lazy Loading

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

Dashboard load before lazy rendering

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: Lazy chart rendering with simulated throttling


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;
}