Skip to content
Merged
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
73 changes: 73 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# CLAUDE.md

Conventions and guidance for working in this repo. Keep this file short — it loads into every Claude session.

## Stack

- Next.js 16 (App Router), React 19, TypeScript 5.8
- PostgreSQL via raw `pg` (no ORM). Migrations are hand-written SQL in `/migrations/`.
- Playwright for e2e tests; ESLint 9 for linting; no Prettier.

## Naming convention: `snake_case` end-to-end

The database is `snake_case`. To eliminate boundary-translation bugs (the kind where a column rename silently breaks a route), we mirror that everywhere data crosses a wire:

- **DB columns**: `snake_case` (Postgres native)
- **TS model interfaces** (e.g. `ContactData`, `User`): properties match column names — `grid_locator`, not `gridLocator`
- **API JSON response fields**: `snake_case` — `{ grid_locator: ... }`
- **API request bodies**: `snake_case` keys

**Exempt from this rule:**

- React component-local form state (`formData.gridLocator`) — purely internal, never crosses the wire. The translation happens at the `fetch()` call site.
- Internal helper function parameters (e.g. `buildLoTWDownloadUrl({ dateFrom, dateTo })`) — not API surface, just JS function args.

**Known still-drifting surfaces** (cleanup candidates, not blockers):

- `GET /api/contacts/search` query params (`gridLocator`, `startDate`, `endDate`, `qslStatus`) still use camelCase. Defer to a follow-up sweep.

## Error response shape

API routes return:

```ts
{ error: string }
```

with an appropriate HTTP status (400 client error, 401 unauthorized, 403 forbidden, 404 not found, 500 server error). Some routes return richer shapes (`{ success: boolean, error?: string, ... }`) — that's fine where it's already established, but new routes should default to the simple shape.

## Logging

- `console.log` in `src/` is being phased out — don't add new ones. (Lint rule coming in a follow-up PR.)
- `console.error` is acceptable in genuine error paths until a real logger is introduced.

## Type discipline

- **No `any`**. The codebase is currently clean of explicit `: any` — keep it that way. If you genuinely don't know a type, use `unknown` and narrow at the use site.
- Run `npm run typecheck` (alias for `tsc --noEmit`) before committing. It must pass.

## Code layout

- `src/app/` — Next.js App Router. Pages live here; API routes under `src/app/api/<route>/route.ts`.
- `src/models/` — DB access. API routes should call models, not `query()` directly, when a model method exists.
- `src/lib/` — utilities, integrations (QRZ, LoTW), auth, db pool, crypto.
- `src/components/` — React components. Radix-based UI primitives in `src/components/ui/`.
- `src/contexts/` — React Context (UserContext, ThemeContext).
- `src/types/` — shared TS types not tied to a single model.
- `/migrations/` — hand-written SQL migrations. Root-level `*.sql` files are install/seed schema.
- `/tests/` — Playwright specs.

## Scripts (run from repo root)

- `npm run dev` — Next dev server (Turbopack)
- `npm run build` — production build
- `npm run lint` — ESLint
- `npm run typecheck` — `tsc --noEmit`
- `npm test` — Playwright e2e

## Pre-commit checklist

1. `npm run lint` clean (warnings allowed for now, errors no)
2. `npm run typecheck` clean
3. `npm run build` succeeds
4. If you touched API request/response shapes, update both ends in the same PR.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"build": "next build",
"start": "next start",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "playwright test",
"test:ui": "playwright test --ui",
"test:debug": "playwright test --debug",
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/auth/login/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export async function POST(request: NextRequest) {
email: user.email,
name: user.name,
callsign: user.callsign,
gridLocator: user.grid_locator
grid_locator: user.grid_locator
}
},
{ status: 200 }
Expand Down
6 changes: 3 additions & 3 deletions src/app/api/auth/register/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { User } from "@/models/User";

