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