Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57d5faf65f | ||
|
|
d8810d288e |
@@ -0,0 +1,22 @@
|
||||
name: check
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
|
||||
# ponytail: only because Traefik still serves its default self-signed cert for
|
||||
# git.kessinen.com. Remove once the LE-DNS01-cloudflare runbook has been run.
|
||||
env:
|
||||
GIT_SSL_NO_VERIFY: "true"
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- run: make check
|
||||
@@ -0,0 +1,58 @@
|
||||
name: release
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
# ponytail: only because Traefik still serves its default self-signed cert for
|
||||
# git.kessinen.com. Remove once the LE-DNS01-cloudflare runbook has been run.
|
||||
env:
|
||||
GIT_SSL_NO_VERIFY: "true"
|
||||
REGISTRY: git.kessinen.com
|
||||
IMAGE: git.kessinen.com/kessinen/foodster
|
||||
|
||||
jobs:
|
||||
image:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Full history and tags: the release number is derived by counting the
|
||||
# tags already cut today.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# The job container is node:22-bookworm and has no docker client. The
|
||||
# static binary is one file; installing docker.io would pull a daemon
|
||||
# that is never used, since the build runs against the host's.
|
||||
- name: Install the docker client
|
||||
run: |
|
||||
curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-27.3.1.tgz \
|
||||
| tar xz --strip-components=1 -C /usr/local/bin docker/docker
|
||||
docker version --format '{{.Client.Version}}'
|
||||
|
||||
- name: Work out the release tag
|
||||
id: rel
|
||||
run: |
|
||||
day=$(date +%Y%m%d)
|
||||
tag="v$day-$(( $(git tag -l "v$day-*" | wc -l) + 1 ))"
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
echo "==> $tag"
|
||||
|
||||
- name: Tag the commit
|
||||
run: |
|
||||
git tag "${{ steps.rel.outputs.tag }}"
|
||||
git push origin "${{ steps.rel.outputs.tag }}"
|
||||
|
||||
- name: Log in to the registry
|
||||
run: |
|
||||
echo "${{ secrets.GITEA_TOKEN }}" \
|
||||
| docker login "$REGISTRY" -u "${{ gitea.actor }}" --password-stdin
|
||||
|
||||
- name: Build and push
|
||||
run: |
|
||||
tag="${{ steps.rel.outputs.tag }}"
|
||||
docker build --platform linux/amd64 --build-arg VERSION="$tag" \
|
||||
-f Containerfile \
|
||||
-t "$IMAGE:$tag" -t "$IMAGE:latest" .
|
||||
docker push "$IMAGE:$tag"
|
||||
docker push "$IMAGE:latest"
|
||||
echo "pushed $IMAGE:$tag and :latest - pull it in dockge when ready"
|
||||
@@ -4,9 +4,6 @@
|
||||
# Build output
|
||||
/foodster
|
||||
|
||||
# The tag `make image` last built, handed to `make push`.
|
||||
/.release-tag
|
||||
|
||||
# Generated by `templ generate` during the container build.
|
||||
*_templ.go
|
||||
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
# Contributing
|
||||
|
||||
A household project, so this is less a set of rules than a note to whoever
|
||||
picks it up next — including me in six months.
|
||||
|
||||
## Getting set up
|
||||
|
||||
```sh
|
||||
cp .env.example .env # then edit it
|
||||
make run # http://localhost:8080, tab titled "dev · Foodster"
|
||||
make # every target, with a one-line description
|
||||
```
|
||||
|
||||
`make check` is the gate: `go vet`, gofmt, unit tests, and `scripts/smoke.sh`,
|
||||
which drives a real server over HTTP. Run it before every commit.
|
||||
|
||||
## Branches
|
||||
|
||||
`dev` is where work happens. `main` holds released versions only — it is
|
||||
protected on the remote and takes no direct pushes, so a release arrives as a
|
||||
pull request from `dev`, squash-merged.
|
||||
|
||||
After a squash merge, reset `dev` onto it or the next pull request will offer
|
||||
the same commits again:
|
||||
|
||||
```sh
|
||||
git switch main && git pull --ff-only
|
||||
git switch dev && git reset --hard main
|
||||
git push --force-with-lease origin dev
|
||||
```
|
||||
|
||||
The release workflow only triggers on `main`, and `main` only moves through a
|
||||
pull request, so a release can never be built from the wrong branch. Nothing
|
||||
needs to check for it.
|
||||
|
||||
## Commit messages
|
||||
|
||||
Conventional Commits — a type, an optional scope, then a short subject in the
|
||||
imperative.
|
||||
|
||||
```
|
||||
feat(kirjaa): expand the selected day in place
|
||||
fix: redirect the catalog to /ruuat, not /ruoat
|
||||
chore(deps): bump the vendored Datastar client
|
||||
```
|
||||
|
||||
| Type | For |
|
||||
|---|---|
|
||||
| `feat` | new behaviour someone will notice |
|
||||
| `fix` | a bug, ideally naming what broke |
|
||||
| `refactor` | same behaviour, different shape |
|
||||
| `test` | tests only |
|
||||
| `docs` | documentation only |
|
||||
| `build` | Makefile, Containerfile, compose, CI |
|
||||
| `chore` | anything else: dependencies, seeds, tidying |
|
||||
|
||||
**The body matters more than the type.** Explain *why*, and what the
|
||||
alternative was — the diff already says what changed. If a fix was subtle,
|
||||
say what made it subtle; if a test caught something, say what. Commits here
|
||||
are the only design record this project has.
|
||||
|
||||
### Release pull requests
|
||||
|
||||
Because `main` is squash-merged, a pull request title becomes a commit message
|
||||
on `main`. A release spans a fix, a feature and some chores at once, so none
|
||||
of the types above fits it honestly. Use `release:` instead:
|
||||
|
||||
```
|
||||
release: repair the catalog 404 and stop the page jumping
|
||||
```
|
||||
|
||||
`main`'s log is then one line per deployment, which is what that branch is
|
||||
for, and the pull request body serves as the release notes. No version in the
|
||||
title — CI creates the CalVer tag after the merge, so it is not known yet.
|
||||
|
||||
The types above are for `dev`, where a commit really does do one thing.
|
||||
|
||||
## Deploying
|
||||
|
||||
The server keeps its own `compose.yaml` and `.env`. Neither is pulled from
|
||||
here, so a release that renames a variable, adds one, or changes a mount
|
||||
needs both copied across **in the same deploy** — otherwise the container
|
||||
comes up against the old names and the app refuses to start.
|
||||
|
||||
Anything in this repository that reaches the server by hand belongs in the
|
||||
release notes, flagged as breaking.
|
||||
|
||||
## Things that are easy to get wrong
|
||||
|
||||
- **The interface is Finnish.** Code, comments, this file and the PRD are
|
||||
English. There is no i18n layer and no language switcher.
|
||||
- **No infrastructure detail is committed** — no hostnames, registry paths or
|
||||
ports. They live in `.env`, which is gitignored, because the PRD leaves the
|
||||
door open to publishing this repository.
|
||||
- **Migrations are immutable once shipped.** A released migration has run on a
|
||||
live database and will not run again. Add a new numbered file instead.
|
||||
- **Interactions patch, they do not navigate.** Anything that reloads the page
|
||||
loses the scroll position, which on a long list is maddening. Links stay
|
||||
links and forms stay forms so it works without JavaScript; Datastar layers
|
||||
over them with `data-on:click__prevent` and `data-on:submit__prevent`.
|
||||
- **A `ponytail:` comment marks a deliberate shortcut** and names its ceiling,
|
||||
so the next reader can tell a decision from an oversight.
|
||||
- **Assert what a response does, not just that it responded.** A redirect to a
|
||||
dead URL is still a 303; that one shipped.
|
||||
@@ -3,18 +3,8 @@
|
||||
COMPOSE ?= podman compose
|
||||
BIN := foodster
|
||||
PKG := ./cmd/foodster
|
||||
STATIC := cmd/foodster/static
|
||||
|
||||
# What `make image` last built. push reads it rather than re-deriving the tag:
|
||||
# sorting tags by date is ambiguous when two point at the same commit, and
|
||||
# re-deriving is what let a parallel make push the wrong one.
|
||||
TAGFILE := .release-tag
|
||||
|
||||
# Vendored Datastar client. Bump, run `make vendor`, commit the result.
|
||||
DATASTAR_VERSION ?= v1.0.3
|
||||
SEED ?= seeds/testi.json
|
||||
|
||||
# Registry coordinates, shared password and TZ live here. Gitignored.
|
||||
# Shared password and TZ live here. Gitignored.
|
||||
ifneq (,$(wildcard .env))
|
||||
include .env
|
||||
export
|
||||
@@ -24,7 +14,7 @@ endif
|
||||
GOFILES = $(shell find . -name '*.go' -not -name '*_templ.go' 2>/dev/null)
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
.PHONY: help generate build run seed test smoke check lint fix icons vendor image push release up down logs clean
|
||||
.PHONY: help generate build run test smoke check lint fix up down logs clean
|
||||
|
||||
help: ## Show this help
|
||||
@grep -hE '^[a-zA-Z_-]+:.*## ' $(MAKEFILE_LIST) \
|
||||
@@ -40,9 +30,6 @@ build: generate ## Build ./foodster
|
||||
run: generate ## Run locally on :8080 (database in ./data)
|
||||
PASSWORD=$${PASSWORD:-dev} ENV=dev go run $(PKG)
|
||||
|
||||
seed: ## Import a dish bundle (SEED=seeds/testi.json)
|
||||
go run $(PKG) -import $(SEED)
|
||||
|
||||
test: generate ## Run unit tests
|
||||
go test ./...
|
||||
|
||||
@@ -57,21 +44,6 @@ check: ## Everything that must pass before a commit
|
||||
@$(MAKE) --no-print-directory smoke
|
||||
@echo "check: all passed"
|
||||
|
||||
icons: ## Rasterise home-screen PNGs from assets/icon.svg and optimise them
|
||||
rsvg-convert -w 180 -h 180 assets/icon.svg -o $(STATIC)/apple-touch-icon.png
|
||||
rsvg-convert -w 192 -h 192 assets/icon.svg -o $(STATIC)/icon-192.png
|
||||
rsvg-convert -w 512 -h 512 assets/icon.svg -o $(STATIC)/icon-512.png
|
||||
# oxipng -o max alone loses to optipng on the 512; --zopfli wins at every
|
||||
# size. Slow, but these are three tiny files built by hand.
|
||||
oxipng -o max --zopfli --quiet \
|
||||
$(STATIC)/apple-touch-icon.png $(STATIC)/icon-192.png $(STATIC)/icon-512.png
|
||||
@ls -l $(STATIC)/*.png
|
||||
|
||||
vendor: ## Re-download the Datastar client (DATASTAR_VERSION=v1.0.3)
|
||||
curl -sSfL -o $(STATIC)/datastar.js \
|
||||
"https://cdn.jsdelivr.net/gh/starfederation/datastar@$(DATASTAR_VERSION)/bundles/datastar.js"
|
||||
@head -1 $(STATIC)/datastar.js
|
||||
|
||||
lint: generate ## go vet, gofmt check, golangci-lint when installed
|
||||
go vet ./...
|
||||
@bad=$$(gofmt -l $(GOFILES) 2>/dev/null); \
|
||||
@@ -84,57 +56,6 @@ fix: ## Format Go and templ sources, tidy go.mod
|
||||
go tool templ fmt .
|
||||
go mod tidy
|
||||
|
||||
image: ## Build and tag an image as vYYYYMMDD-N. Creates a git tag.
|
||||
@test -n "$(REPO)" || { echo "set REPO in .env"; exit 1; }
|
||||
@# A release tag must point into main, or the tag records a commit that
|
||||
@# was never released.
|
||||
@branch=$$(git symbolic-ref --short HEAD); \
|
||||
if [ "$$branch" != "main" ]; then \
|
||||
echo "releases are cut from main, not $$branch:"; \
|
||||
echo " git switch main && git merge --ff-only dev"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@day=$$(date +%Y%m%d); \
|
||||
tag="v$$day-$$(( $$(git tag -l "v$$day-*" | wc -l) + 1 ))"; \
|
||||
echo "==> $$tag"; \
|
||||
git tag "$$tag"; \
|
||||
podman build --platform linux/amd64 --build-arg VERSION="$$tag" \
|
||||
-t "$(REPO):$$tag" -t "$(REPO):latest" . ; \
|
||||
echo "$$tag" > $(TAGFILE)
|
||||
|
||||
# Pushing reported success while uploading the previous release once, because
|
||||
# nothing compared what was built against what arrived. So afterwards, ask the
|
||||
# registry what it actually serves for each tag and fail if it is not the
|
||||
# image we just built.
|
||||
push: ## Push the newest tag and :latest, then verify the registry
|
||||
@test -n "$(REPO)" || { echo "set REPO in .env"; exit 1; }
|
||||
@test -f $(TAGFILE) || { echo "nothing built - run make image"; exit 1; }; \
|
||||
tag=$$(cat $(TAGFILE)); \
|
||||
built=$$(podman image inspect "$(REPO):$$tag" --format '{{.Id}}' 2>/dev/null) || \
|
||||
{ echo "no local image tagged $$tag - run make image"; exit 1; }; \
|
||||
podman push "$(REPO):$$tag"; \
|
||||
podman push "$(REPO):latest"; \
|
||||
echo "==> verifying $$tag"; \
|
||||
for ref in "$$tag" latest; do \
|
||||
podman pull -q "$(REPO):$$ref" >/dev/null 2>&1 || \
|
||||
{ echo " FAIL $$ref is not in the registry"; exit 1; }; \
|
||||
served=$$(podman image inspect "$(REPO):$$ref" --format '{{.Id}}'); \
|
||||
if [ "$$served" != "$$built" ]; then \
|
||||
echo " FAIL $$ref serves $$served"; \
|
||||
echo " expected $$built"; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
echo " ok $$ref"; \
|
||||
done
|
||||
|
||||
# Sub-makes, not prerequisites. Under `make -j` — and -j16 is the default on
|
||||
# at least one machine here — these run concurrently, so push resolves the
|
||||
# newest tag and uploads :latest before image has finished building and
|
||||
# tagging. That silently ships the previous release a second time.
|
||||
release: ## Build, tag and push in one go
|
||||
@$(MAKE) --no-print-directory image
|
||||
@$(MAKE) --no-print-directory push
|
||||
|
||||
up: ## Start the stack
|
||||
@mkdir -p data # or the engine creates it root-owned and the app cannot write
|
||||
$(COMPOSE) up -d
|
||||
|
||||
@@ -372,22 +372,27 @@ Explicitly *not* React.
|
||||
|
||||
## 10. Deployment
|
||||
|
||||
Images are built locally, pushed to a private container registry, then pulled
|
||||
on the server and run with Docker Compose.
|
||||
Images are built by CI, pushed to a private container registry, then pulled on
|
||||
the server and run with Docker Compose.
|
||||
|
||||
- **Branches**: `main` carries released versions only, so its history is the
|
||||
deployment history and every release tag points into it. Development happens
|
||||
on `dev`, and `main` is protected on the remote: it accepts no direct
|
||||
pushes, so a release arrives as a pull request from `dev`. `make image`
|
||||
additionally refuses to run outside `main` — that one has to be local,
|
||||
because the tag and the image are made before anything reaches the remote.
|
||||
pushes, so a release arrives as a pull request from `dev`. The release
|
||||
workflow runs only on `main`, so a release cannot be built from anywhere
|
||||
else and nothing needs to check for it.
|
||||
- **Versioning**: CalVer `vYYYYMMDD-N`, where `N` is the Nth build of that
|
||||
day. `make image` derives `N` by counting the day's existing git tags,
|
||||
creates the new tag, and bakes the version into the binary through
|
||||
`-ldflags -X main.version`. `make release` builds, tags and pushes.
|
||||
- **Tooling**: a `Makefile` is the single entry point — `make` on its own
|
||||
lists every target. Build, test, lint, format, image and compose commands
|
||||
all live there rather than in loose scripts.
|
||||
day. The release workflow derives `N` by counting the day's existing git
|
||||
tags, creates the new tag, and bakes the version into the binary through
|
||||
`-ldflags -X main.version`.
|
||||
- **CI**: Gitea Actions, workflows in `.gitea/workflows/`. `check.yaml` runs
|
||||
`make check` on every push to `dev`; `release.yaml` builds and pushes the
|
||||
image when a pull request merges into `main`. Merging is the release —
|
||||
there is no local build step.
|
||||
- **Tooling**: a `Makefile` covers development — `make` on its own lists every
|
||||
target. Build, test, lint, format and compose commands live there rather
|
||||
than in loose scripts. Commands run a handful of times a year are written
|
||||
out in the README instead of earning a target.
|
||||
- **Image**: a two-stage `Containerfile`. `golang:1.27-alpine` compiles a
|
||||
static binary; the runtime stage is `FROM scratch` holding only that
|
||||
binary, running as UID 65534.
|
||||
@@ -425,7 +430,7 @@ on the server and run with Docker Compose.
|
||||
- Pending migrations are applied on app start.
|
||||
- The Datastar client is vendored at `cmd/foodster/static/datastar.js` and
|
||||
served from the app's own origin — the SDK ships no browser asset, and a
|
||||
CDN link would break an offline LAN. `make vendor` refreshes it; the pinned
|
||||
CDN link would break an offline LAN. The README says how to refresh it; the pinned
|
||||
version lives in the `Makefile` and in the file's first line.
|
||||
- No internet exposure; the server binds to the LAN.
|
||||
|
||||
|
||||
@@ -11,35 +11,35 @@ See [PRD.md](PRD.md) for the full specification.
|
||||
|
||||
## Status
|
||||
|
||||
**Stage 1 — eating history: in development.** The meal catalog and the daily
|
||||
log come first, because the suggester is worthless until there are a few
|
||||
weeks of real history to weight against.
|
||||
**Stage 1 — eating history: in use.** The meal catalog and the daily log came
|
||||
first, because the suggester is worthless until there are a few weeks of real
|
||||
history to weight against.
|
||||
|
||||
Working:
|
||||
|
||||
- **Kirjaa** — log a dinner: pick a dish, tick sides, save. Dishes are ordered
|
||||
and sized by how often they are eaten, so the likely answer is the biggest
|
||||
target. The history sits on the same page underneath: every day back to the
|
||||
first entry, unlogged days shown as explicit gaps, and every row a link that
|
||||
loads that day into the logger above it.
|
||||
- **Kirjaa** — log a dinner: pick a dish, tick sides, save. Dishes are grouped
|
||||
by category, then ordered and sized by how often they are eaten, so the
|
||||
likely answer is the biggest target. The history sits on the same page
|
||||
underneath: every day back to the first entry, unlogged days shown as
|
||||
explicit gaps, and every row opening that day's logger in place. Older days
|
||||
arrive a window at a time.
|
||||
- **Ruuat** — add, edit and delete mains and sides, or import a whole bundle
|
||||
by paste or file upload. Grouped by category and alphabetical inside, since
|
||||
this is a list you manage rather than one you pick from. Deletes are soft,
|
||||
so old log entries keep showing the dish they used.
|
||||
this is a list you manage rather than one you pick from. Edit and delete are
|
||||
row icons, and a delete asks first. Deletes are soft, so old log entries
|
||||
keep showing the dish they used.
|
||||
- **Search as you type** on both tabs, debounced, patching just the list.
|
||||
- **Category icons**, not colour dots: shape and colour together, so two marks
|
||||
are told apart by more than hue.
|
||||
- **Light / dark**, remembered per device, dark by default. The button shows
|
||||
the theme that is on — moon while dark, sun while light — not the one a
|
||||
click would bring.
|
||||
|
||||
Nothing navigates. Every interaction patches the page through Datastar, so
|
||||
the scroll position survives; links and forms still work with JavaScript off.
|
||||
|
||||
Still to build:
|
||||
|
||||
- Live search as you type, and paging for the history and catalog lists once
|
||||
years of entries make them long. Both via Datastar.
|
||||
- Category icons instead of plain colour dots — colour and shape together, so
|
||||
a red blob and a yellow blob are told apart by more than hue.
|
||||
- Edit and delete as icons in the catalog rows, and a confirmation step before
|
||||
a delete actually happens.
|
||||
- A background for the header. Something subtle; the palette gets overhauled
|
||||
later.
|
||||
- Stage 2: the seven-meal suggester, which starts once there is history to
|
||||
weight against.
|
||||
|
||||
@@ -58,6 +58,9 @@ One static Go binary. No Node.js, no bundler, no separate database server.
|
||||
| Auth | HTTP Basic, one shared household password |
|
||||
| Runtime image | `FROM scratch` |
|
||||
|
||||
Working on it: [CONTRIBUTING.md](CONTRIBUTING.md) — branches, commit messages,
|
||||
and the conventions that are easy to miss.
|
||||
|
||||
## Branches
|
||||
|
||||
`main` holds released versions only. Every release tag points at a commit on
|
||||
@@ -71,20 +74,20 @@ no direct pushes, so a release arrives through a pull request.
|
||||
git switch dev # where the work happens
|
||||
# ... commits ...
|
||||
make check # lint, unit tests, smoke
|
||||
git push origin dev
|
||||
git push origin dev # CI runs make check too
|
||||
|
||||
tea pr create --base main --head dev # or open it in the forge
|
||||
# merge the pull request, then:
|
||||
|
||||
git switch main && git pull --ff-only
|
||||
make release # builds, tags vYYYYMMDD-N, pushes the image
|
||||
git push origin --tags
|
||||
# squash-merge the pull request — that is the whole release
|
||||
```
|
||||
|
||||
`make image` additionally refuses to run from any branch but `main`, so a
|
||||
release tag can never point at a commit that was not released. That check
|
||||
lives locally because it has to: tags and images are built before anything
|
||||
reaches the remote, so protection there cannot catch it.
|
||||
Merging is the release. CI builds the image, tags it `vYYYYMMDD-N` and
|
||||
`latest`, pushes both to the registry, and creates the matching git tag. There
|
||||
is nothing to run locally afterwards; pull the new image on the server when
|
||||
you are ready.
|
||||
|
||||
A release tag can therefore never point at a commit that was not released:
|
||||
the workflow only runs on `main`, and `main` only moves through a pull
|
||||
request.
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -99,15 +102,39 @@ make run # http://localhost:8080
|
||||
make fix gofmt, templ fmt, go mod tidy
|
||||
make lint go vet, gofmt check, golangci-lint when installed
|
||||
make test go test ./...
|
||||
make smoke end-to-end check against a scratch server
|
||||
make check lint + test + smoke — run before every commit
|
||||
make build ./foodster
|
||||
make seed import a dish bundle (SEED=seeds/testi.json)
|
||||
make vendor re-download the Datastar client
|
||||
make image build and tag vYYYYMMDD-N (creates a git tag)
|
||||
make push push the newest tag and :latest
|
||||
make release image + push
|
||||
make up/down/logs compose
|
||||
```
|
||||
|
||||
Images are built by CI, not here — see [Deployment](#deployment).
|
||||
|
||||
### Occasional commands
|
||||
|
||||
Rare enough not to earn a `make` target. Both write into
|
||||
`cmd/foodster/static/`, and the results are committed.
|
||||
|
||||
Re-download the vendored Datastar client after bumping the version:
|
||||
|
||||
```sh
|
||||
curl -sSfL -o cmd/foodster/static/datastar.js \
|
||||
"https://cdn.jsdelivr.net/gh/starfederation/[email protected]/bundles/datastar.js"
|
||||
```
|
||||
|
||||
Re-rasterise the home-screen icons after editing `assets/icon.svg`:
|
||||
|
||||
```sh
|
||||
cd cmd/foodster/static
|
||||
rsvg-convert -w 180 -h 180 ../../../assets/icon.svg -o apple-touch-icon.png
|
||||
rsvg-convert -w 192 -h 192 ../../../assets/icon.svg -o icon-192.png
|
||||
rsvg-convert -w 512 -h 512 ../../../assets/icon.svg -o icon-512.png
|
||||
oxipng -o max --zopfli --quiet apple-touch-icon.png icon-192.png icon-512.png
|
||||
```
|
||||
|
||||
`oxipng -o max` on its own loses to optipng on the 512; `--zopfli` wins at
|
||||
every size. Slow, but these are three tiny files built by hand.
|
||||
|
||||
## Importing dishes
|
||||
|
||||
The **Ruuat** tab takes a bundle of mains and sides: paste the JSON or upload
|
||||
@@ -138,7 +165,7 @@ The same importer runs from the command line when you just want to repopulate
|
||||
a scratch database:
|
||||
|
||||
```sh
|
||||
make seed # or: SEED=seeds/other.json make seed
|
||||
go run ./cmd/foodster -import seeds/testi.json
|
||||
```
|
||||
|
||||
## Icons
|
||||
@@ -149,12 +176,8 @@ cannot be transparent and must not change with the theme; they are rasterised
|
||||
from `assets/icon.svg`, which is opaque and keeps the artwork inside the
|
||||
central 80% so Android can mask it to any shape.
|
||||
|
||||
```sh
|
||||
make icons # rsvg-convert, then optipng -o7
|
||||
```
|
||||
|
||||
The PNGs are committed so the build needs no rasterizer. Re-run `make icons`
|
||||
after editing `assets/icon.svg`.
|
||||
The PNGs are committed so the build needs no rasterizer. The commands to
|
||||
regenerate them are under [Occasional commands](#occasional-commands).
|
||||
|
||||
## Migrations
|
||||
|
||||
@@ -179,25 +202,24 @@ Everything is environment variables. `.env` is gitignored; start from
|
||||
| `ADDR` | `:8080` | Listen address. Only useful for a second local instance. |
|
||||
| `PUID` / `PGID` | `1000` | Host owner of `./data`, for the bind mount. |
|
||||
| `TZ` | `Europe/Helsinki` | Used for every calendar-day calculation. |
|
||||
| `REPO` | *required to run* | Image repository, no tag. Used by `compose.yaml`. |
|
||||
| `TAG` | `latest` | Tag to run under compose. |
|
||||
| `HOST` | *required to run* | Hostname Traefik routes to. |
|
||||
|
||||
Names carry no prefix: the container gives them their own namespace already.
|
||||
`PUID`/`PGID` are the exception — `UID` is read-only in bash, so a value set
|
||||
in `.env` would be silently replaced by the invoking shell's own.
|
||||
| `REPO` | *required to build* | Image repository, no tag. |
|
||||
| `TAG` | `latest` | Tag to run under compose. |
|
||||
| `HOST` | *required to run* | Hostname Traefik routes to. |
|
||||
|
||||
Set `TZ` in development too. Under UTC the date rolls over three hours late,
|
||||
which is exactly when dinner gets logged.
|
||||
|
||||
## Deployment
|
||||
|
||||
Images are built with Podman and run under Docker Compose on a LAN server.
|
||||
They are OCI images, so either engine works.
|
||||
Images are built by CI when a pull request merges into `main`, and run under
|
||||
Docker Compose on a LAN server. They are OCI images, so either engine works.
|
||||
|
||||
```sh
|
||||
make release # build, tag, push
|
||||
# on the server:
|
||||
# on the server, once CI reports the build finished:
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
@@ -206,8 +228,15 @@ running version is served at `GET /healthz`, which is the one route outside
|
||||
authentication.
|
||||
|
||||
There is no database container. SQLite lives in `./data`, bind-mounted into
|
||||
the container, so a backup is `cp -r data` and you can inspect the file with
|
||||
any sqlite client without going through the engine.
|
||||
the container, so you can inspect the file with any sqlite client without
|
||||
going through the engine. Back it up with
|
||||
|
||||
```sh
|
||||
sqlite3 data/foodster.db ".backup data/foodster-$(date +%F).db"
|
||||
```
|
||||
|
||||
rather than copying the directory: the database runs in WAL mode, and a plain
|
||||
copy of a live database can catch the `.db` and its `-wal` mid-write.
|
||||
|
||||
That directory must exist and be owned by the user compose runs as — `make up`
|
||||
creates it, and `PUID`/`PGID` in `.env` tell the container who
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512"
|
||||
role="img" aria-label="Foodster">
|
||||
<!-- Source for the home-screen PNGs; `make icons` rasterises it.
|
||||
<!-- Source for the home-screen PNGs; the README says how to rasterise it.
|
||||
Unlike favicon.svg this one is opaque and fixed-colour: a home screen
|
||||
icon cannot be transparent and must not change with the system theme.
|
||||
The bowl sits inside the central 80% so Android can mask it to any
|
||||
|
||||
|
Before Width: | Height: | Size: 918 B After Width: | Height: | Size: 927 B |
@@ -60,12 +60,19 @@ func render(w http.ResponseWriter, r *http.Request, c templ.Component) {
|
||||
// there is nothing to write down for a dinner that has not happened, and a
|
||||
// stray entry dated next year would sit at the top of the history forever.
|
||||
// Every read and write goes through here, so the clamp covers them all.
|
||||
//
|
||||
// The past is clamped too, at maxHistoryDays. The day list runs unbroken from
|
||||
// today down to the selected day, so a picker set to 1994 would ask for eleven
|
||||
// thousand rows. Same ceiling ?paivat= already has.
|
||||
func (a *app) date(r *http.Request) time.Time {
|
||||
now := today(a.loc)
|
||||
if raw := r.FormValue("pvm"); raw != "" {
|
||||
if d, err := time.ParseInLocation(dateLayout, raw, a.loc); err == nil {
|
||||
if d.After(now) {
|
||||
switch floor := now.AddDate(0, 0, -maxHistoryDays+1); {
|
||||
case d.After(now):
|
||||
return now
|
||||
case d.Before(floor):
|
||||
return floor
|
||||
}
|
||||
return d
|
||||
}
|
||||
@@ -205,15 +212,13 @@ func (a *app) buildLog(r *http.Request, o logOptions) logView {
|
||||
// list underneath the tap.
|
||||
func (a *app) loadDays(r *http.Request, v *logView) {
|
||||
v.HistoryDays = historyWindow(r)
|
||||
|
||||
// The window has to reach the selected day, or it would have nowhere to
|
||||
// expand.
|
||||
if reach := int(v.Today.Sub(v.Date).Hours()/24) + 1; reach > v.HistoryDays {
|
||||
v.HistoryDays = min(reach, maxHistoryDays)
|
||||
}
|
||||
v.HistoryMore = v.HistoryDays + historyDays
|
||||
|
||||
page, err := history(a.db, a.loc, v.Today, v.HistoryDays)
|
||||
// The window has to reach the selected day, or it would have nowhere to
|
||||
// expand. history() takes it as a floor rather than the caller inflating
|
||||
// the day count, because the window also truncates at the first entry ever
|
||||
// logged — and a day older than that still has to be loggable.
|
||||
page, err := history(a.db, a.loc, v.Today, v.HistoryDays, v.Date)
|
||||
if err != nil {
|
||||
log.Printf("history: %v", err)
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ import (
|
||||
//go:embed static
|
||||
var staticFS embed.FS
|
||||
|
||||
// version is replaced at build time with the CalVer tag (see `make image`).
|
||||
// version is replaced at build time with the CalVer tag by the release workflow.
|
||||
var version = "dev"
|
||||
|
||||
// envTag marks the browser tab of anything that is not production, so a dev
|
||||
@@ -100,10 +100,15 @@ func run() error {
|
||||
// local instance can pick another port.
|
||||
addr := cmp.Or(os.Getenv("ADDR"), listenAddr)
|
||||
|
||||
// WriteTimeout and IdleTimeout matter more than they look: the pool holds
|
||||
// exactly one database connection, so a reader stalling on a long history
|
||||
// response blocks every other request behind it.
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: routes(db, loc, password),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
+96
-7
@@ -305,7 +305,8 @@ func updateMain(db *sql.DB, id int64, name string, categories []string, hasSides
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE main_dishes SET name = ?, has_sides = ? WHERE id = ? AND deleted_at IS NULL`,
|
||||
`UPDATE main_dishes SET name = ?, has_sides = ?
|
||||
WHERE id = ? AND deleted_at IS NULL AND special = 0`,
|
||||
name, hasSides, id,
|
||||
); err != nil {
|
||||
return taken(err)
|
||||
@@ -345,9 +346,14 @@ func updateSide(db *sql.DB, id int64, name string) error {
|
||||
|
||||
// Soft delete: the row stays so historical log entries keep resolving their
|
||||
// names, but it disappears from the catalog and every picker (PRD §6).
|
||||
//
|
||||
// `special = 0` here and in updateMain: the catalog never lists Tähteet, so
|
||||
// the UI cannot reach it, but a stale tab or a hand-made POST could — and
|
||||
// removing it would take away the row migration 0002 guarantees.
|
||||
func softDeleteMain(db *sql.DB, id int64) error {
|
||||
_, err := db.Exec(
|
||||
`UPDATE main_dishes SET deleted_at = datetime('now') WHERE id = ?`, id)
|
||||
`UPDATE main_dishes SET deleted_at = datetime('now')
|
||||
WHERE id = ? AND special = 0`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -432,7 +438,12 @@ type HistoryPage struct {
|
||||
// history walks back day by day from a given day, so a day nobody wrote down
|
||||
// shows up as an explicit gap rather than silently missing. It stops at the
|
||||
// first entry ever recorded — before that there is no history to be missing.
|
||||
func history(db *sql.DB, loc *time.Location, from time.Time, days int) (HistoryPage, error) {
|
||||
//
|
||||
// reach names a day that must appear whatever the window says. The log board
|
||||
// opens inside the selected day's row, so a date picked from before the first
|
||||
// entry ever logged used to render nothing at all: no row, no board, no way to
|
||||
// log it. Pass the zero time to ask for the plain window.
|
||||
func history(db *sql.DB, loc *time.Location, from time.Time, days int, reach time.Time) (HistoryPage, error) {
|
||||
var first sql.NullString
|
||||
if err := db.QueryRow(`SELECT min(date) FROM meal_log`).Scan(&first); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
@@ -457,14 +468,92 @@ func history(db *sql.DB, loc *time.Location, from time.Time, days int) (HistoryP
|
||||
oldest = firstDate
|
||||
page.More = false
|
||||
}
|
||||
if !reach.IsZero() && reach.Before(oldest) {
|
||||
oldest = reach
|
||||
page.More = firstDate.Before(oldest)
|
||||
}
|
||||
page.Next = oldest.AddDate(0, 0, -1)
|
||||
|
||||
entries, err := entriesBetween(db, oldest, from)
|
||||
if err != nil {
|
||||
return HistoryPage{}, err
|
||||
}
|
||||
for d := from; !d.Before(oldest); d = d.AddDate(0, 0, -1) {
|
||||
entry, err := entryFor(db, d)
|
||||
if err != nil {
|
||||
return HistoryPage{}, err
|
||||
row := HistoryRow{Date: d}
|
||||
if e := entries[d.Format(dateLayout)]; e != nil {
|
||||
e.Date = d
|
||||
row.Entry = e
|
||||
}
|
||||
page.Rows = append(page.Rows, HistoryRow{Date: d, Entry: entry})
|
||||
page.Rows = append(page.Rows, row)
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
|
||||
// entriesBetween loads every logged day in the inclusive range, keyed by
|
||||
// stored date string, in two queries rather than two per day. The widest
|
||||
// window a URL can ask for is five years, which day-at-a-time made 3,600 round
|
||||
// trips through a pool of exactly one connection.
|
||||
func entriesBetween(db *sql.DB, from, to time.Time) (map[string]*Entry, error) {
|
||||
lo, hi := from.Format(dateLayout), to.Format(dateLayout)
|
||||
|
||||
rows, err := db.Query(`
|
||||
SELECT l.id, l.date, m.id, m.name, m.has_sides,
|
||||
coalesce((SELECT group_concat(c.category)
|
||||
FROM main_dish_categories c
|
||||
WHERE c.main_dish_id = m.id), '')
|
||||
FROM meal_log l
|
||||
JOIN main_dishes m ON m.id = l.main_dish_id
|
||||
WHERE l.date BETWEEN ? AND ?`, lo, hi)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
byDate := map[string]*Entry{}
|
||||
byLog := map[int64]*Entry{}
|
||||
for rows.Next() {
|
||||
var logID int64
|
||||
var date, cats string
|
||||
var e Entry
|
||||
if err := rows.Scan(
|
||||
&logID, &date, &e.Main.ID, &e.Main.Name, &e.Main.HasSides, &cats,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cats != "" {
|
||||
e.Main.Categories = strings.Split(cats, ",")
|
||||
}
|
||||
byDate[date] = &e
|
||||
byLog[logID] = &e
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(byLog) == 0 {
|
||||
return byDate, nil
|
||||
}
|
||||
|
||||
sides, err := db.Query(`
|
||||
SELECT ls.meal_log_id, s.id, s.name
|
||||
FROM meal_log_sides ls
|
||||
JOIN side_dishes s ON s.id = ls.side_dish_id
|
||||
JOIN meal_log l ON l.id = ls.meal_log_id
|
||||
WHERE l.date BETWEEN ? AND ?
|
||||
ORDER BY s.name`, lo, hi)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer sides.Close()
|
||||
|
||||
for sides.Next() {
|
||||
var logID int64
|
||||
var s Side
|
||||
if err := sides.Scan(&logID, &s.ID, &s.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if e := byLog[logID]; e != nil {
|
||||
e.Sides = append(e.Sides, s)
|
||||
}
|
||||
}
|
||||
return byDate, sides.Err()
|
||||
}
|
||||
|
||||
@@ -235,7 +235,7 @@ func TestHistoryMarksUnloggedDaysAsGaps(t *testing.T) {
|
||||
t.Fatalf("save -3: %v", err)
|
||||
}
|
||||
|
||||
page, err := history(h.db, loc, now, 60)
|
||||
page, err := history(h.db, loc, now, 60, time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("history: %v", err)
|
||||
}
|
||||
@@ -271,7 +271,7 @@ func TestHistoryPagesInWindows(t *testing.T) {
|
||||
t.Fatalf("save -9: %v", err)
|
||||
}
|
||||
|
||||
first, err := history(h.db, loc, now, 5)
|
||||
first, err := history(h.db, loc, now, 5, time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("first window: %v", err)
|
||||
}
|
||||
@@ -286,7 +286,7 @@ func TestHistoryPagesInWindows(t *testing.T) {
|
||||
}
|
||||
|
||||
// The windows must meet exactly: no day repeated, none skipped.
|
||||
second, err := history(h.db, loc, first.Next, 5)
|
||||
second, err := history(h.db, loc, first.Next, 5, time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("second window: %v", err)
|
||||
}
|
||||
@@ -305,7 +305,7 @@ func TestHistoryPagesInWindows(t *testing.T) {
|
||||
func TestHistoryEmptyWithoutEntries(t *testing.T) {
|
||||
h := seeded(t)
|
||||
|
||||
page, err := history(h.db, time.UTC, today(time.UTC), 60)
|
||||
page, err := history(h.db, time.UTC, today(time.UTC), 60, time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("history: %v", err)
|
||||
}
|
||||
@@ -316,3 +316,64 @@ func TestHistoryEmptyWithoutEntries(t *testing.T) {
|
||||
t.Error("More is set although there is no history at all")
|
||||
}
|
||||
}
|
||||
|
||||
// A date picked from before the first entry ever logged used to fall outside
|
||||
// the window entirely: no row, so the log board had nothing to open in and the
|
||||
// day could not be filled in at all.
|
||||
func TestHistoryReachesDaysOlderThanTheFirstEntry(t *testing.T) {
|
||||
h := seeded(t)
|
||||
loc := time.UTC
|
||||
now := today(loc)
|
||||
|
||||
if err := saveEntry(h.db, now, h.mainNamed(t, "Lohikeitto"), nil); err != nil {
|
||||
t.Fatalf("save today: %v", err)
|
||||
}
|
||||
|
||||
want := now.AddDate(0, 0, -100)
|
||||
page, err := history(h.db, loc, now, 30, want)
|
||||
if err != nil {
|
||||
t.Fatalf("history: %v", err)
|
||||
}
|
||||
if len(page.Rows) != 101 {
|
||||
t.Fatalf("%d rows, want 101 (today back to the selected day)", len(page.Rows))
|
||||
}
|
||||
last := page.Rows[len(page.Rows)-1]
|
||||
if !last.Date.Equal(want) {
|
||||
t.Errorf("last row is %s, want the selected %s",
|
||||
last.Date.Format(dateLayout), want.Format(dateLayout))
|
||||
}
|
||||
if page.More {
|
||||
t.Error("More is set although the window reached past the oldest entry")
|
||||
}
|
||||
}
|
||||
|
||||
// The sides of every day come back in one query now; each still has to land on
|
||||
// its own day.
|
||||
func TestHistoryKeepsSidesWithTheirOwnDay(t *testing.T) {
|
||||
h := seeded(t)
|
||||
loc := time.UTC
|
||||
now := today(loc)
|
||||
|
||||
muusi := h.sideNamed(t, "Perunamuusi")
|
||||
riisi := h.sideNamed(t, "Riisi")
|
||||
if err := saveEntry(h.db, now, h.mainNamed(t, "Lohikeitto"), []int64{muusi}); err != nil {
|
||||
t.Fatalf("save today: %v", err)
|
||||
}
|
||||
if err := saveEntry(h.db, now.AddDate(0, 0, -1), h.mainNamed(t, "Lihapullat"), []int64{riisi}); err != nil {
|
||||
t.Fatalf("save -1: %v", err)
|
||||
}
|
||||
|
||||
page, err := history(h.db, loc, now, 30, time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("history: %v", err)
|
||||
}
|
||||
if len(page.Rows) != 2 {
|
||||
t.Fatalf("%d rows, want 2", len(page.Rows))
|
||||
}
|
||||
for i, want := range []string{"Perunamuusi", "Riisi"} {
|
||||
got := page.Rows[i].Entry
|
||||
if got == nil || len(got.Sides) != 1 || got.Sides[0].Name != want {
|
||||
t.Errorf("row %d sides = %+v, want just %s", i, got.Sides, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+20
-12
@@ -11,6 +11,12 @@ cd "$(dirname "$0")/.."
|
||||
|
||||
addr=127.0.0.1:8099
|
||||
pass=smoke
|
||||
|
||||
# Dates are relative, never literal. A hardcoded one turns into "some day in
|
||||
# the past" at the next midnight, and the assertions quietly start meaning
|
||||
# something else.
|
||||
d0=$(date +%F)
|
||||
d1=$(date -d yesterday +%F)
|
||||
tmp=$(mktemp -d)
|
||||
trap 'kill ${srv:-0} 2>/dev/null || true; rm -rf "$tmp"' EXIT
|
||||
|
||||
@@ -134,51 +140,53 @@ check "picking a dish opens the sides step" \
|
||||
|
||||
check "saving redirects back to the day" \
|
||||
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \
|
||||
-d "pvm=2026-09-05&ruoka=$ruoka" "http://$addr/kirjaa")" "303"
|
||||
-d "pvm=$d0&ruoka=$ruoka" "http://$addr/kirjaa")" "303"
|
||||
|
||||
check "the saved day shows what was eaten" \
|
||||
"$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05")" "kirjattu"
|
||||
"$(curl -s -u ":$pass" "http://$addr/?pvm=$d0")" "kirjattu"
|
||||
|
||||
# The selected day expands inside the list rather than in a panel above it,
|
||||
# so the rows below do not shift when one is tapped.
|
||||
day=$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05")
|
||||
day=$(curl -s -u ":$pass" "http://$addr/?pvm=$d0")
|
||||
check "the selected day expands in place" "$day" 'class="open"'
|
||||
check "and stays in the list rather than being lifted out" "$day" "kirjattu"
|
||||
|
||||
# ---- the day list patches in place instead of navigating ----------------
|
||||
|
||||
dayp=$(curl -s -u ":$pass" -H 'Datastar-Request: true' "http://$addr/paiva?pvm=2026-09-05")
|
||||
dayp=$(curl -s -u ":$pass" -H 'Datastar-Request: true' "http://$addr/paiva?pvm=$d0")
|
||||
check "opening a day patches the list" "$dayp" 'id="paivat"'
|
||||
refute "and returns a fragment, not a page" "$dayp" "<html"
|
||||
|
||||
check "picking a dish patches to the sides step" \
|
||||
"$(curl -s -u ":$pass" -H 'Datastar-Request: true' \
|
||||
"http://$addr/paiva?pvm=2026-09-05&ruoka=$ruoka")" "Tallenna"
|
||||
"http://$addr/paiva?pvm=$d0&ruoka=$ruoka")" "Tallenna"
|
||||
|
||||
check "saving from Datastar patches back" \
|
||||
"$(curl -s -u ":$pass" -H 'Datastar-Request: true' \
|
||||
-d "pvm=2026-09-04&ruoka=$ruoka" "http://$addr/kirjaa")" 'id="paivat"'
|
||||
-d "pvm=$d1&ruoka=$ruoka" "http://$addr/kirjaa")" 'id="paivat"'
|
||||
|
||||
check "deleting from Datastar patches back" \
|
||||
"$(curl -s -u ":$pass" -H 'Datastar-Request: true' \
|
||||
-d "pvm=2026-09-04" "http://$addr/poista")" 'id="paivat"'
|
||||
-d "pvm=$d1" "http://$addr/poista")" 'id="paivat"'
|
||||
|
||||
# Without the header it must still redirect, for no JavaScript.
|
||||
check "a plain save still redirects to the day" \
|
||||
"$(curl -s -o /dev/null -w '%{redirect_url}' -u ":$pass" \
|
||||
-d "pvm=2026-09-04&ruoka=$ruoka" "http://$addr/kirjaa")" "pvm=2026-09-04"
|
||||
-d "pvm=$d1&ruoka=$ruoka" "http://$addr/kirjaa")" "pvm=$d1"
|
||||
|
||||
# Deleting a logged meal drops the row outright, so it asks first.
|
||||
saved=$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05&poista=1")
|
||||
saved=$(curl -s -u ":$pass" "http://$addr/?pvm=$d0&poista=1")
|
||||
check "deleting a meal asks first" "$saved" "Poistetaanko merkintä?"
|
||||
refute "and does not delete while asking" "$saved" "Ei merkintää"
|
||||
# Assert the entry is still shown, rather than that no gap row exists anywhere
|
||||
# on the page: other days are legitimately unlogged and render their own.
|
||||
check "and the entry is still there while asking" "$saved" "kirjattu"
|
||||
|
||||
check "deleting redirects back" \
|
||||
"$(curl -s -o /dev/null -w '%{http_code}' -u ":$pass" \
|
||||
-d "pvm=2026-09-05" "http://$addr/poista")" "303"
|
||||
-d "pvm=$d0" "http://$addr/poista")" "303"
|
||||
|
||||
check "the day is empty again" \
|
||||
"$(curl -s -u ":$pass" "http://$addr/?pvm=2026-09-05")" "Etsi"
|
||||
"$(curl -s -u ":$pass" "http://$addr/?pvm=$d0")" "Etsi"
|
||||
|
||||
check "search filters the board" \
|
||||
"$(curl -s -u ":$pass" "http://$addr/?haku=keitto")" "keitto"
|
||||
|
||||
Reference in New Issue
Block a user