export async function POST(request: NextRequest) {
try {
const { email, password, name, callsign, gridLocator } =
const { email, password, name, callsign, grid_locator } =
await request.json();

if (!email || !password || !name) {
Expand All @@ -30,7 +30,7 @@ export async function POST(request: NextRequest) {
password: hashedPassword,
name,
callsign,
grid_locator: gridLocator,
grid_locator,
});

const token = jwt.sign(
Expand All @@ -47,7 +47,7 @@ export async function POST(request: NextRequest) {
email: newUser.email,
name: newUser.name,
callsign: newUser.callsign,
gridLocator: newUser.grid_locator,
grid_locator: newUser.grid_locator,
},
},
{ status: 201 }
Expand Down
4 changes: 2 additions & 2 deletions src/app/api/contacts/callsigns/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ export async function GET(request: NextRequest) {
value: row.callsign,
label: row.callsign,
secondary: row.name ? `${row.name}${row.qth ? ` - ${row.qth}` : ''}` : row.qth,
contactCount: parseInt(row.contact_count),
lastContact: row.last_contact
contact_count: parseInt(row.contact_count),
last_contact: row.last_contact
}));

return NextResponse.json({ callsigns });
Expand Down
6 changes: 3 additions & 3 deletions src/app/api/install/create-admin/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import pool from '@/lib/db';

