Files
oikos/docs/SPEC.md
T
2026-04-21 06:52:52 +02:00

20 KiB
Raw Blame History

Oikos - Product Specification

Self-hosted family planner web app for a single household (26 people). No app store, no public access. Deployment via Docker on a private Linux server behind an Nginx reverse proxy with SSL.


Data Model

Every table: id INTEGER PRIMARY KEY, created_at TEXT, updated_at TEXT (ISO 8601).

Users

Column Type Constraint
username TEXT UNIQUE NOT NULL
display_name TEXT
password_hash TEXT bcrypt
avatar_color TEXT HEX color code
role TEXT 'admin' or 'member'

Tasks

Column Type Constraint
title TEXT NOT NULL
description TEXT
category TEXT Household, School, Shopping, Repairs, Other
priority TEXT none (default), low, medium, high, urgent
status TEXT open, in_progress, done
due_date TEXT DATE, nullable
due_time TEXT TIME, nullable
assigned_to INTEGER FK → Users
created_by INTEGER FK → Users, NOT NULL
is_recurring INTEGER 0/1
recurrence_rule TEXT iCal RRULE
parent_task_id INTEGER FK → Tasks (max 2 levels)

Shopping Lists

Column Type Constraint
name TEXT NOT NULL (e.g. "Supermarket", "Hardware store")

Shopping Items

Column Type Constraint
list_id INTEGER FK → Shopping Lists, NOT NULL
name TEXT NOT NULL
quantity TEXT e.g. "500g", "2 pieces"
category TEXT FK → Shopping Categories (by name)
is_checked INTEGER 0/1
added_from_meal INTEGER FK → Meals, nullable

Shopping Categories

Custom, household-wide category list for shopping items. Replaces the old hardcoded category set.

Column Type Constraint
id INTEGER PRIMARY KEY
name TEXT NOT NULL
sort_order INTEGER NOT NULL
created_at TEXT
updated_at TEXT

Meals

Column Type Constraint
date TEXT DATE, NOT NULL
meal_type TEXT breakfast, lunch, dinner, snack
title TEXT NOT NULL
notes TEXT
recipe_url TEXT nullable, URL to recipe
created_by INTEGER FK → Users, NOT NULL

Meal Ingredients

Column Type Constraint
meal_id INTEGER FK → Meals, NOT NULL
name TEXT NOT NULL
quantity TEXT
on_shopping_list INTEGER 0/1

Calendar Events

Column Type Constraint
title TEXT NOT NULL
description TEXT
start_datetime TEXT DATETIME, NOT NULL
end_datetime TEXT DATETIME
all_day INTEGER 0/1
location TEXT
color TEXT HEX
assigned_to INTEGER FK → Users
created_by INTEGER FK → Users, NOT NULL
external_calendar_id TEXT ID from external calendar
external_source TEXT local, google, apple, ics
recurrence_rule TEXT iCal RRULE
subscription_id INTEGER FK → ICS Subscriptions (CASCADE delete)
user_modified INTEGER 0/1 — prevents sync overwrite when 1

Notes

Column Type Constraint
title TEXT nullable
content TEXT NOT NULL
color TEXT HEX
pinned INTEGER 0/1
created_by INTEGER FK → Users, NOT NULL

Contacts

Column Type Constraint
name TEXT NOT NULL
category TEXT Doctor, School/Nursery, Authority, Insurance, Tradesperson, Emergency, Other
phone TEXT
email TEXT
address TEXT
notes TEXT

Budget Entries

Column Type Constraint
title TEXT NOT NULL
amount REAL NOT NULL (positive = income, negative = expense)
category TEXT Groceries, Rent, Insurance, Transport, Leisure, Clothing, Health, Education, Other
date TEXT DATE, NOT NULL
is_recurring INTEGER 0/1
recurrence_rule TEXT iCal RRULE
recurrence_parent_id INTEGER FK → Budget Entries (generated instance points to original)
created_by INTEGER FK → Users, NOT NULL

Budget Recurrence Skipped

Stores instances of a recurring entry deleted by the user so they are not re-generated.

