phase 2
This commit is contained in:
parent
4c52771ef7
commit
29780ee3f6
14 changed files with 686 additions and 30 deletions
|
|
@ -1,8 +1,8 @@
|
||||||
# Steady
|
# Steady
|
||||||
|
|
||||||
Steady is an ADHD-friendly task manager built with Flask. Phase 1 provides the
|
Steady is an ADHD-friendly task manager built with Flask. It provides a modular
|
||||||
application factory, database and migration setup, secure authentication, and
|
application foundation, secure authentication, task and subtask management, a
|
||||||
the shared visual foundation.
|
seven-item Today view, status filters, priorities, and progress indicators.
|
||||||
|
|
||||||
## Local setup
|
## Local setup
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,23 @@ input:focus-visible {
|
||||||
font: inherit;
|
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 {
|
.field__errors {
|
||||||
margin-block: 0.35rem 0;
|
margin-block: 0.35rem 0;
|
||||||
padding-left: 1.25rem;
|
padding-left: 1.25rem;
|
||||||
|
|
@ -151,6 +168,178 @@ input:focus-visible {
|
||||||
border-color: var(--orange);
|
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 {
|
.skip-link {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|
@ -203,5 +392,16 @@ input {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding-block: 1rem;
|
padding-block: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav__actions,
|
||||||
|
.page-heading,
|
||||||
|
.task-card__header,
|
||||||
|
.form-grid {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
from flask_wtf import FlaskForm
|
from flask_wtf import FlaskForm
|
||||||
from wtforms import SelectField, StringField, SubmitField, TextAreaField
|
from wtforms import SelectField, StringField, SubmitField, TextAreaField
|
||||||
from wtforms.fields import DateTimeLocalField
|
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
|
from .models import TASK_PRIORITIES, TASK_STATUSES
|
||||||
|
|
||||||
|
|
@ -26,7 +26,7 @@ def not_blank(_form: FlaskForm, field: StringField) -> None:
|
||||||
class TaskForm(FlaskForm):
|
class TaskForm(FlaskForm):
|
||||||
title = StringField(
|
title = StringField(
|
||||||
"Task title",
|
"Task title",
|
||||||
validators=[DataRequired(), not_blank, Length(max=200)],
|
validators=[not_blank, Length(max=200)],
|
||||||
)
|
)
|
||||||
description = TextAreaField(
|
description = TextAreaField(
|
||||||
"Notes",
|
"Notes",
|
||||||
|
|
@ -53,11 +53,10 @@ class TaskForm(FlaskForm):
|
||||||
class SubtaskForm(FlaskForm):
|
class SubtaskForm(FlaskForm):
|
||||||
title = StringField(
|
title = StringField(
|
||||||
"Next small step",
|
"Next small step",
|
||||||
validators=[DataRequired(), not_blank, Length(max=100)],
|
validators=[not_blank, Length(max=100)],
|
||||||
)
|
)
|
||||||
submit = SubmitField("Add step")
|
submit = SubmitField("Add step")
|
||||||
|
|
||||||
|
|
||||||
class ActionForm(FlaskForm):
|
class ActionForm(FlaskForm):
|
||||||
submit = SubmitField()
|
submit = SubmitField()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@
|
||||||
<a class="brand" href="{{ url_for('core.index') }}">Steady</a>
|
<a class="brand" href="{{ url_for('core.index') }}">Steady</a>
|
||||||
<div class="nav__actions">
|
<div class="nav__actions">
|
||||||
{% if current_user.is_authenticated %}
|
{% 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>
|
<span>Hi, {{ current_user.username }}</span>
|
||||||
<form action="{{ url_for('auth.logout') }}" method="post">
|
<form action="{{ url_for('auth.logout') }}" method="post">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
@ -41,4 +43,3 @@
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,11 @@
|
||||||
<p class="eyebrow">One calm step at a time</p>
|
<p class="eyebrow">One calm step at a time</p>
|
||||||
<h1 id="welcome-heading">Welcome to Steady</h1>
|
<h1 id="welcome-heading">Welcome to Steady</h1>
|
||||||
{% if current_user.is_authenticated %}
|
{% 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 %}
|
{% else %}
|
||||||
<p>A quiet, ADHD-friendly place to capture and complete what matters.</p>
|
<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>
|
<a class="button" href="{{ url_for('auth.register') }}">Create your account</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
|
||||||
25
app/templates/tasks/_task_card.html
Normal file
25
app/templates/tasks/_task_card.html
Normal 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 %}
|
||||||
|
|
||||||
72
app/templates/tasks/detail.html
Normal file
72
app/templates/tasks/detail.html
Normal 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 %}
|
||||||
|
|
||||||
54
app/templates/tasks/form.html
Normal file
54
app/templates/tasks/form.html
Normal 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 %}
|
||||||
|
|
||||||
34
app/templates/tasks/list.html
Normal file
34
app/templates/tasks/list.html
Normal 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 %}
|
||||||
|
|
||||||
27
app/templates/tasks/today.html
Normal file
27
app/templates/tasks/today.html
Normal 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 %}
|
||||||
|
|
||||||
28
design.md
28
design.md
|
|
@ -214,22 +214,22 @@ a,
|
||||||
## 6. Feature-Phasen & Task-Liste
|
## 6. Feature-Phasen & Task-Liste
|
||||||
|
|
||||||
### Phase 1: Core Infrastruktur (Week 1-2)
|
### Phase 1: Core Infrastruktur (Week 1-2)
|
||||||
- [ ] Flask Application Factory mit unabhängiger Blueprint-Registrierung [3]
|
- [x] Flask Application Factory mit unabhängiger Blueprint-Registrierung [3]
|
||||||
- [ ] Zentrale, ungebundene Extensions mit `init_app()` [3]
|
- [x] Zentrale, ungebundene Extensions mit `init_app()` [3]
|
||||||
- [ ] Config-Klassen für Development, Testing und Production sowie Feature Toggles [3]
|
- [x] Config-Klassen für Development, Testing und Production sowie Feature Toggles [3]
|
||||||
- [ ] SQLite Database Setup mit SQLAlchemy [3]
|
- [x] SQLite Database Setup mit SQLAlchemy [3]
|
||||||
- [ ] User Model & Registration/Login Routes [3]
|
- [x] User Model & Registration/Login Routes [3]
|
||||||
- [ ] Password Hashing mit werkzeug.security [3]
|
- [x] Password Hashing mit werkzeug.security [3]
|
||||||
- [ ] Session Cookie Security (HttpOnly, SameSite=Lax) [3]
|
- [x] Session Cookie Security (HttpOnly, SameSite=Lax) [3]
|
||||||
- [ ] Jinja2 Base Template mit Pastel CSS [2]
|
- [x] Jinja2 Base Template mit Pastel CSS [2]
|
||||||
|
|
||||||
### Phase 2: Task Management (Week 3-4)
|
### Phase 2: Task Management (Week 3-4)
|
||||||
- [ ] Task CRUD Operations (Create, Read, Update, Delete) [3]
|
- [x] Task CRUD Operations (Create, Read, Update, Delete) [3]
|
||||||
- [ ] Task List View mit Today View (Max 7 Items) [1]
|
- [x] Task List View mit Today View (Max 7 Items) [1]
|
||||||
- [ ] Status Filter (All, Not Started, In Progress, Done) [2]
|
- [x] Status Filter (All, Not Started, In Progress, Done) [2]
|
||||||
- [ ] Priority Badge System (Urgent, Important, Normal) [2]
|
- [x] Priority Badge System (Urgent, Important, Normal) [2]
|
||||||
- [ ] Subtask Decomposition UI [1]
|
- [x] Subtask Decomposition UI [1]
|
||||||
- [ ] Progress Bar Component [1]
|
- [x] Progress Bar Component [1]
|
||||||
|
|
||||||
### Phase 3: ADHD-Specific Features (Week 5-6)
|
### Phase 3: ADHD-Specific Features (Week 5-6)
|
||||||
- [ ] Focus Mode (One Primary Action) [1]
|
- [ ] Focus Mode (One Primary Action) [1]
|
||||||
|
|
|
||||||
45
progress.md
45
progress.md
|
|
@ -5,9 +5,9 @@ This is the living implementation log for Steady. Update it after every meaningf
|
||||||
## Current Status
|
## Current Status
|
||||||
|
|
||||||
- Current phase: Phase 2 — Task Management
|
- Current phase: Phase 2 — Task Management
|
||||||
- Status: In progress — routes and interface
|
- Status: Complete
|
||||||
- Last updated: 2026-08-08
|
- Last updated: 2026-08-08
|
||||||
- Next milestone: Ownership-safe task service layer
|
- Next phase: Phase 3 — ADHD-Specific Features
|
||||||
|
|
||||||
## Phase 1 — Core Infrastructure
|
## Phase 1 — Core Infrastructure
|
||||||
|
|
||||||
|
|
@ -59,7 +59,7 @@ Python compilation completed successfully.
|
||||||
git diff --check completed successfully.
|
git diff --check completed successfully.
|
||||||
```
|
```
|
||||||
|
|
||||||
## Next Work
|
## Phase 2 — Task Management
|
||||||
|
|
||||||
### Phase 2.1 — Task data model and migration
|
### 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.
|
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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,5 @@ def client(app):
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def db():
|
def db(app):
|
||||||
return database
|
return database
|
||||||
|
|
||||||
|
|
|
||||||
206
tests/tasks/test_tasks.py
Normal file
206
tests/tasks/test_tasks.py
Normal 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
|
||||||
Loading…
Add table
Reference in a new issue