export async function POST(request: Request) {
try {
const { name, email, password, callsign, gridLocator } = await request.json();
const { name, email, password, callsign, grid_locator } = await request.json();

// Validate required fields
if (!name || !email || !password || !callsign) {
Expand All @@ -28,7 +28,7 @@ export async function POST(request: Request) {
name, email, password, callsign, grid_locator, role, status, created_at
) VALUES ($1, $2, $3, $4, $5, 'admin', 'active', NOW())
RETURNING id, name, email, callsign, role
`, [name, email, hashedPassword, callsign.toUpperCase(), gridLocator?.toUpperCase() || null]);
`, [name, email, hashedPassword, callsign.toUpperCase(), grid_locator?.toUpperCase() || null]);

const user = userResult.rows[0];

Expand Down Expand Up @@ -57,7 +57,7 @@ export async function POST(request: Request) {
callsign.toUpperCase(),
`${callsign.toUpperCase()} Station`,
name,
gridLocator?.toUpperCase() || null
grid_locator?.toUpperCase() || null
]);
} else {
// Fallback schema with minimal columns
Expand Down
8 changes: 4 additions & 4 deletions src/app/api/stats/advanced/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,17 +166,17 @@ async function getGeographicAnalytics(whereClause: string, params: unknown[]) {
const gridActivity = await query(gridActivityQuery, params);

return {
countryDistribution: countryDistribution.rows.map(row => ({
country_distribution: countryDistribution.rows.map(row => ({
country: row.country || 'Unknown',
continent: row.continent || 'Unknown',
qsos: parseInt(row.qsos)
})),
continentDistribution: continentDistribution.rows.map(row => ({
continent_distribution: continentDistribution.rows.map(row => ({
continent: row.continent,
qsos: parseInt(row.qsos)
})),
gridActivity: gridActivity.rows.map(row => ({
gridSquare: row.grid_square,
grid_activity: gridActivity.rows.map(row => ({
grid_square: row.grid_square,
qsos: parseInt(row.qsos)
}))
};
Expand Down
2 changes: 1 addition & 1 deletion src/app/install/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ export default function InstallPage() {
email: formData.email,
password: formData.password,
callsign: formData.callsign,
gridLocator: formData.gridLocator
grid_locator: formData.gridLocator
}),
});

Expand Down
2 changes: 1 addition & 1 deletion src/app/register/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export default function RegisterPage() {
password: formData.password,
name: formData.name,
callsign: formData.callsign,
gridLocator: formData.gridLocator,
grid_locator: formData.gridLocator,
}),
});

Expand Down
22 changes: 11 additions & 11 deletions src/app/stats/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,9 @@ interface AdvancedStatsData {
}

interface GeographicStatsData {
countryDistribution: Array<{ country: string; continent: string; qsos: number }>;
continentDistribution: Array<{ continent: string; qsos: number }>;
gridActivity: Array<{ gridSquare: string; qsos: number }>;
country_distribution: Array<{ country: string; continent: string; qsos: number }>;
continent_distribution: Array<{ continent: string; qsos: number }>;
grid_activity: Array<{ grid_square: string; qsos: number }>;
}

interface HeatmapStatsData {
Expand Down Expand Up @@ -430,13 +430,13 @@ export default function StatsPage() {
</CardHeader>
<CardContent>
<div className="space-y-2">
{geographicStats.continentDistribution.map(item => (
{geographicStats.continent_distribution.map(item => (
<div key={item.continent} className="flex justify-between items-center">
<span className="font-medium">{item.continent}</span>
<span className="text-muted-foreground">{item.qsos.toLocaleString()}</span>
</div>
))}
{geographicStats.continentDistribution.length === 0 && (
{geographicStats.continent_distribution.length === 0 && (
<p className="text-muted-foreground text-center py-4">No data available</p>
)}
</div>
Expand All @@ -450,13 +450,13 @@ export default function StatsPage() {
</CardHeader>
<CardContent>
<div className="space-y-2">
{geographicStats.countryDistribution.slice(0, 10).map(item => (
{geographicStats.country_distribution.slice(0, 10).map(item => (
<div key={item.country} className="flex justify-between items-center">
<span className="font-medium text-sm">{item.country}</span>
<span className="text-muted-foreground text-sm">{item.qsos.toLocaleString()}</span>
</div>
))}
{geographicStats.countryDistribution.length === 0 && (
{geographicStats.country_distribution.length === 0 && (
<p className="text-muted-foreground text-center py-4">No data available</p>
)}
</div>
Expand All @@ -470,13 +470,13 @@ export default function StatsPage() {
</CardHeader>
<CardContent>
<div className="space-y-2">
{geographicStats.gridActivity.slice(0, 10).map(item => (
<div key={item.gridSquare} className="flex justify-between items-center">
<span className="font-medium font-mono">{item.gridSquare}</span>
{geographicStats.grid_activity.slice(0, 10).map(item => (
<div key={item.grid_square} className="flex justify-between items-center">
<span className="font-medium font-mono">{item.grid_square}</span>
<span className="text-muted-foreground">{item.qsos.toLocaleString()}</span>
</div>
))}
{geographicStats.gridActivity.length === 0 && (
{geographicStats.grid_activity.length === 0 && (
<p className="text-muted-foreground text-center py-4">No data available</p>
)}
</div>
Expand Down
6 changes: 3 additions & 3 deletions src/components/SearchInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ interface CallsignSuggestion {
value: string;
label: string;
secondary?: string;
contactCount: number;
lastContact: string;
contact_count: number;
last_contact: string;
}

interface SearchInputProps {
Expand Down Expand Up @@ -140,7 +140,7 @@ export default function SearchInput({ className }: SearchInputProps) {
<div className="flex items-center justify-between">
<span className="font-medium">{suggestion.label}</span>
<span className="text-xs text-muted-foreground">
{suggestion.contactCount} contact{suggestion.contactCount !== 1 ? 's' : ''}
{suggestion.contact_count} contact{suggestion.contact_count !== 1 ? 's' : ''}
</span>
</div>
{suggestion.secondary && (
Expand Down
2 changes: 1 addition & 1 deletion tests/database-integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ test.describe('Database Integration Tests', () => {
// Should handle database errors gracefully (not return 500)
// May return 401, 403, 404, or other non-500 errors
if (status) {
if (status >= 500) {
if (status >= 500 && response) {
console.log(`Endpoint ${endpoint} returned ${status} status`);
const body = await response.text();
console.log(`Response body: ${body.substring(0, 200)}...`);
Expand Down
Loading