M0: foundation — monorepo, PostGIS schema, deck query, app shell

Greenfield scaffold for Linkder, a swipe-to-hire marketplace for local
professional services.

- pnpm/turbo monorepo: apps/web, packages/{shared,db}
- Postgres 16 + PostGIS via docker compose (ports 5442/6389 to avoid
  clashing with other local stacks)
- Drizzle schema, 23 tables, geography(Point,4326) with GiST indexes
- Domain core in packages/shared: integer-cent money, status transition
  graphs, deck ranking weights, cancellation policy — 46 unit tests
- Deck query: filtering in Postgres on the GiST index, ranking in JS so
  the weights stay tunable — 18 integration tests against a seeded DB
- Deterministic seed placing pros at known distances, including three
  that must NOT appear on a deck (out of radius, unverified, away)
- Next.js 15 app shell with a working swipe deck
- CI: typecheck, lint, test, build against live postgres+redis

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
serfowi
2026-08-20 13:32:35 -04:00
co-authored by Claude Opus 5
commit 19623bcccb
66 changed files with 12412 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
import { eq } from 'drizzle-orm';
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { ArrowLeft } from 'lucide-react';
import { db, getDeck, schema } from '@linkder/db';
import { DeckClient } from './deck-client';
export const dynamic = 'force-dynamic';
export default async function DeckPage({ params }: { params: Promise<{ jobId: string }> }) {
const { jobId } = await params;
const job = await db.query.jobs.findFirst({
where: eq(schema.jobs.id, jobId),
with: { category: true },
});
if (!job) notFound();
const cards = await getDeck(db, { jobId });
return (
<main className="mx-auto flex min-h-dvh max-w-2xl flex-col px-4 py-6">
<header className="mb-6">
<Link
href="/"
className="mb-4 inline-flex items-center gap-1.5 text-sm text-[var(--muted)] hover:text-[var(--fg)]"
>
<ArrowLeft className="h-4 w-4" aria-hidden />
Back
</Link>
<p className="text-sm text-[var(--muted)]">
{job.category.name} · {job.addressText}
</p>
<h1 className="text-xl font-semibold">{job.title}</h1>
</header>
<DeckClient jobId={jobId} cards={cards} />
<p className="mt-8 text-center text-xs text-[var(--muted)]">
Swipe right to send this job to a pro, left to pass. Drag the card or use the buttons.
</p>
</main>
);
}