@jjlmoya/utils-civic 1.5.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/category/index.ts +3 -1
- package/src/entries.ts +4 -0
- package/src/index.ts +1 -0
- package/src/tests/locale_completeness.test.ts +1 -1
- package/src/tests/tool_validation.test.ts +1 -1
- package/src/tool/participatory-budget-allocator/bibliography.astro +6 -0
- package/src/tool/participatory-budget-allocator/bibliography.ts +8 -0
- package/src/tool/participatory-budget-allocator/component.astro +61 -0
- package/src/tool/participatory-budget-allocator/contract.test.ts +13 -0
- package/src/tool/participatory-budget-allocator/controller.ts +108 -0
- package/src/tool/participatory-budget-allocator/dom-views.test.ts +14 -0
- package/src/tool/participatory-budget-allocator/dom-views.ts +58 -0
- package/src/tool/participatory-budget-allocator/entry.ts +27 -0
- package/src/tool/participatory-budget-allocator/evaluator.ts +24 -0
- package/src/tool/participatory-budget-allocator/i18n/de.ts +44 -0
- package/src/tool/participatory-budget-allocator/i18n/en.ts +79 -0
- package/src/tool/participatory-budget-allocator/i18n/es.ts +43 -0
- package/src/tool/participatory-budget-allocator/i18n/fr.ts +43 -0
- package/src/tool/participatory-budget-allocator/i18n/id.ts +43 -0
- package/src/tool/participatory-budget-allocator/i18n/it.ts +43 -0
- package/src/tool/participatory-budget-allocator/i18n/ja.ts +43 -0
- package/src/tool/participatory-budget-allocator/i18n/ko.ts +43 -0
- package/src/tool/participatory-budget-allocator/i18n/nl.ts +43 -0
- package/src/tool/participatory-budget-allocator/i18n/pl.ts +43 -0
- package/src/tool/participatory-budget-allocator/i18n/pt.ts +43 -0
- package/src/tool/participatory-budget-allocator/i18n/ru.ts +43 -0
- package/src/tool/participatory-budget-allocator/i18n/sv.ts +43 -0
- package/src/tool/participatory-budget-allocator/i18n/tr.ts +43 -0
- package/src/tool/participatory-budget-allocator/i18n/zh.ts +43 -0
- package/src/tool/participatory-budget-allocator/index.ts +11 -0
- package/src/tool/participatory-budget-allocator/logic.test.ts +48 -0
- package/src/tool/participatory-budget-allocator/logic.ts +146 -0
- package/src/tool/participatory-budget-allocator/participatory-budget-allocator.css +426 -0
- package/src/tool/participatory-budget-allocator/seo.astro +14 -0
- package/src/tool/participatory-budget-allocator/storage.ts +32 -0
- package/src/tool/participatory-budget-allocator/ui.ts +88 -0
- package/src/tool/public-budget-analyzer/bibliography.astro +15 -0
- package/src/tool/public-budget-analyzer/bibliography.ts +7 -0
- package/src/tool/public-budget-analyzer/component.astro +52 -0
- package/src/tool/public-budget-analyzer/controller.ts +84 -0
- package/src/tool/public-budget-analyzer/dom-views.ts +52 -0
- package/src/tool/public-budget-analyzer/entry.ts +27 -0
- package/src/tool/public-budget-analyzer/evaluator.ts +15 -0
- package/src/tool/public-budget-analyzer/i18n/de.ts +115 -0
- package/src/tool/public-budget-analyzer/i18n/en.ts +78 -0
- package/src/tool/public-budget-analyzer/i18n/es.ts +115 -0
- package/src/tool/public-budget-analyzer/i18n/fr.ts +115 -0
- package/src/tool/public-budget-analyzer/i18n/id.ts +115 -0
- package/src/tool/public-budget-analyzer/i18n/it.ts +115 -0
- package/src/tool/public-budget-analyzer/i18n/ja.ts +115 -0
- package/src/tool/public-budget-analyzer/i18n/ko.ts +115 -0
- package/src/tool/public-budget-analyzer/i18n/nl.ts +115 -0
- package/src/tool/public-budget-analyzer/i18n/pl.ts +115 -0
- package/src/tool/public-budget-analyzer/i18n/pt.ts +115 -0
- package/src/tool/public-budget-analyzer/i18n/ru.ts +115 -0
- package/src/tool/public-budget-analyzer/i18n/sv.ts +115 -0
- package/src/tool/public-budget-analyzer/i18n/tr.ts +115 -0
- package/src/tool/public-budget-analyzer/i18n/zh.ts +115 -0
- package/src/tool/public-budget-analyzer/index.ts +11 -0
- package/src/tool/public-budget-analyzer/logic.test.ts +81 -0
- package/src/tool/public-budget-analyzer/logic.ts +249 -0
- package/src/tool/public-budget-analyzer/public-budget-analyzer.css +461 -0
- package/src/tool/public-budget-analyzer/seo.astro +15 -0
- package/src/tool/public-budget-analyzer/storage.ts +28 -0
- package/src/tool/public-budget-analyzer/ui.ts +78 -0
- package/src/tools.ts +4 -0
package/package.json
CHANGED
package/src/category/index.ts
CHANGED
|
@@ -3,11 +3,13 @@ import { civicPriorityMatrix } from '../tool/civic-priority-matrix/entry';
|
|
|
3
3
|
import { electionSeatApportionmentCalculator } from '../tool/election-seat-apportionment-calculator/entry';
|
|
4
4
|
import { referendumThresholdCalculator } from '../tool/referendum-threshold-calculator/entry';
|
|
5
5
|
import { voteSeatDisproportionalityAnalyzer } from '../tool/vote-seat-disproportionality-analyzer/entry';
|
|
6
|
+
import { participatoryBudgetAllocator } from '../tool/participatory-budget-allocator/entry';
|
|
7
|
+
import { publicBudgetAnalyzer } from '../tool/public-budget-analyzer/entry';
|
|
6
8
|
import type { CategoryLocaleContent, KnownLocale } from '../types';
|
|
7
9
|
|
|
8
10
|
export const civicCategory = {
|
|
9
11
|
icon: 'mdi:bank-outline',
|
|
10
|
-
tools: [civicPriorityMatrix, coalitionMajorityCalculator, electionSeatApportionmentCalculator, referendumThresholdCalculator, voteSeatDisproportionalityAnalyzer],
|
|
12
|
+
tools: [civicPriorityMatrix, coalitionMajorityCalculator, electionSeatApportionmentCalculator, referendumThresholdCalculator, voteSeatDisproportionalityAnalyzer, participatoryBudgetAllocator, publicBudgetAnalyzer],
|
|
11
13
|
i18n: {
|
|
12
14
|
en: () => import('./i18n/en').then((m) => m.content),
|
|
13
15
|
de: () => import('./i18n/de').then((m) => m.content),
|
package/src/entries.ts
CHANGED
|
@@ -11,6 +11,8 @@ import { civicPriorityMatrix } from './tool/civic-priority-matrix/entry';
|
|
|
11
11
|
import { electionSeatApportionmentCalculator } from './tool/election-seat-apportionment-calculator/entry';
|
|
12
12
|
import { referendumThresholdCalculator } from './tool/referendum-threshold-calculator/entry';
|
|
13
13
|
import { voteSeatDisproportionalityAnalyzer } from './tool/vote-seat-disproportionality-analyzer/entry';
|
|
14
|
+
import { participatoryBudgetAllocator } from './tool/participatory-budget-allocator/entry';
|
|
15
|
+
import { publicBudgetAnalyzer } from './tool/public-budget-analyzer/entry';
|
|
14
16
|
|
|
15
17
|
export const ALL_ENTRIES = [
|
|
16
18
|
coalitionMajorityCalculator,
|
|
@@ -18,4 +20,6 @@ export const ALL_ENTRIES = [
|
|
|
18
20
|
electionSeatApportionmentCalculator,
|
|
19
21
|
referendumThresholdCalculator,
|
|
20
22
|
voteSeatDisproportionalityAnalyzer,
|
|
23
|
+
participatoryBudgetAllocator,
|
|
24
|
+
publicBudgetAnalyzer,
|
|
21
25
|
];
|
package/src/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ export { electionSeatApportionmentCalculator, ELECTION_SEAT_APPORTIONMENT_CALCUL
|
|
|
5
5
|
export { referendumThresholdCalculator, REFERENDUM_THRESHOLD_CALCULATOR_TOOL } from './tool/referendum-threshold-calculator';
|
|
6
6
|
export { civicPriorityMatrix, CIVIC_PRIORITY_MATRIX_TOOL } from './tool/civic-priority-matrix';
|
|
7
7
|
export { voteSeatDisproportionalityAnalyzer, VOTE_SEAT_DISPROPORTIONALITY_ANALYZER_TOOL } from './tool/vote-seat-disproportionality-analyzer';
|
|
8
|
+
export { participatoryBudgetAllocator, PARTICIPATORY_BUDGET_ALLOCATOR_TOOL } from './tool/participatory-budget-allocator';
|
|
8
9
|
|
|
9
10
|
export type {
|
|
10
11
|
KnownLocale,
|
|
@@ -5,7 +5,7 @@ import { civicCategory } from '../data';
|
|
|
5
5
|
describe('Tool Validation Suite', () => {
|
|
6
6
|
describe('Library Registration', () => {
|
|
7
7
|
it('should have tools in ALL_TOOLS', () => {
|
|
8
|
-
expect(ALL_TOOLS.length).toBe(
|
|
8
|
+
expect(ALL_TOOLS.length).toBe(7);
|
|
9
9
|
});
|
|
10
10
|
|
|
11
11
|
it('civicCategory should be defined', () => {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { BibliographyEntry } from '../../types';
|
|
2
|
+
|
|
3
|
+
const bibliographyTrace = [
|
|
4
|
+
{ name: 'Northeastern University, Lecture 35 Dynamic Programming', url: 'https://course.khoury.northeastern.edu/cs2510a/lecture35.html', region: 'United States', originalLanguage: 'English', supports: 'The 0/1 knapsack model and dynamic programming search.' },
|
|
5
|
+
{ name: 'Ville de Paris, Budget participatif 2022 Guide du dépôt de projets', url: 'https://cdn.paris.fr/paris/2022/01/04/86f0051197c40fe872c29bac1e98c335.pdf', region: 'France', originalLanguage: 'French', supports: 'The practical context of submitting and costing participatory budget projects.' },
|
|
6
|
+
];
|
|
7
|
+
|
|
8
|
+
export const bibliography: BibliographyEntry[] = bibliographyTrace.map(({ name, url }) => ({ name, url }));
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { ParticipatoryBudgetUI } from './ui';
|
|
3
|
+
import './participatory-budget-allocator.css';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
ui: ParticipatoryBudgetUI;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const { ui } = Astro.props as Props;
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
<div class="participatory-budget-tool" data-participatory-budget-tool>
|
|
13
|
+
<div class="allocation-layout">
|
|
14
|
+
<form class="allocation-form" data-allocation-form>
|
|
15
|
+
<label class="budget-field">{ui.budgetLabel}<input name="budget" type="number" min="0" max="20000" step="0.01" value="12000" required /><span class="field-help">{ui.budgetHelp}</span></label>
|
|
16
|
+
<div class="proposal-heading"><h2>{ui.proposalsHeading}</h2><button class="add-proposal" type="button" data-add-proposal>{ui.addProposal}</button></div>
|
|
17
|
+
<div class="proposal-rows" data-proposal-rows>
|
|
18
|
+
<div class="proposal-row" data-proposal-row>
|
|
19
|
+
<label class="proposal-name">{ui.proposalNameLabel}<input data-proposal-name type="text" value="Pocket park seating" required /></label>
|
|
20
|
+
<label>{ui.proposalCostLabel}<input data-proposal-cost type="number" min="0" step="0.01" value="3500" required /></label>
|
|
21
|
+
<label>{ui.proposalScoreLabel}<input data-proposal-score type="number" min="0" step="1" value="86" required /></label>
|
|
22
|
+
<button class="remove-proposal" type="button" data-remove-proposal>{ui.removeProposal}</button>
|
|
23
|
+
</div>
|
|
24
|
+
<div class="proposal-row" data-proposal-row>
|
|
25
|
+
<label class="proposal-name">{ui.proposalNameLabel}<input data-proposal-name type="text" value="Safer school crossing" required /></label>
|
|
26
|
+
<label>{ui.proposalCostLabel}<input data-proposal-cost type="number" min="0" step="0.01" value="6200" required /></label>
|
|
27
|
+
<label>{ui.proposalScoreLabel}<input data-proposal-score type="number" min="0" step="1" value="100" required /></label>
|
|
28
|
+
<button class="remove-proposal" type="button" data-remove-proposal>{ui.removeProposal}</button>
|
|
29
|
+
</div>
|
|
30
|
+
<div class="proposal-row" data-proposal-row>
|
|
31
|
+
<label class="proposal-name">{ui.proposalNameLabel}<input data-proposal-name type="text" value="Community tool library" required /></label>
|
|
32
|
+
<label>{ui.proposalCostLabel}<input data-proposal-cost type="number" min="0" step="0.01" value="4200" required /></label>
|
|
33
|
+
<label>{ui.proposalScoreLabel}<input data-proposal-score type="number" min="0" step="1" value="74" required /></label>
|
|
34
|
+
<button class="remove-proposal" type="button" data-remove-proposal>{ui.removeProposal}</button>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
<p class="calculate-note">{ui.calculateNote}</p>
|
|
38
|
+
<div class="form-actions"><button class="secondary-action" type="button" data-load-example>{ui.loadExample}</button><button class="print-action" type="button" data-print>{ui.printAction}</button></div>
|
|
39
|
+
<p class="tool-status" data-status role="status"></p>
|
|
40
|
+
</form>
|
|
41
|
+
<section class="allocation-result" aria-live="polite" aria-labelledby="allocation-result-heading">
|
|
42
|
+
<div class="result-heading"><h2 id="allocation-result-heading">{ui.resultHeading}</h2></div>
|
|
43
|
+
<div data-scene></div>
|
|
44
|
+
<div data-result></div>
|
|
45
|
+
</section>
|
|
46
|
+
</div>
|
|
47
|
+
<div class="guardrails" aria-label="Method and limits">
|
|
48
|
+
<article class="guardrail"><span class="guardrail-index">01</span><h2>{ui.methodHeading}</h2><p>{ui.methodText}</p></article>
|
|
49
|
+
<article class="guardrail"><span class="guardrail-index">02</span><h2>{ui.limitsHeading}</h2><p>{ui.limitsText}</p></article>
|
|
50
|
+
<article class="guardrail"><span class="guardrail-index">03</span><h2>{ui.warningsHeading}</h2><p>{ui.warningsText}</p></article>
|
|
51
|
+
</div>
|
|
52
|
+
<script is:inline type="application/json" data-participatory-budget-config set:html={JSON.stringify({ ui })}></script>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<script>
|
|
56
|
+
import { mountParticipatoryBudgetTool } from './controller';
|
|
57
|
+
|
|
58
|
+
const root = document.querySelector<HTMLElement>('[data-participatory-budget-tool]');
|
|
59
|
+
const config = root?.querySelector<HTMLScriptElement>('[data-participatory-budget-config]');
|
|
60
|
+
if (root && config) mountParticipatoryBudgetTool(root, JSON.parse(config.textContent || '{}').ui);
|
|
61
|
+
</script>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { PARTICIPATORY_BUDGET_ALLOCATOR_TOOL, participatoryBudgetAllocator } from './index';
|
|
3
|
+
|
|
4
|
+
describe('participatory budget allocator runtime contract', () => {
|
|
5
|
+
it('exposes all locale loaders and the three page component loaders', async () => {
|
|
6
|
+
const content = await participatoryBudgetAllocator.i18n.en?.();
|
|
7
|
+
expect(content?.slug).toBe('participatory-budget-allocator');
|
|
8
|
+
expect(Object.keys(participatoryBudgetAllocator.i18n)).toEqual(['en', 'de', 'es', 'fr', 'id', 'it', 'ja', 'ko', 'nl', 'pl', 'pt', 'ru', 'sv', 'tr', 'zh']);
|
|
9
|
+
expect(typeof PARTICIPATORY_BUDGET_ALLOCATOR_TOOL.Component).toBe('function');
|
|
10
|
+
expect(typeof PARTICIPATORY_BUDGET_ALLOCATOR_TOOL.SEOComponent).toBe('function');
|
|
11
|
+
expect(typeof PARTICIPATORY_BUDGET_ALLOCATOR_TOOL.BibliographyComponent).toBe('function');
|
|
12
|
+
});
|
|
13
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { allocateBudget, getExampleInput, type AllocationInput, type ProposalInput } from './logic';
|
|
2
|
+
import { renderAllocationResult, renderAllocationScene } from './dom-views';
|
|
3
|
+
import { loadSavedInput, saveInput } from './storage';
|
|
4
|
+
import type { ParticipatoryBudgetUI } from './ui';
|
|
5
|
+
|
|
6
|
+
function numberValue(form: HTMLFormElement, name: string): number {
|
|
7
|
+
const field = form.elements.namedItem(name) as HTMLInputElement | null;
|
|
8
|
+
return parseNumber(field?.value ?? '');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function parseNumber(value: string): number {
|
|
12
|
+
return value.trim() ? Number(value) : Number.NaN;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function fieldValue(value: string): string {
|
|
16
|
+
return value === 'NaN' ? '' : value;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function readRows(form: HTMLFormElement): ProposalInput[] {
|
|
20
|
+
return [...form.querySelectorAll<HTMLElement>('[data-proposal-row]')].map((row) => ({
|
|
21
|
+
name: (row.querySelector('[data-proposal-name]') as HTMLInputElement)?.value ?? '',
|
|
22
|
+
cost: parseNumber((row.querySelector('[data-proposal-cost]') as HTMLInputElement)?.value ?? ''),
|
|
23
|
+
score: parseNumber((row.querySelector('[data-proposal-score]') as HTMLInputElement)?.value ?? ''),
|
|
24
|
+
}));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readInput(form: HTMLFormElement): AllocationInput {
|
|
28
|
+
return { budget: numberValue(form, 'budget'), proposals: readRows(form) };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function createField(label: string, value: string, className: string, marker: string): HTMLLabelElement {
|
|
32
|
+
const labelNode = document.createElement('label');
|
|
33
|
+
labelNode.className = className;
|
|
34
|
+
labelNode.innerHTML = `${label}<input data-${marker} type="${marker === 'proposal-name' ? 'text' : 'number'}" ${marker === 'proposal-name' ? '' : 'min="0" step="0.01"'} value="${fieldValue(value).replace(/"/g, '"')}" required>`;
|
|
35
|
+
return labelNode;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function addRow(container: HTMLElement, proposal: ProposalInput, ui: ParticipatoryBudgetUI): void {
|
|
39
|
+
const row = document.createElement('div');
|
|
40
|
+
row.className = 'proposal-row';
|
|
41
|
+
row.dataset.proposalRow = 'true';
|
|
42
|
+
row.append(createField(ui.proposalNameLabel, proposal.name, 'proposal-name', 'proposal-name'), createField(ui.proposalCostLabel, String(proposal.cost), 'proposal-cost', 'proposal-cost'), createField(ui.proposalScoreLabel, String(proposal.score), 'proposal-score', 'proposal-score'));
|
|
43
|
+
const remove = document.createElement('button');
|
|
44
|
+
remove.type = 'button';
|
|
45
|
+
remove.className = 'remove-proposal';
|
|
46
|
+
remove.dataset.removeProposal = 'true';
|
|
47
|
+
remove.textContent = ui.removeProposal;
|
|
48
|
+
row.append(remove);
|
|
49
|
+
container.append(row);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function setRows(container: HTMLElement, proposals: ProposalInput[], ui: ParticipatoryBudgetUI): void {
|
|
53
|
+
container.replaceChildren();
|
|
54
|
+
proposals.forEach((proposal) => addRow(container, proposal, ui));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function setInput(form: HTMLFormElement, input: AllocationInput, container: HTMLElement, ui: ParticipatoryBudgetUI): void {
|
|
58
|
+
(form.elements.namedItem('budget') as HTMLInputElement).value = String(input.budget);
|
|
59
|
+
setRows(container, input.proposals, ui);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function markFormFeedback(form: HTMLFormElement, input: AllocationInput, ui: ParticipatoryBudgetUI): void {
|
|
63
|
+
[...form.querySelectorAll<HTMLElement>('[data-proposal-row]')].forEach((row, index) => {
|
|
64
|
+
row.classList.remove('proposal-over-budget', 'proposal-invalid');
|
|
65
|
+
row.querySelector('[data-row-warning]')?.remove();
|
|
66
|
+
const proposal = input.proposals[index];
|
|
67
|
+
if (!proposal) return;
|
|
68
|
+
const message = getRowWarning(proposal, input.budget, ui);
|
|
69
|
+
if (!message) return;
|
|
70
|
+
row.classList.add(Number.isFinite(proposal.cost) && proposal.cost > input.budget ? 'proposal-over-budget' : 'proposal-invalid');
|
|
71
|
+
const warning = document.createElement('span');
|
|
72
|
+
warning.className = 'row-warning';
|
|
73
|
+
warning.dataset.rowWarning = 'true';
|
|
74
|
+
warning.textContent = message;
|
|
75
|
+
row.append(warning);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function getRowWarning(proposal: ProposalInput, budget: number, ui: ParticipatoryBudgetUI): string {
|
|
80
|
+
if (!Number.isFinite(proposal.cost) || !Number.isFinite(proposal.score) || !proposal.name.trim()) return ui.invalidProposal;
|
|
81
|
+
if (Number.isFinite(budget) && proposal.cost > budget) return ui.costExceedsBudget;
|
|
82
|
+
return '';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function update(root: HTMLElement, form: HTMLFormElement, ui: ParticipatoryBudgetUI): void {
|
|
86
|
+
const input = readInput(form);
|
|
87
|
+
markFormFeedback(form, input, ui);
|
|
88
|
+
const result = allocateBudget(input);
|
|
89
|
+
saveInput(input);
|
|
90
|
+
root.querySelector<HTMLElement>('[data-scene]')!.innerHTML = renderAllocationScene(result, ui);
|
|
91
|
+
root.querySelector<HTMLElement>('[data-result]')!.innerHTML = renderAllocationResult(result, ui);
|
|
92
|
+
root.querySelector<HTMLElement>('[data-status]')!.textContent = ui.statusSaved;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function mountParticipatoryBudgetTool(root: HTMLElement, ui: ParticipatoryBudgetUI): void {
|
|
96
|
+
const form = root.querySelector<HTMLFormElement>('[data-allocation-form]');
|
|
97
|
+
const rows = root.querySelector<HTMLElement>('[data-proposal-rows]');
|
|
98
|
+
if (!form || !rows) return;
|
|
99
|
+
const saved = loadSavedInput();
|
|
100
|
+
if (saved) setInput(form, saved, rows, ui);
|
|
101
|
+
const refresh = () => update(root, form, ui);
|
|
102
|
+
form.addEventListener('input', refresh);
|
|
103
|
+
root.querySelector('[data-add-proposal]')?.addEventListener('click', () => { addRow(rows, { name: '', cost: Number.NaN, score: Number.NaN }, ui); refresh(); });
|
|
104
|
+
root.addEventListener('click', (event) => { const target = event.target as HTMLElement; if (target.matches('[data-remove-proposal]')) { target.closest('[data-proposal-row]')?.remove(); refresh(); } });
|
|
105
|
+
root.querySelector('[data-load-example]')?.addEventListener('click', () => { setInput(form, getExampleInput(), rows, ui); refresh(); });
|
|
106
|
+
root.querySelector('[data-print]')?.addEventListener('click', () => window.print());
|
|
107
|
+
refresh();
|
|
108
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { renderAllocationScene } from './dom-views';
|
|
3
|
+
import { allocateBudget } from './logic';
|
|
4
|
+
import { ui } from './ui';
|
|
5
|
+
|
|
6
|
+
describe('participatory budget visual scene', () => {
|
|
7
|
+
it('renders one funded segment and the exact remaining percentage', () => {
|
|
8
|
+
const result = allocateBudget({ budget: 5000, proposals: [{ name: 'Community tool library', cost: 4200, score: 74 }, { name: 'Library ramp', cost: 1000, score: 20 }] });
|
|
9
|
+
const scene = renderAllocationScene(result, ui);
|
|
10
|
+
expect(scene.match(/class="scene-tile"/g)).toHaveLength(1);
|
|
11
|
+
expect(scene).toContain('--tile-width:84%');
|
|
12
|
+
expect(scene).toContain('--tile-width:16%');
|
|
13
|
+
});
|
|
14
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { getAllocationStatus, getProposalStatusDetail } from './evaluator';
|
|
2
|
+
import type { ParticipatoryBudgetUI } from './ui';
|
|
3
|
+
import type { AllocationResult, ProposalResult } from './logic';
|
|
4
|
+
|
|
5
|
+
function escapeHtml(value: string): string {
|
|
6
|
+
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function formatMoney(value: number): string {
|
|
10
|
+
return new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function statusLabel(status: ProposalResult['status'], ui: ParticipatoryBudgetUI): string {
|
|
14
|
+
if (status === 'selected') return ui.selectedStatus;
|
|
15
|
+
if (status === 'too-expensive') return ui.tooExpensiveStatus;
|
|
16
|
+
if (status === 'invalid') return ui.invalidStatus;
|
|
17
|
+
return ui.notSelectedStatus;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function renderTile(proposal: ProposalResult, ui: ParticipatoryBudgetUI): string {
|
|
21
|
+
return `<li class="mosaic-tile ${proposal.status}"><div class="tile-top"><strong>${escapeHtml(proposal.name)}</strong><span class="tile-status">${escapeHtml(statusLabel(proposal.status, ui))}</span></div><div class="tile-data"><span>${formatMoney(proposal.cost)}</span><span>${escapeHtml(ui.scoreLabel)} ${proposal.score}</span></div><p><b>${escapeHtml(ui.reasonLabel)}:</b> ${escapeHtml(proposal.reason || getProposalStatusDetail(proposal.status))}</p></li>`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function renderScene(result: AllocationResult, ui: ParticipatoryBudgetUI): string {
|
|
25
|
+
const percentage = result.totals.budget > 0 ? Math.min(100, Math.max(0, result.totals.spent / result.totals.budget * 100)) : 0;
|
|
26
|
+
const tiles = result.selected.map((proposal) => `<span class="scene-tile" style="--tile-width:${proposal.cost / Math.max(result.totals.budget, 1) * 100}%" title="${escapeHtml(proposal.name)}"></span>`).join('');
|
|
27
|
+
const remainder = `<span class="scene-remainder" style="--tile-width:${Math.max(0, result.totals.remaining) / Math.max(result.totals.budget, 1) * 100}%" title="${escapeHtml(ui.remainingLabel)}"></span>`;
|
|
28
|
+
return `<div class="capacity-scene"><div class="scene-caption"><span>${escapeHtml(ui.allocationLabel)}</span><b>${percentage.toFixed(0)}%</b></div><div class="capacity-track"><div class="capacity-segments">${tiles}${remainder}</div></div><div class="scene-caption scene-footer"><span>${escapeHtml(ui.budgetUsedLabel)} ${formatMoney(result.totals.spent)}</span><span>${escapeHtml(ui.budgetLeftLabel)} ${formatMoney(result.totals.remaining)}</span></div></div>`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function renderMetrics(result: AllocationResult, ui: ParticipatoryBudgetUI): string {
|
|
32
|
+
return `<div class="result-metrics"><div><span>${escapeHtml(ui.spentLabel)}</span><strong>${formatMoney(result.totals.spent)}</strong></div><div><span>${escapeHtml(ui.remainingLabel)}</span><strong>${formatMoney(result.totals.remaining)}</strong></div><div><span>${escapeHtml(ui.scoreLabel)}</span><strong>${result.totals.score}</strong></div><div><span>${escapeHtml(ui.proposalCountLabel)}</span><strong>${result.totals.selectedCount}</strong></div></div>`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function renderWarnings(result: AllocationResult, ui: ParticipatoryBudgetUI): string {
|
|
36
|
+
const messages = result.errors.map((error) => getErrorMessage(error, ui));
|
|
37
|
+
if (result.warnings.includes('no-proposals')) messages.push(ui.noProposalsWarning);
|
|
38
|
+
if (result.warnings.includes('no-feasible-proposal')) messages.push(ui.noFeasibleWarning);
|
|
39
|
+
return messages.length ? `<div class="result-warning" role="alert">${messages.map((message) => `<p>${escapeHtml(message)}</p>`).join('')}</div>` : '';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getErrorMessage(error: string, ui: ParticipatoryBudgetUI): string {
|
|
43
|
+
if (error === 'invalid-budget') return ui.invalidBudget;
|
|
44
|
+
if (error === 'budget-too-large') return ui.budgetTooLarge;
|
|
45
|
+
return ui.invalidProposal;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function renderAllocationResult(result: AllocationResult, ui: ParticipatoryBudgetUI): string {
|
|
49
|
+
const status = getAllocationStatus(result);
|
|
50
|
+
const statusClass = `status-${status.tone}`;
|
|
51
|
+
const selected = result.selected.length ? result.selected.map((proposal) => renderTile(proposal, ui)).join('') : `<li class="empty-list">${escapeHtml(ui.emptyResult)}</li>`;
|
|
52
|
+
const excluded = result.excluded.map((proposal) => renderTile(proposal, ui)).join('');
|
|
53
|
+
return `<div class="result-summary ${statusClass}"><div class="status-line"><span class="status-dot"></span><strong>${escapeHtml(status.title)}</strong></div><p>${escapeHtml(status.detail)}</p></div>${renderWarnings(result, ui)}${renderMetrics(result, ui)}<div class="result-section"><h3>${escapeHtml(ui.selectedTilesLabel)}</h3><ul class="mosaic-list">${selected}</ul></div><div class="result-section excluded-section"><h3>${escapeHtml(ui.excludedLabel)}</h3><ul class="mosaic-list">${excluded || `<li class="empty-list">${escapeHtml(ui.emptyResult)}</li>`}</ul></div><p class="tie-break"><b>${escapeHtml(ui.tieBreakLabel)}:</b> ${escapeHtml(result.tieBreak)}</p>`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function renderAllocationScene(result: AllocationResult, ui: ParticipatoryBudgetUI): string {
|
|
57
|
+
return renderScene(result, ui);
|
|
58
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { CivicToolEntry, ToolLocaleContent } from '../../types';
|
|
2
|
+
import type { ParticipatoryBudgetUI } from './ui';
|
|
3
|
+
|
|
4
|
+
export type ParticipatoryBudgetLocaleContent = ToolLocaleContent<ParticipatoryBudgetUI>;
|
|
5
|
+
|
|
6
|
+
export const participatoryBudgetAllocator: CivicToolEntry<ParticipatoryBudgetUI> = {
|
|
7
|
+
id: 'participatory-budget-allocator',
|
|
8
|
+
phase: 'localized',
|
|
9
|
+
icons: { bg: 'mdi:bank-outline', fg: 'mdi:shape-outline' },
|
|
10
|
+
i18n: {
|
|
11
|
+
en: () => import('./i18n/en').then((module) => module.content),
|
|
12
|
+
de: () => import('./i18n/de').then((module) => module.content),
|
|
13
|
+
es: () => import('./i18n/es').then((module) => module.content),
|
|
14
|
+
fr: () => import('./i18n/fr').then((module) => module.content),
|
|
15
|
+
id: () => import('./i18n/id').then((module) => module.content),
|
|
16
|
+
it: () => import('./i18n/it').then((module) => module.content),
|
|
17
|
+
ja: () => import('./i18n/ja').then((module) => module.content),
|
|
18
|
+
ko: () => import('./i18n/ko').then((module) => module.content),
|
|
19
|
+
nl: () => import('./i18n/nl').then((module) => module.content),
|
|
20
|
+
pl: () => import('./i18n/pl').then((module) => module.content),
|
|
21
|
+
pt: () => import('./i18n/pt').then((module) => module.content),
|
|
22
|
+
ru: () => import('./i18n/ru').then((module) => module.content),
|
|
23
|
+
sv: () => import('./i18n/sv').then((module) => module.content),
|
|
24
|
+
tr: () => import('./i18n/tr').then((module) => module.content),
|
|
25
|
+
zh: () => import('./i18n/zh').then((module) => module.content),
|
|
26
|
+
},
|
|
27
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { AllocationResult, ProposalStatus } from './logic';
|
|
2
|
+
|
|
3
|
+
export interface AllocationStatus {
|
|
4
|
+
tone: 'positive' | 'warning' | 'danger' | 'neutral';
|
|
5
|
+
title: string;
|
|
6
|
+
detail: string;
|
|
7
|
+
}
|
|
8
|
+
const statusCopy: Record<ProposalStatus, string> = {
|
|
9
|
+
selected: 'This proposal belongs to the selected best feasible combination.',
|
|
10
|
+
'too-expensive': 'This proposal cannot fit within the budget by itself.',
|
|
11
|
+
'not-selected': 'This proposal was left out because another feasible combination scores better.',
|
|
12
|
+
invalid: 'Correct this row before reading an allocation.',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function getProposalStatusDetail(status: ProposalStatus): string {
|
|
16
|
+
return statusCopy[status];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function getAllocationStatus(result: AllocationResult): AllocationStatus {
|
|
20
|
+
if (!result.valid) return { tone: 'danger', title: 'Check the allocation inputs', detail: 'The model cannot compare proposals until the budget and every row are valid.' };
|
|
21
|
+
if (result.warnings.includes('no-proposals')) return { tone: 'neutral', title: 'Waiting for proposals', detail: 'Add complete proposals to build the allocation mosaic.' };
|
|
22
|
+
if (result.warnings.includes('no-feasible-proposal')) return { tone: 'warning', title: 'Nothing fits yet', detail: 'Increase the budget or review proposal costs before interpreting the score.' };
|
|
23
|
+
return { tone: 'positive', title: 'Feasible allocation found', detail: 'The funded set stays within the ceiling and follows the visible tie-break rule.' };
|
|
24
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { FAQPage, HowTo, SoftwareApplication } from 'schema-dts';
|
|
2
|
+
import { bibliography } from '../bibliography';
|
|
3
|
+
import type { ParticipatoryBudgetLocaleContent } from '../entry';
|
|
4
|
+
import type { ParticipatoryBudgetUI } from '../ui';
|
|
5
|
+
|
|
6
|
+
const ui: ParticipatoryBudgetUI = {
|
|
7
|
+
budgetLabel: 'Verfügbares Budget', budgetHelp: 'Verwende eine Währung und gib die Obergrenze ein, die nicht überschritten werden darf.', proposalsHeading: 'Vorschläge mit vergleichbarer Punktzahl hinzufügen', proposalNameLabel: 'Name des Vorschlags', proposalCostLabel: 'Kosten', proposalScoreLabel: 'Stimmen oder Nutzwert', addProposal: 'Vorschlag hinzufügen', removeProposal: 'Entfernen', calculateNote: 'Die Auswahl wird beim Bearbeiten aktualisiert. Jeder Vorschlag wird vollständig finanziert oder ausgeschlossen.', loadExample: 'Beispiel laden', printAction: 'Prüfblatt drucken', resultHeading: 'Budgetmosaik', emptyResult: 'Füge Vorschläge hinzu, um eine passende vollständige Auswahl zu sehen.', excludedLabel: 'Nicht finanziert', spentLabel: 'Gesamtkosten', remainingLabel: 'Übriges Budget', scoreLabel: 'Gesamtpunktzahl', proposalCountLabel: 'Vorschläge', selectedStatus: 'Finanziert', tooExpensiveStatus: 'Passt nicht', notSelectedStatus: 'Ausgeschlossen', invalidStatus: 'Korrektur nötig', reasonLabel: 'Warum', methodHeading: 'Angewandte Methode', methodText: 'Das Tool löst ein 0/1-Rucksackproblem. Jeder Vorschlag hat Kosten und eine vergleichbare Punktzahl. Es prüft zulässige Kombinationen, maximiert die Punktzahl und verwendet danach niedrigere Kosten, weniger Vorschläge und die Eingabereihenfolge als eindeutige Gleichstandsregeln.', limitsHeading: 'Was dieses Tool nicht tut', limitsText: 'Es entscheidet nicht, ob ein Vorschlag gerecht, legal, förderfähig, umsetzbar oder gesellschaftlich vorzuziehen ist. Es ersetzt weder Beteiligungsregeln noch eine Abstimmung.', warningsHeading: 'Sonderfälle und Datenwarnungen', warningsText: 'Halte Punktzahlen vergleichbar und verwende dieselbe Kostenbasis. Nullwerte, doppelte Namen, unvollständige Schätzungen, Abhängigkeiten und teilbare Vorteile brauchen eine menschliche Prüfung.', noProposalsWarning: 'Es wurden keine Vorschläge eingegeben.', noFeasibleWarning: 'Kein Vorschlag passt in das verfügbare Budget.', invalidBudget: 'Gib ein nicht negatives Budget ein. Das Browsermodell akzeptiert bis zu 20.000,00 in der gewählten Währung.', budgetTooLarge: 'Das Budget überschreitet die Modellgrenze von 20.000,00. Verringere die Obergrenze oder teile die Runde.', invalidProposal: 'Jede Zeile braucht einen Namen, nicht negative Kosten und eine nicht negative Punktzahl.', costExceedsBudget: 'Diese Kosten überschreiten das verfügbare Budget. Der Vorschlag kann nicht ausgewählt werden.', tieBreakLabel: 'Eindeutige Regel', budgetUsedLabel: 'verwendet', budgetLeftLabel: 'übrig', selectedTilesLabel: 'Ausgewählte Felder', allocationLabel: 'Budgetkapazität', statusSaved: 'Entwurf lokal in diesem Browser gespeichert.',
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const faq = [
|
|
11
|
+
{ question: 'Was optimiert dieser Verteiler?', answer: 'Er wählt eine zulässige Menge vollständiger Vorschläge, die die von dir angegebene Gesamtpunktzahl maximiert, ohne die Obergrenze zu überschreiten. Ob diese Punktzahl fair oder sinnvoll ist, entscheidet das Tool nicht.' },
|
|
12
|
+
{ question: 'Warum sind Vorschläge ganz oder gar nicht ausgewählt?', answer: 'Das Modell ist ein 0/1-Modell. Jeder Vorschlag wird zu seinen vollen Kosten umgesetzt oder ausgeschlossen, was für Projekte passt, deren Nutzen von einer vollständigen Umsetzung abhängt.' },
|
|
13
|
+
{ question: 'Was passiert bei gleicher Punktzahl?', answer: 'Dann werden zuerst die niedrigeren Gesamtkosten, danach weniger Vorschläge und schließlich die frühere Eingabereihenfolge gewählt. Die Regel bleibt nachvollziehbar.' },
|
|
14
|
+
{ question: 'Kann das Ergebnis ein echtes Budget entscheiden?', answer: 'Nein. Es ist ein transparentes Szenario. Prüfe Förderfähigkeit, Abhängigkeiten, Umsetzungskapazität, Kostenbasis und das offizielle Verfahren.' },
|
|
15
|
+
];
|
|
16
|
+
const howTo = [
|
|
17
|
+
{ name: 'Obergrenze festlegen', text: 'Gib den verfügbaren Betrag für diese Runde ein und halte die Währungsbasis einheitlich.' },
|
|
18
|
+
{ name: 'Vollständige Vorschläge ergänzen', text: 'Gib jeden Vorschlag einmal mit vollständigen Kosten und einer vergleichbaren Stimmen- oder Nutzwertzahl ein.' },
|
|
19
|
+
{ name: 'Mosaik prüfen', text: 'Prüfe finanzierte Vorschläge, Punktzahl, Kosten und Restbudget beim Aktualisieren der Zeilen.' },
|
|
20
|
+
{ name: 'Ausschlüsse lesen', text: 'Unterscheide Vorschläge, die allein zu teuer sind, von solchen, die gegen eine bessere zulässige Kombination verloren haben.' },
|
|
21
|
+
{ name: 'Menschliche Prüfung anwenden', text: 'Vergleiche das Szenario vor einer Sitzung mit Regeln, Abhängigkeiten, Kapazität und Betroffenen.' },
|
|
22
|
+
];
|
|
23
|
+
const softwareApplication: SoftwareApplication = { '@type': 'SoftwareApplication', name: 'Verteiler für partizipative Budgets', applicationCategory: 'EducationalApplication', operatingSystem: 'Any', description: 'Wähle vollständige öffentliche Vorschläge unter einer festen Budgetgrenze mit einer transparenten Optimierungsregel.', url: 'https://gamebob.dev/de/partizipative-budgetvergabe', offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' } };
|
|
24
|
+
const faqSchema: FAQPage = { '@type': 'FAQPage', mainEntity: faq.map((item) => ({ '@type': 'Question', name: item.question, acceptedAnswer: { '@type': 'Answer', text: item.answer } })) };
|
|
25
|
+
const howToSchema: HowTo = { '@type': 'HowTo', name: 'Ein partizipatives Budget verteilen', step: howTo.map((step) => ({ '@type': 'HowToStep', name: step.name, text: step.text })) };
|
|
26
|
+
|
|
27
|
+
export const content: ParticipatoryBudgetLocaleContent = { slug: 'partizipative-budgetvergabe', title: 'Verteiler für partizipative Budgets', description: 'Wähle vollständige Vorschläge unter einer festen Budgetgrenze, maximiere eine erklärte Punktzahl und sieh Kosten und Grund jedes Ausschlusses.', ui, seo: [
|
|
28
|
+
{ type: 'title', text: 'Eine Budgetgrenze in ein nachvollziehbares Szenario verwandeln', level: 2 },
|
|
29
|
+
{ type: 'paragraph', html: 'Bei einer öffentlichen Budgetrunde gibt es oft mehr sinnvolle Vorschläge als verfügbares Geld. Dieser Verteiler macht den Zielkonflikt sichtbar: Grenze eingeben, vollständige Vorschläge ergänzen und eine vergleichbare Stimmen- oder Nutzwertzahl vergeben. Das Ergebnis zeigt eine zulässige Auswahl statt eine undurchsichtige Einzelentscheidung.' },
|
|
30
|
+
{ type: 'title', text: 'So wird die Auswahl berechnet', level: 2 },
|
|
31
|
+
{ type: 'paragraph', html: 'Das Modell ist ein 0/1-Rucksackproblem. Der Browser vergleicht Kombinationen bis zur Obergrenze und behält die mit der höchsten Punktzahl. Bei Gleichstand gelten niedrigere Kosten, weniger Vorschläge und frühere Eingabereihenfolge. Die Punktzahl ist dein erklärtes Ziel, keine unabhängige Messung des öffentlichen Werts.' },
|
|
32
|
+
{ type: 'table', headers: ['Feld', 'Bedeutung', 'Prüffrage'], rows: [['Kosten', 'Voller Betrag für die Umsetzung', 'Sind alle Zeilen gleich kalkuliert?'], ['Punktzahl', 'Eingegebene Stimmen oder Nutzwert', 'Sind die Werte zwischen Vorschlägen vergleichbar?'], ['Budget', 'Maximale Summe der Auswahl', 'Enthält die Grenze dieselben Kosten wie jede Zeile?'], ['Ausschluss', 'Allein zu teuer oder von einer besseren Kombination geschlagen', 'Ist der Grund mathematisch oder politisch zu prüfen?']] },
|
|
33
|
+
{ type: 'title', text: 'Eingaben vorbereiten, die nicht irreführen', level: 2 },
|
|
34
|
+
{ type: 'list', items: ['Prüfe die Förderfähigkeit aller Vorschläge für dieselbe Runde.', 'Nutze vollständige Umsetzungskosten oder echte unabhängige Projektstufen.', 'Dokumentiere, wie Stimmen oder Nutzwerte entstanden sind.', 'Prüfe Abhängigkeiten, Wartung, rechtliche Fragen und Kapazität getrennt.', 'Bewahre Auswahl und Ausschlussgründe als Beratungsprotokoll auf.'] },
|
|
35
|
+
{ type: 'tip', title: 'Eine Punktzahl ist ein gewähltes Ziel', html: 'Bei Stimmen maximiert das Ergebnis die Gesamtstimmen. Bei einer Nutzwertbewertung maximiert es diese Bewertung. Eine andere Punktzahl stellt eine andere Frage, daher sollte ihre Methode mit dem Ergebnis gespeichert werden.' },
|
|
36
|
+
{ type: 'title', text: 'Finanzierte und ausgeschlossene Mengen lesen', level: 2 },
|
|
37
|
+
{ type: 'paragraph', html: 'Finanzierte Vorschläge zeigen Gesamtkosten, Punktzahl und Restbudget. Ein Vorschlag, der nicht passt, ist allein teurer als die Obergrenze. Ein ausgeschlossener Vorschlag könnte allein passen, gehört aber unter dem gewählten Ziel und der Gleichstandsregel nicht zur besten Kombination. Das sind unterschiedliche Gespräche.' },
|
|
38
|
+
{ type: 'title', text: 'Grenzen und nächste Prüfungen', level: 2 },
|
|
39
|
+
{ type: 'paragraph', html: 'Der Verteiler bewertet weder Gerechtigkeit zwischen Stadtteilen noch Lieferrisiko, Abhängigkeiten oder rechtliche Zulässigkeit. Das Browsermodell ist auf 20.000,00 in der gewählten Währung begrenzt, damit die exakte Suche reaktionsfähig bleibt.' },
|
|
40
|
+
{ type: 'paragraph', html: 'Eine Kombination mit hohem Score ist nur so belastbar wie ihre Kostenannahmen und die Regeln für die Score-Erhebung.' },
|
|
41
|
+
{ type: 'title', text: 'Restbudget als Gesprächsanlass nutzen', level: 2 },
|
|
42
|
+
{ type: 'paragraph', html: 'Ein Restbetrag kann bedeuten, dass kein weiterer Vorschlag verbessert, was bereits ausgewählt wurde. Er kann aber auch auf einen fehlenden Vorschlag oder ein bewusst reserviertes Budget hinweisen.' },
|
|
43
|
+
{ type: 'tip', title: 'Vor der Entscheidung prüfen', html: 'Lies die mathematische Auswahl zusammen mit den offiziellen Teilnahmebedingungen und den Stimmen der betroffenen Menschen.' },
|
|
44
|
+
], faq, bibliography, howTo, schemas: [softwareApplication, faqSchema, howToSchema] as unknown as Record<string, unknown>[] };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { FAQPage, HowTo, SoftwareApplication } from 'schema-dts';
|
|
2
|
+
import type { ToolLocaleContent } from '../../../types';
|
|
3
|
+
import { bibliography } from '../bibliography';
|
|
4
|
+
import type { ParticipatoryBudgetLocaleContent } from '../entry';
|
|
5
|
+
import { ui } from '../ui';
|
|
6
|
+
|
|
7
|
+
const softwareApplication: SoftwareApplication = {
|
|
8
|
+
'@type': 'SoftwareApplication',
|
|
9
|
+
name: 'Participatory Budget Allocator',
|
|
10
|
+
applicationCategory: 'EducationalApplication',
|
|
11
|
+
operatingSystem: 'Any',
|
|
12
|
+
description: 'Select whole public budget proposals under a fixed ceiling with a transparent optimization rule.',
|
|
13
|
+
url: 'https://gamebob.dev/en/participatory-budget-allocator',
|
|
14
|
+
offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const faqPage: FAQPage = {
|
|
18
|
+
'@type': 'FAQPage',
|
|
19
|
+
mainEntity: [
|
|
20
|
+
{ '@type': 'Question', name: 'What does this allocator optimize?', acceptedAnswer: { '@type': 'Answer', text: 'It selects a feasible set of whole proposals that maximizes the total score you provide, without exceeding the budget ceiling. It does not decide whether the score is fair or meaningful.' } },
|
|
21
|
+
{ '@type': 'Question', name: 'Why are proposals all or nothing?', acceptedAnswer: { '@type': 'Answer', text: 'The first version uses a 0/1 model. A proposal is either selected at its full cost or excluded, which fits projects that cannot be delivered in a useful fraction.' } },
|
|
22
|
+
{ '@type': 'Question', name: 'What happens when two allocations have the same score?', acceptedAnswer: { '@type': 'Answer', text: 'The tool first chooses the lower total cost, then fewer proposals, then the earlier input order. The tie breaker is shown so another reviewer can reproduce the result.' } },
|
|
23
|
+
{ '@type': 'Question', name: 'Can this result decide a real public budget?', acceptedAnswer: { '@type': 'Answer', text: 'No. It is a transparent scenario model. Check the applicable participation rules, proposal eligibility, dependencies, delivery capacity, currency basis, and official decision process.' } },
|
|
24
|
+
],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const howTo: HowTo = {
|
|
28
|
+
'@type': 'HowTo',
|
|
29
|
+
name: 'Allocate a fixed participatory budget',
|
|
30
|
+
step: [
|
|
31
|
+
{ '@type': 'HowToStep', name: 'Set the ceiling', text: 'Enter the amount available for this allocation round and keep the currency basis consistent.' },
|
|
32
|
+
{ '@type': 'HowToStep', name: 'Add complete proposals', text: 'Enter each proposal once with a name, full cost, and comparable votes or benefit score.' },
|
|
33
|
+
{ '@type': 'HowToStep', name: 'Inspect the mosaic', text: 'Review the funded set, total score, cost, and remaining budget as the rows update.' },
|
|
34
|
+
{ '@type': 'HowToStep', name: 'Read exclusions', text: 'Open the not funded list to distinguish proposals that cannot fit from proposals that lost to a better feasible combination.' },
|
|
35
|
+
{ '@type': 'HowToStep', name: 'Apply human checks', text: 'Compare the scenario with eligibility, dependencies, delivery capacity, and the official participation rules before using it in a meeting.' },
|
|
36
|
+
],
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const content: ToolLocaleContent<ParticipatoryBudgetLocaleContent['ui']> = {
|
|
40
|
+
slug: 'participatory-budget-allocator',
|
|
41
|
+
title: 'Participatory Budget Allocator',
|
|
42
|
+
description: 'Select whole proposals under a fixed budget ceiling, maximize a declared score, and see the cost and reason behind every exclusion.',
|
|
43
|
+
ui,
|
|
44
|
+
seo: [
|
|
45
|
+
{ type: 'title', text: 'Turn a Budget Ceiling Into a Reproducible Scenario', level: 2 },
|
|
46
|
+
{ type: 'paragraph', html: 'A public budget round often contains more worthwhile proposals than the available money can cover. This allocator makes the trade off explicit: enter the ceiling, add complete proposals, and assign each one a comparable votes or benefit score. The result shows a feasible set rather than hiding the selection behind a single recommendation.' },
|
|
47
|
+
{ type: 'paragraph', html: 'The model treats every proposal as whole. It either funds the full cost or leaves the proposal out. That matters for a crossing, a tool library, or a park improvement whose usefulness depends on delivery as a complete project. If a project can be split into independently useful stages, enter those stages as separate proposals only when the real process allows that.' },
|
|
48
|
+
{ type: 'title', text: 'How the Selection Is Calculated', level: 2 },
|
|
49
|
+
{ type: 'paragraph', html: 'The calculation is a 0/1 knapsack model. For every proposal, the browser compares combinations that stay at or below the ceiling and keeps the combination with the largest total score. It then applies a visible deterministic tie breaker: lower total cost, fewer selected proposals, and earlier input order. The score is your declared objective, not an independent measure of public value.' },
|
|
50
|
+
{ type: 'table', headers: ['Decision field', 'Meaning', 'Review question'], rows: [['Cost', 'Full amount required to deliver the proposal', 'Are all rows priced on the same basis?'], ['Score', 'Votes or benefit value supplied by the user', 'Can these values be compared across proposals?'], ['Budget', 'Maximum total cost of selected proposals', 'Does the ceiling include the same costs as each row?'], ['Exclusion', 'Too expensive alone or beaten by a better feasible set', 'Is the reason mathematical or does it need a policy review?']] },
|
|
51
|
+
{ type: 'title', text: 'Prepare Inputs That Do Not Mislead', level: 2 },
|
|
52
|
+
{ type: 'paragraph', html: 'Use one cost basis and one scoring rule for the entire list. Mixing a construction estimate with a recurring annual cost can make a mathematically tidy selection unusable. Likewise, votes collected under different questions or benefit scores assigned by different panels should not be merged without an explicit decision.' },
|
|
53
|
+
{ type: 'list', items: ['Confirm that every proposal is eligible for the same funding round.', 'Use a full delivery cost or split a project into real independent stages.', 'Record how votes or benefit scores were produced before importing them.', 'Check dependencies, maintenance costs, legal checks, and delivery capacity separately.', 'Save the funded set and the excluded reasons as a review record, not as a final mandate.'] },
|
|
54
|
+
{ type: 'tip', title: 'A score is an objective you chose', html: 'If the score represents votes, the result maximizes total votes. If it represents a benefit assessment, the result maximizes that assessment. Changing the score changes the question, so keep the scoring method alongside the output.' },
|
|
55
|
+
{ type: 'title', text: 'Read the Funded and Excluded Sets', level: 2 },
|
|
56
|
+
{ type: 'paragraph', html: 'The funded proposals fill the capacity band and show total cost, score, and remaining budget. A proposal marked cannot fit costs more than the ceiling by itself. A proposal marked left out could fit by itself but is not in the best combination under the selected objective and tie breaker. These are different operational conversations.' },
|
|
57
|
+
{ type: 'paragraph', html: 'A zero remaining budget is not automatically better than a small remainder. The model values score first, so it may leave money unused when no available proposal improves the objective without forcing out a higher scoring combination. Use the remainder to ask whether another eligible proposal is missing or whether the ceiling is intentionally reserved.' },
|
|
58
|
+
{ type: 'title', text: 'Limits and Next Checks', level: 2 },
|
|
59
|
+
{ type: 'paragraph', html: 'The allocator does not judge fairness between neighborhoods, estimate delivery risk, detect conflicts between projects, or determine legal eligibility. It also cannot tell whether the scoring method captures unrepresented needs. The browser model is intentionally bounded to a budget of 20,000.00 in the chosen currency so that the exact search remains responsive.' },
|
|
60
|
+
{ type: 'tip', title: 'Use two scenarios when priorities are contested', html: 'Run the same proposals with the actual vote score and with a separately agreed benefit score. Compare which proposals change status, then discuss the assumptions that caused the change instead of presenting either scenario as a neutral truth.' },
|
|
61
|
+
],
|
|
62
|
+
faq: [
|
|
63
|
+
{ question: 'What does this allocator optimize?', answer: 'It selects a feasible set of whole proposals that maximizes the total score you provide without exceeding the ceiling. It does not decide whether the score is fair or meaningful.' },
|
|
64
|
+
{ question: 'Why are proposals all or nothing?', answer: 'The first version uses a 0/1 model. A proposal is either selected at its full cost or excluded, which fits projects that cannot be delivered in a useful fraction.' },
|
|
65
|
+
{ question: 'What happens when two allocations have the same score?', answer: 'The tool chooses lower total cost, then fewer proposals, then earlier input order. The tie breaker is shown so another reviewer can reproduce the result.' },
|
|
66
|
+
{ question: 'Can this result decide a real public budget?', answer: 'No. It is a transparent scenario model. Check eligibility, dependencies, delivery capacity, currency basis, and the official decision process.' },
|
|
67
|
+
],
|
|
68
|
+
bibliography,
|
|
69
|
+
howTo: [
|
|
70
|
+
{ name: 'Set the ceiling', text: 'Enter the amount available for this allocation round and keep the currency basis consistent.' },
|
|
71
|
+
{ name: 'Add complete proposals', text: 'Enter each proposal once with a name, full cost, and comparable votes or benefit score.' },
|
|
72
|
+
{ name: 'Inspect the mosaic', text: 'Review the funded set, total score, cost, and remaining budget as the rows update.' },
|
|
73
|
+
{ name: 'Read exclusions', text: 'Distinguish proposals that cannot fit from proposals that lost to a better feasible combination.' },
|
|
74
|
+
{ name: 'Apply human checks', text: 'Compare the scenario with eligibility, dependencies, delivery capacity, and official participation rules.' },
|
|
75
|
+
],
|
|
76
|
+
schemas: [softwareApplication, faqPage, howTo] as unknown as Record<string, unknown>[],
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const englishContent: ParticipatoryBudgetLocaleContent = content;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { FAQPage, HowTo, SoftwareApplication } from 'schema-dts';
|
|
2
|
+
import { bibliography } from '../bibliography';
|
|
3
|
+
import type { ParticipatoryBudgetLocaleContent } from '../entry';
|
|
4
|
+
import type { ParticipatoryBudgetUI } from '../ui';
|
|
5
|
+
|
|
6
|
+
const ui: ParticipatoryBudgetUI = {
|
|
7
|
+
budgetLabel: 'Presupuesto disponible', budgetHelp: 'Usa una moneda y escribe el tope que no debe superarse.', proposalsHeading: 'Añade propuestas con una puntuación comparable', proposalNameLabel: 'Nombre de la propuesta', proposalCostLabel: 'Coste', proposalScoreLabel: 'Votos o puntuación de beneficio', addProposal: 'Añadir propuesta', removeProposal: 'Eliminar', calculateNote: 'La selección se actualiza mientras editas las filas. Cada propuesta se financia completa o queda fuera.', loadExample: 'Cargar ejemplo', printAction: 'Imprimir hoja de revisión', resultHeading: 'Mosaico presupuestario', emptyResult: 'Añade propuestas para ver qué conjunto completo cabe en el presupuesto.', excludedLabel: 'No financiada', spentLabel: 'Coste total', remainingLabel: 'Presupuesto restante', scoreLabel: 'Puntuación total', proposalCountLabel: 'propuestas', selectedStatus: 'Financiada', tooExpensiveStatus: 'No cabe', notSelectedStatus: 'Fuera', invalidStatus: 'Corregir', reasonLabel: 'Por qué', methodHeading: 'Método aplicado', methodText: 'La herramienta resuelve un modelo de mochila 0/1. Cada propuesta tiene un coste y una puntuación comparable. Busca combinaciones viables, maximiza la puntuación y después usa menor coste, menos propuestas y el orden de entrada como desempates deterministas.', limitsHeading: 'Lo que esta herramienta no hace', limitsText: 'No decide si una propuesta es justa, legal, elegible, ejecutable o socialmente preferible. No sustituye las reglas del presupuesto participativo ni una votación.', warningsHeading: 'Casos límite y avisos de datos', warningsText: 'Mantén comparables las puntuaciones y usa la misma base de costes. Los ceros, nombres duplicados, estimaciones incompletas, dependencias y beneficios divisibles requieren revisión humana.', noProposalsWarning: 'No se han introducido propuestas.', noFeasibleWarning: 'Ninguna propuesta cabe en el presupuesto disponible.', invalidBudget: 'Introduce un presupuesto no negativo. El modelo del navegador acepta hasta 20.000,00 en la moneda elegida.', budgetTooLarge: 'El presupuesto supera el límite del modelo de 20.000,00. Reduce el tope o divide el ejercicio.', invalidProposal: 'Cada fila necesita un nombre, un coste no negativo y una puntuación no negativa.', costExceedsBudget: 'Este coste supera el presupuesto disponible. La propuesta no puede seleccionarse.', tieBreakLabel: 'Regla determinista', budgetUsedLabel: 'usado', budgetLeftLabel: 'restante', selectedTilesLabel: 'Bloques seleccionados', allocationLabel: 'Capacidad presupuestaria', statusSaved: 'Borrador guardado localmente en este navegador.',
|
|
8
|
+
};
|
|
9
|
+
const faq = [
|
|
10
|
+
{ question: '¿Qué optimiza este asignador?', answer: 'Elige un conjunto viable de propuestas completas que maximiza la puntuación total que introduces sin superar el tope. No decide si esa puntuación es justa o significativa.' },
|
|
11
|
+
{ question: '¿Por qué las propuestas son todo o nada?', answer: 'La primera versión usa un modelo 0/1. Cada propuesta se financia por su coste completo o se excluye, algo adecuado para proyectos cuyo valor depende de ejecutarlos enteros.' },
|
|
12
|
+
{ question: '¿Qué ocurre si dos asignaciones tienen la misma puntuación?', answer: 'Se elige primero el menor coste total, después menos propuestas y, por último, el orden de entrada más temprano. Así otra persona puede reproducir el desempate.' },
|
|
13
|
+
{ question: '¿Puede este resultado decidir un presupuesto real?', answer: 'No. Es un escenario transparente. Comprueba elegibilidad, dependencias, capacidad de ejecución, base monetaria y proceso oficial.' },
|
|
14
|
+
];
|
|
15
|
+
const howTo = [
|
|
16
|
+
{ name: 'Fija el tope', text: 'Introduce el importe disponible para esta ronda y mantén constante la base monetaria.' },
|
|
17
|
+
{ name: 'Añade propuestas completas', text: 'Introduce cada propuesta una vez con su coste completo y una puntuación comparable.' },
|
|
18
|
+
{ name: 'Inspecciona el mosaico', text: 'Revisa conjunto financiado, puntuación, coste y presupuesto restante mientras cambian las filas.' },
|
|
19
|
+
{ name: 'Lee las exclusiones', text: 'Distingue las propuestas que no caben por sí solas de las que pierden frente a una combinación viable mejor.' },
|
|
20
|
+
{ name: 'Aplica controles humanos', text: 'Compara el escenario con elegibilidad, dependencias, capacidad y reglas oficiales antes de usarlo en una reunión.' },
|
|
21
|
+
];
|
|
22
|
+
const softwareApplication: SoftwareApplication = { '@type': 'SoftwareApplication', name: 'Asignador de presupuesto participativo', applicationCategory: 'EducationalApplication', operatingSystem: 'Any', description: 'Selecciona propuestas públicas completas bajo un tope presupuestario con una regla de optimización transparente.', url: 'https://gamebob.dev/es/asignador-presupuesto-participativo', offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' } };
|
|
23
|
+
const faqSchema: FAQPage = { '@type': 'FAQPage', mainEntity: faq.map((item) => ({ '@type': 'Question', name: item.question, acceptedAnswer: { '@type': 'Answer', text: item.answer } })) };
|
|
24
|
+
const howToSchema: HowTo = { '@type': 'HowTo', name: 'Asignar un presupuesto participativo limitado', step: howTo.map((step) => ({ '@type': 'HowToStep', name: step.name, text: step.text })) };
|
|
25
|
+
|
|
26
|
+
export const content: ParticipatoryBudgetLocaleContent = { slug: 'asignador-presupuesto-participativo', title: 'Asignador de presupuesto participativo', description: 'Selecciona propuestas completas bajo un presupuesto fijo, maximiza una puntuación declarada y entiende el coste y el motivo de cada exclusión.', ui, seo: [
|
|
27
|
+
{ type: 'title', text: 'Convierte un tope presupuestario en un escenario reproducible', level: 2 },
|
|
28
|
+
{ type: 'paragraph', html: 'En una ronda de presupuesto público suele haber más propuestas valiosas que dinero disponible. Este asignador hace explícito el intercambio: introduce el tope, añade propuestas completas y asigna a cada una votos o una puntuación de beneficio comparable. El resultado muestra un conjunto viable, no una recomendación opaca.' },
|
|
29
|
+
{ type: 'title', text: 'Cómo se calcula la selección', level: 2 },
|
|
30
|
+
{ type: 'paragraph', html: 'El modelo es una mochila 0/1. El navegador compara combinaciones que no superan el tope y conserva la de mayor puntuación total. Después aplica un desempate visible y determinista: menor coste, menos propuestas y orden de entrada. La puntuación es el objetivo que declaras, no una medida independiente del valor público.' },
|
|
31
|
+
{ type: 'table', headers: ['Campo', 'Significado', 'Pregunta de revisión'], rows: [['Coste', 'Importe completo para entregar la propuesta', '¿Todas las filas usan la misma base?'], ['Puntuación', 'Votos o valor de beneficio aportado', '¿Se pueden comparar entre propuestas?'], ['Presupuesto', 'Coste total máximo de la selección', '¿El tope incluye los mismos costes que cada fila?'], ['Exclusión', 'No cabe por sí sola o pierde ante un conjunto mejor', '¿El motivo es matemático o necesita revisión política?']] },
|
|
32
|
+
{ type: 'title', text: 'Prepara entradas que no engañen', level: 2 },
|
|
33
|
+
{ type: 'list', items: ['Confirma que todas las propuestas son elegibles para la misma ronda.', 'Usa el coste completo o divide solo en fases realmente independientes.', 'Registra cómo se produjeron los votos o las puntuaciones.', 'Comprueba dependencias, mantenimiento, legalidad y capacidad por separado.', 'Guarda la selección y los motivos de exclusión como acta de revisión.'] },
|
|
34
|
+
{ type: 'tip', title: 'Una puntuación es un objetivo elegido', html: 'Si representa votos, el resultado maximiza votos totales. Si representa beneficio, maximiza esa evaluación. Cambiar la puntuación cambia la pregunta, así que conserva el método junto al resultado.' },
|
|
35
|
+
{ type: 'title', text: 'Lee los conjuntos financiado y excluido', level: 2 },
|
|
36
|
+
{ type: 'paragraph', html: 'Las propuestas financiadas muestran coste total, puntuación y presupuesto restante. Una propuesta que no cabe cuesta más que el tope por sí sola. Una propuesta fuera podría caber sola, pero no forma parte de la mejor combinación bajo el objetivo y el desempate elegidos. Son conversaciones operativas distintas.' },
|
|
37
|
+
{ type: 'title', text: 'Límites y siguientes comprobaciones', level: 2 },
|
|
38
|
+
{ type: 'paragraph', html: 'El asignador no juzga la justicia entre barrios, el riesgo de ejecución, los conflictos entre proyectos ni la elegibilidad legal. Tampoco sabe si la puntuación representa necesidades ausentes. El modelo del navegador está limitado a 20.000,00 para mantener rápida la búsqueda exacta.' },
|
|
39
|
+
{ type: 'paragraph', html: 'Una combinación con mucha puntuación solo es sólida si sus costes y la forma de obtener las puntuaciones también lo son.' },
|
|
40
|
+
{ type: 'title', text: 'Usa el presupuesto restante para conversar', level: 2 },
|
|
41
|
+
{ type: 'paragraph', html: 'Un sobrante puede indicar que ningún proyecto adicional mejora la selección actual. También puede señalar que falta una propuesta elegible o que se ha reservado dinero de forma intencionada.' },
|
|
42
|
+
{ type: 'tip', title: 'Revisa antes de decidir', html: 'Lee la selección matemática junto con las reglas oficiales de participación y las voces de las personas afectadas.' },
|
|
43
|
+
], faq, bibliography, howTo, schemas: [softwareApplication, faqSchema, howToSchema] as unknown as Record<string, unknown>[] };
|