phase4
This commit is contained in:
parent
67e35bcffd
commit
543f135ca0
14 changed files with 488 additions and 13 deletions
11
README.md
11
README.md
|
|
@ -5,7 +5,8 @@ application foundation, secure authentication, task and subtask management, a
|
|||
seven-item Today view, status filters, priorities, progress indicators, and a
|
||||
single-task Focus Mode with countdowns, time blocks, context cues, positive
|
||||
completion feedback, forgiving streaks, bulk text capture, and portable JSON
|
||||
backups with reversible completed-task clearing.
|
||||
backups with reversible completed-task clearing. Per-user settings control
|
||||
theme, reading style, completion sound, and future reminder frequency.
|
||||
|
||||
## Local setup
|
||||
|
||||
|
|
@ -53,3 +54,11 @@ After signing in, open `/tasks/transfer` to:
|
|||
|
||||
JSON restore validates the complete file before changing tasks. Keep downloaded
|
||||
backups private because task descriptions may contain personal information.
|
||||
|
||||
## Personalization
|
||||
|
||||
Open `/settings/` after signing in to choose Light, Dark, or system-controlled
|
||||
color mode; enable a dyslexia-friendly local reading style; turn completion
|
||||
sound on or off; and save a future reminder-frequency preference. The browser
|
||||
reminder control checks capability only. It does not request notification
|
||||
permission, register a service worker, or schedule reminders.
|
||||
|
|
|
|||
|
|
@ -49,8 +49,10 @@ def _register_blueprints(app: Flask) -> None:
|
|||
# Imports stay local so feature modules do not create circular imports.
|
||||
from .auth import bp as auth_blueprint
|
||||
from .core import bp as core_blueprint
|
||||
from .settings import bp as settings_blueprint
|
||||
from .tasks import bp as tasks_blueprint
|
||||
|
||||
app.register_blueprint(core_blueprint)
|
||||
app.register_blueprint(auth_blueprint, url_prefix="/auth")
|
||||
app.register_blueprint(tasks_blueprint, url_prefix="/tasks")
|
||||
app.register_blueprint(settings_blueprint, url_prefix="/settings")
|
||||
|
|
|
|||
|
|
@ -1,2 +1,6 @@
|
|||
"""User-settings feature package."""
|
||||
from flask import Blueprint
|
||||
|
||||
|
||||
bp = Blueprint("settings", __name__)
|
||||
|
||||
from . import routes # noqa: E402, F401
|
||||
|
|
|
|||
25
app/settings/forms.py
Normal file
25
app/settings/forms.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
from flask_wtf import FlaskForm
|
||||
from wtforms import BooleanField, SelectField, SubmitField
|
||||
|
||||
|
||||
THEME_CHOICES = (
|
||||
("auto", "Use system setting"),
|
||||
("light", "Light"),
|
||||
("dark", "Dark"),
|
||||
)
|
||||
NOTIFICATION_CHOICES = (
|
||||
("quiet", "Quiet — only essential reminders"),
|
||||
("moderate", "Moderate — occasional reminders"),
|
||||
("alert", "Alert — more visible reminders"),
|
||||
)
|
||||
|
||||
|
||||
class PreferencesForm(FlaskForm):
|
||||
theme = SelectField("Color theme", choices=THEME_CHOICES)
|
||||
dyslexia_font = BooleanField("Use a dyslexia-friendly reading style")
|
||||
completion_chime = BooleanField("Play a gentle completion chime")
|
||||
notification_frequency = SelectField(
|
||||
"Future reminder frequency",
|
||||
choices=NOTIFICATION_CHOICES,
|
||||
)
|
||||
submit = SubmitField("Save preferences")
|
||||
24
app/settings/routes.py
Normal file
24
app/settings/routes.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
from flask import flash, redirect, render_template, url_for
|
||||
from flask_login import current_user, login_required
|
||||
|
||||
from . import bp
|
||||
from .forms import PreferencesForm
|
||||
from .services import update_preferences
|
||||
|
||||
|
||||
@bp.route("/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def index():
|
||||
form = PreferencesForm(obj=current_user)
|
||||
if form.validate_on_submit():
|
||||
update_preferences(
|
||||
current_user,
|
||||
theme=form.theme.data,
|
||||
dyslexia_font=form.dyslexia_font.data,
|
||||
completion_chime=form.completion_chime.data,
|
||||
notification_frequency=form.notification_frequency.data,
|
||||
)
|
||||
flash("Preferences saved.", "success")
|
||||
return redirect(url_for("settings.index"))
|
||||
return render_template("settings/index.html", form=form)
|
||||
|
||||
39
app/settings/services.py
Normal file
39
app/settings/services.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
from typing import Protocol
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
THEMES = {"auto", "light", "dark"}
|
||||
NOTIFICATION_FREQUENCIES = {"quiet", "moderate", "alert"}
|
||||
|
||||
|
||||
class SettingsUser(Protocol):
|
||||
theme: str
|
||||
dyslexia_font: bool
|
||||
completion_chime: bool
|
||||
notification_frequency: str
|
||||
|
||||
|
||||
def update_preferences(
|
||||
user: SettingsUser,
|
||||
*,
|
||||
theme: str,
|
||||
dyslexia_font: bool,
|
||||
completion_chime: bool,
|
||||
notification_frequency: str,
|
||||
) -> None:
|
||||
if theme not in THEMES:
|
||||
raise ValueError("Unknown theme.")
|
||||
if notification_frequency not in NOTIFICATION_FREQUENCIES:
|
||||
raise ValueError("Unknown notification frequency.")
|
||||
if not isinstance(dyslexia_font, bool) or not isinstance(
|
||||
completion_chime, bool
|
||||
):
|
||||
raise ValueError("Accessibility preferences must be true or false.")
|
||||
|
||||
user.theme = theme
|
||||
user.dyslexia_font = dyslexia_font
|
||||
user.completion_chime = completion_chime
|
||||
user.notification_frequency = notification_frequency
|
||||
db.session.commit()
|
||||
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
:root {
|
||||
color-scheme: light dark;
|
||||
color-scheme: light;
|
||||
--lavender: #b8a9c9;
|
||||
--blue: #a8d8ea;
|
||||
--yellow: #ffeaa7;
|
||||
|
|
@ -13,6 +13,20 @@
|
|||
--space: clamp(1rem, 3vw, 2rem);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--beige: #1e1e24;
|
||||
--surface: #292932;
|
||||
--text: #f5f0e8;
|
||||
--muted: #c5bdcc;
|
||||
--focus: #d4c0ee;
|
||||
--border: #4b4b58;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] a {
|
||||
color: #d9c5f1;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
|
@ -25,6 +39,13 @@ body {
|
|||
font: 1rem/1.6 system-ui, sans-serif;
|
||||
}
|
||||
|
||||
html[data-dyslexia="true"] body {
|
||||
font-family: "Atkinson Hyperlegible", Verdana, Arial, sans-serif;
|
||||
line-height: 1.75;
|
||||
letter-spacing: 0.02em;
|
||||
word-spacing: 0.06em;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #57436c;
|
||||
}
|
||||
|
|
@ -592,6 +613,56 @@ input:focus-visible {
|
|||
color: var(--muted);
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
max-width: 48rem;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
margin: 0 0 1.5rem;
|
||||
padding: var(--space);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 1rem;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.settings-section legend {
|
||||
padding-inline: 0.4rem;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.preference-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 2rem;
|
||||
padding-block: 0.75rem;
|
||||
}
|
||||
|
||||
.preference-row label {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.preference-row p {
|
||||
margin-block: 0.2rem 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.preference-row input[role="switch"] {
|
||||
flex: 0 0 auto;
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
accent-color: var(--lavender);
|
||||
}
|
||||
|
||||
.reminder-placeholder {
|
||||
margin-top: 1.25rem;
|
||||
padding: 1rem;
|
||||
border-left: 0.35rem solid var(--lavender);
|
||||
border-radius: 0.6rem;
|
||||
background: color-mix(in srgb, var(--lavender) 18%, var(--surface));
|
||||
}
|
||||
|
||||
@keyframes gentle-arrival {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
|
@ -626,7 +697,8 @@ input {
|
|||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
html[data-theme="auto"] {
|
||||
color-scheme: dark;
|
||||
--beige: #1e1e24;
|
||||
--surface: #292932;
|
||||
--text: #f5f0e8;
|
||||
|
|
@ -635,7 +707,7 @@ input {
|
|||
--border: #4b4b58;
|
||||
}
|
||||
|
||||
a {
|
||||
html[data-theme="auto"] a {
|
||||
color: #d9c5f1;
|
||||
}
|
||||
}
|
||||
|
|
@ -691,4 +763,9 @@ input {
|
|||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.preference-row {
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,3 +113,25 @@ const celebration = document.querySelector('[data-celebrate="true"]');
|
|||
if (celebration && celebration.dataset.chime === "true") {
|
||||
playCompletionChime();
|
||||
}
|
||||
|
||||
document.querySelectorAll("[data-reminder-capability]").forEach((panel) => {
|
||||
const button = panel.querySelector("[data-check-reminders]");
|
||||
const status = panel.querySelector("[data-reminder-status]");
|
||||
if (!button || !status) return;
|
||||
|
||||
button.addEventListener("click", () => {
|
||||
const supported =
|
||||
"Notification" in window &&
|
||||
"serviceWorker" in navigator &&
|
||||
"PushManager" in window;
|
||||
if (supported) {
|
||||
status.textContent =
|
||||
"This browser supports the foundations for reminders. " +
|
||||
"Steady reminders remain disabled and no permission was requested.";
|
||||
} else {
|
||||
status.textContent =
|
||||
"This browser does not currently expose all reminder capabilities. " +
|
||||
"Your saved preference is unaffected.";
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="en"
|
||||
data-theme="{{ current_user.theme if current_user.is_authenticated else 'auto' }}"
|
||||
data-dyslexia="{{ 'true' if current_user.is_authenticated and current_user.dyslexia_font else 'false' }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
|
@ -19,6 +21,7 @@
|
|||
<a href="{{ url_for('tasks.today') }}">Today</a>
|
||||
<a href="{{ url_for('tasks.index') }}">Tasks</a>
|
||||
<a href="{{ url_for('tasks.transfer') }}">Data</a>
|
||||
<a href="{{ url_for('settings.index') }}">Settings</a>
|
||||
<span>Hi, {{ current_user.username }}</span>
|
||||
<form action="{{ url_for('auth.logout') }}" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
|
|
|||
63
app/templates/settings/index.html
Normal file
63
app/templates/settings/index.html
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Settings · Steady{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Make Steady feel easier</p>
|
||||
<h1>Settings</h1>
|
||||
<p>Choose what supports you. Every preference can be changed again.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="settings-form" method="post" novalidate>
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<fieldset class="settings-section">
|
||||
<legend>Appearance and reading</legend>
|
||||
<div class="field">
|
||||
{{ form.theme.label }}
|
||||
{{ form.theme() }}
|
||||
<p class="field__help">Auto follows your device's light or dark setting.</p>
|
||||
</div>
|
||||
<div class="preference-row">
|
||||
<div>
|
||||
{{ form.dyslexia_font.label }}
|
||||
<p>Uses an installed accessible font when available, with a spacious local fallback and no external font request.</p>
|
||||
</div>
|
||||
{{ form.dyslexia_font(role="switch") }}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="settings-section">
|
||||
<legend>Feedback</legend>
|
||||
<div class="preference-row">
|
||||
<div>
|
||||
{{ form.completion_chime.label }}
|
||||
<p>Visual completion feedback remains available when sound is off.</p>
|
||||
</div>
|
||||
{{ form.completion_chime(role="switch") }}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="settings-section">
|
||||
<legend>Reminders</legend>
|
||||
<div class="field">
|
||||
{{ form.notification_frequency.label }}
|
||||
{{ form.notification_frequency() }}
|
||||
<p class="field__help">This prepares your preference for a future reminder service. Quiet is the default.</p>
|
||||
</div>
|
||||
<div class="reminder-placeholder"
|
||||
data-reminder-capability
|
||||
data-feature-enabled="{{ 'true' if config.FEATURE_BROWSER_REMINDERS else 'false' }}">
|
||||
<strong>Browser reminders are not active yet</strong>
|
||||
<p data-reminder-status>No notifications are scheduled, and Steady has not requested browser permission.</p>
|
||||
<button class="button button--quiet" type="button" data-check-reminders>Check browser support</button>
|
||||
<p class="field__help">This check does not request permission or subscribe your browser.</p>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{{ form.submit(class="button") }}
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
|
@ -25,6 +25,7 @@ class Config:
|
|||
|
||||
FEATURE_ADMIN = False
|
||||
FEATURE_API_V1 = False
|
||||
FEATURE_BROWSER_REMINDERS = False
|
||||
|
||||
|
||||
class DevelopmentConfig(Config):
|
||||
|
|
|
|||
|
|
@ -246,10 +246,10 @@ a,
|
|||
- [x] Clear Completed Tasks (Undoable) [2]
|
||||
|
||||
### Phase 5: Settings & Personalization (Week 8)
|
||||
- [ ] Theme Toggle (Light/Dark/Auto) [2]
|
||||
- [ ] Dyslexia-Friendly Font Toggle [2]
|
||||
- [ ] Notification Frequency Settings [1]
|
||||
- [ ] Browser Reminders (Web Push API Placeholder) [2]
|
||||
- [x] Theme Toggle (Light/Dark/Auto) [2]
|
||||
- [x] Dyslexia-Friendly Font Toggle [2]
|
||||
- [x] Notification Frequency Settings [1]
|
||||
- [x] Browser Reminders (Web Push API Placeholder) [2]
|
||||
|
||||
### Phase 6: Admin Dashboard (Placeholder - Future) [3]
|
||||
- [ ] Admin Route Placeholder [3]
|
||||
|
|
|
|||
59
progress.md
59
progress.md
|
|
@ -4,10 +4,10 @@ This is the living implementation log for Steady. Update it after every meaningf
|
|||
|
||||
## Current Status
|
||||
|
||||
- Current phase: Phase 4 — Import, Export & Backup
|
||||
- Current phase: Phase 5 — Settings & Personalization
|
||||
- Status: Complete
|
||||
- Last updated: 2026-08-08
|
||||
- Next phase: Phase 5 — Settings & Personalization
|
||||
- Next phase: Phase 6 — Admin Dashboard Placeholder
|
||||
|
||||
## Phase 1 — Core Infrastructure
|
||||
|
||||
|
|
@ -242,6 +242,59 @@ No broken Python requirements were found.
|
|||
Line-length, whitespace, and git diff checks completed successfully.
|
||||
```
|
||||
|
||||
## Phase 5 — Settings & Personalization
|
||||
|
||||
### Phase 5.1 — Settings domain and route
|
||||
|
||||
Added an independent settings Blueprint, validated preference form, and service for theme, readability typography, completion chime, and future reminder frequency. The service validates choices again outside the form and commits all preferences together. Browser reminders remain disabled behind a configuration flag.
|
||||
|
||||
Why: settings belong to their own feature boundary rather than authentication or task routes. Form validation supports the current web UI, while service-level validation protects future callers such as an API. One commit prevents partially saved preference combinations.
|
||||
|
||||
Next: apply preferences during server rendering and build the accessible settings interface.
|
||||
|
||||
### Phase 5.2 — Personalized rendering and interface
|
||||
|
||||
Added an accessible settings page grouped into appearance, feedback, and reminders. Every page now receives server-rendered `data-theme` and `data-dyslexia` attributes from the authenticated user, with anonymous users defaulting to system theme and standard typography. CSS supports explicit light/dark overrides, automatic system dark mode, and a local readability-focused font stack with increased spacing.
|
||||
|
||||
Why: server-rendered preferences work without JavaScript and prevent a flash of the wrong theme. The readability option uses installed system fonts rather than sending reading preferences to an external font provider. Native checkboxes retain keyboard and assistive-technology behavior while receiving switch semantics.
|
||||
|
||||
The reminder section clearly states that no service is active, no notifications are scheduled, and no permission has been requested.
|
||||
|
||||
Next: add a capability-only browser check that never requests permission or creates a subscription.
|
||||
|
||||
### Phase 5.3 — Browser-reminder placeholder
|
||||
|
||||
Added a user-triggered JavaScript capability check for the Notification, Service Worker, and Push APIs. It reports whether the browser exposes the necessary foundations while reiterating that reminders remain disabled. It does not call `requestPermission`, register a service worker, create a push subscription, or change the saved notification preference.
|
||||
|
||||
Why: notification permission prompts are disruptive and should occur only when a working feature can explain immediate value. Capability detection lets the future integration expose environmental readiness without creating side effects or false expectations.
|
||||
|
||||
Next: test preference persistence, user isolation, theme markup, sound behavior, invalid input, authentication, and reminder-placeholder guarantees.
|
||||
|
||||
### Phase 5.4 — Automated coverage
|
||||
|
||||
Added eight tests for authentication, inactive-reminder disclosure, preference persistence across pages, invalid-select rejection, cross-user isolation, Focus Mode chime suppression, service-level validation, and the absence of permission/subscription calls in JavaScript. The full suite now contains 45 passing tests.
|
||||
|
||||
Why: settings must affect consuming pages, not merely save successfully. The tests inspect global HTML attributes and Focus Mode output after persistence. The source-level negative assertion makes the placeholder's no-permission promise enforceable during future development.
|
||||
|
||||
Next: run regression, migration, routing, dependency, Python/JavaScript syntax, line-length, whitespace, and patch checks.
|
||||
|
||||
### Phase 5.5 — Final verification
|
||||
|
||||
Completed the full regression suite, Alembic model comparison, Flask route discovery, Python compilation, JavaScript syntax validation, dependency consistency, line-length and trailing-whitespace scans, and patch-format validation. No migration was required because Phase 1 already established all preference columns.
|
||||
|
||||
Why: personalization affects every rendered page and Focus Mode audio behavior, so the full suite is necessary to detect cross-feature regressions. Confirming zero schema drift proves the settings implementation matches the existing database contract.
|
||||
|
||||
Final results:
|
||||
|
||||
```text
|
||||
45 tests passed in 4.62s
|
||||
Alembic detected no pending model operations.
|
||||
Flask discovered all expected routes, including settings.
|
||||
Python and JavaScript syntax validation completed successfully.
|
||||
No broken Python requirements were found.
|
||||
Line-length, whitespace, and git diff checks completed successfully.
|
||||
```
|
||||
|
||||
## Next Work
|
||||
|
||||
Phase 5 will expose the existing theme, dyslexia-font, completion-chime, and notification preferences through an authenticated settings feature. It will also add the browser-reminder placeholder without requesting notification permission prematurely.
|
||||
Phase 6 is intentionally a future-facing admin placeholder. The next decision should be whether to implement only a safely disabled admin Blueprint and role guard as designed, or defer all admin surface area until concrete user-management requirements exist.
|
||||
|
|
|
|||
153
tests/settings/test_settings.py
Normal file
153
tests/settings/test_settings.py
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.auth.services import create_user
|
||||
from app.settings.services import update_preferences
|
||||
from app.tasks.services import configure_focus, create_task
|
||||
|
||||
|
||||
def login(client, email: str) -> None:
|
||||
client.post(
|
||||
"/auth/login",
|
||||
data={"email": email, "password": "secure password"},
|
||||
)
|
||||
|
||||
|
||||
def settings_data(**overrides):
|
||||
data = {
|
||||
"theme": "auto",
|
||||
"completion_chime": "y",
|
||||
"notification_frequency": "quiet",
|
||||
}
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
|
||||
def test_settings_require_authentication(client):
|
||||
response = client.get("/settings/")
|
||||
|
||||
assert response.status_code == 302
|
||||
assert "/auth/login" in response.headers["Location"]
|
||||
|
||||
|
||||
def test_settings_page_explains_inactive_reminders(client, db):
|
||||
user = create_user("settings_user", "settings@example.com", "secure password")
|
||||
login(client, user.email)
|
||||
|
||||
response = client.get("/settings/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert b"Browser reminders are not active yet" in response.data
|
||||
assert b"Steady has not requested browser permission" in response.data
|
||||
assert b'data-feature-enabled="false"' in response.data
|
||||
|
||||
|
||||
def test_preferences_persist_and_render_on_every_page(client, db):
|
||||
user = create_user("theme_user", "theme@example.com", "secure password")
|
||||
login(client, user.email)
|
||||
|
||||
response = client.post(
|
||||
"/settings/",
|
||||
data=settings_data(
|
||||
theme="dark",
|
||||
dyslexia_font="y",
|
||||
completion_chime="",
|
||||
notification_frequency="moderate",
|
||||
),
|
||||
follow_redirects=True,
|
||||
)
|
||||
db.session.refresh(user)
|
||||
|
||||
assert b"Preferences saved" in response.data
|
||||
assert user.theme == "dark"
|
||||
assert user.dyslexia_font is True
|
||||
assert user.completion_chime is False
|
||||
assert user.notification_frequency == "moderate"
|
||||
assert b'data-theme="dark"' in response.data
|
||||
assert b'data-dyslexia="true"' in response.data
|
||||
|
||||
home = client.get("/")
|
||||
assert b'data-theme="dark"' in home.data
|
||||
assert b'data-dyslexia="true"' in home.data
|
||||
|
||||
|
||||
def test_invalid_select_values_do_not_change_preferences(client, db):
|
||||
user = create_user("invalid_settings", "invalid-settings@example.com", "secure password")
|
||||
login(client, user.email)
|
||||
|
||||
response = client.post(
|
||||
"/settings/",
|
||||
data=settings_data(theme="neon", notification_frequency="constant"),
|
||||
)
|
||||
db.session.refresh(user)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert b"Preferences saved" not in response.data
|
||||
assert user.theme == "auto"
|
||||
assert user.notification_frequency == "quiet"
|
||||
|
||||
|
||||
def test_preferences_are_isolated_between_users(client, db):
|
||||
first = create_user("first_settings", "first-settings@example.com", "secure password")
|
||||
second = create_user("second_settings", "second-settings@example.com", "secure password")
|
||||
login(client, first.email)
|
||||
|
||||
client.post(
|
||||
"/settings/",
|
||||
data=settings_data(theme="light", notification_frequency="alert"),
|
||||
)
|
||||
db.session.refresh(first)
|
||||
db.session.refresh(second)
|
||||
|
||||
assert first.theme == "light"
|
||||
assert first.notification_frequency == "alert"
|
||||
assert second.theme == "auto"
|
||||
assert second.notification_frequency == "quiet"
|
||||
|
||||
|
||||
def test_disabled_chime_reaches_focus_timer_markup(client, db):
|
||||
user = create_user("quiet_user", "quiet@example.com", "secure password")
|
||||
update_preferences(
|
||||
user,
|
||||
theme="auto",
|
||||
dyslexia_font=False,
|
||||
completion_chime=False,
|
||||
notification_frequency="quiet",
|
||||
)
|
||||
task = create_task(
|
||||
user.id,
|
||||
title="Quiet focus task",
|
||||
description=None,
|
||||
status="not_started",
|
||||
priority="normal",
|
||||
due_date=None,
|
||||
)
|
||||
configure_focus(task, timer_minutes=5, context=None, chunk_minutes=[])
|
||||
login(client, user.email)
|
||||
|
||||
response = client.get(f"/tasks/{task.id}/focus")
|
||||
|
||||
assert b'data-chime="false"' in response.data
|
||||
|
||||
|
||||
def test_settings_service_rejects_unknown_values_without_commit(db):
|
||||
user = create_user("service_user", "service@example.com", "secure password")
|
||||
|
||||
with pytest.raises(ValueError, match="Unknown theme"):
|
||||
update_preferences(
|
||||
user,
|
||||
theme="neon",
|
||||
dyslexia_font=False,
|
||||
completion_chime=True,
|
||||
notification_frequency="quiet",
|
||||
)
|
||||
|
||||
assert user.theme == "auto"
|
||||
|
||||
|
||||
def test_reminder_placeholder_never_requests_permission():
|
||||
script = Path("app/static/js/main.js").read_text(encoding="utf-8")
|
||||
|
||||
assert "requestPermission" not in script
|
||||
assert ".subscribe(" not in script
|
||||
Loading…
Add table
Reference in a new issue