phase 1
This commit is contained in:
parent
6bdcfa3ebc
commit
4c52771ef7
42 changed files with 2096 additions and 0 deletions
3
.env.example
Normal file
3
.env.example
Normal 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
35
AGENTS.md
Normal 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.
|
||||||
45
Perfect! Hier ist die Zusammenfassung 2de.md
Normal file
45
Perfect! Hier ist die Zusammenfassung 2de.md
Normal 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. 🚀
|
||||||
40
README.md
40
README.md
|
|
@ -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
56
app/__init__.py
Normal 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
2
app/admin/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
"""Reserved package for the optional admin feature."""
|
||||||
|
|
||||||
2
app/api/__init__.py
Normal file
2
app/api/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
"""Reserved package for versioned API features."""
|
||||||
|
|
||||||
6
app/auth/__init__.py
Normal file
6
app/auth/__init__.py
Normal 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
41
app/auth/forms.py
Normal 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
39
app/auth/models.py
Normal 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
64
app/auth/routes.py
Normal 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
50
app/auth/services.py
Normal 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
7
app/core/__init__.py
Normal 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
14
app/core/routes.py
Normal 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
16
app/extensions.py
Normal 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
2
app/settings/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
"""User-settings feature package."""
|
||||||
|
|
||||||
207
app/static/css/style.css
Normal file
207
app/static/css/style.css
Normal 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
6
app/tasks/__init__.py
Normal 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
63
app/tasks/forms.py
Normal 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
70
app/tasks/models.py
Normal 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
136
app/tasks/routes.py
Normal 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
158
app/tasks/services.py
Normal 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
|
||||||
12
app/templates/auth/_field.html
Normal file
12
app/templates/auth/_field.html
Normal 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 %}
|
||||||
|
|
||||||
20
app/templates/auth/login.html
Normal file
20
app/templates/auth/login.html
Normal 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 %}
|
||||||
|
|
||||||
21
app/templates/auth/register.html
Normal file
21
app/templates/auth/register.html
Normal 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
44
app/templates/base.html
Normal 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>
|
||||||
|
|
||||||
17
app/templates/core/index.html
Normal file
17
app/templates/core/index.html
Normal 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
54
config.py
Normal 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
319
design.md
Normal 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
1
migrations/README
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Single-database configuration for Flask.
|
||||||
50
migrations/alembic.ini
Normal file
50
migrations/alembic.ini
Normal 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
113
migrations/env.py
Normal 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
24
migrations/script.py.mako
Normal 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"}
|
||||||
48
migrations/versions/b74f27228a73_create_user_table.py
Normal file
48
migrations/versions/b74f27228a73_create_user_table.py
Normal 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 ###
|
||||||
66
migrations/versions/f9ae19443d53_add_tasks_and_subtasks.py
Normal file
66
migrations/versions/f9ae19443d53_add_tasks_and_subtasks.py
Normal 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
86
progress.md
Normal 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
8
requirements.txt
Normal 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
73
tests/auth/test_auth.py
Normal 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
26
tests/conftest.py
Normal 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
13
tests/core/test_routes.py
Normal 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
34
tests/test_factory.py
Normal 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
5
wsgi.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
from app import create_app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app("production")
|
||||||
|
|
||||||
Loading…
Add table
Reference in a new issue