diff --git a/CLAUDE.md.proposed b/CLAUDE.md.proposed new file mode 100644 index 0000000..0aca331 --- /dev/null +++ b/CLAUDE.md.proposed @@ -0,0 +1,50 @@ +# Oikos + +Self-hosted family planner PWA. Node.js/Express, Vanilla JS (no build step), SQLite, Docker. + +## Hard Constraints + +Violations are always bugs - no exceptions. + +- Never add frontend frameworks (React, Vue, Svelte), bundlers (Webpack, Vite), or CSS libraries (Tailwind, Bootstrap). +- No external frontend dependencies. Only allowed exception: Lucide Icons (`public/lucide.min.js`, self-hosted). No CDN links at runtime. +- `import`/`export` everywhere. Never `require()`. +- Never `eval()`. Never `innerHTML` with user data. Use `textContent` or DOM API. +- All UI text via `t('key')`. Never hardcode strings in components. `de` is the reference locale. +- Migrations append-only. Add entries to the `migrations` array in `server/db.js`. Never modify or reorder existing entries. +- All colors, radii, shadows, font sizes from `public/styles/tokens.css`. Never hardcode design values. +- Every route handler in `try/catch`. No unhandled promise rejections. + +## Architecture + +Request flow: client → Express static (`public/`) or `/api/v1/*` → session auth → route handler → better-sqlite3 (sync) → JSON. + +Key locations that are non-obvious: +- `public/i18n.js` - `t()`, `formatDate()`, `formatTime()`, `SUPPORTED_LOCALES` +- `public/api.js` - fetch wrapper (handles auth, CSRF, errors) +- `public/router.js` - History API router (no library) + +## Conventions + +- API responses: `{ data: ... }` on success, `{ error: string, code: number }` on failure. +- Dates/times: always `formatDate()`/`formatTime()` from `i18n.js`. Never format manually. +- Pages (`public/pages/*.js`): export `render()`. No side effects on import. +- Web Components (`public/components/*.js`): `oikos-` prefix, one component per file. +- Tests: `test-[module].js` in project root, `--experimental-sqlite` flag. Add `test:[module]` script to `package.json`. + +## Verification + +```bash +npm run dev # Start with --watch +npm test # All test suites (requires Node ≥22) +``` + +## Reference + +| What | Where | +|------|-------| +| Data model, UI specs | `docs/SPEC.md` | +| Design tokens | `public/styles/tokens.css` | +| DB schema (source of truth) | `server/db.js` | +| i18n keys | `public/locales/de.json` | +| Code conventions, commit format | `CONTRIBUTING.md` | diff --git a/docs/claude-md-migration.md b/docs/claude-md-migration.md new file mode 100644 index 0000000..2f56b59 --- /dev/null +++ b/docs/claude-md-migration.md @@ -0,0 +1,48 @@ +# CLAUDE.md Migration Summary + +## Result + +| | Lines | +|---|---| +| Before | 82 | +| After | 50 | +| Reduction | -39% (-32 lines) | + +## What was removed and why + +| Removed | Reason | +|---|---| +| `## Quick Reference` commands block (6 lines) | `npm start`, `npm run dev`, `npm test` are all in `package.json scripts`. Claude reads `package.json` on demand. `docker compose up -d` is a deployment detail, not a development constraint. | +| "These are non-negotiable. Every violation is a bug." intro | Moved to tighter one-liner before the list. | +| Full directory tree (21 lines) | Claude navigates the filesystem directly. Listing every file adds no behavioral value. Only non-obvious locations were kept. | +| "Pages are ES modules" standalone paragraph | Merged into Conventions. | +| Semicolons | Inferrable from reading any source file. | +| Header comment convention | Already documented in `CONTRIBUTING.md`. | +| DB table column pattern (`id`, `created_at`, `updated_at`) | Already in `CONTRIBUTING.md`. | +| Commit format and Changelog instructions (2 lines) | Already in `CONTRIBUTING.md`. Claude can read it when committing. | +| `## Current State` paragraph | Describes finished features - zero behavioral value. Becomes stale immediately. | +| "When to consult" column from Reference table | Padding. Claude decides when to read reference docs based on task context. | + +## What moved to rules files + +None. The remaining content is either universal (applies to every file) or a short pointer. No subsystem-specific rules justify a separate file at this project size. + +## What was kept and why + +| Kept | Why | +|---|---| +| All 8 Hard Constraints | Each prevents a class of wrong code that Claude would otherwise produce. The no-frameworks rule in particular would be violated without an explicit reminder. | +| API response shape `{data}` / `{error, code}` | Not inferrable without reading multiple route files. Applies to every new route. | +| `formatDate()`/`formatTime()` | Without this, Claude formats dates manually (e.g. `new Date().toLocaleDateString()`), producing inconsistent output. | +| `pages/*.js` → `render()`, no side effects | Structural contract not obvious from reading one page file. | +| `oikos-` prefix | Web Component naming convention. | +| Non-obvious file locations (`i18n.js`, `api.js`, `router.js`) | These live at `public/` root, not in a subdirectory. Easy to miss when navigating. | +| Request flow one-liner | Architectural orientation for new tasks. | +| Reference table (trimmed) | On-demand pointers replace inline content for spec details. | + +## Token delta estimate + +At ~4 chars/token average for this content: +- Before: ~1,800 tokens loaded every session +- After: ~1,100 tokens loaded every session +- Savings: ~700 tokens per session diff --git a/docs/docker-compose.portainer.yml b/docs/docker-compose.portainer.yml new file mode 100644 index 0000000..c8a30ab --- /dev/null +++ b/docs/docker-compose.portainer.yml @@ -0,0 +1,64 @@ +# Oikos - Standalone Docker Compose for Portainer / remote deployment +# Pulls the pre-built image from GitHub Container Registry. +# No git clone required. +# +# Usage: +# 1. Copy this file and the .env section below to your server +# 2. Create a .env file next to this compose file (see below) +# 3. docker compose -f docker-compose.portainer.yml up -d +# 4. docker compose -f docker-compose.portainer.yml exec oikos node setup.js +# 5. Open http://:3000 +# +# Required .env variables: +# SESSION_SECRET= +# DB_ENCRYPTION_KEY= +# +# Generate secrets: +# openssl rand -base64 32 + +services: + oikos: + image: ghcr.io/ulsklyc/oikos:latest + container_name: oikos + restart: unless-stopped + ports: + - "3000:3000" + volumes: + - oikos_data:/data + environment: + - NODE_ENV=production + - PORT=3000 + - DB_PATH=/data/oikos.db + - SESSION_SECRET=${SESSION_SECRET:?Set SESSION_SECRET in .env} + - DB_ENCRYPTION_KEY=${DB_ENCRYPTION_KEY:?Set DB_ENCRYPTION_KEY in .env} + # Set to true when behind a reverse proxy with HTTPS + - SESSION_SECURE=${SESSION_SECURE:-false} + # Weather (optional) + - OPENWEATHER_API_KEY=${OPENWEATHER_API_KEY:-} + - OPENWEATHER_CITY=${OPENWEATHER_CITY:-Berlin} + - OPENWEATHER_UNITS=${OPENWEATHER_UNITS:-metric} + - OPENWEATHER_LANG=${OPENWEATHER_LANG:-de} + # Google Calendar (optional) + - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-} + - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-} + - GOOGLE_REDIRECT_URI=${GOOGLE_REDIRECT_URI:-} + # Apple Calendar CalDAV (optional) + - APPLE_CALDAV_URL=${APPLE_CALDAV_URL:-https://caldav.icloud.com} + - APPLE_USERNAME=${APPLE_USERNAME:-} + - APPLE_APP_SPECIFIC_PASSWORD=${APPLE_APP_SPECIFIC_PASSWORD:-} + # Sync interval in minutes + - SYNC_INTERVAL_MINUTES=${SYNC_INTERVAL_MINUTES:-15} + # Rate limiting + - RATE_LIMIT_WINDOW_MS=${RATE_LIMIT_WINDOW_MS:-60000} + - RATE_LIMIT_MAX_ATTEMPTS=${RATE_LIMIT_MAX_ATTEMPTS:-5} + - RATE_LIMIT_BLOCK_DURATION_MS=${RATE_LIMIT_BLOCK_DURATION_MS:-900000} + healthcheck: + test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/health', r => process.exit(r.statusCode === 200 ? 0 : 1))"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + +volumes: + oikos_data: + driver: local diff --git a/server/auth.js b/server/auth.js index b82da3c..520e56b 100644 --- a/server/auth.js +++ b/server/auth.js @@ -4,16 +4,14 @@ * Abhängigkeiten: express, bcrypt, express-session, server/db.js */ -'use strict'; - -const express = require('express'); -const bcrypt = require('bcrypt'); -const session = require('express-session'); -const rateLimit = require('express-rate-limit'); -const db = require('./db'); - -const { generateToken, csrfMiddleware } = require('./middleware/csrf'); -const { createLogger } = require('./logger'); +import express from 'express'; +import bcrypt from 'bcrypt'; +import session from 'express-session'; +import rateLimit from 'express-rate-limit'; +import { randomBytes } from 'node:crypto'; +import * as db from './db.js'; +import { generateToken, csrfMiddleware } from './middleware/csrf.js'; +import { createLogger } from './logger.js'; const log = createLogger('Auth'); const router = express.Router(); @@ -97,7 +95,6 @@ if (!process.env.SESSION_SECRET) { if (process.env.NODE_ENV === 'production') { throw new Error('[Auth] SESSION_SECRET muss in der .env gesetzt sein (Produktion).'); } - const { randomBytes } = require('node:crypto'); process.env.SESSION_SECRET = randomBytes(32).toString('hex'); log.warn('SESSION_SECRET nicht gesetzt - zufaelliges Einmal-Secret generiert (Sessions ueberleben keinen Neustart).'); } @@ -410,4 +407,4 @@ router.delete('/users/:id', requireAuth, requireAdmin, csrfMiddleware, (req, res } }); -module.exports = { router, sessionMiddleware, requireAuth, requireAdmin }; +export { router, sessionMiddleware, requireAuth, requireAdmin }; diff --git a/server/db-schema-test.js b/server/db-schema-test.js index a55f5a0..8c7f008 100644 --- a/server/db-schema-test.js +++ b/server/db-schema-test.js @@ -5,8 +5,6 @@ * Abhängigkeiten: keine */ -'use strict'; - // SQL-String für Migration v1 (gespiegelt aus db.js MIGRATIONS[0].up) // Änderungen in db.js MIGRATIONS müssen hier synchron gehalten werden. const MIGRATIONS_SQL = { @@ -182,4 +180,4 @@ const MIGRATIONS_SQL = { `, }; -module.exports = { MIGRATIONS_SQL }; +export { MIGRATIONS_SQL }; diff --git a/server/db.js b/server/db.js index 7e4cf2a..b72399c 100644 --- a/server/db.js +++ b/server/db.js @@ -9,15 +9,13 @@ * Ohne DB_ENCRYPTION_KEY gesetzt läuft die App mit unverschlüsseltem SQLite (für Entwicklung). */ -'use strict'; - -const Database = require('better-sqlite3'); -const path = require('path'); -const { createLogger } = require('./logger'); +import Database from 'better-sqlite3'; +import path from 'path'; +import { createLogger } from './logger.js'; const log = createLogger('DB'); -const DB_PATH = process.env.DB_PATH || path.join(__dirname, '..', 'oikos.db'); +const DB_PATH = process.env.DB_PATH || path.join(import.meta.dirname, '..', 'oikos.db'); const DB_KEY = process.env.DB_ENCRYPTION_KEY; let db; @@ -32,6 +30,7 @@ let db; * @returns {import('better-sqlite3').Database} */ function init() { + if (db) return db; db = new Database(DB_PATH); if (DB_KEY) { @@ -369,4 +368,6 @@ function transaction(fn) { return get().transaction(fn)(); } -module.exports = { init, get, transaction, currentVersion }; +init(); // auto-initialise when module is first imported + +export { init, get, transaction, currentVersion }; diff --git a/server/logger.js b/server/logger.js index 5973d22..f1ccc29 100644 --- a/server/logger.js +++ b/server/logger.js @@ -5,8 +5,6 @@ * Steuerung: LOG_LEVEL env var (debug, info, warn, error). Default: info. */ -'use strict'; - const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 }; const currentLevel = LEVELS[process.env.LOG_LEVEL] ?? LEVELS.info; const isProduction = process.env.NODE_ENV === 'production'; @@ -37,4 +35,4 @@ function createLogger(mod) { }; } -module.exports = { createLogger }; +export { createLogger };