| name | pinpoint-patterns |
| description | Project-specific code patterns for Server Actions, data fetching, error handling, file organization. Use when implementing features to follow established patterns, or when user asks about project conventions/patterns/architecture. |
PinPoint Patterns Guide
When to Use This Skill
Use this skill when:
- Implementing new features
- Wondering "how should I structure this?"
- Looking for established patterns to follow
- Contributing new patterns after implementing same approach 2+ times
- User mentions: "pattern", "convention", "architecture", "how to", "best practice"
Quick Reference
Pattern Discovery Process
- Check
docs/PATTERNS.md (index of all patterns)
- Look in
docs/patterns/ for specific pattern files
- If implementing same approach 2+ times, add new pattern
- Focus on PinPoint-specific patterns (not general Next.js knowledge)
Core Patterns
- Server Actions: File organization, auth, validation, revalidation
- Data Fetching: Drizzle queries, caching, error handling
- Error Handling: Validation errors, auth errors, DB errors
- File Organization: Where to put files, naming conventions
Detailed Documentation
Read these files for all established patterns:
cat docs/PATTERNS.md
ls docs/patterns/
cat docs/patterns/*.md
cat docs/patterns/data-fetching.md
Server Action Patterns
File Organization
src/server/actions/
├── issues.ts # Issue-related actions
├── machines.ts # Machine-related actions
├── users.ts # User-related actions
└── comments.ts # Comment-related actions
Server Action Template
"use server";
import { createClient } from "~/lib/supabase/server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { db } from "~/server/db";
import { issues } from "~/server/db/schema";
const createIssueSchema = z.object({
title: z.string().min(1, "Title required"),
description: z.string().optional(),
machineId: z.string().uuid(),
severity: z.enum(["minor", "playable", "unplayable"]),
});
export type CreateIssueResult =
| { success: true; issueId: string }
| { success: false; error: string };
export async function createIssue(
formData: FormData
): Promise<CreateIssueResult> {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
redirect("/login");
}
const rawData = {
title: formData.get("title"),
description: formData.get("description"),
machineId: formData.get("machineId"),
severity: formData.get("severity"),
};
let validData;
try {
validData = createIssueSchema.parse(rawData);
} catch (error) {
return { success: false, error: "Invalid input" };
}
try {
const [issue] = await db
.insert(issues)
.values({
...validData,
reportedBy: user.id,
})
.returning();
revalidatePath("/issues");
revalidatePath(`/machines/${validData.machineId}`);
return { success: true, issueId: issue.id };
} catch (error) {
return { success: false, error: "Failed to create issue" };
}
}
Client-side form correctness lives in pinpoint-ui § Form Correctness — input type, autocomplete tokens, enterkeyhint, :user-invalid styling, aria-invalid blur sync, required-field indicators. The Server Action structure above is the server half; pair it with the client half from pinpoint-ui for full coverage (CORE-FORM-001..006).
Data Fetching Patterns
Direct Drizzle Queries
import { db } from "~/server/db";
import { issues, machines } from "~/server/db/schema";
import { eq, desc } from "drizzle-orm";
import { cache } from "react";
export const getIssuesForMachine = cache(async (machineId: string) => {
if (!machineId) {
throw new Error("Machine ID required");
}
return await db.query.issues.findMany({
where: eq(issues.machineId, machineId),
orderBy: desc(issues.createdAt),
with: {
machine: true,
reportedByUser: {
columns: {
id: true,
email: true,
fullName: true,
},
},
},
});
});
export default async function MachineIssuesPage({
params,
}: {
params: { machineId: string };
}) {
const issues = await getIssuesForMachine(params.machineId);
return (
<div>
{issues.map((issue) => (
<IssueCard key={issue.id} issue={issue} />
))}
</div>
);
}
Caching Pattern
import { cache } from "react";
export const getMachines = cache(async () => {
return await db.query.machines.findMany({
orderBy: (machines, { asc }) => [asc(machines.name)],
});
});
Error Handling Patterns
Validation Errors
export type ActionResult<T> =
| { success: true; data: T }
| { success: false; error: string };
export async function createMachine(
formData: FormData
): Promise<ActionResult<Machine>> {
const schema = z.object({
name: z.string().min(1),
manufacturer: z.string().min(1),
});
const result = schema.safeParse({
name: formData.get("name"),
manufacturer: formData.get("manufacturer"),
});
if (!result.success) {
return {
success: false,
error: result.error.issues[0]?.message ?? "Validation failed",
};
}
const [machine] = await db.insert(machines).values(result.data).returning();
return { success: true, data: machine };
}
Database Errors
export async function deleteIssue(
issueId: string
): Promise<ActionResult<void>> {
try {
await db.delete(issues).where(eq(issues.id, issueId));
revalidatePath("/issues");
return { success: true, data: undefined };
} catch (error) {
console.error("Failed to delete issue:", error);
return { success: false, error: "Failed to delete issue" };
}
}
File Organization Patterns
Directory Structure
src/
├── app/ # Next.js App Router
│ ├── (app)/ # Authenticated app routes
│ │ ├── machines/
│ │ ├── issues/
│ │ └── layout.tsx
│ ├── (auth)/ # Authentication routes
│ │ ├── login/
│ │ └── signup/
│ └── api/ # API routes (minimal, prefer Server Actions)
├── components/
│ ├── ui/ # shadcn/ui components
│ ├── issues/ # Issue-specific components
│ ├── machines/ # Machine-specific components
│ └── layout/ # Layout components (nav, header, etc.)
├── lib/
│ ├── utils.ts # Utility functions
│ ├── types.ts # Shared types
│ ├── validation/ # Zod schemas
│ └── supabase/
│ ├── client.ts # Client-side Supabase
│ └── server.ts # Server-side Supabase
├── server/
│ ├── actions/ # Server Actions
│ ├── data-access/ # Data fetching functions
│ └── db/
│ ├── index.ts # Drizzle client
│ └── schema.ts # Drizzle schema
└── test/
├── unit/ # Unit tests
└── integration/
└── supabase/ # Integration tests
Naming Conventions
- Server Actions:
createIssue, updateMachine, deleteComment (verb + noun)
- Data Access:
getIssuesForMachine, getMachineById (get + descriptive)
- Components:
IssueCard, MachineList, CreateIssueDialog (PascalCase)
- Utilities:
formatDate, calculateSeverity (camelCase)
- Types:
Issue, Machine, User (PascalCase, singular)
Type Patterns
Database vs Application Types
import { issues } from "~/server/db/schema";
type DbIssue = typeof issues.$inferSelect;
export type Issue = {
id: string;
title: string;
description: string | null;
machineId: string;
severity: "minor" | "playable" | "unplayable";
createdAt: Date;
};
export function dbIssueToIssue(dbIssue: DbIssue): Issue {
return {
id: dbIssue.id,
title: dbIssue.title,
description: dbIssue.description,
machineId: dbIssue.machine_id,
severity: dbIssue.severity,
createdAt: new Date(dbIssue.created_at),
};
}
Revalidation Patterns
import { revalidatePath, revalidateTag } from "next/cache";
export async function createIssue(formData: FormData) {
revalidatePath("/issues");
revalidatePath(`/machines/${machineId}`);
revalidatePath(`/issues/${issueId}`);
}
export async function updateMachine(machineId: string, data: MachineData) {
revalidateTag(`machine-${machineId}`);
}
Testing Patterns
Unit Test Pattern
import { describe, it, expect } from "vitest";
import { calculateSeverityScore } from "~/lib/utils";
describe("calculateSeverityScore", () => {
it("returns 10 for unplayable", () => {
expect(calculateSeverityScore("unplayable")).toBe(10);
});
it("returns 5 for playable", () => {
expect(calculateSeverityScore("playable")).toBe(5);
});
});
Integration Test Pattern
import { describe, it, expect, beforeAll } from "vitest";
import { getPGlite } from "~/test/setup/pglite";
import { getIssuesForMachine } from "~/server/data-access/issues";
describe("getIssuesForMachine", () => {
beforeAll(async () => {
const db = getPGlite();
await db.exec("INSERT INTO machines ...");
await db.exec("INSERT INTO issues ...");
});
it("returns issues for machine", async () => {
const issues = await getIssuesForMachine("machine-id");
expect(issues).toHaveLength(2);
});
});
Contributing New Patterns
When you implement the same approach 2+ times:
- Identify the pattern: What problem does it solve?
- Document it: Add to
docs/patterns/ or update docs/PATTERNS.md
- Include example: Show concrete code, not just description
- Keep it concise: Focus on PinPoint-specific patterns
Example contribution:
## New Pattern: Multi-Step Forms
**When to use**: Forms with multiple steps (e.g., machine setup wizard)
**Pattern**:
1. Use React 19 `useActionState` for state management
2. Store step number in state
3. Conditionally render step components
4. Final step calls Server Action
**Example**: See `src/components/machines/CreateMachineWizard.tsx`
Pattern Checklist
Before implementing a feature:
Additional Resources
- Pattern index:
docs/PATTERNS.md
- Specific patterns:
docs/patterns/*.md
- Non-negotiables:
docs/NON_NEGOTIABLES.md
- Architecture:
docs/TECH_SPEC.md