This commit is contained in:
patsy 2026-08-08 03:00:29 +02:00
parent 4c52771ef7
commit 29780ee3f6
14 changed files with 686 additions and 30 deletions

View file

@ -1,8 +1,8 @@
# 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.
Steady is an ADHD-friendly task manager built with Flask. It provides a modular
application foundation, secure authentication, task and subtask management, a
seven-item Today view, status filters, priorities, and progress indicators.
## Local setup

View file

@ -111,6 +111,23 @@ input:focus-visible {
font: inherit;
}
.field textarea,
.field select {
width: 100%;
padding: 0.6rem 0.75rem;
border: 1px solid var(--border);
border-radius: 0.5rem;
background: var(--surface);
color: var(--text);
font: inherit;
}
.field__help {
margin-block: 0.35rem 0;
color: var(--muted);
font-size: 0.9rem;
}
.field__errors {
margin-block: 0.35rem 0;
padding-left: 1.25rem;
@ -151,6 +168,178 @@ input:focus-visible {
border-color: var(--orange);
}
.page-heading,
.task-card__header,
.form-actions,
.progress-row {
display: flex;
align-items: center;
gap: 1rem;
}
.page-heading,
.task-card__header {
justify-content: space-between;
}
.page-heading {
margin-bottom: 2rem;
}
.page-heading h1 {
margin-block: 0;
}
.filters {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.filters a {
padding: 0.4rem 0.8rem;
border-radius: 2rem;
text-decoration: none;
}
.filters a[aria-current="page"] {
background: var(--lavender);
color: var(--text);
font-weight: 700;
}
.task-list {
display: grid;
gap: 1rem;
}
.task-card,
.task-detail,
.empty-state {
padding: var(--space);
border: 1px solid var(--border);
border-radius: 1rem;
background: var(--surface);
}
.task-card {
border-left-width: 0.45rem;
}
.task-card--urgent {
border-left-color: var(--yellow);
}
.task-card--important {
border-left-color: var(--lavender);
}
.task-card--normal {
border-left-color: var(--blue);
}
.task-card h2 {
margin-bottom: 0.4rem;
}
.badge,
.status {
display: inline-block;
padding: 0.2rem 0.55rem;
border-radius: 2rem;
font-size: 0.85rem;
}
.badge--urgent {
background: var(--yellow);
color: #423916;
}
.badge--important {
background: var(--lavender);
color: #2d2d3a;
}
.badge--normal,
.status {
background: var(--blue);
color: #20373e;
}
.progress-row {
margin-top: 1rem;
}
.progress-row progress {
flex: 1;
min-width: 6rem;
accent-color: var(--lavender);
}
.progress-row--large {
margin-block: 2rem;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.form-actions {
margin-top: 1.5rem;
}
.task-description {
white-space: pre-wrap;
}
.subtask-list {
padding: 0;
list-style: none;
}
.subtask {
padding-block: 0.4rem;
border-bottom: 1px solid var(--border);
}
.subtask form {
margin: 0;
}
.subtask__toggle {
width: 100%;
padding: 0.5rem;
border: 0;
background: transparent;
color: var(--text);
cursor: pointer;
font: inherit;
text-align: left;
}
.subtask--done .subtask__toggle {
color: var(--muted);
text-decoration: line-through;
}
.subtask-form {
max-width: 30rem;
margin-top: 1.5rem;
}
.danger-zone {
margin-top: 3rem;
color: var(--muted);
}
.button--danger {
border-color: #855347;
background: var(--orange);
}
.skip-link {
position: absolute;
top: 0;
@ -203,5 +392,16 @@ input {
flex-direction: column;
padding-block: 1rem;
}
.nav__actions,
.page-heading,
.task-card__header,
.form-grid {
align-items: flex-start;
flex-direction: column;
}
.form-grid {
display: flex;
}
}

View file

@ -1,7 +1,7 @@
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 wtforms.validators import Length, Optional, ValidationError
from .models import TASK_PRIORITIES, TASK_STATUSES
@ -26,7 +26,7 @@ def not_blank(_form: FlaskForm, field: StringField) -> None:
class TaskForm(FlaskForm):
title = StringField(
"Task title",
validators=[DataRequired(), not_blank, Length(max=200)],
validators=[not_blank, Length(max=200)],
)
description = TextAreaField(
"Notes",
@ -53,11 +53,10 @@ class TaskForm(FlaskForm):
class SubtaskForm(FlaskForm):
title = StringField(
"Next small step",
validators=[DataRequired(), not_blank, Length(max=100)],
validators=[not_blank, Length(max=100)],
)
submit = SubmitField("Add step")
class ActionForm(FlaskForm):
submit = SubmitField()

View file

@ -14,6 +14,8 @@
<a class="brand" href="{{ url_for('core.index') }}">Steady</a>
<div class="nav__actions">
{% if current_user.is_authenticated %}
<a href="{{ url_for('tasks.today') }}">Today</a>
<a href="{{ url_for('tasks.index') }}">Tasks</a>
<span>Hi, {{ current_user.username }}</span>
<form action="{{ url_for('auth.logout') }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
@ -41,4 +43,3 @@
</main>
</body>
</html>

View file

@ -7,11 +7,11 @@
<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>
<p>Your workspace is ready. Choose one calm next step.</p>
<a class="button" href="{{ url_for('tasks.today') }}">Open Today</a>
{% 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 %}

View file

@ -0,0 +1,25 @@
{% macro task_card(task) %}
<article class="task-card task-card--{{ task.priority }}">
<div class="task-card__header">
<div>
<span class="badge badge--{{ task.priority }}">{{ task.priority|capitalize }}</span>
<span class="status">{{ task.status|replace('_', ' ')|title }}</span>
</div>
{% if task.due_date %}
<time datetime="{{ task.due_date.isoformat() }}">
Due {{ task.due_date.strftime('%d %b, %H:%M') }}
</time>
{% endif %}
</div>
<h2><a href="{{ url_for('tasks.detail', task_id=task.id) }}">{{ task.title }}</a></h2>
{% if task.description %}<p>{{ task.description|truncate(180) }}</p>{% endif %}
<div class="progress-row">
<label for="task-progress-{{ task.id }}">Progress</label>
<progress id="task-progress-{{ task.id }}" max="100" value="{{ task.progress_percentage }}">
{{ task.progress_percentage }}%
</progress>
<span>{{ task.progress_percentage }}%</span>
</div>
</article>
{% endmacro %}

View file

@ -0,0 +1,72 @@
{% extends "base.html" %}
{% block title %}{{ task.title }} · Steady{% endblock %}
{% block content %}
<article class="task-detail">
<div class="task-card__header">
<div>
<span class="badge badge--{{ task.priority }}">{{ task.priority|capitalize }}</span>
<span class="status">{{ task.status|replace('_', ' ')|title }}</span>
</div>
{% if task.due_date %}
<time datetime="{{ task.due_date.isoformat() }}">Due {{ task.due_date.strftime('%d %b %Y, %H:%M') }}</time>
{% endif %}
</div>
<h1>{{ task.title }}</h1>
{% if task.description %}<p class="task-description">{{ task.description }}</p>{% endif %}
<div class="progress-row progress-row--large">
<label for="task-progress">Progress</label>
<progress id="task-progress" max="100" value="{{ task.progress_percentage }}">
{{ task.progress_percentage }}%
</progress>
<span>{{ task.progress_percentage }}%</span>
</div>
<section aria-labelledby="steps-heading">
<h2 id="steps-heading">Small steps</h2>
{% if task.subtasks %}
<ul class="subtask-list">
{% for subtask in task.subtasks %}
<li class="subtask {% if subtask.completed %}subtask--done{% endif %}">
<form method="post" action="{{ url_for('tasks.toggle_subtask_status', task_id=task.id, subtask_id=subtask.id) }}">
{{ action_form.hidden_tag() }}
<button class="subtask__toggle" type="submit" aria-label="Mark {{ subtask.title }} as {{ 'not completed' if subtask.completed else 'completed' }}">
<span aria-hidden="true">{{ '✓' if subtask.completed else '○' }}</span>
{{ subtask.title }}
</button>
</form>
</li>
{% endfor %}
</ul>
{% else %}
<p>No small steps yet. Add the easiest first move.</p>
{% endif %}
<form class="subtask-form" method="post" action="{{ url_for('tasks.create_subtask', task_id=task.id) }}">
{{ subtask_form.hidden_tag() }}
<div class="field">
{{ subtask_form.title.label }}
{{ subtask_form.title(placeholder="For example: open the document") }}
</div>
{{ subtask_form.submit(class="button button--quiet") }}
</form>
</section>
<div class="form-actions">
<a class="button" href="{{ url_for('tasks.edit', task_id=task.id) }}">Edit task</a>
<a href="{{ url_for('tasks.index') }}">Back to tasks</a>
</div>
<details class="danger-zone">
<summary>Delete this task</summary>
<p>This permanently removes the task and its small steps.</p>
<form method="post" action="{{ url_for('tasks.delete', task_id=task.id) }}">
{{ action_form.hidden_tag() }}
<button class="button button--danger" type="submit">Delete permanently</button>
</form>
</details>
</article>
{% endblock %}

View file

@ -0,0 +1,54 @@
{% extends "base.html" %}
{% block title %}{{ heading }} · Steady{% endblock %}
{% macro field_errors(field) %}
{% if field.errors %}
<ul id="{{ field.id }}-errors" class="field__errors">
{% for error in field.errors %}<li>{{ error }}</li>{% endfor %}
</ul>
{% endif %}
{% endmacro %}
{% block content %}
<section class="panel" aria-labelledby="task-form-heading">
<p class="eyebrow">Capture first, refine later</p>
<h1 id="task-form-heading">{{ heading }}</h1>
<form method="post" novalidate>
{{ form.hidden_tag() }}
<div class="field">
{{ form.title.label }}
{{ form.title(autofocus=true, aria_describedby="title-errors" if form.title.errors else none) }}
{{ field_errors(form.title) }}
</div>
<div class="field">
{{ form.description.label }}
{{ form.description(rows=5, aria_describedby="description-errors" if form.description.errors else none) }}
{{ field_errors(form.description) }}
</div>
<div class="form-grid">
<div class="field">
{{ form.status.label }}
{{ form.status() }}
{{ field_errors(form.status) }}
</div>
<div class="field">
{{ form.priority.label }}
{{ form.priority() }}
{{ field_errors(form.priority) }}
</div>
</div>
<div class="field">
{{ form.due_date.label }}
{{ form.due_date(aria_describedby="due-date-help due_date-errors" if form.due_date.errors else "due-date-help") }}
<p id="due-date-help" class="field__help">Optional. Leave empty to keep it in your flexible inbox.</p>
{{ field_errors(form.due_date) }}
</div>
<div class="form-actions">
{{ form.submit(class="button") }}
<a href="{{ url_for('tasks.index') }}">Cancel</a>
</div>
</form>
</section>
{% endblock %}

View file

@ -0,0 +1,34 @@
{% extends "base.html" %}
{% from "tasks/_task_card.html" import task_card %}
{% block title %}Tasks · Steady{% endblock %}
{% block content %}
<div class="page-heading">
<div>
<p class="eyebrow">Your complete workspace</p>
<h1>Tasks</h1>
</div>
<a class="button" href="{{ url_for('tasks.create') }}">Add task</a>
</div>
<nav class="filters" aria-label="Filter tasks by status">
<a {% if not selected_status %}aria-current="page"{% endif %} href="{{ url_for('tasks.index') }}">All</a>
{% for value, label in status_labels.items() %}
<a {% if selected_status == value %}aria-current="page"{% endif %}
href="{{ url_for('tasks.index', status=value) }}">{{ label }}</a>
{% endfor %}
</nav>
<div class="task-list">
{% for task in tasks %}
{{ task_card(task) }}
{% else %}
<div class="empty-state">
<h2>Nothing here right now</h2>
<p>Try another filter or capture one small task.</p>
</div>
{% endfor %}
</div>
{% endblock %}

View file

@ -0,0 +1,27 @@
{% extends "base.html" %}
{% from "tasks/_task_card.html" import task_card %}
{% block title %}Today · Steady{% endblock %}
{% block content %}
<div class="page-heading">
<div>
<p class="eyebrow">At most seven choices</p>
<h1>Today</h1>
<p>Only current and undated tasks appear here. Future tasks can wait.</p>
</div>
<a class="button" href="{{ url_for('tasks.create') }}">Quick add</a>
</div>
<div class="task-list">
{% for task in tasks %}
{{ task_card(task) }}
{% else %}
<div class="empty-state">
<h2>Your Today view is clear</h2>
<p>There is nothing you need to choose from right now.</p>
</div>
{% endfor %}
</div>
{% endblock %}

View file

@ -214,22 +214,22 @@ a,
## 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]
- [x] Flask Application Factory mit unabhängiger Blueprint-Registrierung [3]
- [x] Zentrale, ungebundene Extensions mit `init_app()` [3]
- [x] Config-Klassen für Development, Testing und Production sowie Feature Toggles [3]
- [x] SQLite Database Setup mit SQLAlchemy [3]
- [x] User Model & Registration/Login Routes [3]
- [x] Password Hashing mit werkzeug.security [3]
- [x] Session Cookie Security (HttpOnly, SameSite=Lax) [3]
- [x] 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]
- [x] Task CRUD Operations (Create, Read, Update, Delete) [3]
- [x] Task List View mit Today View (Max 7 Items) [1]
- [x] Status Filter (All, Not Started, In Progress, Done) [2]
- [x] Priority Badge System (Urgent, Important, Normal) [2]
- [x] Subtask Decomposition UI [1]
- [x] Progress Bar Component [1]
### Phase 3: ADHD-Specific Features (Week 5-6)
- [ ] Focus Mode (One Primary Action) [1]

View file

@ -5,9 +5,9 @@ This is the living implementation log for Steady. Update it after every meaningf
## Current Status
- Current phase: Phase 2 — Task Management
- Status: In progress — routes and interface
- Status: Complete
- Last updated: 2026-08-08
- Next milestone: Ownership-safe task service layer
- Next phase: Phase 3 — ADHD-Specific Features
## Phase 1 — Core Infrastructure
@ -59,7 +59,7 @@ Python compilation completed successfully.
git diff --check completed successfully.
```
## Next Work
## Phase 2 — Task Management
### Phase 2.1 — Task data model and migration
@ -84,3 +84,42 @@ Added validated task and subtask forms plus authenticated routes for task lists,
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.
### Phase 2.4 — Task-management interface
Added the full task list, Today view, create/edit form, and task detail templates. The interface includes status filters, calm priority badges, accessible native progress bars, subtask toggles, responsive layouts, empty states, and a deliberately disclosed delete action. Authenticated navigation now links to Today and Tasks.
Why: Today reduces visible choices to seven without hiding the full workspace. Native progress elements retain semantic meaning for assistive technology. Delete is available but visually separated to reduce accidental destructive actions, while empty states give a low-pressure next action.
Next: cover Phase 2 behavior and ownership boundaries with automated tests.
### Phase 2.5 — Automated coverage
Added eight Phase 2 tests covering authentication gates, complete task CRUD, status filtering, invalid filters, the seven-item Today limit, future/completed hiding, cross-user isolation, subtask-derived progress, and whitespace-only title rejection. The shared database fixture now always establishes an application context.
Why: these tests protect the feature's behavioral and security boundaries. In particular, the ownership test proves that another user receives `404` and cannot delete the record, while the Today test validates the query result and not merely page text.
Verification at this step: `18 passed in 1.51s`.
Next: run compilation, migration consistency, route discovery, whitespace, and full regression checks.
### Phase 2.6 — Final verification
Completed the full regression suite, Alembic model comparison, route discovery, Python compilation, dependency consistency check, trailing-whitespace scan, and patch-format validation.
Why: a feature is complete only when its behavior, schema, dependencies, and integration with earlier work agree. Running the Phase 1 suite alongside the new tests proves that task management did not regress authentication or the application factory.
Final results:
```text
18 tests passed in 1.50s
Alembic detected no pending model operations.
Flask discovered all expected authentication, core, and task routes.
Python compilation completed successfully.
No broken Python requirements were found.
Whitespace and git diff checks completed successfully.
```
## Next Work
Phase 3 will add ADHD-specific interaction features on top of the stable task domain: Focus Mode, a per-task countdown timer, time-block chunking, context-reminder placeholders, positive completion feedback, and a forgiving streak model. Each feature should remain optional, accessible, and independently testable.

View file

@ -21,6 +21,5 @@ def client(app):
@pytest.fixture()
def db():
def db(app):
return database

206
tests/tasks/test_tasks.py Normal file
View file

@ -0,0 +1,206 @@
from datetime import datetime, timedelta
from sqlalchemy import select
from app.auth.services import create_user
from app.tasks.models import Task
from app.tasks.services import (
add_subtask,
create_task,
list_today_tasks,
toggle_subtask,
)
def create_account_and_login(client, suffix: str = "") -> None:
email = f"person{suffix}@example.com"
client.post(
"/auth/register",
data={
"username": f"steady_user{suffix}",
"email": email,
"password": "a calm secure password",
"confirm_password": "a calm secure password",
},
)
client.post(
"/auth/login",
data={"email": email, "password": "a calm secure password"},
)
def task_form_data(**overrides):
data = {
"title": "Write a gentle plan",
"description": "Start with one paragraph.",
"status": "not_started",
"priority": "normal",
"due_date": "",
}
data.update(overrides)
return data
def test_task_pages_require_login(client):
response = client.get("/tasks/")
assert response.status_code == 302
assert "/auth/login" in response.headers["Location"]
def test_create_edit_and_delete_task(client, db):
create_account_and_login(client)
create_response = client.post(
"/tasks/new",
data=task_form_data(due_date="2026-08-09T14:30"),
follow_redirects=True,
)
task = db.session.scalar(
select(Task).where(Task.title == "Write a gentle plan")
)
assert create_response.status_code == 200
assert b"Task captured" in create_response.data
assert task is not None
assert task.due_date == datetime(2026, 8, 9, 14, 30)
edit_response = client.post(
f"/tasks/{task.id}/edit",
data=task_form_data(
title="Write the first paragraph",
status="done",
priority="important",
),
follow_redirects=True,
)
db.session.refresh(task)
assert b"Task updated" in edit_response.data
assert task.title == "Write the first paragraph"
assert task.completed_at is not None
delete_response = client.post(
f"/tasks/{task.id}/delete",
follow_redirects=True,
)
assert b"Task deleted" in delete_response.data
assert db.session.get(Task, task.id) is None
def test_status_filter_only_shows_selected_tasks(client, db):
create_account_and_login(client)
client.post("/tasks/new", data=task_form_data(title="Visible active task"))
client.post(
"/tasks/new",
data=task_form_data(title="Hidden finished task", status="done"),
)
response = client.get("/tasks/?status=not_started")
assert response.status_code == 200
assert b"Visible active task" in response.data
assert b"Hidden finished task" not in response.data
def test_invalid_status_filter_is_rejected(client):
create_account_and_login(client)
response = client.get("/tasks/?status=unexpected")
assert response.status_code == 400
def test_today_is_limited_and_hides_future_and_completed_tasks(db):
user = create_user("today_user", "today@example.com", "secure password")
now = datetime(2026, 8, 8, 12, 0)
for number in range(8):
create_task(
user.id,
title=f"Inbox task {number}",
description=None,
status="not_started",
priority="normal",
due_date=None,
)
future = create_task(
user.id,
title="Future task",
description=None,
status="not_started",
priority="urgent",
due_date=now + timedelta(days=2),
)
completed = create_task(
user.id,
title="Completed task",
description=None,
status="done",
priority="urgent",
due_date=now,
)
tasks = list_today_tasks(user.id, now=now)
assert len(tasks) == 7
assert future not in tasks
assert completed not in tasks
def test_users_cannot_access_each_others_tasks(client, db):
owner = create_user("owner", "owner@example.com", "secure password")
intruder = create_user("intruder", "intruder@example.com", "secure password")
task = create_task(
owner.id,
title="Owner-only task",
description=None,
status="not_started",
priority="normal",
due_date=None,
)
client.post(
"/auth/login",
data={"email": intruder.email, "password": "secure password"},
)
assert client.get(f"/tasks/{task.id}").status_code == 404
assert client.post(f"/tasks/{task.id}/delete").status_code == 404
assert db.session.get(Task, task.id) is not None
def test_subtasks_update_derived_progress(client, db):
user = create_user("steps_user", "steps@example.com", "secure password")
task = create_task(
user.id,
title="Two-step task",
description=None,
status="in_progress",
priority="important",
due_date=None,
)
first = add_subtask(task, "First small step")
add_subtask(task, "Second small step")
assert task.progress_percentage == 0
toggle_subtask(task, first.id)
assert task.progress_percentage == 50
client.post(
"/auth/login",
data={"email": user.email, "password": "secure password"},
)
response = client.get(f"/tasks/{task.id}")
assert b"First small step" in response.data
assert b'value="50"' in response.data
def test_blank_task_title_is_rejected(client, db):
create_account_and_login(client)
response = client.post(
"/tasks/new",
data=task_form_data(title=" "),
)
assert response.status_code == 200
assert b"This field cannot be blank" in response.data
assert db.session.scalar(select(Task)) is None