1
Sprint 1 Plan Part 3
Patrick Plate edited this page 2026-06-24 14:53:33 +02:00

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:

  1. frontend/app/layout.tsx — root layout, fonts, theme colour, link to manifest.
  2. frontend/app/(app)/layout.tsx — protected layout: redirects to /login if no session.
  3. frontend/app/(app)/page.tsx/ redirects to /ideas.
  4. frontend/app/(app)/ideas/page.tsx — list view (server component).
  5. frontend/app/(app)/ideas/new/page.tsx — create form (client component).
  6. frontend/app/(app)/ideas/components/idea-list.tsx.
  7. frontend/app/(app)/ideas/components/idea-form.tsx.
  8. frontend/lib/api.ts — thin fetch wrappers.
  9. frontend/public/manifest.json.
  10. frontend/public/sw.js — stub service worker.
  11. frontend/lib/sw-register.ts — SW registration on first paint.
  12. App icon set: icon-192.png, icon-512.png, apple-touch-icon.png, favicon.ico. Hand-drawn or imagemagick-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.json returns 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:

  1. backend/src/main/resources/db/migration/V2__seed_family_spark_org.sql — already done in W2.
  2. No idea seed data in production. The ideas table starts empty. Patrick will post the first idea himself ("Sparkboard exists.") and that becomes the canonical first row.
  3. Dev-only backend/src/main/resources/db/migration/dev/ Flyway location, activated with spring.profiles.active=dev — seeds 3 example ideas with a fixed author_id of 00000000-0000-0000-0000-000000000099 ("Dev User") so the list page is non-empty when the developer boots locally.
  4. Local dev convenience: a dev-login button on /login that POSTs to a dev-only /api/auth/dev-login endpoint shipped by plate-auth's dev profile (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=dev populates 3 example ideas.
  • Local backend boot without dev profile leaves ideas empty.
  • Local frontend boot with dev env 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:

  1. backend/Dockerfile — multi-stage, distroless Java 25 runtime.
  2. frontend/Dockerfile — multi-stage, Next.js standalone build on Node 22.
  3. docker-compose.yml — local dev (backend + frontend + postgres).
  4. docker-compose.prod.yml — TrueNAS production (adds named volumes, host network for frpc, restart policies).
  5. deploy/caddy/Caddyfile — internal reverse proxy on TrueNAS routing /api/* → backend, everything else → frontend.
  6. deploy/deploy.sh — SSH-side script: pull images, run docker compose -f docker-compose.prod.yml up -d, prune.
  7. deploy/smoke-test.sh — post-deploy: curl https://sparkboard.plate-software.de/api/health, /login, /manifest.json — all must return 2xx.
  8. .gitea/workflows/deploy.yml — on push to main: build → push to Gitea registry → SSH-deploy.
  9. IONOS Apache vhost block (out-of-repo, lives on the IONOS server): sparkboard.plate-software.dehttp://<truenas-ip>:30011.
  10. frpc.toml entry 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 main triggers .gitea/workflows/deploy.yml.
  • The workflow builds both images, pushes to docker.git.plate-software.de/pplate/sparkboard-backend:<sha> and sparkboard-frontend:<sha>.
  • The SSH step runs deploy/deploy.sh on TrueNAS; the script picks up the new images and recreates the containers.
  • deploy/smoke-test.sh runs against https://sparkboard.plate-software.de and passes.
  • A real Google sign-in from a phone on cellular reaches /ideas and 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.