Compare commits

30 Commits
Author SHA1 Message Date
Moritz Graf fc6a32697f fix(garmin): format sync payload with canonical list keys and verify synced counts 2026-10-18 20:31:45 +02:00
Moritz Graf 9f2c2c33d3 fix(datastore): increase backend probe timeout to 3s and rebase before push 2026-10-17 07:39:40 +02:00
Moritz Graf 6b3fbfdbb5 feat(datastore): migrate to single-writer DuckDB backend HTTP proxy and remove legacy HTML renderers 2026-10-16 23:33:02 +02:00
Moritz Graf 8bdd06e598 feat(skills): promote untracked pod skills into mop-skills 2026-10-16 21:10:26 +02:00
Moritz Graf 4f72e4d71b chore(datastore): update Mila live dashboard link to family.haumdaucher.de/tracking/mila/ 2026-10-14 21:39:49 +02:00
Moritz Graf 7b5d039323 feat: consolidate datastore into sleep, nutrition, mila databases 2026-10-13 09:28:07 +02:00
Moritz Graf fe4edcc217 fix(pwa): serve Mila app icons from public /icons/ path for iOS SpringBoard and PWA manifests 2026-10-12 08:48:55 +02:00
Moritz Graf 3e9e12bbc5 fix(pwa): version icon filenames to -v3 and bust iOS Safari WebClip cache 2026-10-12 08:36:02 +02:00
Moritz Graf 7b9b3ff0eb feat(growth): add WHO percentile growth curves, Chart.js collapsible section, and tests 2026-10-12 08:32:38 +02:00
Moritz Graf 191c8e9143 feat(pwa): optimize Mila app icons with safe-zone padding to prevent Android/iOS zoom crop 2026-10-12 08:25:27 +02:00
Moritz Graf f5b5deaae7 fix(pwa): enable crossorigin=use-credentials for manifest, add id and header install trigger 2026-10-12 08:16:03 +02:00
Moritz Graf 545752fa60 feat(mila): update app icon, favicon, and header artwork to pop-art baby 2026-10-12 07:36:23 +02:00
Moritz Graf e9d2c59eed feat(pwa): add auto-update on reopen, live tickers, and manual refresh to dashboards 2026-10-11 22:25:37 +02:00
Moritz Graf 63497765b0 fix(datastore): include morning-ending night sleep in dashboard, pass data-dir, and auto git-sync on writes 2026-10-11 22:14:17 +02:00
Moritz Graf 88c42aa1e5 fix(datastore): support retroactive date logging and fix Garmin balance display logic 2026-10-11 21:59:44 +02:00
Moritz Graf bcb04711c0 feat(security): enforce ADR 0005 hosting boundary segregation strictly to family/ 2026-10-11 21:24:06 +02:00
Moritz Graf 694d0c5072 feat(pwa): make Mila app installable with PWA manifest, service worker and high-res icons 2026-10-11 20:57:50 +02:00
Moritz Graf 1ccab5e522 feat(datastore): expose delete command in CLI 2026-10-11 20:48:07 +02:00
Moritz Graf fe21d01a3f fix(datastore): fix item_count column name in update_moritz_website.py 2026-10-11 20:39:35 +02:00
Moritz Graf 1babaab1e2 feat(datastore): add Moritz Health light-mode PWA dashboard generator and hook 2026-10-11 20:37:18 +02:00
Moritz Graf 3abfa18182 feat(datastore): support --since in stop command, trigger website update from render_views, update docs 2026-10-11 15:07:35 +02:00
Moritz Graf 967909c3a7 feat(datastore): add diaper and feeding tracking, automated update_website posthook, and morning_briefing 2026-10-11 12:16:24 +02:00
Moritz Graf 80e38ab64a fix(datastore): add PEP 723 dependencies to garmin_sync.py for automated uv execution 2026-10-10 15:48:55 +02:00
Moritz Graf 786ce47bd4 fix(datastore): alert on open sessions without silent deckeln, support stop by ID 2026-10-09 19:14:01 +02:00
Moritz Graf 88d49f85e5 fix(datastore): auto-close stale sleep sessions and validate event kind in CLI 2026-10-09 18:46:39 +02:00
Moritz Graf c9cbc56441 docs(google-calendar): document OpenClaw calendar__ prefix, CLI fallback, and no-fake guardrails 2026-10-09 12:29:43 +02:00
Moritz Graf 6a93539a97 feat(datastore): support --kind flag and add 15m duplicate suppression 2026-10-09 10:28:11 +02:00
Moritz Graf 77a0e56d96 feat(datastore): add relative --since duration flag and overnight sleep test 2026-10-09 09:25:04 +02:00
Moritz Graf 0489ef7d62 feat(skills): add datastore skill (DuckDB engine, JSONL partitioning, Garmin mirror, and view renderers) 2026-10-06 18:17:52 +02:00
Moritz Graf 2bde04617f feat(skills): add create-website skill for zero-build responsive SPAs and showcases 2026-09-28 07:37:36 +02:00
38 changed files with 5510 additions and 40 deletions
+13
View File
@@ -84,6 +84,12 @@ Instant chart and diagram generation for chat and Telegram.
- **Delivery:** Generates local PNGs and outputs `MEDIA:<path>` for native Telegram image rendering.
- **Tools:** Standalone CLI at `scripts/chart.py`.
### [create-website](skills/create-website/SKILL.md)
Creative Web Director and Senior Frontend Engineer for single-page applications and interactive showcases.
- **Features:** Builds fancy, responsive, zero-build SPAs (Tailwind CSS Play CDN, Alpine.js reactivity, Lucide icons, Google Fonts) with anti-AI-slop design archetypes (Dark Obsidian, Editorial Warmth, Neo-Brutalism, Modern Clean SaaS).
- **Hosting:** Direct validation and upload to `https://hosting.haumdaucher.de/showcase/...` via the `hosting` skill pipeline, with native Telegram document delivery and QR code generation.
- **Tools:** Standalone validation & publisher CLI at `scripts/publish_website.py`.
### [grill-me](skills/grill-me/SKILL.md)
Relentless, structured interview to stress-test and sharpen plans, architectures, decisions, or ideas before writing code.
- **Features:** Decision tree & frontier exploration, Telegram/mobile-optimized batching (1-3 questions per round), facts-first environment research, anti-sycophancy challenge, and final specification/ADR synthesis.
@@ -95,6 +101,13 @@ Builds, previews, validates and publishes Zwift `.zwo` workout files (bike-only)
- **Tools:** CLI at `scripts/zwo.py` (`build`, `from-template`, `validate`, `parse`, `render`, `templates`) + builder at `scripts/zwo_builder.py`.
- **Integration:** Fitness agent / Tacx Flux 2; synergistic with `garmin-workout` (one spec, two targets).
### [datastore](skills/datastore/SKILL.md)
Structured personal data layer for OpenClaw powered by in-memory DuckDB and git-backed JSONL in `knowledge-wiki/data/`.
- **Purpose:** Eliminates LLM arithmetic errors across nutrition intake, baby sleep tracking, and daily caloric balances (ADR 0004).
- **Features:** Unified time-series events (`nutrition.intake`, `sleep.session` with open intervals `end=NULL`, `feeding.bottle`), entity catalogs (`foods`, `recipes`, `places`, `people`), dual-engine geospatial radius queries (DuckDB spatial + Haversine fallback), and nightly Garmin Connect mirror.
- **Presentation:** Automatically renders Markdown logs in `entities/` and responsive HTML dashboards on `hosting.haumdaucher.de/family/`.
- **Tools:** Standalone MCP server and CLI at `scripts/datastore.py`, `scripts/garmin_sync.py`, and `scripts/render_views.py`.
### [skill-creator](skills/skill-creator/SKILL.md)
Guidelines and scripts to help developers build high-quality, token-efficient Claude skills.
+150
View File
@@ -0,0 +1,150 @@
---
name: create-website
description: Build and host fancy, production-grade, interactive single-page websites, SPAs, and HTML showcases using modern Zero-Build technologies (Tailwind CSS, Alpine.js, Lucide Icons). Use when the user asks to create, build, design, or show a website, landing page, interactive calculator, showcase, recipe app, dashboard, or web tool.
---
# Create Website & Interactive Single-Page Applications 🌐✨
This skill empowers OpenClaw to act as a **Creative Web Director and Senior Frontend Engineer**. It generates visually stunning, responsive, fully interactive single-page applications (SPAs) contained within a single HTML file—without requiring complex build steps or Node.js compilers.
---
## ⚡ Core Philosophy: The Zero-Build Reactive Engine
1. **Self-Contained Single-File**: HTML markup, Tailwind CSS utility styling, Alpine.js reactivity, and Lucide icons reside in a single standalone file.
2. **Zero-Build Latency**: Runs directly in the browser via fast CDNs. Zero compile time, 100% reliable execution across desktop, tablet, and mobile (iOS Safari, Telegram in-app browser).
3. **Anti-AI-Slop Aesthetics**: Reject generic AI defaults (no plain Inter font, no purple-to-blue gradients, no boring centered 3-card monotony). Every site must have an intentional aesthetic direction with strong typography, depth, and micro-interactions.
---
## 🎨 1. Choose a Visual Archetype
Before writing code, commit strictly to one of the 4 design archetypes (detailed in [references/archetypes.md](references/archetypes.md)):
* **Dark Obsidian & Cyber-Luxury**: Deep dark canvas (`#090A0F`), glassmorphism cards (`backdrop-blur-xl border border-white/10`), electric cyan/violet/emerald glows. Fonts: *Syne* + *Plus Jakarta Sans*. (Ideal for tech showcases, tools, AI, fitness trackers).
* **Editorial Warmth & Swiss Typographic**: Linen/ivory background (`#FDFBF7`), hairline borders (`border-stone-200`), warm terracotta/forest green accents. Fonts: *Fraunces* (Serif) + *Instrument Sans*. (Ideal for research dossiers, recipes, long-form stories).
* **Neo-Brutalism & High-Energy**: High contrast, thick black borders (`border-2 border-black`), offset hard drop shadows (`shadow-[4px_4px_0px_0px_#000]`), vivid canary yellow and lime accents. Fonts: *Space Grotesk* + *JetBrains Mono*. (Ideal for events, party invites, challenges, games).
* **Modern Clean SaaS & Dynamic Dashboard**: Crisp slate canvas, automatic dark/light mode toggle via Alpine, KPI cards, filterable tables. Fonts: *Outfit* + *Plus Jakarta Sans*. (Ideal for data apps, comparisons, calculators).
---
## 🛠️ 2. The Standard CDN Skeleton
Use this bulletproof template structure for all generated websites:
```html
<!DOCTYPE html>
<html lang="de" x-data="{ darkMode: localStorage.getItem('theme') === 'dark' }" :class="{ 'dark': darkMode }">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Titel der Website</title>
<!-- 1. Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700&family=Syne:wght@600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<!-- 2. Tailwind CSS v3 Play CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
fontFamily: {
heading: ['Syne', 'sans-serif'],
sans: ['Plus Jakarta Sans', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
}
}
}
}
</script>
<!-- 3. Alpine.js for snappy reactivity -->
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<!-- 4. Lucide Icons CDN -->
<script src="https://unpkg.com/lucide@latest"></script>
<!-- 5. Optional Confetti for Micro-Delight -->
<script src="https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.2/dist/confetti.browser.min.js"></script>
</head>
<body class="bg-[#090A0F] text-zinc-100 font-sans min-h-screen flex flex-col antialiased selection:bg-cyan-500/30 selection:text-cyan-200">
<!-- Main Application Content (Tabs, Filters, Interactive Cards, Calculators) -->
<main class="flex-1 max-w-6xl w-full mx-auto px-4 py-8 md:py-16">
<!-- Component Code Here -->
</main>
<!-- 6. Lucide Icons Initialization (Always at the end of body) -->
<script>
document.addEventListener('DOMContentLoaded', () => {
lucide.createIcons();
});
</script>
</body>
</html>
```
---
## ⚡ 3. Add Interactivity with Alpine.js
Review [references/alpine-patterns.md](references/alpine-patterns.md) for pre-built patterns:
* **Dynamic Tabs**: Seamless view switching without page reloads.
* **Instant Search & Filter**: Real-time filtering of cards/recipes/items based on text input or category pills.
* **Reactive Calculators / Sliders**: Live computation (portions, prices, nutrition, splits) with instant UI updates.
* **Modal Dialogs & Drawers**: Smooth transitions with backdrop blur and escape key handling.
* **Celebration Effects**: Triggering confetti on successful calculations or milestones (`confetti({ particleCount: 80, spread: 70 })`).
---
## 🚀 4. Publishing & Sharing Workflow
Once the website HTML has been generated and saved locally (e.g. to `/tmp/websites/<slug>.html`), publish it to public hosting:
### Step 1: Run the Publisher Script
```bash
uv run /home/node/mop-skills/mop-skills/skills/create-website/scripts/publish_website.py \
--file /tmp/websites/<slug>.html \
--category showcase \
--slug <slug>
```
This validates the HTML and uploads it to MinIO, returning the public HTTPS URL:
`https://hosting.haumdaucher.de/showcase/{YYYYMMDD}-{slug}.html`
### Step 2: Present to User in Telegram
Always deliver both the clickable link and the local file for offline access:
```markdown
🚀 **Deine Website ist live!**
🔗 https://hosting.haumdaucher.de/showcase/20260928-ninja-creami-calc.html
*(Die Datei ist zum lokalen Öffnen ebenfalls beigefügt)*
MEDIA:/tmp/websites/<slug>.html
```
### Step 3: Offer QR Code
Immediately trigger `ask_user` to offer generating a scannable QR code:
```json
{
"question": "Möchtest du für diesen Link einen QR-Code generieren?",
"options": ["Ja, QR-Code erstellen", "Nein, danke"]
}
```
If approved, execute the `qr-code` skill:
```bash
uv run /home/node/mop-skills/mop-skills/skills/qr-code/scripts/generate_qr.py "https://hosting.haumdaucher.de/showcase/20260928-ninja-creami-calc.html"
```
and send via `MEDIA:<path>`.
---
## 🛡️ Quality Gate & Anti-Slop Checklist
Always check the site against [references/anti-slop-checklist.md](references/anti-slop-checklist.md) before publishing:
1. Are font pairings distinct and imported via Google Fonts?
2. Are all buttons responsive with hover and active states (`active:scale-95`)?
3. Does the layout scale gracefully on small mobile screens (`grid-cols-1 md:grid-cols-2 lg:grid-cols-3`)?
4. Are Lucide icons properly rendered via `lucide.createIcons()`?
@@ -0,0 +1,210 @@
# Alpine.js Core Patterns for Single-Page Applications ⚡
These zero-build Alpine.js patterns provide reactive state management directly in HTML. Use these snippets to make your single-page websites snappy, interactive, and responsive.
---
## 1. Dark/Light Mode Switcher with Persistence
Place this in `<html x-data="{ darkMode: localStorage.getItem('theme') === 'dark' }" :class="{ 'dark': darkMode }">`:
```html
<button
@click="darkMode = !darkMode; localStorage.setItem('theme', darkMode ? 'dark' : 'light')"
class="p-2.5 rounded-xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-all cursor-pointer"
title="Toggle Theme">
<i x-show="!darkMode" data-lucide="moon" class="w-5 h-5"></i>
<i x-show="darkMode" data-lucide="sun" class="w-5 h-5"></i>
</button>
```
---
## 2. Dynamic Tab Navigation
Switch between multiple views without page reloads:
```html
<div x-data="{ activeTab: 'overview' }" class="w-full">
<!-- Tab Bar -->
<div class="flex gap-2 p-1.5 bg-zinc-100 dark:bg-zinc-900 rounded-xl max-w-md mx-auto mb-8 border border-zinc-200 dark:border-zinc-800">
<button
@click="activeTab = 'overview'"
:class="activeTab === 'overview' ? 'bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white shadow-sm' : 'text-zinc-500 hover:text-zinc-900 dark:hover:text-white'"
class="flex-1 py-2 px-4 rounded-lg font-medium text-sm transition-all flex items-center justify-center gap-2">
<i data-lucide="layout-grid" class="w-4 h-4"></i> Overview
</button>
<button
@click="activeTab = 'calculator'"
:class="activeTab === 'calculator' ? 'bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white shadow-sm' : 'text-zinc-500 hover:text-zinc-900 dark:hover:text-white'"
class="flex-1 py-2 px-4 rounded-lg font-medium text-sm transition-all flex items-center justify-center gap-2">
<i data-lucide="calculator" class="w-4 h-4"></i> Calculator
</button>
</div>
<!-- Tab Contents with Transitions -->
<div x-show="activeTab === 'overview'" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 translate-y-2" x-transition:enter-end="opacity-100 translate-y-0">
<!-- Overview View Content -->
</div>
<div x-show="activeTab === 'calculator'" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 translate-y-2" x-transition:enter-end="opacity-100 translate-y-0">
<!-- Calculator View Content -->
</div>
</div>
```
---
## 3. Instant Search & Category Filter
Real-time filtering of cards or list items:
```html
<div x-data="{
search: '',
activeCategory: 'all',
items: [
{ title: 'Pistazien Gelato', category: 'creami', protein: '28g', calories: 340, desc: 'Reichhaltiges Gelato mit echter sizilianischer Pistazienpaste.' },
{ title: 'Protein Erdbeer Sorbet', category: 'protein', protein: '35g', calories: 210, desc: 'Frisches Erdbeersorbet mit Clear Whey Isolat.' },
{ title: 'Mango Maracuja Slush', category: 'fruit', protein: '2g', calories: 140, desc: 'Perfekte Sommererfrischung mit Mango-Chunks.' }
],
get filteredItems() {
return this.items.filter(item => {
const matchesSearch = item.title.toLowerCase().includes(this.search.toLowerCase()) || item.desc.toLowerCase().includes(this.search.toLowerCase());
const matchesCategory = this.activeCategory === 'all' || item.category === this.activeCategory;
return matchesSearch && matchesCategory;
});
}
}">
<!-- Search & Pills -->
<div class="flex flex-col sm:flex-row gap-4 mb-8">
<div class="relative flex-1">
<i data-lucide="search" class="w-4 h-4 absolute left-3.5 top-3.5 text-zinc-400"></i>
<input
x-model="search"
type="text"
placeholder="Rezepte durchsuchen..."
class="w-full pl-10 pr-4 py-2.5 rounded-xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-cyan-500 text-sm">
</div>
<div class="flex gap-2 overflow-x-auto pb-1">
<template x-for="cat in ['all', 'creami', 'protein', 'fruit']" :key="cat">
<button
@click="activeCategory = cat"
:class="activeCategory === cat ? 'bg-cyan-500 text-white font-semibold' : 'bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-200'"
class="px-4 py-2 rounded-xl text-xs uppercase tracking-wider capitalize whitespace-nowrap transition-all cursor-pointer"
x-text="cat === 'all' ? 'Alle Rezepte' : cat">
</button>
</template>
</div>
</div>
<!-- Cards Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<template x-for="item in filteredItems" :key="item.title">
<div class="p-6 rounded-2xl bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 hover:border-cyan-500/50 shadow-sm hover:shadow-xl transition-all">
<h3 class="text-lg font-bold text-zinc-900 dark:text-white mb-2" x-text="item.title"></h3>
<p class="text-sm text-zinc-500 dark:text-zinc-400 mb-4" x-text="item.desc"></p>
<div class="flex justify-between items-center text-xs font-semibold text-cyan-600 dark:text-cyan-400">
<span x-text="item.protein + ' Protein'"></span>
<span x-text="item.calories + ' kcal'"></span>
</div>
</div>
</template>
</div>
</div>
```
---
## 4. Live Reactive Slider & Calculator
Instant calculations with automatic UI updates:
```html
<div x-data="{
pints: 1,
baseMilk: 300,
baseWhey: 35,
basePistachio: 30,
get milk() { return this.baseMilk * this.pints; },
get whey() { return this.baseWhey * this.pints; },
get pistachio() { return this.basePistachio * this.pints; },
get proteinTotal() { return Math.round(this.whey * 0.8 + (this.milk * 0.034)); },
celebrate() {
if (typeof confetti === 'function') {
confetti({ particleCount: 75, spread: 60, origin: { y: 0.8 } });
}
}
}" class="p-6 rounded-2xl bg-zinc-900 border border-white/10 text-white">
<div class="flex justify-between items-center mb-6">
<h3 class="text-xl font-bold font-heading">Portions-Rechner</h3>
<span class="px-3 py-1 bg-cyan-500/20 text-cyan-400 rounded-full text-xs font-semibold" x-text="pints + (pints === 1 ? ' Pint' : ' Pints')"></span>
</div>
<div class="mb-6">
<label class="block text-xs uppercase tracking-wider text-zinc-400 mb-2">Anzahl Ninja CREAMi Deluxe Pints (680ml)</label>
<input type="range" min="1" max="4" step="1" x-model.number="pints" class="w-full accent-cyan-400 cursor-pointer">
</div>
<!-- Computed Ingredients -->
<div class="space-y-3 mb-6">
<div class="flex justify-between py-2 border-b border-white/5 text-sm">
<span class="text-zinc-400">Milch 1.5% oder Hafermilch</span>
<span class="font-bold text-white" x-text="milk + ' ml'"></span>
</div>
<div class="flex justify-between py-2 border-b border-white/5 text-sm">
<span class="text-zinc-400">Proteinpulver (Whey/Casein)</span>
<span class="font-bold text-cyan-400" x-text="whey + ' g'"></span>
</div>
<div class="flex justify-between py-2 border-b border-white/5 text-sm">
<span class="text-zinc-400">Pistazienmus (100%)</span>
<span class="font-bold text-emerald-400" x-text="pistachio + ' g'"></span>
</div>
</div>
<button @click="celebrate()" class="w-full py-3 bg-gradient-to-r from-cyan-500 to-blue-600 rounded-xl font-bold text-sm hover:brightness-110 active:scale-95 transition-all">
Zutaten vorbereitet! 🎉
</button>
</div>
```
---
## 5. Modal Dialog with Escape & Backdrop Blur
Accessible modal overlay:
```html
<div x-data="{ open: false }">
<button @click="open = true" class="px-4 py-2 bg-zinc-900 text-white rounded-xl text-sm font-medium">
Details öffnen
</button>
<div
x-show="open"
@keydown.escape.window="open = false"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
style="display: none;">
<div
@click.outside="open = false"
class="bg-white dark:bg-zinc-900 rounded-2xl max-w-lg w-full p-6 border border-zinc-200 dark:border-zinc-800 shadow-2xl"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-bold text-zinc-900 dark:text-white">Modal Titel</h3>
<button @click="open = false" class="text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<p class="text-sm text-zinc-600 dark:text-zinc-400 mb-6">
Hier befindet sich detaillierter Inhalt mit Reaktivität oder Formularen.
</p>
<div class="flex justify-end gap-3">
<button @click="open = false" class="px-4 py-2 text-sm text-zinc-500 hover:text-zinc-800 dark:hover:text-white">Schließen</button>
<button @click="open = false" class="px-4 py-2 text-sm font-medium bg-cyan-500 text-white rounded-xl">Bestätigen</button>
</div>
</div>
</div>
</div>
```
@@ -0,0 +1,38 @@
# Anti-AI-Slop Quality Checklist 🛡️
Before finalizing and publishing any website or HTML artifact, verify every item on this checklist. This ensures the output feels designed by a human senior product designer rather than a generic LLM.
---
## ❌ Forbidden Trappings of "AI Slop"
1. **Never use generic default fonts**:
* 🚫 No raw Inter, Roboto, Arial, or unstyled system-ui.
* ✅ Always import characterful fonts (e.g. *Syne*, *Cabinet Grotesk*, *Fraunces*, *Space Grotesk*, *Plus Jakarta Sans*).
2. **Never use the generic purple-to-indigo gradient**:
* 🚫 `bg-gradient-to-r from-purple-600 to-indigo-600` on white background.
* ✅ Use intentional palettes: deep obsidian with electric cyan accents, warm ivory with terracotta, neo-brutalist yellow with black borders, or slate with emerald glow.
3. **Never generate static, boring centered 3-card rows**:
* 🚫 Three identical white boxes with light gray text centered on a plain gray canvas.
* ✅ Vary card sizes, emphasize a primary card with a border glow or badge, use grid spans (`md:col-span-2`), or add asymmetry.
4. **Never forget interactive micro-interactions**:
* 🚫 Dead buttons that don't react to hover or click.
* ✅ Always add `hover:-translate-y-0.5 hover:shadow-lg transition-all duration-200` and `active:scale-95`.
5. **Never rely on broken external images**:
* 🚫 Using fake URLs like `https://example.com/image.jpg` or placeholder services that fail.
* ✅ Use SVG patterns, Lucide icons, gradient mesh backgrounds, or reliable Unsplash source links with explicit fallback styling.
---
## ✅ Mandatory Pre-Flight Verification
| Check | Requirement | Verified? |
|---|---|:---:|
| **Viewport & Encoding** | `<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">` | [ ] |
| **Tailwind CDN** | `<script src="https://cdn.tailwindcss.com"></script>` included in `<head>` | [ ] |
| **Alpine.js CDN** | `<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>` in `<head>` | [ ] |
| **Lucide Icons** | `<script src="https://unpkg.com/lucide@latest"></script>` + `lucide.createIcons()` at bottom of `<body>` | [ ] |
| **Google Fonts** | Preconnect + Google Font `<link>` + custom `<style>` setting `font-family` | [ ] |
| **Touch Targets** | Buttons and clickable elements have at least `py-2.5 px-4` and `cursor-pointer` | [ ] |
| **Dark Mode Consistency** | If dark mode is supported, all cards, borders, text, and inputs have `dark:` variants | [ ] |
| **Mobile Responsiveness** | Layout uses `grid-cols-1 md:grid-cols-2 lg:grid-cols-3` and responsive paddings (`p-4 md:p-8`) | [ ] |
| **Content Richness** | Real, realistic domain content (no repeated "Lorem Ipsum" filler) | [ ] |
@@ -0,0 +1,122 @@
# Design Archetypes & Visual Systems 🎨
This document defines the 4 core design archetypes used by the `create-website` skill. Every generated website or single-page application must commit fully to one of these visual directions to ensure high aesthetic distinction and eliminate generic AI aesthetics.
---
## 1. Dark Obsidian & Cyber-Luxury 🌌
*Best for: Tech products, developer tools, AI showcases, crypto/finance, dark-mode dashboards, interactive apps.*
### Google Fonts Embed
```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700&family=Syne:wght@600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
body { font-family: 'Plus Jakarta Sans', sans-serif; }
h1, h2, h3, .font-heading { font-family: 'Syne', sans-serif; }
code, pre, .font-mono { font-family: 'JetBrains Mono', monospace; }
</style>
```
### Color Palette & Classes
* **Canvas Background**: `bg-[#090A0F]` with subtle radial gradients:
`bg-[radial-gradient(ellipse_80%_80%_at_50%_-20%,rgba(120,119,198,0.15),rgba(255,255,255,0))]`
* **Card Surface (Glassmorphism)**: `bg-white/[0.03] backdrop-blur-xl border border-white/10 hover:border-white/20 transition-all`
* **Secondary Surface**: `bg-[#12151E] border border-white/5`
* **Text Hierarchy**: Headings `text-white font-bold tracking-tight`, Body `text-zinc-400`, Subtle `text-zinc-500`
* **Accent Highlights**:
* Electric Cyan: `text-cyan-400 bg-cyan-500/10 border-cyan-500/20 shadow-[0_0_20px_rgba(6,182,212,0.15)]`
* Neon Violet: `text-purple-400 bg-purple-500/10 border-purple-500/20`
* Emerald Glow: `text-emerald-400 bg-emerald-500/10 border-emerald-500/20`
* **Buttons**:
* Primary: `bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white font-medium px-5 py-2.5 rounded-xl shadow-lg shadow-cyan-500/20 active:scale-95 transition-all`
* Secondary: `bg-white/5 hover:bg-white/10 text-white border border-white/10 px-5 py-2.5 rounded-xl active:scale-95 transition-all`
---
## 2. Editorial Warmth & Swiss Typographic 📰
*Best for: In-depth research dossiers, long-form journalism, recipe collections, personal manifestos, architectural notes, knowledge collections.*
### Google Fonts Embed
```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..700;1,9..144,300..700&family=Instrument+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
body { font-family: 'Instrument Sans', sans-serif; }
h1, h2, h3, .font-heading { font-family: 'Fraunces', serif; }
</style>
```
### Color Palette & Classes
* **Canvas Background**: Warm Ivory `bg-[#FDFBF7]` or Linen `bg-[#F7F4EE]`
* **Card Surface**: Paper White `bg-white border border-stone-200/80 shadow-sm rounded-2xl`
* **Text Hierarchy**: Headings `text-stone-900 font-semibold tracking-tight`, Body `text-stone-600 leading-relaxed`, Captions `text-stone-500 text-sm`
* **Accent Highlights**:
* Heritage Rust / Terracotta: `text-[#C2410C] bg-[#FFF7ED] border-[#FDBA74]`
* Deep Forest: `text-[#166534] bg-[#F0FDF4] border-[#BBF7D0]`
* Warm Amber: `text-[#B45309] bg-[#FEF3C7] border-[#FDE68A]`
* **Styling Rules**:
* Generous margins and breathing room (`py-16 md:py-24 max-w-4xl mx-auto`).
* Elegant divider rules (`border-t border-stone-200`).
* Dropcaps or serif quotes with subtle italic styling (`italic text-stone-700`).
---
## 3. Neo-Brutalism & High-Energy ⚡
*Best for: Interactive party invites, festival schedules, fitness challenges, hackathon pitches, playful calculators, game companions.*
### Google Fonts Embed
```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet">
<style>
body { font-family: 'Space Grotesk', sans-serif; }
code, pre, .font-mono { font-family: 'JetBrains Mono', monospace; }
</style>
```
### Color Palette & Classes
* **Canvas Background**: High-contrast Pale Pastel `bg-[#FAF8F5]` or Off-Yellow `bg-[#FEFCE8]`
* **Borders & Shadows**:
* Signature Shadow: `border-2 border-black shadow-[4px_4px_0px_0px_#000] hover:shadow-[2px_2px_0px_0px_#000] hover:translate-x-[2px] hover:translate-y-[2px] transition-all`
* Card: `bg-white border-2 border-black shadow-[5px_5px_0px_0px_#000] rounded-xl p-6`
* **Color Accents**:
* Electric Canary: `bg-[#FDE047]`
* Acid Lime: `bg-[#A3E635]`
* Bright Coral: `bg-[#FB7185]`
* Cyan Pill: `bg-[#38BDF8]`
* **Interactive Elements**:
* Badges: `inline-block px-3 py-1 text-xs font-bold uppercase tracking-wider bg-black text-white rounded-md`
* Buttons: `bg-[#FDE047] hover:bg-[#FACC15] text-black font-bold px-6 py-3 border-2 border-black shadow-[4px_4px_0px_0px_#000] active:translate-x-[4px] active:translate-y-[4px] active:shadow-none transition-all rounded-xl cursor-pointer`
---
## 4. Modern Clean SaaS & Dynamic Dashboard 📊
*Best for: Multi-tab trackers, data analytics, KPI summaries, filterable catalogues, interactive calculators, recipe managers.*
### Google Fonts Embed
```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@400;500;600&display=swap" rel="stylesheet">
<style>
body { font-family: 'Plus Jakarta Sans', sans-serif; }
h1, h2, h3, .font-heading { font-family: 'Outfit', sans-serif; }
</style>
```
### Color Palette & Classes
* **Canvas Background**: Adaptive Light/Dark:
`bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100 transition-colors duration-200`
* **Card Surface**:
`bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-2xl shadow-sm hover:shadow-md transition-all`
* **KPI Metrics**:
* Value: `text-3xl md:text-4xl font-bold font-heading tracking-tight text-slate-900 dark:text-white`
* Label: `text-xs font-medium uppercase tracking-wider text-slate-500 dark:text-slate-400 mt-1`
* **Interactive Controls**:
* Inputs: `bg-slate-100 dark:bg-slate-800 border-none rounded-xl px-4 py-2 text-sm focus:ring-2 focus:ring-indigo-500`
* Active Tabs: `bg-indigo-600 text-white rounded-lg px-4 py-2 font-medium shadow-sm`
* Inactive Tabs: `text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white px-4 py-2 rounded-lg`
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
# /// script
# dependencies = [
# "boto3>=1.34.0",
# ]
# ///
"""
Publish single-page websites to hosting.haumdaucher.de with validation and clean URL reporting.
Integrates with the existing 'hosting' skill publisher.
"""
import argparse
import os
import re
import subprocess
import sys
from pathlib import Path
def validate_html(content: str) -> list[str]:
"""Basic pre-flight checks to ensure the generated website isn't malformed."""
warnings = []
content_lower = content.lower()
if "<!doctype html>" not in content_lower:
warnings.append("Missing <!DOCTYPE html> declaration.")
if "viewport" not in content_lower:
warnings.append("Missing responsive viewport meta tag.")
if "cdn.tailwindcss.com" not in content_lower and "tailwindcss" not in content_lower:
warnings.append("Tailwind CSS CDN not detected in HTML.")
if "alpine" not in content_lower:
warnings.append("Alpine.js script not detected (interactivity might be missing).")
if "lucide" in content_lower and "lucide.createicons" not in content_lower:
warnings.append("Lucide icons included, but 'lucide.createIcons()' initialization is missing.")
return warnings
def resolve_hosting_script() -> Path:
"""Finds the publish_file.py script relative to this script or in standard locations."""
current_dir = Path(__file__).resolve().parent
# Standard relative location in mop-skills
sibling_hosting = current_dir.parent.parent / "hosting" / "scripts" / "publish_file.py"
if sibling_hosting.exists():
return sibling_hosting
# Fallback in container
pod_hosting = Path("/home/node/mop-skills/mop-skills/skills/hosting/scripts/publish_file.py")
if pod_hosting.exists():
return pod_hosting
raise FileNotFoundError(f"Cannot find publish_file.py in {sibling_hosting} or {pod_hosting}")
def main():
parser = argparse.ArgumentParser(description="Publish websites to hosting.haumdaucher.de")
parser.add_argument("--file", "-f", help="Local HTML file to upload")
parser.add_argument("--content", "-c", help="Inline HTML content string")
parser.add_argument("--slug", "-s", required=True, help="Slug identifier for URL (e.g. creami-calculator)")
parser.add_argument("--category", default="showcase", help="Category folder (default: showcase)")
parser.add_argument("--skip-validation", action="store_true", help="Skip HTML quality checks")
parser.add_argument("--url-only", action="store_true", help="Print only public URL")
args = parser.parse_args()
if not args.file and args.content is None:
parser.error("You must provide either --file or --content.")
# Read content for validation
html_content = ""
if args.file:
file_path = Path(args.file)
if not file_path.exists():
sys.exit(f"Error: File not found: {args.file}")
html_content = file_path.read_text(encoding="utf-8", errors="replace")
else:
html_content = args.content
# Quality validation
if not args.skip_validation:
warnings = validate_html(html_content)
if warnings:
print("⚠️ Pre-flight HTML Warnings:", file=sys.stderr)
for w in warnings:
print(f" - {w}", file=sys.stderr)
# Delegate to hosting/scripts/publish_file.py
publisher_script = resolve_hosting_script()
cmd = [
sys.executable,
str(publisher_script),
"--category", args.category,
"--slug", args.slug,
"--ext", "html",
]
if args.file:
cmd.extend(["--file", str(args.file)])
else:
cmd.extend(["--content", args.content])
if args.url_only:
cmd.append("--url-only")
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
sys.stderr.write(result.stderr)
sys.exit(result.returncode)
print(result.stdout.strip())
if __name__ == "__main__":
main()
@@ -0,0 +1,45 @@
import unittest
from pathlib import Path
import sys
# Add scripts directory to path
script_dir = Path(__file__).resolve().parent.parent / "scripts"
sys.path.insert(0, str(script_dir))
from publish_website import validate_html, resolve_hosting_script
class TestPublishWebsite(unittest.TestCase):
def test_validate_html_valid(self):
valid_html = """
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script src="https://unpkg.com/lucide@latest"></script>
</head>
<body>
<i data-lucide="sparkles"></i>
<script>lucide.createIcons();</script>
</body>
</html>
"""
warnings = validate_html(valid_html)
self.assertEqual(warnings, [])
def test_validate_html_warnings(self):
broken_html = "<html><body>Hello</body></html>"
warnings = validate_html(broken_html)
self.assertIn("Missing <!DOCTYPE html> declaration.", warnings)
self.assertIn("Missing responsive viewport meta tag.", warnings)
self.assertIn("Tailwind CSS CDN not detected in HTML.", warnings)
self.assertIn("Alpine.js script not detected (interactivity might be missing).", warnings)
def test_resolve_hosting_script(self):
hosting_script = resolve_hosting_script()
self.assertTrue(hosting_script.exists())
self.assertEqual(hosting_script.name, "publish_file.py")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,22 @@
---
name: "datastore-nutrition-logging"
description: "Log nutrition intake via datastore CLI; verify kind and repair mis-parsed events."
---
# Datastore Nutrition Logging
Log consumed food to the datastore CLI and repair events that landed under the wrong kind.
## Steps
1. Log an item: run
`uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/datastore.py add nutrition.intake --subject <person> --actor agent:koch --payload '{"item": "...", "qty": 250, "unit": "g", "protein_g": 7.5, "carbs_g": 87.5, "fat_g": 27.5, "kcal": 625.0}' --estimated`
Pass `nutrition.intake` as the positional argument and `--estimated` as a bare flag with no value. Done when the response contains `feedback.day_total_formatted`.
2. Verify the kind: run
`... datastore.py query "SELECT id, kind, start, payload FROM events WHERE subject='<person>' AND start >= '<YYYY-MM-DD>'"`.
Done when the new event shows `kind = "nutrition.intake"`. If it shows another kind (e.g. `"true"`), a flag value was parsed as the positional kind.
3. Repair a mis-kinded event: delete its line from `/home/node/.openclaw/wiki/main/data/events/<wrong-kind>/YYYY-MM.jsonl`, remove the now-empty `<wrong-kind>/` directory, then re-run step 1. Done when step 2 passes.
4. Refresh the read-only views: run
`PYTHONPATH=/home/node/mop-skills/mop-skills/skills/datastore/scripts uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/render_views.py`.
Done when it prints the rendered nutrition-log paths.
5. Report the day total from `feedback.day_total_formatted`; never compute macros by hand.
+161
View File
@@ -0,0 +1,161 @@
---
name: datastore
description: Structured personal data layer for OpenClaw powered by in-memory DuckDB and JSONL in git. Provides deterministic math, macro calculations, baby sleep interval tracking, and spatial radius search. Use whenever logging food, baby sleep, checking daily caloric balances, or querying places.
---
# 📦 Datastore: Structured Personal Data Layer
The `datastore` skill is the source of truth for all structured, repetitive, and mathematical data in OpenClaw. It stores data in git-versioned JSONL files in the Knowledge Wiki (`knowledge-wiki/data/`) and executes all calculations, aggregations, validations, and geospatial queries via in-memory **DuckDB**.
---
## 🎯 When to Use
1. **Personal Nutrition (`nutrition.intake`):**
- Logging meals, snacks, drinks, and ingredients.
- Computes macros and calories deterministically (zero LLM math).
- If a `food_id` from the food catalog is provided, macros are multiplied automatically from packaging reference values (`per_100 * qty / 100`).
2. **Baby Sleep (`sleep.session`):**
- Starting open sleep sessions when Mila falls asleep (`end=None`).
- Closing sleep sessions when Mila wakes up (`datastore_stop_event`).
- Logging completed nap or night intervals retroactively.
- Querying average night durations, wake windows, and daily sleep totals.
3. **Daily Caloric Balances:**
- Summarizing daily protein, carbs, calories, and deficit against Garmin expenditures.
4. **Points of Interest (`places`):**
- Searching for beer gardens, playgrounds, stores, or farm stands within a given radius (e.g. 500m or 1km around Grabengasse 7) using native DuckDB spatial distance.
5. **Catalogs & Entities (`foods`, `recipes`, `places`, `people`):**
- Upserting and reading reference data.
6. **Baby Growth & WHO Curves (`growth.measurement`):**
- Logging weight (`weight_kg`) and height/length (`height_cm`).
- Deterministic WHO child growth standards evaluation (P3, P50 Median, P97) based on exact age in months.
---
## ⛔ Core Guardrails & Protocol (MANDATORY)
- **NEVER compute arithmetic sums by hand:** Always let DuckDB calculate daily totals, durations, and macronutrient sums via `datastore_add_event`, `datastore_stop_event`, or `datastore_get_summary`.
- **NEVER write structured log tables directly into Markdown files:** Data goes into the datastore. Markdown view pages in `knowledge-wiki/entities/` are read-only views generated from the datastore.
- **Always provide feedback from the datastore response:** When logging an item, echo the DuckDB-calculated running day total (e.g. *"Tag bisher: 85.5 g Eiweiß · 970 kcal"*) provided in the tool response.
---
## 🛠️ MCP Tools
### 1. `datastore_add_event`
Logs a point or interval event.
- **Parameters:**
- `kind` (string, required): e.g. `'nutrition.intake'`, `'sleep.session'`, `'feeding.bottle'`.
- `subject` (string, required): `'moritz'`, `'mila'`, or `'amelie'`.
- `actor` (string, required): e.g. `'agent:koch'`, `'agent:hebamme'`, `'moritz'`.
- `payload` (object, required):
- For `nutrition.intake`: `{"item": "...", "qty": 200, "unit": "g"|"ml", "protein_g": ..., "kcal": ...}` (or `{"food_id": "oatly-haferdrink", "qty": 200, "unit": "ml"}`).
- For `sleep.session`: `{"type": "nap"|"night", "location": "bett"|"auto"|"kinderwagen"}`.
- `start` (string, optional): ISO 8601 timestamp (defaults to current time).
- `since` (string, optional): Relative start offset backwards from now (e.g. `'5m'`, `'15m'`, `'1h'`). Use for "schläft seit X Min" to avoid LLM mental math!
- `end` (string, optional): ISO 8601 timestamp. Leave empty for open sleep intervals!
- `meal` (string, optional): e.g. `'Frühstück'`, `'Mittagessen'`, `'Snack'`.
- `estimated` (boolean, optional): Set to `true` if portions or nutrients are estimated.
- `note` (string, optional): Context or notes.
### 2. `datastore_stop_event`
Closes the currently active open interval event (e.g. when Mila wakes up).
- **Parameters:**
- `kind` (string, required): `'sleep.session'`.
- `subject` (string, required): `'mila'`.
- `end_time` (string, optional): ISO timestamp (e.g. `'2026-10-11T11:40:00+02:00'`). Defaults to current time if omitted.
- `since` (string, optional): Relative wake time offset backwards from now (e.g. `'10m'`, `'15m'`).
- `event_id` (string, optional): Specific open event ID to close.
### 3. `datastore_get_summary`
Retrieves daily nutrition, sleep, and active status totals computed by DuckDB.
- **Parameters:**
- `subject` (string, required): `'moritz'` or `'mila'`.
- `date` (string, optional): `YYYY-MM-DD` (defaults to today).
### 4. `datastore_query`
Executes a read-only DuckDB SQL query for custom analysis.
- **Parameters:**
- `sql` (string, required): e.g. `"SELECT * FROM v_sleep_daily WHERE subject = 'mila' LIMIT 14"`.
### 5. `datastore_find_places_near`
Searches for points of interest within a radius in meters.
- **Parameters:**
- `lat` (number, required): e.g. `49.0205` (Grabengasse 7).
- `lon` (number, required): e.g. `12.0975`.
- `radius_m` (number, optional, default: 1000).
- `category` (string, optional): e.g. `'biergarten'`, `'spielplatz'`.
### 6. `datastore_put_entity`
Upserts a reference entity into `foods`, `recipes`, `places`, or `people`.
- **Parameters:**
- `collection` (string, required): `'foods'`, `'recipes'`, `'places'`, or `'people'`.
- `record` (object, required): Full entity record with an `'id'` field.
---
## 💻 CLI Usage
From the shell or scripts:
```bash
# Add food
uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/datastore.py add nutrition.intake \
--subject moritz \
--actor agent:koch \
--meal Frühstück \
--payload '{"item": "Haferflocken mit Skyr", "qty": 250, "unit": "g", "protein_g": 30.0, "kcal": 350.0}'
# Start Mila's nap (open interval)
uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/datastore.py add sleep.session \
--subject mila \
--actor agent:hebamme \
--payload '{"type": "nap", "location": "kinderwagen"}'
# Start Mila's nap with relative time offset (e.g. asleep since 15 minutes)
uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/datastore.py add sleep.session \
--subject mila \
--actor agent:hebamme \
--since "15m" \
--payload '{"type": "nap", "location": "bett"}'
# Stop Mila's nap (closes open interval at current time)
uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/datastore.py stop sleep.session \
--subject mila
# Stop Mila's nap retroactively with specific wake time
uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/datastore.py stop sleep.session \
--subject mila \
--end "2026-10-11T11:40:00+02:00"
# Stop Mila's nap retroactively with relative wake time (e.g. woke up 15 min ago)
uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/datastore.py stop sleep.session \
--subject mila \
--since "15m"
# Query daily summary
uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/datastore.py summary --subject moritz
# Radius search for beer gardens within 500m of Grabengasse 7
uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/datastore.py places \
--lat 49.0205 --lon 12.0975 --radius 500 --category biergarten
# Nightly Garmin mirror sync
uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/garmin_sync.py --days 2
# Log Mila's weight and height measurement
uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/datastore.py add growth.measurement \
--subject mila \
--actor agent:hebamme \
--payload '{"weight_kg": 7.4, "height_cm": 67.5}'
# Retroactively log measurement with date
uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/datastore.py add growth.measurement \
--subject mila \
--date 2026-06-15 \
--payload '{"weight_kg": 5.8, "height_cm": 60.0}'
# Render Markdown views & HTML dashboards
PYTHONPATH=/home/node/mop-skills/mop-skills/skills/datastore/scripts \
uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/render_views.py
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 593 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 593 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 219 KiB

+48
View File
@@ -0,0 +1,48 @@
{
"id": "/family/mila.html",
"name": "Mila — Schlaf & Entwicklung",
"short_name": "Mila",
"description": "Schlaf-, Trink- und Windeltagebuch für Mila Graf",
"start_url": "/family/mila.html",
"scope": "/family/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#090A0F",
"theme_color": "#090A0F",
"icons": [
{
"src": "/icons/mila-icon-192-v3.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/mila-icon-192-v3.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/icons/mila-icon-512-v3.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/mila-icon-512-v3.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/icons/mila-apple-touch-icon-v3.png",
"sizes": "180x180",
"type": "image/png"
},
{
"src": "/icons/mila-icon.svg",
"sizes": "any",
"type": "image/svg+xml"
}
]
}
@@ -0,0 +1,20 @@
{
"id": "/family/moritz.html",
"name": "Moritz Health",
"short_name": "Health",
"description": "Persönliches Dashboard für Kalorienbilanz, Ernährung und Garmin-Fitness von Moritz.",
"start_url": "/family/moritz.html",
"scope": "/family/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#f8fafc",
"theme_color": "#ffffff",
"icons": [
{
"src": "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'><rect width='512' height='512' rx='128' fill='%2310b981'/><path d='M128 256h64l32-96 64 192 32-96h64' fill='none' stroke='white' stroke-width='40' stroke-linecap='round' stroke-linejoin='round'/></svg>",
"sizes": "192x192 512x512",
"type": "image/svg+xml",
"purpose": "any"
}
]
}
+35
View File
@@ -0,0 +1,35 @@
// Minimal service worker for Family PWAs (Mila & Moritz) with instant cache-bypassing navigation
const CACHE_NAME = 'family-pwa-v3';
self.addEventListener('install', (event) => {
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys.map((key) => {
if (key !== CACHE_NAME) return caches.delete(key);
})
)
).then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return;
// For HTML documents/navigations: ALWAYS bypass HTTP cache to get latest live server rendering
if (event.request.destination === 'document' || event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request, { cache: 'no-cache' }).catch(() => caches.match(event.request))
);
return;
}
// Assets (images, fonts, scripts): network-first with offline cache fallback
event.respondWith(
fetch(event.request).catch(() => caches.match(event.request))
);
});
@@ -0,0 +1,146 @@
#!/usr/bin/env python3
# /// script
# dependencies = [
# "boto3>=1.34.0",
# ]
# ///
"""
audit_hosting_boundaries.py — Automated Continuous Leak Prevention Guard (ADR 0005).
Verifies that no personal, familial, or physiological data files are exposed at the
unauthenticated root or public folders of MinIO bucket 'hosting'.
Enforces:
1. All dashboards for Mila, Moritz, family nutrition, and baby sleep must strictly reside under 'family/'.
2. Root and public directories must only contain public assets (/recipes/*, /showcase/*).
3. Any violating key found at root or public prefixes is detected and optionally quarantined/purged.
"""
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
DEFAULT_ENDPOINT = os.environ.get("MINIO_ENDPOINT", "http://minio.minio.svc.cluster.local:9000")
DEFAULT_ACCESS_KEY = os.environ.get("MINIO_ACCESS_KEY", "openclaw")
DEFAULT_SECRET_KEY = os.environ.get("MINIO_SECRET_KEY", "5uu23t69xC2Oigf8Bky0KTlI")
DEFAULT_BUCKET = os.environ.get("MINIO_BUCKET", "hosting")
SENSITIVE_PATTERNS = [
"mila",
"moritz",
"nutrition",
"sleep",
"schlaf",
"health",
"diaper",
"windel",
"fläschchen",
"flaeschchen",
"garmin",
"zwift",
"incyclist",
]
def get_s3_client():
import boto3
from botocore.client import Config
return boto3.client(
"s3",
endpoint_url=DEFAULT_ENDPOINT,
aws_access_key_id=DEFAULT_ACCESS_KEY,
aws_secret_access_key=DEFAULT_SECRET_KEY,
config=Config(signature_version="s3v4", connect_timeout=5, retries={"max_attempts": 2}),
region_name="us-east-1",
)
def audit_hosting_bucket(clean: bool = False) -> list[str]:
"""Scans MinIO bucket for sensitive personal files outside family/."""
s3 = get_s3_client()
violations: list[str] = []
paginator = s3.get_paginator("list_objects_v2")
for page in paginator.paginate(Bucket=DEFAULT_BUCKET):
for obj in page.get("Contents", []):
key = obj["Key"]
# Whitelisted protected prefixes
if key.startswith("family/") or key.startswith("private/"):
continue
# Check if key matches sensitive personal data
key_lower = key.lower()
if any(term in key_lower for term in SENSITIVE_PATTERNS):
violations.append(key)
if violations:
print(f"🚨 SECURITY VIOLATION: Found {len(violations)} sensitive personal file(s) outside /family/ in bucket '{DEFAULT_BUCKET}':", file=sys.stderr)
for v in violations:
print(f" - {v}", file=sys.stderr)
if clean:
try:
s3.delete_object(Bucket=DEFAULT_BUCKET, Key=v)
print(f" [CLEANED] Deleted '{v}' from public area.", file=sys.stderr)
except Exception as ex:
print(f" [ERROR] Failed to delete '{v}': {ex}", file=sys.stderr)
else:
print(f"✅ S3 Hosting Audit PASSED: Zero sensitive personal files found in public areas of bucket '{DEFAULT_BUCKET}'.")
return violations
def audit_local_public_vault(vault_dir: Path, clean: bool = False) -> list[Path]:
"""Scans local knowledge-wiki/public directory for sensitive files outside family/."""
violations: list[Path] = []
public_dir = vault_dir / "public"
if not public_dir.is_dir():
return violations
for f in public_dir.iterdir():
if f.is_file():
f_lower = f.name.lower()
if any(term in f_lower for term in SENSITIVE_PATTERNS):
violations.append(f)
if clean:
try:
f.unlink()
print(f" [CLEANED] Deleted local unauthenticated file: {f}")
except Exception as ex:
print(f" [ERROR] Failed to delete {f}: {ex}")
if violations and not clean:
print(f"🚨 Local Vault Public Warning: Found {len(violations)} personal file(s) in {public_dir} root.", file=sys.stderr)
elif not violations:
print(f"✅ Local Vault Public Audit PASSED: No sensitive files at public root.")
return violations
def main():
parser = argparse.ArgumentParser(description="Audit and enforce ADR 0005 hosting boundary segregation")
parser.add_argument("--clean", action="store_true", help="Automatically delete any violating files found")
parser.add_argument("--vault", help="Path to knowledge-wiki directory")
args = parser.parse_args()
vault_path = Path(args.vault).resolve() if args.vault else Path(__file__).resolve().parents[4] / "knowledge-wiki"
s3_violations = []
try:
s3_violations = audit_hosting_bucket(clean=args.clean)
except Exception as ex:
print(f"Warning: could not connect to S3/MinIO for audit: {ex}", file=sys.stderr)
local_violations = audit_local_public_vault(vault_path, clean=args.clean)
if s3_violations or local_violations:
if not args.clean:
sys.exit(1)
sys.exit(0)
if __name__ == "__main__":
main()
+1800
View File
File diff suppressed because it is too large Load Diff
+546
View File
@@ -0,0 +1,546 @@
#!/usr/bin/env python3
# /// script
# dependencies = [
# "garminconnect>=0.2.14",
# "pytz>=2024.1",
# ]
# ///
"""
Garmin Sync: Deterministic mirror of Garmin Connect data into git-backed JSONL.
Mirrors daily summaries, sleep data, and activities into:
- data/garmin/daily/<YYYY-MM>.jsonl
- data/garmin/sleep/<YYYY-MM>.jsonl
- data/garmin/activities/<YYYY-MM>.jsonl
Adheres to ADR 0003 (Git as single source of truth) and ADR 0004 (Structured Datastore).
Zero LLM math: records are stored as raw JSONL for deterministic DuckDB ingestion.
"""
from __future__ import annotations
import argparse
import datetime
import json
import logging
import os
import sys
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any, Dict, List, Optional
try:
import pytz
except ImportError:
pytz = None
logger = logging.getLogger("garmin_sync")
def get_berlin_yesterday() -> str:
"""Returns yesterday's date in Europe/Berlin wall-clock time."""
if pytz:
tz = pytz.timezone("Europe/Berlin")
now = datetime.datetime.now(tz)
else:
# Fallback +2h offset for Berlin CEST
tz_offset = datetime.timezone(datetime.timedelta(hours=2))
now = datetime.datetime.now(tz_offset)
yesterday = now - datetime.timedelta(days=1)
return yesterday.strftime("%Y-%m-%d")
def find_default_data_dir() -> Path:
"""Finds the knowledge-wiki/data directory."""
env_dir = os.environ.get("DATASTORE_DATA_DIR")
if env_dir:
p = Path(env_dir)
if p.is_dir():
return p
candidates = [
Path("/home/node/.openclaw/wiki/main/data"),
Path(__file__).resolve().parents[4] / "knowledge-wiki" / "data",
Path.cwd() / "knowledge-wiki" / "data",
Path.cwd() / "data",
]
for c in candidates:
if c.is_dir():
return c
# Fallback to local data dir inside parent
return Path.cwd() / "data"
def get_token_dir(custom_path: Optional[str] = None) -> Path:
"""Discovers token directory according to garmin-auth conventions."""
if custom_path:
return Path(custom_path)
if os.environ.get("GARMINTOKENS"):
return Path(os.path.expanduser(os.environ["GARMINTOKENS"]))
if Path("/home/node/.openclaw/garmin_tokens").is_dir():
return Path("/home/node/.openclaw/garmin_tokens")
return Path(os.path.expanduser("~/.garminconnect"))
class GarminSyncClient:
"""Garmin API client with token re-use and optional mock mode."""
def __init__(self, token_dir: Path, mock: bool = False):
self.token_dir = token_dir
self.mock = mock
self.client: Any = None
if not self.mock:
self._init_live_client()
def _init_live_client(self):
token_file = self.token_dir / "garmin_tokens.json"
if not token_file.is_file():
logger.error(f"No Garmin tokens found at {token_file}")
raise FileNotFoundError(f"Missing garmin_tokens.json in {self.token_dir}")
try:
from garminconnect import Garmin
self.client = Garmin()
self.client.login(str(self.token_dir))
name = self.client.get_full_name()
logger.info(f"Connected to Garmin as {name}")
except Exception as e:
logger.error(f"Failed to authenticate with Garmin Connect: {e}")
raise PermissionError(f"Garmin auth failed: {e}") from e
def get_daily_summary(self, date_str: str) -> Dict[str, Any]:
if self.mock:
return {
"calendarDate": date_str,
"totalKilocalories": 2850,
"activeKilocalories": 610,
"bmrKilocalories": 2240,
"totalSteps": 9840,
"totalDistanceMeters": 7450.0,
"restingHeartRate": 52,
"minHeartRate": 48,
"maxHeartRate": 164,
"floorsAscended": 12,
"source": "garmin_sync_mock",
}
return self.client.get_user_summary(date_str)
def get_sleep_data(self, date_str: str) -> Dict[str, Any]:
if self.mock:
return {
"dailySleepDTO": {
"id": 12345678,
"calendarDate": date_str,
"sleepTimeSeconds": 28800,
"napTimeSeconds": 0,
"deepSleepSeconds": 7200,
"lightSleepSeconds": 14400,
"remSleepSeconds": 5400,
"awakeSleepSeconds": 1800,
"sleepScores": {"overall": {"value": 85, "qualifierKey": "EXCELLENT"}},
},
"source": "garmin_sync_mock",
}
return self.client.get_sleep_data(date_str)
def get_activities(self, date_str: str) -> List[Dict[str, Any]]:
if self.mock:
return [
{
"activityId": 987654321,
"activityName": "Indoor Cycling (Tacx Flux 2)",
"startTimeLocal": f"{date_str} 18:30:00",
"startTimeGMT": f"{date_str} 16:30:00",
"activityType": {"typeId": 2, "typeKey": "cycling"},
"distance": 25400.0,
"duration": 3600.0,
"elapsedDuration": 3600.0,
"calories": 580.0,
"averageSpeed": 7.05,
"maxSpeed": 11.2,
"averageHR": 142,
"maxHR": 168,
"averagePower": 210,
"maxPower": 285,
"source": "garmin_sync_mock",
}
]
return self.client.get_activities_by_date(date_str, date_str)
class GarminMirrorWriter:
"""Manages month-partitioned JSONL files with idempotent upserts."""
def __init__(self, data_dir: Path):
self.data_dir = data_dir
self.garmin_dir = data_dir / "garmin"
self.daily_dir = self.garmin_dir / "daily"
self.sleep_dir = self.garmin_dir / "sleep"
self.unified_sleep_dir = data_dir / "sleep"
self.activities_dir = self.garmin_dir / "activities"
for d in [self.daily_dir, self.sleep_dir, self.unified_sleep_dir, self.activities_dir]:
d.mkdir(parents=True, exist_ok=True)
def _read_existing(self, file_path: Path) -> List[Dict[str, Any]]:
if not file_path.is_file():
return []
items = []
with open(file_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
try:
items.append(json.loads(line))
except json.JSONDecodeError:
continue
return items
def _write_atomic(self, file_path: Path, items: List[Dict[str, Any]]):
tmp_path = file_path.with_suffix(".tmp")
with open(tmp_path, "w", encoding="utf-8") as f:
for item in items:
f.write(json.dumps(item, ensure_ascii=False) + "\n")
tmp_path.replace(file_path)
def upsert_daily_summary(self, summary: Dict[str, Any]):
cal_date = summary.get("calendarDate")
if not cal_date:
logger.warning("Daily summary missing calendarDate, skipping.")
return
month = cal_date[:7] # YYYY-MM
target_file = self.daily_dir / f"{month}.jsonl"
existing = self._read_existing(target_file)
updated = False
new_items = []
for item in existing:
if item.get("calendarDate") == cal_date:
new_items.append(summary)
updated = True
else:
new_items.append(item)
if not updated:
new_items.append(summary)
new_items.sort(key=lambda x: str(x.get("calendarDate", "")))
self._write_atomic(target_file, new_items)
logger.info(f"Upserted daily summary for {cal_date} into {target_file.name}")
def upsert_sleep_data(self, sleep_data: Dict[str, Any]):
cal_date = None
dto = sleep_data.get("dailySleepDTO") if isinstance(sleep_data.get("dailySleepDTO"), dict) else {}
if dto:
cal_date = dto.get("calendarDate")
if not cal_date:
cal_date = sleep_data.get("calendarDate")
if not cal_date:
logger.warning("Sleep data missing calendarDate, skipping.")
return
month = cal_date[:7]
target_file = self.sleep_dir / f"{month}.jsonl"
existing = self._read_existing(target_file)
updated = False
new_items = []
for item in existing:
item_date = None
if "dailySleepDTO" in item and isinstance(item["dailySleepDTO"], dict):
item_date = item["dailySleepDTO"].get("calendarDate")
if not item_date:
item_date = item.get("calendarDate")
if item_date == cal_date:
new_items.append(sleep_data)
updated = True
else:
new_items.append(item)
if not updated:
new_items.append(sleep_data)
def _sort_key(x):
d = (x.get("dailySleepDTO") or {}).get("calendarDate") if isinstance(x.get("dailySleepDTO"), dict) else None
return str(d or x.get("calendarDate") or "")
new_items.sort(key=_sort_key)
self._write_atomic(target_file, new_items)
# Also upsert into unified data/sleep/<YYYY-MM>.jsonl
start_gmt = dto.get("sleepStartTimestampGMT")
end_gmt = dto.get("sleepEndTimestampGMT")
if start_gmt:
start_dt = datetime.datetime.fromtimestamp(start_gmt / 1000, datetime.timezone.utc)
start_iso = start_dt.isoformat()
else:
start_iso = f"{cal_date}T22:30:00+02:00"
if end_gmt:
end_dt = datetime.datetime.fromtimestamp(end_gmt / 1000, datetime.timezone.utc)
end_iso = end_dt.isoformat()
else:
end_iso = f"{cal_date}T06:30:00+02:00"
nap_sec = dto.get("napTimeSeconds") or 0
sleep_sec = dto.get("sleepTimeSeconds") or 0
sleep_type = "nap" if (nap_sec > 0 and sleep_sec == 0) else "night"
unified_rec = {
"id": str(dto.get("id") or (int(start_gmt) if start_gmt else cal_date)),
"subject": "moritz",
"calendarDate": cal_date,
"sleepType": sleep_type,
"location": "bett",
"isOpen": False,
"startIso": start_iso,
"endIso": end_iso,
"sleepStartTimestampGMT": dto.get("sleepStartTimestampGMT"),
"sleepEndTimestampGMT": dto.get("sleepEndTimestampGMT"),
"sleepStartTimestampLocal": dto.get("sleepStartTimestampLocal"),
"sleepEndTimestampLocal": dto.get("sleepEndTimestampLocal"),
"sleepTimeSeconds": dto.get("sleepTimeSeconds"),
"napTimeSeconds": nap_sec,
"deepSleepSeconds": dto.get("deepSleepSeconds"),
"lightSleepSeconds": dto.get("lightSleepSeconds"),
"remSleepSeconds": dto.get("remSleepSeconds"),
"awakeSleepSeconds": dto.get("awakeSleepSeconds"),
"awakeCount": dto.get("awakeCount"),
"avgSleepStress": dto.get("avgSleepStress"),
"avgHeartRate": dto.get("avgHeartRate"),
"averageSpO2Value": dto.get("averageSpO2Value"),
"lowestSpO2Value": dto.get("lowestSpO2Value"),
"highestSpO2Value": dto.get("highestSpO2Value"),
"averageRespirationValue": dto.get("averageRespirationValue"),
"sleepScores": dto.get("sleepScores"),
"avgOvernightHrv": sleep_data.get("avgOvernightHrv"),
"hrvStatus": sleep_data.get("hrvStatus"),
"bodyBatteryChange": sleep_data.get("bodyBatteryChange"),
"restingHeartRate": sleep_data.get("restingHeartRate"),
"restlessMomentsCount": sleep_data.get("restlessMomentsCount"),
"note": None,
"actor": "garmin_sync",
"created_at": start_iso,
"updated_at": None,
"deleted_at": None,
}
unified_file = self.unified_sleep_dir / f"{month}.jsonl"
existing_unified = self._read_existing(unified_file)
updated_u = False
new_unified = []
for item in existing_unified:
if item.get("subject") == "moritz" and item.get("calendarDate") == cal_date:
new_unified.append(unified_rec)
updated_u = True
else:
new_unified.append(item)
if not updated_u:
new_unified.append(unified_rec)
new_unified.sort(key=lambda x: (str(x.get("calendarDate", "")), str(x.get("startIso", "")), str(x.get("id", ""))))
self._write_atomic(unified_file, new_unified)
logger.info(f"Upserted sleep data for {cal_date} into {target_file.name} and {unified_file.name}")
def upsert_activities(self, activities: List[Dict[str, Any]]):
if not activities:
return
# Group activities by month
by_month: Dict[str, List[Dict[str, Any]]] = {}
for act in activities:
start_local = act.get("startTimeLocal", "")
if len(start_local) >= 7:
month = start_local[:7]
else:
month = datetime.date.today().strftime("%Y-%m")
by_month.setdefault(month, []).append(act)
for month, act_list in by_month.items():
target_file = self.activities_dir / f"{month}.jsonl"
existing = self._read_existing(target_file)
id_map = {item.get("activityId"): item for item in existing if item.get("activityId")}
for act in act_list:
act_id = act.get("activityId")
if act_id:
id_map[act_id] = act
all_acts = list(id_map.values())
all_acts.sort(key=lambda x: str(x.get("startTimeLocal", "")))
self._write_atomic(target_file, all_acts)
logger.info(f"Upserted {len(act_list)} activities for month {month} into {target_file.name}")
def sync_date_range(
client: GarminSyncClient,
writer: Optional[GarminMirrorWriter],
dates: List[str],
backend_url: Optional[str] = None,
):
target_backend = None
if backend_url is not None:
target_backend = backend_url
elif os.environ.get("DUCKDB_FORCE_LOCAL") != "1":
env_backend = os.environ.get(
"DUCKDB_BACKEND_URL", "http://duckdb-backend.family.svc.cluster.local:8000/duckdb-api/v1"
).rstrip("/")
try:
base = env_backend.replace("/duckdb-api/v1", "")
test_url = f"{base}/healthz"
req = urllib.request.Request(test_url, method="GET")
with urllib.request.urlopen(req, timeout=1.0) as r:
if r.status == 200:
target_backend = env_backend
logger.info(f"GarminSync using single-writer backend at {target_backend}")
except Exception:
target_backend = None
for d in dates:
logger.info(f"--- Syncing Garmin data for {d} ---")
summary = None
sleep = None
activities = None
try:
summary = client.get_daily_summary(d)
except Exception as e:
logger.warning(f"Could not fetch daily summary for {d}: {e}")
try:
sleep = client.get_sleep_data(d)
except Exception as e:
logger.warning(f"Could not fetch sleep data for {d}: {e}")
try:
activities = client.get_activities(d)
except Exception as e:
logger.warning(f"Could not fetch activities for {d}: {e}")
synced_via_backend = False
if target_backend:
try:
sync_payload = {
"date": d,
"daily": [summary] if summary else [],
"sleep": [sleep] if sleep else [],
"activities": activities or [],
"daily_summary": summary,
"sleep_data": sleep,
}
sync_url = f"{target_backend}/garmin/sync"
req = urllib.request.Request(
sync_url,
data=json.dumps(sync_payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=30) as resp:
resp_body = {}
try:
resp_body = json.loads(resp.read().decode("utf-8"))
except Exception:
pass
expected_records = (1 if summary else 0) + (1 if sleep else 0) + (len(activities) if activities else 0)
synced_records = (
resp_body.get("synced_daily", 0)
+ resp_body.get("synced_sleep", 0)
+ resp_body.get("synced_activities", 0)
)
if resp.status == 200 and (expected_records == 0 or synced_records > 0):
logger.info(f"Successfully posted Garmin sync for {d} to backend: {resp_body}")
synced_via_backend = True
else:
logger.warning(
f"Backend returned HTTP {resp.status} for {d} but synced {synced_records} records "
f"(expected {expected_records}): {resp_body}. Falling back to local writer."
)
except Exception as e:
logger.warning(f"Failed to sync via backend {target_backend} for {d}: {e}. Falling back to local writer if available.")
if not synced_via_backend and writer:
if summary:
writer.upsert_daily_summary(summary)
if sleep:
writer.upsert_sleep_data(sleep)
if activities:
writer.upsert_activities(activities)
def generate_date_list(start_str: str, end_str: str) -> List[str]:
d_start = datetime.datetime.strptime(start_str, "%Y-%m-%d").date()
d_end = datetime.datetime.strptime(end_str, "%Y-%m-%d").date()
if d_start > d_end:
d_start, d_end = d_end, d_start
result = []
curr = d_start
while curr <= d_end:
result.append(curr.strftime("%Y-%m-%d"))
curr += datetime.timedelta(days=1)
return result
def main():
parser = argparse.ArgumentParser(description="Deterministic Garmin Connect data mirror into JSONL")
parser.add_argument("--date", help="Specific date to sync (YYYY-MM-DD). Defaults to yesterday.")
parser.add_argument("--days", type=int, help="Sync last N days up to yesterday.")
parser.add_argument("--start", help="Start date (YYYY-MM-DD)")
parser.add_argument("--end", help="End date (YYYY-MM-DD)")
parser.add_argument("--data-dir", help="Path to knowledge-wiki/data directory")
parser.add_argument("--token-dir", help="Path to Garmin token directory")
parser.add_argument("--mock", action="store_true", help="Run with mock data (for testing/offline)")
parser.add_argument("--verbose", action="store_true", help="Enable verbose debug logging")
args = parser.parse_args()
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
)
data_dir = Path(args.data_dir) if args.data_dir else find_default_data_dir()
token_dir = get_token_dir(args.token_dir)
# Determine date list
if args.start and args.end:
dates = generate_date_list(args.start, args.end)
elif args.days:
yesterday_str = get_berlin_yesterday()
d_yesterday = datetime.datetime.strptime(yesterday_str, "%Y-%m-%d").date()
d_start = d_yesterday - datetime.timedelta(days=args.days - 1)
dates = generate_date_list(d_start.strftime("%Y-%m-%d"), yesterday_str)
elif args.date:
dates = [args.date]
else:
dates = [get_berlin_yesterday()]
logger.info(f"Starting Garmin sync for {len(dates)} day(s): {dates[0]} to {dates[-1]}")
logger.info(f"Data directory: {data_dir}")
try:
client = GarminSyncClient(token_dir, mock=args.mock)
except PermissionError:
logger.error("Authentication error. Please run 'garmin-auth' to refresh credentials.")
sys.exit(2)
except FileNotFoundError as e:
logger.error(str(e))
sys.exit(2)
writer = GarminMirrorWriter(data_dir)
sync_date_range(client, writer, dates)
logger.info("Garmin sync completed successfully.")
sys.exit(0)
if __name__ == "__main__":
main()
+534
View File
@@ -0,0 +1,534 @@
#!/usr/bin/env python3
# /// script
# dependencies = [
# "duckdb>=1.1.0",
# "python-ulid>=3.0.0",
# "pytz>=2024.1",
# ]
# ///
"""
migrate_legacy.py — Migrates legacy Markdown logs to DuckDB-backed JSONL datastore.
Parses:
- standard-foods.md -> data/entities/foods.jsonl
- entities/nutrition-log/moritz/*.md -> data/events/nutrition/*.jsonl
- entities/people/mila-schlafprotokoll.md -> data/events/sleep/*.jsonl & feeding/*.jsonl
Computes exact DuckDB sums, compares with old handwritten sums, and generates audit report.
"""
from __future__ import annotations
import datetime
import json
import os
import re
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from zoneinfo import ZoneInfo
def generate_ulid() -> str:
try:
from ulid import ULID
return str(ULID())
except TypeError:
import ulid
return str(ulid.new())
except ImportError:
import uuid
return str(uuid.uuid4())
import duckdb
BERLIN_TZ = ZoneInfo("Europe/Berlin")
WIKI_DIR = Path("/Users/moritz/src/infrapuzzle/knowledge-wiki")
DATA_DIR = WIKI_DIR / "data"
def parse_number(s: str) -> float:
"""Parses German float string like '~11,3' or '2.364' into float."""
if not s or s.strip() in ["-", "–", ""]:
return 0.0
cleaned = s.replace("~", "").replace("g", "").replace("ml", "").replace("kcal", "").strip()
# If number has both dot and comma (e.g. 1.234,56 or 2.364)
if "." in cleaned and "," in cleaned:
cleaned = cleaned.replace(".", "").replace(",", ".")
elif "," in cleaned:
cleaned = cleaned.replace(",", ".")
elif "." in cleaned:
# Check if dot is thousands separator (e.g. 2.364 kcal)
parts = cleaned.split(".")
if len(parts) == 2 and len(parts[1]) == 3:
cleaned = parts[0] + parts[1]
m = re.search(r"[-+]?\d*\.?\d+", cleaned)
return float(m.group(0)) if m else 0.0
# ==============================================================================
# 1. Migrate Standard Foods
# ==============================================================================
def migrate_standard_foods() -> List[Dict[str, Any]]:
foods_file = DATA_DIR / "entities" / "foods.jsonl"
foods_file.parent.mkdir(parents=True, exist_ok=True)
foods = [
{
"id": "oatly-haferdrink",
"name": "Oatly Haferdrink",
"brand": "Oatly AB",
"category": "milch-alternative",
"per_100": {
"kcal": 61.0,
"protein_g": 1.1,
"carbs_g": 7.1,
"sugar_g": 3.4,
"fat_g": 3.0,
"fiber_g": 0.8,
"salt_g": 0.10,
},
"density_g_per_ml": 1.03,
"ingredients_text": "Wasser, HAFER 10%, Rapsöl, Säureregulator (Dikaliumphosphat), Mineralien (Calciumcarbonat, Kaliumjodid), Salz, Vitamine (D2, Riboflavin, B12).",
"barcode": "7394376616037",
"source": "package_photo",
"description": "Standard-Hafermilch Grabengasse 7. Silberner Karton mit Vitaminen & Mineralien.",
},
{
"id": "fruechte-mix-tk",
"name": "Früchte Mix (TK)",
"brand": None,
"category": "obst",
"per_100": {
"kcal": 45.0,
"protein_g": 1.0,
"carbs_g": 6.5,
"sugar_g": 3.4,
"fat_g": 0.3,
"fiber_g": 3.4,
"salt_g": 0.0,
},
"density_g_per_ml": 1.0,
"ingredients_text": "Gefrorene Beeren-Mischung (TK)",
"barcode": None,
"source": "package_photo",
"description": "Standard gefrorener Beerenmix für Porridge und Overnight Oats.",
},
{
"id": "muller-protein-porridge-mix",
"name": "Müller Protein-Porridge Batch-Mix",
"brand": "Eigenmischung",
"category": "muesli",
"per_100": {
"kcal": 381.0,
"protein_g": 23.3,
"carbs_g": 46.6,
"sugar_g": 2.9,
"fat_g": 8.5,
"fiber_g": 9.8,
"salt_g": 0.15,
},
"density_g_per_ml": 1.0,
"ingredients_text": "Müller Porridge Basic 500g + Müller Eiweißpulver 100g + Chiasamen 50g",
"barcode": None,
"source": "recipe_batch",
"description": "Eigener Batch-Mix für Morgenporridge (Müller Porridge Basic + Eiweißpulver + Chiasamen).",
},
]
with open(foods_file, "w", encoding="utf-8") as f:
for item in foods:
f.write(json.dumps(item, ensure_ascii=False) + "\n")
return foods
# ==============================================================================
# 2. Migrate Nutrition Logs
# ==============================================================================
def migrate_nutrition_logs() -> Tuple[List[Dict[str, Any]], Dict[str, Dict[str, Any]]]:
nutrition_dir = WIKI_DIR / "entities" / "nutrition-log" / "moritz"
events_dir = DATA_DIR / "events" / "nutrition"
events_dir.mkdir(parents=True, exist_ok=True)
all_events: List[Dict[str, Any]] = []
stated_sums: Dict[str, Dict[str, Any]] = {}
for md_file in sorted(nutrition_dir.glob("2026-*.md")):
month_str = md_file.stem
file_events: List[Dict[str, Any]] = []
current_date_str = None
current_day_name = None
with open(md_file, "r", encoding="utf-8") as f:
for line in f:
line_str = line.strip()
# Match date header: ## YYYY-MM-DD Wochentag
m_date = re.match(r"^##\s+(\d{4}-\d{2}-\d{2})(?:\s+(\w+))?", line_str)
if m_date:
current_date_str = m_date.group(1)
current_day_name = m_date.group(2) or ""
continue
# Match table row
if current_date_str and line_str.startswith("|") and not line_str.startswith("| Zeit") and not line_str.startswith("|---"):
cols = [c.strip() for c in line_str.strip("|").split("|")]
if len(cols) >= 10:
zeit_raw = cols[0]
item_raw = cols[1]
menge_raw = cols[2]
eiweiss_raw = cols[3]
kh_raw = cols[4]
zucker_raw = cols[5]
fett_raw = cols[6]
ballast_raw = cols[7]
salz_raw = cols[8]
kcal_raw = cols[9]
# Parse time (default 12:00 if unknown)
m_time = re.search(r"(\d{1,2}):(\d{2})", zeit_raw)
if m_time:
hour = int(m_time.group(1))
minute = int(m_time.group(2))
else:
hour, minute = 12, 0
event_dt = datetime.datetime.strptime(current_date_str, "%Y-%m-%d").replace(
hour=hour, minute=minute, tzinfo=BERLIN_TZ
)
# Determine food_id if matched
food_id = None
if "oatly" in item_raw.lower() or "haferdrink" in item_raw.lower():
food_id = "oatly-haferdrink"
elif "früchte mix" in item_raw.lower() or "beeren" in item_raw.lower():
food_id = "fruechte-mix-tk"
elif "porridge-mischung" in item_raw.lower() or "müller protein-porridge" in item_raw.lower():
food_id = "muller-protein-porridge-mix"
# Parse quantity and unit
m_qty = re.search(r"(\d+(?:[.,]\d+)?)\s*(g|ml|portion|kugel)?", menge_raw)
qty = float(m_qty.group(1).replace(",", ".")) if m_qty else parse_number(menge_raw)
unit = m_qty.group(2) if m_qty and m_qty.group(2) else "g"
is_estimated = "~" in zeit_raw or "~" in item_raw or "~" in menge_raw or "~" in kcal_raw
record = {
"id": generate_ulid(),
"kind": "nutrition.intake",
"subject": "moritz",
"actor": "agent:koch",
"start": event_dt.isoformat(),
"end": None,
"estimated": is_estimated,
"meal": None,
"note": None,
"payload": {
"item": item_raw,
"food_id": food_id,
"qty": qty,
"unit": unit,
"protein_g": parse_number(eiweiss_raw),
"carbs_g": parse_number(kh_raw),
"sugar_g": parse_number(zucker_raw),
"fat_g": parse_number(fett_raw),
"fiber_g": parse_number(ballast_raw),
"salt_g": parse_number(salz_raw),
"kcal": parse_number(kcal_raw),
},
"source_ref": f"{md_file.name}:{zeit_raw}",
"created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"updated_at": None,
"deleted_at": None,
}
file_events.append(record)
all_events.append(record)
# Match stated sums: **Summe heute:** ...
if current_date_str and "**Summe heute:**" in line_str:
sum_text = line_str.split("**Summe heute:**")[1].strip()
stated_sums[current_date_str] = {
"text": sum_text,
"protein_g": parse_number(re.search(r"([\d,]+)\s*g\s*Eiweiß", sum_text).group(1)) if re.search(r"([\d,]+)\s*g\s*Eiweiß", sum_text) else 0.0,
"carbs_g": parse_number(re.search(r"([\d,]+)\s*g\s*KH", sum_text).group(1)) if re.search(r"([\d,]+)\s*g\s*KH", sum_text) else 0.0,
"kcal": parse_number(re.search(r"([\d,.]+)\s*kcal", sum_text).group(1)) if re.search(r"([\d,.]+)\s*kcal", sum_text) else 0.0,
}
# Write partitioned JSONL
file_events.sort(key=lambda x: (x["start"], x["id"]))
out_jsonl = events_dir / f"{month_str}.jsonl"
with open(out_jsonl, "w", encoding="utf-8") as out_f:
for ev in file_events:
out_f.write(json.dumps(ev, ensure_ascii=False) + "\n")
return all_events, stated_sums
# ==============================================================================
# 3. Migrate Mila Sleep & Feeding Logs
# ==============================================================================
def migrate_mila_sleep_and_feeding() -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
md_file = WIKI_DIR / "entities" / "people" / "mila-schlafprotokoll.md"
sleep_events: List[Dict[str, Any]] = []
feeding_events: List[Dict[str, Any]] = []
if not md_file.is_file():
return sleep_events, feeding_events
current_date = None
processed_nights = set()
with open(md_file, "r", encoding="utf-8") as f:
for line in f:
line_str = line.strip()
# Date header: ## Dienstag, 29.09.2026 or ## 2026-09-29
m_date = re.search(r"(\d{2})\.(\d{2})\.(\d{4})", line_str)
if line_str.startswith("##") and m_date:
day, month, year = int(m_date.group(1)), int(m_date.group(2)), int(m_date.group(3))
current_date = datetime.date(year, month, day)
continue
if not current_date or not line_str.startswith("-"):
continue
# Case A: Feeding log (e.g. - 08:15 – Fläschchen (80 ml übrig/ungetrunken))
if "fläschchen" in line_str.lower():
m_time = re.search(r"(\d{1,2}):(\d{2})", line_str)
h, m = (int(m_time.group(1)), int(m_time.group(2))) if m_time else (8, 0)
feed_dt = datetime.datetime(current_date.year, current_date.month, current_date.day, h, m, tzinfo=BERLIN_TZ)
m_ml = re.search(r"(\d+)\s*ml", line_str)
left_ml = float(m_ml.group(1)) if m_ml else 0.0
record = {
"id": generate_ulid(),
"kind": "feeding.bottle",
"subject": "mila",
"actor": "agent:hebamme",
"start": feed_dt.isoformat(),
"end": None,
"estimated": False,
"meal": None,
"note": line_str.lstrip("- ").strip(),
"payload": {
"offered_ml": None,
"consumed_ml": None,
"left_ml": left_ml,
"milk_type": "pre",
},
"source_ref": f"mila-schlafprotokoll.md:{feed_dt.strftime('%Y-%m-%d %H:%M')}",
"created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"updated_at": None,
"deleted_at": None,
}
feeding_events.append(record)
continue
# Case B: Sleep intervals
# Examples:
# - 09:51 – 10:38 | Schlaf am Morgen (46 min: 10 min wach, 6 Leicht-, 30 Tiefschlaf; Einschlafzeit 5 min)
# - 18:57 – 06:50 | Nacht (11 h 53 min)
# - 20:10 – | Nacht (läuft)
# - 07:00 – aufgewacht (Nacht-Ende); Nacht: 19:13 – 07:00 (11 h 47 min)
# Check for "aufgewacht (Nacht-Ende)" duplicate
if "aufgewacht (nacht-ende)" in line_str.lower() or "nacht-ende" in line_str.lower():
m_span = re.search(r"(\d{1,2}):(\d{2})\s*–\s*(\d{1,2}):(\d{2})", line_str)
if m_span:
s_h, s_m, e_h, e_m = int(m_span.group(1)), int(m_span.group(2)), int(m_span.group(3)), int(m_span.group(4))
night_key = f"{s_h:02d}:{s_m:02d}-{e_h:02d}:{e_m:02d}"
if night_key in processed_nights:
# Already logged from the evening line, skip duplicate!
continue
else:
processed_nights.add(night_key)
# The start was yesterday evening
start_dt = datetime.datetime(current_date.year, current_date.month, current_date.day, s_h, s_m, tzinfo=BERLIN_TZ) - datetime.timedelta(days=1)
end_dt = datetime.datetime(current_date.year, current_date.month, current_date.day, e_h, e_m, tzinfo=BERLIN_TZ)
record = {
"id": generate_ulid(),
"kind": "sleep.session",
"subject": "mila",
"actor": "agent:hebamme",
"start": start_dt.isoformat(),
"end": end_dt.isoformat(),
"estimated": False,
"meal": None,
"note": line_str.lstrip("- ").strip(),
"payload": {
"type": "night",
"location": "bett",
},
"source_ref": f"mila-schlafprotokoll.md:{current_date}",
"created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"updated_at": None,
"deleted_at": None,
}
sleep_events.append(record)
continue
# Standard interval: start – end | label
m_int = re.search(r"^-\s*(\d{1,2}):(\d{2})\s*–\s*(\d{1,2})?:?(\d{2})?\s*\|?(.*)", line_str)
if m_int:
s_h = int(m_int.group(1))
s_m = int(m_int.group(2))
e_h_str = m_int.group(3)
e_m_str = m_int.group(4)
desc = m_int.group(5).strip() if m_int.group(5) else ""
start_dt = datetime.datetime(current_date.year, current_date.month, current_date.day, s_h, s_m, tzinfo=BERLIN_TZ)
# Open sleep interval (e.g. 20:10 – | Nacht (läuft))
if not e_h_str or not e_m_str:
end_dt = None
sleep_type = "night" if s_h >= 18 or s_h < 6 else "nap"
else:
e_h = int(e_h_str)
e_m = int(e_m_str)
# If end hour < start hour, crosses midnight!
if e_h < s_h:
end_dt = datetime.datetime(current_date.year, current_date.month, current_date.day, e_h, e_m, tzinfo=BERLIN_TZ) + datetime.timedelta(days=1)
else:
end_dt = datetime.datetime(current_date.year, current_date.month, current_date.day, e_h, e_m, tzinfo=BERLIN_TZ)
sleep_type = "night" if "nacht" in desc.lower() or s_h >= 18 else "nap"
# Deduplication key for night sleeps
night_key = f"{s_h:02d}:{s_m:02d}-{e_h:02d}:{e_m:02d}"
if sleep_type == "night":
processed_nights.add(night_key)
# Location detection
loc = "bett"
if "auto" in desc.lower():
loc = "auto"
elif "kinderwagen" in desc.lower():
loc = "kinderwagen"
elif "outdoor" in desc.lower() or "decke" in desc.lower():
loc = "outdoor"
record = {
"id": generate_ulid(),
"kind": "sleep.session",
"subject": "mila",
"actor": "agent:hebamme",
"start": start_dt.isoformat(),
"end": end_dt.isoformat() if end_dt else None,
"estimated": False,
"meal": None,
"note": desc or None,
"payload": {
"type": sleep_type,
"location": loc,
},
"source_ref": f"mila-schlafprotokoll.md:{current_date}",
"created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"updated_at": None,
"deleted_at": None,
}
sleep_events.append(record)
# Write partition files
sleep_dir = DATA_DIR / "events" / "sleep"
sleep_dir.mkdir(parents=True, exist_ok=True)
feeding_dir = DATA_DIR / "events" / "feeding"
feeding_dir.mkdir(parents=True, exist_ok=True)
# Group sleep by month
sleep_by_month: Dict[str, List[Dict[str, Any]]] = {}
for ev in sleep_events:
m_str = ev["start"][:7] # YYYY-MM
sleep_by_month.setdefault(m_str, []).append(ev)
for m_str, m_events in sleep_by_month.items():
m_events.sort(key=lambda x: (x["start"], x["id"]))
with open(sleep_dir / f"{m_str}.jsonl", "w", encoding="utf-8") as f:
for ev in m_events:
f.write(json.dumps(ev, ensure_ascii=False) + "\n")
# Group feeding by month
feed_by_month: Dict[str, List[Dict[str, Any]]] = {}
for ev in feeding_events:
m_str = ev["start"][:7]
feed_by_month.setdefault(m_str, []).append(ev)
for m_str, m_events in feed_by_month.items():
m_events.sort(key=lambda x: (x["start"], x["id"]))
with open(feeding_dir / f"{m_str}.jsonl", "w", encoding="utf-8") as f:
for ev in m_events:
f.write(json.dumps(ev, ensure_ascii=False) + "\n")
return sleep_events, feeding_events
# ==============================================================================
# 4. Generate Audit Report
# ==============================================================================
def generate_audit_report(stated_sums: Dict[str, Dict[str, Any]]) -> str:
con = duckdb.connect(":memory:")
# Initialize schema
schema_dir = DATA_DIR / "_schema"
for sql_f in ["envelope.sql", "entities.sql", "views.sql"]:
con.execute((schema_dir / sql_f).read_text(encoding="utf-8"))
# Load foods
foods_file = DATA_DIR / "entities" / "foods.jsonl"
if foods_file.is_file():
con.execute(f"INSERT INTO foods SELECT * FROM read_json_auto('{foods_file}')")
# Load all nutrition events
nut_files = str(DATA_DIR / "events" / "nutrition" / "*.jsonl")
con.execute(f"INSERT INTO events SELECT * FROM read_json_auto('{nut_files}')")
# Query daily sums
db_sums = con.execute("SELECT log_date, total_protein_g, total_carbs_g, total_kcal, item_count FROM v_nutrition_daily ORDER BY log_date ASC").fetchall()
lines = []
lines.append("# 📊 Datastore Migration: Arithmetic Discrepancy Audit Report\n")
lines.append(f"**Generated:** {datetime.datetime.now(BERLIN_TZ).strftime('%Y-%m-%d %H:%M:%S')} ")
lines.append("**Source of Truth:** DuckDB engine over `data/events/nutrition/*.jsonl` \n")
lines.append("This audit compares the old handwritten Markdown `**Summe heute:**` against the mathematically exact DuckDB column summations.\n")
lines.append("| Date | Items | Hand Stated Protein | DuckDB Exact Protein | Δ Protein | Hand Stated kcal | DuckDB Exact kcal | Δ kcal |")
lines.append("| :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: |")
for row in db_sums:
d_str = str(row[0])
p_exact = row[1]
k_exact = int(row[3])
count = row[4]
stated = stated_sums.get(d_str, {})
p_hand = stated.get("protein_g", 0.0)
k_hand = int(stated.get("kcal", 0))
delta_p = round(p_exact - p_hand, 1)
delta_k = k_exact - k_hand
p_diff_str = f"**{delta_p:+.1f} g**" if abs(delta_p) > 0.1 else "✅ 0.0"
k_diff_str = f"**{delta_k:+d} kcal**" if abs(delta_k) > 2 else "✅ 0"
lines.append(f"| {d_str} | {count} | {p_hand:.1f} g | {p_exact:.1f} g | {p_diff_str} | {k_hand} | {k_exact} | {k_diff_str} |")
return "\n".join(lines)
if __name__ == "__main__":
print("1. Migrating Standard Foods...")
foods = migrate_standard_foods()
print(f" -> Migrated {len(foods)} food reference items.")
print("2. Migrating Nutrition Logs...")
nut_events, stated_sums = migrate_nutrition_logs()
print(f" -> Migrated {len(nut_events)} nutrition events across {len(stated_sums)} days.")
print("3. Migrating Mila Sleep & Feeding Logs...")
sleep_events, feed_events = migrate_mila_sleep_and_feeding()
print(f" -> Migrated {len(sleep_events)} sleep events and {len(feed_events)} bottle feeding events.")
print("4. Generating Discrepancy Audit Report...")
report = generate_audit_report(stated_sums)
report_file = WIKI_DIR / "reports" / "datastore-migration-audit.md"
report_file.parent.mkdir(parents=True, exist_ok=True)
report_file.write_text(report, encoding="utf-8")
print(f" -> Wrote audit report to {report_file}")
print("\n--- Summary ---")
print(report)
@@ -0,0 +1,203 @@
#!/usr/bin/env python3
# /// script
# dependencies = [
# "duckdb>=1.1.0",
# "pytz>=2024.1",
# "python-ulid>=3.0.0",
# ]
# ///
"""
morning_briefing.py — Generates the daily morning Hebamme sleep roundup for Mila,
computes 7d vs 30d trends, renders a visual trend chart via chart-to-chat,
and returns the message with MEDIA:<path> and the permanent website URL.
"""
from __future__ import annotations
import argparse
import datetime
import json
import os
import subprocess
import sys
from pathlib import Path
from zoneinfo import ZoneInfo
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
from datastore import DatastoreEngine, find_data_dir
BERLIN_TZ = ZoneInfo("Europe/Berlin")
MILA_BIRTHDATE = datetime.date(2026, 3, 14)
WEEKDAYS_SHORT = {
0: "Mo",
1: "Di",
2: "Mi",
3: "Do",
4: "Fr",
5: "Sa",
6: "So",
}
def find_chart_script() -> Optional[Path]:
candidates = [
SCRIPT_DIR.parent.parent / "chart-to-chat" / "scripts" / "chart.py",
Path("/home/node/mop-skills/mop-skills/skills/chart-to-chat/scripts/chart.py"),
Path("/Users/moritz/src/mop-skills/skills/chart-to-chat/scripts/chart.py"),
]
for c in candidates:
if c.is_file():
return c
return None
def generate_morning_briefing(force: bool = False) -> Dict[str, Any]:
data_dir = find_data_dir()
state_file = data_dir / ".mila_morning_state.json"
now_berlin = datetime.datetime.now(BERLIN_TZ)
today = now_berlin.date()
today_str = today.strftime("%Y-%m-%d")
# State check: only brief once per day unless forced
if not force and state_file.is_file():
try:
state = json.loads(state_file.read_text(encoding="utf-8"))
if state.get("last_briefed_date") == today_str:
return {
"already_briefed": True,
"date": today_str,
"message": None,
}
except Exception:
pass
engine = DatastoreEngine(data_dir)
con = engine.con
yesterday = today - datetime.timedelta(days=1)
yesterday_str = yesterday.strftime("%Y-%m-%d")
# 1. Yesterday's sleep
y_agg = con.execute(
f"""
SELECT total_sleep_hours, night_sleep_minutes, nap_count, nap_sleep_minutes
FROM v_sleep_daily
WHERE subject = 'mila' AND log_date = '{yesterday_str}'
"""
).fetchone()
y_tot = y_agg[0] if y_agg and y_agg[0] is not None else 0.0
y_night_min = y_agg[1] if y_agg and y_agg[1] is not None else 0
y_night_h, y_night_m = divmod(y_night_min, 60)
y_naps = y_agg[2] if y_agg and y_agg[2] is not None else 0
y_naps_min = y_agg[3] if y_agg and y_agg[3] is not None else 0
y_naps_h, y_naps_m = divmod(y_naps_min, 60)
# 2. 7-Day & 30-Day Averages
avg_7 = con.execute(
"""
SELECT ROUND(AVG(total_sleep_hours), 2), ROUND(AVG(night_sleep_minutes)/60.0, 2), ROUND(AVG(nap_count), 1)
FROM (SELECT total_sleep_hours, night_sleep_minutes, nap_count FROM v_sleep_daily WHERE subject = 'mila' ORDER BY log_date DESC LIMIT 7)
"""
).fetchone()
avg_7_tot = avg_7[0] if avg_7 and avg_7[0] is not None else y_tot
avg_7_night = avg_7[1] if avg_7 and avg_7[1] is not None else 0.0
avg_7_naps = avg_7[2] if avg_7 and avg_7[2] is not None else 0.0
avg_30 = con.execute(
"""
SELECT ROUND(AVG(total_sleep_hours), 2)
FROM (SELECT total_sleep_hours FROM v_sleep_daily WHERE subject = 'mila' ORDER BY log_date DESC LIMIT 30)
"""
).fetchone()
avg_30_tot = avg_30[0] if avg_30 and avg_30[0] is not None else avg_7_tot
# 3. 7-Day History Data for Chart
recent_7_rows = con.execute(
"""
SELECT log_date, ROUND(total_sleep_hours, 1)
FROM v_sleep_daily
WHERE subject = 'mila'
ORDER BY log_date ASC
LIMIT 7
"""
).fetchall()
chart_media_tag = ""
chart_script = find_chart_script()
if chart_script and len(recent_7_rows) >= 3:
chart_data = []
for r in recent_7_rows:
d_obj = datetime.date.fromisoformat(str(r[0]))
label = f"{WEEKDAYS_SHORT[d_obj.weekday()]} {d_obj.strftime('%d.%m.')}"
chart_data.append({"day": label, "hours": float(r[1])})
try:
chart_cmd = [
sys.executable,
str(chart_script),
"--title", "Mila Schlaf letzte 7 Tage (Stunden)",
"--type", "bar",
"--data", json.dumps(chart_data),
]
res = subprocess.run(chart_cmd, capture_output=True, text=True, timeout=15)
for line in res.stdout.splitlines():
if line.startswith("MEDIA:"):
chart_media_tag = "\n\n" + line.strip()
break
except Exception:
pass
# 4. Mila's exact age & pediatric reflection
days_old = (today - MILA_BIRTHDATE).days
months_old = days_old // 30.4375
trend_reflection = (
f"Mila schläft im 7-Tage-Schnitt **{avg_7_tot:.1f} h** (30-Tage-Basis: **{avg_30_tot:.1f} h**). "
f"Mit rund {int(months_old)} Monaten liegt das wunderbar im altersgerechten Entwicklungsfenster (12–15 Std/24h). "
f"Die Hauptschlafphase nachts konsolidiert sich stabil (Ø {avg_7_night:.1f} h), während tagsüber typischerweise 2–3 Schläfchen den Rhythmus tragen."
)
briefing_text = (
f"Guten Morgen! ☀️ Hier ist euer morgendlicher Schlafrückblick für Mila:\n\n"
f"**Gestern ({yesterday.strftime('%d.%m.')}):**\n"
f"• 24h Gesamtschlaf: **{y_tot:.1f} h**\n"
f"• Nachtschlaf: **{y_night_h} h {y_night_m:02d} min**\n"
f"• Tagesschläfchen: **{y_naps} Naps** ({y_naps_h} h {y_naps_m:02d} min)\n\n"
f"**Entwicklung & Trend:**\n"
f"• {trend_reflection}\n\n"
f"🔗 **Live-Dashboard:** https://family.haumdaucher.de/tracking/mila/\n"
f"{chart_media_tag}"
)
# Save state
try:
state_file.write_text(json.dumps({"last_briefed_date": today_str, "updated_at": now_berlin.isoformat()}), encoding="utf-8")
except Exception:
pass
return {
"already_briefed": False,
"date": today_str,
"message": briefing_text,
}
def main():
parser = argparse.ArgumentParser(description="Mila Morning Sleep Briefing")
parser.add_argument("--force", action="store_true", help="Force briefing even if already generated today")
args = parser.parse_args()
result = generate_morning_briefing(force=args.force)
if result.get("already_briefed"):
print(f"Already briefed for today ({result.get('date')}). Use --force to override.")
else:
print(result.get("message"))
if __name__ == "__main__":
main()
+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/env python3
"""
who_growth.py — WHO Child Growth Standards (Girls 0–24 Months)
Deterministic percentile and z-score calculations via LMS method
and reference curve data for Mila's growth tracking.
"""
from __future__ import annotations
import math
from typing import Any, Dict, List, Optional, Tuple
# WHO Child Growth Standards (Girls: Birth to 24 Months)
# Source: World Health Organization (WHO) Multi-centre Growth Reference Study (MGRS)
# https://www.who.int/tools/child-growth-standards/standards
WHO_GIRLS_WEIGHT = [
{"month": 0, "L": 0.3809, "M": 3.2322, "S": 0.14171, "p3": 2.4, "p50": 3.2, "p97": 4.2},
{"month": 1, "L": 0.1714, "M": 4.1873, "S": 0.13724, "p3": 3.2, "p50": 4.2, "p97": 5.4},
{"month": 2, "L": 0.0962, "M": 5.1282, "S": 0.13000, "p3": 4.0, "p50": 5.1, "p97": 6.5},
{"month": 3, "L": 0.0402, "M": 5.8458, "S": 0.12619, "p3": 4.6, "p50": 5.8, "p97": 7.4},
{"month": 4, "L": -0.0050, "M": 6.4237, "S": 0.12402, "p3": 5.1, "p50": 6.4, "p97": 8.1},
{"month": 5, "L": -0.0430, "M": 6.8985, "S": 0.12274, "p3": 5.5, "p50": 6.9, "p97": 8.7},
{"month": 6, "L": -0.0756, "M": 7.2970, "S": 0.12204, "p3": 5.8, "p50": 7.3, "p97": 9.2},
{"month": 7, "L": -0.1039, "M": 7.6422, "S": 0.12178, "p3": 6.1, "p50": 7.6, "p97": 9.6},
{"month": 8, "L": -0.1288, "M": 7.9487, "S": 0.12181, "p3": 6.3, "p50": 7.9, "p97": 10.0},
{"month": 9, "L": -0.1507, "M": 8.2254, "S": 0.12199, "p3": 6.6, "p50": 8.2, "p97": 10.4},
{"month": 10, "L": -0.1700, "M": 8.4800, "S": 0.12223, "p3": 6.8, "p50": 8.5, "p97": 10.7},
{"month": 11, "L": -0.1872, "M": 8.7192, "S": 0.12247, "p3": 7.0, "p50": 8.7, "p97": 11.0},
{"month": 12, "L": -0.2024, "M": 8.9481, "S": 0.12268, "p3": 7.1, "p50": 8.9, "p97": 11.3},
{"month": 13, "L": -0.2158, "M": 9.1699, "S": 0.12283, "p3": 7.3, "p50": 9.2, "p97": 11.6},
{"month": 14, "L": -0.2278, "M": 9.3870, "S": 0.12294, "p3": 7.5, "p50": 9.4, "p97": 11.9},
{"month": 15, "L": -0.2384, "M": 9.6008, "S": 0.12299, "p3": 7.7, "p50": 9.6, "p97": 12.2},
{"month": 16, "L": -0.2478, "M": 9.8124, "S": 0.12303, "p3": 7.8, "p50": 9.8, "p97": 12.5},
{"month": 17, "L": -0.2562, "M": 10.0226, "S": 0.12306, "p3": 8.0, "p50": 10.0, "p97": 12.7},
{"month": 18, "L": -0.2637, "M": 10.2315, "S": 0.12309, "p3": 8.2, "p50": 10.2, "p97": 13.0},
{"month": 19, "L": -0.2703, "M": 10.4393, "S": 0.12315, "p3": 8.3, "p50": 10.4, "p97": 13.3},
{"month": 20, "L": -0.2762, "M": 10.6464, "S": 0.12323, "p3": 8.5, "p50": 10.6, "p97": 13.5},
{"month": 21, "L": -0.2815, "M": 10.8534, "S": 0.12335, "p3": 8.7, "p50": 10.9, "p97": 13.8},
{"month": 22, "L": -0.2862, "M": 11.0608, "S": 0.12350, "p3": 8.8, "p50": 11.1, "p97": 14.1},
{"month": 23, "L": -0.2903, "M": 11.2688, "S": 0.12369, "p3": 9.0, "p50": 11.3, "p97": 14.3},
{"month": 24, "L": -0.2941, "M": 11.4775, "S": 0.12390, "p3": 9.2, "p50": 11.5, "p97": 14.6},
]
WHO_GIRLS_HEIGHT = [
{"month": 0, "L": 1.0, "M": 49.1477, "S": 0.03790, "p3": 45.6, "p50": 49.1, "p97": 52.7},
{"month": 1, "L": 1.0, "M": 53.6872, "S": 0.03640, "p3": 50.0, "p50": 53.7, "p97": 57.4},
{"month": 2, "L": 1.0, "M": 57.0673, "S": 0.03568, "p3": 53.2, "p50": 57.1, "p97": 60.9},
{"month": 3, "L": 1.0, "M": 59.8029, "S": 0.03520, "p3": 55.8, "p50": 59.8, "p97": 63.8},
{"month": 4, "L": 1.0, "M": 62.0899, "S": 0.03486, "p3": 58.0, "p50": 62.1, "p97": 66.2},
{"month": 5, "L": 1.0, "M": 64.0301, "S": 0.03463, "p3": 59.9, "p50": 64.0, "p97": 68.2},
{"month": 6, "L": 1.0, "M": 65.7311, "S": 0.03448, "p3": 61.5, "p50": 65.7, "p97": 70.0},
{"month": 7, "L": 1.0, "M": 67.2873, "S": 0.03441, "p3": 62.9, "p50": 67.3, "p97": 71.6},
{"month": 8, "L": 1.0, "M": 68.7498, "S": 0.03440, "p3": 64.3, "p50": 68.7, "p97": 73.2},
{"month": 9, "L": 1.0, "M": 70.1435, "S": 0.03444, "p3": 65.6, "p50": 70.1, "p97": 74.7},
{"month": 10, "L": 1.0, "M": 71.4818, "S": 0.03452, "p3": 66.8, "p50": 71.5, "p97": 76.1},
{"month": 11, "L": 1.0, "M": 72.7710, "S": 0.03464, "p3": 68.0, "p50": 72.8, "p97": 77.5},
{"month": 12, "L": 1.0, "M": 74.0150, "S": 0.03479, "p3": 69.2, "p50": 74.0, "p97": 78.9},
{"month": 13, "L": 1.0, "M": 75.2176, "S": 0.03496, "p3": 70.3, "p50": 75.2, "p97": 80.2},
{"month": 14, "L": 1.0, "M": 76.3817, "S": 0.03514, "p3": 71.3, "p50": 76.4, "p97": 81.4},
{"month": 15, "L": 1.0, "M": 77.5099, "S": 0.03534, "p3": 72.4, "p50": 77.5, "p97": 82.7},
{"month": 16, "L": 1.0, "M": 78.6055, "S": 0.03555, "p3": 73.3, "p50": 78.6, "p97": 83.9},
{"month": 17, "L": 1.0, "M": 79.6710, "S": 0.03576, "p3": 74.3, "p50": 79.7, "p97": 85.0},
{"month": 18, "L": 1.0, "M": 80.7079, "S": 0.03598, "p3": 75.2, "p50": 80.7, "p97": 86.2},
{"month": 19, "L": 1.0, "M": 81.7182, "S": 0.03620, "p3": 76.2, "p50": 81.7, "p97": 87.3},
{"month": 20, "L": 1.0, "M": 82.7036, "S": 0.03643, "p3": 77.0, "p50": 82.7, "p97": 88.4},
{"month": 21, "L": 1.0, "M": 83.6654, "S": 0.03666, "p3": 77.9, "p50": 83.7, "p97": 89.4},
{"month": 22, "L": 1.0, "M": 84.6040, "S": 0.03688, "p3": 78.7, "p50": 84.6, "p97": 90.5},
{"month": 23, "L": 1.0, "M": 85.5202, "S": 0.03711, "p3": 79.6, "p50": 85.5, "p97": 91.5},
{"month": 24, "L": 1.0, "M": 86.4153, "S": 0.03734, "p3": 80.3, "p50": 86.4, "p97": 92.5},
]
def _interpolate_lms(table: List[Dict[str, float]], age_months: float) -> Tuple[float, float, float, float, float, float]:
"""Linearly interpolates L, M, S, p3, p50, p97 for a given age in months."""
m_clamped = max(0.0, min(float(table[-1]["month"]), float(age_months)))
m_low = int(math.floor(m_clamped))
m_high = int(math.ceil(m_clamped))
r_low = table[m_low]
if m_low == m_high:
return r_low["L"], r_low["M"], r_low["S"], r_low["p3"], r_low["p50"], r_low["p97"]
r_high = table[m_high]
frac = m_clamped - m_low
L = r_low["L"] + frac * (r_high["L"] - r_low["L"])
M = r_low["M"] + frac * (r_high["M"] - r_low["M"])
S = r_low["S"] + frac * (r_high["S"] - r_low["S"])
p3 = r_low["p3"] + frac * (r_high["p3"] - r_low["p3"])
p50 = r_low["p50"] + frac * (r_high["p50"] - r_low["p50"])
p97 = r_low["p97"] + frac * (r_high["p97"] - r_low["p97"])
return L, M, S, p3, p50, p97
def calculate_percentile(metric: str, value: float, age_months: float) -> Dict[str, Any]:
"""
Computes exact WHO z-score and percentile using the LMS formula.
metric: 'weight' (in kg) or 'height' (in cm)
"""
table = WHO_GIRLS_WEIGHT if metric == "weight" else WHO_GIRLS_HEIGHT
L, M, S, p3, p50, p97 = _interpolate_lms(table, age_months)
if value <= 0 or M <= 0 or S <= 0:
return {
"value": value,
"age_months": round(age_months, 2),
"z_score": 0.0,
"percentile": 50.0,
"percentile_str": "P50",
"p3": round(p3, 2),
"p50": round(p50, 2),
"p97": round(p97, 2),
"in_normal_range": True,
"status": "normal",
}
# Box-Cox power exponential LMS formula
if abs(L) > 1e-6:
z = ((value / M) ** L - 1.0) / (L * S)
else:
z = math.log(value / M) / S
# Cumulative standard normal distribution
p = 0.5 * (1.0 + math.erf(z / math.sqrt(2.0))) * 100.0
p_clamped = max(0.1, min(99.9, p))
in_normal = 3.0 <= p_clamped <= 97.0
if p_clamped < 3.0:
status = "unter P3"
elif p_clamped > 97.0:
status = "über P97"
else:
status = "im WHO-Normbereich"
return {
"value": value,
"age_months": round(age_months, 2),
"z_score": round(z, 2),
"percentile": round(p_clamped, 1),
"percentile_str": f"P{int(round(p_clamped))}",
"p3": round(p3, 2),
"p50": round(p50, 2),
"p97": round(p97, 2),
"in_normal_range": in_normal,
"status": status,
}
def format_growth_feedback(
weight_kg: Optional[float],
height_cm: Optional[float],
age_months: float,
date_str: str,
) -> str:
"""Formats clear, comforting feedback for CLI and Hebamme agent responses."""
parts = []
if weight_kg is not None:
pw = calculate_percentile("weight", weight_kg, age_months)
parts.append(f"{weight_kg:.2f} kg ({pw['percentile_str']} · WHO Median: {pw['p50']:.1f} kg)")
if height_cm is not None:
ph = calculate_percentile("height", height_cm, age_months)
parts.append(f"{height_cm:.1f} cm ({ph['percentile_str']} · WHO Median: {ph['p50']:.1f} cm)")
if not parts:
return f"📏 Messung ({date_str}) ohne Werte geloggt."
details = " · ".join(parts)
return f"📏 Wachstum ({date_str}, ~{age_months:.1f} Mon): {details} [Normbereich: P3–P97]"
+899
View File
@@ -0,0 +1,899 @@
#!/usr/bin/env python3
import datetime
import json
import os
import shutil
import tempfile
from pathlib import Path
import pytest
from datastore import DatastoreEngine
@pytest.fixture
def temp_datastore():
"""Creates a temporary datastore directory with schemas for testing."""
temp_dir = Path(tempfile.mkdtemp())
data_dir = temp_dir / "data"
data_dir.mkdir(parents=True)
# Copy schemas from knowledge-wiki/data/_schema if available, or create them
schema_src = Path("/Users/moritz/src/infrapuzzle/knowledge-wiki/data/_schema")
if schema_src.is_dir():
shutil.copytree(schema_src, data_dir / "_schema")
(data_dir / "_meta.json").write_text("{}")
engine = DatastoreEngine(data_dir=data_dir)
yield engine
shutil.rmtree(temp_dir)
def test_nutrition_explicit_logging(temp_datastore: DatastoreEngine):
"""Verifies logging food with explicit macro values and daily aggregation."""
res1 = temp_datastore.add_event(
kind="nutrition.intake",
subject="moritz",
actor="agent:koch",
start="2026-10-06T08:30:00+02:00",
meal="Frühstück",
payload={
"item": "Protein Porridge",
"qty": 300,
"unit": "g",
"protein_g": 35.5,
"carbs_g": 45.0,
"sugar_g": 5.0,
"fat_g": 8.0,
"fiber_g": 6.0,
"salt_g": 0.5,
"kcal": 420.0,
},
)
assert res1["status"] == "success"
assert res1["event"]["id"] is not None
res2 = temp_datastore.add_event(
kind="nutrition.intake",
subject="moritz",
actor="agent:koch",
start="2026-10-06T12:30:00+02:00",
meal="Mittagessen",
payload={
"item": "Hähnchen mit Reis",
"qty": 400,
"unit": "g",
"protein_g": 50.0,
"carbs_g": 60.0,
"sugar_g": 2.0,
"fat_g": 10.0,
"fiber_g": 4.0,
"salt_g": 1.2,
"kcal": 550.0,
},
)
assert res2["status"] == "success"
summary = temp_datastore.get_summary(subject="moritz", date_str="2026-10-06")
nut = summary["nutrition"]
assert nut["item_count"] == 2
assert nut["total_protein_g"] == 85.5
assert nut["total_carbs_g"] == 105.0
assert nut["total_kcal"] == 970.0
def test_nutrition_from_food_reference(temp_datastore: DatastoreEngine):
"""Verifies that linking a food_id computes macros automatically from foods.per_100."""
temp_datastore.put_entity(
collection="foods",
record={
"id": "oatly-haferdrink",
"name": "Oatly Haferdrink",
"per_100": {
"kcal": 61.0,
"protein_g": 1.1,
"carbs_g": 7.1,
"sugar_g": 3.4,
"fat_g": 3.0,
"fiber_g": 0.8,
"salt_g": 0.1,
},
},
)
# Log 200 ml Oatly Haferdrink
res = temp_datastore.add_event(
kind="nutrition.intake",
subject="moritz",
actor="agent:koch",
start="2026-10-06T09:00:00+02:00",
payload={
"item": "Hafermilch im Kaffee",
"food_id": "oatly-haferdrink",
"qty": 200,
"unit": "ml",
},
)
assert res["status"] == "success"
# Query v_nutrition
rows = temp_datastore.query("SELECT protein_g, carbs_g, kcal FROM v_nutrition WHERE food_id = 'oatly-haferdrink'")
assert len(rows) == 1
assert round(rows[0]["protein_g"], 2) == 2.2
assert round(rows[0]["carbs_g"], 2) == 14.2
assert round(rows[0]["kcal"], 0) == 122.0
def test_baby_sleep_open_and_close(temp_datastore: DatastoreEngine):
"""Verifies open-ended interval sleep logging and stop_event duration calculations."""
start_time = "2026-10-06T13:00:00+02:00"
end_time = "2026-10-06T14:30:00+02:00"
# 1. Start open sleep
res_start = temp_datastore.add_event(
kind="sleep.session",
subject="mila",
actor="agent:hebamme",
start=start_time,
payload={"type": "nap", "location": "bett"},
)
assert res_start["status"] == "success"
assert res_start["event"]["end"] is None
# Verify open status
summary = temp_datastore.get_summary(subject="mila", date_str="2026-10-06")
assert "open_sleep" in summary
assert summary["open_sleep"]["sleep_type"] == "nap"
# 2. Prevent overlapping second open sleep
with pytest.raises(ValueError, match="hat bereits ein aktives Schlaf-Event"):
temp_datastore.add_event(
kind="sleep.session",
subject="mila",
actor="agent:hebamme",
start="2026-10-06T13:30:00+02:00",
payload={"type": "nap"},
)
# 3. Stop sleep
res_stop = temp_datastore.stop_event(
kind="sleep.session",
subject="mila",
end_time=end_time,
)
assert res_stop["status"] == "success"
assert res_stop["duration_minutes"] == 90
assert res_stop["duration_formatted"] == "1 h 30 min"
# 4. Verify aggregated summary
summary2 = temp_datastore.get_summary(subject="mila", date_str="2026-10-06")
slp = summary2["sleep"]
assert slp["nap_count"] == 1
assert slp["nap_sleep_minutes"] == 90
assert slp["total_sleep_hours"] == 1.5
def test_soft_delete_event(temp_datastore: DatastoreEngine):
"""Verifies that soft-deleted events are excluded from views."""
res = temp_datastore.add_event(
kind="nutrition.intake",
subject="moritz",
actor="agent:koch",
start="2026-10-06T07:00:00+02:00",
payload={"item": "Snack", "qty": 50, "unit": "g", "protein_g": 5.0, "kcal": 100.0},
)
event_id = res["event"]["id"]
# Verify present in summary
sum1 = temp_datastore.get_summary(subject="moritz", date_str="2026-10-06")
assert sum1["nutrition"]["total_kcal"] == 100.0
# Delete
del_res = temp_datastore.delete_event(event_id)
assert del_res["status"] == "success"
# Verify removed from view
sum2 = temp_datastore.get_summary(subject="moritz", date_str="2026-10-06")
assert sum2["nutrition"]["total_kcal"] == 0.0
def test_places_spatial_radius_search_both_engines(temp_datastore: DatastoreEngine):
"""Verifies distance calculation using both DuckDB spatial extension and pure-SQL fallback macro."""
temp_datastore.put_entity(
collection="places",
record={
"id": "spitalgarten-regensburg",
"name": "Spitalgarten Regensburg",
"category": "biergarten",
"lat": 49.0223,
"lon": 12.0978,
"address": "St.-Katharinen-Platz 1, 93059 Regensburg",
"google_maps_url": "https://maps.app.goo.gl/spitalgarten",
"tags": ["biergarten", "spielplatz"],
},
)
temp_datastore.put_entity(
collection="places",
record={
"id": "walhalla-donaustauf",
"name": "Walhalla",
"category": "sightseeing",
"lat": 49.0315,
"lon": 12.2245,
"address": "Walhallastraße 48, 93093 Donaustauf",
"google_maps_url": "https://maps.app.goo.gl/walhalla",
"tags": ["ausflug", "kultur"],
},
)
# 1. Test with spatial extension enabled
places_1k = temp_datastore.find_places_near(lat=49.0205, lon=12.0975, radius_m=1000.0)
assert len(places_1k) == 1
assert places_1k[0]["id"] == "spitalgarten-regensburg"
assert 150.0 < places_1k[0]["distance_meters"] < 300.0
# 2. Test fallback macro by forcing has_spatial_extension = False
temp_datastore.has_spatial_extension = False
places_fallback_1k = temp_datastore.find_places_near(lat=49.0205, lon=12.0975, radius_m=1000.0)
assert len(places_fallback_1k) == 1
assert places_fallback_1k[0]["id"] == "spitalgarten-regensburg"
assert 150.0 < places_fallback_1k[0]["distance_meters"] < 300.0
# Differences between Spheroid (WGS84 ellipsoid) and Haversine (sphere) are < 3m
assert abs(places_1k[0]["distance_meters"] - places_fallback_1k[0]["distance_meters"]) < 5.0
def test_garmin_daily_and_daily_balance(temp_datastore: DatastoreEngine):
"""Verifies that Garmin daily summaries join with nutrition to calculate caloric deficit/surplus deterministically."""
# 1. Log nutrition intake on 2026-10-01
temp_datastore.add_event(
kind="nutrition.intake",
subject="moritz",
actor="agent:koch",
start="2026-10-01T12:30:00+02:00",
payload={"item": "Mittagessen", "qty": 400, "unit": "g", "protein_g": 60.0, "kcal": 800.0},
)
temp_datastore.add_event(
kind="nutrition.intake",
subject="moritz",
actor="agent:koch",
start="2026-10-01T19:30:00+02:00",
payload={"item": "Abendessen", "qty": 500, "unit": "g", "protein_g": 80.0, "kcal": 1346.0},
)
# 2. Add Garmin daily summary for 2026-10-01
garmin_dir = temp_datastore.data_dir / "garmin" / "daily"
garmin_dir.mkdir(parents=True, exist_ok=True)
garmin_file = garmin_dir / "2026-10.jsonl"
with open(garmin_file, "a", encoding="utf-8") as f:
f.write(
json.dumps(
{
"calendarDate": "2026-10-01",
"totalKilocalories": 2890,
"activeKilocalories": 650,
"bmrKilocalories": 2240,
"totalSteps": 10500,
"restingHeartRate": 51,
}
)
+ "\n"
)
# 3. Reload data into datastore
temp_datastore._load_all_data()
# 4. Query v_daily_balance
res = temp_datastore.query("SELECT * FROM v_daily_balance WHERE log_date = '2026-10-01'")
assert len(res) == 1
row = res[0]
assert row["subject"] == "moritz"
assert row["intake_kcal"] == 2146.0 # 800 + 1346
assert row["protein_g"] == 140.0 # 60 + 80
assert row["expenditure_kcal"] == 2890
assert row["active_kcal"] == 650
assert row["rmr_kcal"] == 2240
assert row["steps"] == 10500
assert row["balance_kcal"] == -744.0 # 2146 - 2890
assert row["balance_status"] == "Defizit"
def test_add_event_with_since(temp_datastore: DatastoreEngine):
"""Verifies that relative offsets like '15m' calculate start time deterministically in Berlin timezone."""
from zoneinfo import ZoneInfo
berlin_tz = ZoneInfo("Europe/Berlin")
before = datetime.datetime.now(berlin_tz)
res = temp_datastore.add_event(
kind="sleep.session",
subject="mila",
actor="agent:hebamme",
since="15m",
payload={"type": "nap", "location": "bett"},
)
after = datetime.datetime.now(berlin_tz)
assert res["status"] == "success"
event = res["event"]
assert event["end"] is None
start_dt = datetime.datetime.fromisoformat(event["start"])
assert start_dt.tzinfo is not None
# Verify Berlin timezone offset (+02:00 in CEST or +01:00 in CET)
assert start_dt.utcoffset() == before.utcoffset()
# The logged start must be ~15 minutes before the test run
expected_approx = before - datetime.timedelta(minutes=15)
assert abs((start_dt - expected_approx).total_seconds()) < 5
# Test error if both start and since are provided
with pytest.raises(ValueError, match="Cannot specify both 'since' and 'start'"):
temp_datastore.add_event(
kind="nutrition.intake",
subject="moritz",
actor="agent:koch",
start="2026-10-06T12:00:00+02:00",
since="10m",
payload={"item": "Snack", "qty": 10},
)
# Test invalid since format
with pytest.raises(ValueError, match="Unrecognized duration format"):
temp_datastore.add_event(
kind="nutrition.intake",
subject="moritz",
actor="agent:koch",
since="invalid_format",
payload={"item": "Snack", "qty": 10},
)
def test_sleep_over_midnight_attribution(temp_datastore: DatastoreEngine):
"""Verifies that overnight sleep (Wed evening -> Thu morning) stays attributed to Wednesday."""
# Sleep starts Wednesday 19:15
res_start = temp_datastore.add_event(
kind="sleep.session",
subject="mila",
actor="agent:hebamme",
start="2026-10-07T19:15:00+02:00",
payload={"type": "night", "location": "bett"},
)
assert res_start["status"] == "success"
# Sleep ends Thursday 07:00
res_stop = temp_datastore.stop_event(
kind="sleep.session",
subject="mila",
end_time="2026-10-08T07:00:00+02:00",
)
assert res_stop["status"] == "success"
# Duration: 11 hours 45 min = 705 minutes
assert res_stop["duration_minutes"] == 705
# Check v_sleep view: log_date must be the day it STARTED (Wednesday 2026-10-07)
rows = temp_datastore.query("SELECT log_date, duration_minutes, sleep_type FROM v_sleep WHERE subject = 'mila'")
assert len(rows) == 1
assert rows[0]["log_date"] == "2026-10-07"
assert rows[0]["sleep_type"] == "night"
assert rows[0]["duration_minutes"] == 705
# Check daily summary for Wednesday: must contain the full 11.75 hours night sleep
wed_summary = temp_datastore.get_summary(subject="mila", date_str="2026-10-07")
assert "sleep" in wed_summary
assert wed_summary["sleep"]["night_sleep_minutes"] == 705
assert wed_summary["sleep"]["total_sleep_hours"] == 11.75
# Check daily summary for Thursday: must NOT contain Wednesday night's sleep
thu_summary = temp_datastore.get_summary(subject="mila", date_str="2026-10-08")
assert "sleep" not in thu_summary or thu_summary["sleep"] is None
def test_duplicate_suppression_nutrition(temp_datastore: DatastoreEngine):
"""Verifies that duplicate intake events within 15 minutes are suppressed idempotently."""
start_time = "2026-10-09T08:00:00+02:00"
payload = {
"item": "Protein Porridge",
"qty": 300,
"unit": "g",
"protein_g": 35.0,
"kcal": 400.0,
}
# 1. First logging
res1 = temp_datastore.add_event(
kind="nutrition.intake",
subject="moritz",
actor="agent:koch",
start=start_time,
payload=payload,
)
assert res1["status"] == "success"
assert res1.get("duplicate") is not True
first_id = res1["event"]["id"]
# 2. Duplicate logging 5 minutes later
res2 = temp_datastore.add_event(
kind="nutrition.intake",
subject="moritz",
actor="agent:koch",
start="2026-10-09T08:05:00+02:00",
payload=payload,
)
assert res2["status"] == "success"
assert res2.get("duplicate") is True
assert res2["event"]["id"] == first_id
# Verify DuckDB summary: only 1 item counted
sum1 = temp_datastore.get_summary(subject="moritz", date_str="2026-10-09")
assert sum1["nutrition"]["item_count"] == 1
assert sum1["nutrition"]["total_kcal"] == 400.0
# 3. Explicit bypass with allow_duplicate=True
res3 = temp_datastore.add_event(
kind="nutrition.intake",
subject="moritz",
actor="agent:koch",
start="2026-10-09T08:06:00+02:00",
payload=payload,
allow_duplicate=True,
)
assert res3["status"] == "success"
assert res3.get("duplicate") is not True
assert res3["event"]["id"] != first_id
# Verify DuckDB summary: 2 items counted
sum2 = temp_datastore.get_summary(subject="moritz", date_str="2026-10-09")
assert sum2["nutrition"]["item_count"] == 2
assert sum2["nutrition"]["total_kcal"] == 800.0
# 4. Different item within 15 minutes is NOT suppressed
res4 = temp_datastore.add_event(
kind="nutrition.intake",
subject="moritz",
actor="agent:koch",
start="2026-10-09T08:07:00+02:00",
payload={"item": "Apfel", "qty": 150, "kcal": 80.0},
)
assert res4.get("duplicate") is not True
# 5. Same item outside 15 minutes window is NOT suppressed
res5 = temp_datastore.add_event(
kind="nutrition.intake",
subject="moritz",
actor="agent:koch",
start="2026-10-09T09:30:00+02:00",
payload=payload,
)
assert res5.get("duplicate") is not True
def test_duplicate_suppression_sleep(temp_datastore: DatastoreEngine):
"""Verifies that starting the same sleep session within 15 minutes returns existing open session."""
res1 = temp_datastore.add_event(
kind="sleep.session",
subject="mila",
actor="agent:hebamme",
start="2026-10-09T13:00:00+02:00",
payload={"type": "nap", "location": "bett"},
)
assert res1["status"] == "success"
first_id = res1["event"]["id"]
# Repeat 3 minutes later: should return existing open session without throwing ValueError
res2 = temp_datastore.add_event(
kind="sleep.session",
subject="mila",
actor="agent:hebamme",
start="2026-10-09T13:03:00+02:00",
payload={"type": "nap", "location": "bett"},
)
assert res2["status"] == "success"
assert res2.get("duplicate") is True
assert res2["event"]["id"] == first_id
# Stop session
temp_datastore.stop_event(
kind="sleep.session",
subject="mila",
end_time="2026-10-09T14:00:00+02:00",
)
def test_cli_kind_flag_and_positional(temp_datastore: DatastoreEngine):
"""Verifies CLI accepts both positional and --kind flag for add and stop commands."""
import subprocess
import sys
from pathlib import Path
script_path = Path(__file__).resolve().parent.parent / "scripts" / "datastore.py"
data_dir = str(temp_datastore.data_dir)
# 1. Add using --kind flag
proc_add = subprocess.run(
[
sys.executable,
str(script_path),
"add",
"--kind",
"nutrition.intake",
"--subject",
"moritz",
"--payload",
'{"item": "CLI Snack", "qty": 50, "kcal": 100}',
"--start",
"2026-10-09T15:00:00+02:00",
],
env=dict(os.environ, OPENCLAW_DATA_DIR=data_dir),
capture_output=True,
text=True,
)
assert proc_add.returncode == 0
data = json.loads(proc_add.stdout)
assert data["status"] == "success"
assert data["event"]["kind"] == "nutrition.intake"
# 2. Add open sleep using positional kind
proc_sleep_start = subprocess.run(
[
sys.executable,
str(script_path),
"add",
"sleep.session",
"--subject",
"mila",
"--payload",
'{"type": "nap"}',
"--start",
"2026-10-09T15:00:00+02:00",
],
env=dict(os.environ, OPENCLAW_DATA_DIR=data_dir),
capture_output=True,
text=True,
)
assert proc_sleep_start.returncode == 0
# 3. Stop sleep using --kind flag
proc_sleep_stop = subprocess.run(
[
sys.executable,
str(script_path),
"stop",
"--kind",
"sleep.session",
"--subject",
"mila",
"--end",
"2026-10-09T16:00:00+02:00",
],
env=dict(os.environ, OPENCLAW_DATA_DIR=data_dir),
capture_output=True,
text=True,
)
assert proc_sleep_stop.returncode == 0
stop_data = json.loads(proc_sleep_stop.stdout)
assert stop_data["status"] == "success"
assert stop_data["duration_minutes"] == 60
def test_open_session_warning_without_silent_deckeln(temp_datastore: DatastoreEngine):
"""Verifies that an unclosed sleep session is NOT silently deckelt, but alerts on every new log."""
# 1. Open night sleep session yesterday at 19:15
res_night = temp_datastore.add_event(
kind="sleep.session",
subject="mila",
actor="agent:hebamme",
start="2026-10-08T19:15:00+02:00",
payload={"type": "night", "location": "bett"},
)
assert res_night["status"] == "success"
night_id = res_night["event"]["id"]
# 2. Add midday nap today (12:35 to 12:55)
res_nap = temp_datastore.add_event(
kind="sleep.session",
subject="mila",
actor="agent:hebamme",
start="2026-10-09T12:35:00+02:00",
end="2026-10-09T12:55:00+02:00",
payload={"type": "nap", "location": "bett"},
)
assert res_nap["status"] == "success"
# 3. Verify night session is STILL open (never silently deckelt!)
rows = temp_datastore.query(f"SELECT id, start, \"end\", estimated FROM events WHERE id = '{night_id}'")
assert len(rows) == 1
assert rows[0]["end"] is None
assert rows[0]["estimated"] is False
# 4. Verify that feedback gives prominent notice about the open session
feedback = res_nap["feedback"]
assert "open_session_warning" in feedback
assert "ist noch offen" in feedback["open_session_warning"]
assert "ist noch offen" in feedback["day_total_formatted"]
def test_stop_event_by_id(temp_datastore: DatastoreEngine):
"""Verifies closing a specific open sleep session by ID when multiple might exist."""
res1 = temp_datastore.add_event(
kind="sleep.session",
subject="mila",
actor="agent:hebamme",
start="2026-10-08T19:15:00+02:00",
payload={"type": "night"},
)
id1 = res1["event"]["id"]
res2 = temp_datastore.add_event(
kind="sleep.session",
subject="mila",
actor="agent:hebamme",
start="2026-10-09T12:35:00+02:00",
payload={"type": "nap"},
)
id2 = res2["event"]["id"]
# Close specific event id1
res_stop = temp_datastore.stop_event(
kind="sleep.session",
subject="mila",
event_id=id1,
end_time="2026-10-09T07:15:00+02:00",
)
assert res_stop["status"] == "success"
assert res_stop["event_id"] == id1
# Verify id1 is closed and id2 is still open
row1 = temp_datastore.query(f"SELECT \"end\" FROM events WHERE id = '{id1}'")[0]
row2 = temp_datastore.query(f"SELECT \"end\" FROM events WHERE id = '{id2}'")[0]
assert row1["end"] is not None
assert row2["end"] is None
def test_cli_rejects_invalid_kind(temp_datastore: DatastoreEngine):
"""Verifies CLI rejects boolean words or strings without dots as kind."""
import subprocess
import sys
from pathlib import Path
script_path = Path(__file__).resolve().parent.parent / "scripts" / "datastore.py"
data_dir = str(temp_datastore.data_dir)
proc = subprocess.run(
[
sys.executable,
str(script_path),
"add",
"true",
"--subject",
"moritz",
"--payload",
'{"item": "Snack", "qty": 100}',
],
env=dict(os.environ, OPENCLAW_DATA_DIR=data_dir),
capture_output=True,
text=True,
)
assert proc.returncode != 0
assert "Invalid kind 'true'" in proc.stderr
def test_diaper_logging(temp_datastore: DatastoreEngine):
"""Verifies diaper change events, feedback, and DuckDB views."""
res = temp_datastore.add_event(
kind="diaper.change",
subject="mila",
actor="agent:hebamme",
start="2026-10-11T09:45:00+02:00",
payload={"pee": True, "poo": True, "texture": "normal"},
)
assert res["status"] == "success"
feedback = res["feedback"]
assert feedback["total_changes"] == 1
assert feedback["wet_count"] == 1
assert feedback["dirty_count"] == 1
assert "1 gewechselt" in feedback["day_total_formatted"]
# Add second diaper (only pee)
res2 = temp_datastore.add_event(
kind="diaper.change",
subject="mila",
actor="agent:hebamme",
start="2026-10-11T12:30:00+02:00",
payload={"pee": True, "poo": False},
)
assert res2["status"] == "success"
feedback2 = res2["feedback"]
assert feedback2["total_changes"] == 2
assert feedback2["wet_count"] == 2
assert feedback2["dirty_count"] == 1
# Check v_diaper and v_diaper_daily views
rows = temp_datastore.query("SELECT * FROM v_diaper WHERE subject = 'mila'")
assert len(rows) == 2
daily = temp_datastore.query("SELECT * FROM v_diaper_daily WHERE subject = 'mila' AND log_date = '2026-10-11'")
assert len(daily) == 1
assert daily[0]["total_changes"] == 2
assert daily[0]["wet_count"] == 2
assert daily[0]["dirty_count"] == 1
def test_feeding_logging(temp_datastore: DatastoreEngine):
"""Verifies feeding bottle events, feedback, and DuckDB views."""
res = temp_datastore.add_event(
kind="feeding.bottle",
subject="mila",
actor="agent:hebamme",
start="2026-10-11T06:30:00+02:00",
payload={"amount_ml": 180, "consumed_ml": 160, "milk_type": "pre"},
)
assert res["status"] == "success"
feedback = res["feedback"]
assert feedback["bottle_count"] == 1
assert feedback["total_consumed_ml"] == 160.0
assert "160 ml getrunken" in feedback["day_total_formatted"]
# Check v_feeding_daily view
daily = temp_datastore.query("SELECT * FROM v_feeding_daily WHERE subject = 'mila' AND log_date = '2026-10-11'")
assert len(daily) == 1
assert daily[0]["bottle_count"] == 1
assert daily[0]["total_consumed_ml"] == 160.0
def test_growth_logging(temp_datastore: DatastoreEngine):
"""Verifies baby growth measurements, WHO percentile feedback, and DuckDB views."""
# Ensure Mila is in people table with birthdate
temp_datastore.put_entity(
collection="people",
record={"id": "mila", "name": "Mila Graf", "birthdate": "2026-03-14", "role": "child"},
)
# Log measurement at approx 6.9 months (2026-10-12)
res = temp_datastore.add_event(
kind="growth.measurement",
subject="mila",
actor="agent:hebamme",
start="2026-10-12T10:00:00+02:00",
payload={"weight_kg": 7.4, "height_cm": 67.5},
note="U-Untersuchung Vorbereitung",
)
assert res["status"] == "success"
fb = res["feedback"]
assert "weight_kg" in fb
assert fb["weight_kg"] == 7.4
assert fb["height_cm"] == 67.5
assert "weight_percentile" in fb
assert 30 <= fb["weight_percentile"] <= 60 # around P41
assert 45 <= fb["height_percentile"] <= 70 # around P56
assert "Normbereich" in fb["day_total_formatted"]
# Verify DuckDB views
growth_rows = temp_datastore.query("SELECT * FROM v_growth WHERE subject = 'mila'")
assert len(growth_rows) == 1
assert growth_rows[0]["weight_kg"] == 7.4
assert growth_rows[0]["height_cm"] == 67.5
assert growth_rows[0]["note"] == "U-Untersuchung Vorbereitung"
daily_rows = temp_datastore.query("SELECT * FROM v_growth_daily WHERE subject = 'mila'")
assert len(daily_rows) == 1
assert daily_rows[0]["measurement_count"] == 1
# Verify get_summary returns growth_latest
summary = temp_datastore.get_summary(subject="mila", date_str="2026-10-12")
assert "growth_latest" in summary
assert summary["growth_latest"]["weight_kg"] == 7.4
assert summary["growth_latest"]["height_cm"] == 67.5
def test_feeding_solid_brei_logging(temp_datastore: DatastoreEngine):
"""Verifies feeding solid (Brei/Beikost) events, feedback formatting, and DuckDB views."""
res = temp_datastore.add_event(
kind="feeding.solid",
subject="mila",
actor="agent:mila_tracker",
start="2026-10-12T12:00:00+02:00",
meal="Mittagessen",
payload={
"item": "Kürbis-Kartoffel-Brei",
"amount_g": 130,
"kcal": 95,
},
note="Guter Appetit",
)
assert res["status"] == "success"
fb = res["feedback"]
assert fb["solid_count"] == 1
assert fb["total_solid_g"] == 130.0
assert "130 g" in fb["day_total_formatted"]
assert "Brei" in fb["day_total_formatted"]
assert res["event"]["payload"]["item"] == "Kürbis-Kartoffel-Brei"
# Verify v_feeding view
rows = temp_datastore.query("SELECT * FROM v_feeding WHERE subject = 'mila' AND kind = 'feeding.solid'")
assert len(rows) == 1
assert rows[0]["item"] == "Kürbis-Kartoffel-Brei"
assert rows[0]["solid_g"] == 130.0
assert rows[0]["kcal"] == 95.0
# Verify v_feeding_daily combined aggregation
daily = temp_datastore.query("SELECT * FROM v_feeding_daily WHERE subject = 'mila' AND log_date = '2026-10-12'")
assert len(daily) == 1
assert daily[0]["solid_count"] == 1
assert daily[0]["total_solid_g"] == 130.0
assert daily[0]["total_kcal"] == 95.0
def test_three_databases_isolation_and_reloading(temp_datastore: DatastoreEngine):
"""Verifies that events are routed to sleep/, nutrition/, mila/ databases and reloaded correctly."""
# 1. Sleep session -> sleep/
res_sleep = temp_datastore.add_event(
kind="sleep.session",
subject="mila",
actor="agent:mila_tracker",
start="2026-10-13T09:00:00+02:00",
end="2026-10-13T10:00:00+02:00",
payload={"type": "nap", "location": "bett"},
)
assert res_sleep["status"] == "success"
# 2. Nutrition intake -> nutrition/
res_nut = temp_datastore.add_event(
kind="nutrition.intake",
subject="moritz",
actor="agent:nutrition_tracker",
start="2026-10-13T08:00:00+02:00",
meal="Frühstück",
payload={"item": "Rührei", "qty": 150, "unit": "g", "protein_g": 18.0, "kcal": 210.0},
)
assert res_nut["status"] == "success"
# 3. Diaper change -> mila/
res_diaper = temp_datastore.add_event(
kind="diaper.change",
subject="mila",
actor="agent:mila_tracker",
start="2026-10-13T10:15:00+02:00",
payload={"pee": True, "poo": False},
)
assert res_diaper["status"] == "success"
# Verify file locations on disk
data_dir = temp_datastore.data_dir
sleep_files = list((data_dir / "sleep").glob("*.jsonl"))
nutrition_files = list((data_dir / "nutrition").glob("*.jsonl"))
mila_files = list((data_dir / "mila").glob("*.jsonl"))
assert len(sleep_files) == 1
assert len(nutrition_files) == 1
assert len(mila_files) == 1
assert not (data_dir / "events").exists()
# Re-instantiate engine from disk (cold load test)
reloaded_engine = DatastoreEngine(data_dir=data_dir)
# Check that events union view contains all 3 records
all_events = reloaded_engine.query("SELECT id, kind, subject FROM events ORDER BY start ASC")
assert len(all_events) == 3
kinds = [e["kind"] for e in all_events]
assert "nutrition.intake" in kinds
assert "sleep.session" in kinds
assert "diaper.change" in kinds
# Check domain daily views
s_view = reloaded_engine.query("SELECT total_sleep_minutes FROM v_sleep_daily WHERE subject = 'mila' AND log_date = '2026-10-13'")
assert len(s_view) == 1
assert s_view[0]["total_sleep_minutes"] == 60
n_view = reloaded_engine.query("SELECT total_kcal FROM v_nutrition_daily WHERE subject = 'moritz' AND log_date = '2026-10-13'")
assert len(n_view) == 1
assert n_view[0]["total_kcal"] == 210.0
d_view = reloaded_engine.query("SELECT total_changes, wet_count FROM v_diaper_daily WHERE subject = 'mila' AND log_date = '2026-10-13'")
assert len(d_view) == 1
assert d_view[0]["total_changes"] == 1
assert d_view[0]["wet_count"] == 1
@@ -0,0 +1,70 @@
import json
import pytest
import shutil
import tempfile
from pathlib import Path
from datastore import DatastoreEngine
from garmin_sync import GarminSyncClient, GarminMirrorWriter, sync_date_range, generate_date_list
@pytest.fixture
def temp_data_dir():
temp_dir = tempfile.mkdtemp(prefix="garmin_test_")
data_dir = Path(temp_dir)
# create standard dirs
(data_dir / "garmin").mkdir(parents=True)
yield data_dir
shutil.rmtree(temp_dir)
def test_garmin_sync_mock_and_idempotency(temp_data_dir):
client = GarminSyncClient(token_dir=temp_data_dir, mock=True)
writer = GarminMirrorWriter(temp_data_dir)
dates = ["2026-10-01", "2026-10-02"]
sync_date_range(client, writer, dates)
daily_file = temp_data_dir / "garmin" / "daily" / "2026-10.jsonl"
sleep_file = temp_data_dir / "garmin" / "sleep" / "2026-10.jsonl"
activities_file = temp_data_dir / "garmin" / "activities" / "2026-10.jsonl"
assert daily_file.is_file()
assert sleep_file.is_file()
assert activities_file.is_file()
# Read daily lines
with open(daily_file) as f:
daily_lines = [json.loads(line) for line in f if line.strip()]
assert len(daily_lines) == 2
assert daily_lines[0]["calendarDate"] == "2026-10-01"
assert daily_lines[1]["calendarDate"] == "2026-10-02"
# Re-syncing 2026-10-01 must not create duplicates (idempotent upsert)
sync_date_range(client, writer, ["2026-10-01"])
with open(daily_file) as f:
daily_lines_after = [json.loads(line) for line in f if line.strip()]
assert len(daily_lines_after) == 2
# Check activities
with open(activities_file) as f:
act_lines = [json.loads(line) for line in f if line.strip()]
# Mock returns activityId 987654321 for each day, so deduplication keeps 1 record with latest info
assert len(act_lines) == 1
assert act_lines[0]["activityId"] == 987654321
def test_generate_date_list():
dates = generate_date_list("2026-09-28", "2026-10-01")
assert dates == ["2026-09-28", "2026-09-29", "2026-09-30", "2026-10-01"]
# Reversed dates should automatically normalize
dates_rev = generate_date_list("2026-10-01", "2026-09-28")
assert dates_rev == ["2026-09-28", "2026-09-29", "2026-09-30", "2026-10-01"]
def test_garmin_auth_error_handling(temp_data_dir):
# Non-mock client with empty dir should raise FileNotFoundError
with pytest.raises(FileNotFoundError):
GarminSyncClient(token_dir=temp_data_dir, mock=False)
+58 -40
View File
@@ -31,71 +31,89 @@ OpenClaw assigns Google Calendar colors automatically based on event titles or e
---
## 🛠️ MCP Tools
## 🛠️ MCP Tools & CLI Fallbacks
You have access to 8 dedicated calendar tools:
> [!IMPORTANT]
> **OpenClaw Tool Naming:** In OpenClaw, MCP tools from the `calendar` server are prefixed with `calendar__`:
> - `calendar__calendar_list_events`
> - `calendar__calendar_check_conflicts`
> - `calendar__calendar_create_event`
> - `calendar__calendar_update_event`
> - `calendar__calendar_delete_event`
> - `calendar__calendar_move_event`
> - `calendar__calendar_find_free_slots`
> - `calendar__calendar_get_event`
>
> If MCP execution is unavailable or fails with `Tool not found`, ALWAYS fall back to the direct Python CLI:
> ```bash
> # Suchen:
> /home/node/.openclaw/mcp-venv/bin/python3 /home/node/mop-skills/mop-skills/skills/google-calendar/scripts/google_calendar.py list --calendar regensburg --query "Suchbegriff"
>
> # Löschen (inkl. Serie):
> /home/node/.openclaw/mcp-venv/bin/python3 /home/node/mop-skills/mop-skills/skills/google-calendar/scripts/google_calendar.py delete <event_id> --calendar regensburg --series
> ```
1. `calendar_list_events(calendar="family"|"regensburg", time_min=None, time_max=None, query=None, category=None, color_id=None, max_results=25)`:
- Lists appointments within a timeframe including start/end, color name, location, recurrence status, and description.
- `time_min` / `time_max`: ISO format (e.g. `2026-09-20` or `2026-09-20T08:00:00`). Defaults to start of today until 30 days ahead. Automatically expands recurring events into individual occurrences.
- `category` / `color_id`: Filter by person or category (e.g. `category="arzt"` or `color_id="11"`).
- `query`: Text filter to search summaries and descriptions.
### 1. `calendar__calendar_list_events(calendar="family"|"regensburg", time_min=None, time_max=None, query=None, category=None, color_id=None, max_results=25)`:
- Lists appointments within a timeframe including start/end, color name, location, recurrence status, and description.
- `time_min` / `time_max`: ISO format (e.g. `2026-09-20` or `2026-09-20T08:00:00`). Defaults to start of today until 30 days ahead. Automatically expands recurring events into individual occurrences.
- `category` / `color_id`: Filter by person or category (e.g. `category="arzt"` or `color_id="11"`).
- `query`: Text filter to search summaries and descriptions.
2. `calendar_check_conflicts(calendar="family"|"regensburg", start_time, end_time=None, buffer_minutes=15)`:
- Proactively checks if a proposed slot conflicts with existing events or leaves less than `buffer_minutes` travel buffer.
- Returns `has_conflict`, `direct_conflicts`, `has_buffer_warning`, and `buffer_warnings`.
### 2. `calendar__calendar_check_conflicts(calendar="family"|"regensburg", start_time, end_time=None, buffer_minutes=15)`:
- Proactively checks if a proposed slot conflicts with existing events or leaves less than `buffer_minutes` travel buffer.
- Returns `has_conflict`, `direct_conflicts`, `has_buffer_warning`, and `buffer_warnings`.
3. `calendar_create_event(calendar="family"|"regensburg", summary, start_time, end_time=None, recurrence=None, show_as_busy=True, transparency=None, reminder_minutes=None, category=None, color_id=None, description=None, location=None, all_day=False, inclusive_end=True, check_conflicts=True)`:
- Adds a new appointment with automatic color coding, recurrence, transparency, phone reminders, and overlap detection.
- **`recurrence`**: Standard RFC 5545 rule or shortcuts:
- `recurrence="RRULE:FREQ=WEEKLY;BYDAY=SA"` (weekly Saturday market)
- `recurrence="RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA;UNTIL=20261231T230000Z"` (daily except Sun until year end)
- Shortcuts: `"weekly"`, `"daily"`, `"monthly"`, `"workdays"`.
- **`show_as_busy` / `transparency`**:
- For **Regensburg city events, markets, festivals, exhibitions**: set `show_as_busy=False` or `transparency="transparent"` so they are visible on the calendar without blocking family schedule slots or triggering false conflict warnings!
- **`reminder_minutes`**: List of popup alert minutes before event (e.g. `[15, 60]` or `1440` for 1 day before).
- **`inclusive_end`**: For multi-day all-day events, automatically includes the end date without off-by-one errors.
### 3. `calendar__calendar_create_event(calendar="family"|"regensburg", summary, start_time, end_time=None, recurrence=None, show_as_busy=True, transparency=None, reminder_minutes=None, category=None, color_id=None, description=None, location=None, all_day=False, inclusive_end=True, check_conflicts=True)`:
- Adds a new appointment with automatic color coding, recurrence, transparency, phone reminders, and overlap detection.
- **`recurrence`**: Standard RFC 5545 rule or shortcuts:
- `recurrence="RRULE:FREQ=WEEKLY;BYDAY=SA"`
- Shortcuts: `"weekly"`, `"daily"`, `"monthly"`, `"workdays"`.
- **`show_as_busy` / `transparency`**:
- For **Regensburg city events, festivals, exhibitions**: set `show_as_busy=False` or `transparency="transparent"` so they are visible on the calendar without blocking family schedule slots or triggering false conflict warnings!
- **`reminder_minutes`**: List of popup alert minutes before event (e.g. `[15, 60]` or `1440` for 1 day before).
- **`inclusive_end`**: For multi-day all-day events, automatically includes the end date without off-by-one errors.
4. `calendar_update_event(calendar="family"|"regensburg", event_id, summary=None, start_time=None, end_time=None, recurrence=None, show_as_busy=None, transparency=None, reminder_minutes=None, update_series=False, category=None, color_id=None, description=None, location=None)`:
- Updates fields, recurrence, transparency, reminders, or recolors an existing event.
- `update_series=True`: If event is an occurrence of a series, updates the entire master series instead of just this single occurrence.
### 4. `calendar__calendar_update_event(calendar="family"|"regensburg", event_id, summary=None, start_time=None, end_time=None, recurrence=None, show_as_busy=None, transparency=None, reminder_minutes=None, update_series=False, category=None, color_id=None, description=None, location=None)`:
- Updates fields, recurrence, transparency, reminders, or recolors an existing event.
- `update_series=True`: If event is an occurrence of a series, updates the entire master series instead of just this single occurrence.
5. `calendar_delete_event(calendar="family"|"regensburg", event_id, delete_series=False)`:
- Deletes an event.
- `delete_series=True`: If event is part of a recurring series, deletes the entire recurring series instead of just this single occurrence.
### 5. `calendar__calendar_delete_event(calendar="family"|"regensburg", event_id, delete_series=False)`:
- Deletes an event.
- `delete_series=True`: If event is part of a recurring series, deletes the entire recurring series instead of just this single occurrence.
6. `calendar_move_event(event_id, source_calendar="family", destination_calendar="regensburg")`:
- Moves an event atomically from one calendar to another (e.g. turning an interesting city event from Regensburg into a fixed family plan, or correcting a wrongly booked calendar).
### 6. `calendar__calendar_move_event(event_id, source_calendar="family", destination_calendar="regensburg")`:
- Moves an event atomically from one calendar to another.
7. `calendar_find_free_slots(calendar="family"|"regensburg"|"both", time_min=None, time_max=None, duration_minutes=60, day_start_hour=8, day_end_hour=20)`:
- Queries `freebusy` to find open, available time windows across calendars.
- Perfect for answering questions like: *"Wann haben wir diese Woche am Nachmittag 2 Stunden frei für einen Ausflug?"*.
### 7. `calendar__calendar_find_free_slots(calendar="family"|"regensburg"|"both", time_min=None, time_max=None, duration_minutes=60, day_start_hour=8, day_end_hour=20)`:
- Queries `freebusy` to find open, available time windows across calendars.
8. `calendar_get_event(calendar="family"|"regensburg", event_id)`:
- Retrieves full event metadata.
### 8. `calendar__calendar_get_event(calendar="family"|"regensburg", event_id)`:
- Retrieves full event metadata.
---
## 🧭 Routing & Best Practices for OpenClaw
### ⚠️ Strikte Guardrails:
1. **STRIKTE REGEL: NIEMALS ERFOLG VORTÄUSCHEN (NO FAKE CONFIRMATIONS)!** Wenn ein Tool fehlschlägt oder `not found` meldet, behaupte **niemals**, dass der Termin gelöscht oder erstellt wurde! Nutze den CLI-Fallback oder berichte den Fehler wahrheitsgemäß.
2. **STRIKTE REGEL: KEINE MÄRKTE IN DEN KALENDER EINTRAGEN!** Weder Wochenmärkte (Katharinenmarkt, Neupfarrplatz, etc.) noch Bio-Bauernmärkte dürfen in die Kalender eingetragen werden. Dies überfrachtet den Kalender.
### 1. Conflict Guard (Terminkonflikte & Puffer)
- When a user asks to add an event, check `calendar_check_conflicts` or inspect the `conflict_warning` from `calendar_create_event`.
- If an overlap or tight buffer (< 15 Min.) exists, explicitly warn the user:
> *„⚠️ Achtung: Am Freitag um 17:30 Uhr hat Moritz schon 'Moritz Gartenparty Arbeit' (17:30 - 18:30). Dein Termin 'Spielplatz' würde sich damit überschneiden. Soll ich ihn trotzdem eintragen?“*
- When a user asks to add an event, check `calendar__calendar_check_conflicts` or inspect the `conflict_warning` from `calendar__calendar_create_event`.
- If an overlap or tight buffer (< 15 Min.) exists, explicitly warn the user.
### 2. Informational Events vs. Family Commitments (Transparency)
- When adding city events, festivals, or weekly markets to the **`regensburg`** calendar, always pass `show_as_busy=False` (or `transparency="transparent"`).
- When adding city events, festivals, or exhibitions to the **`regensburg`** calendar, always pass `show_as_busy=False` (or `transparency="transparent"`).
- This keeps the event clearly visible on the calendar while allowing family appointments to be scheduled in parallel without false conflict alerts.
### 3. Recurring Series (RRULE)
- When the user asks to add recurring appointments (e.g. weekly markets, sports training, trash collection):
- When the user asks to add recurring appointments (e.g. sports training, trash collection):
- Use `recurrence="RRULE:FREQ=WEEKLY;BYDAY=..."` or convenient shortcuts.
- Do NOT create manual cron scripts or multiple single events when a native Google Calendar recurrence series can be used.
### 4. Smart Location & Google Maps Integration
- If a place or venue is mentioned (e.g. *"Dr. Müller in Prüfening"*, *"Westbad"*):
- Resolve the full address or ask the user, and store it in `location`.
- This activates Google Maps departure alerts on their smartphone (*„Aufbruch in 15 Min. wegen Verkehr“*).
- This activates Google Maps departure alerts on their smartphone.
### 5. Proaktive Rückfragen & Checklisten (Mental Load Entlastung)
- When booking medical/baby appointments (e.g. *Kinderarzt*, *U-Untersuchungen*, *Zahnarzt*):
+15
View File
@@ -0,0 +1,15 @@
---
name: "heartbeat-monitor-rules"
description: "Store heartbeat monitor rules as a skill for session handling"
---
# Heartbeat monitor rules
When a heartbeat monitor scratch context is provided:
1. Follow the scratch context as the source of truth for what to do in that turn.
2. Do not create or change recurring tasks from heartbeat messages. Use the automations tool for recurring tasks.
3. Do not infer or repeat old tasks from previous chats.
4. If the heartbeat has nothing that needs attention, respond exactly: NO_REPLY.
This skill documents the handling rules for heartbeat messages and is triggered when a heartbeat scratch context is received.
+32
View File
@@ -0,0 +1,32 @@
---
name: "mcp-server-debugging"
description: "MCP server tools missing or fails to start (\"Connection closed\"): reproduce via stdio client, pin mcp&lt;2, fix writable HOME/PATH."
---
# MCP Server Debugging
Diagnose and fix OpenClaw MCP servers whose tools never appear or that crash on startup.
## Trigger
- A configured MCP server's tools are absent from the session, or a stdio MCP client fails `initialize` with `Connection closed`.
- A newly added MCP server does not show up as `<name>__*` tools.
## Steps
1. Read the server definition: call `gateway` with `action=config.get`, `path=mcp`; note `command`, `args`, `env`, `cwd` for the failing server.
2. Reproduce and capture the real error: run the configured command directly with stdin from `/dev/null` (e.g. `timeout 60 python3 <launcher> < /dev/null 2>&1 | head -40`). Completion: stderr shows the underlying failure, not only the client-side "Connection closed".
3. Map the error to the fix:
- `ModuleNotFoundError: No module named 'mcp.server.fastmcp'` → the runner pulled **mcp 2.x**, where `FastMCP` was renamed to `MCPServer`. Fix: pin 1.x, e.g. run `uvx --with "mcp<2" <package>`, or add the pin inside the launcher's `os.execvp` call.
- `Read-only file system` on a home/credentials path (launcher writes to `Path.home()/.<name>`) → `HOME` is read-only. Fix: point `HOME` to a writable dir (e.g. `/tmp`) or change the written path.
- `FileNotFoundError: uvx` → prepend the uvx directory (normally `/home/openclaw/.openclaw/bin`) to `PATH`.
4. Verify end-to-end with a minimal stdio client before declaring success: spawn the fixed command, `initialize`, `list_tools`, then call the target tool and confirm real data returns. Use `uv run --with mcp --python 3.12 python <client.py>` where the client uses `mcp.client.stdio.stdio_client` + `ClientSession`, passing `env` with the corrected `HOME`/`PATH`.
5. Route config changes through the `openclaw` tool (human approval required); never edit gateway config files directly. Report the exact old → new command.
## Tool calls fail with argument validation errors
- An MCP tool returning a pydantic `Unexpected keyword argument` error means the argument names are wrong for that server, not that the server is down: read the real schema with `tool_describe` and retry with its exact parameters.
- Home Assistant MCP: `ha_call_service` takes `{domain, service, entity_id, data}` (not `service_data`/`target`); `ha_search_entities` takes `{query}` only (no `domain`). A successful call reports `success: true` in `structuredContent`.
- A type/filter variant that errors with `'NoneType' object has no attribute 'get'` (e.g. Spotify MCP `types=playlist`) → switch to a working type (`types=track` or `album`), act on that URI, and report the server bug instead of retrying the same call.
## Pitfalls
- A session only exposes tools loaded at session start; a newly added server's tools appear as `<name>__*` only after a gateway reload.
- Test with the same `HOME`/`PATH` the gateway uses, otherwise you validate the wrong environment.
- Never print secret values; check presence only (`[ -n "${VAR}" ]`).
+13
View File
@@ -0,0 +1,13 @@
---
name: "memory-wiki-synthesis"
description: "wiki_apply create_synthesis fails without sourceIds; create a source page first, then file the synthesis"
---
# Filing a Memory-Wiki Synthesis
Use when filing a research dossier or synthesis into the memory wiki via `wiki_apply` with `op: "create_synthesis"`.
1. Call `wiki_apply` with `op: "create_synthesis"`, `title`, and `body`. If it returns `wiki mutation requires at least one sourceId for create_synthesis`, do not retry blind. Completion: the error names the missing `sourceIds`.
2. Create a source page first. Read the vault path from `wiki_status` (`vaultPath`, e.g. `/home/openclaw/.openclaw/wiki/main`), then write `sources/YYYY-MM-DD-<slug>.md` with frontmatter `type: Source`, a unique `id: <slug>`, `title`, and a `sources:` list of the URLs used. Completion: the file exists and carries an `id`.
3. Re-run `wiki_apply` `op: "create_synthesis"` passing `sourceIds: ["<slug>"]` (the source page `id`) plus `title`, `body`, and any `questions`/`confidence`. Completion: the response reports the new `syntheses/...` page path and refreshes the index files.
4. Run `wiki_lint` after the update. Completion: lint returns; expect a large pre-existing baseline of structural warnings, so only act on issues for the pages just written.
+24
View File
@@ -0,0 +1,24 @@
---
name: "pdf-onepager"
description: "Generate styled one-page PDFs in the sandbox via fpdf2 and print with brother-printer."
---
# PDF One-Pager (fpdf2)
Generate a styled, single-page PDF in the sandbox, then print it with the brother-printer skill.
## Steps
1. Draft the content as a Python script using `fpdf2` (FPDF, A4, `set_auto_page_break(False)`, margins ~15mm). Build layout with helper functions: colored title, section headers with rule line, label+text bullets, a filled warning box (`set_fill_color` + `multi_cell(fill=True)`), and a small gray source footer. Do not use emoji/HTML-entities in text: fpdf2 core fonts (helvetica) support only Latin-1 — replace emoji with plain words.
- Done when: the script is saved (e.g. `/tmp/make_pdf.py`) with an absolute output path and prints a pages count from `pdf.pages_count`.
2. Run `uv run --with fpdf2 /tmp/make_pdf.py`.
- Do NOT attempt `weasyprint` (fails in this sandbox: missing system libs) or wkhtmltopdf/chromium/pandoc (not installed). Go straight to fpdf2.
- Done when: script prints `done, pages: 1` (or expected count) and the PDF file exists.
3. Verify the file (`ls -la <path>`), then print per the brother-printer skill: `uv run /home/node/mop-skills/mop-skills/skills/brother-printer/scripts/brother_printer.py print --file <pdf> --title <title> --color monochrome --nup 1 --duplex two-sided-long-edge --chat-id <chat_id>`.
- For print-option selection, use `ask_user` with the standard preset menu from the brother-printer skill.
- Done when: script reports 'Druck erfolgreich abgeschlossen'.
## Reference
- fpdf2 API: `pdf.cell(w, h, text, new_x="LMARGIN", new_y="NEXT")` for labels/headers; `pdf.multi_cell(W, h, text, new_x="LMARGIN", new_y="NEXT")` for wrapped text; colors via `set_text_color/set_fill_color/set_draw_color(RGB tuples)`.
- Keep single-page fit: font 9–9.5pt body, 11–11.5pt headers, 17pt title; if content overflows, trim bullets rather than shrinking below 9pt.
- Save the PDF in the workspace (e.g. `/home/openclaw/.openclaw/workspace/<Name>.pdf`) so it can be resent later.
+23
View File
@@ -0,0 +1,23 @@
---
name: "sonos-media-playback"
description: "Play music on Sonos speakers via Home Assistant; when Spotify search or play_media fails, use Sonos source_list favorites."
---
# Sonos Media Playback
Play music (genre, playlist, artist, "50% volume") on a room Sonos via Home Assistant MCP.
## Steps
1. Find the room player: `homeassistant__ha_search_entities` with `domain_filter="media_player"` and the room name (German room names match, e.g. "bad" -> `media_player.bad`). Done when one media_player entity is found.
2. Set volume first with `ha_call_service(domain="media_player", service="volume_set", data={"volume_level": <0-1>})` — this works even while the player is paused/idle. Percent requested / 100.
3. If the player is `paused` or `idle`, wake it with `media_player.media_play` so Spotify Connect may register it.
4. Resolve the requested music. Prefer, in order:
- `spotify__search` for playlist/track URIs, then play via the Spotify MCP `play` tool. If `play` returns 404 "No active device found": call `spotify__get_devices`, pick the room-matching device, and retry `play` with that `device_id` explicitly.
- If Spotify search errors or no Spotify device is active: get the player state with `ha_get_state` and read its `source_list` (Sonos favorites). Pick the favorite closest to the requested genre (e.g. dark techno -> `ChilloutTechno`, sets -> `Fusion 2026 Sets`), then `ha_call_service(domain="media_player", service="select_source", data={"source": "<favorite>"})`.
5. Verify: `ha_get_state` on the entity shows `playing` with `media_title`/`media_playlist` and the requested `volume_level`. Report title and volume to the user; offer the nearest alternative favorite if the genre match is loose.
## Do not retry
- `ha_call_service(media_player, play_media)` with any `spotify:` URI on Sonos: returns HTTP 500 regardless of `media_content_type` (`music`/`playlist`) — skip it and go straight to `source_list` favorites.
- Repeated retries of a failing `spotify__search` with different arguments: the failure is server-side; fall back once, then use favorites.