diff --git a/home/minerva-dashboard/.env.example b/home/minerva-dashboard/.env.example new file mode 100644 index 0000000..fd63041 --- /dev/null +++ b/home/minerva-dashboard/.env.example @@ -0,0 +1,5 @@ +TZ=America/Chicago +MINERVA_HOST=0.0.0.0 +MINERVA_PORT=8094 +MINERVA_STATE_DIR=/data/state +MINERVA_SHARED_PASSWORD= diff --git a/home/minerva-dashboard/.gitignore b/home/minerva-dashboard/.gitignore new file mode 100644 index 0000000..37dc78d --- /dev/null +++ b/home/minerva-dashboard/.gitignore @@ -0,0 +1,6 @@ +.env +.venv/ +__pycache__/ +.pytest_cache/ +/data/ +*.pyc diff --git a/home/minerva-dashboard/Dockerfile b/home/minerva-dashboard/Dockerfile new file mode 100644 index 0000000..7d8e6aa --- /dev/null +++ b/home/minerva-dashboard/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app + +EXPOSE 8094 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8094"] diff --git a/home/minerva-dashboard/README.md b/home/minerva-dashboard/README.md new file mode 100644 index 0000000..422b88b --- /dev/null +++ b/home/minerva-dashboard/README.md @@ -0,0 +1,87 @@ +# Minerva Dashboard + +Private-first household and personal-assistant dashboard for Manndra, with a Ravenclaw common-room feel and phone-first layout. + +## Purpose + +Minerva Dashboard is meant to be a calm, useful front page for Manndra's household flow. V1 keeps the data model simple and JSON-backed so the first live slice can be exercised quickly before deeper integrations arrive. + +The first pass focuses on: + +- agenda and reminders +- household lists +- quick notes +- useful links +- meal ideas and shortcuts +- Minerva quick actions for fast capture from a phone + +## Runtime shape + +- **Repo source:** `home/minerva-dashboard` +- **Expected live PD runtime:** `/mnt/docker-ssd/docker/compose/minerva-dashboard` +- **Expected first exposure:** private-first on PD unless later changed +- **Published app port:** `8094` + +Do not run production from an agent workspace. + +## V1 defaults + +Unless John or Manndra changes direction later, V1 assumes: + +- Manndra is the primary active user +- mobile-friendly first, desktop-usable second +- elegant Ravenclaw/common-room styling instead of playful novelty clutter +- JSON-backed state first for speed and easy inspection +- practical household wording over infra/operator language +- no live deploy in this scaffold task + +## Current scaffold + +The scaffold already provides: + +- seeded Manndra profile and dashboard copy +- seeded agenda, reminders, lists, notes, links, meal ideas, shortcuts, and quick actions +- server-rendered homepage for phone use +- real themed graphics in visible UI locations (library hero, owl medallion, blue-interior art, Art Nouveau ornament) +- provenance notes for vendored imagery in `docs/ASSET_SOURCES.md` +- API endpoints for health, summary, and quick note capture +- form-based quick note capture from the dashboard +- Docker, compose, env example, local run helper, and pytest coverage for initial flows + +## Current API surface + +- `GET /api/health` +- `GET /api/dashboard/summary` +- `POST /api/notes/quick-add` +- `POST /quick-actions/notes` + +## Local run + +```bash +cd /home/fizzlepoof/repos/truenas-stacks/home/minerva-dashboard +python3 -m venv .venv +. .venv/bin/activate +pip install -r requirements.txt +uvicorn app.main:app --reload --port 8094 +``` + +Or: + +```bash +cd /home/fizzlepoof/repos/truenas-stacks/home/minerva-dashboard +bin/run-local.sh +``` + +## Verification targets + +```bash +curl -fsS http://127.0.0.1:8094/api/health +curl -fsS http://127.0.0.1:8094/api/dashboard/summary +curl -fsS http://127.0.0.1:8094/ | grep 'Minerva Dashboard' +``` + +## Assumptions captured in scaffold + +- Manndra is the first and primary user for this app +- agenda/reminders are seeded locally until a calendar or Donetick-style integration is chosen +- the quick-add mutation writes notes first because quick capture from a phone is a strong default for this product shape diff --git a/home/minerva-dashboard/app/__init__.py b/home/minerva-dashboard/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/home/minerva-dashboard/app/config.py b/home/minerva-dashboard/app/config.py new file mode 100644 index 0000000..e4b7566 --- /dev/null +++ b/home/minerva-dashboard/app/config.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(slots=True) +class Settings: + app_name: str = 'Minerva Dashboard' + default_state_dir: Path = Path(os.environ.get('MINERVA_STATE_DIR', '/tmp/minerva-dashboard-state')) diff --git a/home/minerva-dashboard/app/main.py b/home/minerva-dashboard/app/main.py new file mode 100644 index 0000000..9430fc7 --- /dev/null +++ b/home/minerva-dashboard/app/main.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from pathlib import Path + +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles + +from app.config import Settings +from app.routes.api import router as api_router +from app.routes.ui import router as ui_router +from app.services.store import JSONStore + +BASE_DIR = Path(__file__).resolve().parent + + +def create_app(state_dir: Path | str | None = None) -> FastAPI: + settings = Settings() + app = FastAPI(title=settings.app_name) + resolved_state_dir = Path(state_dir) if state_dir is not None else settings.default_state_dir + app.state.store = JSONStore(resolved_state_dir) + app.mount('/static', StaticFiles(directory=BASE_DIR / 'static'), name='static') + app.include_router(ui_router) + app.include_router(api_router) + return app + + +app = create_app() diff --git a/home/minerva-dashboard/app/models.py b/home/minerva-dashboard/app/models.py new file mode 100644 index 0000000..62f859f --- /dev/null +++ b/home/minerva-dashboard/app/models.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, Field + + +class QuickAddNotePayload(BaseModel): + title: str = Field(min_length=1, max_length=120) + body: str = Field(min_length=1, max_length=1000) + category: str = Field(default='note', min_length=1, max_length=40) + source: str = Field(default='quick-add', min_length=1, max_length=40) + pinned: bool = False + + +class DashboardSummary(BaseModel): + profile: dict[str, Any] + counts: dict[str, int] + agenda: list[dict[str, Any]] + reminders: list[dict[str, Any]] + lists: list[dict[str, Any]] + notes: list[dict[str, Any]] + links: list[dict[str, Any]] + meal_ideas: list[dict[str, Any]] + shortcuts: list[dict[str, Any]] + quick_actions: list[dict[str, Any]] diff --git a/home/minerva-dashboard/app/routes/api.py b/home/minerva-dashboard/app/routes/api.py new file mode 100644 index 0000000..8c3bbbd --- /dev/null +++ b/home/minerva-dashboard/app/routes/api.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from fastapi import APIRouter, Request, status + +from app.models import DashboardSummary, QuickAddNotePayload + +router = APIRouter(prefix='/api') + + +@router.get('/health') +def health() -> dict[str, str]: + return {'status': 'ok'} + + +@router.get('/dashboard/summary', response_model=DashboardSummary) +def dashboard_summary(request: Request) -> dict: + return request.app.state.store.dashboard_summary() + + +@router.post('/notes/quick-add', status_code=status.HTTP_201_CREATED) +def quick_add_note(payload: QuickAddNotePayload, request: Request) -> dict: + return request.app.state.store.add_quick_note(payload.model_dump()) diff --git a/home/minerva-dashboard/app/routes/ui.py b/home/minerva-dashboard/app/routes/ui.py new file mode 100644 index 0000000..9cf18e4 --- /dev/null +++ b/home/minerva-dashboard/app/routes/ui.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from pathlib import Path + +from fastapi import APIRouter, Form, Request, status +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates + +BASE_DIR = Path(__file__).resolve().parents[1] +templates = Jinja2Templates(directory=str(BASE_DIR / 'templates')) +router = APIRouter() + + +@router.get('/', response_class=HTMLResponse) +def dashboard(request: Request) -> HTMLResponse: + summary = request.app.state.store.dashboard_summary() + return templates.TemplateResponse( + request, + 'dashboard.html', + { + 'summary': summary, + }, + ) + + +@router.post('/quick-actions/notes') +def quick_add_note_form( + request: Request, + title: str = Form(...), + body: str = Form(...), + category: str = Form('note'), +) -> RedirectResponse: + request.app.state.store.add_quick_note( + { + 'title': title, + 'body': body, + 'category': category, + 'source': 'dashboard-form', + 'pinned': False, + } + ) + return RedirectResponse(url='/', status_code=status.HTTP_303_SEE_OTHER) diff --git a/home/minerva-dashboard/app/services/store.py b/home/minerva-dashboard/app/services/store.py new file mode 100644 index 0000000..d44a743 --- /dev/null +++ b/home/minerva-dashboard/app/services/store.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +import json +from copy import deepcopy +from datetime import datetime, timezone +from pathlib import Path +from typing import Any +from uuid import uuid4 + +DEFAULT_STATE = { + 'profile': { + 'id': 'manndra', + 'display_name': 'Manndra', + 'role': 'Primary household user', + 'persona': 'Minerva', + 'house_style': 'Ravenclaw common room', + 'tone': 'Elegant, magical, useful', + 'primary_color_story': 'Midnight blue, parchment, and warm bronze', + 'notes': 'Seeded as the primary user for the Minerva household dashboard scaffold.', + }, + 'agenda': [ + { + 'id': 'agenda-1', + 'title': 'Morning check-in', + 'time_label': '8:30 AM', + 'detail': 'Review the day, any errands, and which household lane needs attention first.', + }, + { + 'id': 'agenda-2', + 'title': 'After-lunch reset', + 'time_label': '1:30 PM', + 'detail': 'Tidy loose notes, review reminders, and confirm dinner direction.', + }, + { + 'id': 'agenda-3', + 'title': 'Evening wind-down', + 'time_label': '8:00 PM', + 'detail': 'Wrap tomorrow notes, shortlist meals, and leave one clean next action.', + }, + ], + 'reminders': [ + {'id': 'reminder-1', 'title': 'Check school and household messages', 'urgency': 'today'}, + {'id': 'reminder-2', 'title': 'Restock one comfort-snack shelf item', 'urgency': 'this week'}, + {'id': 'reminder-3', 'title': 'Set aside one graceful task for future Minerva automation', 'urgency': 'someday'}, + ], + 'lists': [ + { + 'id': 'list-1', + 'title': 'Errands', + 'items': [ + {'label': 'Pick up household basics', 'done': False}, + {'label': 'Confirm any school supply gaps', 'done': False}, + {'label': 'Check gift / occasion queue', 'done': False}, + ], + }, + { + 'id': 'list-2', + 'title': 'House reset', + 'items': [ + {'label': 'Clear one surface', 'done': True}, + {'label': 'Run a fast room sweep', 'done': False}, + {'label': 'Stage tomorrow launch pad', 'done': False}, + ], + }, + ], + 'notes': [ + { + 'id': 'note-1', + 'title': 'Minerva welcome', + 'body': 'This front page should feel calm, capable, and immediately useful from a phone.', + 'category': 'welcome', + 'source': 'seed', + 'pinned': True, + 'created_at': '2026-05-23T09:00:00Z', + }, + { + 'id': 'note-2', + 'title': 'Household rhythm', + 'body': 'Keep the wording practical: what matters now, what can wait, and what would make the day easier.', + 'category': 'guidance', + 'source': 'seed', + 'pinned': False, + 'created_at': '2026-05-23T10:15:00Z', + }, + ], + 'links': [ + {'id': 'link-1', 'label': 'Calendar', 'url': '#', 'note': 'Placeholder until live integration is chosen.'}, + {'id': 'link-2', 'label': 'School hub', 'url': '#', 'note': 'Quick route for classes, due dates, and messages.'}, + {'id': 'link-3', 'label': 'Household notes', 'url': '#', 'note': 'Pin common references in one predictable place.'}, + ], + 'meal_ideas': [ + {'id': 'meal-1', 'title': 'Easy comfort dinner', 'detail': 'One low-friction weeknight fallback with leftovers.'}, + {'id': 'meal-2', 'title': 'Snack board night', 'detail': 'Small-prep option for a busier day.'}, + {'id': 'meal-3', 'title': 'Tomorrow prep idea', 'detail': 'Something that makes the next day easier.'}, + ], + 'shortcuts': [ + {'id': 'shortcut-1', 'label': 'Quick note', 'hint': 'Capture the thing before it evaporates.'}, + {'id': 'shortcut-2', 'label': 'Meal spark', 'hint': 'Write one dinner idea without overthinking it.'}, + {'id': 'shortcut-3', 'label': 'Errand add', 'hint': 'Drop a list item while already on the move.'}, + ], + 'quick_actions': [ + {'id': 'action-1', 'label': 'Capture note', 'detail': 'Fast jot for reminders, ideas, and school fragments.'}, + {'id': 'action-2', 'label': 'Review day', 'detail': 'One-tap habit: scan agenda, reminders, and lists.'}, + {'id': 'action-3', 'label': 'Choose dinner lane', 'detail': 'Decide between comfort, fast, or leftovers.'}, + ], +} + + +class JSONStore: + def __init__(self, state_dir: Path) -> None: + self.state_dir = Path(state_dir) + self.state_path = self.state_dir / 'state.json' + self.state_dir.mkdir(parents=True, exist_ok=True) + if not self.state_path.exists(): + self._write(deepcopy(DEFAULT_STATE)) + + def _read(self) -> dict[str, Any]: + return json.loads(self.state_path.read_text()) + + def _write(self, payload: dict[str, Any]) -> None: + self.state_path.write_text(json.dumps(payload, indent=2, sort_keys=True)) + + def dashboard_summary(self) -> dict[str, Any]: + state = self._read() + notes = self._sorted_notes(state['notes']) + return { + 'profile': state['profile'], + 'counts': { + 'agenda': len(state['agenda']), + 'reminders': len(state['reminders']), + 'lists': len(state['lists']), + 'notes': len(notes), + 'links': len(state['links']), + 'meal_ideas': len(state['meal_ideas']), + 'shortcuts': len(state['shortcuts']), + 'quick_actions': len(state['quick_actions']), + }, + 'agenda': state['agenda'], + 'reminders': state['reminders'], + 'lists': state['lists'], + 'notes': notes, + 'links': state['links'], + 'meal_ideas': state['meal_ideas'], + 'shortcuts': state['shortcuts'], + 'quick_actions': state['quick_actions'], + } + + def add_quick_note(self, payload: dict[str, Any]) -> dict[str, Any]: + state = self._read() + saved = { + 'id': uuid4().hex, + 'title': payload['title'].strip(), + 'body': payload['body'].strip(), + 'category': payload.get('category', 'note').strip() or 'note', + 'source': payload.get('source', 'quick-add').strip() or 'quick-add', + 'pinned': bool(payload.get('pinned', False)), + 'created_at': datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace('+00:00', 'Z'), + } + state['notes'].append(saved) + self._write(state) + return saved + + @staticmethod + def _sorted_notes(notes: list[dict[str, Any]]) -> list[dict[str, Any]]: + return sorted( + notes, + key=lambda item: ( + not item.get('pinned', False), + item.get('created_at', ''), + ), + reverse=False, + )[::-1] diff --git a/home/minerva-dashboard/app/static/app.css b/home/minerva-dashboard/app/static/app.css new file mode 100644 index 0000000..8f8b621 --- /dev/null +++ b/home/minerva-dashboard/app/static/app.css @@ -0,0 +1,343 @@ +:root { + --bg: #0f1728; + --bg-elevated: #18233b; + --card: rgba(19, 31, 54, 0.86); + --card-strong: rgba(17, 28, 49, 0.94); + --text: #e7edf9; + --muted: #b7c3db; + --line: rgba(198, 169, 102, 0.18); + --accent: #8bb8ff; + --bronze: #c6a966; + --shadow: 0 18px 40px rgba(3, 8, 20, 0.35); +} + +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; } +body { + font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background: + radial-gradient(circle at top, rgba(93, 129, 194, 0.16), transparent 32%), + linear-gradient(180deg, #0d1524 0%, var(--bg) 100%); + color: var(--text); + min-height: 100vh; +} + +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } + +.page-shell { + width: min(1120px, calc(100vw - 1.5rem)); + margin: 0 auto; + padding: 1rem 0 2.5rem; +} + +.card { + background: var(--card); + border: 1px solid var(--line); + border-radius: 20px; + box-shadow: var(--shadow); + padding: 1rem; + backdrop-filter: blur(8px); +} + +.glow-card { + background: linear-gradient(135deg, rgba(23, 37, 65, 0.96), rgba(26, 40, 68, 0.9)); +} + +.masthead { + display: grid; + grid-template-columns: minmax(0, 1.15fr) minmax(260px, 0.85fr); + gap: 1rem; + margin-bottom: 1rem; + overflow: hidden; + position: relative; +} + +.masthead::before { + content: ""; + position: absolute; + inset: 0; + background: + linear-gradient(120deg, rgba(10, 18, 32, 0.9) 0%, rgba(10, 18, 32, 0.82) 44%, rgba(12, 20, 34, 0.58) 100%), + radial-gradient(circle at top right, rgba(139, 184, 255, 0.18), transparent 32%); + pointer-events: none; +} + +.masthead > * { + position: relative; + z-index: 1; +} + +.masthead-copy { + display: grid; + gap: 1rem; + align-content: space-between; +} + +.masthead h1, .section-heading h2, .spotlight h2, .subcard h3, .note-item h3 { + font-family: Georgia, 'Times New Roman', serif; + letter-spacing: 0.01em; + margin: 0; +} + +.masthead-meta, .section-heading, .note-head { + display: flex; + gap: 0.5rem; + justify-content: space-between; + align-items: flex-start; + flex-wrap: wrap; +} + +.masthead-hero-frame, +.visual-medallion, +.inline-ornament { + margin: 0; +} + +.masthead-hero-frame { + display: grid; + gap: 0.5rem; + align-content: start; +} + +.masthead-hero-image { + width: 100%; + aspect-ratio: 16 / 10; + object-fit: cover; + border-radius: 18px; + border: 1px solid rgba(198, 169, 102, 0.22); + box-shadow: 0 14px 34px rgba(3, 8, 20, 0.32); +} + +.masthead-hero-frame figcaption, +.visual-medallion figcaption, +.inline-ornament figcaption { + color: var(--muted); + font-size: 0.78rem; +} + +.eyebrow { + margin: 0 0 0.35rem; + color: var(--bronze); + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 0.72rem; +} + +.lede, .muted { + color: var(--muted); +} + +.badge, .pill { + display: inline-flex; + align-items: center; + border-radius: 999px; + padding: 0.3rem 0.7rem; + font-size: 0.78rem; + border: 1px solid rgba(139, 184, 255, 0.26); + background: rgba(139, 184, 255, 0.08); +} + +.badge.bronze, .pill.subtle { + border-color: rgba(198, 169, 102, 0.28); + background: rgba(198, 169, 102, 0.1); +} + +.badge.outline { + background: transparent; +} + +.hero-grid, +.dashboard-grid { + display: grid; + gap: 1rem; +} + +.hero-grid { + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + margin-bottom: 1rem; +} + +.dashboard-grid { + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); +} + +.spotlight { + min-height: 100%; +} + +.spotlight-visuals { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.85rem; + margin-top: 1rem; +} + +.visual-medallion { + display: grid; + gap: 0.45rem; + background: rgba(13, 22, 38, 0.76); + border: 1px solid rgba(198, 169, 102, 0.12); + border-radius: 16px; + padding: 0.65rem; +} + +.visual-medallion img, +.inline-ornament img { + width: 100%; + display: block; +} + +.visual-medallion img { + border-radius: 14px; + aspect-ratio: 1 / 1; + object-fit: cover; +} + +.framed-medallion { + background: + linear-gradient(180deg, rgba(27, 42, 70, 0.96), rgba(18, 29, 50, 0.92)), + url('/static/images/art-nouveau-frame.svg') center / cover no-repeat; +} + +.room-medallion img { + object-position: center 22%; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; + margin-top: 1rem; +} + +.stats-grid div, +.subcard, +.note-item { + background: var(--card-strong); + border: 1px solid rgba(198, 169, 102, 0.12); + border-radius: 16px; + padding: 0.85rem; +} + +.stats-grid span, +.tiny { + color: var(--muted); + font-size: 0.8rem; +} + +.stats-grid strong { + display: block; + font-size: 1.25rem; + margin-top: 0.2rem; +} + +.stack-form { + display: grid; + gap: 0.85rem; +} + +label { + display: grid; + gap: 0.4rem; + font-size: 0.92rem; +} + +input, +textarea, +button { + width: 100%; + border-radius: 14px; + border: 1px solid rgba(139, 184, 255, 0.16); + padding: 0.85rem 0.95rem; + font: inherit; +} + +input, +textarea { + background: rgba(10, 18, 33, 0.88); + color: var(--text); +} + +button { + background: linear-gradient(135deg, #5c78b7, #7ea6ef); + color: #09111e; + font-weight: 700; + cursor: pointer; +} + +button:hover { + filter: brightness(1.04); +} + +.timeline-list, +.plain-list, +.check-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 0.75rem; +} + +.timeline-list li, +.plain-list li, +.check-list li { + background: rgba(15, 24, 42, 0.72); + border: 1px solid rgba(198, 169, 102, 0.1); + border-radius: 14px; + padding: 0.8rem; +} + +.check-list li.done { + text-decoration: line-through; + opacity: 0.72; +} + +.notes-card { + grid-column: span 2; +} + +.note-item.pinned { + border-color: rgba(198, 169, 102, 0.34); +} + +.quick-add-card { + align-self: start; +} + +.inline-ornament { + margin-top: 1rem; + display: grid; + gap: 0.4rem; +} + +.inline-ornament img { + max-height: 96px; + object-fit: cover; + border-radius: 12px; + border: 1px solid rgba(198, 169, 102, 0.16); + background: linear-gradient(135deg, rgba(198, 169, 102, 0.18), rgba(139, 184, 255, 0.12)); +} + +@media (max-width: 800px) { + .page-shell { + width: min(100vw - 1rem, 100%); + } + + .masthead { + grid-template-columns: 1fr; + } + + .masthead-hero-image { + aspect-ratio: 16 / 9; + } + + .spotlight-visuals { + grid-template-columns: 1fr; + } + + .notes-card { + grid-column: span 1; + } +} diff --git a/home/minerva-dashboard/app/static/images/art-nouveau-frame.svg b/home/minerva-dashboard/app/static/images/art-nouveau-frame.svg new file mode 100644 index 0000000..82634bc --- /dev/null +++ b/home/minerva-dashboard/app/static/images/art-nouveau-frame.svg @@ -0,0 +1,60 @@ + + + + + + + + image/svg+xml + + + + + + + + + diff --git a/home/minerva-dashboard/app/static/images/blue-interior.jpg b/home/minerva-dashboard/app/static/images/blue-interior.jpg new file mode 100644 index 0000000..683dc42 Binary files /dev/null and b/home/minerva-dashboard/app/static/images/blue-interior.jpg differ diff --git a/home/minerva-dashboard/app/static/images/library-hero.jpg b/home/minerva-dashboard/app/static/images/library-hero.jpg new file mode 100644 index 0000000..fd2225e Binary files /dev/null and b/home/minerva-dashboard/app/static/images/library-hero.jpg differ diff --git a/home/minerva-dashboard/app/static/images/owl-engraving.jpg b/home/minerva-dashboard/app/static/images/owl-engraving.jpg new file mode 100644 index 0000000..d5bd228 Binary files /dev/null and b/home/minerva-dashboard/app/static/images/owl-engraving.jpg differ diff --git a/home/minerva-dashboard/app/templates/base.html b/home/minerva-dashboard/app/templates/base.html new file mode 100644 index 0000000..2309d0f --- /dev/null +++ b/home/minerva-dashboard/app/templates/base.html @@ -0,0 +1,38 @@ + + + + + + {{ summary.profile.persona if summary is defined else 'Minerva' }} Dashboard + + + +
+
+
+
+

