This commit is contained in:
patsy 2026-08-08 03:08:28 +02:00
parent 29780ee3f6
commit e651369587
13 changed files with 966 additions and 16 deletions

View file

@ -2,7 +2,9 @@
Steady is an ADHD-friendly task manager built with Flask. It provides a modular Steady is an ADHD-friendly task manager built with Flask. It provides a modular
application foundation, secure authentication, task and subtask management, a application foundation, secure authentication, task and subtask management, a
seven-item Today view, status filters, priorities, and progress indicators. seven-item Today view, status filters, priorities, progress indicators, and a
single-task Focus Mode with countdowns, time blocks, context cues, positive
completion feedback, and forgiving streaks.
## Local setup ## Local setup

View file

@ -340,6 +340,217 @@ input:focus-visible {
background: var(--orange); background: var(--orange);
} }
.task-card__focus {
display: inline-block;
margin-top: 1rem;
font-weight: 700;
}
.focus-heading {
display: grid;
grid-template-columns: 1fr auto;
align-items: start;
gap: 2rem;
margin-bottom: 2rem;
}
.focus-heading h1 {
margin-block: 0;
}
.streak-card {
display: grid;
max-width: 16rem;
padding: 0.8rem 1rem;
border: 1px solid var(--border);
border-radius: 0.8rem;
background: var(--surface);
}
.streak-card span {
color: var(--muted);
font-size: 0.9rem;
}
.focus-task {
max-width: 48rem;
padding: clamp(1.5rem, 5vw, 3rem);
border: 1px solid var(--border);
border-radius: 1.25rem;
background: var(--surface);
box-shadow: 0 0.75rem 2.5rem rgb(45 45 58 / 8%);
}
.focus-task h2 {
font-size: clamp(1.75rem, 5vw, 2.75rem);
line-height: 1.2;
}
.context-cue {
display: grid;
gap: 0.15rem;
margin-block: 1.5rem;
padding: 1rem;
border-left: 0.35rem solid var(--blue);
border-radius: 0.5rem;
background: color-mix(in srgb, var(--blue) 24%, var(--surface));
}
.context-cue small {
color: var(--muted);
}
.timer {
margin-block: 2rem;
padding: 1.5rem;
border: 1px solid var(--border);
border-radius: 1rem;
text-align: center;
}
.timer__display {
display: block;
margin-block: 0.5rem;
font-size: clamp(3rem, 12vw, 6rem);
font-variant-numeric: tabular-nums;
font-weight: 750;
letter-spacing: 0.04em;
line-height: 1.1;
}
.timer__progress {
height: 0.6rem;
overflow: hidden;
border-radius: 1rem;
background: var(--border);
}
.timer__progress span {
display: block;
width: 100%;
height: 100%;
border-radius: inherit;
background: var(--lavender);
transition: width 0.25s linear;
}
.timer__actions,
.focus-task__actions {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 1.25rem;
}
.timer--complete {
border-color: var(--blue);
background: color-mix(in srgb, var(--blue) 18%, var(--surface));
}
.chunk-list {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
padding: 0;
list-style: none;
}
.chunk button {
display: grid;
place-items: center;
min-width: 5rem;
min-height: 4.5rem;
padding: 0.6rem;
border: 1px solid var(--border);
border-radius: 0.75rem;
background: var(--surface);
color: var(--text);
cursor: pointer;
font: inherit;
}
.chunk--done button {
border-color: var(--blue);
background: color-mix(in srgb, var(--blue) 28%, var(--surface));
}
.button--complete {
border-color: #6994a3;
background: var(--blue);
color: #20373e;
}
.focus-settings {
max-width: 48rem;
margin-top: 1.5rem;
padding: 1rem;
border: 1px solid var(--border);
border-radius: 0.75rem;
background: var(--surface);
}
.focus-settings summary {
cursor: pointer;
font-weight: 700;
}
.celebration {
display: flex;
align-items: center;
gap: 1rem;
max-width: 48rem;
margin-bottom: 2rem;
padding: 1rem 1.25rem;
border: 1px solid var(--blue);
border-radius: 1rem;
background: color-mix(in srgb, var(--blue) 25%, var(--surface));
animation: gentle-arrival 0.5s ease-out both;
}
.celebration h1,
.celebration p {
margin-block: 0;
}
.celebration__mark {
display: grid;
place-items: center;
flex: 0 0 auto;
width: 3rem;
height: 3rem;
border-radius: 50%;
background: var(--blue);
color: #20373e;
font-size: 1.6rem;
animation: gentle-pulse 0.6s ease-out;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@keyframes gentle-arrival {
from {
opacity: 0;
transform: translateY(0.5rem);
}
}
@keyframes gentle-pulse {
50% {
transform: scale(1.08);
}
}
.skip-link { .skip-link {
position: absolute; position: absolute;
top: 0; top: 0;
@ -384,6 +595,11 @@ input {
animation-duration: 0.01ms !important; animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important; animation-iteration-count: 1 !important;
} }
.celebration,
.celebration__mark {
animation: none;
}
} }
@media (max-width: 38rem) { @media (max-width: 38rem) {
@ -404,4 +620,12 @@ input {
.form-grid { .form-grid {
display: flex; display: flex;
} }
.focus-heading {
grid-template-columns: 1fr;
}
.streak-card {
max-width: none;
}
} }

115
app/static/js/main.js Normal file
View file

@ -0,0 +1,115 @@
function playCompletionChime() {
try {
const AudioContext = window.AudioContext || window.webkitAudioContext;
if (!AudioContext) return;
const context = new AudioContext();
const oscillator = context.createOscillator();
const gain = context.createGain();
oscillator.type = "sine";
oscillator.frequency.setValueAtTime(523.25, context.currentTime);
oscillator.frequency.setValueAtTime(659.25, context.currentTime + 0.12);
gain.gain.setValueAtTime(0.0001, context.currentTime);
gain.gain.exponentialRampToValueAtTime(0.12, context.currentTime + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, context.currentTime + 0.35);
oscillator.connect(gain);
gain.connect(context.destination);
oscillator.start();
oscillator.stop(context.currentTime + 0.36);
oscillator.addEventListener("ended", () => context.close());
} catch (_error) {
// Audio feedback is optional; browser policy must never block completion.
}
}
function initializeTimer(timer) {
const duration = Number(timer.dataset.duration);
const taskId = timer.dataset.taskId;
const storageKey = `steady.timer.${taskId}`;
const display = timer.querySelector(".timer__display");
const progress = timer.querySelector(".timer__progress span");
const announcement = timer.querySelector(".timer__announcement");
let intervalId;
function readState() {
try {
const stored = JSON.parse(localStorage.getItem(storageKey));
if (stored && Number.isFinite(stored.remaining)) return stored;
} catch (_error) {
// A corrupt or unavailable local store simply starts a fresh timer.
}
return { remaining: duration, running: false, updatedAt: Date.now() };
}
let state = readState();
function currentRemaining() {
if (!state.running) return state.remaining;
const elapsed = Math.floor((Date.now() - state.updatedAt) / 1000);
return Math.max(0, state.remaining - elapsed);
}
function saveState() {
try {
localStorage.setItem(storageKey, JSON.stringify(state));
} catch (_error) {
// The countdown still works for this page when storage is unavailable.
}
}
function render() {
const remaining = currentRemaining();
const minutes = Math.floor(remaining / 60);
const seconds = remaining % 60;
display.textContent = `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
progress.style.width = `${Math.max(0, Math.min(100, (remaining / duration) * 100))}%`;
if (remaining === 0 && state.running) {
state = { remaining: 0, running: false, updatedAt: Date.now() };
saveState();
clearInterval(intervalId);
timer.classList.add("timer--complete");
announcement.textContent = "Focus block complete. Take a breath.";
if (timer.dataset.chime === "true") playCompletionChime();
}
}
timer.querySelector("[data-timer-start]").addEventListener("click", () => {
const remaining = currentRemaining() || duration;
state = { remaining, running: true, updatedAt: Date.now() };
timer.classList.remove("timer--complete");
saveState();
clearInterval(intervalId);
intervalId = window.setInterval(render, 250);
render();
});
timer.querySelector("[data-timer-pause]").addEventListener("click", () => {
state = {
remaining: currentRemaining(),
running: false,
updatedAt: Date.now(),
};
saveState();
clearInterval(intervalId);
render();
});
timer.querySelector("[data-timer-reset]").addEventListener("click", () => {
state = { remaining: duration, running: false, updatedAt: Date.now() };
saveState();
clearInterval(intervalId);
timer.classList.remove("timer--complete");
announcement.textContent = "Timer reset.";
render();
});
if (state.running) intervalId = window.setInterval(render, 250);
render();
}
document.querySelectorAll("[data-focus-timer]").forEach(initializeTimer);
const celebration = document.querySelector('[data-celebrate="true"]');
if (celebration && celebration.dataset.chime === "true") {
playCompletionChime();
}

View file

@ -1,7 +1,7 @@
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import SelectField, StringField, SubmitField, TextAreaField from wtforms import IntegerField, SelectField, StringField, SubmitField, TextAreaField
from wtforms.fields import DateTimeLocalField from wtforms.fields import DateTimeLocalField
from wtforms.validators import Length, Optional, ValidationError from wtforms.validators import Length, NumberRange, Optional, ValidationError
from .models import TASK_PRIORITIES, TASK_STATUSES from .models import TASK_PRIORITIES, TASK_STATUSES
@ -60,3 +60,43 @@ class SubtaskForm(FlaskForm):
class ActionForm(FlaskForm): class ActionForm(FlaskForm):
submit = SubmitField() submit = SubmitField()
def valid_chunk_plan(_form: FlaskForm, field: StringField) -> None:
if not field.data or not field.data.strip():
return
values = [value.strip() for value in field.data.split(",")]
if len(values) > 12:
raise ValidationError("Use at most 12 focus blocks.")
try:
minutes = [int(value) for value in values]
except ValueError as exc:
raise ValidationError(
"Use comma-separated whole minutes, such as 5, 5, 10."
) from exc
if any(value < 1 or value > 120 for value in minutes):
raise ValidationError("Each focus block must be 1 to 120 minutes.")
class FocusSettingsForm(FlaskForm):
timer_minutes = IntegerField(
"Countdown length in minutes",
validators=[Optional(), NumberRange(min=1, max=180)],
)
context = StringField(
"Helpful context",
validators=[Optional(), Length(max=100)],
)
chunk_plan = StringField(
"Focus blocks",
validators=[Optional(), Length(max=120), valid_chunk_plan],
)
submit = SubmitField("Save focus setup")
def parsed_chunks(self) -> list[int]:
if not self.chunk_plan.data or not self.chunk_plan.data.strip():
return []
return [
int(value.strip())
for value in self.chunk_plan.data.split(",")
]

View file

@ -2,16 +2,26 @@ from flask import abort, flash, redirect, render_template, request, url_for
from flask_login import current_user, login_required from flask_login import current_user, login_required
from . import bp from . import bp
from .forms import ActionForm, STATUS_LABELS, SubtaskForm, TaskForm from .forms import (
ActionForm,
FocusSettingsForm,
STATUS_LABELS,
SubtaskForm,
TaskForm,
)
from .models import TASK_STATUSES from .models import TASK_STATUSES
from .services import ( from .services import (
TaskNotFoundError, TaskNotFoundError,
add_subtask, add_subtask,
calculate_forgiving_streak,
complete_task,
configure_focus,
create_task, create_task,
delete_task, delete_task,
get_task, get_task,
list_tasks, list_tasks,
list_today_tasks, list_today_tasks,
toggle_chunk_session,
toggle_subtask, toggle_subtask,
update_task, update_task,
) )
@ -46,6 +56,48 @@ def today():
return render_template("tasks/today.html", tasks=tasks) return render_template("tasks/today.html", tasks=tasks)
def _focus_settings_form(task):
chunks = ", ".join(
str(session.get("minutes", 0))
if isinstance(session, dict)
else str(session)
for session in task.chunk_sessions
)
return FocusSettingsForm(
timer_minutes=task.timer_seconds // 60 or None,
context=task.context,
chunk_plan=chunks,
)
def _render_focus(task=None, *, celebrate: bool = False):
return render_template(
"tasks/focus.html",
task=task,
celebrate=celebrate,
focus_form=_focus_settings_form(task) if task else None,
action_form=ActionForm(),
streak=calculate_forgiving_streak(current_user.id),
)
@bp.get("/focus")
@login_required
def focus():
tasks = list_today_tasks(current_user.id, limit=1)
return _render_focus(
tasks[0] if tasks else None,
celebrate=request.args.get("celebrate") == "1",
)
@bp.get("/<int:task_id>/focus")
@login_required
def focus_task(task_id: int):
task = _owned_task_or_404(task_id)
return _render_focus(task)
@bp.route("/new", methods=["GET", "POST"]) @bp.route("/new", methods=["GET", "POST"])
@login_required @login_required
def create(): def create():
@ -134,3 +186,50 @@ def toggle_subtask_status(task_id: int, subtask_id: int):
except TaskNotFoundError: except TaskNotFoundError:
abort(404) abort(404)
return redirect(url_for("tasks.detail", task_id=task.id)) return redirect(url_for("tasks.detail", task_id=task.id))
@bp.post("/<int:task_id>/focus-settings")
@login_required
def update_focus_settings(task_id: int):
task = _owned_task_or_404(task_id)
form = FocusSettingsForm()
if form.validate_on_submit():
configure_focus(
task,
timer_minutes=form.timer_minutes.data,
context=form.context.data,
chunk_minutes=form.parsed_chunks(),
)
flash("Focus setup saved.", "success")
else:
for errors in form.errors.values():
for error in errors:
flash(error, "error")
return redirect(url_for("tasks.focus_task", task_id=task.id))
@bp.post("/<int:task_id>/chunks/<int:chunk_index>/toggle")
@login_required
def toggle_focus_chunk(task_id: int, chunk_index: int):
task = _owned_task_or_404(task_id)
form = ActionForm()
if not form.validate_on_submit():
abort(400)
try:
toggle_chunk_session(task, chunk_index)
except TaskNotFoundError:
abort(404)
return redirect(url_for("tasks.focus_task", task_id=task.id))
@bp.post("/<int:task_id>/complete")
@login_required
def complete(task_id: int):
task = _owned_task_or_404(task_id)
form = ActionForm()
if not form.validate_on_submit():
abort(400)
completed_title = task.title
complete_task(task)
flash(f"You completed {completed_title}. That counts.", "success")
return redirect(url_for("tasks.focus", celebrate=1))

View file

@ -1,4 +1,6 @@
from datetime import datetime, time, timezone from dataclasses import dataclass
from datetime import date, datetime, time, timedelta, timezone
from typing import Any
from sqlalchemy import case, or_, select from sqlalchemy import case, or_, select
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@ -12,6 +14,12 @@ class TaskNotFoundError(LookupError):
pass pass
@dataclass(frozen=True)
class ForgivingStreak:
days: int
freeze_used: bool
def _task_query(user_id: int): def _task_query(user_id: int):
return ( return (
select(Task) select(Task)
@ -146,6 +154,90 @@ def toggle_subtask(task: Task, subtask_id: int) -> Subtask:
return subtask return subtask
def configure_focus(
task: Task,
*,
timer_minutes: int | None,
context: str | None,
chunk_minutes: list[int],
) -> Task:
existing_sessions = [
_normalize_chunk_session(session) for session in task.chunk_sessions
]
sessions = []
for index, minutes in enumerate(chunk_minutes):
completed = (
index < len(existing_sessions)
and existing_sessions[index]["minutes"] == minutes
and existing_sessions[index]["completed"]
)
sessions.append({"minutes": minutes, "completed": completed})
task.timer_seconds = (timer_minutes or 0) * 60
task.context = _clean_optional(context)
task.chunk_sessions = sessions
db.session.commit()
return task
def toggle_chunk_session(task: Task, chunk_index: int) -> Task:
sessions = [
_normalize_chunk_session(session) for session in task.chunk_sessions
]
if chunk_index < 0 or chunk_index >= len(sessions):
raise TaskNotFoundError
sessions[chunk_index]["completed"] = not sessions[chunk_index]["completed"]
task.chunk_sessions = sessions
db.session.commit()
return task
def complete_task(task: Task, *, completed_at: datetime | None = None) -> Task:
task.status = "done"
task.completed_at = completed_at or datetime.now(timezone.utc)
db.session.commit()
return task
def calculate_forgiving_streak(
user_id: int,
*,
today: date | None = None,
) -> ForgivingStreak:
completion_dates = {
completed_at.date()
for completed_at in db.session.scalars(
select(Task.completed_at).where(
Task.user_id == user_id,
Task.completed_at.is_not(None),
)
)
if completed_at is not None
}
current_day = today or datetime.now().astimezone().date()
# The current day is never treated as missed while it is still in progress.
cursor = current_day if current_day in completion_dates else current_day - timedelta(days=1)
if cursor not in completion_dates:
return ForgivingStreak(days=0, freeze_used=False)
days = 0
freeze_used = False
while True:
if cursor in completion_dates:
days += 1
cursor -= timedelta(days=1)
continue
previous_day = cursor - timedelta(days=1)
if not freeze_used and previous_day in completion_dates:
freeze_used = True
cursor = previous_day
continue
break
return ForgivingStreak(days=days, freeze_used=freeze_used)
def _validate_choices(status: str, priority: str) -> None: def _validate_choices(status: str, priority: str) -> None:
if status not in TASK_STATUSES: if status not in TASK_STATUSES:
raise ValueError("Unknown task status.") raise ValueError("Unknown task status.")
@ -156,3 +248,12 @@ def _validate_choices(status: str, priority: str) -> None:
def _clean_optional(value: str | None) -> str | None: def _clean_optional(value: str | None) -> str | None:
cleaned = value.strip() if value else "" cleaned = value.strip() if value else ""
return cleaned or None return cleaned or None
def _normalize_chunk_session(session: Any) -> dict[str, int | bool]:
if isinstance(session, dict):
return {
"minutes": int(session.get("minutes", 0)),
"completed": bool(session.get("completed", False)),
}
return {"minutes": int(session), "completed": False}

View file

@ -6,6 +6,7 @@
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<title>{% block title %}Steady{% endblock %}</title> <title>{% block title %}Steady{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<script src="{{ url_for('static', filename='js/main.js') }}" defer></script>
</head> </head>
<body> <body>
<a class="skip-link" href="#main-content">Skip to content</a> <a class="skip-link" href="#main-content">Skip to content</a>
@ -14,6 +15,7 @@
<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.focus') }}">Focus</a>
<a href="{{ url_for('tasks.today') }}">Today</a> <a href="{{ url_for('tasks.today') }}">Today</a>
<a href="{{ url_for('tasks.index') }}">Tasks</a> <a href="{{ url_for('tasks.index') }}">Tasks</a>
<span>Hi, {{ current_user.username }}</span> <span>Hi, {{ current_user.username }}</span>

View file

@ -20,6 +20,8 @@
</progress> </progress>
<span>{{ task.progress_percentage }}%</span> <span>{{ task.progress_percentage }}%</span>
</div> </div>
{% if task.status != 'done' %}
<a class="task-card__focus" href="{{ url_for('tasks.focus_task', task_id=task.id) }}">Focus on this task</a>
{% endif %}
</article> </article>
{% endmacro %} {% endmacro %}

View file

@ -55,6 +55,13 @@
</section> </section>
<div class="form-actions"> <div class="form-actions">
{% if task.status != 'done' %}
<a class="button button--complete" href="{{ url_for('tasks.focus_task', task_id=task.id) }}">Focus on this task</a>
<form method="post" action="{{ url_for('tasks.complete', task_id=task.id) }}">
{{ action_form.hidden_tag() }}
<button class="button button--quiet" type="submit">Mark complete</button>
</form>
{% endif %}
<a class="button" href="{{ url_for('tasks.edit', task_id=task.id) }}">Edit task</a> <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> <a href="{{ url_for('tasks.index') }}">Back to tasks</a>
</div> </div>
@ -69,4 +76,3 @@
</details> </details>
</article> </article>
{% endblock %} {% endblock %}

View file

@ -0,0 +1,130 @@
{% extends "base.html" %}
{% block title %}Focus · Steady{% endblock %}
{% block content %}
{% if celebrate %}
<div class="celebration" role="status" data-celebrate="true" data-chime="{{ 'true' if current_user.completion_chime else 'false' }}">
<span class="celebration__mark" aria-hidden="true"></span>
<div>
<h1>That step is complete</h1>
<p>Pause for a moment. Progress still counts without perfection.</p>
</div>
</div>
{% endif %}
<div class="focus-heading">
<div>
<p class="eyebrow">One primary action</p>
<h1>Focus Mode</h1>
</div>
<aside class="streak-card" aria-label="Forgiving completion streak">
<strong>{{ streak.days }} steady {{ 'day' if streak.days == 1 else 'days' }}</strong>
{% if streak.freeze_used %}
<span>A rest day was protected.</span>
{% elif streak.days == 0 %}
<span>Any completion can begin again.</span>
{% else %}
<span>No pressure to be perfect.</span>
{% endif %}
</aside>
</div>
{% if task %}
<article class="focus-task" aria-labelledby="focus-task-title">
<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 id="focus-task-title">{{ task.title }}</h2>
{% if task.description %}<p class="task-description">{{ task.description }}</p>{% endif %}
{% if task.context %}
<aside class="context-cue">
<strong>Helpful context</strong>
<span>{{ task.context }}</span>
<small>Context reminder automation will arrive in a later integration.</small>
</aside>
{% endif %}
{% if task.timer_seconds %}
<section class="timer" aria-labelledby="timer-heading"
data-focus-timer data-task-id="{{ task.id }}"
data-duration="{{ task.timer_seconds }}"
data-chime="{{ 'true' if current_user.completion_chime else 'false' }}">
<h3 id="timer-heading">Visual countdown</h3>
<output class="timer__display" aria-live="off">{{ '%02d:%02d'|format(task.timer_seconds // 60, task.timer_seconds % 60) }}</output>
<div class="timer__progress" aria-hidden="true"><span></span></div>
<p class="timer__announcement visually-hidden" aria-live="polite"></p>
<div class="timer__actions">
<button class="button" type="button" data-timer-start>Start</button>
<button class="button button--quiet" type="button" data-timer-pause>Pause</button>
<button class="button button--quiet" type="button" data-timer-reset>Reset</button>
</div>
</section>
{% endif %}
{% if task.chunk_sessions %}
<section aria-labelledby="blocks-heading">
<h3 id="blocks-heading">Focus blocks</h3>
<ol class="chunk-list">
{% for session in task.chunk_sessions %}
<li class="chunk {% if session.completed %}chunk--done{% endif %}">
<form method="post" action="{{ url_for('tasks.toggle_focus_chunk', task_id=task.id, chunk_index=loop.index0) }}">
{{ action_form.hidden_tag() }}
<button type="submit" aria-label="Mark {{ session.minutes }} minute block as {{ 'not completed' if session.completed else 'completed' }}">
<span aria-hidden="true">{{ '✓' if session.completed else loop.index }}</span>
<span>{{ session.minutes }} min</span>
</button>
</form>
</li>
{% endfor %}
</ol>
</section>
{% endif %}
<div class="focus-task__actions">
<form method="post" action="{{ url_for('tasks.complete', task_id=task.id) }}">
{{ action_form.hidden_tag() }}
<button class="button button--complete" type="submit">Mark this task complete</button>
</form>
<a href="{{ url_for('tasks.detail', task_id=task.id) }}">View all details</a>
</div>
</article>
<details class="focus-settings">
<summary>Adjust focus setup</summary>
<form method="post" action="{{ url_for('tasks.update_focus_settings', task_id=task.id) }}">
{{ focus_form.hidden_tag() }}
<div class="field">
{{ focus_form.timer_minutes.label }}
{{ focus_form.timer_minutes(min=1, max=180, inputmode="numeric") }}
<p class="field__help">Optional. Choose 1180 minutes.</p>
</div>
<div class="field">
{{ focus_form.context.label }}
{{ focus_form.context(placeholder="For example: at my desk with headphones") }}
<p class="field__help">A visible cue only; no automatic location tracking.</p>
</div>
<div class="field">
{{ focus_form.chunk_plan.label }}
{{ focus_form.chunk_plan(placeholder="5, 5, 10") }}
<p class="field__help">Comma-separated minutes, with at most 12 blocks.</p>
</div>
{{ focus_form.submit(class="button") }}
</form>
</details>
{% else %}
<section class="empty-state focus-empty">
<h2>Your focus space is clear</h2>
<p>There is no current task to surface. Rest or capture one small next step.</p>
<a class="button" href="{{ url_for('tasks.create') }}">Add a task</a>
</section>
{% endif %}
{% endblock %}

View file

@ -232,12 +232,12 @@ a,
- [x] 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] - [x] Focus Mode (One Primary Action) [1]
- [ ] Countdown Timer per Task [1] - [x] Countdown Timer per Task [1]
- [ ] Task Chunking UI (Time-Blocks) [1] - [x] Task Chunking UI (Time-Blocks) [1]
- [ ] Context-based Reminders Placeholder [1] - [x] Context-based Reminders Placeholder [1]
- [ ] Positive Feedback Animation + Completion Chime [1] - [x] Positive Feedback Animation + Completion Chime [1]
- [ ] Forgiving Streak System (No Guilt) [1] - [x] Forgiving Streak System (No Guilt) [1]
### Phase 4: Import/Export & Backup (Week 7) ### Phase 4: Import/Export & Backup (Week 7)
- [ ] Markdown File Upload & Bulk Task Import [2] - [ ] Markdown File Upload & Bulk Task Import [2]

View file

@ -4,10 +4,10 @@ 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 3 — ADHD-Specific Features
- Status: Complete - Status: Complete
- Last updated: 2026-08-08 - Last updated: 2026-08-08
- Next phase: Phase 3 — ADHD-Specific Features - Next phase: Phase 4 — Import, Export & Backup
## Phase 1 — Core Infrastructure ## Phase 1 — Core Infrastructure
@ -120,6 +120,62 @@ No broken Python requirements were found.
Whitespace and git diff checks completed successfully. Whitespace and git diff checks completed successfully.
``` ```
## Phase 3 — ADHD-Specific Features
### Phase 3.1 — Focus domain
Added validated focus settings for an optional countdown, helpful context, and up to 12 comma-separated time blocks. Service operations configure focus without resetting unchanged completed blocks, toggle individual blocks, complete tasks, and calculate a forgiving streak with one internal missed-day freeze.
Why: focus behavior belongs in reusable services so the web interface does not become the only possible client. The streak is derived from completion history rather than stored counters, avoiding drift and making the freeze rule explainable. The current day is never counted as missed while it is still underway.
Next: expose these operations through authenticated, ownership-safe Focus Mode routes.
### Phase 3.2 — Focus Mode routes
Added Focus Mode routes for the highest-ranked Today task or an explicitly selected owned task. Added protected mutations for focus settings, chunk toggles, and completion. Focus rendering includes the derived streak and moves to the next eligible task after completion.
Why: the default route surfaces exactly one decision, while explicit task focus preserves user control. All mutations remain POST-only with CSRF and ownership checks. Completing a task redirects to the next focus item instead of returning users to a visually dense list.
Next: build the focused screen, countdown controls, chunk plan, context cue, and positive completion feedback.
### Phase 3.3 — Focus interface and feedback
Added a single-task Focus Mode screen with an optional persistent visual countdown, time-block checklist, visible context cue, forgiving streak card, and focused completion action. Added navigation from task cards and details. Completion moves to the next task and renders a calm celebration with an optional synthesized chime.
The timer uses local browser storage to survive reloads, supports start/pause/reset, announces completion to assistive technology, and continues in-page if storage is unavailable. Chime failures are ignored so browser audio policy can never block task completion.
Why: only one task is rendered as the primary action. Timer state is local interaction state rather than high-frequency database traffic. Context is explicitly a visible placeholder with no hidden location tracking. Feedback remains optional and respects the user's existing chime preference.
Next: add accessible styling and automated tests for focus selection, settings, chunks, streak rules, completion, and ownership.
### Phase 3.4 — Automated coverage
Added eight tests for single-task focus selection, focus configuration, invalid chunk plans, exact block toggling, completion and advancement, ownership isolation, an internal rest-day freeze, and an unfinished current day. The full suite now contains 26 passing tests.
Why: deterministic dates make the emotional contract of the forgiving streak testable. Ownership tests cover every new mutation, while the advancement assertion distinguishes the completed-task confirmation from the next primary focus heading.
Verification at this step: `26 passed in 2.53s`.
Next: run JavaScript syntax, Python compilation, migration, dependency, routing, formatting, and full regression checks.
### Phase 3.5 — Final verification
Completed the full regression suite, JavaScript syntax validation, Alembic model comparison, Flask route discovery, Python compilation, dependency consistency, line-length and trailing-whitespace scans, and patch-format validation. Added the same positive completion action to task details so feedback is not restricted to users who enter Focus Mode first.
Why: Phase 3 combines client-side state with server-side ownership rules, so both language runtimes and their integration points require validation. Reusing the protected completion route keeps completion semantics, streak history, and feedback consistent from every entry point.
Final results:
```text
26 tests passed in 2.54s
JavaScript syntax validation completed successfully.
Alembic detected no pending model operations.
Flask discovered all expected routes.
Python compilation completed successfully.
No broken Python requirements were found.
Line-length, whitespace, and git diff checks completed successfully.
```
## Next Work ## 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. Phase 4 will add safe Markdown bulk capture, versioned JSON backup export and restore, and undoable clearing of completed tasks. Import formats must be validated, ownership must remain explicit, and destructive operations must provide a recovery path.

173
tests/tasks/test_focus.py Normal file
View file

@ -0,0 +1,173 @@
from datetime import date, datetime
from app.auth.services import create_user
from app.tasks.services import (
calculate_forgiving_streak,
complete_task,
create_task,
)
def login(client, email: str, password: str = "secure password") -> None:
client.post(
"/auth/login",
data={"email": email, "password": password},
)
def make_task(user_id: int, title: str, *, priority: str = "normal"):
return create_task(
user_id,
title=title,
description=None,
status="not_started",
priority=priority,
due_date=None,
)
def test_focus_mode_surfaces_only_highest_ranked_today_task(client, db):
user = create_user("focus_user", "focus@example.com", "secure password")
make_task(user.id, "Lower priority choice")
primary = make_task(user.id, "Primary urgent choice", priority="urgent")
login(client, user.email)
response = client.get("/tasks/focus")
assert response.status_code == 200
assert b"Primary urgent choice" in response.data
assert b"Lower priority choice" not in response.data
assert f'data-task-id="{primary.id}"'.encode() not in response.data
def test_focus_settings_store_timer_context_and_chunks(client, db):
user = create_user("setup_user", "setup@example.com", "secure password")
task = make_task(user.id, "Configured focus task")
login(client, user.email)
response = client.post(
f"/tasks/{task.id}/focus-settings",
data={
"timer_minutes": "25",
"context": "At my desk with headphones",
"chunk_plan": "5, 5, 10",
},
follow_redirects=True,
)
db.session.refresh(task)
assert response.status_code == 200
assert b"Focus setup saved" in response.data
assert b"Visual countdown" in response.data
assert b"At my desk with headphones" in response.data
assert task.timer_seconds == 1500
assert task.context == "At my desk with headphones"
assert task.chunk_sessions == [
{"minutes": 5, "completed": False},
{"minutes": 5, "completed": False},
{"minutes": 10, "completed": False},
]
def test_invalid_chunk_plan_is_rejected_without_changes(client, db):
user = create_user("invalid_user", "invalid@example.com", "secure password")
task = make_task(user.id, "Unchanged focus task")
login(client, user.email)
response = client.post(
f"/tasks/{task.id}/focus-settings",
data={"timer_minutes": "10", "chunk_plan": "5, later, 10"},
follow_redirects=True,
)
db.session.refresh(task)
assert b"Use comma-separated whole minutes" in response.data
assert task.timer_seconds == 0
assert task.chunk_sessions == []
def test_chunk_toggle_updates_only_requested_block(client, db):
user = create_user("chunk_user", "chunk@example.com", "secure password")
task = make_task(user.id, "Chunked task")
task.chunk_sessions = [
{"minutes": 5, "completed": False},
{"minutes": 10, "completed": False},
]
db.session.commit()
login(client, user.email)
response = client.post(
f"/tasks/{task.id}/chunks/1/toggle",
follow_redirects=True,
)
db.session.refresh(task)
assert response.status_code == 200
assert task.chunk_sessions[0]["completed"] is False
assert task.chunk_sessions[1]["completed"] is True
assert client.post(f"/tasks/{task.id}/chunks/4/toggle").status_code == 404
def test_completion_celebrates_and_advances_to_next_task(client, db):
user = create_user("complete_user", "complete@example.com", "secure password")
primary = make_task(user.id, "Finish this first", priority="urgent")
make_task(user.id, "Then show this task")
login(client, user.email)
response = client.post(
f"/tasks/{primary.id}/complete",
follow_redirects=True,
)
db.session.refresh(primary)
assert response.status_code == 200
assert b"That step is complete" in response.data
assert b'id="focus-task-title">Then show this task' in response.data
assert b'id="focus-task-title">Finish this first' not in response.data
assert primary.status == "done"
assert primary.completed_at is not None
def test_focus_mutations_do_not_expose_foreign_tasks(client, db):
owner = create_user("focus_owner", "focus-owner@example.com", "secure password")
visitor = create_user(
"focus_visitor",
"focus-visitor@example.com",
"secure password",
)
task = make_task(owner.id, "Private focus task")
login(client, visitor.email)
assert client.get(f"/tasks/{task.id}/focus").status_code == 404
assert client.post(f"/tasks/{task.id}/complete").status_code == 404
assert (
client.post(
f"/tasks/{task.id}/focus-settings",
data={"timer_minutes": "15"},
).status_code
== 404
)
assert client.post(f"/tasks/{task.id}/chunks/0/toggle").status_code == 404
def test_forgiving_streak_allows_one_internal_rest_day(db):
user = create_user("streak_user", "streak@example.com", "secure password")
for day in (8, 7, 5):
task = make_task(user.id, f"Completed on day {day}")
complete_task(task, completed_at=datetime(2026, 8, day, 12, 0))
streak = calculate_forgiving_streak(user.id, today=date(2026, 8, 8))
assert streak.days == 3
assert streak.freeze_used is True
def test_current_incomplete_day_does_not_break_streak(db):
user = create_user("rest_user", "rest@example.com", "secure password")
yesterday = make_task(user.id, "Yesterday counted")
complete_task(yesterday, completed_at=datetime(2026, 8, 7, 12, 0))
streak = calculate_forgiving_streak(user.id, today=date(2026, 8, 8))
assert streak.days == 1
assert streak.freeze_used is False