Skip to content

feat(congregation): add the congregation organigram - #370

Merged
mindsers merged 41 commits into
mainfrom
feat/congregation-organigram
Aug 30, 2026
Merged

feat(congregation): add the congregation organigram#370
mindsers merged 41 commits into
mainfrom
feat/congregation-organigram

Conversation

@mindsers

Copy link
Copy Markdown
Contributor

What

Congregations keep an Organisation des services sheet: the elders and ministerial servants, a
service committee, and the services hanging under whichever committee member oversees each one.
Role was flat, so none of it could be expressed.

It already half-existed in the data — eight of the demo congregation's ten custom roles are boxes
from that sheet. What was missing is the edge between them.

Model

One role per box. The person who runs a box is a member of that box marked leader, not a
separate child role. A prototype that split each box in two took the demo congregation from 21
roles to 50, and every role picker in the app grew with it, because all six run
role.findMany({ where: { congregationId } }) with no filter. Of the 29 nodes that prototype
created, zero carried a permission.

The tree grants nothing. parentRoleId is reporting structure and is never traversed by
permission resolution, in either direction. The chart's root is the elder roster, so inheriting
downward would make every seated person an elder, and inheriting upward would give every elder
every permission in the congregation. A person's permissions come only from the roles they are
actually in.

Tenancy is enforced by Postgres. The FK is composite — [parentRoleId, congregationId]
[id, congregationId] — so a role cannot be parented into another congregation. MATCH SIMPLE
means a null parent skips the check, so roots need no special case.

Migration is additive: four nullable columns plus two defaulted, nothing backfilled,
showInOrganigram false everywhere. The chart stays empty until an admin builds one, and the
currently-deployed image never selects these columns.

Page

/congregation/roles/organigram — a read-only chart plus a node panel.

  • The chart never mutates. Selecting a node opens its panel, so editing is scoped to what you
    clicked instead of re-selected in a detached form.
  • Selection lives in ?node= so the panel survives a form post.
  • Searchable person picker (PersonDropdown); members without a login appear disabled with the
    reason rather than hidden.
  • Descendants are absent from the "move under" list, so a cycle cannot be chosen rather than
    being refused after a reload.
  • One panel positioned by CSS: docked above the mobile tab bar below md, sticky column above it.

Tests

Invariants the database cannot hold — cycles, a depth cap of 10, identity rosters as roots — are
pure functions in role-tree.policy.ts. Tree assembly is separable from the queries so orphan
promotion and ordering are tested without a database.

The migration test asserts the applied schema rather than re-running the DDL inside a
transaction: ALTER TABLE ADD COLUMN takes ACCESS EXCLUSIVE on Role and holds it until
rollback, and integration files run in parallel against one database — a previous migration test
in this directory caused random timeouts in unrelated suites that way. Idempotence is asserted by
reading the shipped SQL instead.

Not in this PR

  • Import/export do not yet carry the new columns or kind; restoring an archive would flatten the
    chart and demote every leader. Next change.
  • deleteRole has no child guard yet.
  • The board document (the read surface for members without role permissions).
  • Filtering organigram roles out of the six eligibility pickers.

Congregations maintain an "Organisation des services" sheet — the elders and
ministerial servants, a service committee, and the services hanging under
whichever committee member oversees each one. `Role` was flat, so none of it
could be expressed.

It already half-existed in the data: eight of the demo congregation's ten
custom roles are boxes from that sheet. What was missing is the edge between
them.

Model
- `Role.parentRoleId` — reporting structure only, never traversed by
  permission resolution. The chart's root is the `elder` roster, so inheriting
  downward would make every seated person an elder and inheriting upward would
  give every elder everything. Composite FK to [id, congregationId], so a role
  cannot be parented across congregations — enforced by Postgres rather than by
  a service check.
- `UserRoleAssignment.kind` (leader | deputy | member) — one role per box, with
  the seat on the membership. Splitting each box into a unit plus a leader role
  doubled the node count and grew every unfiltered role picker with it.
- Additive migration: four nullable columns plus two defaulted, nothing
  backfilled, `showInOrganigram` false everywhere.