Column Type Constraint
parent_id INTEGER FK → Budget Entries, NOT NULL
month TEXT YYYY-MM, NOT NULL
PRIMARY KEY (parent_id, month)

ICS Subscriptions

External calendar feeds subscribed by users (read-only, auto-synced).

Column Type Constraint
name TEXT NOT NULL
url TEXT NOT NULL (https:// or webcal://)
color TEXT HEX, default #6366f1
shared INTEGER 0/1 — visible to all family members when 1
created_by INTEGER FK → Users (SET NULL on delete)
etag TEXT HTTP ETag for conditional fetch
last_modified TEXT HTTP Last-Modified for conditional fetch
last_sync TEXT ISO timestamp of last successful sync
created_at TEXT ISO timestamp

Sync Config

Key-value table for OAuth tokens and CalDAV credentials.

Column Type Constraint
key TEXT PRIMARY KEY
value TEXT NOT NULL

Modules

Dashboard (/)

Responsive grid: 1 column on mobile, 2 on tablet, 3 on desktop.

Widgets:

  • Greeting: "Good [morning/afternoon/evening], [Name]" + date
  • Weather: OpenWeatherMap proxy, 3-day preview, refresh every 30 min, hide widget on API error
  • Upcoming events: next 35, color-coded by person
  • Urgent tasks: priority urgent/high + due_date ≤48h
  • Today's meals: meals for the current day
  • Pinboard preview: 23 pinned notes
  • FAB (quick actions): + Task, + Event, + Shopping list item, + Note

Skeleton loading instead of spinners. Clicking any widget navigates to that module.

Tasks (/tasks)

Views:

  • List view (default): grouped by category or due date (toggleable), filter: person, priority, status
  • Kanban: columns Open → In Progress → Done, drag & drop
  • View mode persisted in localStorage; URL parameter ?view=kanban overrides (useful for tablet kiosk setups)

Features:

  • CRUD + subtasks (max 2 levels, checkbox list, progress bar)
  • Assignment to users (avatar color as indicator)
  • Priorities shown visually via color/icon
  • Recurring: automatically create next instance on completion
  • Mobile swipe: left = done, right = edit
  • Badge for overdue tasks

Shopping Lists (/shopping)

  • Multiple lists in parallel
  • Items: name, category, quantity, checkbox
  • Grouping by category (aisle logic)
  • Integration with meal plan: "Add ingredients to shopping list" transfers with source reference
  • Checked items shown with strikethrough + moved to bottom
  • "Clear list" = remove checked items only
  • Autocomplete from previous entries (local)
  • Mobile swipe: left = check/uncheck, right = delete; × delete button hidden on mobile (swipe takes over)

Meal Plan (/meals)

Weekly view (MonSun), slots: breakfast / lunch / dinner / snack.

  • Meal: title + notes + ingredient list
  • "→ Shopping list" button: transfer unchecked ingredients of the week to a selected list
  • Week navigation forward/back
  • Drag & drop between days/slots
  • Autocomplete from meal history
  • Customizable meal visibility: In Settings, users can toggle which meal types (breakfast, lunch, dinner, snack) are shown in the planner. Stored as household-wide preference in sync_config (key: visible_meal_types). At least one type must remain active.

Calendar (/calendar)

Views: Month (default, dot indicators), Week (hour grid), Day (timeline), Agenda (list).

  • CRUD: title, description, start/end, all-day, location, color, assignment
  • Color-coding per person
  • Recurring via iCal RRULE
  • Google Calendar: OAuth 2.0, Calendar API v3, two-way sync
  • Apple Calendar: CalDAV (tsdav), two-way sync
  • ICS Subscriptions: Subscribe to any public ICS/webcal URL (e.g. public holidays, sports schedules). Per-subscription color, private/shared visibility, manual "Sync now" and automatic sync on the shared interval. RRULE events expanded into a rolling ±6/+12 month window. SSRF-protected (DNS pre-resolution), ETag/Last-Modified conditional fetch, 10 MB limit, 15 s timeout. User-edited events are protected from being overwritten (user_modified); a "Reset to original" link restores them.
  • Configurable sync interval (default 15 min)
  • External events visually distinguishable
  • Conflicts: external event wins, local additions are preserved

Notes (/notes)

Masonry grid with colored sticky notes.

  • CRUD: title (optional), content, color
  • Pin → appears at top + on dashboard
  • Creator shown (avatar color)
  • Markdown-light: bold, italic, lists (regex-based)
  • Full-text search: client-side filter bar, filters instantly by title + content

Contacts (/contacts)

  • CRUD with category filter
  • Phone: tel: link, email: mailto: link
  • Address: Maps link (Google/Apple via user agent)
  • Real-time search filter
  • vCard export: each contact downloadable as .vcf (GET /api/v1/contacts/:id/vcard)
  • vCard import: upload file → client-side parser (FN, TEL, EMAIL, ADR, NOTE, CATEGORIES) → create contact

Login (/login)

Unauthenticated users are redirected here. No public registration form - admin creates users via setup wizard (setup.js) or Settings.

  • Username + password form
  • Error display for wrong credentials
  • Rate limiting: 5 attempts/min/IP, 15-min lockout
  • After successful login: redirect to dashboard

Settings (/settings)

User management and app configuration. Logged-in users only.

  • Profile: change display name, avatar color, password
  • User management (admin): create new users, edit/delete existing users, assign roles (admin/member)
  • Calendar integration: connect/disconnect Google Calendar OAuth, store Apple Calendar (CalDAV) credentials, configure sync interval; manage ICS URL subscriptions (add, delete, sync now, set color and visibility)
  • Weather: configure OpenWeatherMap location
  • Language: System (follows navigator.language), German, English, Spanish, French, Italian, Swedish, Greek, Russian, Turkish, Chinese - via oikos-locale-picker web component; switch without page reload
  • Tab navigation: Settings is organized in six tabs (General, Meals, Budget, Shopping, Calendar, Account). Sticky tab bar, active tab persists in sessionStorage, Calendar tab auto-activates after OAuth callbacks.
  • App info: version, license

Budget (/budget)

Views:

  • Monthly overview: income vs. expenses, balance, bar chart by category (Canvas, no library)

  • Transaction list: chronological, filterable

  • CRUD: title, amount, category, date

  • Recurring entries

  • Monthly comparison (current vs. previous month)

  • CSV export


Design System

Colors (CSS Custom Properties)

Source of truth: public/styles/tokens.css. Key values:

:root {
  --color-bg: #F5F5F7;
  --color-surface: #FFFFFF;
  --color-border: #E5E5EA;
  --color-text-primary: #1C1C1E;
  --color-text-secondary: #8E8E93;
  --color-text-tertiary: #6B6B68;      /* WCAG AA on --color-bg */
  --color-accent: #2563EB;
  --color-accent-deep: #1E5CB3;
  --color-accent-light: #EEF2FF;
  --color-success: #16A34A;
  --color-warning: #D97706;
  --color-danger: #DC2626;
  --color-info: #0969DA;               /* WCAG AA on white */
  /* Glass layer tokens (Section 16): */
  --glass-bg: rgba(255,255,255,0.72);
  --glass-border: rgba(255,255,255,0.55);
  --blur-md: 16px;
  --radius-glass-button: 9999px;       /* capsule */
  --ease-glass: cubic-bezier(0.34, 1.56, 0.64, 1); /* spring */

  /* Glass Vibrancy tokens (Phase 4): */
  --glass-bg-card: rgba(255,255,255,0.52);     /* transparent for vibrancy */
  --glass-bg-card-hover: rgba(255,255,255,0.65);
  --glass-bg-input: rgba(255,255,255,0.48);
  --glass-bg-toolbar: rgba(255,255,255,0.58);
  --glass-tint-strength: 6%;                   /* module accent tint */
}

@media (prefers-color-scheme: dark) {
  :root {
    --color-bg: #111110;
    --color-surface: #1C1C1A;
    --color-border: #2C2C2A;
    --color-text-primary: #F5F5F3;
    --color-text-secondary: #8E8E8C;
    --glass-bg: rgba(28,28,26,0.75);
    --glass-border: rgba(255,255,255,0.12);
    --glass-bg-card: rgba(38,38,36,0.50);
    --glass-tint-strength: 8%;
  }
}

Typography

  • System font stack, headings 600700
  • Body: 16px mobile, 15px desktop, line-height 1.5
  • Caption: 13px, var(--color-text-secondary)

Glass Layer (public/styles/glass.css)

Additive CSS file loaded globally after layout.css. Implements a Liquid Glass design language inspired by Apple's iOS 26 Liquid Glass, adapted for CSS/web:

Phase 1-3 (Shell + Components + Polish):

  • Translucent surfaces: backdrop-filter: blur() on bottom nav, sidebar, modal overlay, cards on hover. All blur effects are inside @supports (backdrop-filter: blur(1px)) for progressive enhancement.
  • Glass tokens: Section 16 of tokens.css defines --glass-bg*, --glass-border*, --blur-xs through --blur-xl, --opacity-glass-*, --glass-highlight*, --glass-shadow-sm/md/lg, --radius-glass-card/inner/chip/button, --ease-glass, --transition-glass. Full dark mode overrides.
  • Capsule shapes: Buttons, FAB, and search inputs use --radius-glass-button (pill shape).
  • Spring animations: Modal entrance (glass-modal-scale-in / glass-sheet-in), page transitions, and list stagger all use cubic-bezier(0.34, 1.56, 0.64, 1) spring easing.
  • FAB attention pulse: fab-ring-pulse keyframe expands a ring around the FAB to signal readiness.
  • Nav auto-hide: Bottom bar hides on scroll-down, reappears on scroll-up (mobile only, < 1024px, 4 px hysteresis). CSS: .nav-bottom--hidden { transform: translateY(calc(100% + var(--safe-area-inset-bottom))); }. JS: initNavHideOnScroll() in router.js.

Phase 4 (Vibrancy + Tint):

  • Deeper glass penetration: Dashboard widgets, task cards, note items, meal slots, form inputs, toolbars, group toggles, and FAB speed-dial actions all use semi-transparent glass backgrounds (--glass-bg-card, 52% opacity) with backdrop-filter: blur() saturate() so underlying content shines through.
  • Module tint: Each glass surface receives a subtle accent color gradient overlay via ::after pseudo-element using color-mix(in srgb, var(--module-accent) var(--glass-tint-strength), transparent). Strength is 6% in light mode, 8% in dark mode.
  • App vibrancy background: app-content uses a radial gradient with the active module accent at 3% opacity to provide an ambient color base that glass elements refract.
  • Load-order safety: All Phase 4 glass selectors use parent-scoped specificity (.dashboard .widget, .tasks-page .task-card, .meals-page .meal-slot) to prevent override by on-demand page CSS that loads after glass.css.

Accessibility: prefers-reduced-transparency, prefers-reduced-motion, and prefers-contrast: more blocks deactivate blur/animation and restore solid fallbacks across all phases.

Components

  • Cards: var(--color-surface) base, glass vibrancy via var(--glass-bg-card) + backdrop-filter: blur(8px) saturate(180%) when supported. var(--radius-md), var(--shadow-sm). Module tint overlay via ::after. Consistent padding var(--space-4) (16px) across all modules.
  • Buttons: Primary = accent + white. Secondary = outline. Min-height 44px. Capsule shape via --radius-glass-button. Submit buttons show success (checkmark, 700ms green via .btn--success) and error (shake via .btn--shaking).
  • Inputs: var(--radius-sm), 1.5px border, padding 12px 16px. Search inputs use --radius-glass-button and --glass-border-subtle. [required] fields receive validation status on blur (.form-field--error / .form-field--valid). Enter moves focus to the next field; Enter on the last field triggers submit.
  • FAB (Floating Action Button): Color follows the module accent token (--module-accent) - each module defines its own accent color. Specular inner highlight + attention ring pulse. Hidden when the virtual keyboard is open (visualViewport.resize, threshold 75% of window height).
  • Module accent colors: --module-accent is applied on three visual layers - (1) active nav tab (bottom bar + sidebar stripe), (2) toolbar border-top: 3px, (3) cards/rows border-left: 3px. The active accent is written to --active-module-accent on :root on every navigation change. Falls back to --color-accent for pages without a module context.
  • Navigation: Bottom tab bar on mobile (Dashboard, Tasks, Calendar, Meals, More), auto-hides on scroll-down. Sidebar on desktop. Both use glass blur surface.
  • Transitions: Directional slide-X animation on page change (forward = from right, back = from left, 200ms) with spring easing. Respects prefers-reduced-motion.
  • Empty states: Consistent .empty-state class across all modules (icon + title + description, centered). Compact variant .empty-state--compact for meal slots.
  • Modals: Centered panel on desktop with glass overlay. On mobile (< 768px) bottom sheet - spring slide-in from below, sheet handle visible, swipe-to-close (> 80px downward). focusin scrolls inputs into view when the virtual keyboard is open.
  • List animation: Staggered spring fade-in on load (stagger() from public/utils/ux.js) - max 5 elements staggered (30ms gap), rest appear immediately.
  • Vibration: vibrate() from public/utils/ux.js - short pulses for light actions (10-40ms), pattern [30, 50, 30] for destructive actions (delete). Respects prefers-reduced-motion.
  • PWA install prompt: Appears only after 2 user interactions. Dismiss window 7 days; interaction counter resets after dismiss.
  • PWA offline fallback: Service worker serves /offline.html when the network is unreachable and index.html is not cached. Includes a reload button.

Breakpoints

  • Mobile: < 768px (1 column, bottom nav)
  • Tablet: 7681024px (2 columns, bottom nav)
  • Desktop: > 1024px (sidebar + content)

Internationalization (i18n)

All UI strings are managed via public/i18n.js. No hardcoded text in JS files outside of locale files.

Architecture

  • Module: public/i18n.js - exports: initI18n(), setLocale(), t(key, params?), getLocale(), getSupportedLocales(), formatDate(date), formatTime(date)
  • Locale files: public/locales/de.json (reference), public/locales/en.json, public/locales/es.json, public/locales/fr.json, public/locales/it.json, public/locales/sv.json, public/locales/el.json, public/locales/ru.json, public/locales/tr.json, public/locales/zh.json, public/locales/ja.json, public/locales/ar.json, public/locales/hi.json, public/locales/pt.json - structure: { "module.camelCaseKey": "Value" }
  • Variables: {{variable}} syntax in translation strings, e.g. t('tasks.assignedTo', { name: 'Anna' })
  • Fallback chain: active locale → German (de) → key itself
  • Date format: Intl.DateTimeFormat with current locale - use formatDate() and formatTime() from i18n.js

Language Detection

  1. localStorage entry oikos-locale (manual selection)
  2. navigator.languages[0] (browser language)
  3. Fallback: en

Supported Languages

Code Language Status
de German Reference locale (all keys defined here)
en English Full translation
es Spanish Full translation
fr French Full translation (added v0.16.3)
it Italian Full translation (added v0.5.8)
sv Swedish Full translation (added v0.11.3)
el Greek Full translation (added v0.16.3)
ru Russian Full translation (added v0.16.3)
tr Turkish Full translation (added v0.16.3)
zh Chinese (Simplified) Full translation (added v0.16.3)
ja Japanese Full translation (added v0.19.0)
ar Arabic Full translation (added v0.19.0)
hi Hindi Full translation (added v0.19.0)
pt Portuguese Full translation (added v0.19.0)

Adding a New Language

  1. Create public/locales/xx.json (copy of de.json, translate)
  2. Add 'xx' to SUPPORTED_LOCALES in public/i18n.js
  3. Add label in oikos-locale-picker (LOCALE_LABELS['xx'] = 'Name')

Locale Switching

setLocale(locale) saves the selection, loads the new locale file, and fires the locale-changed custom event. All page modules and web components listen to this event and re-render - no page reload required.