From e651369587a8b59d3e47921cf9bc3de9670a6e50 Mon Sep 17 00:00:00 2001 From: patsy Date: Sat, 8 Aug 2026 03:08:28 +0200 Subject: [PATCH] phase 3 --- README.md | 4 +- app/static/css/style.css | 224 ++++++++++++++++++++++++++++ app/static/js/main.js | 115 ++++++++++++++ app/tasks/forms.py | 44 +++++- app/tasks/routes.py | 101 ++++++++++++- app/tasks/services.py | 103 ++++++++++++- app/templates/base.html | 2 + app/templates/tasks/_task_card.html | 4 +- app/templates/tasks/detail.html | 8 +- app/templates/tasks/focus.html | 130 ++++++++++++++++ design.md | 12 +- progress.md | 62 +++++++- tests/tasks/test_focus.py | 173 +++++++++++++++++++++ 13 files changed, 966 insertions(+), 16 deletions(-) create mode 100644 app/static/js/main.js create mode 100644 app/templates/tasks/focus.html create mode 100644 tests/tasks/test_focus.py diff --git a/README.md b/README.md index f77e308..58b5424 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ 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. +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 diff --git a/app/static/css/style.css b/app/static/css/style.css index 0af4077..6140994 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -340,6 +340,217 @@ input:focus-visible { 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 { position: absolute; top: 0; @@ -384,6 +595,11 @@ input { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; } + + .celebration, + .celebration__mark { + animation: none; + } } @media (max-width: 38rem) { @@ -404,4 +620,12 @@ input { .form-grid { display: flex; } + + .focus-heading { + grid-template-columns: 1fr; + } + + .streak-card { + max-width: none; + } } diff --git a/app/static/js/main.js b/app/static/js/main.js new file mode 100644 index 0000000..066329d --- /dev/null +++ b/app/static/js/main.js @@ -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(); +} diff --git a/app/tasks/forms.py b/app/tasks/forms.py index c1c4682..df48c66 100644 --- a/app/tasks/forms.py +++ b/app/tasks/forms.py @@ -1,7 +1,7 @@ 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.validators import Length, Optional, ValidationError +from wtforms.validators import Length, NumberRange, Optional, ValidationError from .models import TASK_PRIORITIES, TASK_STATUSES @@ -60,3 +60,43 @@ class SubtaskForm(FlaskForm): class ActionForm(FlaskForm): 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(",") + ] diff --git a/app/tasks/routes.py b/app/tasks/routes.py index 51a2f8a..c8b7d3f 100644 --- a/app/tasks/routes.py +++ b/app/tasks/routes.py @@ -2,16 +2,26 @@ from flask import abort, flash, redirect, render_template, request, url_for from flask_login import current_user, login_required from . import bp -from .forms import ActionForm, STATUS_LABELS, SubtaskForm, TaskForm +from .forms import ( + ActionForm, + FocusSettingsForm, + STATUS_LABELS, + SubtaskForm, + TaskForm, +) from .models import TASK_STATUSES from .services import ( TaskNotFoundError, add_subtask, + calculate_forgiving_streak, + complete_task, + configure_focus, create_task, delete_task, get_task, list_tasks, list_today_tasks, + toggle_chunk_session, toggle_subtask, update_task, ) @@ -46,6 +56,48 @@ def today(): 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("//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"]) @login_required def create(): @@ -134,3 +186,50 @@ def toggle_subtask_status(task_id: int, subtask_id: int): except TaskNotFoundError: abort(404) return redirect(url_for("tasks.detail", task_id=task.id)) + + +@bp.post("//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("//chunks//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("//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)) diff --git a/app/tasks/services.py b/app/tasks/services.py index a9f05c8..a7f8252 100644 --- a/app/tasks/services.py +++ b/app/tasks/services.py @@ -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.orm import selectinload @@ -12,6 +14,12 @@ class TaskNotFoundError(LookupError): pass +@dataclass(frozen=True) +class ForgivingStreak: + days: int + freeze_used: bool + + def _task_query(user_id: int): return ( select(Task) @@ -146,6 +154,90 @@ def toggle_subtask(task: Task, subtask_id: int) -> 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: if status not in TASK_STATUSES: 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: cleaned = value.strip() if value else "" 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} diff --git a/app/templates/base.html b/app/templates/base.html index ccd8123..db019ff 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -6,6 +6,7 @@ {% block title %}Steady{% endblock %} + @@ -14,6 +15,7 @@ Steady + {% if task.status != 'done' %} + Focus on this task + {% endif %} {% endmacro %} - diff --git a/app/templates/tasks/detail.html b/app/templates/tasks/detail.html index a353b13..b4436b3 100644 --- a/app/templates/tasks/detail.html +++ b/app/templates/tasks/detail.html @@ -55,6 +55,13 @@
+ {% if task.status != 'done' %} + Focus on this task +
+ {{ action_form.hidden_tag() }} + +
+ {% endif %} Edit task Back to tasks
@@ -69,4 +76,3 @@ {% endblock %} - diff --git a/app/templates/tasks/focus.html b/app/templates/tasks/focus.html new file mode 100644 index 0000000..b1c588a --- /dev/null +++ b/app/templates/tasks/focus.html @@ -0,0 +1,130 @@ +{% extends "base.html" %} + +{% block title %}Focus · Steady{% endblock %} + +{% block content %} +{% if celebrate %} +
+ +
+

That step is complete

+

Pause for a moment. Progress still counts without perfection.

+
+
+{% endif %} + +
+
+

One primary action

+

Focus Mode

+
+ +
+ +{% if task %} +
+
+
+ {{ task.priority|capitalize }} + {{ task.status|replace('_', ' ')|title }} +
+ {% if task.due_date %} + + {% endif %} +
+

{{ task.title }}

+ {% if task.description %}

{{ task.description }}

{% endif %} + + {% if task.context %} + + {% endif %} + + {% if task.timer_seconds %} +
+

Visual countdown

+ {{ '%02d:%02d'|format(task.timer_seconds // 60, task.timer_seconds % 60) }} + +

+
+ + + +
+
+ {% endif %} + + {% if task.chunk_sessions %} +
+

Focus blocks

+
    + {% for session in task.chunk_sessions %} +
  1. +
    + {{ action_form.hidden_tag() }} + +
    +
  2. + {% endfor %} +
+
+ {% endif %} + +
+
+ {{ action_form.hidden_tag() }} + +
+ View all details +
+
+ +
+ Adjust focus setup +
+ {{ focus_form.hidden_tag() }} +
+ {{ focus_form.timer_minutes.label }} + {{ focus_form.timer_minutes(min=1, max=180, inputmode="numeric") }} +

Optional. Choose 1–180 minutes.

+
+
+ {{ focus_form.context.label }} + {{ focus_form.context(placeholder="For example: at my desk with headphones") }} +

A visible cue only; no automatic location tracking.

+
+
+ {{ focus_form.chunk_plan.label }} + {{ focus_form.chunk_plan(placeholder="5, 5, 10") }} +

Comma-separated minutes, with at most 12 blocks.

+
+ {{ focus_form.submit(class="button") }} +
+
+{% else %} +
+

Your focus space is clear

+

There is no current task to surface. Rest or capture one small next step.

+ Add a task +
+{% endif %} +{% endblock %} + diff --git a/design.md b/design.md index 8c54031..9a7365f 100644 --- a/design.md +++ b/design.md @@ -232,12 +232,12 @@ a, - [x] Progress Bar Component [1] ### Phase 3: ADHD-Specific Features (Week 5-6) -- [ ] Focus Mode (One Primary Action) [1] -- [ ] Countdown Timer per Task [1] -- [ ] Task Chunking UI (Time-Blocks) [1] -- [ ] Context-based Reminders Placeholder [1] -- [ ] Positive Feedback Animation + Completion Chime [1] -- [ ] Forgiving Streak System (No Guilt) [1] +- [x] Focus Mode (One Primary Action) [1] +- [x] Countdown Timer per Task [1] +- [x] Task Chunking UI (Time-Blocks) [1] +- [x] Context-based Reminders Placeholder [1] +- [x] Positive Feedback Animation + Completion Chime [1] +- [x] Forgiving Streak System (No Guilt) [1] ### Phase 4: Import/Export & Backup (Week 7) - [ ] Markdown File Upload & Bulk Task Import [2] diff --git a/progress.md b/progress.md index 2b6222f..48cdf9b 100644 --- a/progress.md +++ b/progress.md @@ -4,10 +4,10 @@ This is the living implementation log for Steady. Update it after every meaningf ## Current Status -- Current phase: Phase 2 — Task Management +- Current phase: Phase 3 — ADHD-Specific Features - Status: Complete - Last updated: 2026-08-08 -- Next phase: Phase 3 — ADHD-Specific Features +- Next phase: Phase 4 — Import, Export & Backup ## Phase 1 — Core Infrastructure @@ -120,6 +120,62 @@ No broken Python requirements were found. 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 -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. diff --git a/tests/tasks/test_focus.py b/tests/tasks/test_focus.py new file mode 100644 index 0000000..08ed830 --- /dev/null +++ b/tests/tasks/test_focus.py @@ -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