Page
- `/congregation/roles/organigram`, read-only chart plus a node panel.
- The chart never mutates; selecting a node opens its panel, so editing is
  scoped to what you clicked rather than re-selected in a detached form.
- Selection lives in `?node=` so the panel survives a form post.
- Searchable person picker; members without a login are shown disabled with the
  reason rather than hidden.
- Descendants are absent from the "move under" list, so a cycle cannot be
  chosen rather than being refused after a reload.

Invariants the database cannot hold — cycles, depth cap, identity rosters as
roots — live in `role-tree.policy.ts` as pure functions.
Completes the feature: the chart survives an export/import round trip, is
readable by the whole congregation from the display board, and can be started
from nothing.

Import/export
- Export was blind to the tree columns and to `UserRoleAssignment.kind`, so a
  restore flattened the chart and demoted every responsable to a plain member.
- `parentRoleId` in an archive is a *source* id, and target ids are only known
  once each row is inserted. Sorting the archive by depth — the intuitive fix —
  addresses ordering while the real problem is translation, so it would still
  write the wrong parent. Resolved in a second pass through the importer's id
  map, which is what that map exists for.
- A parent missing from the archive leaves the role detached with a warning
  rather than throwing: the reader promotes an orphan to a root, so the branch
  stays visible and recoverable.

Board document
- Fourth `DynamicType`, in its own file — `dynamic-documents.server.ts` is
  already 428 lines against a 350 hard limit and only passes CI because it is
  grandfathered.
- This is the surface members without `can-view-roles` actually read, so it is
  gated only by `can-view-board` and the section's visibility roles.
- Content version tracks both the roles and their holders: a new elder joining
  the roster changes the document without any role row being touched.

Deleting a role
- `deleteRole` now refuses when other roles report to it, naming them, instead
  of failing on a raw foreign-key violation the admin cannot act on.

Starting a chart
- An empty organigram was a dead end: with no node there was no panel, and the
  panel was the only place a role could be added. There is now a root-level
  add, prominent in the empty state and available afterwards for a second root.

Also: the E2E moves into app/tests/e2e so CI runs it, skipping gracefully when
the environment has no chart to look at.

Not done, deliberately: filtering organigram roles out of the eligibility
pickers. That was planned when the model would have added ~30 structural nodes;
under one-role-per-box the chart is built from roles that already existed, so
the pickers gained nothing to filter — and «Sono» or «Estrade» are legitimately
both an organigram node and a part-eligibility group.
`Role` is how the chart is stored; it is not what a congregation calls the thing
it is looking at. The printed sheet this replaces is titled "Organisation des
services", so the page uses that vocabulary throughout and the storage entity
stops leaking into the interface.

- « Ajouter un rôle ici » → « Rattacher un service »
- « Aucun rôle dans l'organigramme » → « Aucun service dans l'organigramme »
- « Le rôle et ses membres sont conservés » → « Le service et ses membres… »
- « N rôles en dessous » → « N services rattachés »
- The cycle message now reads « Un service ne peut pas être rattaché à lui-même
  ni à l'un des services qui en dépendent », which also explains the rule better
  than naming the constraint did.
- Screen-reader labels follow: the seat control is « Fonction de X dans Y », the
  panel is « Service : X ».

`deleteRole` keeps saying "rôle" for the thing being deleted — it is reached
from the roles page — but now names what blocks it as services in the chart,
which is what makes the message actionable.

Identifiers, types and comments stay in English and keep using `role`: the model
has not changed, only the words shown to a user.
Three gaps between "the plan is complete" and "the feature is done", plus a live
500 that the first of them surfaced.

Nobody could find it
- Nothing linked to /congregation/roles/organigram — not the sidebar, not the
  command palette, not the roles page. It was reachable only by typing the URL,
  and every gate passed because nothing tested discoverability.
- «Organigramme» now replaces «Rôles» in the sidebar and the palette. The
  eligibility matrix becomes its second tab, «Groupes d'aptitude», named for what
  it is actually for: populations like commerces or micros that hold no place in
  the chart.

