Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions tests/e2e/test_onboarding_wizard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"""Overnight B3 e2e — a fresh admin completes the first-run wizard.

signup → /a/onboarding shows 0/4 → invite → create event → solve →
publish, with the wizard's progress advancing at each step and ending
in the complete state.
"""

from __future__ import annotations

import pytest

from tests.e2e._helpers import accept_invitation, invite_token, no_js_errors, rid, signup_admin

pytestmark = pytest.mark.e2e


def _progress(page, base, text):
page.goto(f"{base}/a/onboarding")
page.wait_for_selector(f"#onboarding-progress:has-text('{text}')")


def test_fresh_admin_completes_wizard(live_server, new_context, page, db_path):
base = live_server
vol_email = f"vol+{rid()}@hope.e2e"

signup_admin(page, base)

# Fresh org — nothing done yet.
_progress(page, base, "0 of 4 done")

# 1) Invite a teammate.
page.goto(f"{base}/a/people")
page.click("button:has-text('Invite person')")
page.fill("#inv_name", "Jamie Park")
page.fill("#inv_email", vol_email)
page.select_option("#inv_role", "volunteer")
page.click("button:has-text('Send invite')")
page.wait_for_selector("#invite-result:has-text('Invitation sent')")
accept_invitation(new_context(), base, invite_token(db_path, vol_email))
_progress(page, base, "1 of 4 done")

# 2) Create an event.
page.goto(f"{base}/a/events")
page.click("button:has-text('New event')")
page.wait_for_selector("#ev_type", state="visible")
page.fill("#ev_type", "Sunday 10am Service")
page.fill("#ev_date", "2026-06-07")
page.fill("#ev_start", "10:00")
page.fill("#ev_end", "11:30")
page.fill("input[name=role_name]", "volunteer")
page.fill("input[name=role_count]", "1")
page.click("button:has-text('Create event')")
page.wait_for_selector("#events-list:has-text('Sunday 10am Service')")
_progress(page, base, "2 of 4 done")

# 3) Generate a schedule, 4) review and publish it — one uninterrupted
# sequence (the solver page only holds the result until you navigate
# away, so the review link must be clicked without leaving).
page.goto(f"{base}/a/solver")
page.fill("#from_date", "2026-05-19")
page.fill("#to_date", "2026-06-30")
page.click("button:has-text('Run solver')")
page.wait_for_selector("#solver-result:has-text('Review solution')")
page.click("a:has-text('Review solution')")
page.wait_for_url("**/a/solution/**")
page.wait_for_selector("#publish-state")
page.click("button:has-text('Publish this solution')")
page.wait_for_selector("#publish-state:has-text('Unpublish')")

# Wizard is complete.
page.goto(f"{base}/a/onboarding")
page.wait_for_selector("#onboarding-complete")
page.wait_for_selector("#onboarding-progress:has-text('4 of 4 done')")

no_js_errors(page)
109 changes: 109 additions & 0 deletions tests/web/test_onboarding_wizard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
"""Overnight B3 — first-run onboarding wizard."""

from __future__ import annotations

from datetime import datetime, timedelta

from api.models import Event, Invitation, OnboardingProgress, Solution
from tests.web.conftest import seed_person
from web.deps import SESSION_COOKIE


def _admin(client, db, *, org="ob_o", pid="ob_admin", email="admin@ob.test"):
seed_person(db, person_id=pid, org_id=org, email=email, roles=["admin"])
r = client.post("/auth/login", data={"email": email, "password": "WebPass123!"})
return r.cookies[SESSION_COOKIE]


def _invite(db, org, by, *, iid, token):
db.add(
Invitation(
id=iid,
org_id=org,
email=f"{iid}@ob.test",
name="Invitee",
roles=["volunteer"],
invited_by=by,
token=token,
expires_at=datetime.utcnow() + timedelta(days=7),
)
)
db.commit()


def test_onboarding_requires_auth(client):
assert client.get("/a/onboarding").status_code == 303


def test_fresh_admin_sees_zero_progress(client, db):
tok = _admin(client, db)
r = client.get("/a/onboarding", cookies={SESSION_COOKIE: tok})
assert r.status_code == 200
assert "0 of 4 done" in r.text
row = (
db.query(OnboardingProgress)
.filter(
OnboardingProgress.person_id == "ob_admin",
OnboardingProgress.org_id == "ob_o",
)
.first()
)
assert row is not None and row.wizard_step_completed == 0