Ravenclaw common room

+

Minerva Dashboard

+

Elegant household focus for {{ summary.profile.display_name if summary is defined else 'Manndra' }}: agenda, lists, notes, and one calm place to decide what matters now.

+
+
+ Private-first + Phone friendly + {{ summary.profile.tone if summary is defined else 'Elegant, magical, useful' }} +
+
+
+ Grand library interior with high shelves and warm lamplight +
Library study-hall artwork grounding the dashboard in a scholarly common-room mood.
+
+
+
+ {% block content %}{% endblock %} +
+
+ + diff --git a/home/minerva-dashboard/app/templates/dashboard.html b/home/minerva-dashboard/app/templates/dashboard.html new file mode 100644 index 0000000..3425abe --- /dev/null +++ b/home/minerva-dashboard/app/templates/dashboard.html @@ -0,0 +1,160 @@ +{% extends 'base.html' %} +{% block content %} +
+
+
+
+

Primary profile

+

{{ summary['profile']['display_name'] }} / {{ summary['profile']['persona'] }}

+
+ {{ summary['profile']['house_style'] }} +
+

{{ summary['profile']['notes'] }}

+
+
Agenda{{ summary['counts']['agenda'] }}
+
Reminders{{ summary['counts']['reminders'] }}
+
Lists{{ summary['counts']['lists'] }}
+
Notes{{ summary['counts']['notes'] }}
+
+
+
+ Vintage owl engraving used as a scholarly crest substitute +
Owl engraving crest
+
+
+ Blue-toned interior painting with elegant room details +
Blue interior study corner
+
+
+
+ +
+
+
+