The matrix toggle was broken (regression test added)
- role-list.tsx submitted `member.id` into a field named `userId`, which reached
  `addUserToRole` and landed in `userRoleAssignment.create` where it must be a
  `UserAccount` id. Different sequences, so it failed on the foreign key and
  surfaced as a 500. It survived because this feature had no route tests.
- The action now resolves the member's account, the field is named `memberId`,
  and a member with no login is shown disabled with the reason rather than
  failing on click.

It could not create a service
- The picker only adopted existing roles, so a congregation whose «Comité de
  service» did not exist yet had to leave for the roles page and come back —
  roughly fifteen times while building a first chart.
- «Rattacher un service» now offers an existing service or a new name, in one
  submit, and focuses whatever it just created. A duplicate name is refused in
  French with the suggestion to attach the existing one instead: two services
  sharing an identity would split the same team's membership in half.

Bands instead of cumulative indentation
- The chart now groups children under «Sous la responsabilité de …» headers, as
  the printed sheet does, so depth costs no horizontal space. The old nesting
  lost ~15% of a 390px screen per level and was unreadable by depth 6.
- The rules are derived from the tree, never stored: a unit with child units
  becomes a band, a unit whose only content is people becomes a row, and the
  roster header is suppressed because the masthead already names it.
- A band's own node renders as its first row rather than as a bare heading —
  reducing «Coordinateur» to a header silently dropped Marc DUPONT.
…ditor

Two surfaces, two jobs, so they should not read the same way.

The board document is read and printed: bands — « Sous la responsabilité de
Comité de service » — are what make the paper sheet scannable on one page, and
depth costs no horizontal space there.

The organigram page is where the structure gets rearranged, and nesting has to
stay visible for that: you are choosing what sits under what, so the indentation
is the information rather than decoration. It goes back to the nested tree.

`toLayout` moves to app/shared/domain: display-board may not deep-import another
feature's ui/ — `test:boundaries` enforces that — and the layout rules are pure
tree derivations with no feature of their own.
The panel had four uppercase section labels stacked down a 22rem column, each
competing with the node's own name — the only thing there that should carry
weight. Sentence case, with a hairline rule above each section: the separation
that the capitals were doing, without the emphasis.

The seat control was the loudest thing on the page. A list of six people
rendered as six full-strength bordered selects, when a seat is set once and
read thereafter. It is borderless until you go near it, and still 36px tall —
past the 24px WCAG 2.2 target minimum.

The remove button is muted rather than hidden-until-hover. There is no hover on
a touch screen, and a control that only exists on a pointer device is a control
half the users never get.
Every congregation has one service committee of three elders — the coordinator,
the secretary and the service overseer — and most services answer to one of
them; the rest answer to the body of elders. That is universal, not a local
arrangement, so it belongs in code rather than being retyped by each
congregation.

Until now those posts were ordinary custom roles. The demo congregation shows
what that costs: French baked into `Role.name`, keys slugified from French
(`responsable-pour-la-predication`), and only two of the three posts present —
nothing noticed the secretary was missing, because nothing knew to look.

Stable keys are the point. A default permission set can now ship for "the
secretary", and seating a new coordinator can revoke the outgoing one's
permissions, neither of which is expressible against a free-text role that a
congregation may rename or delete.

Stored like the identity rosters — isBuiltIn, name/description null, resolved
per-locale by getRoleDisplayName — but appointed by hand like the system roles,
so seats stay on UserRoleAssignment. Their place in the chart is fixed: the
committee under the elders, the three posts inside it, neither re-parentable nor
removable.

New congregations get the whole structure at provisioning, so the first chart is
no longer a blank page. Existing ones get the roles unplaced: they already have
their own «Coordinateur» with people seated on it, and switching these on here
would show them a duplicated, empty committee. They adopt the structure from the
organigram page instead, where the mapping is proposed and confirmed rather than
guessed — that flow is the next commit.

The migration is additive, as it must be: the running image does not roll when
migrations apply, and an unplaced role with no permissions is invisible to every
existing query. Where a congregation already used one of these exact keys, its
row is promoted rather than duplicated, and the name it typed is left alone —
clearing it would read better and destroy something no one can get back.
The three posts are single-seat and elder-only. Both rules are checked before
any write, so a refusal never leaves a post vacant, and the picker greys out
non-elders rather than letting the admin choose and then be refused.

