From 48e800c630f278c9ff11954a1ab6fb4a463cbf48 Mon Sep 17 00:00:00 2001 From: mindsers Date: Wed, 2 Sep 2026 13:48:50 +0200 Subject: [PATCH 1/2] fix(display-board): stop the organigram naming a nested post as a team MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A personal role sitting under another personal role was printed on the band's « ÉQUIPES » line, so the sheet announced a post as if it were a team: « ÉQUIPE Responsable du programme des services ». A band's rows are not all teams. The settled model treats a child personal role as an adjoint arrangement, and the band already folds that child's titulaire into its ADJOINTS — so the person was on the sheet twice, once correctly as an adjoint and once as the name of a team that does not exist. Filtering the ÉQUIPES line to group roles loses nobody. Both halves of the rule move into organigram-layout as `teamRows` and `bandAdjoints`. The screen view and the PDF carried identical private copies of the adjoint fold, which is how a fix lands in one renderer and not the other; now neither can drift. Filtered at render time rather than in `toLayout`, because a collector band (`node: null`) prints the same rows as full lines, people and all — there a personal role belongs. Verified on the board viewer by nesting a personal role under Audio/Video: its name no longer appears among « Perches, Estrade, Sono » and its titulaire shows up in ADJOINTS instead. --- .../ui/dynamic/OrganigramDocument.tsx | 25 +++-- .../ui/dynamic/OrganigramView.tsx | 33 ++++--- app/shared/domain/organigram-layout.test.ts | 94 ++++++++++++++++++- app/shared/domain/organigram-layout.ts | 34 ++++++- 4 files changed, 163 insertions(+), 23 deletions(-) diff --git a/app/features/display-board/ui/dynamic/OrganigramDocument.tsx b/app/features/display-board/ui/dynamic/OrganigramDocument.tsx index 9e88fd77..c2c4c8bb 100644 --- a/app/features/display-board/ui/dynamic/OrganigramDocument.tsx +++ b/app/features/display-board/ui/dynamic/OrganigramDocument.tsx @@ -2,7 +2,14 @@ import path from 'node:path' import { Document, Font, Page, StyleSheet, Text, View } from '@react-pdf/renderer' import type { OrganigramHolder, OrganigramNode } from '~/shared/domain/organigram.queries' import type { BandBlock, CommitteeBlock, RosterBlock } from '~/shared/domain/organigram-layout' -import { groupLayout, responsibilityEyebrow, seatLabel, toLayout } from '~/shared/domain/organigram-layout' +import { + bandAdjoints, + groupLayout, + responsibilityEyebrow, + seatLabel, + teamRows, + toLayout, +} from '~/shared/domain/organigram-layout' import { sanitizeText } from '~/shared/utils/sanitize-text' // The printable « Organisation des services » — the same sheet the board shows, as the A4 page @@ -108,13 +115,13 @@ function Line({ node }: { node: OrganigramNode }) { ) } -function ServiceWithTeams({ node, teams }: { node: OrganigramNode; teams: OrganigramNode[] }) { +function ServiceWithTeams({ node, rows }: { node: OrganigramNode; rows: OrganigramNode[] }) { + // Only group roles are teams; the personal roles among `rows` are adjoint arrangements and + // reach the reader through the ADJOINTS segment instead. Both halves of that rule live in + // organigram-layout so this view and the PDF cannot drift apart on it. + const teams = teamRows(rows) const leaders = node.holders.filter(holder => holder.kind === 'leader') - const seen = new Set(leaders.map(leader => leader.memberId)) - const deputies = [ - ...node.holders.filter(holder => holder.kind === 'deputy'), - ...teams.flatMap(team => team.holders.filter(holder => holder.kind === 'leader')), - ].filter(deputy => !seen.has(deputy.memberId) && seen.add(deputy.memberId)) + const deputies = bandAdjoints(node, rows) return ( @@ -196,7 +203,7 @@ function Bands({ bands }: { bands: BandBlock[] }) { <> {bands.map(band => band.node ? ( - + ) : ( band.rows.map(row => ) ), @@ -246,7 +253,7 @@ export function OrganigramDocument({ {legacy.map(block => { if (block.kind === 'row') return - if (block.node) return + if (block.node) return return block.rows.map(row => ) })} diff --git a/app/features/display-board/ui/dynamic/OrganigramView.tsx b/app/features/display-board/ui/dynamic/OrganigramView.tsx index 026d1606..ef4e828c 100644 --- a/app/features/display-board/ui/dynamic/OrganigramView.tsx +++ b/app/features/display-board/ui/dynamic/OrganigramView.tsx @@ -1,7 +1,14 @@ import * as m from '~/i18n/paraglide/messages' import type { OrganigramNode } from '~/shared/domain/organigram.queries' import type { BandBlock, CommitteeBlock, RosterBlock } from '~/shared/domain/organigram-layout' -import { groupLayout, responsibilityEyebrow, seatLabel, toLayout } from '~/shared/domain/organigram-layout' +import { + bandAdjoints, + groupLayout, + responsibilityEyebrow, + seatLabel, + teamRows, + toLayout, +} from '~/shared/domain/organigram-layout' import { cn } from '~/shared/utils/utils' // The board's rendering of the organigram — a document, not a tool. @@ -79,17 +86,19 @@ const dot = ( * A service and its teams as one line: « Audio/Vidéo — RESPONSABLE Philippe MARTIN · ADJOINTS * Sébastien ROUX, Jérôme MULLER · ÉQUIPES Perches, Estrade, Sono ». * - * The team préposés fold into the service's adjoints — that is what they are to the person - * reading the sheet: who helps the responsable run this. The teams themselves become names, - * because who to ask for matters on a noticeboard; the full roster never did. + * The leaders of everything beneath fold into the service's adjoints — that is what they are to + * the person reading the sheet: who helps the responsable run this. Group roles then become + * names on the ÉQUIPES line, because who to ask for matters on a noticeboard; the full roster + * never did. A nested personal role is not a team and is not named there — it is already on the + * line above, as an adjoint. */ -function ServiceWithTeams({ node, teams }: { node: OrganigramNode; teams: OrganigramNode[] }) { +function ServiceWithTeams({ node, rows }: { node: OrganigramNode; rows: OrganigramNode[] }) { + // Only group roles are teams; the personal roles among `rows` are adjoint arrangements and + // reach the reader through the ADJOINTS segment instead. Both halves of that rule live in + // organigram-layout so this view and the PDF cannot drift apart on it. + const teams = teamRows(rows) const leaders = node.holders.filter(holder => holder.kind === 'leader') - const seen = new Set(leaders.map(leader => leader.memberId)) - const deputies = [ - ...node.holders.filter(holder => holder.kind === 'deputy'), - ...teams.flatMap(team => team.holders.filter(holder => holder.kind === 'leader')), - ].filter(deputy => !seen.has(deputy.memberId) && seen.add(deputy.memberId)) + const deputies = bandAdjoints(node, rows) return (
@@ -202,7 +211,7 @@ function BranchSection({ under, bands }: { under: string; bands: BandBlock[] })
{bands.map(band => band.node ? ( - + ) : ( band.rows.map(row => ) ), @@ -247,7 +256,7 @@ export function OrganigramView({ tree }: { tree: OrganigramNode[] }) {
{legacy.map(block => { if (block.kind === 'row') return - if (block.node) return + if (block.node) return return block.rows.map(row => ) })}
diff --git a/app/shared/domain/organigram-layout.test.ts b/app/shared/domain/organigram-layout.test.ts index 4243c954..82b1c03f 100644 --- a/app/shared/domain/organigram-layout.test.ts +++ b/app/shared/domain/organigram-layout.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { SERVICE_COMMITTEE_KEY, SERVICE_COMMITTEE_POST_KEYS } from '~/shared/domain/built-in-roles.server' import type { OrganigramNode } from '~/shared/domain/organigram.queries' -import { groupLayout, responsibilityEyebrow, seatLabel, toLayout } from './organigram-layout' +import { bandAdjoints, groupLayout, responsibilityEyebrow, seatLabel, teamRows, toLayout } from './organigram-layout' // The printed "Organisation des services" sheet has no connector lines and no deep indentation. // It groups children under band headers — « Sous la responsabilité du secrétaire » — and prints @@ -247,3 +247,95 @@ describe('responsibilityEyebrow — the French contraction', () => { expect(responsibilityEyebrow('Équipe technique')).toBe('Sous la responsabilité de l’') }) }) + +// A band's rows are not all teams. A personal role nested under another personal role is an +// adjoint arrangement — the settled model treats a child personal role as an adjoint, and its +// titulaire already folds into the parent's ADJOINTS — so naming it on the « ÉQUIPES » line +// announces a post as if it were a team, which is what the board sheet was doing. +describe('teamRows', () => { + const porte = node({ name: 'Porte' }) + const auditorium = node({ name: 'Auditorium' }) + const adjointPost = node({ name: 'Responsable du programme des services', isSinglePerson: true }) + + it('keeps group roles', () => { + expect(teamRows([porte, auditorium]).map(row => row.name)).toEqual(['Porte', 'Auditorium']) + }) + + it('drops a personal role, which is an adjoint and not a team', () => { + expect(teamRows([adjointPost]).map(row => row.name)).toEqual([]) + }) + + it('keeps the teams and drops the post when a band has both', () => { + expect(teamRows([porte, adjointPost, auditorium]).map(row => row.name)).toEqual(['Porte', 'Auditorium']) + }) + + it('preserves order', () => { + expect(teamRows([auditorium, porte]).map(row => row.name)).toEqual(['Auditorium', 'Porte']) + }) + + it('returns nothing for an empty band', () => { + expect(teamRows([])).toEqual([]) + }) +}) + +// The other half of the same rule. A band folds the leaders of everything beneath it into its +// ADJOINTS line — that is how a team's préposé and a nested post's titulaire both reach the +// reader. Extracted from the two renderers, which carried identical copies: the screen view and +// the PDF drifting apart is how a fix lands in one and not the other. +describe('bandAdjoints', () => { + const holder = (memberId: number, kind: string) => ({ + roleId: 0, + memberId, + firstname: `F${memberId}`, + lastname: `L${memberId}`, + anonymizedAt: null, + kind, + isElder: false, + }) + + it("folds a team's leader in as an adjoint", () => { + const service = node({ holders: [holder(1, 'leader')] }) + const team = node({ holders: [holder(2, 'leader')] }) + + expect(bandAdjoints(service, [team]).map(a => a.memberId)).toEqual([2]) + }) + + // The point of the fix: a nested personal role contributes its titulaire here, so removing its + // name from the ÉQUIPES line loses nothing — the person is still on the sheet. + it("folds a nested post's titulaire in as an adjoint", () => { + const post = node({ isSinglePerson: true, holders: [holder(1, 'leader')] }) + const child = node({ isSinglePerson: true, holders: [holder(2, 'leader')] }) + + expect(bandAdjoints(post, [child]).map(a => a.memberId)).toEqual([2]) + expect(teamRows([child])).toEqual([]) + }) + + it("keeps the band's own deputies, ahead of the folded ones", () => { + const service = node({ holders: [holder(1, 'leader'), holder(2, 'deputy')] }) + const team = node({ holders: [holder(3, 'leader')] }) + + expect(bandAdjoints(service, [team]).map(a => a.memberId)).toEqual([2, 3]) + }) + + it('never repeats someone who already leads the band', () => { + const service = node({ holders: [holder(1, 'leader')] }) + const team = node({ holders: [holder(1, 'leader')] }) + + expect(bandAdjoints(service, [team])).toEqual([]) + }) + + it('lists someone leading two teams once', () => { + const service = node({ holders: [holder(1, 'leader')] }) + const a = node({ holders: [holder(2, 'leader')] }) + const b = node({ holders: [holder(2, 'leader')] }) + + expect(bandAdjoints(service, [a, b]).map(x => x.memberId)).toEqual([2]) + }) + + it('ignores plain members beneath the band', () => { + const service = node({ holders: [holder(1, 'leader')] }) + const team = node({ holders: [holder(2, 'leader'), holder(3, 'member')] }) + + expect(bandAdjoints(service, [team]).map(a => a.memberId)).toEqual([2]) + }) +}) diff --git a/app/shared/domain/organigram-layout.ts b/app/shared/domain/organigram-layout.ts index e394e80c..628a2426 100644 --- a/app/shared/domain/organigram-layout.ts +++ b/app/shared/domain/organigram-layout.ts @@ -1,4 +1,4 @@ -import type { OrganigramNode } from '~/shared/domain/organigram.queries' +import type { OrganigramHolder, OrganigramNode } from '~/shared/domain/organigram.queries' // Turning the tree into the layout the printed sheet uses. // @@ -160,6 +160,38 @@ export function seatLabel( return holder.isElder ? 'Responsable' : 'Préposé' } +/** + * The rows a band names on its « ÉQUIPES » line. + * + * A band's rows are not all teams. A personal role nested under another personal role is an + * adjoint arrangement — the settled model treats a child personal role as an adjoint, and + * ServiceWithTeams already folds its titulaire into the parent's ADJOINTS. Naming it here as + * well announced a post as if it were a team: « ÉQUIPE Responsable du programme des services ». + * + * Filtered at render time rather than in `toLayout`, because a collector band (`node: null`) + * prints the same rows as full lines, people and all — there a personal role belongs. + */ +export function teamRows(rows: OrganigramNode[]): OrganigramNode[] { + return rows.filter(row => !row.isSinglePerson) +} + +/** + * The people a band prints as its ADJOINTS. + * + * Its own deputies first, then the leaders of everything beneath it — a team's préposé and a + * nested post's titulaire alike. That fold is why `teamRows` can drop a nested post from the + * ÉQUIPES line without losing anybody: the person is already here. + * + * Anyone already leading the band is skipped, and nobody appears twice. + */ +export function bandAdjoints(node: OrganigramNode, rows: OrganigramNode[]): OrganigramHolder[] { + const seen = new Set(node.holders.filter(holder => holder.kind === 'leader').map(leader => leader.memberId)) + return [ + ...node.holders.filter(holder => holder.kind === 'deputy'), + ...rows.flatMap(row => row.holders.filter(holder => holder.kind === 'leader')), + ].filter(adjoint => !seen.has(adjoint.memberId) && seen.add(adjoint.memberId)) +} + export interface GroupedLayout { rosters: RosterBlock[] committee: CommitteeBlock | null From e7d296e5b9600da5d8e76fda1f374df4744159cd Mon Sep 17 00:00:00 2001 From: mindsers Date: Wed, 2 Sep 2026 16:29:06 +0200 Subject: [PATCH 2/2] docs(organigram): say that a band's rows are not all teams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the review of #382. `BandBlock.rows` documented itself as "Rendered beneath, in order", which is true and says nothing about the one property a caller has to know: the list mixes group roles with personal roles, and treating them alike is exactly the bug this branch fixes. Documented rather than encoded in the type. Splitting the field would express the invariant properly, but a collector band prints the same list unfiltered as full lines, so the split would have to be undone at that call site — a worse trade for a two-caller helper. Left as a note here and a backlog item. --- app/shared/domain/organigram-layout.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/shared/domain/organigram-layout.ts b/app/shared/domain/organigram-layout.ts index 628a2426..285e63bf 100644 --- a/app/shared/domain/organigram-layout.ts +++ b/app/shared/domain/organigram-layout.ts @@ -49,7 +49,13 @@ export interface BandBlock { node: OrganigramNode | null /** Whose responsibility this band falls under — «Sous la responsabilité : Coordinateur». */ under: string | null - /** Rendered beneath, in order. */ + /** + * Rendered beneath, in order — and deliberately heterogeneous: group roles (the real teams) + * sit alongside personal roles (adjoint arrangements). A caller that treats the two alike is + * the bug this list has already caused once, naming a post on the « ÉQUIPES » line. Split + * with `teamRows` before naming anything a team; a collector band (`node: null`) prints the + * list unfiltered, as full lines, where a personal role does belong. + */ rows: OrganigramNode[] }