This commit is contained in:
patsy 2026-08-08 02:53:18 +02:00
parent 6bdcfa3ebc
commit 4c52771ef7
42 changed files with 2096 additions and 0 deletions

3
.env.example Normal file
View file

@ -0,0 +1,3 @@
STEADY_ENV=development
SECRET_KEY=replace-with-a-long-random-value
# DATABASE_URL may override the default absolute instance/steady.db location.

35
AGENTS.md Normal file
View file

@ -0,0 +1,35 @@
# Repository Guidelines
## Project Structure & Module Organization
This repository is currently a minimal Flask template containing only `README.md`. As the application is introduced, keep production code in an `app/` package, with the application factory in `app/__init__.py`, route modules under `app/routes/`, templates in `app/templates/`, and browser assets in `app/static/`. Put automated tests in `tests/`, mirroring the source layout where practical. Keep one-off maintenance commands in `scripts/` rather than mixing them with application modules.
## Build, Test, and Development Commands
No dependency manifest or task runner is committed yet. When adding them, document the exact setup and execution commands in `README.md`. A conventional local workflow is:
```bash
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
flask --app app run --debug
pytest
```
Do not assume these commands work until the corresponding Flask package, requirements file, and tests have been added. Prefer `python -m <tool>` when it avoids ambiguity about which virtual environment is active.
## Coding Style & Naming Conventions
Use four spaces for Python indentation and follow PEP 8. Name modules, functions, and variables with `snake_case`; classes with `PascalCase`; constants with `UPPER_SNAKE_CASE`. Keep routes thin and move reusable business logic into focused service modules. Use descriptive template and endpoint names, such as `account/profile.html` and `account.profile`. If formatters or linters are introduced, commit their configuration and run them before submitting changes.
## Testing Guidelines
Use `pytest` for new tests. Name files `test_*.py` and test functions `test_<behavior>`. Place shared fixtures in `tests/conftest.py`, including an application configured for testing. Cover success paths, validation failures, and relevant HTTP status codes. Every bug fix should include a regression test. No coverage threshold is configured; avoid reducing coverage for code you touch.
## Commit & Pull Request Guidelines
The history currently contains only `first commit`, so no established convention exists. Use short, imperative commit subjects, for example `Add health check endpoint`, and keep unrelated changes separate. Pull requests should explain the purpose and approach, list verification commands, and link relevant issues. Include screenshots for visible template or styling changes, and call out configuration, migration, or deployment impacts.
## Security & Configuration
Never commit secrets, local virtual environments, or generated caches. Load environment-specific values from environment variables, provide safe placeholders in `.env.example`, and keep real `.env` files ignored. Validate user input and avoid exposing Flask debug mode outside local development.

View file

@ -0,0 +1,45 @@
Perfect! Hier ist die Zusammenfassung dessen, was wir erreicht haben:
## 📋 Zusammenfassung unseres Projekts
### 1. Standalone HTML Task Manager ✅
Wir haben eine vollständige, interaktive HTML-Datei erstellt mit:
- **Visuelle Karten-basierte Aufgabenliste**
- **Pastelle Farbpalette** (Lavendel, Blau, Gelb, Lila, Orange, Beige)
- **ADHS-freundliches Design** (beruhigend, klar strukturiert, reduzierte kognitive Belastung)
- **Features**: Status ändern, Timer, Fortschrittsbalken, Dark Mode
- **Markdown Import**: .md Dateien hochladen und Aufgaben bulk importieren
### 2. Design-System definiert ✅
Exakte Farbwerte wurden festgelegt:
```
--lavender: #B8A9C9 | --blue: #A8D8EA | --yellow: #FFEAA7
--purple: #D4A5FF | --orange: #FFB4A2 | --beige: #F5F0E8
```
### 3. ADHD-Design Prinzipien dokumentiert ✅
- **Cognitive Load Reduction**: Keine visuelle Überflutung
- **Executive Function Support**: Bulk Import, Timer für Time Blindness
- **Dopamine Feedback**: Fortschrittsbalken, Animations-Feedback
- **Sensory Considerations**: Smooth transitions, keine harten Kontraste
### 4. Flask App Prompt erstellt ✅
Eine detaillierte **Markdown-Datei** wurde erstellt ("Codex Prompt: Flask ADHS Task Manager") mit:
- **Technische Spezifikation**: Flask + SQLite + Flask-Login + Blueprints
- **User Roles**: Admin, User, Viewer (Platzhalter), Coach (Platzhalter)
- **Features**: CRUD Tasks, .md Import, Timer, Progress Bars, Dark Mode
- **Future-Proof Architecture**: Blueprint Pattern, config-driven features, API placeholder
- **Admin Dashboard**: Platzhalter für zukünftige Erweiterungen
### 5. Nächste Schritte für Codex
Der Prompt in der .md Datei gibt Codex alle Anweisungen, um:
1. Die Flask App Struktur zu erstellen (Blueprints, Models, Routes)
2. SQLite Database mit User/Task Models zu integrieren
3. Authentication mit Rollensystem aufzubauen
4. Die gleichen pastellen Farben und ADHS-UX Prinzipien zu implementieren
5. Die App modular und erweiterbar zu gestalten
---
Die Markdown-Datei enthält alle Details, die Codex braucht, um die Flask App zu entwickeln vom Design-System über die Datenbank-Models bis zu den ADHD-UX Prinzipien. 🚀

View file

@ -0,0 +1,40 @@
# Steady
Steady is an ADHD-friendly task manager built with Flask. Phase 1 provides the
application factory, database and migration setup, secure authentication, and
the shared visual foundation.
## Local setup
```bash
python -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt
flask --app app run --debug
```
The development server is available at `http://127.0.0.1:5000`. Copy
`.env.example` to `.env` or export its values in your shell. Replace the sample
`SECRET_KEY` before using the app beyond local development.
## Database migrations
Apply the committed migrations after installing dependencies:
```bash
flask --app app db upgrade
```
When a model changes, generate a migration with
`flask --app app db migrate -m "Describe the schema change"`, review the
generated operations, and commit them so every environment applies the same
schema.
## Tests
```bash
python -m pytest
```
Tests use an isolated in-memory SQLite database and never write to the local
development database.

56
app/__init__.py Normal file
View file

@ -0,0 +1,56 @@
import os
from pathlib import Path
from flask import Flask
from config import CONFIGS
from .extensions import csrf, db, login_manager, migrate
def create_app(config_name: str | None = None) -> Flask:
"""Create and configure an isolated Steady application instance."""
app = Flask(__name__, instance_relative_config=True)
selected_config = config_name or os.environ.get("STEADY_ENV", "development")
try:
app.config.from_object(CONFIGS[selected_config])
except KeyError as exc:
valid_names = ", ".join(sorted(CONFIGS))
raise ValueError(
f"Unknown configuration {selected_config!r}; choose {valid_names}."
) from exc
Path(app.instance_path).mkdir(parents=True, exist_ok=True)
_validate_config(app)
_initialize_extensions(app)
_register_blueprints(app)
return app
def _validate_config(app: Flask) -> None:
if not app.config.get("SECRET_KEY"):
raise RuntimeError("SECRET_KEY must be set outside development and testing.")
def _initialize_extensions(app: Flask) -> None:
db.init_app(app)
migrate.init_app(app, db)
login_manager.init_app(app)
csrf.init_app(app)
login_manager.login_view = "auth.login"
login_manager.login_message_category = "info"
def _register_blueprints(app: Flask) -> None:
# Imports stay local so feature modules do not create circular imports.
from .auth import bp as auth_blueprint
from .core import bp as core_blueprint
from .tasks import bp as tasks_blueprint
app.register_blueprint(core_blueprint)
app.register_blueprint(auth_blueprint, url_prefix="/auth")
app.register_blueprint(tasks_blueprint, url_prefix="/tasks")

