Table of Contents
Sprint 1 — Plan, Part 3 ("Spark")
Continued from Sprint-1-Plan-Part-2. Covers W4 (frontend), W5 (seed data), W6 (deploy + CI/CD).
W4 — Frontend
Goal: the four humans can see the idea list and post a new idea. PWA manifest is served and "Add to Home Screen" works on iOS Safari + Android Chrome. Service worker registers (stub-only, no caching yet).
Pre-requisite: W3 complete (/api/ideas works locally against a logged-in proxy session).
Deliverables:
frontend/app/layout.tsx— root layout, fonts, theme colour, link to manifest.frontend/app/(app)/layout.tsx— protected layout: redirects to/loginif no session.frontend/app/(app)/page.tsx—/redirects to/ideas.frontend/app/(app)/ideas/page.tsx— list view (server component).frontend/app/(app)/ideas/new/page.tsx— create form (client component).frontend/app/(app)/ideas/components/idea-list.tsx.frontend/app/(app)/ideas/components/idea-form.tsx.frontend/lib/api.ts— thin fetch wrappers.frontend/public/manifest.json.frontend/public/sw.js— stub service worker.frontend/lib/sw-register.ts— SW registration on first paint.- App icon set:
icon-192.png,icon-512.png,apple-touch-icon.png,favicon.ico. Hand-drawn orimagemagick-generated single-colour campfire glyph. See Open Question Q07.
Acceptance gate:
- Local dev: signed-in user can hit
/ideas, see an empty list, click "+", post an idea, see it appear. /manifest.jsonreturns 200 with valid content.- DevTools → Application → Manifest shows green checkmarks.
- iOS Safari "Add to Home Screen" produces an icon labelled "Sparkboard".
- Android Chrome shows the install prompt.
- Satisfies the UI half of A4, all of A5, and the post-login navigation half of A1.
Code sketch — frontend/app/(app)/ideas/page.tsx (server component):
import { listIdeas } from "@/lib/api";
import { IdeaList } from "./components/idea-list";
import Link from "next/link";
export default async function IdeasPage() {
const ideas = await listIdeas();
return (
<main className="mx-auto max-w-2xl px-4 py-8">
<header className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-semibold">Sparkboard</h1>
<Link
href="/ideas/new"
className="rounded-md bg-orange-600 px-4 py-2 text-white text-sm"
>
+ New
</Link>
</header>
<IdeaList ideas={ideas} />
</main>
);
}
Code sketch — frontend/lib/api.ts:
import { cookies } from "next/headers";
export type Idea = {
id: string;
authorId: string;
title: string;
description: string | null;
status: "RAW" | "EXPLORING" | "BUILDING" | "SHIPPED" | "DEAD";
createdAt: string;
updatedAt: string;
};
const ORIGIN = process.env.NEXTAUTH_URL ?? "http://localhost:3000";
export async function listIdeas(): Promise<Idea[]> {
const res = await fetch(`${ORIGIN}/api/backend/api/ideas`, {
headers: { cookie: (await cookies()).toString() },
cache: "no-store",
});
if (!res.ok) throw new Error(`listIdeas: ${res.status}`);
return res.json();
}
export async function createIdea(input: { title: string; description?: string }) {
const res = await fetch(`${ORIGIN}/api/backend/api/ideas`, {
method: "POST",
headers: { "content-type": "application/json", cookie: (await cookies()).toString() },
body: JSON.stringify(input),
});
if (!res.ok) throw new Error(`createIdea: ${res.status}`);
return res.json();
}
Code sketch — frontend/app/(app)/ideas/components/idea-form.tsx (client component):
"use client";
import { useTransition, useState } from "react";
import { useRouter } from "next/navigation";
export function IdeaForm() {
const router = useRouter();
const [pending, start] = useTransition();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const submit = (e: React.FormEvent) => {
e.preventDefault();
start(async () => {
const res = await fetch("/api/backend/api/ideas", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ title, description: description || undefined }),
});
if (res.ok) router.push("/ideas");
});
};
return (
<form onSubmit={submit} className="space-y-4">
<input
autoFocus required maxLength={200}
value={title} onChange={e => setTitle(e.target.value)}
placeholder="Catch the spark…"
className="w-full text-lg border-b py-2 focus:outline-none"
/>
<textarea
value={description} onChange={e => setDescription(e.target.value)}
placeholder="More detail (optional)"
className="w-full min-h-[120px] border rounded p-3"
/>
<button
type="submit" disabled={pending || !title.trim()}
className="rounded bg-orange-600 px-4 py-2 text-white disabled:opacity-50"
>
{pending ? "Posting…" : "Post"}
</button>
</form>
);
}
Code sketch — frontend/public/manifest.json:
{
"name": "Sparkboard",
"short_name": "Sparkboard",
"description": "Catch the spark before it fades.",
"start_url": "/ideas",
"scope": "/",
"display": "standalone",
"background_color": "#0a0a0a",
"theme_color": "#ea580c",
"orientation": "portrait-primary",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
]
}
Code sketch — frontend/public/sw.js (stub, intentionally minimal):
// Sprint 1 service worker stub.
// Purpose: register, claim clients, fail closed. NO caching.
// Real offline caching arrives in Sprint 4 (Ember).
self.addEventListener("install", e => self.skipWaiting());
self.addEventListener("activate", e => e.waitUntil(self.clients.claim()));
self.addEventListener("fetch", () => { /* let the network handle it */ });
Code sketch — frontend/lib/sw-register.ts:
"use client";
import { useEffect } from "react";
export function ServiceWorkerRegister() {
useEffect(() => {
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js").catch(() => {});
}
}, []);
return null;
}
Wired into app/layout.tsx once.
On protecting the (app) route group:
NextAuth v5 + @platesoft/auth's middleware re-export handles auth-aware redirects. Sparkboard's middleware.ts:
export { default } from "@platesoft/auth/middleware";
export const config = { matcher: ["/((?!login|api/auth|_next|icons|sw.js|manifest.json).*)"] };
Anything matched and unauthenticated → bounce to /login. That's the entire client-side auth check.
W5 — Seed data
Goal: local dev and CI environments start with a tiny but useful dataset so the four humans (and the test runner) can see something on day one.
Pre-requisite: W2 + W3 + W4 done. The migration system works.
Deliverables:
backend/src/main/resources/db/migration/V2__seed_family_spark_org.sql— already done in W2.- No idea seed data in production. The
ideastable starts empty. Patrick will post the first idea himself ("Sparkboard exists.") and that becomes the canonical first row. - Dev-only
backend/src/main/resources/db/migration/dev/Flyway location, activated withspring.profiles.active=dev— seeds 3 example ideas with a fixedauthor_idof00000000-0000-0000-0000-000000000099("Dev User") so the list page is non-empty when the developer boots locally. - Local dev convenience: a
dev-loginbutton on/loginthat POSTs to adev-only/api/auth/dev-loginendpoint shipped by plate-auth'sdevprofile (already provided by plate-auth — see plate-auth Integration Guide §3). Sparkboard does not implement this endpoint; just configures the flag.
Acceptance gate:
- Local backend boot with
spring.profiles.active=devpopulates 3 example ideas. - Local backend boot without dev profile leaves
ideasempty. - Local frontend boot with
devenv shows a "Dev login" button alongside "Sign in with Google". - Prod boot has neither dev migration nor dev login.
Code sketch — dev seed migration db/migration/dev/R__dev_seed_ideas.sql (Flyway repeatable, prefix R__):
-- Runs every dev boot if changed. Idempotent via WHERE NOT EXISTS.
INSERT INTO ideas (id, org_id, author_id, title, description, status, created_at, updated_at)
SELECT
'11111111-1111-1111-1111-111111111111',
'00000000-0000-0000-0000-000000000001',
'00000000-0000-0000-0000-000000000099',
'Sparkboard exists.',
'The first canonical idea. Burn it down if you want.',
'EXPLORING',
now() - interval '3 days',
now() - interval '3 days'
WHERE NOT EXISTS (SELECT 1 FROM ideas WHERE id = '11111111-1111-1111-1111-111111111111');
-- Two more sample ideas elided for brevity.
Activated via spring.flyway.locations=classpath:db/migration,classpath:db/migration/dev in application-dev.yml.
W6 — Deploy + CI/CD
Goal: a push to main on git.plate-software.de/pplate/sparkboard results in a healthy https://sparkboard.plate-software.de within 10 minutes. Wall-clock end-to-end.
Pre-requisite: W4 complete (a build that runs locally). All Sprint-0 prereqs done: DNS, frps port 30011, IONOS vhost, TrueNAS dataset, Google OAuth client.
Deliverables:
backend/Dockerfile— multi-stage, distroless Java 25 runtime.frontend/Dockerfile— multi-stage, Next.js standalone build on Node 22.docker-compose.yml— local dev (backend + frontend + postgres).docker-compose.prod.yml— TrueNAS production (adds named volumes, host network for frpc, restart policies).deploy/caddy/Caddyfile— internal reverse proxy on TrueNAS routing/api/*→ backend, everything else → frontend.deploy/deploy.sh— SSH-side script: pull images, rundocker compose -f docker-compose.prod.yml up -d, prune.deploy/smoke-test.sh— post-deploy:curl https://sparkboard.plate-software.de/api/health,/login,/manifest.json— all must return 2xx..gitea/workflows/deploy.yml— on push tomain: build → push to Gitea registry → SSH-deploy.- IONOS Apache vhost block (out-of-repo, lives on the IONOS server):
sparkboard.plate-software.de→http://<truenas-ip>:30011. frpc.tomlentry on TrueNAS, new section:[sparkboard] type = "tcp" local_ip = "127.0.0.1" local_port = 8080 # caddy on truenas remote_port = 30011
Acceptance gate:
- A merge to
maintriggers.gitea/workflows/deploy.yml. - The workflow builds both images, pushes to
docker.git.plate-software.de/pplate/sparkboard-backend:<sha>andsparkboard-frontend:<sha>. - The SSH step runs
deploy/deploy.shon TrueNAS; the script picks up the new images and recreates the containers. deploy/smoke-test.shruns againsthttps://sparkboard.plate-software.deand passes.- A real Google sign-in from a phone on cellular reaches
/ideasand shows an empty list (or the dev seed if dev). End-to-end. - Satisfies A6 and is the final closing gate for A1.
Code sketch — backend/Dockerfile:
# syntax=docker/dockerfile:1
FROM maven:3.9-eclipse-temurin-25 AS build
WORKDIR /build
COPY pom.xml .
RUN mvn -B -e -ntp dependency:go-offline
COPY src src
RUN mvn -B -e -ntp -DskipTests package
FROM gcr.io/distroless/java25-debian12:nonroot
WORKDIR /app
COPY --from=build /build/target/sparkboard-backend-*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
Code sketch — frontend/Dockerfile:
# syntax=docker/dockerfile:1
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
FROM node:22-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN corepack enable && pnpm build
FROM gcr.io/distroless/nodejs22-debian12:nonroot
WORKDIR /app
COPY --from=build /app/.next/standalone /app/
COPY --from=build /app/.next/static /app/.next/static
COPY --from=build /app/public /app/public
EXPOSE 3000
ENV PORT=3000 HOSTNAME=0.0.0.0
CMD ["server.js"]
Code sketch — docker-compose.prod.yml (excerpt):
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: sparkboard
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- /mnt/tank/sparkboard/pgdata:/var/lib/postgresql/data
restart: unless-stopped
backend:
image: docker.git.plate-software.de/pplate/sparkboard-backend:${SHA}
depends_on: [postgres]
environment:
SPRING_PROFILES_ACTIVE: prod
DB_HOST: postgres
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
PLATE_AUTH_JWT_SECRET: ${PLATE_AUTH_JWT_SECRET}
PLATE_AUTH_EXCHANGE_SECRET: ${PLATE_AUTH_EXCHANGE_SECRET}
PLATE_AUTH_ALLOWLIST_EMAILS: ${PLATE_AUTH_ALLOWLIST_EMAILS}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET}
restart: unless-stopped
frontend:
image: docker.git.plate-software.de/pplate/sparkboard-frontend:${SHA}
depends_on: [backend]
environment:
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
NEXTAUTH_URL: https://sparkboard.plate-software.de
PLATE_AUTH_BACKEND_URL: http://backend:8080
PLATE_AUTH_EXCHANGE_SECRET: ${PLATE_AUTH_EXCHANGE_SECRET}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET}
restart: unless-stopped
caddy:
image: caddy:2-alpine
ports: ["127.0.0.1:8080:8080"] # frpc forwards 30011 → 8080
depends_on: [backend, frontend]
volumes:
- ./deploy/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
restart: unless-stopped
Code sketch — deploy/caddy/Caddyfile:
:8080 {
encode gzip
handle /api/* {
reverse_proxy backend:8080
}
handle {
reverse_proxy frontend:3000
}
}
Code sketch — .gitea/workflows/deploy.yml:
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Login Gitea registry
run: echo "${{ secrets.GITEA_DEPLOY_TOKEN }}" | docker login docker.git.plate-software.de -u pplate --password-stdin
- name: Build backend
run: docker build -t docker.git.plate-software.de/pplate/sparkboard-backend:${{ github.sha }} ./backend
- name: Build frontend
run: docker build -t docker.git.plate-software.de/pplate/sparkboard-frontend:${{ github.sha }} ./frontend
- name: Push images
run: |
docker push docker.git.plate-software.de/pplate/sparkboard-backend:${{ github.sha }}
docker push docker.git.plate-software.de/pplate/sparkboard-frontend:${{ github.sha }}
- name: SSH deploy
uses: appleboy/ssh-action@v1.0.0
with:
host: ${{ secrets.TRUENAS_HOST }}
username: ${{ secrets.TRUENAS_USER }}
key: ${{ secrets.TRUENAS_SSH_KEY }}
script: |
cd /mnt/tank/sparkboard
export SHA=${{ github.sha }}
./deploy.sh
- name: Smoke test
run: ./deploy/smoke-test.sh
Code sketch — deploy/deploy.sh:
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
echo "Deploying SHA=${SHA}"
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d --remove-orphans
docker image prune -af --filter "until=168h"
echo "Deploy complete."
Code sketch — deploy/smoke-test.sh:
#!/usr/bin/env bash
set -euo pipefail
URL="https://sparkboard.plate-software.de"
check() {
local path="$1" expected="$2"
local got
got=$(curl -sS -o /dev/null -w "%{http_code}" "$URL$path")
[[ "$got" == "$expected" ]] || { echo "FAIL $path: got $got, expected $expected"; exit 1; }
echo "OK $path → $got"
}
check /api/health 200
check /login 200
check /manifest.json 200
check /sw.js 200
echo "All smoke checks passed."
/api/health is provided by spring-boot-starter-actuator — Sparkboard exposes management.endpoints.web.exposure.include=health in application-prod.yml. No code written.
Continued in Sprint-1-Plan-Part-4: implementation order, acceptance→workstream mapping, open questions.