def test_partial_progress_and_dashboard_banner(client, db):
tok = _admin(client, db, org="ob_o3", pid="ob_a3", email="a3@ob.test")
_invite(db, "ob_o3", "ob_a3", iid="inv3", token="tk3")
r = client.get("/a/onboarding", cookies={SESSION_COOKIE: tok})
assert "1 of 4 done" in r.text
d = client.get("/a/dashboard", cookies={SESSION_COOKIE: tok})
assert 'id="onboarding-banner"' in d.text and "1/4" in d.text


def test_full_progress_marks_complete(client, db):
tok = _admin(client, db, org="ob_o2", pid="ob_a2", email="a2@ob.test")
_invite(db, "ob_o2", "ob_a2", iid="inv2", token="tk2")
start = datetime.utcnow() + timedelta(days=3)
db.add(
Event(
id="ev1",
org_id="ob_o2",
type="Svc",
start_time=start,
end_time=start + timedelta(hours=1),
)
)
db.add(
Solution(
org_id="ob_o2",
hard_violations=0,
soft_score=1.0,
health_score=90.0,
is_published=True,
published_at=datetime.utcnow(),
)
)
db.commit()
r = client.get("/a/onboarding", cookies={SESSION_COOKIE: tok})
assert "4 of 4 done" in r.text
assert 'id="onboarding-complete"' in r.text
# complete → no dashboard nudge.
d = client.get("/a/dashboard", cookies={SESSION_COOKIE: tok})
assert 'id="onboarding-banner"' not in d.text


def test_skip_persists_and_hides_banner(client, db):
tok = _admin(client, db, org="ob_o4", pid="ob_a4", email="a4@ob.test")
r = client.post("/a/onboarding/skip", cookies={SESSION_COOKIE: tok})
assert r.status_code == 303 and r.headers["location"] == "/a/dashboard"
row = (
db.query(OnboardingProgress)
.filter(
OnboardingProgress.person_id == "ob_a4",
OnboardingProgress.org_id == "ob_o4",
)
.first()
)
assert row.onboarding_skipped is True
d = client.get("/a/dashboard", cookies={SESSION_COOKIE: tok})
assert 'id="onboarding-banner"' not in d.text
123 changes: 123 additions & 0 deletions web/routers/pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -501,10 +501,133 @@ def admin_dashboard(
"person": person,
"active_tab": "dashboard",
"kpis": _dashboard_kpis(db, person.org_id),
"ob": _onboarding_state(db, person),
},
)


ONBOARDING_TOTAL = 4


def _onboarding_state(db: Session, person: Person) -> dict:
"""Derive the 4-step first-run checklist from real org data and
persist the count to onboarding_progress. Steps are independent
(a checklist, not a gated wizard) so progress survives any order."""
from api.models import Invitation, OnboardingProgress, Solution

org_id = person.org_id
people_n = db.query(Person).filter(Person.org_id == org_id).count()
invites_n = db.query(Invitation).filter(Invitation.org_id == org_id).count()
events_n = db.query(Event).filter(Event.org_id == org_id).count()
sols_n = db.query(Solution).filter(Solution.org_id == org_id).count()
pub_n = (
db.query(Solution)
.filter(Solution.org_id == org_id, Solution.is_published.is_(True))
.count()
)

steps = [
{
"key": "invite",
"title": "Invite a teammate",
"desc": "Add the people you schedule.",
"href": "/a/people",
"cta": "Invite people",
"done": invites_n > 0 or people_n > 1,
},
{
"key": "event",
"title": "Create an event",
"desc": "Add something that needs volunteers.",
"href": "/a/events",
"cta": "Create event",
"done": events_n > 0,
},
{
"key": "solve",
"title": "Generate a schedule",
"desc": "Let the solver build a fair roster.",
"href": "/a/solver",
"cta": "Run solver",
"done": sols_n > 0,
},
{
"key": "publish",
"title": "Publish it",
"desc": "Share the schedule with volunteers.",
"href": "/a/solver",
"cta": "Publish",
"done": pub_n > 0,
},
]
done_n = sum(1 for s in steps if s["done"])

row = (
db.query(OnboardingProgress)
.filter(
OnboardingProgress.person_id == person.id,
OnboardingProgress.org_id == org_id,
)
.first()
)
if row is None:
row = OnboardingProgress(person_id=person.id, org_id=org_id)
db.add(row)
if row.wizard_step_completed != done_n:
row.wizard_step_completed = done_n
db.commit()

