@jjlmoya/utils-civic 1.4.0 → 1.6.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 +6 -0
- package/src/index.ts +2 -0
- package/src/pages/[locale]/[slug].astro +7 -4
- package/src/tests/locale_completeness.test.ts +1 -1
- package/src/tests/tool_validation.test.ts +1 -1
- package/src/tool/civic-priority-matrix/bibliography.astro +6 -0
- package/src/tool/civic-priority-matrix/bibliography.ts +12 -0
- package/src/tool/civic-priority-matrix/civic-priority-matrix.css +531 -0
- package/src/tool/civic-priority-matrix/component.astro +108 -0
- package/src/tool/civic-priority-matrix/contract.test.ts +16 -0
- package/src/tool/civic-priority-matrix/controller.ts +168 -0
- package/src/tool/civic-priority-matrix/dom-views.ts +123 -0
- package/src/tool/civic-priority-matrix/entry.ts +28 -0
- package/src/tool/civic-priority-matrix/evaluator.ts +13 -0
- package/src/tool/civic-priority-matrix/i18n/de.ts +52 -0
- package/src/tool/civic-priority-matrix/i18n/en.ts +73 -0
- package/src/tool/civic-priority-matrix/i18n/es.ts +52 -0
- package/src/tool/civic-priority-matrix/i18n/fr.ts +50 -0
- package/src/tool/civic-priority-matrix/i18n/id.ts +50 -0
- package/src/tool/civic-priority-matrix/i18n/it.ts +50 -0
- package/src/tool/civic-priority-matrix/i18n/ja.ts +121 -0
- package/src/tool/civic-priority-matrix/i18n/ko.ts +121 -0
- package/src/tool/civic-priority-matrix/i18n/nl.ts +45 -0
- package/src/tool/civic-priority-matrix/i18n/pl.ts +35 -0
- package/src/tool/civic-priority-matrix/i18n/pt.ts +32 -0
- package/src/tool/civic-priority-matrix/i18n/ru.ts +32 -0
- package/src/tool/civic-priority-matrix/i18n/sv.ts +32 -0
- package/src/tool/civic-priority-matrix/i18n/tr.ts +32 -0
- package/src/tool/civic-priority-matrix/i18n/zh.ts +121 -0
- package/src/tool/civic-priority-matrix/index.ts +11 -0
- package/src/tool/civic-priority-matrix/logic.test.ts +68 -0
- package/src/tool/civic-priority-matrix/logic.ts +124 -0
- package/src/tool/civic-priority-matrix/seo.astro +12 -0
- package/src/tool/civic-priority-matrix/storage.ts +50 -0
- package/src/tool/civic-priority-matrix/ui.ts +96 -0
- 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/tools.ts +4 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { SEORenderer } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { civicPriorityMatrix } from './entry';
|
|
4
|
+
import type { KnownLocale } from '../../types';
|
|
5
|
+
|
|
6
|
+
interface Props { locale?: KnownLocale; }
|
|
7
|
+
|
|
8
|
+
const { locale = 'en' } = Astro.props as Props;
|
|
9
|
+
const content = await civicPriorityMatrix.i18n[locale]?.() ?? await civicPriorityMatrix.i18n.en?.();
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
{content && <SEORenderer content={{ locale, sections: content.seo }} />}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { InitiativeInput } from './logic';
|
|
2
|
+
|
|
3
|
+
export interface CivicPriorityState {
|
|
4
|
+
initiatives: InitiativeInput[];
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const STORAGE_KEY = 'jjlmoya-civic-priority-matrix';
|
|
8
|
+
|
|
9
|
+
export const defaultInitiatives: InitiativeInput[] = [
|
|
10
|
+
{ id: 'accessible-bus-stops', name: 'Accessible bus stops', impact: 5, urgency: 4, effort: 2 },
|
|
11
|
+
{ id: 'cooling-centres', name: 'Cooling centres', impact: 4, urgency: 5, effort: 3 },
|
|
12
|
+
{ id: 'meeting-transcripts', name: 'Public meeting transcripts', impact: 3, urgency: 2, effort: 1 },
|
|
13
|
+
{ id: 'park-lighting', name: 'Park lighting repair', impact: 4, urgency: 3, effort: 4 },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
function isInitiative(value: unknown): value is InitiativeInput {
|
|
17
|
+
if (!value || typeof value !== 'object') return false;
|
|
18
|
+
const item = value as Record<string, unknown>;
|
|
19
|
+
return typeof item.id === 'string'
|
|
20
|
+
&& typeof item.name === 'string'
|
|
21
|
+
&& typeof item.impact === 'number'
|
|
22
|
+
&& typeof item.urgency === 'number'
|
|
23
|
+
&& typeof item.effort === 'number';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isState(value: unknown): value is CivicPriorityState {
|
|
27
|
+
if (!value || typeof value !== 'object') return false;
|
|
28
|
+
const state = value as Record<string, unknown>;
|
|
29
|
+
return Array.isArray(state.initiatives) && state.initiatives.every(isInitiative);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function loadCivicPriorityState(): CivicPriorityState | null {
|
|
33
|
+
try {
|
|
34
|
+
if (typeof window === 'undefined') return null;
|
|
35
|
+
const raw = window.localStorage.getItem(STORAGE_KEY);
|
|
36
|
+
if (!raw) return null;
|
|
37
|
+
const parsed: unknown = JSON.parse(raw);
|
|
38
|
+
return isState(parsed) ? parsed : null;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function saveCivicPriorityState(state: CivicPriorityState): void {
|
|
45
|
+
try {
|
|
46
|
+
if (typeof window !== 'undefined') window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
|
47
|
+
} catch {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
export interface CivicPriorityUI {
|
|
2
|
+
[key: string]: string;
|
|
3
|
+
addInitiative: string;
|
|
4
|
+
initiativeList: string;
|
|
5
|
+
inputHint: string;
|
|
6
|
+
initiative: string;
|
|
7
|
+
impact: string;
|
|
8
|
+
urgency: string;
|
|
9
|
+
effort: string;
|
|
10
|
+
scoreScale: string;
|
|
11
|
+
scoreHint: string;
|
|
12
|
+
removeInitiative: string;
|
|
13
|
+
reset: string;
|
|
14
|
+
resultTitle: string;
|
|
15
|
+
rankingTitle: string;
|
|
16
|
+
matrixLabel: string;
|
|
17
|
+
summaryPrefix: string;
|
|
18
|
+
summarySuffix: string;
|
|
19
|
+
rank: string;
|
|
20
|
+
priorityScore: string;
|
|
21
|
+
denominator: string;
|
|
22
|
+
quadrant: string;
|
|
23
|
+
emptyState: string;
|
|
24
|
+
invalidState: string;
|
|
25
|
+
nameRequired: string;
|
|
26
|
+
impactInvalid: string;
|
|
27
|
+
urgencyInvalid: string;
|
|
28
|
+
effortInvalid: string;
|
|
29
|
+
actNow: string;
|
|
30
|
+
plan: string;
|
|
31
|
+
quickResponse: string;
|
|
32
|
+
defer: string;
|
|
33
|
+
highImpact: string;
|
|
34
|
+
lowImpact: string;
|
|
35
|
+
highUrgency: string;
|
|
36
|
+
lowUrgency: string;
|
|
37
|
+
methodTitle: string;
|
|
38
|
+
methodText: string;
|
|
39
|
+
notDoTitle: string;
|
|
40
|
+
notDoText: string;
|
|
41
|
+
edgeCasesTitle: string;
|
|
42
|
+
edgeCasesText: string;
|
|
43
|
+
warningTitle: string;
|
|
44
|
+
zeroEffortWarning: string;
|
|
45
|
+
invalidRowsWarning: string;
|
|
46
|
+
noInitiatives: string;
|
|
47
|
+
scoreOutOf: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const ui: CivicPriorityUI = {
|
|
51
|
+
addInitiative: 'Add initiative',
|
|
52
|
+
initiativeList: 'Initiatives to score',
|
|
53
|
+
inputHint: 'Give every proposal the same 1 to 5 scale before comparing them.',
|
|
54
|
+
initiative: 'Initiative',
|
|
55
|
+
impact: 'Impact',
|
|
56
|
+
urgency: 'Urgency',
|
|
57
|
+
effort: 'Effort',
|
|
58
|
+
scoreScale: 'Score from 1 to 5',
|
|
59
|
+
scoreHint: 'Higher means more impact, more urgency, or more effort.',
|
|
60
|
+
removeInitiative: 'Remove initiative',
|
|
61
|
+
reset: 'Reset example',
|
|
62
|
+
resultTitle: 'Decision field',
|
|
63
|
+
rankingTitle: 'Explainable order',
|
|
64
|
+
matrixLabel: 'Initiatives placed by impact and urgency',
|
|
65
|
+
summaryPrefix: 'Showing',
|
|
66
|
+
summarySuffix: 'scored initiatives',
|
|
67
|
+
rank: 'Rank',
|
|
68
|
+
priorityScore: 'Priority score',
|
|
69
|
+
denominator: 'Effort denominator',
|
|
70
|
+
quadrant: 'Quadrant',
|
|
71
|
+
emptyState: 'Add an initiative to open the decision field.',
|
|
72
|
+
invalidState: 'Fix the highlighted values to calculate a priority order.',
|
|
73
|
+
nameRequired: 'Name is required.',
|
|
74
|
+
impactInvalid: 'Impact must be a whole number from 1 to 5.',
|
|
75
|
+
urgencyInvalid: 'Urgency must be a whole number from 1 to 5.',
|
|
76
|
+
effortInvalid: 'Effort must be a whole number from 0 to 5.',
|
|
77
|
+
actNow: 'Act now',
|
|
78
|
+
plan: 'Plan',
|
|
79
|
+
quickResponse: 'Quick response',
|
|
80
|
+
defer: 'Defer',
|
|
81
|
+
highImpact: 'High impact',
|
|
82
|
+
lowImpact: 'Low impact',
|
|
83
|
+
highUrgency: 'High urgency',
|
|
84
|
+
lowUrgency: 'Low urgency',
|
|
85
|
+
methodTitle: 'Method applied',
|
|
86
|
+
methodText: 'The priority score is impact multiplied by urgency, divided by effort. Impact, urgency, and effort use a visible 1 to 5 scale. Effort 0 is treated as 1 so a free or already-resourced action remains finite rather than becoming an infinite score. The ranking breaks ties by impact, urgency, lower effort, then input order.',
|
|
87
|
+
notDoTitle: 'What this tool does not do',
|
|
88
|
+
notDoText: 'It does not establish objective social value, measure outcomes automatically, or replace deliberation. A high score is a transparent scenario under your assumptions, not a mandate or a prediction of public benefit.',
|
|
89
|
+
edgeCasesTitle: 'Edge cases and warnings',
|
|
90
|
+
edgeCasesText: 'Keep the same interpretation of each score across every row. A missing or non-integer score excludes that row until fixed. Score 3 is the dividing line in the field. Equal scores remain in stable input order after the documented tie-breakers. Change one assessment at a time when discussing a result with a group.',
|
|
91
|
+
warningTitle: 'Review before deciding',
|
|
92
|
+
zeroEffortWarning: 'An effort score of 0 uses a denominator of 1 to keep the score finite and comparable.',
|
|
93
|
+
invalidRowsWarning: 'Some rows are excluded until their highlighted values are fixed.',
|
|
94
|
+
noInitiatives: 'No initiatives entered yet.',
|
|
95
|
+
scoreOutOf: 'of 25 maximum',
|
|
96
|
+
};
|
|
@@ -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>[] };
|