Skip to content
Draft
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
12 changes: 7 additions & 5 deletions apps/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,25 @@

The user-facing web application. A Next.js server-rendered React app.

**Boilerplate:** the home page shows a welcome message and a health-check link.
Example branches add their own pages, API routes, and Client Components.
The Job Tracker: a landing page, the signed-in user's job list, and a form to
add a job (see `docs/specs/jobs/list.md`).

## Public surface

All public routes are defined under `app/api/`:
Pages live under `app/`, route handlers under `app/api/`:

| Route | Method | Purpose |
|---|---|---|
| `/` | GET | Home page — boilerplate welcome message |
| `/` | GET | Landing page — links to the job list |
| `/jobs` | GET | The current user's jobs, newest applied first |
| `/jobs/new` | GET | Add-a-job form; submits via a Server Action |
| `/api/health` | GET | Health check — is the process alive? Can it reach the DB? |

## Dependencies

- `@project/db` — the Prisma client and generated types
- `@project/services` — queue, storage, and notify adapters
- `@project/domain` — Zod schemas and database queries (empty on main, populated on example branches)
- `@project/domain` — Zod schemas and database queries
- `@project/auth` — the `currentUserId()` dev identity stub
- `@project/log` — pino logger

Expand Down
16 changes: 16 additions & 0 deletions apps/web/app/jobs/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"use client";
// Retry card for /jobs. Shows a generic message — never error.message.
export default function JobsError({ reset }: { error: Error; reset: () => void }) {
return (
<main role="alert" className="rounded-lg border border-red-200 bg-red-50 p-6">
<p className="mb-4 text-red-800">We couldn't load your jobs.</p>
<button
type="button"
onClick={reset}
className="rounded bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-700"
>
Try again
</button>
</main>
);
}
13 changes: 13 additions & 0 deletions apps/web/app/jobs/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Skeleton for /jobs while the list loads.
export default function Loading() {
return (
<main className="space-y-6" aria-busy="true" aria-label="Loading jobs">
<div className="h-8 w-40 animate-pulse rounded bg-neutral-200" />
<div className="space-y-2">
{Array.from({ length: 5 }, (_, i) => (
<div key={i} className="h-6 animate-pulse rounded bg-neutral-100" />
))}
</div>
</main>
);
}
42 changes: 42 additions & 0 deletions apps/web/app/jobs/new/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"use server";
// Creates a job from the /jobs/new form, following the server entry ritual
// in docs/specs/web.md.
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { currentUserId } from "@project/auth";
import { CreateJob, createJob } from "@project/domain";
import { mapError, type ErrorBody } from "../../../lib/errors";

// fieldErrors and values ride beside the error shape; values refill the form,
// which React resets after every action.
export type CreateState =
| (ErrorBody & {
fieldErrors?: Record<string, string[] | undefined>;
values?: Record<string, string>;
})
| null;

export async function createJobAction(_: CreateState, form: FormData): Promise<CreateState> {
const userId = await currentUserId();
const values = {
company: String(form.get("company") ?? ""),
title: String(form.get("title") ?? ""),
status: String(form.get("status") ?? ""),
dateApplied: String(form.get("dateApplied") ?? ""),
};
const parsed = CreateJob.safeParse({
company: values.company,
title: values.title,
status: values.status || undefined,
dateApplied: values.dateApplied || undefined,
});
if (!parsed.success)
return { ...mapError(parsed.error).body, fieldErrors: parsed.error.flatten().fieldErrors, values };
try {
await createJob(userId, parsed.data);
} catch (e) {
return { ...mapError(e).body, values };
}
revalidatePath("/jobs");
redirect("/jobs"); // outside try — redirect throws
}
71 changes: 71 additions & 0 deletions apps/web/app/jobs/new/form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"use client";
// The add-a-job form. Field errors render under their input; any other error
// renders once at the top.
import { useActionState } from "react";
import { createJobAction } from "./actions";

const input = "w-full rounded border border-neutral-300 px-3 py-2";

function FieldError({ id, messages }: { id: string; messages?: string[] }) {
if (!messages?.length) return null;
return (
<p id={id} className="mt-1 text-sm text-red-700">
{messages[0]}
</p>
);
}