Quick action

+

Capture a note before it disappears

+
+ Minerva quick add +
+
+ + + + +
+
+ Art Nouveau ornamental frame accent +
Ornamental frame accent for the quick-capture panel.
+
+
+
+ +
+
+

Agenda

Today at a glance

+ +
+ +
+

Reminders

Loose ends

+ +
+ +
+

Lists

Household lanes

+ {% for block in summary['lists'] %} +
+

{{ block['title'] }}

+
    + {% for item in block['items'] %} +
  • {{ item['label'] }}
  • + {% endfor %} +
+
+ {% endfor %} +
+ +
+

Notes

Recent captures

+ {% for item in summary['notes'][:5] %} +
+
+

{{ item['title'] }}

+ {{ item['category'] }} +
+

{{ item['body'] }}

+

{{ item['source'] }}{% if item['created_at'] %} · {{ item['created_at'] }}{% endif %}

+
+ {% endfor %} +
+ +
+

Useful links

Launch points

+ +
+ +
+

Meal ideas

Dinner sparks

+ +
+ +
+

Shortcuts

Fast household moves

+ +
+ +
+

Quick actions

Minerva prompts

+ +
+
+{% endblock %} diff --git a/home/minerva-dashboard/bin/run-local.sh b/home/minerva-dashboard/bin/run-local.sh new file mode 100755 index 0000000..9730ceb --- /dev/null +++ b/home/minerva-dashboard/bin/run-local.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." + +if [ ! -d .venv ]; then + python3 -m venv .venv +fi + +. .venv/bin/activate +pip install -r requirements.txt +exec uvicorn app.main:app --reload --host 0.0.0.0 --port 8094 diff --git a/home/minerva-dashboard/docker-compose.yaml b/home/minerva-dashboard/docker-compose.yaml new file mode 100644 index 0000000..3ad4113 --- /dev/null +++ b/home/minerva-dashboard/docker-compose.yaml @@ -0,0 +1,31 @@ +services: + minerva-dashboard: + container_name: minerva-dashboard + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + ports: + - "8094:8094" + env_file: + - .env + environment: + TZ: ${TZ} + MINERVA_HOST: ${MINERVA_HOST:-0.0.0.0} + MINERVA_PORT: ${MINERVA_PORT:-8094} + MINERVA_STATE_DIR: ${MINERVA_STATE_DIR:-/data/state} + MINERVA_SHARED_PASSWORD: ${MINERVA_SHARED_PASSWORD:-} + volumes: + - /mnt/docker-ssd/docker/appdata/minerva-dashboard:/data + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8094/api/health || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 20s + networks: + - minerva-dashboard-net + +networks: + minerva-dashboard-net: + driver: bridge diff --git a/home/minerva-dashboard/docs/ASSET_SOURCES.md b/home/minerva-dashboard/docs/ASSET_SOURCES.md new file mode 100644 index 0000000..3400259 --- /dev/null +++ b/home/minerva-dashboard/docs/ASSET_SOURCES.md @@ -0,0 +1,41 @@ +# Minerva Dashboard asset sources + +This scaffold vendors a small set of real graphics so the Ravenclaw/common-room theme is visible in the UI rather than existing only as CSS treatment. + +## Vendored assets + +### `app/static/images/library-hero.jpg` +- Title: Interior of a Library - Google Art Project +- Source URL: https://commons.wikimedia.org/wiki/File:Interior_of_a_Library_-_Google_Art_Project.jpg +- Download URL used: https://commons.wikimedia.org/wiki/Special:Redirect/file/Interior_of_a_Library_-_Google_Art_Project.jpg +- License: Public domain in the US / faithful reproduction of a public-domain 2D work per Wikimedia Commons listing +- UI placement: masthead hero image at the top of the dashboard +- Notes: used with dark overlay and cropped responsively for phone readability + +### `app/static/images/blue-interior.jpg` +- Title: Harriet Backer - Blue Interior - Google Art Project +- Source URL: https://commons.wikimedia.org/wiki/File:Harriet_Backer_-_Blue_Interior_-_Google_Art_Project.jpg +- Download URL used: https://commons.wikimedia.org/wiki/Special:Redirect/file/Harriet_Backer_-_Blue_Interior_-_Google_Art_Project.jpg +- License: Public domain in the US / faithful reproduction of a public-domain 2D work per Wikimedia Commons listing +- UI placement: secondary visual medallion inside the primary profile spotlight card +- Notes: intentionally cropped tighter so it reads on phone screens + +### `app/static/images/owl-engraving.jpg` +- Title: The owl copper engraving 1800 +- Source URL: https://commons.wikimedia.org/wiki/File:The_owl_copper_engraving_1800.jpg +- Download URL used: https://commons.wikimedia.org/wiki/Special:Redirect/file/The_owl_copper_engraving_1800.jpg +- License: Public domain / free of known restrictions per Wikimedia Commons listing +- UI placement: crest-style medallion inside the primary profile spotlight card +- Notes: used as the strongest explicit Ravenclaw-adjacent motif without copying franchise crests + +### `app/static/images/art-nouveau-frame.svg` +- Title: Art Nouveau pond frame, Serões, 1901 +- Source URL: https://commons.wikimedia.org/wiki/File:Art_Nouveau_pond_frame,_Ser%C3%B5es,_1901.svg +- Download URL used: https://commons.wikimedia.org/wiki/Special:Redirect/file/Art_Nouveau_pond_frame,_Ser%C3%B5es,_1901.svg +- License: Public domain per Wikimedia Commons listing +- UI placement: subtle framed backdrop on the owl medallion plus a visible ornament in the quick-add card +- Notes: SVG keeps the accent crisp while staying lightweight and easy to recolor later if needed + +## Implementation intent + +The asset pass favors elegant library/interior/owl imagery over childish or direct Harry Potter franchise art. The goal is "Ravenclaw/common-room inspired household dashboard" rather than a copied fan site. diff --git a/home/minerva-dashboard/pytest.ini b/home/minerva-dashboard/pytest.ini new file mode 100644 index 0000000..a635c5c --- /dev/null +++ b/home/minerva-dashboard/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +pythonpath = . diff --git a/home/minerva-dashboard/requirements.txt b/home/minerva-dashboard/requirements.txt new file mode 100644 index 0000000..358bf4b --- /dev/null +++ b/home/minerva-dashboard/requirements.txt @@ -0,0 +1,6 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +jinja2==3.1.4 +python-multipart==0.0.9 +httpx==0.27.2 +pytest==8.3.3 diff --git a/home/minerva-dashboard/tests/test_app.py b/home/minerva-dashboard/tests/test_app.py new file mode 100644 index 0000000..16c0224 --- /dev/null +++ b/home/minerva-dashboard/tests/test_app.py @@ -0,0 +1,110 @@ +from pathlib import Path + +from fastapi.testclient import TestClient + +from app.main import create_app + + +def make_client(tmp_path: Path) -> TestClient: + app = create_app(state_dir=tmp_path) + return TestClient(app) + + +def test_health_endpoint_reports_ok(tmp_path: Path) -> None: + client = make_client(tmp_path) + + response = client.get('/api/health') + + assert response.status_code == 200 + assert response.json() == {'status': 'ok'} + + +def test_dashboard_summary_seeds_manndra_first(tmp_path: Path) -> None: + client = make_client(tmp_path) + + response = client.get('/api/dashboard/summary') + + assert response.status_code == 200 + payload = response.json() + assert payload['profile']['id'] == 'manndra' + assert payload['profile']['display_name'] == 'Manndra' + assert payload['profile']['persona'] == 'Minerva' + assert payload['counts'] == { + 'agenda': 3, + 'reminders': 3, + 'lists': 2, + 'notes': 2, + 'links': 3, + 'meal_ideas': 3, + 'shortcuts': 3, + 'quick_actions': 3, + } + assert payload['agenda'][0]['title'] == 'Morning check-in' + assert payload['lists'][0]['title'] == 'Errands' + assert payload['meal_ideas'][0]['title'] == 'Easy comfort dinner' + + +def test_quick_add_note_updates_summary_and_persists_newest_first(tmp_path: Path) -> None: + client = make_client(tmp_path) + + response = client.post( + '/api/notes/quick-add', + json={ + 'title': 'Field trip forms', + 'body': 'Check whether anything needs signing tonight.', + 'category': 'school', + 'source': 'api-test', + 'pinned': False, + }, + ) + + assert response.status_code == 201 + saved = response.json() + assert saved['title'] == 'Field trip forms' + assert saved['category'] == 'school' + assert saved['source'] == 'api-test' + + summary = client.get('/api/dashboard/summary') + assert summary.status_code == 200 + payload = summary.json() + assert payload['counts']['notes'] == 3 + assert payload['notes'][0]['title'] == 'Field trip forms' + assert payload['notes'][0]['body'] == 'Check whether anything needs signing tonight.' + + +def test_homepage_renders_ravenclaw_common_room_dashboard(tmp_path: Path) -> None: + client = make_client(tmp_path) + + response = client.get('/') + + assert response.status_code == 200 + body = response.text + assert 'Minerva Dashboard' in body + assert 'Ravenclaw common room' in body + assert 'Capture a note before it disappears' in body + assert 'Dinner sparks' in body + assert '/static/images/library-hero.jpg' in body + assert '/static/images/owl-engraving.jpg' in body + assert '/static/images/blue-interior.jpg' in body + assert '/static/images/art-nouveau-frame.svg' in body + + +def test_form_post_redirects_after_note_capture(tmp_path: Path) -> None: + client = make_client(tmp_path) + + response = client.post( + '/quick-actions/notes', + data={ + 'title': 'Gift reminder', + 'body': 'Look for one elegant but practical option.', + 'category': 'household', + }, + follow_redirects=False, + ) + + assert response.status_code == 303 + assert response.headers['location'] == '/' + + summary = client.get('/api/dashboard/summary').json() + assert summary['counts']['notes'] == 3 + assert summary['notes'][0]['title'] == 'Gift reminder'