Seating a new coordinator removes the outgoing one. That is not a side effect —
it is the behaviour this feature was built for: the post carries the
permissions, so handing it over hands those over too, without anyone having to
remember to unseat first. The panel names whose fonction is about to end before
you submit.

`kind` is forced to leader on a post: one person holds it, so there is no
membre/adjoint distinction to offer, and a control whose value the service
overwrites is worse than no control at all. The committee and its posts also
lose the move and remove controls, since the service refuses both.

Fixes a bug this same change would otherwise have introduced: the tree reader
derived `isRoster` from `isBuiltIn`, which was true of the two auto-synced
rosters alone until the committee posts arrived. Left as it was, a post would
have rendered as a reconciled list whose membership cannot be edited — the one
thing those posts exist to let you do. It now reads the roster keys directly.

The people section moves to its own file: the posts change nearly every line of
it, and inlining that many branches put the panel over both the complexity
budget and, at 394 of 400 lines, nearly over the size one.
Congregations that built a chart before the committee was structure already have
their own «Comité de service» and «Coordinateur», with people seated on them and
services reporting to them. The migration gave them the built-in posts but left
them out of the chart precisely so this step could exist: a banner on the
organigram, and a screen that proposes a mapping for them to confirm.

The suggestion is made from the role key, which is already slugified, so
«Responsable pour la prédication» is matched to the service overseer and a
congregation with no secretary node is offered "start empty" rather than a
guess. Longest hint first, so a bare «responsable» that is a different service
entirely does not win over the specific one.

On confirm, per mapped role: the holder moves onto the post, the services
reporting to it are re-hung under the post, and the role leaves the chart
without being deleted. Its permissions are **copied**, not moved — the old role
may still gate part eligibility or be assigned outside the chart, and moving a
permission off it would revoke access having nothing to do with the organigram.
Where it held several people, the leader moves and the rest stay behind on a
role that still exists, so nobody loses anything they had.

Also fixes two things this exposed:

The action used Conform, whose flattening does not turn bare repeated field
names into arrays. It parsed to nothing and redirected as if it had succeeded —
the adoption silently did not happen, which only showed up in the database.
`getAll` instead, and a mismatched pair now throws rather than redirecting.

The "attach a service" picker offered the appointed posts: they pass
`canShowInOrganigram` but hold a fixed place, so the service refuses to attach
them anywhere. Offering them was offering an error.
The per-choice body — copy permissions, move the leader, re-hang the children,
unflag the old node — is one coherent operation on one role, and inlining it in
the loop put adoptServiceCommittee over the complexity budget.
Confirming « Supprimer » closed the dialog and did nothing. No request, no
error, no change — which is why it read as "I can't remove roles" rather than as
a failure.

`AlertDialogAction` closes the dialog on click, unmounting the dialog's content
and, with it, the `<Form>` that lived inside it — before the browser reached the
button's default submit. Submitting programmatically makes the close irrelevant.

Nothing below the browser could have caught this: the action was never reached,
so no server-side test would have failed. The regression guard is therefore an
e2e one, and it asserts that a request is sent rather than that the role is
gone — whether the delete then succeeds or is refused depends on the data, but
sending nothing is always the bug.

Fixing that exposed a second bug it had been masking. `deleteRole` throws
ConflictError when organigram services still report to the role, and the action
caught only ForbiddenError, so that escaped as a 500 instead of « déplacez-les
avant de supprimer ce rôle ». Both refusals now surface as messages; a genuine
fault still throws, because reporting a dropped connection as "cannot be
deleted" would be a lie.

Also fixes an organigram e2e selector of mine that was wrong: `getByRole('link')`
matches every link on the page, so `.first()` clicked the sidebar's «Accueil» and
asserted against the home page.
It picked the first role on the page and deleted it. Every run destroyed real
data in whatever database it was pointed at — three services disappeared from a
development congregation before that was noticed.