// Statuses come from the server page: importing @project/domain here would
// pull the Prisma client into the browser bundle.
export function NewJobForm({ statuses, today }: { statuses: readonly string[]; today: string }) {
const [state, formAction, pending] = useActionState(createJobAction, null);
const fe = state?.fieldErrors ?? {};
const v = state?.values;

return (
<form action={formAction} className="space-y-4">
{state && !state.fieldErrors && (
<p role="alert" className="rounded border border-red-200 bg-red-50 p-3 text-sm text-red-800">
{state.error.message}
</p>
)}

<div>
<label htmlFor="company" className="mb-1 block text-sm font-medium">Company</label>
<input id="company" name="company" required defaultValue={v?.company} className={input}
aria-invalid={!!fe.company} aria-describedby={fe.company ? "company-error" : undefined} />
<FieldError id="company-error" messages={fe.company} />
</div>

<div>
<label htmlFor="title" className="mb-1 block text-sm font-medium">Title</label>
<input id="title" name="title" required defaultValue={v?.title} className={input}
aria-invalid={!!fe.title} aria-describedby={fe.title ? "title-error" : undefined} />
<FieldError id="title-error" messages={fe.title} />
</div>

<div>
<label htmlFor="status" className="mb-1 block text-sm font-medium">Status</label>
<select id="status" name="status" defaultValue={v?.status || "APPLIED"} className={input}
aria-invalid={!!fe.status} aria-describedby={fe.status ? "status-error" : undefined}>
{statuses.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
<FieldError id="status-error" messages={fe.status} />
</div>

<div>
<label htmlFor="dateApplied" className="mb-1 block text-sm font-medium">Date applied</label>
<input id="dateApplied" name="dateApplied" type="date" defaultValue={v?.dateApplied || today} className={input}
aria-invalid={!!fe.dateApplied} aria-describedby={fe.dateApplied ? "dateApplied-error" : undefined} />
<FieldError id="dateApplied-error" messages={fe.dateApplied} />
</div>

<button type="submit" disabled={pending}
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50">
{pending ? "Adding…" : "Add job"}
</button>
</form>
);
}
21 changes: 21 additions & 0 deletions apps/web/app/jobs/new/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Add-a-job page (docs/specs/jobs/list.md).
import Link from "next/link";
import { JOB_STATUSES } from "@project/domain";
import { NewJobForm } from "./form";

// Dynamic so the date-applied default is today, not the build date.
export const dynamic = "force-dynamic";

export default function NewJobPage() {
return (
<main className="space-y-6">
<header>
<Link href="/jobs" className="text-sm text-blue-600 hover:text-blue-800">
&larr; Your jobs
</Link>
<h1 className="text-2xl font-bold">Add a job</h1>
</header>
<NewJobForm statuses={JOB_STATUSES} today={new Date().toISOString().slice(0, 10)} />
</main>
);
}
64 changes: 64 additions & 0 deletions apps/web/app/jobs/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// The current user's jobs, newest applied first (docs/specs/jobs/list.md).
import Link from "next/link";
import { currentUserId } from "@project/auth";
import { listJobs } from "@project/domain";

export const dynamic = "force-dynamic";

// UTC so a date stored at midnight UTC doesn't render as the previous day.
function fmt(d: Date) {
return d.toLocaleDateString("en-US", { timeZone: "UTC" });
}

const addLink = (
<Link
href="/jobs/new"
className="rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
Add a job
</Link>
);

export default async function JobsPage() {
const userId = await currentUserId();
const jobs = await listJobs(userId);

return (
<main className="space-y-6">
<header className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Your jobs</h1>
{addLink}
</header>

{jobs.length === 0 ? (
<section className="rounded-lg border border-dashed border-neutral-300 p-8 text-center">
<p className="mb-4 text-neutral-600">No jobs yet. Add the first application you've sent.</p>
{addLink}
</section>
) : (
<table className="w-full text-left text-sm">
<thead className="border-b border-neutral-200 text-neutral-500">
<tr>
<th className="py-2 font-medium">Company</th>
<th className="py-2 font-medium">Title</th>
<th className="py-2 font-medium">Status</th>
<th className="py-2 font-medium">Applied</th>
<th className="py-2 font-medium">Follow up</th>
</tr>
</thead>
<tbody>
{jobs.map((j) => (
<tr key={j.id} className="border-b border-neutral-100">
<td className="py-2">{j.company}</td>
<td className="py-2">{j.title}</td>
<td className="py-2">{j.status}</td>
<td className="py-2">{fmt(j.dateApplied)}</td>
<td className="py-2">{j.followUpDate ? fmt(j.followUpDate) : "—"}</td>
</tr>
))}
</tbody>
</table>
)}
</main>
);
}
4 changes: 2 additions & 2 deletions apps/web/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import type { Metadata } from "next";
import "./globals.css";

export const metadata: Metadata = {
title: "Starter Skeleton",
description: "C12 Fall 2026 — every layer exists; the weeks make them deep.",
title: "Job Tracker",
description: "Track every job application and what happened next.",
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
Expand Down
41 changes: 13 additions & 28 deletions apps/web/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,19 @@
// Boilerplate home page. Shows the current dev user and a link to the health
// check endpoint. Example branches replace this with their own app-specific UI.
import { currentUserId } from "@project/auth";

export const dynamic = "force-dynamic";

export default async function Home() {
const userId = await currentUserId();
// Landing page. Points the user at their job list.
import Link from "next/link";

export default function Home() {
return (
<main className="space-y-8">
<main className="space-y-6">
<header>
<h1 className="text-2xl font-bold">Starter Skeleton</h1>
<p className="text-sm text-neutral-500">
Signed in as <code className="rounded bg-neutral-100 px-1">{userId}</code> (dev
identity stub — real auth arrives in Week 8)
</p>
<h1 className="text-2xl font-bold">Job Tracker</h1>
<p className="text-neutral-600">Every application you've sent, and what happened next.</p>
</header>

<section className="rounded-lg border border-neutral-200 bg-neutral-50 p-6">
<p className="mb-4 text-neutral-600">
Welcome to the starter skeleton. The database is connected and ready.
Example branches add their own pages, API routes, and background workers here.
</p>
<a
href="/api/health"
className="text-blue-600 underline underline-offset-2 hover:text-blue-800"
>
Check health &rarr;
</a>
</section>
<Link
href="/jobs"
className="inline-block rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700"
>
Your jobs &rarr;
</Link>
</main>
);
}
}
19 changes: 19 additions & 0 deletions apps/web/lib/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Maps anything thrown at the server boundary to the one error shape
// ({ error: { code, message } }, see docs/specs/web.md). Unknown errors are
// logged here and never reach the client.
import { Prisma } from "@project/db";
import { ZodError } from "@project/domain";
import { log } from "@project/log";

export type ErrorBody = { error: { code: string; message: string } };

export function mapError(err: unknown): { status: number; body: ErrorBody } {
if (err instanceof ZodError)
return { status: 400, body: { error: { code: "VALIDATION", message: err.issues[0]?.message ?? "Invalid input" } } };
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === "P2002") return { status: 409, body: { error: { code: "CONFLICT", message: "Already exists" } } };
if (err.code === "P2025") return { status: 404, body: { error: { code: "NOT_FOUND", message: "Not found" } } };
}
log.error({ err }, "unhandled server error");
return { status: 500, body: { error: { code: "INTERNAL", message: "Something went wrong" } } };
}
14 changes: 5 additions & 9 deletions docs/specs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,13 @@ store issue state. See [CONTRIBUTING](../../CONTRIBUTING.md) for the full arc.
| [deploy](deploy.md) | infrastructure | Container build + Azure topology, config via env only, kill-switches | 0008 |
| [observability](observability.md) | infrastructure | Structured logs, request IDs, what gets measured | — |

