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
@@ -0,0 +1,55 @@
'use client';
import { useCallback, useState } from 'react';
import type { DeckCard } from '@linkder/db';
import { Deck } from '@/components/deck';
import { recordSwipe } from './actions';
/**
* Bridges the server-rendered deck to the swipe action.
*
* Swipes are optimistic: the card leaves immediately and the write happens in
* the background. A failed right-swipe (usually the open-request cap) surfaces
* as a banner rather than snapping the card back — the client has moved on, and
* re-inserting a card they already dismissed is more confusing than a message.
*/
export function DeckClient({ jobId, cards }: { jobId: string; cards: DeckCard[] }) {
const [notice, setNotice] = useState<{ kind: 'sent' | 'error'; text: string } | null>(null);
const onDecide = useCallback(
async (proId: string, direction: 'left' | 'right') => {
const card = cards.find((c) => c.proId === proId);
const result = await recordSwipe({ jobId, proId, direction });
if (!result.ok) {
setNotice({ kind: 'error', text: result.error ?? 'Something went wrong' });
return;
}
if (result.requested) {
setNotice({
kind: 'sent',
text: `Job sent to ${card?.name ?? 'the pro'}. You'll hear back once they accept.`,
});
}
},
[cards, jobId],
);
return (
<div className="flex flex-col gap-4">
{notice && (
<div
role="status"
className={
notice.kind === 'sent'
? 'rounded-xl border border-[var(--color-go-500)]/30 bg-[var(--color-go-500)]/10 px-4 py-3 text-sm'
: 'rounded-xl border border-[var(--color-stop-500)]/30 bg-[var(--color-stop-500)]/10 px-4 py-3 text-sm'
}
>
{notice.text}
</div>
)}
<Deck cards={cards} onDecide={onDecide} />
</div>
);
}