Files
decision/web/src/lib/doc.svelte.ts
T
YvvandClaude Opus 5.5 033b331228 Fusion de la tête du chantier (924a40d : lots B2 et F) dans le lot D
Les deux côtés gardés partout. Aux coutures :
- une objection (redit / contredit) porte sur deux articles, jamais sur un module ;
  un ordre ne se corrige pas, ni par son auteur ni par un autre ; une coquille du
  titre suit le chemin de F (l'auteur du titre, un administrateur pour le titre
  de départ, ou 3 jours), et documents.title suit à l'application ;
- votable() couvre les modules ; leurs totaux passent par shown_tally, à l'écran
  et dans document.json ;
- « Modules à voter » ne compte plus une version retenue par une objection (test
  vu rouge) ; la liste du groupe la compte toujours parmi les propositions en cours ;
- le panier : une fonction pour les articles et les modules, avec les versions
  retenues de F ; un nouvel article se propose dans une section, à sa fin, et peut
  répondre à une objection ;
- migrations 0007 (modules) puis 0008 (objections) ;
- C25 et C26 supportent un groupe d'essai où une voix suffit (dès 16 membres).

Boucle : fmt, clippy -D warnings, cargo test (227), svelte-check 0, vitest 55,
build, e2e 35 / 35 (C1–C33 et M1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 09:41:30 +01:00

199 lines
7.3 KiB
TypeScript

// State of the reader: the document, the view (in force / previews), the
// language the text is read in, and the panel (which unit, which version,
// which tab) — the panel lives in the URL so a link reopens it, as does the
// vote unfolded under an article (`?vote=`).
import { api, ApiError } from './api';
import type { GroupView } from './group';
import type { DocView, Lang, PreviewUnit, Proposal, Translation, Unit } from './types';
export type View = 'inforce' | 'wellrated' | 'all';
/** The panel: the basket of the vote, the versions of an article, a new proposal, the details of a version, the formula. */
export type Tab = 'basket' | 'proposals' | 'propose' | 'details' | 'settings';
export interface PanelState {
unitId: string | null;
proposalId: string | null;
tab: Tab;
draftId?: string | null;
/** The basket, opened on one section. */
section?: string | null;
prefill?: { title: string; body: string } | null;
/** A new proposal that answers this objection (ADR 0010). */
objectionId?: string | null;
/** The details of one's own version, its edit form open: to reword it. */
edit?: boolean;
}
/** The tabs as the URL names them. */
export const TABS: Record<string, Tab> = { panier: 'basket', propositions: 'proposals', proposer: 'propose', details: 'details', reglages: 'settings' };
export const TAB_NAMES: Record<Tab, string> = { basket: 'panier', proposals: 'propositions', propose: 'proposer', details: 'details', settings: 'reglages' };
/** The query of a reader's URL: the unfolded vote and the panel. */
export function panelSearch(panel: PanelState | null, vote: string | null): string {
const q = new URLSearchParams();
if (vote) q.set('vote', vote);
if (panel) {
q.set('onglet', TAB_NAMES[panel.tab]);
if (panel.unitId) q.set('article', panel.unitId);
if (panel.proposalId) q.set('version', panel.proposalId);
if (panel.draftId) q.set('brouillon', panel.draftId);
if (panel.section) q.set('section', panel.section);
if (panel.objectionId) q.set('objection', panel.objectionId);
if (panel.edit) q.set('modifier', '1');
}
return q.toString();
}
export interface Shown {
title: string;
body: string;
/** The text is a translation (editorial). */
translated: boolean;
stale: boolean;
/** Asked for a translation that does not exist yet: the source is shown. */
missing: boolean;
}
export function shown(
v: { title: string; body: string; translations: Partial<Record<Lang, Translation>> },
lang: Lang,
source: Lang,
): Shown {
if (lang === source) return { title: v.title, body: v.body, translated: false, stale: false, missing: false };
const tr = v.translations[lang];
if (!tr) return { title: v.title, body: v.body, translated: false, stale: false, missing: true };
return { title: tr.title, body: tr.body, translated: true, stale: tr.stale, missing: false };
}
export class DocState {
view = $state<DocView | null>(null);
error = $state<string | null>(null);
mode = $state<View>('inforce');
preview = $state<PreviewUnit[] | null>(null);
original = $state(false);
async load(group: string, slug: string) {
try {
const g = await api.get<GroupView>(`/groups/${group}`);
const d = g.docs.find((x) => x.slug === slug);
if (!d) {
this.error = g.group.open ? 'not_found' : 'forbidden';
return;
}
this.view = await api.get<DocView>(`/docs/${d.id}`);
this.error = null;
if (this.mode !== 'inforce') await this.loadPreview();
} catch (e) {
this.error = e instanceof ApiError ? e.code : 'internal';
}
}
async reload() {
if (!this.view) return;
this.view = await api.get<DocView>(`/docs/${this.view.doc.id}`);
if (this.mode !== 'inforce') await this.loadPreview();
}
async setMode(mode: View) {
this.mode = mode;
if (mode === 'inforce') this.preview = null;
else await this.loadPreview();
}
async loadPreview() {
if (!this.view) return;
const r = await api.get<{ units: PreviewUnit[] }>(`/docs/${this.view.doc.id}/preview?mode=${this.mode}`);
this.preview = r.units;
}
/** The modules of the document (ADR 0009): its title, then the order of each section. */
get modules(): Unit[] {
const m = this.view?.modules;
return m ? [...(m.title ? [m.title] : []), ...m.orders] : [];
}
/** The order of a section, when it has something to decide (two articles or more). */
orderOf(section: string | null): Unit | undefined {
return this.view?.modules.orders.find((o) => o.section === section && !o.moot);
}
/** An article or a module. */
unit(id: string | null): Unit | undefined {
return this.view?.units.find((u) => u.id === id) ?? this.modules.find((u) => u.id === id);
}
proposal(id: string | null): Proposal | undefined {
if (!id || !this.view) return undefined;
for (const u of [...this.view.units, ...this.modules]) {
if (u.inForce?.id === id) return u.inForce;
const p = u.proposals.find((x) => x.id === id);
if (p) return p;
}
return undefined;
}
get inForce(): Unit[] {
return (this.view?.units ?? []).filter((u) => u.inForce);
}
/**
* Articles and modules in document order (ADR 0009): the title first, then the
* articles of each section followed by its order, when it has something to decide.
*/
get ordered(): Unit[] {
const m = this.view?.modules;
const out: Unit[] = m?.title ? [m.title] : [];
const units = this.view?.units ?? [];
units.forEach((u, i) => {
out.push(u);
if (units[i + 1]?.section === u.section) return;
const order = this.orderOf(u.section);
if (order) out.push(order);
});
return out;
}
/**
* The text in force by section, each closed by its order (ADR 0009) — shown even
* where no article is in force yet, as long as it has something to decide.
*/
get inForceBySection(): { section: string | null; units: Unit[]; order: Unit | undefined }[] {
const out: { section: string | null; units: Unit[]; order: Unit | undefined }[] = [];
for (const u of this.view?.units ?? []) {
let last = out.at(-1);
if (!last || last.section !== u.section) {
last = { section: u.section, units: [], order: this.orderOf(u.section) };
out.push(last);
}
if (u.inForce) last.units.push(u);
}
return out.filter((s) => s.units.length || s.order);
}
/** Open proposals, of articles and modules, in document order; drafts apart. */
get open(): { unit: Unit; proposal: Proposal }[] {
const out: { unit: Unit; proposal: Proposal }[] = [];
for (const unit of this.ordered) {
for (const proposal of unit.proposals) if (proposal.status === 'open') out.push({ unit, proposal });
}
return out;
}
/** The title of an article as it is read: its text in force, else its latest open text. */
articleTitle(id: string, lang: Lang, source: Lang): string {
const u = this.view?.units.find((x) => x.id === id);
if (!u) return id;
const v = u.inForce ?? u.proposals.findLast((p) => p.status === 'open' && p.kind === 'text');
return v ? shown(v, lang, source).title : u.code;
}
get drafts(): { unit: Unit; proposal: Proposal }[] {
const out: { unit: Unit; proposal: Proposal }[] = [];
for (const unit of this.view?.units ?? []) {
for (const proposal of unit.proposals) if (proposal.status === 'draft') out.push({ unit, proposal });
}
return out;
}
}