return {
"steps": steps,
"done": done_n,
"total": ONBOARDING_TOTAL,
"complete": done_n >= ONBOARDING_TOTAL,
"skipped": bool(row.onboarding_skipped),
}


@router.get("/a/onboarding", response_class=HTMLResponse)
def admin_onboarding(
request: Request,
person: Person = Depends(get_session_admin),
db: Session = Depends(get_db),
):
from web.app import templates

return templates.TemplateResponse(
request,
"admin/onboarding.html",
{
"person": person,
"active_tab": "dashboard",
"ob": _onboarding_state(db, person),
},
)


@router.post("/a/onboarding/skip")
def admin_onboarding_skip(
person: Person = Depends(get_session_admin),
db: Session = Depends(get_db),
):
from api.models import OnboardingProgress

row = (
db.query(OnboardingProgress)
.filter(
OnboardingProgress.person_id == person.id,
OnboardingProgress.org_id == person.org_id,
)
.first()
)
if row is None:
row = OnboardingProgress(person_id=person.id, org_id=person.org_id)
db.add(row)
row.onboarding_skipped = True
db.commit()
return RedirectResponse(url="/a/dashboard", status_code=303)


def _org_settings(db: Session, org_id: str) -> dict:
"""Current org settings for the form (timezone lives in config)."""
from api.routers.organizations import get_organization
Expand Down
8 changes: 8 additions & 0 deletions web/templates/admin/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@
</div>
<div class="page-title">Dashboard</div>
<div class="scroll">
{% if ob and not ob.skipped and not ob.complete %}
<a class="card" href="/a/onboarding" id="onboarding-banner"
style="display:block;border-color:var(--accent)">
<div class="row-title">Finish setting up — {{ ob.done }}/{{ ob.total }}</div>
<div class="row-sub">Invite people, create an event, build &amp; publish a schedule.</div>
</a>
<div class="spacer-12"></div>
{% endif %}
<div class="kpi-grid">
<div class="kpi">
<div class="kpi-value">{{ kpis.active_volunteers }}<span class="unit">/ {{ kpis.total_volunteers }}</span></div>
Expand Down
63 changes: 63 additions & 0 deletions web/templates/admin/onboarding.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
{% extends "base.html" %}
{% block title %}Get started · SignUpFlow{% endblock %}
{% block body %}
<div class="nav">
<a class="nav-back" href="/a/dashboard">‹ Dashboard</a>
<form method="post" action="/auth/logout" style="margin:0">
<button class="nav-action" type="submit">Sign out</button>
</form>
</div>
<div class="page-title">Get started</div>
<div class="scroll" id="onboarding-wizard">
<div class="card">
<div class="row-title" id="onboarding-progress">{{ ob.done }} of {{ ob.total }} done</div>
<div class="row-sub">
{% if ob.complete %}
Your organization is set up and a schedule is live.
{% else %}
A few steps to a published schedule. Do them in any order.
{% endif %}
</div>
</div>

{% if ob.complete %}
<div class="spacer-12"></div>
<div class="card" id="onboarding-complete" style="border-color:var(--accent)">
<div class="row-title">You're all set 🎉</div>
<div class="row-sub">Volunteers can now see their published shifts.</div>
<div class="spacer-12"></div>
<a class="btn btn-primary" href="/a/dashboard">Go to dashboard</a>
</div>
{% else %}

<div class="group">
{% for s in ob.steps %}
<div class="row ob-step" data-key="{{ s.key }}" data-done="{{ 'yes' if s.done else 'no' }}">
<div class="row-main">
<div class="row-title">
<span class="ob-mark">{% if s.done %}✓{% else %}○{% endif %}</span>
{{ s.title }}
</div>
<div class="row-sub">{{ s.desc }}</div>
</div>
{% if not s.done %}
<a class="btn btn-secondary" href="{{ s.href }}">{{ s.cta }}</a>
{% endif %}
</div>
{% endfor %}
</div>

<div class="spacer-12"></div>
<form method="post" action="/a/onboarding/skip" style="margin:0">
<button class="btn btn-secondary" type="submit">Skip for now</button>
</form>
{% endif %}
</div>
{% set tabs = [
('/a/dashboard', '▣', 'Dashboard', 'dashboard'),
('/a/people', '◔', 'People', 'people'),
('/a/events', '▤', 'Events', 'events'),
('/a/solver', '◈', 'Solver', 'solver'),
] %}
{% include "_nav.html" %}
{% endblock %}
Loading