diff --git a/web/e2e/mobile.spec.ts b/web/e2e/mobile.spec.ts index eaea48f..d7ebfee 100644 --- a/web/e2e/mobile.spec.ts +++ b/web/e2e/mobile.spec.ts @@ -1,7 +1,7 @@ import { expect, test } from '@playwright/test'; import { DOC, signIn } from './helpers'; -test('M1 — mobile : logo dans la barre, menu en tiroir, actions visibles, panneau en feuille', async ({ page }) => { +test('M1 — mobile : logo dans la barre, menu en tiroir, actions visibles, panneau en feuille, réglages lisibles', async ({ page }) => { await signIn(page, 'Chiara'); await page.goto('/'); const brand = page.locator('header.topbar a.brand'); @@ -18,4 +18,10 @@ test('M1 — mobile : logo dans la barre, menu en tiroir, actions visibles, pann const first = page.locator('section.text article.unit').first(); await first.getByRole('button', { name: 'Le vote' }).click(); await expect(page.locator('aside.panel').getByText('Seuil : 50 voix pour')).toBeVisible(); + // The settings of the formula read on a phone: the table of the five levels holds in the sheet. + await page.locator('aside.panel').getByRole('button', { name: 'Réglages de la formule' }).click(); + await expect(page.locator('aside.panel').getByRole('tab', { name: 'Réglages' })).toBeInViewport(); + await expect(page.locator('aside.panel table tbody tr')).toHaveCount(5); + const fits = await page.locator('aside.panel .scroll').evaluate((el) => el.scrollWidth <= el.clientWidth + 1); + expect(fits).toBe(true); }); diff --git a/web/e2e/reader.spec.ts b/web/e2e/reader.spec.ts index 5c79cfc..c4ee3dc 100644 --- a/web/e2e/reader.spec.ts +++ b/web/e2e/reader.spec.ts @@ -60,6 +60,9 @@ test('C4 — les actions sont contextuelles, le vote s’ouvre en panneau', asyn await expect(panel.getByText('Il manque 50 voix pour.')).toBeVisible(); await panel.getByText('La formule du seuil').click(); await expect(panel.getByText(/W — électorat/)).toBeVisible(); + // The level of the article sets M and G: the Raison d'être is at 3 / 5. + await expect(panel.getByText('M — majorité à pleine participation : 55 % — inertie 3 / 5 · moyenne')).toBeVisible(); + await expect(panel.getByRole('button', { name: 'Réglages de la formule' })).toBeVisible(); // The panel lives in the URL. await expect(page).toHaveURL(/onglet=vote/); await page.keyboard.press('Escape'); @@ -242,3 +245,57 @@ test('C19 — un article, une ligne : ses versions se déplient', async ({ page await expect(e3).toHaveAttribute('aria-expanded', 'false'); await expect(versions).toHaveCount(0); }); + +test('C20 — réglages de la formule et cinq crans', async ({ page }) => { + await signIn(page, 'Chiara'); + await page.goto(DOC); + // From the head of the document: the scale of the group, at its usual turnout. + await page.getByRole('button', { name: 'Réglages de la formule' }).click(); + await expect(page).toHaveURL(/onglet=reglages/); + const panel = page.locator('aside.panel'); + await expect(panel.getByRole('heading', { name: 'Réglages de la formule' })).toBeVisible(); + await expect(panel.getByText(/W — électorat : 5\s745/)).toBeVisible(); + await expect(panel.getByText('C — minimum de voix pour : 50')).toBeVisible(); + // No vote yet: the reference, twice C. + await expect(panel.getByText(/100 votants sur 5\s745 \(1,74 %\), deux fois C : la participation de référence/)).toBeVisible(); + const rows = panel.locator('table tbody tr'); + await expect(rows).toHaveCount(5); + await expect(rows.locator('th')).toHaveText([/1\s*très basse/, /2\s*basse/, /3\s*moyenne/, /4\s*haute/, /5\s*très haute/]); + await expect(rows.locator('td:nth-child(2)')).toHaveText(['50 %', '50 %', '55 %', '60 %', '66 %']); + await expect(rows.locator('td:nth-child(4)')).toHaveText(['60 %', '67 %', '75 %', '83 %', '90 %']); + // At 100 voters: C plus each level's share of the 50 votes beyond C. + const required = panel.locator('table tbody td.required'); + await expect(required).toHaveText(['80', '84', '88', '92', '95']); + await expect(panel.getByText('celles exigées à la participation habituelle, 100 votants')).toBeVisible(); + await page.keyboard.press('Escape'); + + // From the vote of an article: Unicité du compte is at 4 / 5, its row is marked. + const e1 = page.locator('section.text article.unit', { hasText: 'Unicité du compte' }); + await e1.hover(); + await e1.getByRole('button', { name: 'Le vote' }).click(); + await expect(panel.getByRole('heading', { name: 'Inertie · 4 / 5 · haute' })).toBeVisible(); + await panel.getByRole('button', { name: 'Réglages de la formule' }).click(); + await expect(panel.locator('tr.current')).toHaveCount(1); + await expect(panel.locator('tr.current th')).toContainText('4'); + await expect(panel.locator('tr.current th')).toContainText('cet article'); + await expect(required).toHaveText(['50', '50', '50', '50', '50']); + await expect(panel.getByText('celles exigées à la participation de cette version, 0 votant.')).toBeVisible(); + await page.keyboard.press('Escape'); + + // The five levels to choose from, in the test group; the change is voted at the article's level. + await page.goto(TEST_DOC); + const e2 = page.locator('section.text article.unit', { hasText: 'Réciprocité' }); + await e2.hover(); + await e2.getByRole('button', { name: 'Le vote' }).click(); + await panel.getByText("Proposer un changement d'inertie").click(); + const levels = panel.getByRole('radiogroup', { name: 'Nouvelle inertie' }).getByRole('radio'); + await expect(levels).toHaveCount(5); + await expect(levels.nth(3)).toBeDisabled(); + await expect(levels.nth(3)).toContainText('actuelle'); + await expect(panel.getByText("Un changement d'inertie se vote lui-même à l'inertie de l'article : 4 / 5 · haute.")).toBeVisible(); + await levels.nth(4).click(); + await expect(levels.nth(4)).toHaveAttribute('aria-checked', 'true'); + await panel.getByRole('button', { name: 'Soumettre au vote' }).click(); + await expect(panel.getByRole('heading', { name: 'inertie vers 5 / 5 · très haute' })).toBeVisible(); + await expect(panel.getByText('inertie 4 / 5 · haute', { exact: true })).toBeVisible(); +}); diff --git a/web/src/lib/components/DeepList.svelte b/web/src/lib/components/DeepList.svelte index e09907a..ea00f7a 100644 --- a/web/src/lib/components/DeepList.svelte +++ b/web/src/lib/components/DeepList.svelte @@ -4,7 +4,7 @@ // unfolds the others, each with its label, its water level and its marks — and // each opens its own vote. import { plain } from '$lib/markdown'; - import { relationOf, titleOf } from '$lib/labels'; + import { inertiaBadge, relationOf, titleOf } from '$lib/labels'; import { i18n, t, type Key } from '$lib/i18n/index.svelte'; import type { Proposal, Unit } from '$lib/types'; import { excerpts, type Article } from '$lib/versions'; @@ -73,7 +73,7 @@ {t('tally.short', { for: best.tally.for, against: best.tally.against })} {#if relationOf(best, unit)}{relationOf(best, unit)}{/if} - {#if unit.inertia !== 'standard'}{t(`inertia.${unit.inertia}`)}{/if} + {#if unit.inertia !== 3}{inertiaBadge(unit.inertia)}{/if} {@render marks(best)} @@ -94,7 +94,7 @@ {#if a.leads}{t('deep.lead', { label: labelOf(best, a) })}{/if} {t('tally.short', { for: best.tally.for, against: best.tally.against })} {#if relationOf(best, unit)}{relationOf(best, unit)}{/if} - {#if unit.inertia !== 'standard'}{t(`inertia.${unit.inertia}`)}{/if} + {#if unit.inertia !== 3}{inertiaBadge(unit.inertia)}{/if} {#if best.evaluation?.wellRated}{t('deep.wellrated')}{/if} {#if a.versions.some((p) => p.myVoteStale)}{t('deep.stale')}{/if} {#if a.versions.some((p) => p.mine)}{t('deep.mine')}{/if} diff --git a/web/src/lib/components/DocPanel.svelte b/web/src/lib/components/DocPanel.svelte index 6d299fe..4141a61 100644 --- a/web/src/lib/components/DocPanel.svelte +++ b/web/src/lib/components/DocPanel.svelte @@ -3,6 +3,7 @@ import { t } from '$lib/i18n/index.svelte'; import { relationOf, titleOf } from '$lib/labels'; import type { Lang } from '$lib/types'; + import FormulaSettings from './FormulaSettings.svelte'; import Icon from './Icon.svelte'; import ProposeView from './ProposeView.svelte'; import VoteView from './VoteView.svelte'; @@ -27,21 +28,30 @@ ['vote', t('panel.vote'), !!voteId], ['proposals', `${t('panel.proposals')}${versions.length > 1 ? ` · ${versions.length}` : ''}`, !!unit], ['propose', t('panel.propose'), true], + // Reached from the head of the document or from a vote; shown while it is open. + ['settings', t('panel.settings'), panel.tab === 'settings'], ] as const ).filter(([, , show]) => show), ); let body: HTMLElement | undefined = $state(); + let tablist: HTMLElement | undefined = $state(); $effect(() => { void panel.tab; void panel.proposalId; body?.scrollTo({ top: 0 }); }); + + // On a narrow sheet the tabs scroll: the open one stays in sight. + $effect(() => { + void panel.tab; + tablist?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + }); diff --git a/web/src/lib/components/Formula.svelte b/web/src/lib/components/Formula.svelte index b8d8b86..321d5b0 100644 --- a/web/src/lib/components/Formula.svelte +++ b/web/src/lib/components/Formula.svelte @@ -1,7 +1,8 @@
@@ -41,18 +45,23 @@
  • {t('formula.W', { n: i18n.number(electorate), source: t(source === 'wot' ? 'formula.W.wot' : 'formula.W.group') })}
  • {t('formula.T', { n: tally.for + tally.against })}
  • {#if p} -
  • {t('formula.M', { n: p.majorityPct })} — {t(`inertia.${inertia}`)}
  • -
  • {t('formula.G', { n: p.gradient })}
  • +
  • {t('formula.M', { n: i18n.number(p.majorityPct) })} — {inertiaBadge(inertia)}
  • +
  • {t('formula.G', { n: i18n.number(Math.round(p.gradient * 1000) / 1000) })}
  • {/if} -
  • {t('formula.B', { n: base })}
  • -
  • {t('formula.C', { n: floor })}
  • +
  • {t('formula.B', { n: i18n.number(base) })}
  • +
  • {t('formula.C', { n: i18n.number(floor) })}
  • {#if evaluation}
  • {t('formula.R', { n: evaluation.required })}
  • {/if}

    {t('formula.reading')}

    +{#if onsettings} + +{/if} diff --git a/web/src/lib/components/FormulaSettings.svelte b/web/src/lib/components/FormulaSettings.svelte new file mode 100644 index 0000000..178c713 --- /dev/null +++ b/web/src/lib/components/FormulaSettings.svelte @@ -0,0 +1,195 @@ + + +{#if view && p} +
    +

    {t('settings.title')}

    +

    {t('settings.intro')}

    + + + +
    +

    {t('settings.usual')}

    +

    + {#if p.source === 'median'} + {i18n.plural('settings.usual.median', p.voters, { electorate: i18n.number(view.doc.electorate), pct: pct(p.ratio), sample: p.sample })} + {:else} + {i18n.plural('settings.usual.reference', p.voters, { electorate: i18n.number(view.doc.electorate), pct: pct(p.ratio), min: p.minSample, sample: p.sample })} + {/if} +

    + {#if p.bounded}

    {t('settings.usual.bounded', { lo: pct(p.bounds[0]), hi: pct(p.bounds[1]) })}

    {/if} +

    {p.measuredAt ? t('settings.usual.when', { date: i18n.date(p.measuredAt) }) : t('settings.usual.never')}

    +
    + +

    {t('settings.levels')}

    +
    + + + + + + + + + + + + {#each levels as l (l.level)} + + + + + + + + {/each} + +
    {t('settings.col.level')}MG{t('settings.col.share')}{t('settings.col.required')}
    + + {l.level} + {t(`inertia.name.${l.level}`)} + {#if l.level === current}{t('settings.current')}{/if} + + {whole(l.majorityPct)}{gradient(l.gradient)}{whole(l.sharePct)}{l.required === null ? '—' : i18n.number(l.required)}
    +
    +

    {i18n.plural('settings.share.note', p.voters)}

    +

    {i18n.plural(version ? 'settings.required.version' : 'settings.required.usual', cast)}

    +

    {t('settings.reading')}

    +
    +{/if} + + diff --git a/web/src/lib/components/InertiaPicker.svelte b/web/src/lib/components/InertiaPicker.svelte new file mode 100644 index 0000000..1ba35ce --- /dev/null +++ b/web/src/lib/components/InertiaPicker.svelte @@ -0,0 +1,171 @@ + + +
    +

    {t('inertia.title')} · {inertiaLevel(unit.inertia)}

    +

    {t('inertia.explain')}

    + {#each unit.inertiaChanges as c (c.at + c.to)} +

    {t(`inertia.history.${c.cause}`, { from: level(c.from), to: level(c.to), date: i18n.date(c.at) })}

    + {/each} + {#each open as ip (ip.id)} + + {/each} +
    + {t('inertia.change')} +
    + {#each INERTIA_LEVELS as k (k)} + + {/each} +
    + +

    {t('inertia.change.note', { level: inertiaLevel(unit.inertia) })}

    +
    + {#if error}{/if} +
    + + diff --git a/web/src/lib/components/VoteView.svelte b/web/src/lib/components/VoteView.svelte index 2ea98c6..a3495f8 100644 --- a/web/src/lib/components/VoteView.svelte +++ b/web/src/lib/components/VoteView.svelte @@ -2,11 +2,12 @@ import { api, ApiError } from '$lib/api'; import { shown, type DocState } from '$lib/doc.svelte'; import { i18n, t, type Key } from '$lib/i18n/index.svelte'; - import { titleOf } from '$lib/labels'; + import { inertiaBadge, titleOf } from '$lib/labels'; import { toasts } from '$lib/toast.svelte'; - import type { DiffPart, Entered, Inertia, Lang, Proposal } from '$lib/types'; + import type { DiffPart, Entered, Inertia, Lang, LevelInfo, Proposal } from '$lib/types'; import DiffText from './DiffText.svelte'; import Formula from './Formula.svelte'; + import InertiaPicker from './InertiaPicker.svelte'; import Prose from './Prose.svelte'; import WaterLevel from './WaterLevel.svelte'; @@ -16,12 +17,14 @@ lang, onopen, onpropose, + onsettings, }: { doc: DocState; proposalId: string; lang: Lang; onopen: (proposalId: string) => void; onpropose: (prefill: { title: string; body: string }) => void; + onsettings?: () => void; } = $props(); interface Detail { @@ -36,13 +39,13 @@ }[]; electorate: number; voteFloor: number; + levels: LevelInfo[]; } let detail = $state(null); let busy = $state(false); let showDiff = $state(false); let error = $state(''); - let inertiaTarget = $state(''); let typoOpen = $state(false); let typoTitle = $state(''); let typoBody = $state(''); @@ -58,7 +61,6 @@ const source = $derived(view?.doc.sourceLang ?? 'fr'); const text = $derived(p ? shown(p, lang, source) : null); const votable = $derived(p?.status === 'open' || p?.status === 'in_force'); - const inertiaOpen = $derived((unit?.proposals ?? []).filter((x) => x.kind === 'inertia' && x.status === 'open')); const signalTargets = $derived( (view?.units ?? []).flatMap((u) => [u.inForce, ...u.proposals] @@ -122,21 +124,6 @@ } } - async function proposeInertia() { - if (!unit || !inertiaTarget) return; - busy = true; - try { - const r = await api.post<{ id: string }>(`/units/${unit.id}/inertia`, { target: inertiaTarget }); - inertiaTarget = ''; - await doc.reload(); - onopen(r.id); - } catch (e) { - error = message(e); - } finally { - busy = false; - } - } - async function saveTypo(e: SubmitEvent) { e.preventDefault(); busy = true; @@ -250,7 +237,7 @@

    {t('tally.required', { n: p.evaluation.required })}

    {p.evaluation.reached ? t('tally.reached') : i18n.plural('tally.missing', p.evaluation.missing)}

    {/if} -

    {t(`inertia.${p.votedAtInertia}`)}

    +

    {inertiaBadge(p.votedAtInertia)}

    {#if p.blocked}

    {t('vote.blocked')}

    {/if} @@ -271,30 +258,9 @@ {/if} - {#if p.kind !== 'inertia' && unit} -
    -

    {t('inertia.title')} · {t(`inertia.level.${unit.inertia}`)}

    -

    {t('inertia.explain')}

    - {#each inertiaOpen as ip (ip.id)} - - {/each} -
    - {t('inertia.change')} -
    - - -
    -

    {t('inertia.change.note', { level: t(`inertia.level.${({ low: 'standard', standard: 'high', high: 'max', max: 'max' } as const)[unit.inertia]}`) })}

    -
    -
    - {/if} + {#if p.kind !== 'inertia' && unit}{/if} - + {#if p.kind === 'text' && (p.status === 'in_force' || p.origin === 'seed' || p.mine) && votable}
    @@ -474,11 +440,6 @@ color: var(--water); font-size: 0.92rem; } - .row { - display: flex; - gap: 0.5rem; - margin-top: 0.5rem; - } .form { display: grid; gap: 0.6rem; @@ -490,15 +451,6 @@ .textarea.short { min-height: 5rem; } - .link { - border: 0; - background: none; - padding: 0; - color: var(--water); - font-weight: 700; - text-align: left; - font-size: 0.9rem; - } .history { margin: 0; padding-left: 1rem; diff --git a/web/src/lib/doc.svelte.ts b/web/src/lib/doc.svelte.ts index c85aefe..99a42f8 100644 --- a/web/src/lib/doc.svelte.ts +++ b/web/src/lib/doc.svelte.ts @@ -7,7 +7,7 @@ import type { GroupView } from './group'; import type { DocView, Lang, PreviewUnit, Proposal, Translation, Unit } from './types'; export type View = 'inforce' | 'wellrated' | 'all'; -export type Tab = 'vote' | 'proposals' | 'propose'; +export type Tab = 'vote' | 'proposals' | 'propose' | 'settings'; export interface PanelState { unitId: string | null; diff --git a/web/src/lib/i18n/en.ts b/web/src/lib/i18n/en.ts index 4848656..c4a8e1f 100644 --- a/web/src/lib/i18n/en.ts +++ b/web/src/lib/i18n/en.ts @@ -134,7 +134,7 @@ export const en: Messages = { 'deep.rival': 'would replace “{title}”', 'deep.addition': 'new article', 'deep.removal': 'removal of “{title}”', - 'deep.inertia': 'inertia: {level}', + 'deep.inertia': 'inertia to {level}', 'deep.blocked': 'blocked', 'deep.mine': 'your proposal', 'deep.stale': 'needs reconfirming', @@ -163,19 +163,19 @@ export const en: Messages = { 'tally.missing.other': '{n} more votes in favor needed.', 'tally.reached': 'Threshold reached.', 'tally.level': 'Level: {pct}% of the threshold', + 'inertia.name.1': 'very low', + 'inertia.name.2': 'low', + 'inertia.name.3': 'medium', + 'inertia.name.4': 'high', + 'inertia.name.5': 'very high', + 'inertia.level': '{k} / 5 · {name}', + 'inertia.badge': 'inertia {level}', - 'inertia.low': 'low inertia', - 'inertia.standard': 'standard inertia', - 'inertia.high': 'high inertia', - 'inertia.max': 'maximum inertia', - 'inertia.level.low': 'low', - 'inertia.level.standard': 'standard', - 'inertia.level.high': 'high', - 'inertia.level.max': 'maximum', 'panel.vote': 'Voting', 'panel.proposals': 'Proposals', 'panel.propose': 'Propose', + 'panel.settings': 'Settings', 'panel.close': 'Close panel', 'panel.inforce': 'In force', 'panel.proposal': 'Proposal', @@ -205,9 +205,12 @@ export const en: Messages = { 'inertia.explain': 'The higher the inertia, the more votes it takes to change this text when few people vote.', 'inertia.change': 'Propose an inertia change', 'inertia.change.to': 'New inertia', - 'inertia.change.note': 'An inertia change is itself voted on with the next inertia up: {level}.', + 'inertia.change.note': "A change of inertia is itself voted at the article's inertia: {level}.", 'inertia.change.submit': 'Submit to vote', 'inertia.change.open': 'Inertia change in progress: to {level}', + 'inertia.change.current': 'current', + 'inertia.history.vote': 'Went from {from} to {to} on {date}, by a vote.', + 'inertia.history.amendment': 'Went from {from} to {to} on {date}: starting setting of the text.', 'formula.title': 'The threshold formula', 'formula.intro': 'The number of votes in favor required, R, depends on turnout:', @@ -221,6 +224,29 @@ export const en: Messages = { 'formula.C': 'C — minimum votes in favor: {n}', 'formula.R': 'R — current threshold: {n} votes in favor', 'formula.reading': 'At low turnout, near-unanimity is needed; when everyone votes, majority M is enough. Nothing passes with fewer than C votes in favor.', + 'common.pct': '{n}%', + 'settings.title': 'Formula settings', + 'settings.intro': 'To change a text, the group asks for a number of votes in favour that depends on its electorate, its usual turnout and the inertia of the article.', + 'settings.usual': 'Usual turnout', + 'settings.usual.median.one': '{n} voter out of {electorate} ({pct}): the median of the {sample} proposals voted on over the past year.', + 'settings.usual.median.other': '{n} voters out of {electorate} ({pct}): the median of the {sample} proposals voted on over the past year.', + 'settings.usual.reference.one': '{n} voter out of {electorate} ({pct}), twice C: the reference turnout, until the group has voted on {min} proposals ({sample} so far).', + 'settings.usual.reference.other': '{n} voters out of {electorate} ({pct}), twice C: the reference turnout, until the group has voted on {min} proposals ({sample} so far).', + 'settings.usual.bounded': 'Kept between {lo} and {hi} of the electorate.', + 'settings.usual.when': 'Measured once a day, last on {date}.', + 'settings.usual.never': 'Measured once a day.', + 'settings.levels': 'The five levels', + 'settings.col.level': 'Level', + 'settings.col.share': 'Share asked', + 'settings.col.required': 'Votes for', + 'settings.current': 'this article', + 'settings.share.note.one': 'Share asked: the share of votes in favour beyond C when {n} person votes, as usual.', + 'settings.share.note.other': 'Share asked: the share of votes in favour beyond C when {n} people vote, as usual.', + 'settings.required.version.one': 'Votes for: those asked at the turnout of this version, {n} voter.', + 'settings.required.version.other': 'Votes for: those asked at the turnout of this version, {n} voters.', + 'settings.required.usual.one': 'Votes for: those asked at the usual turnout, {n} voter.', + 'settings.required.usual.other': 'Votes for: those asked at the usual turnout, {n} voters.', + 'settings.reading': 'At each level, the group asks for its share of votes in favour beyond C when it votes as usual; the more voters, the closer it comes to M. Nothing passes below C votes in favour.', 'history.title': 'Corrections', 'history.initial': 'initial text', diff --git a/web/src/lib/i18n/es.ts b/web/src/lib/i18n/es.ts index 4bb20ef..717aeeb 100644 --- a/web/src/lib/i18n/es.ts +++ b/web/src/lib/i18n/es.ts @@ -134,7 +134,7 @@ export const es: Messages = { 'deep.rival': 'reemplazaría «{title}»', 'deep.addition': 'artículo nuevo', 'deep.removal': 'retirada de «{title}»', - 'deep.inertia': 'inercia: {level}', + 'deep.inertia': 'inercia a {level}', 'deep.blocked': 'bloqueada', 'deep.mine': 'tu propuesta', 'deep.stale': 'por reconfirmar', @@ -163,19 +163,19 @@ export const es: Messages = { 'tally.missing.other': 'Faltan {n} votos a favor.', 'tally.reached': 'Umbral alcanzado.', 'tally.level': 'Nivel: {pct} % del umbral', + 'inertia.name.1': 'muy baja', + 'inertia.name.2': 'baja', + 'inertia.name.3': 'media', + 'inertia.name.4': 'alta', + 'inertia.name.5': 'muy alta', + 'inertia.level': '{k} / 5 · {name}', + 'inertia.badge': 'inercia {level}', - 'inertia.low': 'inercia baja', - 'inertia.standard': 'inercia estándar', - 'inertia.high': 'inercia alta', - 'inertia.max': 'inercia máxima', - 'inertia.level.low': 'baja', - 'inertia.level.standard': 'estándar', - 'inertia.level.high': 'alta', - 'inertia.level.max': 'máxima', 'panel.vote': 'Votación', 'panel.proposals': 'Propuestas', 'panel.propose': 'Proponer', + 'panel.settings': 'Ajustes', 'panel.close': 'Cerrar el panel', 'panel.inforce': 'En vigor', 'panel.proposal': 'Propuesta', @@ -205,9 +205,12 @@ export const es: Messages = { 'inertia.explain': 'Cuanto más alta es la inercia, más votos hacen falta para cambiar este texto cuando vota poca gente.', 'inertia.change': 'Proponer un cambio de inercia', 'inertia.change.to': 'Nueva inercia', - 'inertia.change.note': 'Un cambio de inercia se vota a su vez con la inercia superior: {level}.', + 'inertia.change.note': 'Un cambio de inercia se vota a su vez con la inercia del artículo: {level}.', 'inertia.change.submit': 'Someter a votación', 'inertia.change.open': 'Cambio de inercia en curso: a {level}', + 'inertia.change.current': 'actual', + 'inertia.history.vote': 'Pasó de {from} a {to} el {date}, por votación.', + 'inertia.history.amendment': 'Pasó de {from} a {to} el {date}: ajuste inicial del texto.', 'formula.title': 'La fórmula del umbral', 'formula.intro': 'El número de votos a favor exigido, R, depende de la participación:', @@ -221,6 +224,29 @@ export const es: Messages = { 'formula.C': 'C — mínimo de votos a favor: {n}', 'formula.R': 'R — umbral actual: {n} votos a favor', 'formula.reading': 'Con poca participación, hace falta casi la unanimidad; cuando vota todo el mundo, basta la mayoría M. Nada pasa con menos de C votos a favor.', + 'common.pct': '{n} %', + 'settings.title': 'Ajustes de la fórmula', + 'settings.intro': 'Para cambiar un texto, el grupo pide un número de votos a favor que depende de su electorado, de su participación habitual y de la inercia del artículo.', + 'settings.usual': 'Participación habitual', + 'settings.usual.median.one': '{n} votante de {electorate} ({pct}): la mediana de las {sample} propuestas votadas en el último año.', + 'settings.usual.median.other': '{n} votantes de {electorate} ({pct}): la mediana de las {sample} propuestas votadas en el último año.', + 'settings.usual.reference.one': '{n} votante de {electorate} ({pct}), dos veces C: la participación de referencia, hasta que el grupo haya votado {min} propuestas ({sample} por ahora).', + 'settings.usual.reference.other': '{n} votantes de {electorate} ({pct}), dos veces C: la participación de referencia, hasta que el grupo haya votado {min} propuestas ({sample} por ahora).', + 'settings.usual.bounded': 'Mantenida entre el {lo} y el {hi} del electorado.', + 'settings.usual.when': 'Medida una vez al día, la última el {date}.', + 'settings.usual.never': 'Medida una vez al día.', + 'settings.levels': 'Los cinco niveles', + 'settings.col.level': 'Nivel', + 'settings.col.share': 'Proporción exigida', + 'settings.col.required': 'Votos a favor', + 'settings.current': 'este artículo', + 'settings.share.note.one': 'Proporción exigida: la parte de votos a favor más allá de C cuando vota {n} persona, como de costumbre.', + 'settings.share.note.other': 'Proporción exigida: la parte de votos a favor más allá de C cuando votan {n} personas, como de costumbre.', + 'settings.required.version.one': 'Votos a favor: los exigidos con la participación de esta versión, {n} votante.', + 'settings.required.version.other': 'Votos a favor: los exigidos con la participación de esta versión, {n} votantes.', + 'settings.required.usual.one': 'Votos a favor: los exigidos con la participación habitual, {n} votante.', + 'settings.required.usual.other': 'Votos a favor: los exigidos con la participación habitual, {n} votantes.', + 'settings.reading': 'En cada nivel, el grupo pide su parte de votos a favor más allá de C cuando vota como de costumbre; cuantos más votantes, más se acerca a M. Nada pasa con menos de C votos a favor.', 'history.title': 'Correcciones', 'history.initial': 'texto inicial', diff --git a/web/src/lib/i18n/fr.ts b/web/src/lib/i18n/fr.ts index e511159..f8d87d3 100644 --- a/web/src/lib/i18n/fr.ts +++ b/web/src/lib/i18n/fr.ts @@ -137,7 +137,7 @@ export const fr = { 'deep.rival': 'remplacerait « {title} »', 'deep.addition': 'nouvel article', 'deep.removal': 'retrait de « {title} »', - 'deep.inertia': 'inertie : {level}', + 'deep.inertia': 'inertie vers {level}', 'deep.blocked': 'bloquée', 'deep.mine': 'ta proposition', 'deep.stale': 'à reconfirmer', @@ -166,19 +166,19 @@ export const fr = { 'tally.missing.other': 'Il manque {n} voix pour.', 'tally.reached': 'Seuil atteint.', 'tally.level': 'Niveau : {pct} % du seuil', + 'inertia.name.1': 'très basse', + 'inertia.name.2': 'basse', + 'inertia.name.3': 'moyenne', + 'inertia.name.4': 'haute', + 'inertia.name.5': 'très haute', + 'inertia.level': '{k} / 5 · {name}', + 'inertia.badge': 'inertie {level}', - 'inertia.low': 'inertie faible', - 'inertia.standard': 'inertie standard', - 'inertia.high': 'inertie haute', - 'inertia.max': 'inertie maximale', - 'inertia.level.low': 'faible', - 'inertia.level.standard': 'standard', - 'inertia.level.high': 'haute', - 'inertia.level.max': 'maximale', 'panel.vote': 'Le vote', 'panel.proposals': 'Propositions', 'panel.propose': 'Proposer', + 'panel.settings': 'Réglages', 'panel.close': 'Fermer le panneau', 'panel.inforce': 'En vigueur', 'panel.proposal': 'Proposition', @@ -208,9 +208,12 @@ export const fr = { 'inertia.explain': "Plus l'inertie est haute, plus il faut de voix pour changer ce texte quand peu de gens votent.", 'inertia.change': "Proposer un changement d'inertie", 'inertia.change.to': 'Nouvelle inertie', - 'inertia.change.note': "Un changement d'inertie se vote lui-même avec l'inertie au-dessus : {level}.", + 'inertia.change.note': "Un changement d'inertie se vote lui-même à l'inertie de l'article : {level}.", 'inertia.change.submit': 'Soumettre au vote', 'inertia.change.open': "Changement d'inertie en cours : vers {level}", + 'inertia.change.current': 'actuelle', + 'inertia.history.vote': 'Passée de {from} à {to} le {date}, par un vote.', + 'inertia.history.amendment': 'Passée de {from} à {to} le {date} : réglage de départ du texte.', 'formula.title': 'La formule du seuil', 'formula.intro': 'Le nombre de voix pour exigé, R, dépend de la participation :', @@ -224,6 +227,29 @@ export const fr = { 'formula.C': 'C — minimum de voix pour : {n}', 'formula.R': 'R — seuil actuel : {n} voix pour', 'formula.reading': "À faible participation, il faut presque l'unanimité ; quand tout le monde vote, la majorité M suffit. Rien ne passe sous C voix pour.", + 'common.pct': '{n} %', + 'settings.title': 'Réglages de la formule', + 'settings.intro': "Pour changer un texte, le groupe demande un nombre de voix pour qui dépend de son électorat, de sa participation habituelle et de l'inertie de l'article.", + 'settings.usual': 'Participation habituelle', + 'settings.usual.median.one': '{n} votant sur {electorate} ({pct}) : la médiane des {sample} propositions votées depuis un an.', + 'settings.usual.median.other': '{n} votants sur {electorate} ({pct}) : la médiane des {sample} propositions votées depuis un an.', + 'settings.usual.reference.one': "{n} votant sur {electorate} ({pct}), deux fois C : la participation de référence, tant que le groupe n'a pas voté sur {min} propositions ({sample} pour l'instant).", + 'settings.usual.reference.other': "{n} votants sur {electorate} ({pct}), deux fois C : la participation de référence, tant que le groupe n'a pas voté sur {min} propositions ({sample} pour l'instant).", + 'settings.usual.bounded': "Ramenée entre {lo} et {hi} de l'électorat.", + 'settings.usual.when': 'Mesurée une fois par jour, la dernière fois le {date}.', + 'settings.usual.never': 'Mesurée une fois par jour.', + 'settings.levels': 'Les cinq crans', + 'settings.col.level': 'Cran', + 'settings.col.share': 'Part exigée', + 'settings.col.required': 'Voix pour', + 'settings.current': 'cet article', + 'settings.share.note.one': "Part exigée : la part de « pour » au-delà de C quand {n} personne vote, comme d'habitude.", + 'settings.share.note.other': "Part exigée : la part de « pour » au-delà de C quand {n} personnes votent, comme d'habitude.", + 'settings.required.version.one': 'Voix pour : celles exigées à la participation de cette version, {n} votant.', + 'settings.required.version.other': 'Voix pour : celles exigées à la participation de cette version, {n} votants.', + 'settings.required.usual.one': 'Voix pour : celles exigées à la participation habituelle, {n} votant.', + 'settings.required.usual.other': 'Voix pour : celles exigées à la participation habituelle, {n} votants.', + 'settings.reading': "À chaque cran, le groupe demande sa part de « pour » au-delà de C quand il vote comme d'habitude ; plus il y a de votants, plus elle se rapproche de M. Rien ne passe sous C voix pour.", 'history.title': 'Corrections', 'history.initial': 'texte initial', diff --git a/web/src/lib/labels.ts b/web/src/lib/labels.ts index f510a5d..25612f0 100644 --- a/web/src/lib/labels.ts +++ b/web/src/lib/labels.ts @@ -1,10 +1,20 @@ import { t } from './i18n/index.svelte'; -import type { Proposal, Unit } from './types'; +import type { Inertia, Proposal, Unit } from './types'; + +/** « 3 / 5 · moyenne ». */ +export function inertiaLevel(k: Inertia): string { + return t('inertia.level', { k, name: t(`inertia.name.${k}`) }); +} + +/** « inertie 3 / 5 · moyenne ». */ +export function inertiaBadge(k: Inertia): string { + return t('inertia.badge', { level: inertiaLevel(k) }); +} /** What a proposal is, in words, whatever its kind. */ export function titleOf(p: Proposal, unit: Unit | undefined): string { if (p.kind === 'inertia' && p.inertiaTarget) { - return t('deep.inertia', { level: t(`inertia.level.${p.inertiaTarget}`) }); + return t('deep.inertia', { level: inertiaLevel(p.inertiaTarget) }); } if (p.kind === 'remove') return t('deep.removal', { title: unit?.inForce?.title ?? p.title }); return p.title; diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 952fdfd..123faa0 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -1,6 +1,40 @@ export type Lang = 'fr' | 'en' | 'es'; export type LangMap = Partial>; -export type Inertia = 'low' | 'standard' | 'high' | 'max'; +/** The five levels of inertia, 1 (lightest) to 5 (heaviest); 3 by default. */ +export type Inertia = 1 | 2 | 3 | 4 | 5; +export const INERTIA_LEVELS: Inertia[] = [1, 2, 3, 4, 5]; + +/** One level on the group's scale: M and G of the formula, s_k, and the *for* votes asked at `cast` votes. */ +export interface LevelInfo { + level: Inertia; + majorityPct: number; + gradient: number; + sharePct: number; + cast: number; + required: number | null; +} + +/** p̃ — the usual turnout of the group, measured once a day (or the reference 2C/W). */ +export interface ParticipationInfo { + ratio: number; + source: 'median' | 'reference'; + sample: number; + median: number | null; + bounded: boolean; + voters: number; + minSample: number; + windowDays: number; + everyHours: number; + bounds: [number, number]; + measuredAt: string | null; +} + +export interface InertiaChange { + from: Inertia | null; + to: Inertia | null; + cause: 'vote' | 'amendment'; + at: string; +} export interface Me { id: string; @@ -75,6 +109,7 @@ export interface Unit { section: string | null; position: number; inertia: Inertia; + inertiaChanges: InertiaChange[]; inForce: Proposal | null; proposals: Proposal[]; } @@ -103,10 +138,12 @@ export interface DocView { electorate: number; electorateSource: 'wot' | 'group'; formula: { base: number }; + participation: ParticipationInfo; publication: Publication; }; units: Unit[]; - inertia: { key: Inertia; majorityPct: number; gradient: number }[]; + /** The five levels, with the *for* votes asked at the usual turnout. */ + levels: LevelInfo[]; } export type DiffPart = { op: 'same' | 'added' | 'removed'; text: string }; diff --git a/web/src/routes/g/[group]/docs/[doc]/+page.svelte b/web/src/routes/g/[group]/docs/[doc]/+page.svelte index 2aaab58..9ad0282 100644 --- a/web/src/routes/g/[group]/docs/[doc]/+page.svelte +++ b/web/src/routes/g/[group]/docs/[doc]/+page.svelte @@ -20,8 +20,8 @@ }); // The panel lives in the URL: a link (or a feedback report) reopens it. - const TABS: Record = { vote: 'vote', propositions: 'proposals', proposer: 'propose' }; - const TAB_NAMES: Record = { vote: 'vote', proposals: 'propositions', propose: 'proposer' }; + const TABS: Record = { vote: 'vote', propositions: 'proposals', proposer: 'propose', reglages: 'settings' }; + const TAB_NAMES: Record = { vote: 'vote', proposals: 'propositions', propose: 'proposer', settings: 'reglages' }; let prefill = $state(null); const panel = $derived.by((): PanelState | null => { const q = page.url.searchParams; @@ -77,6 +77,7 @@

    {i18n.plural('docs.inForce', doc.inForce.length)} · {i18n.plural('docs.open', doc.open.length)} {#if view.doc.publication.state === 'done'} · {t('doc.published')}{:else if view.doc.publication.state === 'pending'} · {t('doc.publish.pending')}{/if} + ·

    diff --git a/web/tests/unit/inertia.test.ts b/web/tests/unit/inertia.test.ts new file mode 100644 index 0000000..39fed47 --- /dev/null +++ b/web/tests/unit/inertia.test.ts @@ -0,0 +1,22 @@ +// The five levels of inertia, as the screen names them (ADR 0007). +import { describe, expect, it } from 'vitest'; +import { en } from '../../src/lib/i18n/en'; +import { es } from '../../src/lib/i18n/es'; +import { fr } from '../../src/lib/i18n/fr'; +import { INERTIA_LEVELS } from '../../src/lib/types'; + +describe('five levels of inertia', () => { + it('go from 1 to 5, 3 in the middle', () => { + expect(INERTIA_LEVELS).toEqual([1, 2, 3, 4, 5]); + }); + it('each level has its own short name, in the three languages', () => { + expect(INERTIA_LEVELS.map((k) => fr[`inertia.name.${k}`])).toEqual(['très basse', 'basse', 'moyenne', 'haute', 'très haute']); + expect(INERTIA_LEVELS.map((k) => en[`inertia.name.${k}`])).toEqual(['very low', 'low', 'medium', 'high', 'very high']); + expect(INERTIA_LEVELS.map((k) => es[`inertia.name.${k}`])).toEqual(['muy baja', 'baja', 'media', 'alta', 'muy alta']); + }); + it('a change of inertia is said to be voted at the level of the article, no longer one above', () => { + for (const cat of [fr, en, es]) expect(cat['inertia.change.note']).toContain('{level}'); + expect(fr['inertia.change.note']).toContain("à l'inertie de l'article"); + expect(fr['inertia.change.note']).not.toContain('au-dessus'); + }); +}); diff --git a/web/tests/unit/versions.test.ts b/web/tests/unit/versions.test.ts index 842b2b2..e8fa69b 100644 --- a/web/tests/unit/versions.test.ts +++ b/web/tests/unit/versions.test.ts @@ -8,13 +8,13 @@ function prop(id: string, unitId: string, over: Partial = {}): Proposa label: null, author: null, mine: false, revision: 1, createdAt: '', updatedAt: '', submittedAt: null, enteredAt: null, confirmedAt: null, seedMinimal: false, tally: { for: 0, against: 0 }, evaluation: { cast: 0, required: 50, reached: false, missing: 50, requiredRatio: 1, wellRated: false }, - votedAtInertia: 'standard', blocked: false, signals: 0, myVote: null, myVoteStale: false, translations: {}, + votedAtInertia: 3, blocked: false, signals: 0, myVote: null, myVoteStale: false, translations: {}, ...over, }; } function unit(id: string, proposals: Proposal[]): Unit { - return { id, code: id, section: null, position: 0, inertia: 'standard', inForce: null, proposals }; + return { id, code: id, section: null, position: 0, inertia: 3, inertiaChanges: [], inForce: null, proposals }; } /** The reader's list: every open proposal, unit by unit, in document order. */