commit fffcb73ea400e061c8b249bd42528251119580ef Author: Michal Pemcak Date: Wed Aug 12 11:57:55 2026 +0200 Initial commit: EatMe attendance tracker Google-SSO PWA for bistro employee clock-in/out, admin employee management, and stats with CSV export. Express + SQLite backend, React + Zustand frontend in the light-mono-tui design language. Multi-stage Dockerfile, compose.yaml for image-based deploys, nginx reverse-proxy template, and an OKF documentation bundle in docs/. Co-Authored-By: Claude Sonnet 5 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d7614df --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +**/node_modules +backend/dist +backend/data +backend/.env +frontend/dist +frontend/dev-dist +frontend/.env +.git +docs +branding +*.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..373c72b --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# Used by `docker compose` for both the frontend build arg and the +# container's runtime env — copy to .env next to compose.yaml on the host +# and fill in real values. Not read by the apps directly (see +# backend/.env.example and frontend/.env.example for local dev). + +VITE_GOOGLE_CLIENT_ID=766105276369-qa2iojmdc89ec3bqbmmg0ujmp9114scr.apps.googleusercontent.com +GOOGLE_CLIENT_ID=766105276369-qa2iojmdc89ec3bqbmmg0ujmp9114scr.apps.googleusercontent.com +ADMIN_EMAILS=mipemco@gmail.com,filip.thurrigl@gmail.com +JWT_SECRET=change-me-generate-with-openssl-rand-hex-32 +CORS_ORIGIN=https://eatme.mipem.co +COOKIE_SECURE=true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a87bcc6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,42 @@ +# syntax=docker/dockerfile:1 + +# ---- frontend build: React/Vite PWA -> static dist/ ---- +FROM node:22-slim AS frontend-build +WORKDIR /app/frontend +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci +COPY frontend/ . +# Vite inlines VITE_* env vars at build time, so the Google client id has to +# be supplied as a build arg: docker build --build-arg VITE_GOOGLE_CLIENT_ID=... +ARG VITE_GOOGLE_CLIENT_ID +ENV VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID +RUN npm run build + +# ---- backend build: TypeScript -> single esbuild bundle ---- +FROM node:22-slim AS backend-build +WORKDIR /app/backend +COPY backend/package.json backend/package-lock.json ./ +RUN npm ci +COPY backend/ . +RUN npm run build + +# ---- runtime: only prod deps + the two build outputs ---- +FROM node:22-slim AS runtime +ENV NODE_ENV=production +WORKDIR /app + +# better-sqlite3 has a native binding, so it's installed for real here rather +# than copied from another stage — same base image as backend-build, so the +# compiled binary always matches what it runs on. +COPY backend/package.json backend/package-lock.json ./ +RUN npm ci --omit=dev && npm cache clean --force + +COPY --from=backend-build /app/backend/dist ./dist +COPY --from=frontend-build /app/frontend/dist ./dist/public + +ENV PORT=4000 +ENV DB_PATH=/app/data/eatme.db +EXPOSE 4000 +VOLUME ["/app/data"] + +CMD ["node", "dist/index.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..5390b83 --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +# EatMe — Docházka + +Evidence docházky pro bistro EatMe. Zaměstnanci se přihlašují Google účtem a +logují příchod / odchod / start a konec pauzy; admin (majitel) spravuje seznam +zaměstnanců (jen e-mail, žádná hesla) a vidí souhrnné i individuální statistiky +s grafem a exportem do CSV. + +``` +backend/ Express + TypeScript API, SQLite (better-sqlite3) +frontend/ React + TypeScript PWA (Vite), Google Sign-In, Zustand +docs/ OKF (Open Knowledge Format) — architektura, datový model, provoz +branding/ Logo a vygenerované favicony/PWA ikony z podkladu bistra +deploy/ Šablony configů pro hosty mimo tento repo (nginx server block) +Dockerfile Multi-stage build — jeden image pro API i frontend +compose.yaml Produkční nasazení hotového image (viz Produkce níž) +``` + +Vzhled je podle `~/doc/concepts/ui/light-mono-tui.md` — světlý monochromní +"TUI" styl (mono font, ostré hrany, žádné barvy, inverze pro aktivní stav). + +## Požadavky + +- Node.js 22+ +- Google Cloud projekt s nakonfigurovaným OAuth Client ID (Web application) + — origins musí obsahovat `http://localhost:5174` pro vývoj a produkční + doménu. Client ID se nastavuje do `GOOGLE_CLIENT_ID` (backend) a + `VITE_GOOGLE_CLIENT_ID` (frontend) — musí být **stejné**. + +## Vývoj + +```bash +# backend +cd backend +cp .env.example .env # doplň GOOGLE_CLIENT_ID, ADMIN_EMAILS, JWT_SECRET +npm install +npm run dev # http://localhost:4000 + +# frontend (v druhém terminálu) +cd frontend +cp .env.example .env # VITE_GOOGLE_CLIENT_ID stejné jako backend +npm install +npm run dev # http://localhost:5174 (port je pinnutý — musí sedět s Google origin) +``` + +Frontend v dev módu proxuje `/api/*` na backend (viz `frontend/vite.config.ts`). + +### Typecheck / build + +```bash +cd backend && npm run typecheck && npm run build # -> dist/index.js (esbuild bundle) +cd frontend && npm run build # -> dist/ (Vite, PWA) +``` + +## Produkce (Docker) + +Jeden multi-stage `Dockerfile` v rootu postaví frontend i backend a spustí je +jako jeden kontejner — backend servíruje API na `/api/*` a staticky sbalený +frontend na všem ostatním. + +Image se buildí lokálně (pro platformu cílového serveru) a na server se +posílá hotový, ne zdrojáky: + +```bash +# buildni pro platformu serveru (na Apple Silicon Macu proti x86_64 serveru +# je --platform linux/amd64 povinné, jinak image tam vůbec nenaběhne) +docker build --platform linux/amd64 \ + --build-arg VITE_GOOGLE_CLIENT_ID= \ + -t eatme:latest . + +# přenes hotový image přes ssh (žádný build na serveru) +docker save eatme:latest | gzip | ssh "gunzip | docker load" + +# pošli jen compose.yaml + .env (viz .env.example) a nastartuj +scp compose.yaml :~/eatme/compose.yaml +ssh "cd ~/eatme && docker compose up -d" +``` + +`VITE_GOOGLE_CLIENT_ID` musí jít jako **build arg** (Vite ho zapéká při +buildu, ne za běhu — v `.env` na serveru se proto řeší jen runtime proměnné). +Pojmenovaný volume `eatme_data` (viz `compose.yaml`) drží SQLite databázi +mimo kontejner, ať přežije redeploy. + +Před kontejner patří TLS-terminující reverse proxy (mimo tento repo) — +šablona server blocku pro nginx je v `deploy/eatme.mipem.co`, proxuje na +`http://:8092`. + +## Datový model / provoz + +Zaměstnanci jsou whitelist e-mailů spravovaný adminem (`ADMIN_EMAILS` v env — +samostatná role, není v tabulce zaměstnanců). Odebrání zaměstnance je pouze +soft-delete (`active = 0`) — historie docházky zůstává, opětovným přidáním +stejného e-mailu se účet reaktivuje. Statistiky se počítají po kalendářních +měsících (`?month=YYYY-MM`), s možností listovat měsíci v adminově detailu +zaměstnance (graf + export CSV). + +Víc v `docs/` (OKF bundle). diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..cfe3bff --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,22 @@ +# Port the API listens on +PORT=4000 + +# OAuth client ID from Google Cloud Console (Web application). +# Must match the client ID used by the frontend's Google Sign-In button. +GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com + +# Comma-separated list of email addresses that get admin access. +# Admins do not need a row in the employees table. +ADMIN_EMAILS=kamarad@example.com + +# Secret used to sign session JWTs. Generate with: openssl rand -hex 32 +JWT_SECRET=change-me-to-a-long-random-string + +# Where the SQLite database file lives. +DB_PATH=./data/eatme.db + +# Origin of the frontend app, for CORS + cookie settings. +CORS_ORIGIN=http://localhost:5173 + +# Set to "true" behind HTTPS in production so the session cookie gets Secure. +COOKIE_SECURE=false diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..95c51f8 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +dist/ +data/ +.env +*.tsbuildinfo +*.db +*.db-journal +*.db-wal +*.db-shm diff --git a/backend/esbuild.config.js b/backend/esbuild.config.js new file mode 100644 index 0000000..9645dd4 --- /dev/null +++ b/backend/esbuild.config.js @@ -0,0 +1,20 @@ +const esbuild = require("esbuild"); + +esbuild + .build({ + entryPoints: ["src/index.ts"], + outfile: "dist/index.js", + bundle: true, + platform: "node", + target: "node22", + format: "cjs", + minify: true, + sourcemap: true, + // better-sqlite3 ships a native .node binding — it can't be bundled, + // so it stays a real dependency installed alongside the bundle. + external: ["better-sqlite3"], + }) + .then(() => { + console.log("backend bundled -> dist/index.js"); + }) + .catch(() => process.exit(1)); diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..df5f70f --- /dev/null +++ b/backend/package.json @@ -0,0 +1,34 @@ +{ + "name": "eatme-backend", + "version": "0.1.0", + "private": true, + "description": "EatMe bistro — attendance tracking API", + "type": "commonjs", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc --noEmit && node esbuild.config.js", + "start": "node dist/index.js", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "better-sqlite3": "^11.9.1", + "cookie-parser": "^1.4.7", + "cors": "^2.8.5", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "google-auth-library": "^9.15.1", + "jsonwebtoken": "^9.0.2", + "zod": "^3.24.2" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.12", + "@types/cookie-parser": "^1.4.8", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.7", + "@types/node": "^22.10.2", + "esbuild": "^0.28.2", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + } +} diff --git a/backend/src/auth/google.ts b/backend/src/auth/google.ts new file mode 100644 index 0000000..802dcdb --- /dev/null +++ b/backend/src/auth/google.ts @@ -0,0 +1,26 @@ +import { OAuth2Client } from "google-auth-library"; +import { env } from "../env"; + +const client = new OAuth2Client(env.googleClientId); + +export interface GoogleProfile { + email: string; + name: string | null; + emailVerified: boolean; +} + +export async function verifyGoogleIdToken(idToken: string): Promise { + const ticket = await client.verifyIdToken({ + idToken, + audience: env.googleClientId, + }); + const payload = ticket.getPayload(); + if (!payload?.email) { + throw new Error("Google token payload is missing an email"); + } + return { + email: payload.email.toLowerCase(), + name: payload.name ?? null, + emailVerified: payload.email_verified ?? false, + }; +} diff --git a/backend/src/auth/middleware.ts b/backend/src/auth/middleware.ts new file mode 100644 index 0000000..240737a --- /dev/null +++ b/backend/src/auth/middleware.ts @@ -0,0 +1,28 @@ +import type { NextFunction, Request, Response } from "express"; +import { readSession } from "./session"; + +export function requireAuth(req: Request, res: Response, next: NextFunction): void { + const user = readSession(req); + if (!user) { + res.status(401).json({ error: "Not authenticated" }); + return; + } + req.user = user; + next(); +} + +export function requireAdmin(req: Request, res: Response, next: NextFunction): void { + if (req.user?.role !== "admin") { + res.status(403).json({ error: "Admin access required" }); + return; + } + next(); +} + +export function requireEmployee(req: Request, res: Response, next: NextFunction): void { + if (req.user?.employeeId == null) { + res.status(403).json({ error: "No employee profile linked to this account" }); + return; + } + next(); +} diff --git a/backend/src/auth/session.ts b/backend/src/auth/session.ts new file mode 100644 index 0000000..c54e745 --- /dev/null +++ b/backend/src/auth/session.ts @@ -0,0 +1,32 @@ +import jwt from "jsonwebtoken"; +import type { Request, Response } from "express"; +import { env } from "../env"; +import type { SessionUser } from "../types"; + +export const SESSION_COOKIE = "eatme_session"; +const SESSION_TTL_SECONDS = 60 * 60 * 12; // 12h + +export function issueSession(res: Response, user: SessionUser): void { + const token = jwt.sign(user, env.jwtSecret, { expiresIn: SESSION_TTL_SECONDS }); + res.cookie(SESSION_COOKIE, token, { + httpOnly: true, + secure: env.cookieSecure, + sameSite: "lax", + maxAge: SESSION_TTL_SECONDS * 1000, + path: "/", + }); +} + +export function clearSession(res: Response): void { + res.clearCookie(SESSION_COOKIE, { path: "/" }); +} + +export function readSession(req: Request): SessionUser | null { + const token = req.cookies?.[SESSION_COOKIE]; + if (!token) return null; + try { + return jwt.verify(token, env.jwtSecret) as SessionUser; + } catch { + return null; + } +} diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts new file mode 100644 index 0000000..fb99d4d --- /dev/null +++ b/backend/src/db/index.ts @@ -0,0 +1,30 @@ +import Database from "better-sqlite3"; +import fs from "node:fs"; +import path from "node:path"; +import { env } from "../env"; + +fs.mkdirSync(path.dirname(env.dbPath), { recursive: true }); + +export const db = new Database(env.dbPath); +db.pragma("journal_mode = WAL"); +db.pragma("foreign_keys = ON"); + +db.exec(` + CREATE TABLE IF NOT EXISTS employees ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL UNIQUE, + name TEXT, + active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) + ); + + CREATE TABLE IF NOT EXISTS attendance_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + employee_id INTEGER NOT NULL REFERENCES employees(id), + type TEXT NOT NULL CHECK (type IN ('clock_in', 'clock_out', 'break_start', 'break_end')), + ts TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) + ); + + CREATE INDEX IF NOT EXISTS idx_attendance_employee_ts + ON attendance_events (employee_id, ts); +`); diff --git a/backend/src/env.ts b/backend/src/env.ts new file mode 100644 index 0000000..c0373f9 --- /dev/null +++ b/backend/src/env.ts @@ -0,0 +1,22 @@ +import "dotenv/config"; + +function required(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + return value; +} + +export const env = { + port: Number(process.env.PORT ?? 4000), + googleClientId: required("GOOGLE_CLIENT_ID"), + adminEmails: required("ADMIN_EMAILS") + .split(",") + .map((e) => e.trim().toLowerCase()) + .filter(Boolean), + jwtSecret: required("JWT_SECRET"), + dbPath: process.env.DB_PATH ?? "./data/eatme.db", + corsOrigin: process.env.CORS_ORIGIN ?? "http://localhost:5173", + cookieSecure: process.env.COOKIE_SECURE === "true", +}; diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..e8a8057 --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,43 @@ +import "./db"; // ensure schema is created before handling requests +import fs from "node:fs"; +import path from "node:path"; +import cookieParser from "cookie-parser"; +import cors from "cors"; +import express from "express"; +import { env } from "./env"; +import { authRouter } from "./routes/auth"; +import { attendanceRouter } from "./routes/attendance"; +import { adminRouter } from "./routes/admin"; + +const app = express(); + +app.use(cors({ origin: env.corsOrigin, credentials: true })); +app.use(express.json()); +app.use(cookieParser()); + +app.get("/api/health", (_req, res) => res.json({ ok: true })); + +app.use("/api/auth", authRouter); +app.use("/api/attendance", attendanceRouter); +app.use("/api/admin", adminRouter); + +// In the production Docker image the built frontend is copied next to this +// bundle as dist/public. Serve it (with an SPA fallback) when present; in dev +// the frontend runs on its own Vite server, so this is simply skipped. +const publicDir = path.join(__dirname, "public"); +if (fs.existsSync(publicDir)) { + app.use(express.static(publicDir)); + app.get(/^(?!\/api\/).*/, (_req, res) => { + res.sendFile(path.join(publicDir, "index.html")); + }); +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + console.error(err); + res.status(500).json({ error: "Internal server error" }); +}); + +app.listen(env.port, () => { + console.log(`EatMe API listening on http://localhost:${env.port}`); +}); diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts new file mode 100644 index 0000000..f520604 --- /dev/null +++ b/backend/src/routes/admin.ts @@ -0,0 +1,109 @@ +import { Router } from "express"; +import { z } from "zod"; +import { requireAdmin, requireAuth } from "../auth/middleware"; +import { + addEmployee, + deactivateEmployee, + findEmployeeById, + listEmployees, +} from "../services/employees"; +import { eventsInRange, summarize } from "../services/attendance"; +import { parseRange } from "../util/dateRange"; +import { sessionsToCsv } from "../util/csv"; + +export const adminRouter = Router(); +adminRouter.use(requireAuth, requireAdmin); + +adminRouter.get("/employees", (_req, res) => { + res.json({ employees: listEmployees() }); +}); + +const addEmployeeSchema = z.object({ + email: z.string().email(), + name: z.string().trim().min(1).optional(), +}); + +adminRouter.post("/employees", (req, res) => { + const parsed = addEmployeeSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: "Valid email is required" }); + return; + } + const employee = addEmployee(parsed.data.email, parsed.data.name ?? null); + res.status(201).json({ employee }); +}); + +adminRouter.delete("/employees/:id", (req, res) => { + const id = Number(req.params.id); + if (!Number.isInteger(id)) { + res.status(400).json({ error: "Invalid employee id" }); + return; + } + const removed = deactivateEmployee(id); + if (!removed) { + res.status(404).json({ error: "Employee not found" }); + return; + } + res.status(204).end(); +}); + +adminRouter.get("/stats", (req, res) => { + const { fromIso, toIso } = parseRange(req.query as Record); + const now = new Date(); + const employees = listEmployees(); + + const perEmployee = employees.map((employee) => { + const events = eventsInRange(employee.id, fromIso, toIso); + const stats = summarize(events, now); + return { + employee, + totalWorkedMs: stats.totalWorkedMs, + totalBreakMs: stats.totalBreakMs, + shiftCount: stats.shiftCount, + }; + }); + + const totals = perEmployee.reduce( + (acc, e) => ({ + totalWorkedMs: acc.totalWorkedMs + e.totalWorkedMs, + totalBreakMs: acc.totalBreakMs + e.totalBreakMs, + shiftCount: acc.shiftCount + e.shiftCount, + }), + { totalWorkedMs: 0, totalBreakMs: 0, shiftCount: 0 } + ); + + res.json({ range: { from: fromIso, to: toIso }, totals, employees: perEmployee }); +}); + +adminRouter.get("/stats/:id", (req, res) => { + const id = Number(req.params.id); + const employee = Number.isInteger(id) ? findEmployeeById(id) : undefined; + if (!employee) { + res.status(404).json({ error: "Employee not found" }); + return; + } + const { fromIso, toIso } = parseRange(req.query as Record); + const events = eventsInRange(employee.id, fromIso, toIso); + const stats = summarize(events, new Date()); + res.json({ employee, range: { from: fromIso, to: toIso }, ...stats }); +}); + +adminRouter.get("/stats/:id/export", (req, res) => { + const id = Number(req.params.id); + const employee = Number.isInteger(id) ? findEmployeeById(id) : undefined; + if (!employee) { + res.status(404).json({ error: "Employee not found" }); + return; + } + const { fromIso, toIso } = parseRange(req.query as Record); + const events = eventsInRange(employee.id, fromIso, toIso); + const { sessions } = summarize(events, new Date()); + const csv = sessionsToCsv(sessions); + + const period = fromIso.slice(0, 7); + const safeName = employee.email.replace(/[^a-z0-9.@-]/gi, "_"); + + res.setHeader("Content-Type", "text/csv; charset=utf-8"); + res.setHeader("Content-Disposition", `attachment; filename="${safeName}_${period}.csv"`); + res.send("" + csv); // BOM so Excel opens the Czech diacritics as UTF-8 +}); diff --git a/backend/src/routes/attendance.ts b/backend/src/routes/attendance.ts new file mode 100644 index 0000000..b2df71f --- /dev/null +++ b/backend/src/routes/attendance.ts @@ -0,0 +1,51 @@ +import { Router } from "express"; +import { z } from "zod"; +import { requireAuth, requireEmployee } from "../auth/middleware"; +import { + InvalidTransitionError, + eventsInRange, + getLiveStatus, + recordEvent, + summarize, +} from "../services/attendance"; +import { parseRange } from "../util/dateRange"; + +export const attendanceRouter = Router(); +attendanceRouter.use(requireAuth, requireEmployee); + +const eventSchema = z.object({ + type: z.enum(["clock_in", "clock_out", "break_start", "break_end"]), +}); + +attendanceRouter.post("/event", (req, res) => { + const parsed = eventSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: "Invalid event type" }); + return; + } + + const employeeId = req.user!.employeeId!; + try { + const event = recordEvent(employeeId, parsed.data.type); + res.status(201).json({ event, status: getLiveStatus(employeeId) }); + } catch (err) { + if (err instanceof InvalidTransitionError) { + res.status(409).json({ error: err.message, status: err.current }); + return; + } + throw err; + } +}); + +attendanceRouter.get("/state", (req, res) => { + const employeeId = req.user!.employeeId!; + res.json({ status: getLiveStatus(employeeId) }); +}); + +attendanceRouter.get("/me", (req, res) => { + const employeeId = req.user!.employeeId!; + const { fromIso, toIso } = parseRange(req.query as Record); + const events = eventsInRange(employeeId, fromIso, toIso); + const stats = summarize(events, new Date()); + res.json({ range: { from: fromIso, to: toIso }, ...stats }); +}); diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts new file mode 100644 index 0000000..c1e010e --- /dev/null +++ b/backend/src/routes/auth.ts @@ -0,0 +1,64 @@ +import { Router } from "express"; +import { z } from "zod"; +import { verifyGoogleIdToken } from "../auth/google"; +import { clearSession, issueSession } from "../auth/session"; +import { requireAuth } from "../auth/middleware"; +import { env } from "../env"; +import { findActiveEmployeeByEmail, touchEmployeeName } from "../services/employees"; +import type { SessionUser } from "../types"; + +export const authRouter = Router(); + +const loginSchema = z.object({ credential: z.string().min(1) }); + +authRouter.post("/google", async (req, res) => { + const parsed = loginSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: "Missing Google credential" }); + return; + } + + let profile; + try { + profile = await verifyGoogleIdToken(parsed.data.credential); + } catch { + res.status(401).json({ error: "Invalid Google token" }); + return; + } + + if (!profile.emailVerified) { + res.status(403).json({ error: "Google account email is not verified" }); + return; + } + + const isAdmin = env.adminEmails.includes(profile.email); + const employee = findActiveEmployeeByEmail(profile.email); + + if (!isAdmin && !employee) { + res.status(403).json({ error: "This account is not registered as an EatMe employee" }); + return; + } + + if (employee) { + touchEmployeeName(employee.id, profile.name); + } + + const user: SessionUser = { + email: profile.email, + name: profile.name, + role: isAdmin ? "admin" : "employee", + employeeId: employee?.id ?? null, + }; + + issueSession(res, user); + res.json({ user }); +}); + +authRouter.post("/logout", (_req, res) => { + clearSession(res); + res.status(204).end(); +}); + +authRouter.get("/me", requireAuth, (req, res) => { + res.json({ user: req.user }); +}); diff --git a/backend/src/services/attendance.ts b/backend/src/services/attendance.ts new file mode 100644 index 0000000..d379cf6 --- /dev/null +++ b/backend/src/services/attendance.ts @@ -0,0 +1,134 @@ +import { db } from "../db"; +import type { AttendanceEvent, EventType } from "../types"; + +export type LiveStatus = "clocked_out" | "working" | "on_break"; + +const NEXT_ALLOWED: Record = { + clocked_out: ["clock_in"], + working: ["break_start", "clock_out"], + on_break: ["break_end"], +}; + +function statusAfter(lastType: EventType | null): LiveStatus { + switch (lastType) { + case null: + case "clock_out": + return "clocked_out"; + case "clock_in": + case "break_end": + return "working"; + case "break_start": + return "on_break"; + } +} + +export function getLastEvent(employeeId: number): AttendanceEvent | undefined { + return db + .prepare<[number], AttendanceEvent>( + "SELECT * FROM attendance_events WHERE employee_id = ? ORDER BY ts DESC, id DESC LIMIT 1" + ) + .get(employeeId); +} + +export function getLiveStatus(employeeId: number): LiveStatus { + const last = getLastEvent(employeeId); + return statusAfter(last?.type ?? null); +} + +export class InvalidTransitionError extends Error { + constructor(public readonly current: LiveStatus, public readonly attempted: EventType) { + super(`Cannot record "${attempted}" while status is "${current}"`); + } +} + +export function recordEvent(employeeId: number, type: EventType): AttendanceEvent { + const current = getLiveStatus(employeeId); + if (!NEXT_ALLOWED[current].includes(type)) { + throw new InvalidTransitionError(current, type); + } + const result = db + .prepare("INSERT INTO attendance_events (employee_id, type) VALUES (?, ?)") + .run(employeeId, type); + return db + .prepare<[number], AttendanceEvent>("SELECT * FROM attendance_events WHERE id = ?") + .get(Number(result.lastInsertRowid))!; +} + +export function eventsInRange(employeeId: number, fromIso: string, toIso: string): AttendanceEvent[] { + return db + .prepare<[number, string, string], AttendanceEvent>( + `SELECT * FROM attendance_events + WHERE employee_id = ? AND ts >= ? AND ts < ? + ORDER BY ts ASC, id ASC` + ) + .all(employeeId, fromIso, toIso); +} + +export interface Session { + clockIn: string; + clockOut: string | null; + breaks: { start: string; end: string | null }[]; + workedMs: number; + breakMs: number; + open: boolean; +} + +/** + * Walks a chronological event list and pairs them into shifts. Any session still + * open at the end of the range (no clock_out yet) is included with open: true so + * "currently working" shows up in today's view, but callers doing historical + * totals should treat open sessions' worked time as provisional (measured up to `now`). + */ +export function buildSessions(events: AttendanceEvent[], now: Date): Session[] { + const sessions: Session[] = []; + let current: Session | null = null; + + for (const event of events) { + const ts = event.ts; + if (event.type === "clock_in") { + current = { clockIn: ts, clockOut: null, breaks: [], workedMs: 0, breakMs: 0, open: true }; + sessions.push(current); + } else if (!current) { + // Orphaned event (e.g. data edited manually) — ignore rather than crash. + continue; + } else if (event.type === "break_start") { + current.breaks.push({ start: ts, end: null }); + } else if (event.type === "break_end") { + const openBreak = current.breaks.find((b) => b.end === null); + if (openBreak) openBreak.end = ts; + } else if (event.type === "clock_out") { + current.clockOut = ts; + current.open = false; + current = null; + } + } + + for (const session of sessions) { + const end = session.clockOut ? new Date(session.clockOut) : now; + const start = new Date(session.clockIn); + session.breakMs = session.breaks.reduce((sum, b) => { + const bEnd = b.end ? new Date(b.end) : now; + return sum + Math.max(0, bEnd.getTime() - new Date(b.start).getTime()); + }, 0); + session.workedMs = Math.max(0, end.getTime() - start.getTime() - session.breakMs); + } + + return sessions; +} + +export interface StatsSummary { + sessions: Session[]; + totalWorkedMs: number; + totalBreakMs: number; + shiftCount: number; +} + +export function summarize(events: AttendanceEvent[], now: Date): StatsSummary { + const sessions = buildSessions(events, now); + return { + sessions, + totalWorkedMs: sessions.reduce((sum, s) => sum + s.workedMs, 0), + totalBreakMs: sessions.reduce((sum, s) => sum + s.breakMs, 0), + shiftCount: sessions.length, + }; +} diff --git a/backend/src/services/employees.ts b/backend/src/services/employees.ts new file mode 100644 index 0000000..8dacb46 --- /dev/null +++ b/backend/src/services/employees.ts @@ -0,0 +1,52 @@ +import { db } from "../db"; +import type { Employee } from "../types"; + +export function findActiveEmployeeByEmail(email: string): Employee | undefined { + return db + .prepare<[string], Employee>( + "SELECT * FROM employees WHERE email = ? AND active = 1" + ) + .get(email.toLowerCase()); +} + +export function findEmployeeById(id: number): Employee | undefined { + return db.prepare<[number], Employee>("SELECT * FROM employees WHERE id = ?").get(id); +} + +export function listEmployees(): Employee[] { + return db + .prepare<[], Employee>("SELECT * FROM employees ORDER BY active DESC, email ASC") + .all(); +} + +/** Fills in the display name the first time we see a Google profile for this employee. */ +export function touchEmployeeName(id: number, name: string | null): void { + if (!name) return; + db.prepare("UPDATE employees SET name = ? WHERE id = ? AND name IS NULL").run(name, id); +} + +export function addEmployee(email: string, name: string | null): Employee { + const normalized = email.trim().toLowerCase(); + const existing = db + .prepare<[string], Employee>("SELECT * FROM employees WHERE email = ?") + .get(normalized); + + if (existing) { + db.prepare("UPDATE employees SET active = 1, name = COALESCE(?, name) WHERE id = ?").run( + name, + existing.id + ); + return findEmployeeById(existing.id)!; + } + + const result = db + .prepare("INSERT INTO employees (email, name) VALUES (?, ?)") + .run(normalized, name); + return findEmployeeById(Number(result.lastInsertRowid))!; +} + +/** Soft delete: revokes login access but keeps attendance history for stats. */ +export function deactivateEmployee(id: number): boolean { + const result = db.prepare("UPDATE employees SET active = 0 WHERE id = ?").run(id); + return result.changes > 0; +} diff --git a/backend/src/types.ts b/backend/src/types.ts new file mode 100644 index 0000000..6f47daf --- /dev/null +++ b/backend/src/types.ts @@ -0,0 +1,33 @@ +export type EventType = "clock_in" | "clock_out" | "break_start" | "break_end"; + +export interface Employee { + id: number; + email: string; + name: string | null; + active: 0 | 1; + created_at: string; +} + +export interface AttendanceEvent { + id: number; + employee_id: number; + type: EventType; + ts: string; +} + +export type Role = "admin" | "employee"; + +export interface SessionUser { + email: string; + name: string | null; + role: Role; + employeeId: number | null; +} + +declare global { + namespace Express { + interface Request { + user?: SessionUser; + } + } +} diff --git a/backend/src/util/csv.ts b/backend/src/util/csv.ts new file mode 100644 index 0000000..7441034 --- /dev/null +++ b/backend/src/util/csv.ts @@ -0,0 +1,35 @@ +import type { Session } from "../services/attendance"; + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString("cs-CZ"); +} + +function formatTime(iso: string): string { + return new Date(iso).toLocaleTimeString("cs-CZ", { hour: "2-digit", minute: "2-digit" }); +} + +function formatDuration(ms: number): string { + const totalMinutes = Math.round(ms / 60000); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + return `${hours}:${String(minutes).padStart(2, "0")}`; +} + +function escapeCsvField(value: string): string { + return /[",\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value; +} + +export function sessionsToCsv(sessions: Session[]): string { + const header = ["Datum", "Příchod", "Odchod", "Pauza (h:mm)", "Odpracováno (h:mm)"]; + const rows = [...sessions] + .sort((a, b) => a.clockIn.localeCompare(b.clockIn)) + .map((s) => [ + formatDate(s.clockIn), + formatTime(s.clockIn), + s.clockOut ? formatTime(s.clockOut) : "probíhá", + formatDuration(s.breakMs), + formatDuration(s.workedMs), + ]); + + return [header, ...rows].map((row) => row.map(escapeCsvField).join(",")).join("\r\n"); +} diff --git a/backend/src/util/dateRange.ts b/backend/src/util/dateRange.ts new file mode 100644 index 0000000..6fbbf33 --- /dev/null +++ b/backend/src/util/dateRange.ts @@ -0,0 +1,33 @@ +export interface Range { + fromIso: string; + toIso: string; +} + +/** + * Parses attendance query params into an [from, to) ISO range. Priority: + * 1. explicit ?from=YYYY-MM-DD&to=YYYY-MM-DD (to is inclusive of that whole day) + * 2. ?month=YYYY-MM — that whole calendar month + * 3. default — the current calendar month + */ +export function parseRange(query: Record): Range { + const now = new Date(); + + const from = typeof query.from === "string" && query.from ? new Date(query.from) : null; + const toRaw = typeof query.to === "string" && query.to ? new Date(query.to) : null; + + if (from && toRaw) { + const to = new Date(toRaw); + to.setDate(to.getDate() + 1); // make "to" inclusive of that whole day + return { fromIso: from.toISOString(), toIso: to.toISOString() }; + } + + const month = typeof query.month === "string" && /^\d{4}-\d{2}$/.test(query.month) ? query.month : null; + const [year, monthIndex] = month + ? [Number(month.slice(0, 4)), Number(month.slice(5, 7)) - 1] + : [now.getFullYear(), now.getMonth()]; + + const monthStart = new Date(year, monthIndex, 1); + const monthEnd = new Date(year, monthIndex + 1, 1); + + return { fromIso: monthStart.toISOString(), toIso: monthEnd.toISOString() }; +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..dec0477 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "moduleResolution": "node", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": false, + "sourceMap": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/branding/apple-touch-icon.png b/branding/apple-touch-icon.png new file mode 100644 index 0000000..d564210 Binary files /dev/null and b/branding/apple-touch-icon.png differ diff --git a/branding/favicon-16x16.png b/branding/favicon-16x16.png new file mode 100644 index 0000000..b027368 Binary files /dev/null and b/branding/favicon-16x16.png differ diff --git a/branding/favicon-32x32.png b/branding/favicon-32x32.png new file mode 100644 index 0000000..30afdb4 Binary files /dev/null and b/branding/favicon-32x32.png differ diff --git a/branding/favicon.ico b/branding/favicon.ico new file mode 100644 index 0000000..f37d380 Binary files /dev/null and b/branding/favicon.ico differ diff --git a/branding/icon-192.png b/branding/icon-192.png new file mode 100644 index 0000000..d8d9a6b Binary files /dev/null and b/branding/icon-192.png differ diff --git a/branding/icon-512.png b/branding/icon-512.png new file mode 100644 index 0000000..f4e6578 Binary files /dev/null and b/branding/icon-512.png differ diff --git a/branding/icon-maskable-512.png b/branding/icon-maskable-512.png new file mode 100644 index 0000000..6bb37ca Binary files /dev/null and b/branding/icon-maskable-512.png differ diff --git a/branding/logo-master.png b/branding/logo-master.png new file mode 100644 index 0000000..4af5466 Binary files /dev/null and b/branding/logo-master.png differ diff --git a/branding/logo-source.jpg b/branding/logo-source.jpg new file mode 100644 index 0000000..4c23052 Binary files /dev/null and b/branding/logo-source.jpg differ diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..fa44a0b --- /dev/null +++ b/compose.yaml @@ -0,0 +1,18 @@ +services: + eatme: + image: eatme:latest + container_name: eatme + restart: unless-stopped + ports: + - "8092:4000" + environment: + - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} + - ADMIN_EMAILS=${ADMIN_EMAILS} + - JWT_SECRET=${JWT_SECRET} + - CORS_ORIGIN=${CORS_ORIGIN} + - COOKIE_SECURE=${COOKIE_SECURE} + volumes: + - eatme_data:/app/data + +volumes: + eatme_data: diff --git a/deploy/eatme.mipem.co b/deploy/eatme.mipem.co new file mode 100644 index 0000000..c07ce1b --- /dev/null +++ b/deploy/eatme.mipem.co @@ -0,0 +1,30 @@ +server { + listen 80; + server_name eatme.mipem.co; + return 301 https://$host$request_uri; +} + +server { + client_max_body_size 0; + listen 443 ssl; + server_name eatme.mipem.co; + ssl on; + ssl_certificate /etc/letsencrypt/live/eatme.mipem.co/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/eatme.mipem.co/privkey.pem; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + # HSTS (ngx_http_headers_module is required) (63072000 seconds) + add_header Strict-Transport-Security "max-age=63072000" always; + + location / { + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port 443; + proxy_set_header Host $http_host; + proxy_http_version 1.1; + proxy_pass http://:8092; + proxy_redirect off; + } +} diff --git a/docs/api/admin-routes.md b/docs/api/admin-routes.md new file mode 100644 index 0000000..ee6c8cb --- /dev/null +++ b/docs/api/admin-routes.md @@ -0,0 +1,71 @@ +--- +type: API Endpoint +title: Admin routes +description: Employee CRUD (add/soft-delete), org-wide and per-employee stats, and CSV export — all requireAdmin. +resource: backend/src/routes/admin.ts +tags: [api, admin] +timestamp: 2026-08-12T00:00:00Z +--- + +# Admin routes + +Mounted at `/api/admin`. Every route requires `requireAuth` + `requireAdmin` +(role resolved from `ADMIN_EMAILS`, see [Auth flow](/docs/architecture/auth-flow.md)). + +# Examples + +``` +GET /api/admin/employees +200 -> { "employees": Employee[] } // includes inactive (soft-deleted) rows +``` + +``` +POST /api/admin/employees +Body: { "email": string, "name"?: string } +201 -> { "employee": Employee } +``` +Upserts by email — see [employees](/docs/data-model/employees.md) for the +reactivate-on-re-add behavior. + +``` +DELETE /api/admin/employees/:id +204 on success, 404 if not found. +``` +Soft delete only (`active = 0`) — never removes the row or its attendance +history. + +``` +GET /api/admin/stats?month=YYYY-MM +200 -> { + "range": { "from": string, "to": string }, + "totals": { "totalWorkedMs": number, "totalBreakMs": number, "shiftCount": number }, + "employees": [{ "employee": Employee, "totalWorkedMs": number, "totalBreakMs": number, "shiftCount": number }] +} +``` +Same calendar-month default as the employee's own stats endpoint. + +``` +GET /api/admin/stats/:id?month=YYYY-MM +200 -> { "employee": Employee, "range": {...}, "sessions": Session[], "totalWorkedMs": number, "totalBreakMs": number, "shiftCount": number } +404 -> employee not found +``` + +``` +GET /api/admin/stats/:id/export?month=YYYY-MM +200, Content-Type: text/csv; charset=utf-8 +Content-Disposition: attachment; filename="_.csv" +``` +Columns: `Datum, Příchod, Odchod, Pauza (h:mm), Odpracováno (h:mm)`, one row +per shift, sorted chronologically (`backend/src/util/csv.ts`). Prefixed with +a UTF-8 BOM so Excel renders the Czech diacritics correctly. Downloaded from +the frontend as a plain `` — the session cookie rides +along automatically since it's a same-origin GET. + +# Related + +- [employees](/docs/data-model/employees.md) +- [attendance_events](/docs/data-model/attendance-events.md) +- [Attendance routes](./attendance-routes.md) +- [Frontend stores](/docs/frontend/stores.md) — `adminStore` owns the + selected month and drives both the summary table and the per-employee + chart off it diff --git a/docs/api/attendance-routes.md b/docs/api/attendance-routes.md new file mode 100644 index 0000000..2a64ef6 --- /dev/null +++ b/docs/api/attendance-routes.md @@ -0,0 +1,54 @@ +--- +type: API Endpoint +title: Attendance routes +description: POST /api/attendance/event, GET /api/attendance/state, GET /api/attendance/me — the employee's own clock in/out and stats. +resource: backend/src/routes/attendance.ts +tags: [api, attendance] +timestamp: 2026-08-12T00:00:00Z +--- + +# Attendance routes + +Mounted at `/api/attendance`. Every route requires `requireAuth` + +`requireEmployee` — admins with no employee row get `403` here, by design +(the owner isn't necessarily clocking in themselves). + +# Examples + +``` +POST /api/attendance/event +Body: { "type": "clock_in" | "clock_out" | "break_start" | "break_end" } +201 -> { "event": AttendanceEvent, "status": LiveStatus } +400 -> invalid type +409 -> invalid transition for the current status, e.g. break_start while + already clocked_out — { "error": string, "status": LiveStatus } +``` + +``` +GET /api/attendance/state +200 -> { "status": "clocked_out" | "working" | "on_break" } +``` + +``` +GET /api/attendance/me?month=YYYY-MM (or ?from=YYYY-MM-DD&to=YYYY-MM-DD) +Defaults to the current calendar month if no query params are given +— see parseRange in backend/src/util/dateRange.ts. +200 -> { + "range": { "from": string, "to": string }, + "sessions": Session[], + "totalWorkedMs": number, + "totalBreakMs": number, + "shiftCount": number +} +``` + +`Session` and the state machine behind these responses are documented at +[attendance_events](/docs/data-model/attendance-events.md). + +# Related + +- [attendance_events](/docs/data-model/attendance-events.md) +- [Admin routes](./admin-routes.md) — same stats shape, but for any employee +- [Frontend stores](/docs/frontend/stores.md) — `attendanceStore` polls + `GET /state` + `GET /me` every 60s and ticks a local clock every second + between polls diff --git a/docs/api/auth-routes.md b/docs/api/auth-routes.md new file mode 100644 index 0000000..86bfc64 --- /dev/null +++ b/docs/api/auth-routes.md @@ -0,0 +1,51 @@ +--- +type: API Endpoint +title: Auth routes +description: POST /api/auth/google, POST /api/auth/logout, GET /api/auth/me. +resource: backend/src/routes/auth.ts +tags: [api, auth] +timestamp: 2026-08-12T00:00:00Z +--- + +# Auth routes + +Mounted at `/api/auth` (`backend/src/index.ts`). + +# Examples + +``` +POST /api/auth/google +Body: { "credential": "" } +200 -> { "user": SessionUser } +400 -> missing credential +401 -> invalid Google token +403 -> email not verified, or not an admin and not an active employee +``` + +Sets the `eatme_session` cookie on success. See +[Auth flow](/docs/architecture/auth-flow.md) for what happens before this +(role resolution) and what's inside the cookie. + +``` +POST /api/auth/logout +204, clears the session cookie. No auth required. +``` + +``` +GET /api/auth/me +Requires a valid session cookie (requireAuth). +200 -> { "user": SessionUser } +401 -> not authenticated +``` + +`SessionUser` shape (`backend/src/types.ts`): + +```ts +{ email: string; name: string | null; role: "admin" | "employee"; employeeId: number | null } +``` + +# Related + +- [Auth flow](/docs/architecture/auth-flow.md) +- [Attendance routes](./attendance-routes.md) +- [Admin routes](./admin-routes.md) diff --git a/docs/api/index.md b/docs/api/index.md new file mode 100644 index 0000000..181ce72 --- /dev/null +++ b/docs/api/index.md @@ -0,0 +1,8 @@ +# API + +Express routers under `/api/*`, mounted in `backend/src/index.ts`. All +responses are JSON except the CSV export. Errors follow `{ "error": string }`. + +- [Auth routes](./auth-routes.md) - `/api/auth/*` - Google sign-in, logout, current session +- [Attendance routes](./attendance-routes.md) - `/api/attendance/*` - the employee's own clock in/out and stats +- [Admin routes](./admin-routes.md) - `/api/admin/*` - employee management, org/individual stats, CSV export diff --git a/docs/architecture/auth-flow.md b/docs/architecture/auth-flow.md new file mode 100644 index 0000000..21356a8 --- /dev/null +++ b/docs/architecture/auth-flow.md @@ -0,0 +1,58 @@ +--- +type: Architecture Overview +title: Authentication & authorization +description: Google ID-token verification, the session cookie, and the admin/employee role split. +tags: [architecture, auth, security] +timestamp: 2026-08-12T00:00:00Z +--- + +# Authentication & authorization + +## Sign-in + +1. The frontend loads the Google Identity Services script + (`https://accounts.google.com/gsi/client`, in `frontend/index.html`) and + renders the button in `frontend/src/auth/GoogleButton.tsx`. +2. Google returns a signed ID token to the browser (`credential`). The + frontend does **not** trust it — it POSTs it to `POST /api/auth/google`. +3. The backend verifies the token server-side with `google-auth-library` + (`backend/src/auth/google.ts`, `verifyGoogleIdToken`), checking the + audience against `GOOGLE_CLIENT_ID` and requiring `email_verified`. + +## Role resolution + +Two independent checks decide who gets in and as what role +(`backend/src/routes/auth.ts`): + +- **Admin**: the verified email is in the `ADMIN_EMAILS` env var + (comma-separated, lowercased — `backend/src/env.ts`). Admins do **not** + need a row in the `employees` table. +- **Employee**: the verified email matches an **active** row in + [employees](/docs/data-model/employees.md). + +If neither matches, login is rejected with 403 ("This account is not +registered as an EatMe employee"). This is how the employee whitelist is +enforced — there's no self-service signup. + +## Session + +On success the backend signs its own JWT (`backend/src/auth/session.ts`, +`issueSession`) containing `{ email, name, role, employeeId }`, and sets it +as an httpOnly, `SameSite=Lax` cookie (`eatme_session`, 12h TTL). All +subsequent `/api/*` requests carry that cookie +(`frontend/src/api/client.ts` sends `credentials: 'include'`); the backend +never re-derives role from the DB on every request — it trusts the signed +cookie until it expires. + +`backend/src/auth/middleware.ts` provides three guards used by the routers: + +- `requireAuth` — valid session cookie present +- `requireAdmin` — `role === 'admin'` +- `requireEmployee` — `employeeId != null` (blocks admins with no employee + profile from hitting `/api/attendance/*`) + +## Related + +- [System overview](./overview.md) +- [Auth API](/docs/api/auth-routes.md) +- [Employees table](/docs/data-model/employees.md) diff --git a/docs/architecture/deployment.md b/docs/architecture/deployment.md new file mode 100644 index 0000000..1b1e717 --- /dev/null +++ b/docs/architecture/deployment.md @@ -0,0 +1,109 @@ +--- +type: Architecture Overview +title: Build & deployment +description: esbuild backend bundle, Vite frontend build, the multi-stage Docker image, and how it ships to a host. +tags: [architecture, deployment, docker] +timestamp: 2026-08-12T12:00:00Z +--- + +# Build & deployment + +## Backend build + +`backend/package.json`'s `build` script runs `tsc --noEmit` (typecheck only) +then `node esbuild.config.js`, which bundles `src/index.ts` into a single +minified `dist/index.js` (~1.4MB, with sourcemap). `better-sqlite3` is +marked `external` in `esbuild.config.js` because it ships a native `.node` +binding that can't be bundled — it stays a real `node_modules` dependency at +runtime. + +## Frontend build + +Standard Vite build (`frontend/package.json` → `tsc -b && vite build`), +producing an optimized static `dist/` with a service worker +(`vite-plugin-pwa`, `frontend/vite.config.ts`). `VITE_*` env vars (just +`VITE_GOOGLE_CLIENT_ID`) are inlined at build time — they cannot be changed +at container runtime, only at image-build time. + +## Docker image + +The root `Dockerfile` is a three-stage build: + +1. `frontend-build` — `npm ci` + `npm run build` in `frontend/`. Takes + `VITE_GOOGLE_CLIENT_ID` as a build arg. +2. `backend-build` — `npm ci` + `npm run build` in `backend/`, producing the + esbuild bundle. +3. `runtime` — fresh `npm ci --omit=dev` for `backend/package.json` on the + **same base image** as `backend-build` (so `better-sqlite3`'s native + binding is compiled for the environment it'll actually run in, rather + than copied from a different stage). Then copies in the backend bundle + (`dist/index.js`) and the frontend build output as `dist/public/`. + +At runtime, `backend/src/index.ts` checks whether `dist/public` exists next +to itself and, if so, serves it with `express.static` plus a SPA fallback +route (`app.get(/^(?!\/api\/).*/, ...)`) — that's what makes the single +image serve both API and UI on one origin. In local dev this directory +doesn't exist, so the branch is skipped and the frontend's own Vite dev +server is used instead. + +```bash +docker build --build-arg VITE_GOOGLE_CLIENT_ID= -t eatme . +docker run -d -p 4000:4000 -v eatme-data:/app/data \ + -e GOOGLE_CLIENT_ID= -e ADMIN_EMAILS= \ + -e JWT_SECRET= -e CORS_ORIGIN= -e COOKIE_SECURE=true \ + eatme +``` + +The SQLite file lives at `DB_PATH` (default `/app/data/eatme.db` in the +image) — mount `/app/data` as a volume or it's lost on container recreate. + +## Shipping to a host + +The image is always built where its target platform matches the deploy +host, and shipped as a built artifact — never by copying the repo to the +server and building there (keeps source off prod hosts, no build toolchain +needed remotely, and the exact artifact tested locally is what runs). If +building on an Apple Silicon Mac for an x86_64 server, `--platform +linux/amd64` is required or the image won't run there at all. + +```bash +docker build --platform linux/amd64 \ + --build-arg VITE_GOOGLE_CLIENT_ID= -t eatme:latest . +docker save eatme:latest | gzip | ssh "gunzip | docker load" +``` + +Only `compose.yaml` (root of the repo) and a `.env` next to it (see +`.env.example`) then need to reach the host — `compose.yaml` references +`image: eatme:latest` (not `build:`), so `docker compose up -d` just starts +the already-loaded image. Redeploying a new version is the same +build → save → load → `docker compose up -d` sequence again. + +```bash +scp compose.yaml :~/eatme/compose.yaml +ssh "cd ~/eatme && docker compose up -d" +``` + +TLS termination is handled by an nginx reverse proxy in front of the +container (not part of this repo) — a template server block lives at +`deploy/` for whichever host runs nginx, proxying +`https://` to `http://:`. It's a plain +file to be moved into `/etc/nginx/sites-available/` (and symlinked into +`sites-enabled/`) by whoever has sudo on that host — nothing here writes to +system nginx config directly. + +## Environment variables + +| Var | App | Required | Notes | +|---|---|---|---| +| `GOOGLE_CLIENT_ID` | backend | yes | must match frontend's `VITE_GOOGLE_CLIENT_ID` | +| `ADMIN_EMAILS` | backend | yes | comma-separated | +| `JWT_SECRET` | backend | yes | session signing key | +| `DB_PATH` | backend | no | default `./data/eatme.db` | +| `CORS_ORIGIN` | backend | no | default `http://localhost:5173` | +| `COOKIE_SECURE` | backend | no | set `true` behind HTTPS | +| `PORT` | backend | no | default `4000` | +| `VITE_GOOGLE_CLIENT_ID` | frontend | yes | build-time only | + +## Related + +- [System overview](./overview.md) diff --git a/docs/architecture/index.md b/docs/architecture/index.md new file mode 100644 index 0000000..7c4219b --- /dev/null +++ b/docs/architecture/index.md @@ -0,0 +1,5 @@ +# Architecture + +- [System overview](./overview.md) - how the backend, frontend, and database fit together +- [Authentication & authorization](./auth-flow.md) - Google ID-token verification, session cookie, admin/employee roles +- [Build & deployment](./deployment.md) - esbuild bundle, Vite build, multi-stage Dockerfile, env vars diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md new file mode 100644 index 0000000..47796b8 --- /dev/null +++ b/docs/architecture/overview.md @@ -0,0 +1,51 @@ +--- +type: Architecture Overview +title: System overview +description: How the EatMe frontend, backend, and database fit together. +tags: [architecture, backend, frontend] +timestamp: 2026-08-12T00:00:00Z +--- + +# System overview + +EatMe is a small monorepo with two independently-runnable apps that ship as +one Docker image in production. + +- **`backend/`** — Express + TypeScript API, SQLite via `better-sqlite3` + (entry point `backend/src/index.ts`). Owns all state: employee whitelist, + attendance events, sessions. +- **`frontend/`** — Vite + React + TypeScript PWA (entry point + `frontend/src/App.tsx`). Talks to the backend only over `/api/*`; in dev, + Vite proxies that path to `http://localhost:4000` (see + `frontend/vite.config.ts`). + +## Request flow + +1. Employee/admin signs in with Google Identity Services in the browser + (ID-token flow, no server-side OAuth redirect) — see + [Auth flow](./auth-flow.md). +2. The frontend POSTs the Google ID token to `POST /api/auth/google`; the + backend verifies it, issues its own JWT session cookie, and from then on + every `/api/*` call rides on that cookie (`credentials: 'include'` in + `frontend/src/api/client.ts`). +3. All app logic (fetching, polling, derived state) lives in Zustand stores + under `frontend/src/store/` — see [Frontend](/docs/frontend/index.md). + Components are thin consumers of store selectors/actions. +4. In production the backend also serves the built frontend as static files + (`dist/public/`, see [Deployment](./deployment.md)) — one process, one + container, one origin. + +## Design language + +The UI follows the "light monochrome TUI" style defined at +`~/doc/concepts/ui/light-mono-tui.md`: grey page background, black type and +1px borders, mono font, inverted fills for active/hover state, no accent +colors, no border-radius. Implemented as plain CSS tokens in +`frontend/src/styles/tui.css`. + +## Related + +- [Auth flow](./auth-flow.md) +- [Deployment](./deployment.md) +- [Data model](/docs/data-model/index.md) +- [API](/docs/api/index.md) diff --git a/docs/data-model/attendance-events.md b/docs/data-model/attendance-events.md new file mode 100644 index 0000000..80c65be --- /dev/null +++ b/docs/data-model/attendance-events.md @@ -0,0 +1,68 @@ +--- +type: SQLite Table +title: attendance_events +description: Append-only log of clock_in/clock_out/break_start/break_end events, and the state machine built on top of it. +resource: backend/src/db/index.ts +tags: [data-model, sqlite] +timestamp: 2026-08-12T00:00:00Z +--- + +# attendance_events + +Append-only event log — there is no "shift" or "session" row. Shifts are +derived at read time by walking events chronologically +(`buildSessions` in `backend/src/services/attendance.ts`). + +# Schema + +| Column | Type | Description | +|---|---|---| +| `id` | INTEGER PK | autoincrement | +| `employee_id` | INTEGER | references [employees.id](./employees.md) | +| `type` | TEXT | one of `clock_in`, `clock_out`, `break_start`, `break_end` (CHECK constraint) | +| `ts` | TEXT | ISO 8601 UTC, set by SQLite default | + +Indexed on `(employee_id, ts)`. + +# State machine + +`getLiveStatus` derives one of three states from an employee's most recent +event: + +| Last event | Status | +|---|---| +| none / `clock_out` | `clocked_out` | +| `clock_in` / `break_end` | `working` | +| `break_start` | `on_break` | + +`recordEvent` only allows the transition that's valid from the current +status (`NEXT_ALLOWED` map) — e.g. you can't `break_start` while already +`clocked_out`. An invalid attempt throws `InvalidTransitionError`, surfaced +by the API as `409`. + +``` +clocked_out --clock_in--> working +working --break_start--> on_break +on_break --break_end--> working +working --clock_out--> clocked_out +``` + +# Deriving shifts + +`buildSessions(events, now)` walks a chronological event list and pairs +`clock_in`...`clock_out` into a `Session`, with nested `breaks`. A session +still missing its `clock_out` is `open: true` and its `workedMs`/`breakMs` +are computed against `now` (the request time) rather than a real end — +that's why the frontend re-derives it against a live clock between polls +(see [Frontend stores](/docs/frontend/stores.md)) instead of trusting a +stale fetch forever. + +`summarize(events, now)` reduces sessions into `totalWorkedMs`, +`totalBreakMs`, `shiftCount` for a date range — the shape returned by both +the employee's own stats endpoint and the admin per-employee endpoint (see +[Attendance API](/docs/api/attendance-routes.md), [Admin API](/docs/api/admin-routes.md)). + +# Related + +- [employees](./employees.md) +- [Attendance API](/docs/api/attendance-routes.md) diff --git a/docs/data-model/employees.md b/docs/data-model/employees.md new file mode 100644 index 0000000..ffe9e76 --- /dev/null +++ b/docs/data-model/employees.md @@ -0,0 +1,40 @@ +--- +type: SQLite Table +title: employees +description: The whitelist of employee emails allowed to log attendance, with a soft-delete active flag. +resource: backend/src/db/index.ts +tags: [data-model, sqlite] +timestamp: 2026-08-12T00:00:00Z +--- + +# employees + +The employee whitelist. A row here (with `active = 1`) is what lets a Google +account log in as an employee — see [Auth flow](/docs/architecture/auth-flow.md). +Admins are **not** rows in this table; they're resolved purely from the +`ADMIN_EMAILS` env var. + +# Schema + +| Column | Type | Description | +|---|---|---| +| `id` | INTEGER PK | autoincrement | +| `email` | TEXT | unique, lowercased on write | +| `name` | TEXT | nullable; filled in from the Google profile on first login (`touchEmployeeName` in `backend/src/services/employees.ts`), only if still null | +| `active` | INTEGER | 1 = can log in, 0 = soft-deleted | +| `created_at` | TEXT | ISO 8601 UTC, set by SQLite default | + +# Behavior + +- **Add** (`addEmployee`): upsert by email. If a soft-deleted row exists for + that email, it's reactivated (`active = 1`) rather than duplicated. +- **Remove** (`deactivateEmployee`): sets `active = 0`. This is a **soft** + delete by design — [attendance_events](./attendance-events.md) rows keep + referencing the employee, so historical stats for a removed employee are + still computable and re-adding the same email restores access without + losing history. + +# Related + +- [attendance_events](./attendance-events.md) — `employee_id` references this table +- [Employees API](/docs/api/admin-routes.md) diff --git a/docs/data-model/index.md b/docs/data-model/index.md new file mode 100644 index 0000000..654dccc --- /dev/null +++ b/docs/data-model/index.md @@ -0,0 +1,6 @@ +# Data model + +SQLite via `better-sqlite3`, schema created on boot in `backend/src/db/index.ts`. + +- [employees](./employees.md) - the whitelist of employee emails, with a soft-delete active flag +- [attendance_events](./attendance-events.md) - append-only clock-in/out/break log and the derived state machine diff --git a/docs/frontend/design-system.md b/docs/frontend/design-system.md new file mode 100644 index 0000000..f1dcfba --- /dev/null +++ b/docs/frontend/design-system.md @@ -0,0 +1,45 @@ +--- +type: Design Language +title: TUI monochrome design system +description: How the light-mono-tui design language is implemented in this app's CSS. +resource: frontend/src/styles/tui.css +tags: [frontend, design, css] +timestamp: 2026-08-12T00:00:00Z +--- + +# TUI monochrome design system + +The full design language spec lives outside this bundle at +`~/doc/concepts/ui/light-mono-tui.md` (grey background, black type/borders, +mono font, inverted active/hover states, no accent color, no radius, no +shadow). This doc covers how EatMe implements it. + +`frontend/src/styles/tui.css` defines the tokens (`--bg`, `--fg`, `--muted`, +`--hover`, `--empty`, `--panel`, `--font`) and the component classes built on +them: `.tabs`/`.tab`, `.btn`/`.btn-block`/`.btn-ghost`, `.panel`, +`.field-row`, `.list`/`.row`, `.badge` (`-solid`, `-dashed`, `-double`), +`.stat-grid`/`.stat-tile`, `.login-card`. + +# App-specific additions + +Built for this app, following the same rules (square edges, monochrome, +1px borders) rather than introducing new visual language: + +- **`.bar-chart-*`** (`frontend/src/components/BarChart.tsx`) — the + per-employee daily-hours chart. Y-axis labels + a repeating 1px + `--empty`-colored gridline background at 25% steps computed from a + "nice" rounded max (`Math.ceil` to the next whole hour); bars are solid + `--fg`, `--muted` on hover; day-of-month labels render in a *separate* + flex row below the fixed-height bar track (not nested inside each bar's + percentage-height column) specifically to avoid the bars visually + overlapping the labels. +- **`.month-nav`** (`frontend/src/components/MonthNav.tsx`) — `[<] label [>]` + bordered strip, styled like the design language's tab strip. Deliberately + placed *inside* the employee-detail panel next to the chart it controls, + not as a page-level control — an earlier version put it at the top of the + whole admin stats page, which tested as confusing ("can't browse history + on the chart") since it was visually disconnected from what it affected. + +# Related + +- [Zustand stores](./stores.md) diff --git a/docs/frontend/index.md b/docs/frontend/index.md new file mode 100644 index 0000000..d1c24de --- /dev/null +++ b/docs/frontend/index.md @@ -0,0 +1,6 @@ +# Frontend + +Vite + React + TypeScript PWA, Google Identity Services for sign-in. + +- [Zustand stores](./stores.md) - authStore, attendanceStore, adminStore — where all app logic lives +- [TUI monochrome design system](./design-system.md) - the light-mono-tui design language and this app's chart/nav additions diff --git a/docs/frontend/stores.md b/docs/frontend/stores.md new file mode 100644 index 0000000..16dbee1 --- /dev/null +++ b/docs/frontend/stores.md @@ -0,0 +1,62 @@ +--- +type: Frontend Module +title: Zustand stores +description: authStore, attendanceStore, and adminStore — where all app/fetch logic lives, keeping components thin. +resource: frontend/src/store +tags: [frontend, zustand, state] +timestamp: 2026-08-12T00:00:00Z +--- + +# Zustand stores + +Convention (matching the user's other `~/mywork` projects): one flat +`create()` per concern in `frontend/src/store/Store.ts`, +default-exported as `useStore`. State and actions live together; +async/API calls are colocated directly in the actions via `set`/`get` — no +separate service layer. No `persist`/`devtools`/`immer`/slices are used +here. Components read via selectors (`useXStore(s => s.field)`) and call +actions; they don't own fetch/interval logic themselves. + +# `authStore.ts` + +`{ user, loading, loginError }` + `init()` (calls `GET /auth/me` once on +app mount, from `App.tsx`), `loginWithGoogle(credential)`, `logout()`. No +persistence — the real session lives in the httpOnly cookie, so on reload +the store just re-asks the backend via `init()`. + +# `attendanceStore.ts` + +Employee's own clock state. `{ status, stats, live, busy, error }` plus +`load()`, `recordEvent(type)`, `startPolling()`/`stopPolling()`. + +- `startPolling` sets two intervals: one that calls `load()` every 60s + (keeps the server truth in sync), and one that recomputes `live` every + 1s from the last-fetched `stats` against a fresh `Date` — via + `withLiveTime` (`frontend/src/lib/liveSession.ts`) — so an open shift's + duration counts up smoothly instead of freezing until the next poll. + `EmployeeApp.tsx` starts/stops this based on `status` (only polls while + `working`/`on_break`). +- **Gotcha this hit in practice**: `live` is a plain cached field, updated + by `set()` inside the tick/poll callbacks — it is deliberately *not* a + selector method like `liveStats: () => withLiveTime(...)` called as + `useAttendanceStore(s => s.liveStats())`. That pattern returns a new + object on every call, which breaks React's `useSyncExternalStore` + (used internally by zustand v5) — "Maximum update depth exceeded" from an + infinite render loop, since the snapshot is never referentially stable + between renders even when nothing actually changed. + +# `adminStore.ts` + +Employee list + org/individual stats + the shared `month` ("YYYY-MM") used +by both the summary table and the selected employee's detail chart. +`setMonth`/`prevMonth`/`nextMonth` update `month` and re-fetch `loadStats()` +plus (if an employee is selected) `selectEmployee()` together, so the +summary and the detail chart always show the same period. `removeEmployee` +calls the soft-delete endpoint — see [employees](/docs/data-model/employees.md). + +# Related + +- [attendance_events](/docs/data-model/attendance-events.md) — the state + machine and `Session` shape these stores fetch +- [Attendance API](/docs/api/attendance-routes.md) +- [Admin API](/docs/api/admin-routes.md) diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..e8dc450 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,14 @@ +--- +okf_version: "0.1" +--- + +# EatMe — Docházka + +Knowledge bundle for the EatMe bistro employee attendance-tracking system: a +Google-SSO PWA where employees log clock-in/out and breaks, and the owner +(admin) manages the employee whitelist and views statistics. + +- [Architecture](./architecture/) — system overview, auth flow, deployment +- [Data model](./data-model/) — SQLite tables and the attendance state machine +- [API](./api/) — HTTP routes under `/api/auth`, `/api/attendance`, `/api/admin` +- [Frontend](./frontend/) — Zustand stores and the TUI monochrome design system diff --git a/docs/log.md b/docs/log.md new file mode 100644 index 0000000..ce0c704 --- /dev/null +++ b/docs/log.md @@ -0,0 +1,10 @@ +# Update Log + +## 2026-08-12 +- **Update**: Documented the actual production shipping workflow in + [Build & deployment](./architecture/deployment.md) — build locally for + the target platform, `docker save`/`load` over ssh (never copy source to + the server), ship only `compose.yaml` + `.env`, nginx reverse proxy + template in `deploy/`. +- **Creation**: Initial OKF bundle documenting the EatMe attendance system — + architecture, data model, API surface, frontend structure, and deployment. diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..ff70d4f --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,2 @@ +# Must match the backend's GOOGLE_CLIENT_ID. +VITE_GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..692c42a --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,26 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +dev-dist +*.local +.env + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/frontend/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..9ca235d --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,17 @@ + + + + + + + + + + EatMe — Docházka + + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..87dcd25 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,27 @@ +{ + "name": "eatme-frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.8", + "react-dom": "^19.2.8", + "zustand": "^5.0.14" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.4", + "oxlint": "^1.75.0", + "typescript": "~6.0.2", + "vite": "^8.2.0", + "vite-plugin-pwa": "^1.3.0" + } +} diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png new file mode 100644 index 0000000..d564210 Binary files /dev/null and b/frontend/public/apple-touch-icon.png differ diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000..f37d380 Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/public/icon-192.png b/frontend/public/icon-192.png new file mode 100644 index 0000000..d8d9a6b Binary files /dev/null and b/frontend/public/icon-192.png differ diff --git a/frontend/public/icon-512.png b/frontend/public/icon-512.png new file mode 100644 index 0000000..f4e6578 Binary files /dev/null and b/frontend/public/icon-512.png differ diff --git a/frontend/public/icon-maskable-512.png b/frontend/public/icon-maskable-512.png new file mode 100644 index 0000000..6bb37ca Binary files /dev/null and b/frontend/public/icon-maskable-512.png differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..9df0d8a --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,50 @@ +import { useEffect } from 'react'; +import useAuthStore from './store/authStore'; +import { LoginPage } from './pages/LoginPage'; +import { EmployeeApp } from './pages/EmployeeApp'; +import { AdminApp } from './pages/AdminApp'; + +function App() { + const user = useAuthStore((s) => s.user); + const loading = useAuthStore((s) => s.loading); + const logout = useAuthStore((s) => s.logout); + const init = useAuthStore((s) => s.init); + + useEffect(() => { + init(); + }, [init]); + + if (loading) { + return
> načítám…
; + } + + if (!user) { + return ; + } + + return ( +
+
+
+
+ EatMe + docházka +
+
+ + {user.name ?? user.email} · {user.role === 'admin' ? 'admin' : 'zaměstnanec'} + + +
+
+
+
+
{user.role === 'admin' ? : }
+
+
+ ); +} + +export default App; diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..54cdadd --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,38 @@ +export class ApiError extends Error { + status: number; + + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + +async function request(path: string, init?: RequestInit): Promise { + const res = await fetch(`/api${path}`, { + ...init, + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + ...init?.headers, + }, + }); + + if (res.status === 204) { + return undefined as T; + } + + const body = await res.json().catch(() => ({})); + + if (!res.ok) { + throw new ApiError(res.status, body.error ?? `Request failed (${res.status})`); + } + + return body as T; +} + +export const api = { + get: (path: string) => request(path), + post: (path: string, data?: unknown) => + request(path, { method: 'POST', body: data ? JSON.stringify(data) : undefined }), + delete: (path: string) => request(path, { method: 'DELETE' }), +}; diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts new file mode 100644 index 0000000..571a633 --- /dev/null +++ b/frontend/src/api/types.ts @@ -0,0 +1,55 @@ +export type EventType = 'clock_in' | 'clock_out' | 'break_start' | 'break_end'; +export type LiveStatus = 'clocked_out' | 'working' | 'on_break'; +export type Role = 'admin' | 'employee'; + +export interface SessionUser { + email: string; + name: string | null; + role: Role; + employeeId: number | null; +} + +export interface Employee { + id: number; + email: string; + name: string | null; + active: 0 | 1; + created_at: string; +} + +export interface AttendanceEvent { + id: number; + employee_id: number; + type: EventType; + ts: string; +} + +export interface Session { + clockIn: string; + clockOut: string | null; + breaks: { start: string; end: string | null }[]; + workedMs: number; + breakMs: number; + open: boolean; +} + +export interface StatsSummary { + range: { from: string; to: string }; + sessions: Session[]; + totalWorkedMs: number; + totalBreakMs: number; + shiftCount: number; +} + +export interface EmployeeStat { + employee: Employee; + totalWorkedMs: number; + totalBreakMs: number; + shiftCount: number; +} + +export interface AdminStatsSummary { + range: { from: string; to: string }; + totals: { totalWorkedMs: number; totalBreakMs: number; shiftCount: number }; + employees: EmployeeStat[]; +} diff --git a/frontend/src/auth/GoogleButton.tsx b/frontend/src/auth/GoogleButton.tsx new file mode 100644 index 0000000..7720835 --- /dev/null +++ b/frontend/src/auth/GoogleButton.tsx @@ -0,0 +1,44 @@ +import { useEffect, useRef } from 'react'; +import useAuthStore from '../store/authStore'; + +export function GoogleButton() { + const ref = useRef(null); + const loginWithGoogle = useAuthStore((s) => s.loginWithGoogle); + + useEffect(() => { + const clientId = import.meta.env.VITE_GOOGLE_CLIENT_ID; + let cancelled = false; + + function render() { + if (cancelled || !ref.current || !window.google) return; + window.google.accounts.id.initialize({ + client_id: clientId, + callback: (response) => loginWithGoogle(response.credential), + }); + window.google.accounts.id.renderButton(ref.current, { + theme: 'outline', + size: 'large', + text: 'signin_with', + shape: 'square', + width: 280, + }); + } + + if (window.google) { + render(); + } else { + const interval = setInterval(() => { + if (window.google) { + clearInterval(interval); + render(); + } + }, 100); + return () => { + cancelled = true; + clearInterval(interval); + }; + } + }, [loginWithGoogle]); + + return
; +} diff --git a/frontend/src/components/AdminStats.tsx b/frontend/src/components/AdminStats.tsx new file mode 100644 index 0000000..ca9460d --- /dev/null +++ b/frontend/src/components/AdminStats.tsx @@ -0,0 +1,99 @@ +import { useEffect } from 'react'; +import useAdminStore from '../store/adminStore'; +import { StatsSummary } from './StatsSummary'; +import { SessionList } from './SessionList'; +import { MonthNav } from './MonthNav'; +import { BarChart } from './BarChart'; +import { formatDuration } from '../lib/format'; +import { formatMonthLabel } from '../lib/month'; +import { aggregateDailyWorkedMs } from '../lib/dailyAggregate'; + +export function AdminStats() { + const stats = useAdminStore((s) => s.stats); + const month = useAdminStore((s) => s.month); + const selectedEmployeeId = useAdminStore((s) => s.selectedEmployeeId); + const detail = useAdminStore((s) => s.employeeDetail); + const detailLoading = useAdminStore((s) => s.detailLoading); + const loadStats = useAdminStore((s) => s.loadStats); + const selectEmployee = useAdminStore((s) => s.selectEmployee); + const prevMonth = useAdminStore((s) => s.prevMonth); + const nextMonth = useAdminStore((s) => s.nextMonth); + + useEffect(() => { + loadStats(); + }, [loadStats]); + + if (!stats) return
> načítám…
; + + return ( + <> +
+

Souhrn · {formatMonthLabel(month)}

+ +
+ +
+

Podle zaměstnance · klikni pro detail

+
+ {stats.employees.length === 0 && ( +
· zatím žádní zaměstnanci
+ )} + {stats.employees.map((e) => ( +
selectEmployee(e.employee.id)} + style={{ cursor: 'pointer' }} + > +
+
{e.employee.name ?? e.employee.email}
+
+ {e.shiftCount} směn · pauzy {formatDuration(e.totalBreakMs)} +
+
+
+ {formatDuration(e.totalWorkedMs)} +
+
+ ))} +
+
+ + {selectedEmployeeId != null && ( +
+ )} + + ); +} diff --git a/frontend/src/components/BarChart.tsx b/frontend/src/components/BarChart.tsx new file mode 100644 index 0000000..9df4313 --- /dev/null +++ b/frontend/src/components/BarChart.tsx @@ -0,0 +1,59 @@ +import { formatDuration } from '../lib/format'; + +interface Props { + /** Worked ms per day, index 0 = day 1 of the shown month. */ + values: number[]; + month: string; +} + +const HOUR_MS = 3_600_000; + +export function BarChart({ values, month }: Props) { + const maxHours = Math.max(...values) / HOUR_MS; + const niceMaxHours = Math.max(1, Math.ceil(maxHours)); + const niceMaxMs = niceMaxHours * HOUR_MS; + const [year, monthIndex] = month.split('-').map(Number); + + const yTicks = [4, 3, 2, 1, 0].map((n) => (niceMaxMs * n) / 4); + + return ( +
+
+ {yTicks.map((ms) => ( + {formatDuration(ms)} + ))} +
+
+
+ {values.map((ms, i) => { + const day = i + 1; + const heightPct = (ms / niceMaxMs) * 100; + const label = new Date(year, monthIndex - 1, day).toLocaleDateString('cs-CZ', { + day: 'numeric', + month: 'numeric', + }); + return ( +
+
+
+ ); + })} +
+
+ {values.map((_, i) => { + const day = i + 1; + return ( +
+ {day === 1 || day % 5 === 0 ? day : ''} +
+ ); + })} +
+
+
+ ); +} diff --git a/frontend/src/components/ClockControls.tsx b/frontend/src/components/ClockControls.tsx new file mode 100644 index 0000000..05e7d9b --- /dev/null +++ b/frontend/src/components/ClockControls.tsx @@ -0,0 +1,40 @@ +import type { EventType, LiveStatus } from '../api/types'; + +interface Props { + status: LiveStatus; + busy: boolean; + onAction: (type: EventType) => void; +} + +export function ClockControls({ status, busy, onAction }: Props) { + if (status === 'clocked_out') { + return ( + + ); + } + + if (status === 'working') { + return ( +
+ + +
+ ); + } + + return ( + + ); +} diff --git a/frontend/src/components/EmployeeManager.tsx b/frontend/src/components/EmployeeManager.tsx new file mode 100644 index 0000000..2d4d0df --- /dev/null +++ b/frontend/src/components/EmployeeManager.tsx @@ -0,0 +1,72 @@ +import { useEffect, useState } from 'react'; +import useAdminStore from '../store/adminStore'; +import { formatDate } from '../lib/format'; + +export function EmployeeManager() { + const employees = useAdminStore((s) => s.employees); + const error = useAdminStore((s) => s.employeesError); + const busy = useAdminStore((s) => s.employeesBusy); + const loadEmployees = useAdminStore((s) => s.loadEmployees); + const addEmployee = useAdminStore((s) => s.addEmployee); + const removeEmployee = useAdminStore((s) => s.removeEmployee); + + const [email, setEmail] = useState(''); + + useEffect(() => { + loadEmployees(); + }, [loadEmployees]); + + async function handleAdd(e: React.FormEvent) { + e.preventDefault(); + if (!email.trim()) return; + await addEmployee(email.trim()); + setEmail(''); + } + + return ( +
+

Zaměstnanci · přístup přes Google účet

+ +
+ setEmail(e.target.value)} + disabled={busy} + /> + +
+ + {error &&
{error}
} + +
+ {employees === null &&
> načítám…
} + {employees?.length === 0 &&
· zatím žádní zaměstnanci
} + {employees?.map((emp) => ( +
+
+
{emp.name ?? emp.email}
+
+ {emp.email} · od {formatDate(emp.created_at)} +
+
+
+ {emp.active ? ( + + ) : ( + + NEAKTIVNÍ + + )} +
+
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/MonthNav.tsx b/frontend/src/components/MonthNav.tsx new file mode 100644 index 0000000..827d823 --- /dev/null +++ b/frontend/src/components/MonthNav.tsx @@ -0,0 +1,21 @@ +import { formatMonthLabel } from '../lib/month'; + +interface Props { + month: string; + onPrev: () => void; + onNext: () => void; +} + +export function MonthNav({ month, onPrev, onNext }: Props) { + return ( +
+ +
{formatMonthLabel(month)}
+ +
+ ); +} diff --git a/frontend/src/components/SessionList.tsx b/frontend/src/components/SessionList.tsx new file mode 100644 index 0000000..f62d365 --- /dev/null +++ b/frontend/src/components/SessionList.tsx @@ -0,0 +1,38 @@ +import type { Session } from '../api/types'; +import { formatDate, formatDuration, formatTime } from '../lib/format'; + +export function SessionList({ sessions }: { sessions: Session[] }) { + if (sessions.length === 0) { + return
· žádné směny v tomto období
; + } + + const sorted = [...sessions].sort((a, b) => b.clockIn.localeCompare(a.clockIn)); + + return ( +
+ {sorted.map((s) => ( +
+
+
+ {formatDate(s.clockIn)} · {formatTime(s.clockIn)} + {' – '} + {s.clockOut ? formatTime(s.clockOut) : 'probíhá'} +
+
+ {s.breaks.length > 0 + ? `${s.breaks.length}× pauza · ${formatDuration(s.breakMs)}` + : 'bez pauzy'} +
+
+
+ {s.open ? ( + {formatDuration(s.workedMs)} + ) : ( + {formatDuration(s.workedMs)} + )} +
+
+ ))} +
+ ); +} diff --git a/frontend/src/components/StatsSummary.tsx b/frontend/src/components/StatsSummary.tsx new file mode 100644 index 0000000..04281ef --- /dev/null +++ b/frontend/src/components/StatsSummary.tsx @@ -0,0 +1,26 @@ +import { formatDuration } from '../lib/format'; + +interface Props { + totalWorkedMs: number; + totalBreakMs: number; + shiftCount: number; +} + +export function StatsSummary({ totalWorkedMs, totalBreakMs, shiftCount }: Props) { + return ( +
+
+
{formatDuration(totalWorkedMs)}
+
Odpracováno
+
+
+
{formatDuration(totalBreakMs)}
+
Pauzy
+
+
+
{shiftCount}
+
Směny
+
+
+ ); +} diff --git a/frontend/src/components/StatusBadge.tsx b/frontend/src/components/StatusBadge.tsx new file mode 100644 index 0000000..1f6813c --- /dev/null +++ b/frontend/src/components/StatusBadge.tsx @@ -0,0 +1,14 @@ +import type { LiveStatus } from '../api/types'; + +const LABELS: Record = { + clocked_out: 'ODHLÁŠEN', + working: 'PRACUJE', + on_break: 'PAUZA', +}; + +export function StatusBadge({ status }: { status: LiveStatus }) { + const label = LABELS[status]; + if (status === 'working') return {label}; + if (status === 'on_break') return {label}; + return {label}; +} diff --git a/frontend/src/lib/dailyAggregate.ts b/frontend/src/lib/dailyAggregate.ts new file mode 100644 index 0000000..af4f8d9 --- /dev/null +++ b/frontend/src/lib/dailyAggregate.ts @@ -0,0 +1,14 @@ +import type { Session } from '../api/types'; +import { daysInMonth } from './month'; + +/** Worked ms per day of `month` ("YYYY-MM"), index 0 = day 1. A shift is credited to its clock-in day. */ +export function aggregateDailyWorkedMs(sessions: Session[], month: string): number[] { + const days = new Array(daysInMonth(month)).fill(0) as number[]; + for (const session of sessions) { + const day = new Date(session.clockIn).getDate(); + if (day >= 1 && day <= days.length) { + days[day - 1] += session.workedMs; + } + } + return days; +} diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts new file mode 100644 index 0000000..f529f13 --- /dev/null +++ b/frontend/src/lib/format.ts @@ -0,0 +1,22 @@ +export function formatDuration(ms: number): string { + const totalMinutes = Math.round(ms / 60000); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + return `${hours}h ${String(minutes).padStart(2, '0')}m`; +} + +export function formatTime(iso: string): string { + return new Date(iso).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); +} + +export function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString(undefined, { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }); +} + +export function formatDateTime(iso: string): string { + return `${formatDate(iso)} ${formatTime(iso)}`; +} diff --git a/frontend/src/lib/liveSession.ts b/frontend/src/lib/liveSession.ts new file mode 100644 index 0000000..d3cf856 --- /dev/null +++ b/frontend/src/lib/liveSession.ts @@ -0,0 +1,39 @@ +import type { Session, StatsSummary } from '../api/types'; + +/** + * The API computes worked/break durations at fetch time, so an open (still + * running) session freezes until the next request. This recomputes an open + * session's durations against a live clock so the UI can tick without polling. + */ +function recomputeOpenSession(session: Session, now: Date): Session { + if (!session.open) return session; + + const start = new Date(session.clockIn).getTime(); + const breakMs = session.breaks.reduce((sum, b) => { + const end = b.end ? new Date(b.end).getTime() : now.getTime(); + return sum + Math.max(0, end - new Date(b.start).getTime()); + }, 0); + const workedMs = Math.max(0, now.getTime() - start - breakMs); + + return { ...session, breakMs, workedMs }; +} + +export function withLiveTime(stats: StatsSummary, now: Date): StatsSummary { + let deltaWorked = 0; + let deltaBreak = 0; + + const sessions = stats.sessions.map((s) => { + if (!s.open) return s; + const live = recomputeOpenSession(s, now); + deltaWorked += live.workedMs - s.workedMs; + deltaBreak += live.breakMs - s.breakMs; + return live; + }); + + return { + ...stats, + sessions, + totalWorkedMs: stats.totalWorkedMs + deltaWorked, + totalBreakMs: stats.totalBreakMs + deltaBreak, + }; +} diff --git a/frontend/src/lib/month.ts b/frontend/src/lib/month.ts new file mode 100644 index 0000000..19814ca --- /dev/null +++ b/frontend/src/lib/month.ts @@ -0,0 +1,23 @@ +export function currentMonthKey(): string { + const now = new Date(); + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; +} + +export function shiftMonthKey(month: string, delta: number): string { + const [year, monthIndex] = month.split('-').map(Number); + const d = new Date(year, monthIndex - 1 + delta, 1); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; +} + +export function formatMonthLabel(month: string): string { + const [year, monthIndex] = month.split('-').map(Number); + return new Date(year, monthIndex - 1, 1).toLocaleDateString('cs-CZ', { + month: 'long', + year: 'numeric', + }); +} + +export function daysInMonth(month: string): number { + const [year, monthIndex] = month.split('-').map(Number); + return new Date(year, monthIndex, 0).getDate(); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..f8d99e9 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import './styles/tui.css'; +import App from './App.tsx'; + +createRoot(document.getElementById('root')!).render( + + + +); diff --git a/frontend/src/pages/AdminApp.tsx b/frontend/src/pages/AdminApp.tsx new file mode 100644 index 0000000..26547c8 --- /dev/null +++ b/frontend/src/pages/AdminApp.tsx @@ -0,0 +1,40 @@ +import { useEffect, useState } from 'react'; +import { EmployeeManager } from '../components/EmployeeManager'; +import { AdminStats } from '../components/AdminStats'; + +type Tab = 'employees' | 'stats'; + +export function AdminApp() { + const [tab, setTab] = useState('employees'); + + useEffect(() => { + function onKey(e: KeyboardEvent) { + const target = e.target as HTMLElement; + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return; + if (e.key === '1') setTab('employees'); + if (e.key === '2') setTab('stats'); + } + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, []); + + return ( + <> +
+ + +
+ +
+ {tab === 'employees' ? : } +
+ + ); +} diff --git a/frontend/src/pages/EmployeeApp.tsx b/frontend/src/pages/EmployeeApp.tsx new file mode 100644 index 0000000..f91484b --- /dev/null +++ b/frontend/src/pages/EmployeeApp.tsx @@ -0,0 +1,61 @@ +import { useEffect } from 'react'; +import useAttendanceStore from '../store/attendanceStore'; +import { ClockControls } from '../components/ClockControls'; +import { StatusBadge } from '../components/StatusBadge'; +import { StatsSummary } from '../components/StatsSummary'; +import { SessionList } from '../components/SessionList'; + +export function EmployeeApp() { + const status = useAttendanceStore((s) => s.status); + const busy = useAttendanceStore((s) => s.busy); + const error = useAttendanceStore((s) => s.error); + const live = useAttendanceStore((s) => s.live); + const load = useAttendanceStore((s) => s.load); + const recordEvent = useAttendanceStore((s) => s.recordEvent); + const startPolling = useAttendanceStore((s) => s.startPolling); + const stopPolling = useAttendanceStore((s) => s.stopPolling); + + useEffect(() => { + load(); + }, [load]); + + useEffect(() => { + if (status === null || status === 'clocked_out') { + stopPolling(); + return; + } + startPolling(); + return () => stopPolling(); + }, [status, startPolling, stopPolling]); + + if (!status || !live) { + return
> načítám…
; + } + + return ( + <> +
+

Docházka

+
+ +
+ + {error &&
{error}
} +
+ +
+

Statistiky · tento měsíc

+ +
+ +
+

Směny

+ +
+ + ); +} diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..bf12858 --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -0,0 +1,18 @@ +import useAuthStore from '../store/authStore'; +import { GoogleButton } from '../auth/GoogleButton'; + +export function LoginPage() { + const loginError = useAuthStore((s) => s.loginError); + + return ( +
+
+ EatMe +

EatMe · Docházka

+

Přihlas se firemním Google účtem

+ + {loginError &&
{loginError}
} +
+
+ ); +} diff --git a/frontend/src/store/adminStore.ts b/frontend/src/store/adminStore.ts new file mode 100644 index 0000000..30c87c3 --- /dev/null +++ b/frontend/src/store/adminStore.ts @@ -0,0 +1,105 @@ +import { create } from 'zustand'; +import { api, ApiError } from '../api/client'; +import type { AdminStatsSummary, Employee, StatsSummary } from '../api/types'; +import { currentMonthKey, shiftMonthKey } from '../lib/month'; + +interface AdminState { + employees: Employee[] | null; + employeesError: string | null; + employeesBusy: boolean; + + stats: AdminStatsSummary | null; + + month: string; // "YYYY-MM", the period shown for the selected employee's detail + selectedEmployeeId: number | null; + employeeDetail: StatsSummary | null; + detailLoading: boolean; + + loadEmployees: () => Promise; + addEmployee: (email: string) => Promise; + removeEmployee: (id: number) => Promise; + + loadStats: () => Promise; + selectEmployee: (id: number) => Promise; + setMonth: (month: string) => Promise; + prevMonth: () => Promise; + nextMonth: () => Promise; +} + +const useAdminStore = create((set, get) => ({ + employees: null, + employeesError: null, + employeesBusy: false, + + stats: null, + + month: currentMonthKey(), + selectedEmployeeId: null, + employeeDetail: null, + detailLoading: false, + + loadEmployees: async () => { + try { + const res = await api.get<{ employees: Employee[] }>('/admin/employees'); + set({ employees: res.employees, employeesError: null }); + } catch { + set({ employeesError: 'Nepodařilo se načíst zaměstnance' }); + } + }, + + addEmployee: async (email) => { + set({ employeesBusy: true, employeesError: null }); + try { + await api.post('/admin/employees', { email }); + await get().loadEmployees(); + } catch (err) { + set({ employeesError: err instanceof ApiError ? err.message : 'Přidání se nezdařilo' }); + } finally { + set({ employeesBusy: false }); + } + }, + + removeEmployee: async (id) => { + set({ employeesBusy: true, employeesError: null }); + try { + await api.delete(`/admin/employees/${id}`); + await get().loadEmployees(); + } catch (err) { + set({ employeesError: err instanceof ApiError ? err.message : 'Odebrání se nezdařilo' }); + } finally { + set({ employeesBusy: false }); + } + }, + + loadStats: async () => { + const res = await api.get(`/admin/stats?month=${get().month}`); + set({ stats: res }); + }, + + selectEmployee: async (id) => { + set({ selectedEmployeeId: id, detailLoading: true }); + try { + const res = await api.get(`/admin/stats/${id}?month=${get().month}`); + set({ employeeDetail: res }); + } finally { + set({ detailLoading: false }); + } + }, + + setMonth: async (month) => { + set({ month }); + await get().loadStats(); + const id = get().selectedEmployeeId; + if (id != null) await get().selectEmployee(id); + }, + + prevMonth: async () => { + await get().setMonth(shiftMonthKey(get().month, -1)); + }, + + nextMonth: async () => { + await get().setMonth(shiftMonthKey(get().month, 1)); + }, +})); + +export default useAdminStore; diff --git a/frontend/src/store/attendanceStore.ts b/frontend/src/store/attendanceStore.ts new file mode 100644 index 0000000..e3818ff --- /dev/null +++ b/frontend/src/store/attendanceStore.ts @@ -0,0 +1,82 @@ +import { create } from 'zustand'; +import { api, ApiError } from '../api/client'; +import type { EventType, LiveStatus, StatsSummary } from '../api/types'; +import { withLiveTime } from '../lib/liveSession'; + +const POLL_MS = 60_000; +const TICK_MS = 1_000; + +interface AttendanceState { + status: LiveStatus | null; + stats: StatsSummary | null; + /** `stats` recomputed against a live clock, cached so it only changes reference on an actual tick. */ + live: StatsSummary | null; + busy: boolean; + error: string | null; + pollTimer: ReturnType | null; + tickTimer: ReturnType | null; + + load: () => Promise; + recordEvent: (type: EventType) => Promise; + startPolling: () => void; + stopPolling: () => void; +} + +const useAttendanceStore = create((set, get) => ({ + status: null, + stats: null, + live: null, + busy: false, + error: null, + pollTimer: null, + tickTimer: null, + + load: async () => { + try { + const [stateRes, statsRes] = await Promise.all([ + api.get<{ status: LiveStatus }>('/attendance/state'), + api.get('/attendance/me'), + ]); + const now = new Date(); + set({ status: stateRes.status, stats: statsRes, live: withLiveTime(statsRes, now), error: null }); + } catch { + set({ error: 'Nepodařilo se načíst data' }); + } + }, + + recordEvent: async (type) => { + set({ busy: true, error: null }); + try { + await api.post('/attendance/event', { type }); + await get().load(); + } catch (err) { + set({ error: err instanceof ApiError ? err.message : 'Akce se nezdařila' }); + } finally { + set({ busy: false }); + } + }, + + // Polls the server every minute (so a shift that keeps running stays in sync) + // and ticks a local clock every second so the on-screen duration counts up + // smoothly between polls instead of freezing until the next reload. + startPolling: () => { + if (get().pollTimer || get().tickTimer) return; + const poll = setInterval(() => { + get().load(); + }, POLL_MS); + const tick = setInterval(() => { + const { stats } = get(); + if (stats) set({ live: withLiveTime(stats, new Date()) }); + }, TICK_MS); + set({ pollTimer: poll, tickTimer: tick }); + }, + + stopPolling: () => { + const { pollTimer, tickTimer } = get(); + if (pollTimer) clearInterval(pollTimer); + if (tickTimer) clearInterval(tickTimer); + set({ pollTimer: null, tickTimer: null }); + }, +})); + +export default useAttendanceStore; diff --git a/frontend/src/store/authStore.ts b/frontend/src/store/authStore.ts new file mode 100644 index 0000000..fd52435 --- /dev/null +++ b/frontend/src/store/authStore.ts @@ -0,0 +1,44 @@ +import { create } from 'zustand'; +import { api, ApiError } from '../api/client'; +import type { SessionUser } from '../api/types'; + +interface AuthState { + user: SessionUser | null; + loading: boolean; + loginError: string | null; + init: () => Promise; + loginWithGoogle: (credential: string) => Promise; + logout: () => Promise; +} + +const useAuthStore = create((set) => ({ + user: null, + loading: true, + loginError: null, + + init: async () => { + try { + const res = await api.get<{ user: SessionUser }>('/auth/me'); + set({ user: res.user, loading: false }); + } catch { + set({ user: null, loading: false }); + } + }, + + loginWithGoogle: async (credential) => { + set({ loginError: null }); + try { + const res = await api.post<{ user: SessionUser }>('/auth/google', { credential }); + set({ user: res.user }); + } catch (err) { + set({ loginError: err instanceof ApiError ? err.message : 'Přihlášení se nezdařilo' }); + } + }, + + logout: async () => { + await api.post('/auth/logout'); + set({ user: null }); + }, +})); + +export default useAuthStore; diff --git a/frontend/src/styles/tui.css b/frontend/src/styles/tui.css new file mode 100644 index 0000000..3f64f8e --- /dev/null +++ b/frontend/src/styles/tui.css @@ -0,0 +1,520 @@ +:root { + --bg: #f0f0f0; + --fg: #000000; + --muted: #666666; + --border: #000000; + --hover: #e0e0e0; + --empty: #d8d8d8; + --panel: #fafafa; + --font: 'IBM Plex Mono', 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace; +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + height: 100%; + margin: 0; +} + +html, +body { + background: var(--bg); + color: var(--fg); + font-family: var(--font); + font-size: 13px; + font-variant-numeric: tabular-nums; +} + +body.app { + overflow: hidden; +} + +#root { + display: flex; + flex-direction: column; +} + +a { + color: inherit; +} + +button { + font-family: inherit; + font-size: inherit; +} + +/* ---------- shell ---------- */ + +.app-shell { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +.chrome { + flex-shrink: 0; + border-bottom: 1px solid var(--border); + background: var(--bg); +} + +.chrome-inner { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.6rem 1rem; + max-width: 72rem; + margin: 0 auto; + width: 100%; +} + +.brand { + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.brand-sub { + color: var(--muted); + font-size: 11px; + margin-left: 0.5rem; +} + +.chrome-user { + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 11px; + color: var(--muted); +} + +.scroll { + flex: 1; + min-height: 0; + overflow-y: auto; +} + +.scroll-inner { + max-width: 72rem; + margin: 0 auto; + padding: 1rem; +} + +/* ---------- tabs ---------- */ + +.tabs { + display: flex; + border: 1px solid var(--border); + overflow: hidden; +} + +.tab { + flex: 1; + padding: 0.5rem 0.75rem; + text-align: center; + text-transform: uppercase; + font-size: 11px; + letter-spacing: 0.04em; + background: var(--bg); + cursor: pointer; + border: none; + border-right: 1px solid var(--border); +} + +.tab:last-child { + border-right: none; +} + +.tab:hover { + background: var(--hover); +} + +.tab.active { + background: var(--fg); + color: var(--bg); + font-weight: 700; +} + +/* ---------- buttons ---------- */ + +.btn { + display: inline-block; + border: 1px solid var(--border); + background: var(--bg); + color: var(--fg); + padding: 0.5rem 1rem; + text-transform: uppercase; + font-weight: 700; + font-size: 11px; + letter-spacing: 0.03em; + cursor: pointer; + text-decoration: none; +} + +.btn:hover:not(:disabled) { + background: var(--fg); + color: var(--bg); +} + +.btn:disabled { + color: var(--muted); + border-color: var(--muted); + cursor: not-allowed; +} + +.btn-block { + width: 100%; + padding: 0.9rem 1rem; + font-size: 13px; +} + +.btn-ghost { + border: none; + background: transparent; + text-decoration: underline; + text-transform: none; + font-weight: 400; + padding: 0.2rem 0.4rem; +} + +.btn-ghost:hover:not(:disabled) { + background: var(--hover); + color: var(--fg); +} + +/* ---------- panels / bordered ---------- */ + +.bordered { + border: 1px solid var(--border); +} + +.panel { + border: 1px solid var(--border); + background: var(--panel); + padding: 1rem; +} + +.panel + .panel { + margin-top: 1rem; +} + +.panel-title { + text-transform: uppercase; + font-size: 11px; + letter-spacing: 0.04em; + color: var(--muted); + margin: 0 0 0.75rem; +} + +/* ---------- field row (bordered input + button) ---------- */ + +.field-row { + display: flex; + border: 1px solid var(--border); +} + +.field-row input { + flex: 1; + border: none; + background: var(--panel); + padding: 0.6rem 0.75rem; + font-family: inherit; + font-size: 13px; + color: var(--fg); + outline: none; + min-width: 0; +} + +.field-row input::placeholder { + color: var(--muted); +} + +.field-row .btn { + border: none; + border-left: 1px solid var(--border); +} + +/* ---------- dense list rows ---------- */ + +.list { + border: 1px solid var(--border); + border-top: none; +} + +.row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.6rem 0.75rem; + border-top: 1px solid var(--border); + background: var(--bg); +} + +.row:hover { + background: var(--hover); +} + +.row-main { + min-width: 0; + flex: 1; +} + +.row-title { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.row-meta { + color: var(--muted); + font-size: 11px; + margin-top: 0.15rem; +} + +.row-action { + flex-shrink: 0; + display: flex; + gap: 0.5rem; + align-items: center; +} + +/* ---------- badges ---------- */ + +.badge { + display: inline-block; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.03em; + padding: 0.15rem 0.4rem; + border: 1px solid var(--border); +} + +.badge-solid { + background: var(--fg); + color: var(--bg); + font-weight: 700; +} + +.badge-dashed { + border-style: dashed; + color: var(--muted); +} + +.badge-double { + border-width: 3px; + border-style: double; +} + +/* ---------- stat tiles ---------- */ + +.stat-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); + border: 1px solid var(--border); +} + +.stat-tile { + padding: 0.75rem; + border-left: 1px solid var(--border); +} + +.stat-tile:first-child { + border-left: none; +} + +.stat-value { + font-size: 20px; + font-weight: 700; +} + +.stat-label { + color: var(--muted); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.04em; + margin-top: 0.2rem; +} + +/* ---------- login ---------- */ + +.login-shell { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +} + +.login-card { + width: 100%; + max-width: 22rem; + border: 1px solid var(--border); + background: var(--panel); + padding: 2rem 1.75rem; + text-align: center; +} + +.login-logo { + width: 96px; + height: 96px; + margin: 0 auto 1rem; + display: block; + border: 1px solid var(--border); +} + +.login-title { + text-transform: uppercase; + font-weight: 700; + letter-spacing: 0.04em; + margin: 0 0 0.25rem; +} + +.login-sub { + color: var(--muted); + font-size: 11px; + margin: 0 0 1.5rem; +} + +.google-btn-slot { + display: flex; + justify-content: center; + margin: 0 auto; +} + +.login-error { + margin-top: 1rem; + font-size: 11px; + color: var(--fg); + border: 1px dashed var(--border); + padding: 0.5rem; + text-align: left; +} + +/* ---------- month nav ---------- */ + +.month-nav { + display: flex; + align-items: stretch; + border: 1px solid var(--border); + margin-bottom: 1rem; +} + +.month-nav .btn { + border: none; + flex-shrink: 0; +} + +.month-nav .btn:first-child { + border-right: 1px solid var(--border); +} + +.month-nav .btn:last-child { + border-left: 1px solid var(--border); +} + +.month-nav-label { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + text-transform: uppercase; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; +} + +/* ---------- bar chart ---------- */ + +.bar-chart-wrap { + display: flex; + gap: 0.5rem; + border: 1px solid var(--border); + background: var(--panel); + padding: 0.75rem 0.5rem 0.5rem; +} + +.bar-chart-yaxis { + flex-shrink: 0; + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: flex-end; + height: 140px; + padding-right: 0.5rem; + border-right: 1px solid var(--border); + color: var(--muted); + font-size: 9px; + line-height: 1; +} + +.bar-chart-body { + flex: 1; + min-width: 0; +} + +.bar-chart { + display: flex; + align-items: flex-end; + gap: 2px; + height: 140px; + background-image: repeating-linear-gradient( + to top, + var(--empty) 0, + var(--empty) 1px, + transparent 1px, + transparent 25% + ); +} + +.bar-col { + flex: 1; + height: 100%; + display: flex; + flex-direction: column; + justify-content: flex-end; + align-items: stretch; + min-width: 2px; +} + +.bar { + background: var(--fg); + min-height: 1px; +} + +.bar-col:hover .bar { + background: var(--muted); +} + +.bar-chart-labels { + display: flex; + gap: 2px; + margin-top: 0.25rem; +} + +.bar-col-label { + flex: 1; + min-width: 2px; + color: var(--muted); + font-size: 9px; + text-align: center; + line-height: 1; +} + +/* ---------- empty / loading ---------- */ + +.empty-line { + color: var(--muted); + padding: 0.75rem; +} + +/* ---------- focus ---------- */ + +:focus-visible { + outline: 1px solid var(--fg); + outline-offset: 2px; +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..887c7a7 --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,37 @@ +/// + +interface ImportMetaEnv { + readonly VITE_GOOGLE_CLIENT_ID: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} + +interface GoogleCredentialResponse { + credential: string; +} + +interface Window { + google?: { + accounts: { + id: { + initialize(config: { + client_id: string; + callback: (response: GoogleCredentialResponse) => void; + }): void; + renderButton( + parent: HTMLElement, + options: { + theme?: 'outline' | 'filled_black' | 'filled_blue'; + size?: 'small' | 'medium' | 'large'; + text?: 'signin_with' | 'signup_with' | 'continue_with'; + shape?: 'rectangular' | 'pill' | 'circle' | 'square'; + width?: number; + } + ): void; + prompt(): void; + }; + }; + }; +} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..6830b6f --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "allowArbitraryExtensions": true, + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..8455dcb --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "module": "nodenext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..4456ef2 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,38 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import { VitePWA } from 'vite-plugin-pwa' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [ + react(), + VitePWA({ + registerType: 'autoUpdate', + includeAssets: ['favicon.ico', 'apple-touch-icon.png'], + manifest: { + name: 'EatMe — Docházka', + short_name: 'EatMe', + description: 'Evidence docházky zaměstnanců bistra EatMe', + theme_color: '#000000', + background_color: '#f0f0f0', + display: 'standalone', + start_url: '/', + icons: [ + { src: '/icon-192.png', sizes: '192x192', type: 'image/png' }, + { src: '/icon-512.png', sizes: '512x512', type: 'image/png' }, + { src: '/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }, + ], + }, + }), + ], + server: { + port: 5174, + strictPort: true, + proxy: { + '/api': { + target: 'http://localhost:4000', + changeOrigin: true, + }, + }, + }, +})