2
app/admin/__init__.py Normal file
View file

@ -0,0 +1,2 @@
"""Reserved package for the optional admin feature."""

2
app/api/__init__.py Normal file
View file

@ -0,0 +1,2 @@
"""Reserved package for versioned API features."""

6
app/auth/__init__.py Normal file
View file

@ -0,0 +1,6 @@
from flask import Blueprint
bp = Blueprint("auth", __name__)
from . import models, routes # noqa: E402, F401

41
app/auth/forms.py Normal file
View file

@ -0,0 +1,41 @@
from flask_wtf import FlaskForm
from wtforms import BooleanField, PasswordField, StringField, SubmitField
from wtforms.validators import DataRequired, Email, EqualTo, Length
class RegistrationForm(FlaskForm):
username = StringField(
"Username",
validators=[DataRequired(), Length(min=2, max=80)],
)
email = StringField(
"Email address",
validators=[DataRequired(), Email(), Length(max=254)],
)
password = PasswordField(
"Password",
validators=[DataRequired(), Length(min=12, max=128)],
)
confirm_password = PasswordField(
"Confirm password",
validators=[DataRequired(), EqualTo("password")],
)
submit = SubmitField("Create account")
class LoginForm(FlaskForm):
email = StringField(
"Email address",
validators=[DataRequired(), Email(), Length(max=254)],
)
password = PasswordField(
"Password",
validators=[DataRequired(), Length(max=128)],
)
remember = BooleanField("Keep me signed in")
submit = SubmitField("Sign in")
class LogoutForm(FlaskForm):
submit = SubmitField("Sign out")

39
app/auth/models.py Normal file
View file

@ -0,0 +1,39 @@
from datetime import datetime, timezone
from flask_login import UserMixin
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from werkzeug.security import check_password_hash, generate_password_hash
from app.extensions import db, login_manager
class User(UserMixin, db.Model):
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(80), unique=True, index=True)
email: Mapped[str] = mapped_column(String(254), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(256))
role: Mapped[str] = mapped_column(String(20), default="user")
created_at: Mapped[datetime] = mapped_column(
default=lambda: datetime.now(timezone.utc)
)
theme: Mapped[str] = mapped_column(String(20), default="auto")
completion_chime: Mapped[bool] = mapped_column(default=True)
dyslexia_font: Mapped[bool] = mapped_column(default=False)
notification_frequency: Mapped[str] = mapped_column(
String(20), default="quiet"
)
def set_password(self, password: str) -> None:
self.password_hash = generate_password_hash(password)
def check_password(self, password: str) -> bool:
return check_password_hash(self.password_hash, password)
@login_manager.user_loader
def load_user(user_id: str) -> User | None:
if not user_id.isdigit():
return None
return db.session.get(User, int(user_id))

64
app/auth/routes.py Normal file
View file

@ -0,0 +1,64 @@
from urllib.parse import urljoin, urlsplit
from flask import flash, redirect, render_template, request, url_for
from flask_login import current_user, login_required, login_user, logout_user
from . import bp
from .forms import LoginForm, LogoutForm, RegistrationForm
from .services import DuplicateUserError, authenticate_user, create_user
def _is_safe_redirect(target: str) -> bool:
host_url = urlsplit(request.host_url)
redirect_url = urlsplit(urljoin(request.host_url, target))
return redirect_url.scheme in {"http", "https"} and (
host_url.netloc == redirect_url.netloc
)
@bp.route("/login", methods=["GET", "POST"])
def login():
if current_user.is_authenticated:
return redirect(url_for("core.index"))
form = LoginForm()
if form.validate_on_submit():
user = authenticate_user(form.email.data, form.password.data)
if user is None:
flash("Email or password is incorrect.", "error")
else:
login_user(user, remember=form.remember.data)
next_page = request.args.get("next", "")
if next_page and _is_safe_redirect(next_page):
return redirect(next_page)
return redirect(url_for("core.index"))
return render_template("auth/login.html", form=form)
@bp.route("/register", methods=["GET", "POST"])
def register():
if current_user.is_authenticated:
return redirect(url_for("core.index"))
form = RegistrationForm()
if form.validate_on_submit():
try:
create_user(form.username.data, form.email.data, form.password.data)
except DuplicateUserError as exc:
flash(str(exc), "error")
else:
flash("Your account is ready. You can sign in now.", "success")
return redirect(url_for("auth.login"))
return render_template("auth/register.html", form=form)
@bp.post("/logout")
@login_required
def logout():
form = LogoutForm()
if form.validate_on_submit():
logout_user()
flash("You have been signed out.", "info")
return redirect(url_for("core.index"))

50
app/auth/services.py Normal file
View file

@ -0,0 +1,50 @@
from sqlalchemy import or_, select
from sqlalchemy.exc import IntegrityError
from app.extensions import db
from .models import User
class DuplicateUserError(ValueError):
pass
def create_user(username: str, email: str, password: str) -> User:
normalized_username = username.strip()
normalized_email = email.strip().lower()
existing_user = db.session.scalar(
select(User).where(
or_(
User.username == normalized_username,
User.email == normalized_email,
)
)
)
if existing_user is not None:
raise DuplicateUserError("That username or email is already registered.")
user = User(username=normalized_username, email=normalized_email)
user.set_password(password)
db.session.add(user)
try:
db.session.commit()
except IntegrityError as exc:
db.session.rollback()
raise DuplicateUserError(
"That username or email is already registered."
) from exc
return user
def authenticate_user(email: str, password: str) -> User | None:
user = db.session.scalar(
select(User).where(User.email == email.strip().lower())
)
if user is None or not user.check_password(password):
return None
return user

7
app/core/__init__.py Normal file
View file

@ -0,0 +1,7 @@
from flask import Blueprint
bp = Blueprint("core", __name__)
from . import routes # noqa: E402, F401

14
app/core/routes.py Normal file
View file

@ -0,0 +1,14 @@
from flask import jsonify, render_template
from . import bp
@bp.get("/")
def index():
return render_template("core/index.html")
@bp.get("/health")
def health():
return jsonify(status="ok")

16
app/extensions.py Normal file
View file

@ -0,0 +1,16 @@
from flask_login import LoginManager
from flask_migrate import Migrate
from flask_sqlalchemy import SQLAlchemy
from flask_wtf import CSRFProtect
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass
db = SQLAlchemy(model_class=Base)
login_manager = LoginManager()
migrate = Migrate()
csrf = CSRFProtect()

2
app/settings/__init__.py Normal file
View file

@ -0,0 +1,2 @@
"""User-settings feature package."""

207
app/static/css/style.css Normal file
View file

