diff --git a/.gitignore b/.gitignore index dc22134..e80ee70 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ coverage/ .vscode/ .idea/ package-lock.json +apps/backend/data/ +*.sqlite diff --git a/apps/backend/package.json b/apps/backend/package.json index bd8dd77..a12f6dc 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -11,10 +11,12 @@ "test": "vitest run" }, "dependencies": { + "better-sqlite3": "^9.4.4", "express": "^4.18.2" }, "devDependencies": { "@eslint/js": "^8.56.0", + "@types/better-sqlite3": "^7.6.9", "@types/express": "^4.17.21", "@types/node": "^20.10.6", "eslint": "^8.56.0", diff --git a/apps/backend/src/index.test.ts b/apps/backend/src/index.test.ts index ef38eb4..b29ee3a 100644 --- a/apps/backend/src/index.test.ts +++ b/apps/backend/src/index.test.ts @@ -1,7 +1,46 @@ -import { describe, it, expect } from "vitest"; +import { describe, expect, it } from "vitest"; -describe("Backend", () => { - it("should pass placeholder test", () => { - expect(true).toBe(true); +import { calculateAverage, validateFeedback } from "./roti-service.js"; + +describe("validateFeedback", () => { + it("rejects ratings below 1", () => { + expect(() => validateFeedback({ rating: 0, comment: "ok" })).toThrow( + "Rating must be an integer between 1 and 5" + ); + }); + + it("rejects ratings above 5", () => { + expect(() => validateFeedback({ rating: 6, comment: "ok" })).toThrow( + "Rating must be an integer between 1 and 5" + ); + }); + + it("rejects non-integer ratings", () => { + expect(() => validateFeedback({ rating: 2.5, comment: "ok" })).toThrow( + "Rating must be an integer between 1 and 5" + ); + }); + + it("requires a comment for ratings at or below 3", () => { + expect(() => validateFeedback({ rating: 3, comment: " " })).toThrow( + "Comment is required when rating is 3 or below" + ); + }); + + it("allows empty comment for ratings above 3", () => { + expect(validateFeedback({ rating: 4, comment: " " })).toEqual({ + rating: 4, + comment: "", + }); + }); +}); + +describe("calculateAverage", () => { + it("returns zero for empty input", () => { + expect(calculateAverage([])).toBe(0); + }); + + it("calculates average to one decimal", () => { + expect(calculateAverage([5, 4, 4])).toBe(4.3); }); }); diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 678bf6d..8a498e6 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -1,7 +1,11 @@ -import express, { Request, Response } from "express"; import path from "path"; import { fileURLToPath } from "url"; +import express, { Request, Response } from "express"; + +import { insertFeedback, listFeedbacks } from "./roti-db.js"; +import { calculateAverage, validateFeedback } from "./roti-service.js"; + const __dirname = path.dirname(fileURLToPath(import.meta.url)); const app = express(); const PORT = process.env.PORT || 3001; @@ -17,6 +21,28 @@ app.get("/api/hello", (_req: Request, res: Response) => { res.json({ message: "Hello from backend!", env: process.env.NODE_ENV || "development" }); }); +app.get("/api/roti", (_req: Request, res: Response) => { + const feedbacks = listFeedbacks(); + const average = calculateAverage(feedbacks.map((feedback) => feedback.rating)); + + res.json({ + average, + count: feedbacks.length, + feedbacks, + }); +}); + +app.post("/api/roti", (req: Request, res: Response) => { + try { + const feedback = validateFeedback(req.body); + const created = insertFeedback(feedback); + res.status(201).json(created); + } catch (error) { + const message = error instanceof Error ? error.message : "Invalid request"; + res.status(400).json({ error: message }); + } +}); + // Serve frontend static files const frontendPath = path.join(__dirname, "../../frontend/dist"); app.use(express.static(frontendPath)); diff --git a/apps/backend/src/roti-db.ts b/apps/backend/src/roti-db.ts new file mode 100644 index 0000000..85df337 --- /dev/null +++ b/apps/backend/src/roti-db.ts @@ -0,0 +1,66 @@ +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +import Database from "better-sqlite3"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const defaultDbPath = path.join(__dirname, "../data/roti.sqlite"); +const dbPath = process.env.ROTI_DB_PATH || defaultDbPath; + +fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + +const db = new Database(dbPath); +db.pragma("journal_mode = WAL"); +db.exec(` + CREATE TABLE IF NOT EXISTS roti_feedback ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rating INTEGER NOT NULL, + comment TEXT NOT NULL, + created_at TEXT NOT NULL + ) +`); + +export interface RotiFeedbackRecord { + id: number; + rating: number; + comment: string; + createdAt: string; +} + +interface RotiFeedbackRow { + id: number; + rating: number; + comment: string; + createdAt: string; +} + +export function insertFeedback(input: { rating: number; comment: string }): RotiFeedbackRecord { + const createdAt = new Date().toISOString(); + const statement = db.prepare( + "INSERT INTO roti_feedback (rating, comment, created_at) VALUES (?, ?, ?)" + ); + const result = statement.run(input.rating, input.comment, createdAt); + + return { + id: Number(result.lastInsertRowid), + rating: input.rating, + comment: input.comment, + createdAt, + }; +} + +export function listFeedbacks(): RotiFeedbackRecord[] { + const rows = db + .prepare( + "SELECT id, rating, comment, created_at as createdAt FROM roti_feedback ORDER BY datetime(created_at) DESC" + ) + .all() as RotiFeedbackRow[]; + + return rows.map((row) => ({ + id: Number(row.id), + rating: Number(row.rating), + comment: String(row.comment), + createdAt: String(row.createdAt), + })); +} diff --git a/apps/backend/src/roti-service.ts b/apps/backend/src/roti-service.ts new file mode 100644 index 0000000..faf5aad --- /dev/null +++ b/apps/backend/src/roti-service.ts @@ -0,0 +1,34 @@ +export interface FeedbackInput { + rating: unknown; + comment: unknown; +} + +export interface ValidatedFeedback { + rating: number; + comment: string; +} + +export function validateFeedback(input: FeedbackInput): ValidatedFeedback { + if (typeof input.rating !== "number" || !Number.isInteger(input.rating)) { + throw new Error("Rating must be an integer between 1 and 5"); + } + + if (input.rating < 1 || input.rating > 5) { + throw new Error("Rating must be an integer between 1 and 5"); + } + + const comment = typeof input.comment === "string" ? input.comment.trim() : ""; + + if (input.rating <= 3 && comment.length === 0) { + throw new Error("Comment is required when rating is 3 or below"); + } + + return { rating: input.rating, comment }; +} + +export function calculateAverage(values: number[]): number { + if (values.length === 0) return 0; + const total = values.reduce((sum, value) => sum + value, 0); + const average = total / values.length; + return Math.round(average * 10) / 10; +} diff --git a/apps/frontend/index.html b/apps/frontend/index.html index 48e2a8e..2ab7e68 100644 --- a/apps/frontend/index.html +++ b/apps/frontend/index.html @@ -3,7 +3,13 @@ - Live Code + + + + ROTI Live
diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 7441660..25c5b76 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -18,9 +18,12 @@ "@types/react": "^18.2.43", "@types/react-dom": "^18.2.17", "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.19", "eslint": "^8.56.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.5", + "postcss": "^8.4.35", + "tailwindcss": "^3.4.1", "typescript": "^5.2.2", "typescript-eslint": "^7.0.0", "vite": "^5.0.8" diff --git a/apps/frontend/postcss.config.cjs b/apps/frontend/postcss.config.cjs new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/apps/frontend/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/frontend/src/App.css b/apps/frontend/src/App.css deleted file mode 100644 index 5700eae..0000000 --- a/apps/frontend/src/App.css +++ /dev/null @@ -1,49 +0,0 @@ -* { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -body { - font-family: system-ui, sans-serif; - background: #f5f5f5; - min-height: 100vh; -} - -.app { - max-width: 600px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -h1 { - color: #333; - margin-bottom: 0.5rem; -} - -p { - color: #666; -} - -.card { - background: white; - padding: 1.5rem; - border-radius: 8px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - margin-top: 1rem; - text-align: left; -} - -.card h2 { - font-size: 1rem; - color: #333; - border-bottom: 2px solid #007bff; - padding-bottom: 0.5rem; - margin-bottom: 0.5rem; -} - -.error { - color: #dc3545; - margin-top: 1rem; -} diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 3af0e05..ab56fb2 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -1,50 +1,207 @@ -import { useEffect, useState } from "react"; -import "./App.css"; - -interface ApiResponse { - status?: string; - message?: string; - timestamp?: string; - env?: string; +import { useEffect, useMemo, useState, type FormEvent } from "react"; + +interface RotiFeedback { + id: number; + rating: number; + comment: string; + createdAt: string; +} + +interface RotiResponse { + average: number; + count: number; + feedbacks: RotiFeedback[]; } +const ratings = [1, 2, 3, 4, 5]; + function App() { - const [health, setHealth] = useState(null); - const [hello, setHello] = useState(null); + const [rating, setRating] = useState(5); + const [comment, setComment] = useState(""); + const [data, setData] = useState({ average: 0, count: 0, feedbacks: [] }); const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + + const commentRequired = useMemo(() => rating <= 3, [rating]); + + const loadFeedbacks = async () => { + setIsLoading(true); + setError(null); + try { + const response = await fetch("/api/roti"); + if (!response.ok) throw new Error("Failed to load ROTI feedbacks"); + const payload = (await response.json()) as RotiResponse; + setData(payload); + } catch (loadError) { + const message = loadError instanceof Error ? loadError.message : "Unexpected error"; + setError(message); + } finally { + setIsLoading(false); + } + }; useEffect(() => { - Promise.all([fetch("/api/health"), fetch("/api/hello")]) - .then(async ([healthRes, helloRes]) => { - if (!healthRes.ok || !helloRes.ok) throw new Error("API error"); - setHealth(await healthRes.json()); - setHello(await helloRes.json()); - }) - .catch((err) => setError(err.message)); + loadFeedbacks(); }, []); + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + setError(null); + + if (commentRequired && comment.trim().length === 0) { + setError("Comment is required when rating is 3 or below"); + return; + } + + setIsSubmitting(true); + try { + const response = await fetch("/api/roti", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ rating, comment }), + }); + + if (!response.ok) { + const payload = (await response.json()) as { error?: string }; + throw new Error(payload.error || "Failed to submit feedback"); + } + + setComment(""); + setRating(5); + await loadFeedbacks(); + } catch (submitError) { + const message = submitError instanceof Error ? submitError.message : "Unexpected error"; + setError(message); + } finally { + setIsSubmitting(false); + } + }; + return ( -
-

Live Code

-

React + Express + Railway

+
+
+
+
+
+

ROTI Live

+ + Live voting + +
+

+ Rate the talk in seconds +

+

+ Drop a quick ROTI score and help us capture the room energy. Comments are + required when the rating is 3 or below. +

+
+ +
+
+

Your ROTI

+ 1 = low, 5 = amazing +
- {error &&

Error: {error}

} +
+ {ratings.map((value) => ( + + ))} +
- {health && ( -
-

Health

-

Status: {health.status}

-

Time: {health.timestamp}

+
+ +