### Items domain (`items/`)
### Jobs domain (`jobs/`)

| Doc | Type | What it covers | Key ADRs |
|---|---|---|---|
| [list](items/list.md) | feature | List my items, scoped by user, excludes soft-deleted | 0009, 0003 |
| [create](items/create.md) | feature | Create an item with Zod validation + CREATED event | 0009 |
| [detail](items/detail.md) | feature | View item detail, ownership, lightbox | 0009 |
| [attachment](items/attachment.md) | feature | Upload/download image, blob storage, MIME validation | 0006, 0008 |
| [thumbnail](items/thumbnail.md) | feature | Worker thumbnail generation with sharp, queue pipeline | 0006, 0007, 0008 |
| [live-progress](items/live-progress.md) | feature | SSE live progress for thumbnail pipeline, pg_notify fan-out | 0007, 0005 |
| [data-model](jobs/data-model.md) | feature | `Job` + append-only `JobEvent`; scoping, soft delete, history in one transaction | 0009 |
| [seed](jobs/seed.md) | feature | Believable demo data for `demo-user` | 0003 |
| [list](jobs/list.md) | feature | `/jobs` lists my jobs; `/jobs/new` adds one with validation + `CREATED` event | 0009, 0003 |

Feature specs live under `docs/specs/<domain>/`, grouped by the domain they
describe. In this template repo the `items/` domain is a worked demonstration;
an application built from this template keeps its own domains on `main`.
describe.
4 changes: 3 additions & 1 deletion docs/specs/jobs/data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ history per job. Stated once, here, so every other spec can lean on it.
atomically; a crash between the two leaves neither.
- Invalid input never reaches the database: `CreateJob` validates at the
boundary (required non-empty company/title, enum status, URL shape,
`salaryMin ≤ salaryMax`, length caps).
`salaryMin ≤ salaryMax`, length caps). Only company and title are
required: `status` defaults to `APPLIED`, `dateApplied` to now, `notes` to
empty, and every other optional field to null.

## Examples

Expand Down
Loading
Loading