From 74c94b05f9b5a17fc9a55dd74deb625c7d237335 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20W=C3=B6lfer?= Date: Thu, 16 Apr 2026 22:00:50 +0200 Subject: [PATCH] chore: initialized repository --- app/components/castration-tracker.tsx | 133 ++++++++++++++++++++++++++ app/routes/home.tsx | 49 +++------- database/schema.ts | 16 +++- drizzle/0001_heavy_havok.sql | 6 ++ drizzle/meta/0001_snapshot.json | 122 +++++++++++++++++++++++ drizzle/meta/_journal.json | 7 ++ package-lock.json | 2 +- package.json | 4 +- server/app.ts | 18 ++++ tsconfig.json | 10 +- tsconfig.node.json | 15 ++- tsconfig.vite.json | 25 +++-- 12 files changed, 354 insertions(+), 53 deletions(-) create mode 100644 app/components/castration-tracker.tsx create mode 100644 drizzle/0001_heavy_havok.sql create mode 100644 drizzle/meta/0001_snapshot.json diff --git a/app/components/castration-tracker.tsx b/app/components/castration-tracker.tsx new file mode 100644 index 0000000..0811a25 --- /dev/null +++ b/app/components/castration-tracker.tsx @@ -0,0 +1,133 @@ +import { useState, useEffect } from "react"; +import type { castrations } from "~/database/schema"; + +interface CastrationTrackerProps { + castrations: (typeof castrations.$inferSelect)[]; + totalCount: number; +} + +export function CastrationTracker({ + castrations: initialCastrations, + totalCount: initialCount, +}: CastrationTrackerProps) { + const [castrations, setCastrations] = useState(initialCastrations); + const [totalCount, setTotalCount] = useState(initialCount); + const [loading, setLoading] = useState(false); + + const handleCastration = async (gender: "male" | "female") => { + setLoading(true); + try { + const response = await fetch("/api/castration", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ gender }), + }); + + if (response.ok) { + const result = await response.json(); + + // Add new castration to the list + const newCastration = { + id: castrations.length + 1, + gender, + timestamp: new Date(), + }; + + setCastrations([newCastration, ...castrations]); + setTotalCount(totalCount + 1); + } + } catch (error) { + console.error("Failed to record castration:", error); + } finally { + setLoading(false); + } + }; + + const formatTime = (date: Date | string) => { + const d = typeof date === "string" ? new Date(date) : date; + return d.toLocaleString(); + }; + + return ( +
+
+ {/* Header */} +
+

+ Castration Tracker +

+
+

{totalCount}

+

Total Castrations

+
+
+ + {/* Buttons */} +
+ + + +
+ + {/* Castration List */} +
+
+

Recent Castrations

+
+ + {castrations.length > 0 ? ( +
+ {castrations.map((castration) => ( +
+
+
+ {castration.gender === "male" ? "♂" : "♀"} +
+
+

+ {castration.gender} +

+

+ {formatTime(castration.timestamp)} +

+
+
+
#{castration.id}
+
+ ))} +
+ ) : ( +
+

No castrations recorded yet. Start by clicking a button above!

+
+ )} +
+
+
+ ); +} diff --git a/app/routes/home.tsx b/app/routes/home.tsx index 0ab1e93..e68ff18 100644 --- a/app/routes/home.tsx +++ b/app/routes/home.tsx @@ -2,59 +2,36 @@ import { database } from "~/database/context"; import * as schema from "~/database/schema"; import type { Route } from "./+types/home"; -import { Welcome } from "../welcome/welcome"; +import { CastrationTracker } from "../components/castration-tracker"; export function meta({}: Route.MetaArgs) { return [ - { title: "New React Router App" }, - { name: "description", content: "Welcome to React Router!" }, + { title: "Castration Tracker" }, + { name: "description", content: "Track animal castrations" }, ]; } -export async function action({ request }: Route.ActionArgs) { - const formData = await request.formData(); - let name = formData.get("name"); - let email = formData.get("email"); - if (typeof name !== "string" || typeof email !== "string") { - return { guestBookError: "Name and email are required" }; - } - - name = name.trim(); - email = email.trim(); - if (!name || !email) { - return { guestBookError: "Name and email are required" }; - } - - const db = database(); - try { - await db.insert(schema.guestBook).values({ name, email }); - } catch (error) { - return { guestBookError: "Error adding to guest book" }; - } -} - export async function loader({ context }: Route.LoaderArgs) { const db = database(); - const guestBook = await db.query.guestBook.findMany({ - columns: { - id: true, - name: true, - }, + const castrations = await db.query.castrations.findMany({ + orderBy: (castrations, { desc }) => [desc(castrations.timestamp)], }); + const totalCount = castrations.length; + return { - guestBook, + castrations, + totalCount, message: context.VALUE_FROM_EXPRESS, }; } -export default function Home({ actionData, loaderData }: Route.ComponentProps) { +export default function Home({ loaderData }: Route.ComponentProps) { return ( - ); } diff --git a/database/schema.ts b/database/schema.ts index 809fdd7..33791d8 100644 --- a/database/schema.ts +++ b/database/schema.ts @@ -1,7 +1,21 @@ -import { integer, pgTable, varchar } from "drizzle-orm/pg-core"; +import { + integer, + pgTable, + timestamp, + pgEnum, + varchar, +} from "drizzle-orm/pg-core"; + +export const genderEnum = pgEnum("gender", ["male", "female"]); export const guestBook = pgTable("guestBook", { id: integer().primaryKey().generatedAlwaysAsIdentity(), name: varchar({ length: 255 }).notNull(), email: varchar({ length: 255 }).notNull().unique(), }); + +export const castrations = pgTable("castrations", { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + gender: genderEnum("gender").notNull(), + timestamp: timestamp().notNull().defaultNow(), +}); diff --git a/drizzle/0001_heavy_havok.sql b/drizzle/0001_heavy_havok.sql new file mode 100644 index 0000000..1b3352b --- /dev/null +++ b/drizzle/0001_heavy_havok.sql @@ -0,0 +1,6 @@ +CREATE TYPE "public"."gender" AS ENUM('male', 'female');--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "castrations" ( + "id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "castrations_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), + "gender" "gender" NOT NULL, + "timestamp" timestamp DEFAULT now() NOT NULL +); diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..9321ec3 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,122 @@ +{ + "id": "a9835a0d-a647-43bb-b34a-f049e710ecf1", + "prevId": "6bf145c1-851c-4a50-a085-9306f05abb25", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.castrations": { + "name": "castrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "castrations_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "gender": { + "name": "gender", + "type": "gender", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.guestBook": { + "name": "guestBook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "guestBook_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "guestBook_email_unique": { + "name": "guestBook_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.gender": { + "name": "gender", + "schema": "public", + "values": [ + "male", + "female" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index e7b9f6b..f0c5927 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1732076135211, "tag": "0000_short_donald_blake", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1776369457264, + "tag": "0001_heavy_havok", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 8a21b63..ee1b44f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "@types/express": "^5.0.6", "@types/express-serve-static-core": "^5.1.1", "@types/morgan": "^1.9.9", - "@types/node": "^20", + "@types/node": "^20.19.39", "@types/pg": "^8.18.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/package.json b/package.json index bc5d2fd..b5b372e 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "@types/express": "^5.0.6", "@types/express-serve-static-core": "^5.1.1", "@types/morgan": "^1.9.9", - "@types/node": "^20", + "@types/node": "^20.19.39", "@types/pg": "^8.18.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -41,4 +41,4 @@ "typescript": "^5.9.3", "vite": "^8.0.3" } -} \ No newline at end of file +} diff --git a/server/app.ts b/server/app.ts index 2543284..d983322 100644 --- a/server/app.ts +++ b/server/app.ts @@ -21,6 +21,24 @@ const client = postgres(process.env.DATABASE_URL); const db = drizzle(client, { schema }); app.use((_, __, next) => DatabaseContext.run(db, next)); +// API endpoint to record castration +app.post("/api/castration", express.json(), async (req, res) => { + try { + const { gender } = req.body; + + if (!gender || !["male", "female"].includes(gender)) { + return res.status(400).json({ error: "Invalid gender" }); + } + + const result = await db.insert(schema.castrations).values({ gender }); + + return res.json({ success: true, result }); + } catch (error) { + console.error("Error recording castration:", error); + return res.status(500).json({ error: "Failed to record castration" }); + } +}); + app.use( createRequestHandler({ build: () => import("virtual:react-router/server-build"), diff --git a/tsconfig.json b/tsconfig.json index cea27d4..6b17471 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,8 +1,12 @@ { "files": [], "references": [ - { "path": "./tsconfig.node.json" }, - { "path": "./tsconfig.vite.json" } + { + "path": "./tsconfig.node.json" + }, + { + "path": "./tsconfig.vite.json" + } ], "compilerOptions": { "checkJs": true, @@ -11,4 +15,4 @@ "strict": true, "noEmit": true } -} +} \ No newline at end of file diff --git a/tsconfig.node.json b/tsconfig.node.json index 12107b4..dc741ed 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -1,13 +1,20 @@ { "extends": "./tsconfig.json", - "include": ["server.js", "vite.config.ts"], + "include": [ + "server.js", + "vite.config.ts" + ], "compilerOptions": { "composite": true, "strict": true, - "types": ["node"], - "lib": ["ES2022"], + "types": [ + "node" + ], + "lib": [ + "ES2022" + ], "target": "ES2022", "module": "ES2022", "moduleResolution": "bundler" } -} +} \ No newline at end of file diff --git a/tsconfig.vite.json b/tsconfig.vite.json index 3d5ec11..fe40b1a 100644 --- a/tsconfig.vite.json +++ b/tsconfig.vite.json @@ -11,18 +11,31 @@ "compilerOptions": { "composite": true, "strict": true, - "lib": ["DOM", "DOM.Iterable", "ES2022"], - "types": ["vite/client"], + "lib": [ + "DOM", + "DOM.Iterable", + "ES2022" + ], + "types": [ + "vite/client" + ], "target": "ES2022", "module": "ES2022", "moduleResolution": "bundler", "jsx": "react-jsx", - "rootDirs": [".", "./.react-router/types"], + "rootDirs": [ + ".", + "./.react-router/types" + ], "paths": { - "~/database/*": ["./database/*"], - "~/*": ["./app/*"] + "~/database/*": [ + "./database/*" + ], + "~/*": [ + "./app/*" + ] }, "esModuleInterop": true, "resolveJsonModule": true } -} +} \ No newline at end of file