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 <noreply@anthropic.com>
This commit is contained in:
Michal Pemcak
2026-08-12 11:57:55 +02:00
commit fffcb73ea4
90 changed files with 3411 additions and 0 deletions

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
**/node_modules
backend/dist
backend/data
backend/.env
frontend/dist
frontend/dev-dist
frontend/.env
.git
docs
branding
*.md

11
.env.example Normal file
View File

@@ -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

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.env

42
Dockerfile Normal file
View File

@@ -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"]

96
README.md Normal file
View File

@@ -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=<client-id> \
-t eatme:latest .
# přenes hotový image přes ssh (žádný build na serveru)
docker save eatme:latest | gzip | ssh <host> "gunzip | docker load"
# pošli jen compose.yaml + .env (viz .env.example) a nastartuj
scp compose.yaml <host>:~/eatme/compose.yaml
ssh <host> "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://<deploy-host>: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).

22
backend/.env.example Normal file
View File

@@ -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

9
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
node_modules/
dist/
data/
.env
*.tsbuildinfo
*.db
*.db-journal
*.db-wal
*.db-shm

20
backend/esbuild.config.js Normal file
View File

@@ -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));

34
backend/package.json Normal file
View File

@@ -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"
}
}

View File

@@ -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<GoogleProfile> {
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,
};
}

View File

@@ -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();
}

View File

@@ -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;
}
}

30
backend/src/db/index.ts Normal file
View File

@@ -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);
`);

22
backend/src/env.ts Normal file
View File

@@ -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",
};

43
backend/src/index.ts Normal file
View File

@@ -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}`);
});

109
backend/src/routes/admin.ts Normal file
View File

@@ -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<string, unknown>);
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<string, unknown>);
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<string, unknown>);
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
});

View File

@@ -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<string, unknown>);
const events = eventsInRange(employeeId, fromIso, toIso);
const stats = summarize(events, new Date());
res.json({ range: { from: fromIso, to: toIso }, ...stats });
});

View File

@@ -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 });
});

View File

@@ -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<LiveStatus, EventType[]> = {
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,
};
}

View File

@@ -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;
}

33
backend/src/types.ts Normal file
View File

@@ -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;
}
}
}

35
backend/src/util/csv.ts Normal file
View File

@@ -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");
}

View File

@@ -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<string, unknown>): 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() };
}

22
backend/tsconfig.json Normal file
View File

@@ -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"]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

BIN
branding/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
branding/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
branding/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
branding/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

BIN
branding/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

BIN
branding/logo-master.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 766 KiB

BIN
branding/logo-source.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

18
compose.yaml Normal file
View File

@@ -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:

30
deploy/eatme.mipem.co Normal file
View File

@@ -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://<deploy-host>:8092;
proxy_redirect off;
}
}

71
docs/api/admin-routes.md Normal file
View File

@@ -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="<email>_<YYYY-MM>.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 `<a href=... download>` — 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

View File

@@ -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

51
docs/api/auth-routes.md Normal file
View File

@@ -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": "<google id token>" }
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)

8
docs/api/index.md Normal file
View File

@@ -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

View File

@@ -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)

View File

@@ -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=<id> -t eatme .
docker run -d -p 4000:4000 -v eatme-data:/app/data \
-e GOOGLE_CLIENT_ID=<id> -e ADMIN_EMAILS=<emails> \
-e JWT_SECRET=<secret> -e CORS_ORIGIN=<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=<id> -t eatme:latest .
docker save eatme:latest | gzip | ssh <host> "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 <host>:~/eatme/compose.yaml
ssh <host> "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/<domain>` for whichever host runs nginx, proxying
`https://<domain>` to `http://<deploy-host>:<published-port>`. 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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

6
docs/data-model/index.md Normal file
View File

@@ -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

View File

@@ -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)

6
docs/frontend/index.md Normal file
View File

@@ -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

62
docs/frontend/stores.md Normal file
View File

