home: add minerva dashboard scaffold
Some checks failed
secret-guardrails / artifact-secret-scan (push) Has been cancelled
secret-guardrails / gitleaks (push) Has been cancelled

This commit is contained in:
Fizzlepoof
2026-05-25 15:40:26 +00:00
parent 6659fd929f
commit 2db6a23a2c
24 changed files with 1220 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
TZ=America/Chicago
MINERVA_HOST=0.0.0.0
MINERVA_PORT=8094
MINERVA_STATE_DIR=/data/state
MINERVA_SHARED_PASSWORD=

6
home/minerva-dashboard/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
.env
.venv/
__pycache__/
.pytest_cache/
/data/
*.pyc

View File

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

View File

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

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 359 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 803 KiB

View File

@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ summary.profile.persona if summary is defined else 'Minerva' }} Dashboard</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
<div class="page-shell">
<header class="masthead card glow-card">
<div class="masthead-copy">
<div>
<p class="eyebrow">Ravenclaw common room</p>
<h1>Minerva Dashboard</h1>
<p class="lede">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.</p>
</div>
<div class="masthead-meta">
<span class="badge">Private-first</span>
<span class="badge bronze">Phone friendly</span>
<span class="badge outline">{{ summary.profile.tone if summary is defined else 'Elegant, magical, useful' }}</span>
</div>
</div>
<figure class="masthead-hero-frame">
<img
class="masthead-hero-image"
src="/static/images/library-hero.jpg"
alt="Grand library interior with high shelves and warm lamplight"
>
<figcaption>Library study-hall artwork grounding the dashboard in a scholarly common-room mood.</figcaption>
</figure>
</header>
<main>
{% block content %}{% endblock %}
</main>
</div>
</body>
</html>

View File

@@ -0,0 +1,160 @@
{% extends 'base.html' %}
{% block content %}
<section class="hero-grid">
<article class="card spotlight">
<div class="section-heading">
<div>
<p class="eyebrow">Primary profile</p>
<h2>{{ summary['profile']['display_name'] }} / {{ summary['profile']['persona'] }}</h2>
</div>
<span class="badge bronze">{{ summary['profile']['house_style'] }}</span>
</div>
<p class="muted">{{ summary['profile']['notes'] }}</p>
<div class="stats-grid">
<div><span>Agenda</span><strong>{{ summary['counts']['agenda'] }}</strong></div>
<div><span>Reminders</span><strong>{{ summary['counts']['reminders'] }}</strong></div>
<div><span>Lists</span><strong>{{ summary['counts']['lists'] }}</strong></div>
<div><span>Notes</span><strong>{{ summary['counts']['notes'] }}</strong></div>
</div>
<div class="spotlight-visuals" aria-label="Ravenclaw common-room imagery">
<figure class="visual-medallion framed-medallion">
<img src="/static/images/owl-engraving.jpg" alt="Vintage owl engraving used as a scholarly crest substitute">
<figcaption>Owl engraving crest</figcaption>
</figure>
<figure class="visual-medallion room-medallion">
<img src="/static/images/blue-interior.jpg" alt="Blue-toned interior painting with elegant room details">
<figcaption>Blue interior study corner</figcaption>
</figure>
</div>
</article>
<article class="card quick-add-card">
<div class="section-heading">
<div>
<p class="eyebrow">Quick action</p>
<h2>Capture a note before it disappears</h2>
</div>
<span class="badge">Minerva quick add</span>
</div>
<form method="post" action="/quick-actions/notes" class="stack-form">
<label>Title
<input type="text" name="title" placeholder="School follow-up, errand, reminder..." required>
</label>
<label>Note
<textarea name="body" rows="4" placeholder="Write the useful fragment and keep moving." required></textarea>
</label>
<label>Category
<input type="text" name="category" value="note" placeholder="note, reminder, meal, school">
</label>
<button type="submit">Save quick note</button>
</form>
<figure class="inline-ornament">
<img src="/static/images/art-nouveau-frame.svg" alt="Art Nouveau ornamental frame accent">
<figcaption>Ornamental frame accent for the quick-capture panel.</figcaption>
</figure>
</article>
</section>
<section class="dashboard-grid">
<article class="card">
<div class="section-heading"><div><p class="eyebrow">Agenda</p><h2>Today at a glance</h2></div></div>
<ul class="timeline-list">
{% for item in summary['agenda'] %}
<li>
<strong>{{ item.time_label }}</strong>
<span>{{ item.title }}</span>
<p class="muted">{{ item.detail }}</p>
</li>
{% endfor %}
</ul>
</article>
<article class="card">
<div class="section-heading"><div><p class="eyebrow">Reminders</p><h2>Loose ends</h2></div></div>
<ul class="plain-list">
{% for item in summary['reminders'] %}
<li>
<strong>{{ item.title }}</strong>
<span class="pill subtle">{{ item.urgency }}</span>
</li>
{% endfor %}
</ul>
</article>
<article class="card">
<div class="section-heading"><div><p class="eyebrow">Lists</p><h2>Household lanes</h2></div></div>
{% for block in summary['lists'] %}
<div class="subcard">
<h3>{{ block['title'] }}</h3>
<ul class="check-list">
{% for item in block['items'] %}
<li class="{{ 'done' if item['done'] else '' }}">{{ item['label'] }}</li>
{% endfor %}
</ul>
</div>
{% endfor %}
</article>
<article class="card notes-card">
<div class="section-heading"><div><p class="eyebrow">Notes</p><h2>Recent captures</h2></div></div>
{% for item in summary['notes'][:5] %}
<div class="note-item {{ 'pinned' if item['pinned'] else '' }}">
<div class="note-head">
<h3>{{ item['title'] }}</h3>
<span class="pill subtle">{{ item['category'] }}</span>
</div>
<p>{{ item['body'] }}</p>
<p class="muted tiny">{{ item['source'] }}{% if item['created_at'] %} · {{ item['created_at'] }}{% endif %}</p>
</div>
{% endfor %}
</article>
<article class="card">
<div class="section-heading"><div><p class="eyebrow">Useful links</p><h2>Launch points</h2></div></div>
<ul class="plain-list links-list">
{% for item in summary['links'] %}
<li>
<a href="{{ item.url }}">{{ item.label }}</a>
<p class="muted">{{ item.note }}</p>
</li>
{% endfor %}
</ul>
</article>
<article class="card">
<div class="section-heading"><div><p class="eyebrow">Meal ideas</p><h2>Dinner sparks</h2></div></div>
<ul class="plain-list">
{% for item in summary['meal_ideas'] %}
<li>
<strong>{{ item.title }}</strong>
<p class="muted">{{ item.detail }}</p>
</li>
{% endfor %}
</ul>
</article>
<article class="card">
<div class="section-heading"><div><p class="eyebrow">Shortcuts</p><h2>Fast household moves</h2></div></div>
<ul class="plain-list">
{% for item in summary['shortcuts'] %}
<li>
<strong>{{ item.label }}</strong>
<p class="muted">{{ item.hint }}</p>
</li>
{% endfor %}
</ul>
</article>
<article class="card">
<div class="section-heading"><div><p class="eyebrow">Quick actions</p><h2>Minerva prompts</h2></div></div>
<ul class="plain-list">
{% for item in summary['quick_actions'] %}
<li>
<strong>{{ item.label }}</strong>
<p class="muted">{{ item.detail }}</p>
</li>
{% endfor %}
</ul>
</article>
</section>
{% endblock %}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
[pytest]
pythonpath = .

View File

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

View File

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