It now creates a uniquely named role, deletes that, and asserts it is gone, so
the run leaves the database as it found it. A test for a destructive action has
to supply its own subject; reaching for whatever happens to be there makes the
test itself the most dangerous thing in the suite.

Still fails when the dialog's submit is swallowed, which is what it is for.
A congregation's committee IS the coordinator, the secretary and the service
overseer — that is what the word means, not a list that happens to contain them.
So its membership is now derived: seating someone in one of the three posts
joins them to the committee, and unseating removes them, the same way the elder
roster is reconciled from Member flags rather than edited by hand.

Real `UserRoleAssignment` rows rather than a union computed while rendering,
because permissions resolve from that table: a committee role carrying a
permission has to actually reach the three people holding its posts.

The reconcile is idempotent and writes nothing when the membership already
matches, so calling it after every seating neither churns rows nor fills the
audit log with changes nobody made. Seating deliberately no longer returns early
on an unchanged seat, which makes the operation self-healing — it repairs a
committee whose membership drifted instead of requiring someone to guess which
seat to poke. Adoption skips moving a holder onto the committee for the same
reason: it would be reconciled straight back off, a change the admin would watch
undo itself.

The panel stops offering to edit a derived list. Its members show without a
function select or a remove button, with a line saying where they come from — a
form whose result the next reconcile would silently undo is worse than no form.

Seating moves to its own file: the two questions are different — one arranges
boxes, the other puts people in them — and organigram.server.ts was within three
lines of its budget, so the next edit to it would have broken the build.
…three words

Role.isSinglePerson marks a personal role — one titulaire, a handover on
re-seating, adjoints allowed. The migration backfills the three committee
posts, which were always exactly that; the committee itself stays a group.

A second migration pins UserRoleAssignment.kind to leader/deputy/member
with a CHECK constraint: every writer already keeps to those, so anything
else reaching the table is a bug, and a loud insert failure is worth more
than a seat that silently sorts to the bottom of its node.
…rite

The route passed the form's postKey strings straight through, so a crafted
request could carry a custom role's permissions onto any role at all —
elder included — and a duplicate post could seat two people on a
single-person post. The loader's already-adopted redirect also did not
survive a stale tab: a re-posted adoption mapped a second time.

Every choice is now resolved and refused before the first write, which is
what lets the route turn an AppError into a flash message instead of a 500
without ever committing a half-placed committee.
…rough handover

Seating generalises the posts' handover to every isSinglePerson role: a
new titulaire sweeps only the previous leader seat, so adjoints survive —
on the committee posts too, which used to lose every seat. The committee's
membership now counts titulaires only, the elder rule applies to the
titular seat and not to who helps them, and a 'membre' seat does not exist
on a personal role.

The read side carries the flag onto the node, and seatLabel — now shared
between the chart and the board document — stops calling the holder of a
one-person role its responsable: nobody is responsible *of* the
coordinateur; the node name is the function and the person holds it.

Also closes two request-level gaps the UI never exposed: seating someone
into an auto-synced identity roster (a permanent grant no sync would ever
clean up), and re-parenting a role that is not in the chart.
Three accounts on one role — leader, deputy, member — all resolve the
role's permission and are all found by findAccountsWithPermission. The
seat says who leads a service on the chart; anyone needing different
access is modelled as a child role, never as a seat with different
rights. If resolution ever becomes kind-aware, this breaks loudly.
An absent field in an older archive leaves the pre-seeded committee
posts' flag untouched on import rather than stripping it.
The panel showed four workflows at equal weight — seating, moving,
attaching, removing — and read as a wall of fields where a mistake felt
easy. It is now two tiers: « Personnes » stands alone, and everything
structural folds into one collapsed « Organiser l'arborescence » block,
anchored by eyebrow labels instead of a stack of identical borders. The
existing-or-new radio pair is gone: one attach form, and the action does
whichever field the admin filled (a typed name wins).

The tree gets one holder per line with legible seat labels, a band behind
the rosters, breathing room, and a real « Vacant » badge where a personal
role has no titulaire — the question the chart exists to answer. The help
block opens itself while the chart is empty. The panel's loader assembly
moves to a tested server helper, which also returns the route to its size
budget.

