Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Generated files. All of these are committed so a plain `go build` and `bunx
# playwright test` work on a fresh clone without the generator toolchain, but
# they are never hand-edited - regenerate instead, and CI enforces that
# regenerating produces no diff.
#
# linguist-generated: collapse them in PR diffs and exclude from language stats.
# -diff: don't produce a textual diff for them by default.

# templ -> Go
*_templ.go linguist-generated=true -diff

# sqlc -> Go, generated from internal/db/queries.sql + the schema. These land in
# internal/db/ next to the hand-written store.go, so they're marked by name.
internal/db/db.go linguist-generated=true -diff
internal/db/models.go linguist-generated=true -diff
internal/db/queries.sql.go linguist-generated=true -diff

# Paraglide compiles messages/{da,en,fr}.json into the typed m.*() module the
# e2e specs import. It lives under src/ because the specs import it by that
# relative path.
src/lib/paraglide/** linguist-generated=true -diff

# Tailwind -> CSS, and the vendored JS libraries.
static/app.css linguist-generated=true -diff
static/htmx.min.js linguist-vendored=true -diff
static/alpine.min.js linguist-vendored=true -diff

# Lockfile
bun.lock linguist-generated=true -diff
20 changes: 14 additions & 6 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,19 @@
# (run automatically by the "prepare" script on `bun install`).
set -e

echo "▶ format:check"
bun run format:check
echo "▶ gofmt"
unformatted=$(gofmt -l . 2>/dev/null | grep -v '_templ\.go' || true)
if [ -n "$unformatted" ]; then
echo "not gofmt'd:"
echo "$unformatted"
exit 1
fi

echo "▶ go vet"
go vet ./...

echo "▶ lint"
bun run lint
echo "▶ go test"
go test ./...

echo "▶ check (svelte-check)"
bun run check
echo "▶ format:check"
bun run format:check
71 changes: 64 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,32 +5,89 @@ on:
pull_request:

jobs:
# One job, no matrix - single app, chromium-only per iteration 7.
# One job, no matrix - single app, chromium-only.
check-and-test:
runs-on: ubuntu-latest
env:
CI: 'true'
DATABASE_URL: postgres://postgres:postgres@localhost:55432/poll

# The e2e harness talks to this service, so the specs and the app share one
# database - the same shape compose.yaml gives local dev.
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: poll
ports:
- 55432:5432
options: >-
--health-cmd "pg_isready -U postgres -d poll"
--health-interval 5s
--health-timeout 5s
--health-retries 10

steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version: '1.25'
cache: true

- uses: oven-sh/setup-bun@v2

- name: Install code generators
run: |
go install github.com/a-h/templ/cmd/[email protected]
go install github.com/sqlc-dev/sqlc/cmd/[email protected]

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Install Playwright browser
run: bunx playwright install --with-deps chromium

- name: Type-check
run: bun run check
- name: Build (regenerates everything)
run: make build

# Generated code is committed, so regenerating it must be a no-op. If this
# fails, someone edited generated output by hand or changed a source
# (queries.sql, a .templ, messages/*.json) without running `make build`.
- name: Generated code is up to date
run: |
if [ -n "$(git status --porcelain)" ]; then
echo "::error::Generated files are stale. Run 'make build' and commit the result."
git status --porcelain
git diff
exit 1
fi

- name: Format
run: |
gofmt -l . | grep -v '_templ\.go' > /tmp/unformatted || true
if [ -s /tmp/unformatted ]; then
echo "::error::Not gofmt'd:"; cat /tmp/unformatted; exit 1
fi
bun run format:check

- name: Vet
run: go vet ./...

- name: Unit tests
run: bun run test
run: go test ./...

# The service above is already up and healthy, so the schema applies directly.
- name: Apply schema
run: psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f internal/db/migrations/0001_init.sql

# test:e2e applies migrations (globalSetup) and boots `bun run preview`
# (vite build && wrangler dev) against a fresh local D1 in the runner.
# DB_READY: the Postgres service above is already up with the schema applied,
# so the server command must not try to start one of its own.
- name: E2E tests
run: bun run test:e2e
run: bunx playwright test
env:
DB_READY: '1'

- name: Upload Playwright report
if: failure()
Expand Down
20 changes: 6 additions & 14 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,18 +1,14 @@
node_modules

# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# Go build output
/poll
/bin

# Paraglide (generated from messages/*.json + project.inlang)
/src/lib/paraglide
# Generated code is committed - see .gitattributes. A fresh clone builds and
# runs the tests without templ/sqlc/tailwind installed, and CI checks that
# regenerating produces no diff. So nothing generated is ignored here.

# Test artifacts
.playwright-mcp
test-results
playwright-report

Expand All @@ -29,7 +25,3 @@ Thumbs.db
.env.*
!.env.example
!.env.test

# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
14 changes: 10 additions & 4 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
.svelte-kit
.wrangler
build
node_modules

# Generated / vendored - committed, but never hand-formatted.
src/lib/paraglide
src/lib/types.ts
static/app.css
static/htmx.min.js
static/alpine.min.js
bun.lock
migrations

test-results
playwright-report
4 changes: 1 addition & 3 deletions .prettierrc
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,5 @@
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
"printWidth": 100
}
55 changes: 55 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
DATABASE_URL ?= postgres://postgres:postgres@localhost:55432/poll

.PHONY: generate css vendor build run db e2e-server test clean

# Everything generated is committed (see .gitattributes); CI runs `make build`
# and fails if the tree is dirty afterwards, so these stay honest.
# templ -> *_templ.go
# sqlc -> internal/db/{db,models,queries.sql}.go
# paraglide-> src/lib/paraglide/ (the module the e2e specs import)
generate:
templ generate
sqlc generate
bun run messages
# paraglide-js writes a `*` .gitignore into its own output dir. We commit that
# output deliberately (the specs import it), so drop the ignore file - it would
# otherwise hide the module from a fresh clone and from CI's porcelain check.
rm -f src/lib/paraglide/.gitignore src/lib/paraglide/.prettierignore

css:
bunx @tailwindcss/cli -i static/app.src.css -o static/app.css --minify

vendor:
cp node_modules/htmx.org/dist/htmx.min.js static/htmx.min.js
cp node_modules/alpinejs/dist/cdn.min.js static/alpine.min.js

build: generate css vendor
go build ./...

# Bring up Postgres (idempotent - reuses a running container) and apply the
# schema fresh, so no run inherits the last one's rows. CI supplies its own
# database and applies the schema itself, so it skips this.
db:
docker compose up -d --wait
psql "$(DATABASE_URL)" -v ON_ERROR_STOP=1 -X -q \
-c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;' \
-f internal/db/migrations/0001_init.sql

run: build db
go run ./cmd/server

# What playwright.config.ts boots. CI already has a database with the schema
# applied, so it sets DB_READY=1 and this skips straight to the server.
e2e-server: build $(if $(DB_READY),,db)
go run ./cmd/server

test:
go test ./...

clean:
rm -f static/app.css static/htmx.min.js static/alpine.min.js
find . -name '*_templ.go' -delete
# sqlc's output shares internal/db/ with the hand-written store.go, so remove
# it by exact name - never a wildcard over that directory.
rm -f internal/db/db.go internal/db/models.go internal/db/queries.sql.go
docker compose down -v 2>/dev/null || true
81 changes: 48 additions & 33 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# family-date-poll
# poll

[![CI](https://github.com/malpou/family-date-poll/actions/workflows/ci.yml/badge.svg)](https://github.com/malpou/family-date-poll/actions/workflows/ci.yml)

Expand Down Expand Up @@ -35,56 +35,71 @@ shows it prominently at the top.

## Stack

- **SvelteKit 2** + **Svelte 5** (runes) + **Tailwind 4**
- **Cloudflare Workers/Pages** hosting, **D1** (SQLite) for storage
- **Paraglide** (inlang) for DA/EN/FR messages, compiled from `messages/{da,en,fr}.json`
- **bun** for install, scripts, and lockfile
- **Playwright** for end-to-end smoke tests
- **Go** + **[templ](https://templ.guide)** for server-rendered HTML
- **[HTMX](https://htmx.org)** for server round-trips (form actions, mutations)
and **[Alpine.js](https://alpinejs.dev)** for in-page state (the preference
selector, the submit gate, inline edit toggles)
- **[sqlc](https://sqlc.dev)** over **Postgres** (pgx/v5) - queries are written
as SQL and compiled to typed Go
- **Tailwind 4** for styling, compiled to a static `static/app.css`
- **Paraglide** (inlang) for DA/EN/FR messages, compiled from
`messages/{da,en,fr}.json`
- **Playwright** for end-to-end tests

UI is organized by **atomic design** under `src/lib/components/`
(`atoms/` → `molecules/` → `organisms/`). Data goes through a
single `DataProvider` interface (`src/lib/data/provider.ts`) with a mock
implementation for local dev and tests and a D1 implementation in production -
swapping is one line.
Layout:

```
cmd/server/ main - wiring, config, graceful DB wait
internal/handlers/ HTTP: routes, form actions, validation
internal/views/ templ templates (the only place HTML lives)
internal/domain/ dates, results ranking, tokens - pure logic, unit-tested
internal/db/ store.go + queries.sql -> sqlc-generated code
messages/ da/en/fr JSON: one source of truth for copy
```

`messages/{da,en,fr}.json` is read by **both** the Go server (rendering) and the
Playwright specs (asserting), so copy can never drift between app and tests.
All app SQL lives in `internal/db/queries.sql`; the e2e specs seed through
`e2e/db.ts`. Neither writes SQL anywhere else.

## Develop

Requires Go 1.25+, [templ](https://templ.guide/quick-start/installation),
[sqlc](https://docs.sqlc.dev/en/latest/overview/install.html), bun, and Docker
(for the local Postgres).

```sh
bun install
bun run dev # http://localhost:5173, uses the mock data provider
make run # brings up Postgres, builds, serves on http://localhost:8787
```

Useful scripts:
Useful targets:

```sh
bun run check # svelte-check (types)
bun run build # production build
bun run preview # preview the production build
make build # generate (templ, sqlc, messages) + tailwind + vendor JS + go build
make test # Go unit tests
make db # bring up Postgres and apply the schema
make clean # drop generated artifacts and the database container
```

## Testing

```sh
bun run test # unit tests (vitest)
bun run test:e2e # Playwright smoke tests: full journey against a local D1
make test # Go unit tests (dates, results ranking)
bun run test:e2e # Playwright: full journey against the real server + Postgres
```

`test:e2e` builds the app, boots `wrangler dev` on a freshly-migrated **local
D1**, and drives the real create → dashboard → respond → results journey. Specs
seed and read the database through one shared helper (`e2e/db.ts`) - no raw SQL
in the tests themselves; all query-building lives in that module, mirroring how
`src/lib/data/d1.ts` is the single home for app SQL.
`test:e2e` boots the Go server against a throwaway Postgres container
(`compose.yaml`), applies the schema, and drives the real create → dashboard →
respond → results journey. Specs seed and read the database through one shared
helper (`e2e/db.ts`) - no raw SQL in the tests themselves.

**CI** ([`.github/workflows/ci.yml`](.github/workflows/ci.yml)) runs the full
gate - `check` → unit tests → e2e - on every push and pull request, and uploads
the Playwright HTML report as an artifact when a run fails.

## Deploy (Cloudflare)
gate - vet → unit tests → e2e - on every push and pull request, and uploads the
Playwright HTML report as an artifact when a run fails.

```sh
bun run d1:migrate # apply migrations to D1
bun run deploy # publish to Cloudflare
```
## Deploy

Requires a Cloudflare account with a D1 database bound as `DB` and the
`poll.malpou.io` route configured. See `wrangler.toml`.
The server is a single Go binary plus the `static/` directory. It needs
`DATABASE_URL` (Postgres) and optionally `PORT` (default 8787). Apply
`internal/db/migrations/0001_init.sql` to a fresh database before first boot.
Loading
Loading