@ -0,0 +1,207 @@
:root {
color-scheme: light dark;
--lavender: #b8a9c9;
--blue: #a8d8ea;
--yellow: #ffeaa7;
--orange: #ffb4a2;
--beige: #f5f0e8;
--surface: #fffdf9;
--text: #2d2d3a;
--muted: #626274;
--focus: #67547d;
--border: #d9d2c8;
--space: clamp(1rem, 3vw, 2rem);
}
* {
box-sizing: border-box;
}
body {
min-height: 100vh;
margin: 0;
background: var(--beige);
color: var(--text);
font: 1rem/1.6 system-ui, sans-serif;
}
a {
color: #57436c;
}
a:focus-visible,
button:focus-visible,
input:focus-visible {
outline: 3px solid var(--focus);
outline-offset: 3px;
}
.container {
width: min(100% - 2rem, 62rem);
margin-inline: auto;
}
.site-header {
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.nav,
.nav__actions {
display: flex;
align-items: center;
gap: 1rem;
}
.nav {
min-height: 4.5rem;
justify-content: space-between;
}
.nav__actions form {
margin: 0;
}
.brand {
color: var(--text);
font-size: 1.35rem;
font-weight: 750;
text-decoration: none;
}
.main-content {
padding-block: clamp(2rem, 8vw, 5rem);
}
.hero,
.panel {
max-width: 36rem;
padding: var(--space);
border: 1px solid var(--border);
border-radius: 1rem;
background: var(--surface);
box-shadow: 0 0.5rem 2rem rgb(45 45 58 / 8%);
}
.eyebrow {
color: var(--muted);
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.field {
margin-block: 1.25rem;
}
.field label {
display: block;
margin-bottom: 0.35rem;
font-weight: 650;
}
.field input {
width: 100%;
min-height: 2.75rem;
padding: 0.6rem 0.75rem;
border: 1px solid var(--border);
border-radius: 0.5rem;
background: var(--surface);
color: var(--text);
font: inherit;
}
.field__errors {
margin-block: 0.35rem 0;
padding-left: 1.25rem;
color: #754338;
}
.check-field {
margin-block: 1rem;
}
.button {
display: inline-block;
min-height: 2.75rem;
padding: 0.6rem 1rem;
border: 1px solid #79678f;
border-radius: 0.6rem;
background: var(--lavender);
color: var(--text);
cursor: pointer;
font: inherit;
font-weight: 700;
text-decoration: none;
}
.button--quiet {
background: transparent;
}
.message {
max-width: 36rem;
padding: 0.8rem 1rem;
border-left: 0.35rem solid var(--blue);
border-radius: 0.4rem;
background: var(--surface);
}
.message--error {
border-color: var(--orange);
}
.skip-link {
position: absolute;
top: 0;
left: -9999px;
z-index: 10;
padding: 0.75rem;
background: var(--surface);
}
.skip-link:focus {
left: 0;
}
button,
a,
input {
transition: color 0.2s ease, background-color 0.2s ease,
border-color 0.2s ease, transform 0.2s ease;
}
@media (prefers-color-scheme: dark) {
:root {
--beige: #1e1e24;
--surface: #292932;
--text: #f5f0e8;
--muted: #c5bdcc;
--focus: #d4c0ee;
--border: #4b4b58;
}
a {
color: #d9c5f1;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
@media (max-width: 38rem) {
.nav {
align-items: flex-start;
flex-direction: column;
padding-block: 1rem;
}
}

6
app/tasks/__init__.py Normal file
View file

@ -0,0 +1,6 @@
from flask import Blueprint
bp = Blueprint("tasks", __name__)
from . import models, routes # noqa: E402, F401

63
app/tasks/forms.py Normal file
View file

@ -0,0 +1,63 @@
from flask_wtf import FlaskForm
from wtforms import SelectField, StringField, SubmitField, TextAreaField
from wtforms.fields import DateTimeLocalField
from wtforms.validators import DataRequired, Length, Optional, ValidationError
from .models import TASK_PRIORITIES, TASK_STATUSES
STATUS_LABELS = {
"not_started": "Not started",
"in_progress": "In progress",
"done": "Done",
}
PRIORITY_LABELS = {
"urgent": "Urgent",
"important": "Important",
"normal": "Normal",
}
def not_blank(_form: FlaskForm, field: StringField) -> None:
if not field.data or not field.data.strip():
raise ValidationError("This field cannot be blank.")
class TaskForm(FlaskForm):
title = StringField(
"Task title",
validators=[DataRequired(), not_blank, Length(max=200)],
)
description = TextAreaField(
"Notes",
validators=[Optional(), Length(max=5000)],
)
status = SelectField(
"Status",
choices=[(value, STATUS_LABELS[value]) for value in TASK_STATUSES],
default="not_started",
)
priority = SelectField(
"Priority",
choices=[(value, PRIORITY_LABELS[value]) for value in TASK_PRIORITIES],
default="normal",
)
due_date = DateTimeLocalField(
"Due date and time",
format="%Y-%m-%dT%H:%M",
validators=[Optional()],
)
submit = SubmitField("Save task")
class SubtaskForm(FlaskForm):
title = StringField(
"Next small step",
validators=[DataRequired(), not_blank, Length(max=100)],
)
submit = SubmitField("Add step")
class ActionForm(FlaskForm):
submit = SubmitField()

70
app/tasks/models.py Normal file
View file

@ -0,0 +1,70 @@
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import CheckConstraint, ForeignKey, JSON, String, Text
from sqlalchemy.ext.mutable import MutableList
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.extensions import db
TASK_STATUSES = ("not_started", "in_progress", "done")
TASK_PRIORITIES = ("urgent", "important", "normal")
class Task(db.Model):
__table_args__ = (
CheckConstraint(
"status IN ('not_started', 'in_progress', 'done')",
name="ck_task_status",
),
CheckConstraint(
"priority IN ('urgent', 'important', 'normal')",
name="ck_task_priority",
),
)
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(200))
description: Mapped[str | None] = mapped_column(Text)
status: Mapped[str] = mapped_column(String(20), default="not_started")
priority: Mapped[str] = mapped_column(String(20), default="normal")
created_at: Mapped[datetime] = mapped_column(
default=lambda: datetime.now(timezone.utc)
)
due_date: Mapped[datetime | None]
completed_at: Mapped[datetime | None]
user_id: Mapped[int] = mapped_column(
ForeignKey("user.id", ondelete="CASCADE"), index=True
)
# Stored now so Phase 3 can add timers and chunking without reshaping CRUD.
timer_seconds: Mapped[int] = mapped_column(default=0)
context: Mapped[str | None] = mapped_column(String(100))
chunk_sessions: Mapped[list[Any]] = mapped_column(
MutableList.as_mutable(JSON), default=list
)
subtasks: Mapped[list["Subtask"]] = relationship(
back_populates="task",
cascade="all, delete-orphan",
order_by="Subtask.id",
)
@property
def progress_percentage(self) -> int:
if not self.subtasks:
return 100 if self.status == "done" else 0
completed = sum(subtask.completed for subtask in self.subtasks)
return round(completed / len(self.subtasks) * 100)
class Subtask(db.Model):
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(100))
completed: Mapped[bool] = mapped_column(default=False)
task_id: Mapped[int] = mapped_column(
ForeignKey("task.id", ondelete="CASCADE"), index=True
)
task: Mapped[Task] = relationship(back_populates="subtasks")

136
app/tasks/routes.py Normal file
View file

@ -0,0 +1,136 @@
from flask import abort, flash, redirect, render_template, request, url_for
from flask_login import current_user, login_required
from . import bp
from .forms import ActionForm, STATUS_LABELS, SubtaskForm, TaskForm
from .models import TASK_STATUSES
from .services import (
TaskNotFoundError,
add_subtask,
create_task,
delete_task,
get_task,
list_tasks,
list_today_tasks,
toggle_subtask,
update_task,
)
def _owned_task_or_404(task_id: int):
try:
return get_task(task_id, current_user.id)
except TaskNotFoundError:
abort(404)
@bp.get("/")
@login_required
def index():
selected_status = request.args.get("status") or None
if selected_status not in {None, *TASK_STATUSES}:
abort(400)
tasks = list_tasks(current_user.id, selected_status)
return render_template(
"tasks/list.html",
tasks=tasks,
selected_status=selected_status,
status_labels=STATUS_LABELS,
)
@bp.get("/today")
@login_required
def today():
tasks = list_today_tasks(current_user.id)
return render_template("tasks/today.html", tasks=tasks)
@bp.route("/new", methods=["GET", "POST"])
@login_required
def create():
form = TaskForm()
if form.validate_on_submit():
task = create_task(
current_user.id,
title=form.title.data,
description=form.description.data,
status=form.status.data,
priority=form.priority.data,
due_date=form.due_date.data,
)
flash("Task captured.", "success")
return redirect(url_for("tasks.detail", task_id=task.id))
return render_template("tasks/form.html", form=form, heading="New task")
@bp.get("/<int:task_id>")
@login_required
def detail(task_id: int):
task = _owned_task_or_404(task_id)
return render_template(
"tasks/detail.html",
task=task,
subtask_form=SubtaskForm(),
action_form=ActionForm(),
)
@bp.route("/<int:task_id>/edit", methods=["GET", "POST"])
@login_required
def edit(task_id: int):
task = _owned_task_or_404(task_id)
form = TaskForm(obj=task)
if form.validate_on_submit():
update_task(
task,
title=form.title.data,
description=form.description.data,
status=form.status.data,
priority=form.priority.data,
due_date=form.due_date.data,
)
flash("Task updated.", "success")
return redirect(url_for("tasks.detail", task_id=task.id))
return render_template("tasks/form.html", form=form, heading="Edit task")
@bp.post("/<int:task_id>/delete")
@login_required
def delete(task_id: int):
task = _owned_task_or_404(task_id)
form = ActionForm()
if not form.validate_on_submit():
abort(400)
delete_task(task)
flash("Task deleted.", "info")
return redirect(url_for("tasks.index"))
@bp.post("/<int:task_id>/subtasks")
@login_required
def create_subtask(task_id: int):
task = _owned_task_or_404(task_id)
form = SubtaskForm()
if form.validate_on_submit():
add_subtask(task, form.title.data)
flash("Small step added.", "success")
else:
for errors in form.errors.values():
for error in errors:
flash(error, "error")
return redirect(url_for("tasks.detail", task_id=task.id))
@bp.post("/<int:task_id>/subtasks/<int:subtask_id>/toggle")
@login_required
def toggle_subtask_status(task_id: int, subtask_id: int):
task = _owned_task_or_404(task_id)
form = ActionForm()
if not form.validate_on_submit():
abort(400)
try:
toggle_subtask(task, subtask_id)
except TaskNotFoundError:
abort(404)
return redirect(url_for("tasks.detail", task_id=task.id))

158
app/tasks/services.py Normal file
View file

@ -0,0 +1,158 @@
from datetime import datetime, time, timezone
from sqlalchemy import case, or_, select
from sqlalchemy.orm import selectinload
from app.extensions import db
from .models import TASK_PRIORITIES, TASK_STATUSES, Subtask, Task
class TaskNotFoundError(LookupError):
pass
def _task_query(user_id: int):
return (
select(Task)
.where(Task.user_id == user_id)
.options(selectinload(Task.subtasks))
)
def _task_order():
priority_order = case(
{"urgent": 0, "important": 1, "normal": 2},
value=Task.priority,
else_=3,
)
return (
case((Task.status == "done", 1), else_=0),
priority_order,
Task.due_date.is_(None),
Task.due_date,
Task.created_at,
)
def get_task(task_id: int, user_id: int) -> Task:
task = db.session.scalar(_task_query(user_id).where(Task.id == task_id))
if task is None:
raise TaskNotFoundError
return task
def list_tasks(user_id: int, status: str | None = None) -> list[Task]:
query = _task_query(user_id)
if status is not None:
if status not in TASK_STATUSES:
raise ValueError("Unknown task status.")
query = query.where(Task.status == status)
return list(db.session.scalars(query.order_by(*_task_order())))
def list_today_tasks(
user_id: int,
*,
now: datetime | None = None,
limit: int = 7,
) -> list[Task]:
local_now = now or datetime.now().astimezone()
end_of_today = datetime.combine(local_now.date(), time.max)
query = (
_task_query(user_id)
.where(Task.status != "done")
.where(or_(Task.due_date.is_(None), Task.due_date <= end_of_today))
.order_by(*_task_order())
.limit(limit)
)
return list(db.session.scalars(query))
def create_task(
user_id: int,
*,
title: str,
description: str | None,
status: str,
priority: str,
due_date: datetime | None,
) -> Task:
_validate_choices(status, priority)
task = Task(
user_id=user_id,
title=title.strip(),
description=_clean_optional(description),
status=status,
priority=priority,
due_date=due_date,
)
if status == "done":
task.completed_at = datetime.now(timezone.utc)
db.session.add(task)
db.session.commit()
return task
def update_task(
task: Task,
*,
title: str,
description: str | None,
status: str,
priority: str,
due_date: datetime | None,
) -> Task:
_validate_choices(status, priority)
was_done = task.status == "done"
task.title = title.strip()
task.description = _clean_optional(description)
task.status = status
task.priority = priority
task.due_date = due_date
if status == "done" and not was_done:
task.completed_at = datetime.now(timezone.utc)
elif status != "done":
task.completed_at = None
db.session.commit()
return task
def delete_task(task: Task) -> None:
db.session.delete(task)
db.session.commit()
def add_subtask(task: Task, title: str) -> Subtask:
subtask = Subtask(task=task, title=title.strip())
db.session.add(subtask)
db.session.commit()
return subtask
def toggle_subtask(task: Task, subtask_id: int) -> Subtask:
subtask = next(
(item for item in task.subtasks if item.id == subtask_id),
None,
)
if subtask is None:
raise TaskNotFoundError
subtask.completed = not subtask.completed
db.session.commit()
return subtask
def _validate_choices(status: str, priority: str) -> None:
if status not in TASK_STATUSES:
raise ValueError("Unknown task status.")
if priority not in TASK_PRIORITIES:
raise ValueError("Unknown task priority.")
def _clean_optional(value: str | None) -> str | None:
cleaned = value.strip() if value else ""
return cleaned or None

View file

@ -0,0 +1,12 @@
{% macro render_field(field, autocomplete=None) %}
<div class="field">
{{ field.label }}
{{ field(autocomplete=autocomplete, aria_describedby=field.id ~ '-errors' if field.errors else none) }}
{% if field.errors %}
<ul id="{{ field.id }}-errors" class="field__errors">
{% for error in field.errors %}<li>{{ error }}</li>{% endfor %}
</ul>
{% endif %}
</div>
{% endmacro %}

View file

@ -0,0 +1,20 @@
{% extends "base.html" %}
{% from "auth/_field.html" import render_field %}
{% block title %}Sign in · Steady{% endblock %}
{% block content %}
<section class="panel" aria-labelledby="login-heading">
<h1 id="login-heading">Welcome back</h1>
<p>Sign in to continue at your own pace.</p>
<form method="post" novalidate>
{{ form.hidden_tag() }}
{{ render_field(form.email, "email") }}
{{ render_field(form.password, "current-password") }}
<div class="check-field">{{ form.remember() }} {{ form.remember.label }}</div>
{{ form.submit(class="button") }}
</form>
<p>New here? <a href="{{ url_for('auth.register') }}">Create an account</a>.</p>
</section>
{% endblock %}

View file

@ -0,0 +1,21 @@
{% extends "base.html" %}
{% from "auth/_field.html" import render_field %}
{% block title %}Create account · Steady{% endblock %}
{% block content %}
<section class="panel" aria-labelledby="register-heading">
<h1 id="register-heading">Create your account</h1>
<p>Just the essentials. You can personalize Steady later.</p>
<form method="post" novalidate>
{{ form.hidden_tag() }}
{{ render_field(form.username, "username") }}
{{ render_field(form.email, "email") }}
{{ render_field(form.password, "new-password") }}
{{ render_field(form.confirm_password, "new-password") }}
{{ form.submit(class="button") }}
</form>
<p>Already registered? <a href="{{ url_for('auth.login') }}">Sign in</a>.</p>
</section>
{% endblock %}

44
app/templates/base.html Normal file
View file

@ -0,0 +1,44 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>{% block title %}Steady{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<a class="skip-link" href="#main-content">Skip to content</a>
<header class="site-header">
<nav class="container nav" aria-label="Main navigation">
<a class="brand" href="{{ url_for('core.index') }}">Steady</a>
<div class="nav__actions">
{% if current_user.is_authenticated %}
<span>Hi, {{ current_user.username }}</span>
<form action="{{ url_for('auth.logout') }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="button button--quiet" type="submit">Sign out</button>
</form>
{% else %}
<a href="{{ url_for('auth.login') }}">Sign in</a>
<a class="button" href="{{ url_for('auth.register') }}">Create account</a>
{% endif %}
</div>
</nav>
</header>
<main id="main-content" class="container main-content">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="messages" aria-live="polite">
{% for category, message in messages %}
<p class="message message--{{ category }}">{{ message }}</p>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
</body>
</html>

View file

@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block title %}Home · Steady{% endblock %}
{% block content %}
<section class="hero" aria-labelledby="welcome-heading">
<p class="eyebrow">One calm step at a time</p>
<h1 id="welcome-heading">Welcome to Steady</h1>
{% if current_user.is_authenticated %}
<p>Your workspace is ready. Task management arrives in Phase 2.</p>
{% else %}
<p>A quiet, ADHD-friendly place to capture and complete what matters.</p>
<a class="button" href="{{ url_for('auth.register') }}">Create your account</a>
{% endif %}
</section>
{% endblock %}

54
config.py Normal file
View file

@ -0,0 +1,54 @@
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent
class Config:
"""Shared settings with secure production-oriented defaults."""
SECRET_KEY = os.environ.get("SECRET_KEY")
SQLALCHEMY_DATABASE_URI = os.environ.get(
"DATABASE_URL",
f"sqlite:///{BASE_DIR / 'instance' / 'steady.db'}",
)
SQLALCHEMY_TRACK_MODIFICATIONS = False
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
SESSION_COOKIE_SECURE = True
REMEMBER_COOKIE_HTTPONLY = True
REMEMBER_COOKIE_SAMESITE = "Lax"
REMEMBER_COOKIE_SECURE = True
WTF_CSRF_TIME_LIMIT = 3600
FEATURE_ADMIN = False
FEATURE_API_V1 = False
class DevelopmentConfig(Config):
DEBUG = True
SECRET_KEY = os.environ.get("SECRET_KEY", "development-only-change-me")
SESSION_COOKIE_SECURE = False
REMEMBER_COOKIE_SECURE = False
class TestingConfig(Config):
TESTING = True
SECRET_KEY = "testing-only"
SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:"
WTF_CSRF_ENABLED = False
SESSION_COOKIE_SECURE = False
REMEMBER_COOKIE_SECURE = False
class ProductionConfig(Config):
pass
CONFIGS = {
"development": DevelopmentConfig,
"testing": TestingConfig,
"production": ProductionConfig,
}

319
design.md Normal file
View file

@ -0,0 +1,319 @@
# 📋 Design Document: Steady ADHD-Friendly Flask Task Manager
## 1. Projektübersicht
**Steady** ist eine Flask-basierte, ADHS-freundliche Task-Management-Anwendung, die wissenschaftliche Erkenntnisse über Executive Function Deficits in ein intuitives, beruhigendes UI übersetzt. Die App compensiert typische ADHS-Herausforderungen wie Time Blindness, Working Memory Limits und Entscheidungsparalyse durch strukturierte, visuell ruhige Interaktion.
---
## 2. Technische Architektur
### 2.1 Stack
- **Backend**: Flask 3.x mit Application-Factory- und Blueprint-Architektur
- **Database**: SQLite3 (Flask-SQLAlchemy ORM)
- **Authentifizierung**: Flask-Login (Passwort-Hashing via werkzeug.security)
- **Frontend**: Jinja2 Templates + Vanilla CSS (keine Frameworks, nur Reset) + Vanilla JS
- **Sicherung**: JSON Export/Import für User-Backups
- **Erweiterbarkeit**: Unabhängige Feature-Packages, Config-driven Feature Toggles, versionierbare API
### 2.2 Verbindliche Architekturprinzipien
- Die Flask-App **muss Raum für weitere Apps und Features lassen**. Jede fachliche Funktion wird als eigenständiges Feature-Package mit Blueprint, Routes, Models, Services und optionalen Templates angelegt.
- Die Application Factory (`create_app`) erstellt und konfiguriert App-Instanzen. Globale Erweiterungen werden ungebunden in `extensions.py` definiert und per `init_app()` verbunden.
- Features dürfen keine internen Routes, Models oder Templates anderer Features importieren. Gemeinsame, fachlich neutrale Bausteine gehören nach `app/core/`; notwendige fachübergreifende Abläufe nutzen dokumentierte Service-Schnittstellen.
- Neue Features müssen registriert oder über Konfiguration aktiviert werden können, ohne bestehende Feature-Packages umzubauen.
- Erweiterbarkeit darf Sicherheit, Tests, Barrierefreiheit oder Wartbarkeit nicht umgehen. Etablierte Flask- und Python-Best-Practices sind verpflichtend.
### 2.3 Verzeichnisstruktur
```
steady/
├── app/
│ ├── __init__.py # create_app() und Blueprint-Registrierung
│ ├── extensions.py # db, login_manager, csrf (ungebunden)
│ ├── core/ # Gemeinsame Fehlerseiten und neutrale Services
│ ├── auth/ # Eigenständiges Auth-Feature
│ │ ├── __init__.py
│ │ ├── routes.py
│ │ ├── models.py
│ │ └── services.py
│ ├── tasks/ # Eigenständiges Task-Feature
│ │ ├── __init__.py
│ │ ├── routes.py
│ │ ├── models.py
│ │ └── services.py
│ ├── settings/ # Eigenständiges Settings-Feature
│ ├── admin/ # Deaktivierbares zukünftiges Feature
│ ├── api/ # Versionierte API-Blueprints (zukünftig)
│ │ └── v1/
│ ├── templates/
│ │ ├── base.html
│ │ ├── auth/
│ │ ├── dashboard/
│ │ ├── tasks/
│ │ ├── settings/
│ │ └── admin/
│ └── static/
│ ├── css/
│ │ └── style.css # Pastel Design-System
│ └── js/
│ └── main.js
├── config.py # Umgebungsconfig und Feature Toggles
├── wsgi.py # Produktions-Entrypoint
├── requirements.txt
├── instance/
│ └── steady.db # SQLite Database
└── tests/ # Unit- und Feature-Integrationstests
├── conftest.py
├── auth/
└── tasks/
```
Diese Struktur ist ein Vertrag: Neue Funktionen wie Kalender, Coaching oder Benachrichtigungen werden als zusätzliche Packages unter `app/` ergänzt. Nur tatsächlich gemeinsam genutzter, fachlich neutraler Code darf nach `app/core/` verschoben werden.
---
## 3. Datenbank-Modell (SQLite/SQLAlchemy)
### 3.1 User Model
```python
class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
password_hash = db.Column(db.String(256), nullable=False)
role = db.Column(db.String(20), default='user') # user, admin, viewer, coach
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# Settings (JSON-serialized)
theme = db.Column(db.String(20), default='auto') # auto, light, dark
completion_chime = db.Column(db.Boolean, default=True)
dyslexia_font = db.Column(db.Boolean, default=False)
notification_frequency = db.Column(db.String(20), default='quiet') # quiet, moderate, alert
tasks = db.relationship('Task', backref='owner', lazy=True)
```
### 3.2 Task Model
```python
class Task(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text, nullable=True)
status = db.Column(db.String(20), default='not_started') # not_started, in_progress, done
priority = db.Column(db.String(20), default='normal') # urgent, important, normal
created_at = db.Column(db.DateTime, default=datetime.utcnow)
due_date = db.Column(db.DateTime, nullable=True)
completed_at = db.Column(db.DateTime, nullable=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
# ADHD Features
subtasks = db.relationship('Subtask', backref='parent_task', lazy=True)
timer_seconds = db.Column(db.Integer, default=0) # Countdown Timer
context = db.Column(db.String(100), nullable=True) # Location/Context-based reminders
chunk_sessions = db.Column(db.JSON, default=list) # Time-block sessions ["5min", "5min"]
```
### 3.3 Subtask Model
```python
class Subtask(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(100), nullable=False)
completed = db.Column(db.Boolean, default=False)
task_id = db.Column(db.Integer, db.ForeignKey('task.id'), nullable=False)
```
---
## 4. ADHS-UX/UI Richtlinien (aus ADHD.md abgeleitet)
### 4.1 Feature 1: Radical Reduction of Visible Items [1]
- **Today View** zeigt maximal 7 Items (Working Memory Cap) [1][2]
- Future-dated Items werden automatisch versteckt (Deferr/Snooze) [1]
- **One Primary Action** surfaced at a time in Focus Mode [1]
### 4.2 Feature 2: Frictionless Capture [1]
- Voice Input / Quick Add (<3 Sekunden) [1]
- Unstructured Inbox First, dann Organization [1]
- Markdown/Text Bulk Import [1]
### 4.3 Feature 3: Concrete External Time Structures [1]
- Visual Countdown Timer pro Task [1]
- Progress Bars für Time Blindness Countermeasure [1]
- Task Chunking in Time-Blocks (z.B. "3 x 5-min") [1]
- Context-based Reminders (Location) statt Fixed-Clock Alerts [1]
### 4.4 Feature 4: Forgiving, Shame-Free Feedback [1]
- Keine roten Overdue-Warnungen [1]
- Streak Freeze (Flexible Goals) [1]
- Immediate Positive Feedback (Animation + Audio Chime) [1]
### 4.5 Feature 5: Task Decomposition [1]
- Subtask-System für Step-by-Step Scaffolding [1]
- Persistent Progress Indicators [1]
- Sequential Task Lists (nur nächster Schritt) [1]
### 4.6 Feature 6: Visual Simplicity [1]
- Minimalist Layout mit Generous White Space [1]
- Consistent Navigation [1]
- User-Controlled Notifications (Default: Quiet) [1]
- System "Reduce Motion" Respekt [1]
### 4.7 Feature 7: Personalization [1]
- Theme Toggle (Light/Dark/Auto) [1]
- Dyslexia-Friendly Font Toggle [1]
- Customizable Notification Frequency [1]
---
## 5. Design-System (CSS Variables)
```css
:root {
/* Pastel Palette */
--lavender: #B8A9C9; /* Primary Actions, Important Tasks */
--blue: #A8D8EA; /* Completed Tasks */
--yellow: #FFEAA7; /* Urgent Tasks */
--purple: #D4A5FF; /* Not Started Tasks */
--orange: #FFB4A2; /* In Progress Tasks */
--beige: #F5F0E8; /* Background Light */
--dark-bg: #1E1E24; /* Background Dark */
--text-primary: #2D2D3A;
--text-secondary: #6B6B7B;
--success: #A8D8EA;
--warning: #FFEAA7;
--error: #FFB4A2; /* Nur für positive Feedback, nie für Overdue */
}
/* ADHD Guidelines: ruhige, gezielte Übergänge statt transition: all */
button,
a,
.task-card {
transition: color 0.2s ease, background-color 0.2s ease,
border-color 0.2s ease, transform 0.2s ease;
}
/* Generous White Space */
.task-card {
padding: 1.5rem;
margin-bottom: 1rem;
}
/* Accessibility-Best-Practice: Systemeinstellung respektieren */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
```
---
## 6. Feature-Phasen & Task-Liste
### Phase 1: Core Infrastruktur (Week 1-2)
- [ ] Flask Application Factory mit unabhängiger Blueprint-Registrierung [3]
- [ ] Zentrale, ungebundene Extensions mit `init_app()` [3]
- [ ] Config-Klassen für Development, Testing und Production sowie Feature Toggles [3]
- [ ] SQLite Database Setup mit SQLAlchemy [3]
- [ ] User Model & Registration/Login Routes [3]
- [ ] Password Hashing mit werkzeug.security [3]
- [ ] Session Cookie Security (HttpOnly, SameSite=Lax) [3]
- [ ] Jinja2 Base Template mit Pastel CSS [2]
### Phase 2: Task Management (Week 3-4)
- [ ] Task CRUD Operations (Create, Read, Update, Delete) [3]
- [ ] Task List View mit Today View (Max 7 Items) [1]
- [ ] Status Filter (All, Not Started, In Progress, Done) [2]
- [ ] Priority Badge System (Urgent, Important, Normal) [2]
- [ ] Subtask Decomposition UI [1]
- [ ] Progress Bar Component [1]
### Phase 3: ADHD-Specific Features (Week 5-6)
- [ ] Focus Mode (One Primary Action) [1]
- [ ] Countdown Timer per Task [1]
- [ ] Task Chunking UI (Time-Blocks) [1]
- [ ] Context-based Reminders Placeholder [1]
- [ ] Positive Feedback Animation + Completion Chime [1]
- [ ] Forgiving Streak System (No Guilt) [1]
### Phase 4: Import/Export & Backup (Week 7)
- [ ] Markdown File Upload & Bulk Task Import [2]
- [ ] JSON Export (Backup) [2]
- [ ] JSON Import (Restore) [2]
- [ ] Clear Completed Tasks (Undoable) [2]
### Phase 5: Settings & Personalization (Week 8)
- [ ] Theme Toggle (Light/Dark/Auto) [2]
- [ ] Dyslexia-Friendly Font Toggle [2]
- [ ] Notification Frequency Settings [1]
- [ ] Browser Reminders (Web Push API Placeholder) [2]
### Phase 6: Admin Dashboard (Placeholder - Future) [3]
- [ ] Admin Route Placeholder [3]
- [ ] User Management UI (Future) [3]
- [ ] Analytics Dashboard (Future) [3]
- [ ] Role-based Access Control (Admin, User, Viewer, Coach) [3]
### Phase 7: Polish & Testing (Week 9-10)
- [ ] Responsive Design Testing [2]
- [ ] Reduce Motion Respekt [1]
- [ ] Accessibility Audit (WCAG) [1]
- [ ] Performance Optimization [3]
- [ ] Documentation & Deployment Guide [3]
---
## 7. API Placeholder (Future Integration)
```python
# app/api/v1/
# Separater, versionierter Blueprint für stabile Integrationen
# - /api/v1/tasks (JSON CRUD)
# - /api/v1/users (Admin)
# - /api/v1/analytics (Admin Dashboard)
```
Web-Routes und API-Routes verwenden dieselbe Service-Schicht, damit Geschäftslogik nicht dupliziert wird. Öffentliche Schnittstellen müssen versioniert, validiert, autorisiert und durch Vertragstests abgesichert sein.
---
## 8. Sicherheit & Best Practices
- **Password Hashing**: werkzeug.security.generate_password_hash [3]
- **Session Security**: HttpOnly, SameSite=Lax Cookies [3]
- **CSRF Protection**: Flask-WTF Token [3]
- **Input Validation**: Form-/Schema-Validierung an jeder Systemgrenze; SQLAlchemy schützt Datenbankabfragen zusätzlich durch parametrisierte Queries [3]
- **Role-Based Access**: Flask-Login @login_required + Role Check [3]
- **Best Practices (verpflichtend)**: PEP 8, Type Hints für öffentliche Funktionen, kleine Module, klare Verantwortlichkeiten und keine Geschäftslogik in Routes oder Templates
- **Dependency Management**: Versionen reproduzierbar festhalten, Updates prüfen und bekannte Schwachstellen vermeiden
- **Tests**: Jedes Feature erhält isolierte Unit-Tests und Integrationstests mit einer eigenen Test-App und temporärer Datenbank
- **Migrationen**: Schemaänderungen ausschließlich nachvollziehbar über Flask-Migrate/Alembic; keine manuellen Produktionsänderungen
- **Observability**: Strukturiertes Logging und zentrale Fehlerbehandlung ohne Secrets oder personenbezogene Daten in Logs
- **Feature Contract**: Jedes neue Feature dokumentiert Blueprint, Konfiguration, Berechtigungen, Datenmodell und öffentliche Service-Schnittstellen
---
## 9. Zukünftige Erweiterungen
Alle Erweiterungen werden als deaktivierbare Feature-Packages umgesetzt und über die Application Factory registriert. Sie dürfen bestehende Features nur über dokumentierte Schnittstellen verwenden und müssen unabhängig testbar bleiben.
- **Multi-User Collaboration**: Shared Tasks, Coach-User Mapping [3]
- **Push Notifications**: WebSockets/Celery Background Tasks [3]
- **AI Task Summarization**: LLM Integration for Auto-Subtasking [3]
- **Calendar Integration**: Google/Outlook Sync [3]
- **Mobile App**: React Native Wrapper [3]
---
## 10. Referenzen
[1] ADHD.md Wissenschaftliche Grundlagen für ADHS-UX/UI
[2] index.html –现有 HTML Mockup Features
[3] Codex Prompt.md Flask App Architektur
---
*Diese Design-Dokument dient als BluePrint für die Entwicklung der Flask-App. Alle ADHS-UX-Richtlinien sind evidenzbasiert aus peer-reviewed Meta-Analysen und UX-Research für neurodivergente Nutzer.*

1
migrations/README Normal file
View file

@ -0,0 +1 @@
Single-database configuration for Flask.

50
migrations/alembic.ini Normal file
View file

@ -0,0 +1,50 @@
# A generic, single database configuration.
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic,flask_migrate
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[logger_flask_migrate]
level = INFO
handlers =
qualname = flask_migrate
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

113
migrations/env.py Normal file
View file

@ -0,0 +1,113 @@
import logging
from logging.config import fileConfig
from flask import current_app
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
logger = logging.getLogger('alembic.env')
def get_engine():
try:
# this works with Flask-SQLAlchemy<3 and Alchemical
return current_app.extensions['migrate'].db.get_engine()
except (TypeError, AttributeError):
# this works with Flask-SQLAlchemy>=3
return current_app.extensions['migrate'].db.engine
def get_engine_url():
try:
return get_engine().url.render_as_string(hide_password=False).replace(
'%', '%%')
except AttributeError:
return str(get_engine().url).replace('%', '%%')
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
config.set_main_option('sqlalchemy.url', get_engine_url())
target_db = current_app.extensions['migrate'].db
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def get_metadata():
if hasattr(target_db, 'metadatas'):
return target_db.metadatas[None]
return target_db.metadata
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url, target_metadata=get_metadata(), literal_binds=True
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, 'autogenerate', False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info('No changes in schema detected.')
conf_args = current_app.extensions['migrate'].configure_args
if conf_args.get("process_revision_directives") is None:
conf_args["process_revision_directives"] = process_revision_directives
connectable = get_engine()
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=get_metadata(),
**conf_args
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

24
migrations/script.py.mako Normal file
View file

@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View file

@ -0,0 +1,48 @@
"""Create user table
Revision ID: b74f27228a73
Revises:
Create Date: 2026-08-08 02:42:54.095321
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'b74f27228a73'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('user',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('username', sa.String(length=80), nullable=False),
sa.Column('email', sa.String(length=254), nullable=False),
sa.Column('password_hash', sa.String(length=256), nullable=False),
sa.Column('role', sa.String(length=20), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('theme', sa.String(length=20), nullable=False),
sa.Column('completion_chime', sa.Boolean(), nullable=False),
sa.Column('dyslexia_font', sa.Boolean(), nullable=False),
sa.Column('notification_frequency', sa.String(length=20), nullable=False),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_user_email'), ['email'], unique=True)
batch_op.create_index(batch_op.f('ix_user_username'), ['username'], unique=True)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_user_username'))
batch_op.drop_index(batch_op.f('ix_user_email'))
op.drop_table('user')
# ### end Alembic commands ###

View file

@ -0,0 +1,66 @@
"""Add tasks and subtasks
Revision ID: f9ae19443d53
Revises: b74f27228a73
Create Date: 2026-08-08 02:50:58.197622
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f9ae19443d53'
down_revision = 'b74f27228a73'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('task',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(length=200), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('priority', sa.String(length=20), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('due_date', sa.DateTime(), nullable=True),
sa.Column('completed_at', sa.DateTime(), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('timer_seconds', sa.Integer(), nullable=False),
sa.Column('context', sa.String(length=100), nullable=True),
sa.Column('chunk_sessions', sa.JSON(), nullable=False),
sa.CheckConstraint("priority IN ('urgent', 'important', 'normal')", name='ck_task_priority'),
sa.CheckConstraint("status IN ('not_started', 'in_progress', 'done')", name='ck_task_status'),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('task', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_task_user_id'), ['user_id'], unique=False)
op.create_table('subtask',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(length=100), nullable=False),
sa.Column('completed', sa.Boolean(), nullable=False),
sa.Column('task_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['task_id'], ['task.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('subtask', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_subtask_task_id'), ['task_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('subtask', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_subtask_task_id'))
op.drop_table('subtask')
with op.batch_alter_table('task', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_task_user_id'))
op.drop_table('task')
# ### end Alembic commands ###

86
progress.md Normal file
View file

@ -0,0 +1,86 @@
# Development Progress
This is the living implementation log for Steady. Update it after every meaningful development step with what changed, why it changed, how it was verified, and what remains.
## Current Status
- Current phase: Phase 2 — Task Management
- Status: In progress — routes and interface
- Last updated: 2026-08-08
- Next milestone: Ownership-safe task service layer
## Phase 1 — Core Infrastructure
### 1. Application foundation
Added an application factory, environment-specific configuration, secure defaults, feature flags, and independently initialized Flask extensions.
Why: isolated application instances support testing and future deployments, while `init_app()` keeps extensions reusable. Feature packages can be added without creating a single tightly coupled application module.
Key files: `app/__init__.py`, `app/extensions.py`, `config.py`, and `wsgi.py`.
### 2. Modular routing
Added `core` and `auth` Blueprints, a `/health` endpoint, and reserved packages for tasks, settings, admin, and the versioned API.
Why: each feature owns its routes and implementation. The health endpoint provides a lightweight operational check that does not depend on authentication or database access.
### 3. Secure authentication
Implemented user registration, login, POST-only logout, password hashing, CSRF protection, safe redirects, generic login failures, and unique username/email handling. Authentication is separated into models, forms, services, and routes under `app/auth/`.
Why: separating HTTP handling from business logic allows future web and API interfaces to reuse the same account services. Security checks are part of the initial design rather than later additions.
### 4. Shared interface
Added a reusable Jinja base template, authentication pages, navigation, feedback messages, and a responsive pastel stylesheet.
Why: all future features inherit consistent navigation and accessible interaction. The interface includes keyboard focus indicators, system light/dark themes, restrained animation, and reduced-motion support.
### 5. Database migrations
Configured Flask-Migrate and generated the initial Alembic migration for the `user` table.
Why: committed migrations provide a reproducible database history as additional features introduce new tables and schema changes.
### 6. Tests and documentation
Added factory, configuration, CSRF, route, registration, password, duplicate-account, login/logout, and redirect-security tests. Added local setup, migration, and testing instructions to `README.md`.
Why: tests protect observable behavior and security boundaries. The documentation gives contributors a repeatable development workflow.
## Verification
```text
10 tests passed in 0.52s
Flask discovered all expected routes.
Alembic reported no pending model changes.
Python compilation completed successfully.
git diff --check completed successfully.
```
## Next Work
### Phase 2.1 — Task data model and migration
Added an independently registered task Blueprint plus typed `Task` and `Subtask` models. Tasks include status, priority, due date, ownership, completion time, and reserved Phase 3 timing fields. Database constraints restrict status and priority values; owned subtasks cascade safely when their task is deleted. Progress is derived from subtask completion instead of stored redundantly.
Why: database constraints protect invariants even outside web forms, while ownership keys prepare every query for user isolation. Keeping progress derived prevents stale percentages when subtasks change.
Generated, reviewed, and applied the `Add tasks and subtasks` Alembic migration. The existing 10-test suite remained green after the schema change.
### Phase 2.2 — Task service layer
Added service operations for owned task lookup, filtered lists, the seven-item Today view, creation, editing, deletion, subtask creation, and subtask toggling. Today excludes completed and future-dated tasks while accepting undated inbox items. Sorting consistently favors active, higher-priority, nearer-due work.
Why: ownership filtering in the service boundary prevents routes and future APIs from accidentally exposing another user's records. Centralized ordering and completion timestamps keep behavior consistent across every interface.
Next: add validated forms and authenticated routes that use these services.
### Phase 2.3 — Forms and authenticated routes
Added validated task and subtask forms plus authenticated routes for task lists, Today, create, detail, edit, delete, subtask creation, and subtask toggling. Invalid filters return `400`; missing or foreign-owned records return `404`; all mutations require POST and CSRF validation.
Why: forms constrain input at the HTTP boundary, while the service layer remains the ownership authority. Separating read routes from POST mutations prevents links or crawlers from changing data and gives CSRF protection complete coverage.
Next: build the templates and visual components for filtering, priorities, subtasks, and progress.

8
requirements.txt Normal file
View file

@ -0,0 +1,8 @@
Flask>=3.1,<4
Flask-Login>=0.6,<1
Flask-Migrate>=4,<5
Flask-SQLAlchemy>=3.1,<4
Flask-WTF>=1.2,<2
email-validator>=2.2,<3
pytest>=8,<10
python-dotenv>=1.0,<2

73
tests/auth/test_auth.py Normal file
View file

@ -0,0 +1,73 @@
from sqlalchemy import select
from app.auth.models import User
def register(client, **overrides):
data = {
"username": "steady_user",
"email": "person@example.com",
"password": "a calm secure password",
"confirm_password": "a calm secure password",
}
data.update(overrides)
return client.post("/auth/register", data=data, follow_redirects=True)
def test_registration_hashes_password(client, db):
response = register(client)
user = db.session.scalar(select(User).where(User.email == "person@example.com"))
assert response.status_code == 200
assert b"Your account is ready" in response.data
assert user is not None
assert user.password_hash != "a calm secure password"
assert user.check_password("a calm secure password")
def test_registration_rejects_duplicate_identity(client):
register(client)
response = register(client, email="another@example.com")
assert b"That username or email is already registered" in response.data
def test_login_and_logout(client):
register(client)
login_response = client.post(
"/auth/login",
data={
"email": "person@example.com",
"password": "a calm secure password",
},
follow_redirects=True,
)
assert b"Hi, steady_user" in login_response.data
logout_response = client.post("/auth/logout", follow_redirects=True)
assert b"You have been signed out" in logout_response.data
assert b"Create account" in logout_response.data
def test_login_uses_generic_failure_message(client):
response = client.post(
"/auth/login",
data={"email": "missing@example.com", "password": "incorrect"},
)
assert b"Email or password is incorrect" in response.data
def test_external_next_url_is_not_used(client):
register(client)
response = client.post(
"/auth/login?next=https://attacker.example/",
data={
"email": "person@example.com",
"password": "a calm secure password",
},
)
assert response.headers["Location"] == "/"

26
tests/conftest.py Normal file
View file

@ -0,0 +1,26 @@
import pytest
from app import create_app
from app.extensions import db as database
@pytest.fixture()
def app():
application = create_app("testing")
with application.app_context():
database.create_all()
yield application
database.session.remove()
database.drop_all()
@pytest.fixture()
def client(app):
return app.test_client()
@pytest.fixture()
def db():
return database

13
tests/core/test_routes.py Normal file
View file

@ -0,0 +1,13 @@
def test_health_endpoint(client):
response = client.get("/health")
assert response.status_code == 200
assert response.get_json() == {"status": "ok"}
def test_home_page_loads(client):
response = client.get("/")
assert response.status_code == 200
assert b"Welcome to Steady" in response.data

34
tests/test_factory.py Normal file
View file

@ -0,0 +1,34 @@
import pytest
from app import create_app
def test_testing_configuration_is_isolated():
app = create_app("testing")
assert app.config["TESTING"] is True
assert app.config["SQLALCHEMY_DATABASE_URI"] == "sqlite:///:memory:"
assert app.config["SESSION_COOKIE_HTTPONLY"] is True
assert app.config["SESSION_COOKIE_SAMESITE"] == "Lax"
def test_unknown_configuration_fails_clearly():
with pytest.raises(ValueError, match="Unknown configuration"):
create_app("unknown")
def test_csrf_is_required_outside_testing():
app = create_app("development")
client = app.test_client()
response = client.post(
"/auth/register",
data={
"username": "steady_user",
"email": "person@example.com",
"password": "a calm secure password",
"confirm_password": "a calm secure password",
},
)
assert response.status_code == 400

5
wsgi.py Normal file
View file

@ -0,0 +1,5 @@
from app import create_app
app = create_app("production")