The sheet becomes a static header row above a scroll area, on every
viewport: the node's name and the close button sit flush with the top and
content cannot slide behind them — the sticky variant misaligned, and
sticky never engages against the document inside the app shell anyway,
which is also why the chart column (not the page) now scrolls beside an
open panel on desktop. Seat controls grow to 44px on touch screens.
The « Rôle personnel » checkbox joins name and description on the role
edit and creation forms — the chart arranges roles and seats people; the
role pages define them, and the panel links there. Refusals from the flag
(several titulaires still seated) land on the checkbox with their own
message instead of colliding with the duplicate-name error.
A sheet's action buttons otherwise sit one thumb-width above five
navigation targets, and a mis-tap leaves the page mid-edit. Hidden with
the same zero-JS has() trick the layout uses for data-form-actions,
keyed on data-bottom-sheet; the bar returns the moment the sheet
closes.
…e organigram

Three guards draw the line the two pages now stand on. addUserToRole
refuses personal roles — a blind add would write a plain member seat onto
a role that has only a titulaire and adjoints, skipping the handover.
removeUserFromRole refuses to strip a leader or deputy seat — one stray
uncheck while bulk-editing must not silently unseat a responsable. And
the account-assignable filter excludes personal roles from both sides of
the settings page's assignment diff, so a titulaire is never granted
there and never stripped there either.
Columns are banded by the committee post whose branch of the organigram
they belong to — the printed sheet's « sous la responsabilité du
coordinateur » as a header row — with « autres services » and « hors
organigramme » closing the table. A band folds to one narrow column
through a URL-driven link, so a reader's working view survives every
toggle and can be bookmarked.

Only the chart's leaves become columns: the matrix edits plain members,
members live on the teams, and upper levels carry seats this grid may not
touch. Personal roles are out for the same reason. A role still holding
plain members from before this model keeps its column until emptied —
no membership ever becomes invisible — then disappears on its own. Any
responsable or adjoint cell that does surface links to the chart instead
of toggling.

Cells say R/A/✓ from the seat kind, headers carry congregation-wide
holder counts, every change flashes a named confirmation, and the page
keeps its scroll through a toggle. Below md the table becomes member
cards with tappable chips, bands preserved as section labels.
…nts only

The « Membre » option leaves the seat form: plain members are the
matrix's to add and remove in bulk, and this panel names who leads. The
form offers Responsable/Adjoint (Titulaire/Adjoint on a personal role),
member rows still show in the list but carry no controls — a stray click
while reviewing a team cannot remove half of it — and a quiet hint under
the form points to the groupes d'aptitude. The help block opens on the
division of labour.
On the printed sheet every service ultimately answers to the collège des
anciens; a service floating at the top answers to nobody. The policy now
refuses a parentless service — on adding to the chart and on moving
within it alike — and the page stops offering what the policy refuses:
the « au sommet » option leaves the move select (a legacy parentless
node gets a required placeholder instead, so its one gesture is picking
a proper home), and the root-add form narrows to its one legitimate use,
putting a roster back at the top. That form appears only while a roster
is off the chart — a congregation that removed its elder list, or an
archive from before the organigram — and renders nothing otherwise.
« Groupes d'aptitude » named the concept but not the place: the tab now
says « Rôles », matching the title above it, and the panel hint and the
help block point at the same word.
…oute

The roster split pushed the route past its hard size budget — caught by
the pre-push hook. The adoptable/rosters assembly joins the other pure
page assembly in organigram-panel.server, where it is testable without a
request.
The export writes UserRoleAssignment.kind — its own comment warns that
dropping it on restore would quietly demote every responsable — and the
import did exactly that, reading only userId and roleId so every seat
came back a plain member. The round trip is now proven against the real
database: a leader and a deputy keep their seats through the archive.
Archives from before seats existed carry no kind and restore as members,
which is what those rows were.

The organigram parent pass also logs a missing role-id mapping the way
it already logged a missing parent, instead of skipping in silence.
…ying nothing

