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:

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:

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:

Dashboard with preloader

Deferred Operations

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:

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