@@ -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<State>()` per concern in `frontend/src/store/<name>Store.ts`,
default-exported as `use<Name>Store`. 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)

14
docs/index.md Normal file
View File

@@ -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

10
docs/log.md Normal file
View File

@@ -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.

2
frontend/.env.example Normal file
View File

@@ -0,0 +1,2 @@
# Must match the backend's GOOGLE_CLIENT_ID.
VITE_GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com

26
frontend/.gitignore vendored Normal file
View File

@@ -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?

8
frontend/.oxlintrc.json Normal file
View File

@@ -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 }]
}
}

17
frontend/index.html Normal file
View File

@@ -0,0 +1,17 @@
<!doctype html>
<html lang="cs">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Evidence docházky zaměstnanců bistra EatMe" />
<title>EatMe — Docházka</title>
<script src="https://accounts.google.com/gsi/client" async defer></script>
</head>
<body class="app">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

27
frontend/package.json Normal file
View File

@@ -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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

50
frontend/src/App.tsx Normal file
View File

@@ -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 <div className="empty-line">&gt; načítám</div>;
}
if (!user) {
return <LoginPage />;
}
return (
<div className="app-shell">
<header className="chrome">
<div className="chrome-inner">
<div>
<span className="brand">EatMe</span>
<span className="brand-sub">docházka</span>
</div>
<div className="chrome-user">
<span>
{user.name ?? user.email} &middot; {user.role === 'admin' ? 'admin' : 'zaměstnanec'}
</span>
<button className="btn-ghost" onClick={logout}>
odhlásit [x]
</button>
</div>
</div>
</header>
<main className="scroll">
<div className="scroll-inner">{user.role === 'admin' ? <AdminApp /> : <EmployeeApp />}</div>
</main>
</div>
);
}
export default App;

View File

@@ -0,0 +1,38 @@
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
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: <T>(path: string) => request<T>(path),
post: <T>(path: string, data?: unknown) =>
request<T>(path, { method: 'POST', body: data ? JSON.stringify(data) : undefined }),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
};

55
frontend/src/api/types.ts Normal file
View File

@@ -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[];
}

View File

@@ -0,0 +1,44 @@
import { useEffect, useRef } from 'react';
import useAuthStore from '../store/authStore';
export function GoogleButton() {
const ref = useRef<HTMLDivElement>(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 <div ref={ref} className="google-btn-slot" />;
}

View File

@@ -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 <div className="empty-line">&gt; načítám</div>;
return (
<>
<div className="panel">
<p className="panel-title">Souhrn &middot; {formatMonthLabel(month)}</p>
<StatsSummary
totalWorkedMs={stats.totals.totalWorkedMs}
totalBreakMs={stats.totals.totalBreakMs}
shiftCount={stats.totals.shiftCount}
/>
</div>
<div className="panel">
<p className="panel-title">Podle zaměstnance &middot; klikni pro detail</p>
<div className="list">
{stats.employees.length === 0 && (
<div className="empty-line">· zatím žádní zaměstnanci</div>
)}
{stats.employees.map((e) => (
<div
className="row"
key={e.employee.id}
onClick={() => selectEmployee(e.employee.id)}
style={{ cursor: 'pointer' }}
>
<div className="row-main">
<div className="row-title">{e.employee.name ?? e.employee.email}</div>
<div className="row-meta">
{e.shiftCount} směn &middot; pauzy {formatDuration(e.totalBreakMs)}
</div>
</div>
<div className="row-action">
<span className="badge badge-solid">{formatDuration(e.totalWorkedMs)}</span>
</div>
</div>
))}
</div>
</div>
{selectedEmployeeId != null && (
<div className="panel">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<p className="panel-title">
Detail &middot;{' '}
{stats.employees.find((e) => e.employee.id === selectedEmployeeId)?.employee.name ??
stats.employees.find((e) => e.employee.id === selectedEmployeeId)?.employee.email}
</p>
<a
className="btn"
href={`/api/admin/stats/${selectedEmployeeId}/export?month=${month}`}
download
>
Export CSV
</a>
</div>
<MonthNav month={month} onPrev={prevMonth} onNext={nextMonth} />
{detailLoading && !detail && <div className="empty-line">&gt; načítám</div>}
{detail && (
<>
<BarChart values={aggregateDailyWorkedMs(detail.sessions, month)} month={month} />
<div style={{ marginTop: '1rem' }}>
<SessionList sessions={detail.sessions} />
</div>
</>
)}
</div>
)}
</>
);
}

View File

@@ -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 (
<div className="bar-chart-wrap">
<div className="bar-chart-yaxis">
{yTicks.map((ms) => (
<span key={ms}>{formatDuration(ms)}</span>
))}
</div>
<div className="bar-chart-body">
<div
className="bar-chart bar-chart-grid"
role="img"
aria-label={`Odpracované hodiny podle dne v měsíci ${month}`}
>
{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 (
<div className="bar-col" key={day} title={`${label}: ${formatDuration(ms)}`}>
<div className="bar" style={{ height: `${heightPct}%` }} />
</div>
);
})}
</div>
<div className="bar-chart-labels">
{values.map((_, i) => {
const day = i + 1;
return (
<div className="bar-col-label" key={day}>
{day === 1 || day % 5 === 0 ? day : ''}
</div>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -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 (
<button className="btn btn-block" disabled={busy} onClick={() => onAction('clock_in')}>
Příchod
</button>
);
}
if (status === 'working') {
return (
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
className="btn btn-block"
disabled={busy}
onClick={() => onAction('break_start')}
>
Start pauzy
</button>
<button className="btn btn-block" disabled={busy} onClick={() => onAction('clock_out')}>
Odchod
</button>
</div>
);
}
return (
<button className="btn btn-block" disabled={busy} onClick={() => onAction('break_end')}>
Konec pauzy
</button>
);
}

View File

@@ -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 (
<div className="panel">
<p className="panel-title">Zaměstnanci &middot; přístup přes Google účet</p>
<form className="field-row" onSubmit={handleAdd}>
<input
type="email"
placeholder="&gt; email@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={busy}
/>
<button className="btn" type="submit" disabled={busy}>
Přidat
</button>
</form>
{error && <div className="login-error" style={{ marginTop: '0.75rem' }}>{error}</div>}
<div className="list" style={{ marginTop: '1rem' }}>
{employees === null && <div className="empty-line">&gt; načítám</div>}
{employees?.length === 0 && <div className="empty-line">· zatím žádní zaměstnanci</div>}
{employees?.map((emp) => (
<div className="row" key={emp.id}>
<div className="row-main">
<div className="row-title">{emp.name ?? emp.email}</div>
<div className="row-meta">
{emp.email} &middot; od {formatDate(emp.created_at)}
</div>
</div>
<div className="row-action">
{emp.active ? (
<button className="btn" disabled={busy} onClick={() => removeEmployee(emp.id)}>
Odebrat
</button>
) : (
<span className="badge badge-dashed" title="Historie docházky zůstává zachována">
NEAKTIVNÍ
</span>
)}
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -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 (
<div className="month-nav">
<button className="btn" onClick={onPrev} aria-label="Předchozí měsíc">
&lt;
</button>
<div className="month-nav-label">{formatMonthLabel(month)}</div>
<button className="btn" onClick={onNext} aria-label="Následující měsíc">
&gt;
</button>
</div>
);
}

View File

@@ -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 <div className="empty-line">· žádné směny v tomto období</div>;
}
const sorted = [...sessions].sort((a, b) => b.clockIn.localeCompare(a.clockIn));
return (
<div className="list">
{sorted.map((s) => (
<div className="row" key={s.clockIn}>
<div className="row-main">
<div className="row-title">
{formatDate(s.clockIn)} &middot; {formatTime(s.clockIn)}
{' '}
{s.clockOut ? formatTime(s.clockOut) : 'probíhá'}
</div>
<div className="row-meta">
{s.breaks.length > 0
? `${s.breaks.length}× pauza · ${formatDuration(s.breakMs)}`
: 'bez pauzy'}
</div>
</div>
<div className="row-action">
{s.open ? (
<span className="badge badge-solid">{formatDuration(s.workedMs)}</span>
) : (
<span className="badge">{formatDuration(s.workedMs)}</span>
)}
</div>
</div>
))}
</div>
);
}

View File

@@ -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 (
<div className="stat-grid">
<div className="stat-tile">
<div className="stat-value">{formatDuration(totalWorkedMs)}</div>
<div className="stat-label">Odpracováno</div>
</div>
<div className="stat-tile">
<div className="stat-value">{formatDuration(totalBreakMs)}</div>
<div className="stat-label">Pauzy</div>
</div>
<div className="stat-tile">
<div className="stat-value">{shiftCount}</div>
<div className="stat-label">Směny</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,14 @@
import type { LiveStatus } from '../api/types';
const LABELS: Record<LiveStatus, string> = {
clocked_out: 'ODHLÁŠEN',
working: 'PRACUJE',
on_break: 'PAUZA',
};
export function StatusBadge({ status }: { status: LiveStatus }) {
const label = LABELS[status];
if (status === 'working') return <span className="badge badge-solid">{label}</span>;
if (status === 'on_break') return <span className="badge badge-dashed">{label}</span>;
return <span className="badge">{label}</span>;
}

View File

@@ -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;
}

View File

@@ -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)}`;
}

View File

@@ -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,
};
}

23
frontend/src/lib/month.ts Normal file
View File

@@ -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();
}

10
frontend/src/main.tsx Normal file
View File

@@ -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(
<StrictMode>
<App />
</StrictMode>
);

View File

@@ -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<Tab>('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 (
<>
<div className="tabs">
<button
className={`tab ${tab === 'employees' ? 'active' : ''}`}
onClick={() => setTab('employees')}
>
[1] Zaměstnanci
</button>
<button className={`tab ${tab === 'stats' ? 'active' : ''}`} onClick={() => setTab('stats')}>
[2] Statistiky
</button>
</div>
<div style={{ marginTop: '1rem' }}>
{tab === 'employees' ? <EmployeeManager /> : <AdminStats />}
</div>
</>
);
}

View File

@@ -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 <div className="empty-line">&gt; načítám</div>;
}
return (
<>
<div className="panel">
<p className="panel-title">Docházka</p>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '1rem' }}>
<StatusBadge status={status} />
</div>
<ClockControls status={status} busy={busy} onAction={recordEvent} />
{error && <div className="login-error">{error}</div>}
</div>
<div className="panel">
<p className="panel-title">Statistiky &middot; tento měsíc</p>
<StatsSummary
totalWorkedMs={live.totalWorkedMs}
totalBreakMs={live.totalBreakMs}
shiftCount={live.shiftCount}
/>
</div>
<div className="panel">
<p className="panel-title">Směny</p>
<SessionList sessions={live.sessions} />
</div>
</>
);
}