On a stale tab whose role had been deleted, the toggle chained three
silences: the service no-opped, the success flash hinged on a post-write
lookup that missed, and the page reloaded saying nothing — the click
that 'did nothing'. The role is now resolved before the write: missing,
the admin is told the role no longer exists; present, the named success
flash always fires.
The picker extraction landed buildRolePickers between the JSDoc and the
function it describes. Also fixes a typo in the adoption banner comment.
…y are added

The board only shows documents whose visibility window has opened, and a
freshly created dynamic document was born with a null visibleFrom — it
flashed « ajouté » and then never appeared, until the admin guessed that
the optional date field on the edit page was the reason. Creation now
stamps visibleFrom with the current time: adding a document means
showing it, and the edit page is where a different window gets chosen.
The organigram had fallen through every per-type branch: no icon on the
picker card, the generic grey DocumentCard, no preview line. It now
carries the Network icon, a teal card treatment beside the other three
types, and a « N services » teaser — computed in the sibling
organigram-document file, since dynamic-documents.server.ts is over its
grandfathered size budget.
toLayout now knows the printed order: the two rosters as the masthead —
the assistants no longer sink below the whole elder branch — then the
committee as a bench of three nameplates, the page's one framed element,
because the coordinator is not « sous la responsabilité » of the
committee: he is part of it. Each post's branch follows in canonical
order under an eyebrow-and-name section header, and the services the
collège des anciens keeps for itself close the sheet.

The lines speak the congregation's vocabulary: a leader is titled
« Responsable » only when they are an elder, otherwise « Préposé » —
from Member.isHelder, carried onto every holder — and « Adjoint » stays
what it was. A service with teams is one line: titled leaders, then the
service's deputies and each team's préposé folded together under
ADJOINTS, then ÉQUIPES naming the teams on their own row. Plain members
collapse to a count everywhere except the rosters, which ARE the list.

Typography and rhythm follow the app's own identity — Fraunces headers,
the teal accent on the titles, firmer roster rules, aligned bench
baselines — and print tightens the gaps so the sheet holds to one A4.
The UI refresh capped page content at max-w-7xl, and the two viewers
fake full-bleed with negative margins — which cancel padding but cannot
escape a max-width. On any screen wider than the cap, the viewer became
a centred column with gutters instead of filling the page. The wrapper
now lifts the cap for pages that declare data-full-bleed, the same
zero-JS has() trick it already uses for data-form-actions.
The section eyebrow contracts like the phrase it is — « du » Coordinateur,
« des » for a plural, « de l’ » before a vowel — and the elder branch
answers to the « Collège des anciens », never to a roster called
« Anciens ». The service-overseer post is « Responsable de la
prédication » in French, everywhere at once since built-in names resolve
per locale, with the adoption hints taught the new slug. Hidden team
members leave no count behind, and every service name carries the same
weight: the bold on services-with-teams was the old container-heading
style, orphaned once ÉQUIPES became a text line.

groupLayout and responsibilityEyebrow move into organigram-layout.ts —
pure, tested, and about to be shared with the printable version.
A Download button on the organigram document's viewer produces the sheet
as an A4 PDF — same reading order, same vocabulary, same blocks as the
screen, because OrganigramDocument consumes the same pure pipeline:
toLayout, groupLayout, seatLabel, responsibilityEyebrow. Built on the
app's react-pdf conventions (renderPdfResponse, Fira Sans, sanitized
text), and the resource route is guarded exactly like the viewer — board
permission plus section visibility, so a PDF URL can never show anyone a
document the board itself would not.
The dynamic viewer's PDF download adopts the document viewer's button —
outline, small, icon beside a visible label that collapses to the icon
alone on a phone — instead of a bare ghost icon nobody could name.
…PDF route guards

The committee-key contract test proves behaviourally that the layout's
client-side key mirrors have not drifted from built-in-roles.server.
…y-tree download

groupLayout can only send rows and headerless bands to the legacy pile, so
the type now says so and both renderers drop their dead defensive branch.
The download button hides while the organigram is empty — that PDF is a
blank page.
@mindsers
mindsers merged commit 2e05685 into main Aug 30, 2026
7 checks passed
@mindsers
mindsers deleted the feat/congregation-organigram branch August 30, 2026 14:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant