Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
92 commits
Select commit Hold shift + click to select a range
44e9cdc
feat(tournaments): add TournamentForm companion model and onboarding …
ethnjs Aug 26, 2026
74c74ac
feat(onboarding): add tournament onboarding-forms routes for adding, …
ethnjs Aug 26, 2026
b465b22
refactor(onboarding): make tournament_forms.form_id the primary key a…
ethnjs Aug 26, 2026
c44145a
fix(onboarding): require published forms and validate reorder sequence
ethnjs Aug 26, 2026
b5969fb
feat(onboarding): add member progress and completion endpoint
ethnjs Aug 26, 2026
34743ae
test(onboarding): cover configuration and member progress flow
ethnjs Aug 26, 2026
6da3cca
feat(forms): add tournament onboarding configuration page
ethnjs Aug 26, 2026
46fda87
refactor(forms): move shared header and tabs into layout
ethnjs Aug 26, 2026
a1688f9
feat(onboarding): add drag-and-drop onboarding forms config page
ethnjs Aug 26, 2026
e2fc96b
refactor(onboarding): refine form sequence controls
ethnjs Aug 26, 2026
ccf0bf7
feat(onboarding): route tournament joins through form flow
ethnjs Aug 26, 2026
eec0baa
refactor(onboarding): consolidate client API methods
ethnjs Aug 26, 2026
4129d6b
refactor(forms): add reusable respondent view route
ethnjs Aug 26, 2026
bbe8fbf
refactor(forms): share floating action bar behavior
ethnjs Aug 26, 2026
3398c3d
feat(forms): open tournament form editors in new tabs
ethnjs Aug 26, 2026
841dbf7
feat(forms): keep final form question clear of submit bar
ethnjs Aug 26, 2026
c13243b
feat(forms): add reversible form publishing and archiving lifecycle
ethnjs Aug 26, 2026
5151b35
refactor(forms): consolidate lifecycle transitions in patch route
ethnjs Aug 26, 2026
04435b2
feat(forms): add tournament form prerequisite evaluation
ethnjs Aug 26, 2026
1c88b82
refactor(migrations): squash tournament form prerequisites
ethnjs Aug 26, 2026
26dec19
feat(forms): add prerequisite management API
ethnjs Aug 26, 2026
7444515
feat(forms): surface eligible member forms
ethnjs Aug 26, 2026
ed93ff3
style(forms): refine overview form card
ethnjs Aug 26, 2026
ec18fa8
feat(tracks): add tournament track catalog model
ethnjs Aug 26, 2026
7e7d33e
feat(tracks): add tournament track management API
ethnjs Aug 26, 2026
20f9449
feat(tracks): add tournament track settings
ethnjs Aug 26, 2026
1be5092
style(tracks): match track settings page to invites layout and add po…
ethnjs Aug 26, 2026
e2fafc4
refactor(tracks): use shared EditableText, status badge, and icon-onl…
ethnjs Aug 26, 2026
e43bdb7
refactor(invites): use shared EditableText component for invite label…
ethnjs Aug 26, 2026
935b68c
refactor(forms): replace preset buttons with dropdown
ethnjs Aug 26, 2026
0e659b4
feat(forms): validate track outcome fields
ethnjs Aug 26, 2026
2e456cc
refactor(forms): store track outcomes in option values
ethnjs Aug 26, 2026
eaaeb39
feat(forms): inject track name on standard form GET
ethnjs Aug 26, 2026
d98cbef
feat(forms): add track outcome controls to the form editor
ethnjs Aug 26, 2026
080f97e
feat(forms): allow availability forms to store track outcomes
ethnjs Aug 26, 2026
d7ef677
feat(forms): preserve form options when changing presets
ethnjs Aug 26, 2026
3253148
feat(forms): default preset dropdown to empty with a clear button ins…
ethnjs Aug 26, 2026
e1fe17b
style(forms): move option row delete button to the far right edge
ethnjs Aug 26, 2026
b94f67c
feat(forms): render track chip status as a colored pill menu instead …
ethnjs Aug 27, 2026
6e47d76
chore(forms): update field key info hint
ethnjs Aug 27, 2026
9e2f011
fix(forms): honor submitted field_key on edit and don't read entity i…
ethnjs Aug 27, 2026
1b4804d
fix(forms): keep option rows and branching when clearing a preset
ethnjs Aug 27, 2026
57c52ac
fix(forms): share one track status assignment extractor to fix 500 on…
ethnjs Aug 27, 2026
865a805
fix(forms): hide archived options from the editor and respondent view
ethnjs Aug 27, 2026
bff76e6
docs(forms): add form edit lifecycle spec and preset config shapes
ethnjs Aug 27, 2026
27e0f22
docs(forms): spec gated response patching and pending update lifecycle
ethnjs Aug 27, 2026
e697f98
docs(forms): document pending update reasons and storage model
ethnjs Aug 27, 2026
9037ca0
feat(forms): record answer semantics and key pending updates on field_id
ethnjs Aug 27, 2026
e8da775
test(forms): fix view access fixture to use an available form
ethnjs Aug 27, 2026
515e0f5
docs(forms): note that archived field keys are now reusable
ethnjs Aug 27, 2026
6c1d60d
feat(forms): edit fields in place and release archived field keys
ethnjs Aug 27, 2026
4a0585f
feat(forms): classify field edits and flag only the responders each c…
ethnjs Aug 27, 2026
eefe60f
refactor(forms): squash edit lifecycle migrations into one revision
ethnjs Aug 27, 2026
b93d2eb
feat(forms): restrict response edits to flagged questions via a gated…
ethnjs Aug 28, 2026
ce7f349
fix(forms): scope availability write-through to the days a submission…
ethnjs Aug 28, 2026
8d5be4a
feat(forms): flag regrouped preset options and pin availability shift…
ethnjs Aug 28, 2026
ab24be5
docs(forms): document day-scoped availability write-through and optio…
ethnjs Aug 28, 2026
f4b0f6c
feat(forms): add archive, unarchive, and invalidate verbs for fields …
ethnjs Aug 28, 2026
65d2010
feat(forms): expose pending updates and the new lifecycle routes to t…
ethnjs Aug 28, 2026
a6adbed
feat(forms): add an archived questions section with restore and perma…
ethnjs Aug 28, 2026
b631cba
feat(forms): confirm which edits send questions back to previous resp…
ethnjs Aug 28, 2026
f2490c8
fix(forms): route previous responders to a patch flow instead of a fa…
ethnjs Aug 28, 2026
625d6ed
fix(forms): recover from staged fields whose rows were deleted elsewhere
ethnjs Aug 28, 2026
84abf39
fix(forms): only require unique option values on freeform questions
ethnjs Aug 28, 2026
5ead41f
feat(forms): let TDs archive an option instead of removing it
ethnjs Aug 28, 2026
6f3df2e
feat(forms): show archived fields immediately and use archive/unarchi…
ethnjs Aug 28, 2026
29b78c3
perf(forms): fetch the track catalog once per field instead of once p…
ethnjs Aug 28, 2026
99304c3
fix(forms): type the scroll timer handle as the browser's numeric one
ethnjs Aug 28, 2026
1b4ab8e
refactor(forms): classify edits server-side instead of mirroring the …
ethnjs Aug 28, 2026
451cc7f
fix(forms): read shift ids off the opted-in availability option shape
ethnjs Aug 28, 2026
03c94ea
feat(tracks): add the membership track status write-through table
ethnjs Aug 28, 2026
2c3fcab
feat(tracks): sync track statuses under a monotonic transition rule
ethnjs Aug 28, 2026
557537f
refactor(members): drop membership status in favor of per-track statuses
ethnjs Aug 28, 2026
784227a
feat(forms): write track statuses through on submit and patch
ethnjs Aug 28, 2026
d947e91
feat(tracks): expose member track statuses on membership reads
ethnjs Aug 29, 2026
8061c29
fix(db): make alembic the source of truth for schema on startup
ethnjs Aug 29, 2026
6967901
docs(forms): document track status write-through
ethnjs Aug 29, 2026
7e148a9
refactor(members): show track statuses as a list beside roles
ethnjs Aug 29, 2026
a0c5050
feat(forms): enforce allow_duplicates on ranked-choice submissions
ethnjs Aug 29, 2026
2158483
feat(forms): validate event_preference options strictly
ethnjs Aug 29, 2026
7d3420a
feat(forms): hide duplicate-ranks toggle for event_preference fields
ethnjs Aug 29, 2026
eec7e6b
feat(db): add tournament_membership_event_preferences table
ethnjs Aug 29, 2026
a9f50a0
feat(forms): write through event_preference answers to structural table
ethnjs Aug 29, 2026
78cd2a0
fix(forms): invalidate event_preference fields to clear their write-t…
ethnjs Aug 29, 2026
639d6cb
feat(memberships): expose grouped event preferences on membership reads
ethnjs Aug 29, 2026
03250fc
docs(forms): document event_preference write-through and allow_duplic…
ethnjs Aug 29, 2026
5647dfb
fix(dev): drop non-ascii checkmarks from seed prints so startup survi…
ethnjs Aug 30, 2026
0c9d4d3
feat(dev): give each git worktree its own database via post-checkout …
ethnjs Aug 30, 2026
0345bc9
fix(dev): make SQL echo opt-in and stop alembic fileConfig disabling …
ethnjs Aug 30, 2026
2d8f0e3
chore: ignore .claude/settings.local.json
ethnjs Aug 30, 2026
19055d6
fix(onboarding): require every profile field except pronouns
ethnjs Aug 29, 2026
5cb197a
fix(onboarding): require every newly-mandatory profile field, no skip…
ethnjs Aug 29, 2026
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
5 changes: 5 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Shell scripts must keep LF endings. Git's autocrlf would otherwise check them
# out with CRLF on Windows, and bash fails on the shebang:
# bad interpreter: /usr/bin/env bash^M
*.sh text eol=lf
.githooks/* text eol=lf
12 changes: 12 additions & 0 deletions .githooks/post-checkout
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# Runs after `git worktree add` and after every branch checkout.
#
# $3 is 1 for a branch checkout, 0 for a file checkout (`git checkout -- path`).
# Only the former can land us in a new worktree.
[[ ${3:-0} == 1 ]] || exit 0

root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
[[ -f "$root/scripts/setup-worktree-db.sh" ]] || exit 0

# Never fail a checkout over dev-environment setup.
bash "$root/scripts/setup-worktree-db.sh" || true
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@

# Claude
CLAUDE_OUTPUT_BEHAVIOR.md
.claude/settings.local.json

# Misc
password.txt
docs/
CLAUDE.md
CLAUDE.md
TASK.md
7 changes: 5 additions & 2 deletions backend/alembic/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,12 @@
# Alembic Config object — provides access to alembic.ini values
config = context.config

# Wire up Python logging from alembic.ini
# Wire up Python logging from alembic.ini.
# disable_existing_loggers must be False: init_db() runs this inside the app's
# startup lifespan, and the default (True) would disable every logger not named
# in alembic.ini — including uvicorn.access, silencing the request log.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
fileConfig(config.config_file_name, disable_existing_loggers=False)

# Override sqlalchemy.url with value from .env
config.set_main_option("sqlalchemy.url", settings.database_url)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""add tournament membership track statuses

Revision ID: 066d0807d514
Revises: d8c1e4c9b52d
Create Date: 2026-08-28 14:16:15.320805

Write-through target for track_status_* answers (and opted-in availability_*
answers). One row per (membership, track).

Autogenerate also picked up pre-existing drift between the models and the dev
database — a dropped ix_forms_id index and a batch of created_at/updated_at
nullability flips on unrelated tables. Those are not this change and were
stripped out; they need their own revision if they're real.
"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa

# revision identifiers, used by Alembic.
revision: str = '066d0807d514'
down_revision: Union[str, None] = 'd8c1e4c9b52d'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.create_table(
'tournament_membership_track_statuses',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('membership_id', sa.Integer(), nullable=False),
sa.Column('track_id', sa.Integer(), nullable=False),
sa.Column('status', sa.String(length=32), nullable=False),
sa.Column('source_response_id', sa.String(length=12), nullable=True),
sa.Column('source_field_id', sa.String(length=12), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['membership_id'], ['tournament_memberships.id'], ondelete='CASCADE'),
# SET NULL, not CASCADE: invalidating the question that set a status
# must not delete the status. See backend/form-edit-lifecycle.md.
sa.ForeignKeyConstraint(['source_field_id'], ['form_fields.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['source_response_id'], ['form_responses.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['track_id'], ['tournament_tracks.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('membership_id', 'track_id', name='uq_membership_track_status'),
)
op.create_index(
op.f('ix_tournament_membership_track_statuses_id'),
'tournament_membership_track_statuses', ['id'], unique=False,
)


def downgrade() -> None:
op.drop_index(
op.f('ix_tournament_membership_track_statuses_id'),
table_name='tournament_membership_track_statuses',
)
op.drop_table('tournament_membership_track_statuses')
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"""tournament forms, onboarding, prerequisites, and tracks

Revision ID: 8d55ec2b6640
Revises: 7db31ae17e3c
Create Date: 2026-08-25 00:00:00.000000

Adds tournament_forms — a 1:1 companion row every tournament-scoped Form
gets (owner_type == "tournament"; chapter forms never get one). is_onboarding
+ order (order only meaningful for is_onboarding=True rows) drive the
onboarding step sequence for a tournament. See the TournamentForm model
docstring in app/models/models.py for the full design.

form_id is the table's own primary key, not a separate surrogate id — the
relationship is strictly 1:1, so there's no "which TournamentForm" beyond
"which Form." Deleting a Form cascades away its TournamentForm row for free.

Also adds tournament_memberships.onboarded_at, set once a member has
answered every currently-onboarding-flagged published form, plus the
TournamentForm.prerequisites JSON configuration used by standard form
visibility rules and the tournament_tracks catalog used by later track fields.

Backfills a tournament_forms row for every existing forms row that already
has a tournament_id, so the 1:1 invariant holds for pre-existing data too.
"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa

# revision identifiers, used by Alembic.
revision: str = '8d55ec2b6640'
down_revision: Union[str, None] = '7db31ae17e3c'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.create_table('tournament_forms',
sa.Column('form_id', sa.String(length=12), nullable=False),
sa.Column('tournament_id', sa.Integer(), nullable=False),
sa.Column('is_onboarding', sa.Boolean(), nullable=False),
sa.Column('order', sa.Integer(), nullable=True),
sa.Column('prerequisites', sa.JSON(), nullable=False, server_default=sa.text("'{}'::json")),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['form_id'], ['forms.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['tournament_id'], ['tournaments.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('form_id')
)

op.add_column('tournament_memberships', sa.Column('onboarded_at', sa.DateTime(timezone=True), nullable=True))

op.create_table(
'tournament_tracks',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('tournament_id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('is_archived', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['tournament_id'], ['tournaments.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('tournament_id', 'name', name='uq_tournament_track_name'),
)
op.create_index(op.f('ix_tournament_tracks_id'), 'tournament_tracks', ['id'], unique=False)

# Backfill: every pre-existing tournament-owned form needs its
# companion row too, or the 1:1 invariant is broken from day one.
op.execute(
"""
INSERT INTO tournament_forms (form_id, tournament_id, is_onboarding, created_at)
SELECT id, tournament_id, false, now()
FROM forms
WHERE tournament_id IS NOT NULL
"""
)
op.alter_column('tournament_forms', 'prerequisites', server_default=None)


def downgrade() -> None:
op.drop_index(op.f('ix_tournament_tracks_id'), table_name='tournament_tracks')
op.drop_table('tournament_tracks')
op.drop_column('tournament_memberships', 'onboarded_at')
op.drop_table('tournament_forms')
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""add tournament membership event preferences

Revision ID: 8e43330211be
Revises: a3f81c60e274
Create Date: 2026-08-28 00:00:00.000000

Write-through target for event_preference_* answers. One row per
(membership, key, event) — an event can only appear in one option per field
(see validate_event_preference_options) and a ranked_choice event_preference
field is required to have allow_duplicates=false, so rank is stored per row
but isn't part of the uniqueness.
"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa

# revision identifiers, used by Alembic.
revision: str = '8e43330211be'
down_revision: Union[str, None] = 'a3f81c60e274'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.create_table(
'tournament_membership_event_preferences',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('membership_id', sa.Integer(), nullable=False),
sa.Column('tournament_event_id', sa.Integer(), nullable=False),
sa.Column('key', sa.String(length=64), nullable=False),
sa.Column('rank', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['membership_id'], ['tournament_memberships.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['tournament_event_id'], ['tournament_events.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('membership_id', 'key', 'tournament_event_id', name='uq_membership_event_preference'),
)
op.create_index(
op.f('ix_tournament_membership_event_preferences_id'),
'tournament_membership_event_preferences', ['id'], unique=False,
)


def downgrade() -> None:
op.drop_index(
op.f('ix_tournament_membership_event_preferences_id'),
table_name='tournament_membership_event_preferences',
)
op.drop_table('tournament_membership_event_preferences')
41 changes: 41 additions & 0 deletions backend/alembic/versions/a3f81c60e274_drop_membership_status.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""drop membership status

Revision ID: a3f81c60e274
Revises: 066d0807d514
Create Date: 2026-08-28 15:02:00.000000

TournamentMembership.status ("interested" | "confirmed") is superseded by
per-track statuses: one tournament-wide flag can't express a member who is
confirmed on one track and declined on another, which is what complex
tournaments actually need. See tournament_membership_track_statuses.

The column gated nothing and was effectively write-only — set at join and at
tournament creation, then never moved (mark_confirmed, its only mutator, had
zero callers). Nothing to migrate into the new table: an "interested" here
never named a track, so there's no per-track fact to recover from it.

Irreversible in practice — downgrade recreates the column with the old default
rather than the values it held.
"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa

# revision identifiers, used by Alembic.
revision: str = 'a3f81c60e274'
down_revision: Union[str, None] = '066d0807d514'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.drop_column('tournament_memberships', 'status')


def downgrade() -> None:
op.add_column(
'tournament_memberships',
sa.Column('status', sa.String(length=32), nullable=False, server_default='interested'),
)
op.alter_column('tournament_memberships', 'status', server_default=None)
Loading
Loading