View File

@@ -0,0 +1,18 @@
import useAuthStore from '../store/authStore';
import { GoogleButton } from '../auth/GoogleButton';
export function LoginPage() {
const loginError = useAuthStore((s) => s.loginError);
return (
<div className="login-shell">
<div className="login-card">
<img className="login-logo" src="/icon-192.png" alt="EatMe" />
<p className="login-title">EatMe &middot; Docházka</p>
<p className="login-sub">Přihlas se firemním Google účtem</p>
<GoogleButton />
{loginError && <div className="login-error">{loginError}</div>}
</div>
</div>
);
}

View File

@@ -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<void>;
addEmployee: (email: string) => Promise<void>;
removeEmployee: (id: number) => Promise<void>;
loadStats: () => Promise<void>;
selectEmployee: (id: number) => Promise<void>;
setMonth: (month: string) => Promise<void>;
prevMonth: () => Promise<void>;
nextMonth: () => Promise<void>;
}
const useAdminStore = create<AdminState>((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<AdminStatsSummary>(`/admin/stats?month=${get().month}`);
set({ stats: res });
},
selectEmployee: async (id) => {
set({ selectedEmployeeId: id, detailLoading: true });
try {
const res = await api.get<StatsSummary>(`/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;

View File

@@ -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<typeof setInterval> | null;
tickTimer: ReturnType<typeof setInterval> | null;
load: () => Promise<void>;
recordEvent: (type: EventType) => Promise<void>;
startPolling: () => void;
stopPolling: () => void;
}
const useAttendanceStore = create<AttendanceState>((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<StatsSummary>('/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;

View File

@@ -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<void>;
loginWithGoogle: (credential: string) => Promise<void>;
logout: () => Promise<void>;
}
const useAuthStore = create<AuthState>((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;

520
frontend/src/styles/tui.css Normal file
View File

@@ -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;
}

37
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,37 @@
/// <reference types="vite/client" />
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;
};
};
};
}

View File

@@ -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"]
}

7
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -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"]
}

38
frontend/vite.config.ts Normal file
View File

@@ -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,
},
},
},
})