diff --git a/CLAUDE.md b/CLAUDE.md index 7424096..676165b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -166,6 +166,7 @@ part of the tree — see §7. Device registration on the API side lives in | `GROQ_MODEL` | Optional. Groq model ID. Defaults to `llama-3.3-70b-versatile`. | | `ENROLLMENT_NOTIFY_EMAILS` | Optional. Comma-separated extra addresses alerted on a new enrollment, **in addition to** every active `admin` account. For people who should be told but hold no JQC login. | | `ENROLLMENT_DIR` | Optional. Directory for enrollment-form JSON submissions. Defaults to `/enrollments` (git-ignored). Created at boot. | +| `DEFAULT_UI_THEME` | Optional, default `modern` (phase50). The design shown when a user has no stored preference — i.e. new accounts and unauthenticated pages. A stored `users.ui_theme` always wins. Set `classic` to revert the default **without** touching anyone's saved choice. | | `COMMENTS_VISIBLE_TO_ALL` | Optional, default `true`. **TEMPORARY (Aug 2026).** When true, customers see *every* comment on an issue, not only those ticked "Share with customer". Set `false` to restore the phase22 staff-only filtering — `is_customer_visible` is still written on every comment, so the revert needs no data repair. | | `PHOTO_STAMP_ENABLED` | Optional, default `true`. Burns a capture-time + geo overlay into photos uploaded via `POST /api/v1/photos/upload`. Set `false` to store raw uploads. | @@ -908,7 +909,22 @@ phase1_projects_roles → phase6_features → phase7_mobile_api → phase8_notif → phase46_followup_req_by → phase47_sched_acknowledged → phase48_user_ui_theme - → phase49_external_inspector ← HEAD + → phase49_external_inspector + → phase50_default_modern ← HEAD + +#### phase50 — modern design becomes the default + +Revision id `phase50_default_modern`. Promotes the phase48 A/B-test design to the default. Two changes, **both required**: the `users.ui_theme` column default becomes `'modern'` (new accounts), and existing rows are moved `'classic'` → `'modern'`. phase48 stored a literal `'classic'` for everyone rather than NULL, so a default change alone would leave every current user on the old design. + +**It overwrites a deliberate choice** — phase48 gave no way to tell "I picked classic" from "I never touched it", so anyone who actively preferred classic is moved too. They can switch back from the account menu and that choice then sticks; the switcher is unchanged. `/ui/theme-votes` reads the same column, so the tally reads 100% modern afterwards — capture it first if the numbers matter. `downgrade()` returns *everyone* to classic (individual prior choices were never recorded). + +To change the default without touching saved preferences, set `DEFAULT_UI_THEME=classic` instead of running this. + +**Deploy order:** +```bash +flask db upgrade +sudo systemctl restart gunicorn +``` #### phase49 — External Inspector role diff --git a/app/__init__.py b/app/__init__.py index f51640f..020f975 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -167,22 +167,28 @@ def create_app(config_name='default'): # The mobile API renders no templates and authenticates by JWT — skip it # so this never touches the Flask-Login session loader on API traffic. if request.path.startswith('/api/'): + # The API renders no templates; 'classic' here only means "never + # rewrite a template name" (see ThemedEnvironment.get_template). g.jqc_theme = 'classic' return from flask_login import current_user as _cu - theme = 'classic' + # phase50 — the fallback is configurable and now defaults to 'modern'. + # A stored users.ui_theme still wins, so an explicit choice is kept. + default = app.config.get('DEFAULT_UI_THEME', 'modern') + theme = default try: if _cu.is_authenticated: - theme = _cu.ui_theme or 'classic' + theme = _cu.ui_theme or default except Exception: # DB column missing (migration not yet run) - theme = 'classic' - g.jqc_theme = theme if theme in ('classic', 'modern') else 'classic' + theme = default + g.jqc_theme = theme if theme in ('classic', 'modern') else default @app.context_processor def inject_ui_theme(): """Give base.html the shell to extend.""" from app.utils.time_utils import now_eastern - theme = getattr(g, 'jqc_theme', 'classic') + theme = getattr(g, 'jqc_theme', + app.config.get('DEFAULT_UI_THEME', 'modern')) _now = now_eastern() return { 'jqc_theme': theme, diff --git a/app/models/user.py b/app/models/user.py index 4048cbe..19003f5 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -59,8 +59,10 @@ class User(UserMixin, db.Model): # Drives base.html's layout dispatch via the inject_ui_theme() context # processor. Persisted per user so the choice survives logout and can be # tallied as a vote (see /ui/theme-votes). + # phase50 — 'modern' is the default for new accounts. Existing rows were + # migrated in phase50; anyone who switches keeps their own choice. ui_theme = db.Column(db.String(16), nullable=False, - server_default='classic', default='classic') + server_default='modern', default='modern') # ── Customer password-setup workflow ────────────────────────────────── # password_set: False for newly created customer accounts until they diff --git a/app/routes/ui.py b/app/routes/ui.py index 5e356b8..8f3d6b1 100644 --- a/app/routes/ui.py +++ b/app/routes/ui.py @@ -16,7 +16,7 @@ layout shell base.html extends. import logging from flask import (Blueprint, render_template, redirect, request, - url_for, flash) + url_for, flash, current_app) from flask_login import login_required, current_user from sqlalchemy import func @@ -51,7 +51,8 @@ def switch_theme(): flash('Unknown design option.', 'warning') return redirect(_safe_next(request.form.get('next'))) - previous = current_user.ui_theme or 'classic' + previous = current_user.ui_theme or current_app.config.get( + 'DEFAULT_UI_THEME', 'modern') if previous != theme: current_user.ui_theme = theme db.session.commit() diff --git a/config.py b/config.py index 1f0e68d..911605d 100644 --- a/config.py +++ b/config.py @@ -58,6 +58,16 @@ class Config: # bar into the image before storing it. Set false to store raw uploads. PHOTO_STAMP_ENABLED = os.environ.get('PHOTO_STAMP_ENABLED', 'true').lower() == 'true' + # ── Web portal design (phase50 — Aug 2026) ─────────────────────────────── + # The design a user sees when they have never chosen one. phase48 shipped + # the modern design as an opt-in A/B test with 'classic' as the default; + # phase50 promotes 'modern' to the default now that the test is settled. + # + # This is the FALLBACK only — a stored users.ui_theme always wins, so anyone + # who switches keeps their choice. Set DEFAULT_UI_THEME=classic to revert + # the default without touching a single saved preference. + DEFAULT_UI_THEME = os.environ.get('DEFAULT_UI_THEME', 'modern') + # ── Issue comment visibility (TEMPORARY — Aug 2026) ────────────────────── # True = every comment on an issue is visible to everyone, customers # included; the per-comment is_customer_visible flag is ignored diff --git a/migrations/versions/phase50_default_modern_theme.py b/migrations/versions/phase50_default_modern_theme.py new file mode 100644 index 0000000..7d8f9ba --- /dev/null +++ b/migrations/versions/phase50_default_modern_theme.py @@ -0,0 +1,57 @@ +"""phase50 — make the modern design the default + +phase48 shipped the modern design as an opt-in A/B test: `users.ui_theme` +defaulted to 'classic' and users switched themselves over. The test is settled, +so this promotes 'modern' to the default. + +Two separate things have to change, and BOTH are needed: + +1. The column default, so NEW accounts start on modern. +2. The existing rows. Every account created under phase48 has a literal + 'classic' stored — not a NULL — so a default change alone would leave every + current user on the old design and the switch would look like it did nothing. + +**This overwrites a deliberate choice.** phase48 gave no way to distinguish "I +picked classic" from "I never touched it", so anyone who actively preferred +classic is moved too. They can switch straight back from the account menu, and +that choice will then stick — nothing here removes the switcher. + +`/ui/theme-votes` reads this same column, so the A/B tally reads 100% modern +once this runs. Capture it before deploying if the numbers still matter. + +To change the default WITHOUT touching saved preferences, set +DEFAULT_UI_THEME=classic in the environment instead of running this. +""" + +revision = 'phase50_default_modern' +down_revision = 'phase49_external_inspector' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + # 1. New accounts start on modern. + op.execute(sa.text( + "ALTER TABLE users MODIFY COLUMN ui_theme VARCHAR(16) " + "NOT NULL DEFAULT 'modern'" + )) + # 2. Move everyone still on the phase48 default. Idempotent: re-running + # matches nothing once every row is 'modern'. + op.execute(sa.text( + "UPDATE users SET ui_theme = 'modern' WHERE ui_theme = 'classic'" + )) + + +def downgrade(): + op.execute(sa.text( + "ALTER TABLE users MODIFY COLUMN ui_theme VARCHAR(16) " + "NOT NULL DEFAULT 'classic'" + )) + # Individual pre-upgrade choices were not recorded, so this returns + # EVERYONE to classic rather than restoring who had what. + op.execute(sa.text( + "UPDATE users SET ui_theme = 'classic' WHERE ui_theme = 'modern'" + ))