@jjlmoya/utils-civic 1.5.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.
Files changed (38) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +2 -1
  3. package/src/entries.ts +2 -0
  4. package/src/index.ts +1 -0
  5. package/src/tests/locale_completeness.test.ts +1 -1
  6. package/src/tests/tool_validation.test.ts +1 -1
  7. package/src/tool/participatory-budget-allocator/bibliography.astro +6 -0
  8. package/src/tool/participatory-budget-allocator/bibliography.ts +8 -0
  9. package/src/tool/participatory-budget-allocator/component.astro +61 -0
  10. package/src/tool/participatory-budget-allocator/contract.test.ts +13 -0
  11. package/src/tool/participatory-budget-allocator/controller.ts +108 -0
  12. package/src/tool/participatory-budget-allocator/dom-views.test.ts +14 -0
  13. package/src/tool/participatory-budget-allocator/dom-views.ts +58 -0
  14. package/src/tool/participatory-budget-allocator/entry.ts +27 -0
  15. package/src/tool/participatory-budget-allocator/evaluator.ts +24 -0
  16. package/src/tool/participatory-budget-allocator/i18n/de.ts +44 -0
  17. package/src/tool/participatory-budget-allocator/i18n/en.ts +79 -0
  18. package/src/tool/participatory-budget-allocator/i18n/es.ts +43 -0
  19. package/src/tool/participatory-budget-allocator/i18n/fr.ts +43 -0
  20. package/src/tool/participatory-budget-allocator/i18n/id.ts +43 -0
  21. package/src/tool/participatory-budget-allocator/i18n/it.ts +43 -0
  22. package/src/tool/participatory-budget-allocator/i18n/ja.ts +43 -0
  23. package/src/tool/participatory-budget-allocator/i18n/ko.ts +43 -0
  24. package/src/tool/participatory-budget-allocator/i18n/nl.ts +43 -0
  25. package/src/tool/participatory-budget-allocator/i18n/pl.ts +43 -0
  26. package/src/tool/participatory-budget-allocator/i18n/pt.ts +43 -0
  27. package/src/tool/participatory-budget-allocator/i18n/ru.ts +43 -0
  28. package/src/tool/participatory-budget-allocator/i18n/sv.ts +43 -0
  29. package/src/tool/participatory-budget-allocator/i18n/tr.ts +43 -0
  30. package/src/tool/participatory-budget-allocator/i18n/zh.ts +43 -0
  31. package/src/tool/participatory-budget-allocator/index.ts +11 -0
  32. package/src/tool/participatory-budget-allocator/logic.test.ts +48 -0
  33. package/src/tool/participatory-budget-allocator/logic.ts +146 -0
  34. package/src/tool/participatory-budget-allocator/participatory-budget-allocator.css +426 -0
  35. package/src/tool/participatory-budget-allocator/seo.astro +14 -0
  36. package/src/tool/participatory-budget-allocator/storage.ts +32 -0
  37. package/src/tool/participatory-budget-allocator/ui.ts +88 -0
  38. package/src/tools.ts +2 -0
@@ -0,0 +1,426 @@
1
+ .participatory-budget-tool {
2
+ --n-ink: #172238;
3
+ --n-muted: #5b687b;
4
+ --n-paper: #fbf7ef;
5
+ --n-surface: #fffdf8;
6
+ --n-line: #d9d4c9;
7
+ --n-cobalt: #2457a6;
8
+ --n-cobalt-soft: #dce8fa;
9
+ --n-coral: #c95d43;
10
+ --n-coral-soft: #f8e0d7;
11
+ --n-moss: #39725c;
12
+ --n-moss-soft: #dcefe3;
13
+ --n-shadow: #1722381a;
14
+ --n-button-text: #fff;
15
+
16
+ color: var(--n-ink);
17
+ background: var(--n-paper);
18
+ border: 1px solid var(--n-line);
19
+ border-radius: 1.25rem;
20
+ box-shadow: 0 0.8rem 2rem var(--n-shadow);
21
+ overflow: hidden;
22
+ }
23
+
24
+ .theme-dark .participatory-budget-tool {
25
+ --n-ink: #f6f1e7;
26
+ --n-muted: #b8c0ce;
27
+ --n-paper: #151b28;
28
+ --n-surface: #202938;
29
+ --n-line: #3d495c;
30
+ --n-cobalt: #8eb7ff;
31
+ --n-cobalt-soft: #253b5d;
32
+ --n-coral: #ff9b7e;
33
+ --n-coral-soft: #4b2b2b;
34
+ --n-moss: #83d1a7;
35
+ --n-moss-soft: #214737;
36
+ --n-shadow: #0005;
37
+ --n-button-text: #172238;
38
+ }
39
+
40
+ .allocation-layout {
41
+ display: grid;
42
+ grid-template-columns: minmax(18rem, 0.9fr) minmax(20rem, 1.1fr);
43
+ gap: 0;
44
+ }
45
+
46
+ .allocation-form,
47
+ .allocation-result {
48
+ padding: clamp(1.2rem, 3vw, 2.2rem);
49
+ }
50
+
51
+ .allocation-form {
52
+ border-right: 1px solid var(--n-line);
53
+ }
54
+
55
+ .field-label,
56
+ .proposal-row label,
57
+ .budget-field {
58
+ color: var(--n-muted);
59
+ font-size: 0.75rem;
60
+ font-weight: 700;
61
+ letter-spacing: 0.05em;
62
+ text-transform: uppercase;
63
+ }
64
+
65
+ .budget-field,
66
+ .proposal-row label {
67
+ display: grid;
68
+ gap: 0.42rem;
69
+ }
70
+
71
+ .budget-field input,
72
+ .proposal-row input {
73
+ box-sizing: border-box;
74
+ width: 100%;
75
+ min-height: 2.8rem;
76
+ padding: 0.65rem 0.75rem;
77
+ border: 1px solid var(--n-line);
78
+ border-radius: 0.55rem;
79
+ background: var(--n-surface);
80
+ color: var(--n-ink);
81
+ font: inherit;
82
+ }
83
+
84
+ .budget-field input:focus,
85
+ .proposal-row input:focus,
86
+ button:focus-visible {
87
+ outline: 3px solid var(--n-cobalt);
88
+ outline-offset: 2px;
89
+ }
90
+
91
+ .field-help,
92
+ .calculate-note,
93
+ .tool-status {
94
+ color: var(--n-muted);
95
+ font-size: 0.82rem;
96
+ line-height: 1.5;
97
+ }
98
+
99
+ .field-help { margin: 0.45rem 0 0; }
100
+
101
+ .proposal-heading,
102
+ .result-heading {
103
+ display: flex;
104
+ align-items: baseline;
105
+ justify-content: space-between;
106
+ gap: 1rem;
107
+ margin: 1.7rem 0 0.8rem;
108
+ }
109
+
110
+ .proposal-heading h2,
111
+ .result-heading h2,
112
+ .guardrail h2 {
113
+ margin: 0;
114
+ font-size: 1.1rem;
115
+ }
116
+
117
+ .proposal-heading h2 { max-width: 18rem; }
118
+
119
+ .proposal-rows {
120
+ display: grid;
121
+ gap: 0.7rem;
122
+ }
123
+
124
+ .proposal-row {
125
+ display: grid;
126
+ grid-template-columns: 1.35fr 0.7fr 0.8fr auto;
127
+ align-items: end;
128
+ gap: 0.55rem;
129
+ padding: 0.8rem;
130
+ border: 1px solid var(--n-line);
131
+ border-radius: 0.75rem;
132
+ background: var(--n-surface);
133
+ }
134
+
135
+ .proposal-row.proposal-over-budget,
136
+ .proposal-row.proposal-invalid {
137
+ border-color: var(--n-coral);
138
+ background: var(--n-coral-soft);
139
+ }
140
+
141
+ .proposal-row.proposal-over-budget input,
142
+ .proposal-row.proposal-invalid input {
143
+ border-color: var(--n-coral);
144
+ }
145
+
146
+ .row-warning {
147
+ grid-column: 1 / -1;
148
+ color: var(--n-coral);
149
+ font-size: 0.75rem;
150
+ font-weight: 700;
151
+ line-height: 1.35;
152
+ }
153
+
154
+ button {
155
+ border: 1px solid var(--n-cobalt);
156
+ border-radius: 999px;
157
+ padding: 0.65rem 0.9rem;
158
+ background: var(--n-cobalt);
159
+ color: var(--n-button-text);
160
+ cursor: pointer;
161
+ font: inherit;
162
+ font-weight: 700;
163
+ }
164
+
165
+ .add-proposal,
166
+ .secondary-action,
167
+ .remove-proposal,
168
+ .print-action {
169
+ background: transparent;
170
+ color: var(--n-cobalt);
171
+ }
172
+
173
+ .add-proposal { white-space: nowrap; }
174
+
175
+ .remove-proposal {
176
+ border-color: var(--n-line);
177
+ color: var(--n-coral);
178
+ font-size: 0.75rem;
179
+ padding-inline: 0.65rem;
180
+ }
181
+
182
+ .form-actions {
183
+ display: flex;
184
+ flex-wrap: wrap;
185
+ gap: 0.6rem;
186
+ margin-top: 1.2rem;
187
+ }
188
+
189
+ .tool-status {
190
+ min-height: 1.2rem;
191
+ margin: 0.8rem 0 0;
192
+ }
193
+
194
+ .result-heading { margin-top: 0; }
195
+
196
+ .result-heading h2 { color: var(--n-cobalt); }
197
+
198
+ .capacity-scene {
199
+ margin: 1.3rem 0 1.4rem;
200
+ padding: 1rem;
201
+ border: 1px solid var(--n-line);
202
+ border-radius: 1rem;
203
+ background: var(--n-cobalt-soft);
204
+ }
205
+
206
+ .scene-caption {
207
+ display: flex;
208
+ justify-content: space-between;
209
+ gap: 1rem;
210
+ color: var(--n-muted);
211
+ font-size: 0.78rem;
212
+ font-weight: 700;
213
+ text-transform: uppercase;
214
+ }
215
+
216
+ .scene-caption b { color: var(--n-cobalt); }
217
+
218
+ .capacity-track {
219
+ height: 3.6rem;
220
+ margin: 0.7rem 0;
221
+ padding: 0.35rem;
222
+ border: 1px solid var(--n-line);
223
+ border-radius: 0.6rem;
224
+ background: var(--n-surface);
225
+ }
226
+
227
+ .capacity-segments {
228
+ display: flex;
229
+ height: 100%;
230
+ border-radius: 0.35rem;
231
+ overflow: hidden;
232
+ }
233
+
234
+ .scene-tile {
235
+ display: block;
236
+ flex: 0 0 var(--tile-width);
237
+ background: var(--n-coral);
238
+ }
239
+
240
+ .scene-tile + .scene-tile { border-left: 1px solid var(--n-paper); }
241
+
242
+ .scene-remainder {
243
+ display: block;
244
+ flex: 0 0 var(--tile-width);
245
+ background: var(--n-cobalt);
246
+ }
247
+
248
+ .scene-footer { font-size: 0.7rem; }
249
+
250
+ .status-line {
251
+ display: flex;
252
+ align-items: center;
253
+ gap: 0.55rem;
254
+ font-size: 1.05rem;
255
+ }
256
+
257
+ .status-dot {
258
+ width: 0.75rem;
259
+ height: 0.75rem;
260
+ border-radius: 50%;
261
+ background: var(--n-muted);
262
+ }
263
+
264
+ .status-positive .status-dot { background: var(--n-moss); }
265
+ .status-warning .status-dot { background: var(--n-coral); }
266
+ .status-danger .status-dot { background: var(--n-coral); }
267
+
268
+ .result-summary p {
269
+ margin: 0.5rem 0 0;
270
+ color: var(--n-muted);
271
+ line-height: 1.5;
272
+ }
273
+
274
+ .result-metrics {
275
+ display: grid;
276
+ grid-template-columns: repeat(4, 1fr);
277
+ gap: 0.5rem;
278
+ margin: 1rem 0 1.4rem;
279
+ }
280
+
281
+ .result-metrics div {
282
+ display: grid;
283
+ gap: 0.25rem;
284
+ padding: 0.65rem;
285
+ border-top: 2px solid var(--n-cobalt);
286
+ }
287
+
288
+ .result-metrics span {
289
+ color: var(--n-muted);
290
+ font-size: 0.7rem;
291
+ }
292
+ .result-metrics strong { font-size: 1.05rem; }
293
+
294
+ .result-section { margin-top: 1.2rem; }
295
+ .result-section h3 {
296
+ margin: 0 0 0.6rem;
297
+ font-size: 0.9rem;
298
+ }
299
+
300
+ .mosaic-list {
301
+ display: grid;
302
+ gap: 0.55rem;
303
+ margin: 0;
304
+ padding: 0;
305
+ list-style: none;
306
+ }
307
+
308
+ .mosaic-tile {
309
+ padding: 0.7rem;
310
+ border-left: 4px solid var(--n-line);
311
+ background: var(--n-surface);
312
+ }
313
+
314
+ .mosaic-tile.selected { border-color: var(--n-moss); }
315
+ .mosaic-tile.too-expensive,
316
+ .mosaic-tile.invalid { border-color: var(--n-coral); }
317
+
318
+ .tile-top,
319
+ .tile-data {
320
+ display: flex;
321
+ justify-content: space-between;
322
+ gap: 0.7rem;
323
+ }
324
+
325
+ .tile-top { align-items: start; }
326
+
327
+ .tile-status {
328
+ color: var(--n-muted);
329
+ font-size: 0.7rem;
330
+ font-weight: 700;
331
+ text-transform: uppercase;
332
+ }
333
+
334
+ .tile-data {
335
+ margin-top: 0.35rem;
336
+ color: var(--n-cobalt);
337
+ font-size: 0.78rem;
338
+ }
339
+
340
+ .mosaic-tile p {
341
+ margin: 0.45rem 0 0;
342
+ color: var(--n-muted);
343
+ font-size: 0.78rem;
344
+ line-height: 1.4;
345
+ }
346
+
347
+ .empty-list {
348
+ color: var(--n-muted);
349
+ font-size: 0.82rem;
350
+ }
351
+
352
+ .result-warning {
353
+ margin: 1rem 0;
354
+ padding: 0.75rem 0.9rem;
355
+ border-left: 4px solid var(--n-coral);
356
+ background: var(--n-coral-soft);
357
+ color: var(--n-ink);
358
+ }
359
+
360
+ .result-warning p {
361
+ margin: 0.2rem 0;
362
+ font-size: 0.82rem;
363
+ line-height: 1.45;
364
+ }
365
+
366
+ .tie-break {
367
+ color: var(--n-muted);
368
+ font-size: 0.75rem;
369
+ line-height: 1.45;
370
+ }
371
+
372
+ .guardrails {
373
+ display: grid;
374
+ grid-template-columns: repeat(3, 1fr);
375
+ gap: 1px;
376
+ border-top: 1px solid var(--n-line);
377
+ background: var(--n-line);
378
+ }
379
+
380
+ .guardrail {
381
+ padding: 1.2rem;
382
+ background: var(--n-surface);
383
+ }
384
+
385
+ .guardrail-index {
386
+ color: var(--n-coral);
387
+ font-weight: 800;
388
+ }
389
+
390
+ .guardrail p {
391
+ margin: 0.65rem 0 0;
392
+ color: var(--n-muted);
393
+ font-size: 0.82rem;
394
+ line-height: 1.55;
395
+ }
396
+
397
+ @media (max-width: 800px) {
398
+ .allocation-layout { grid-template-columns: 1fr; }
399
+ .allocation-form {
400
+ border-right: 0;
401
+ border-bottom: 1px solid var(--n-line);
402
+ }
403
+ .guardrails { grid-template-columns: 1fr; }
404
+ }
405
+
406
+ @media (max-width: 520px) {
407
+ .proposal-row { grid-template-columns: 1fr 1fr; }
408
+ .proposal-row .proposal-name { grid-column: 1 / -1; }
409
+ .remove-proposal { justify-self: start; }
410
+ .result-metrics { grid-template-columns: repeat(2, 1fr); }
411
+ }
412
+
413
+ @media print {
414
+ .participatory-budget-tool {
415
+ border: 0;
416
+ box-shadow: none;
417
+ }
418
+
419
+ .allocation-form,
420
+ .guardrails,
421
+ .form-actions,
422
+ .tool-status {
423
+ display: none;
424
+ }
425
+ .allocation-result { padding: 0; }
426
+ }
@@ -0,0 +1,14 @@
1
+ ---
2
+ import { SEORenderer } from '@jjlmoya/utils-shared';
3
+ import { participatoryBudgetAllocator } from './entry';
4
+ import type { KnownLocale } from '../../types';
5
+
6
+ interface Props {
7
+ locale?: KnownLocale;
8
+ }
9
+
10
+ const { locale = 'en' } = Astro.props as Props;
11
+ const content = await participatoryBudgetAllocator.i18n[locale]?.();
12
+ ---
13
+
14
+ {content && <SEORenderer content={{ locale, sections: content.seo }} />}
@@ -0,0 +1,32 @@
1
+ import type { AllocationInput } from './logic';
2
+
3
+ const STORAGE_KEY = 'jjlmoya-civic-participatory-budget-v1';
4
+
5
+ function hasStorage(): boolean {
6
+ return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
7
+ }
8
+
9
+ function isSavedInput(value: unknown): value is AllocationInput {
10
+ if (!value || typeof value !== 'object') return false;
11
+ const candidate = value as Record<string, unknown>;
12
+ return typeof candidate.budget === 'number' && Array.isArray(candidate.proposals);
13
+ }
14
+
15
+ export function loadSavedInput(): AllocationInput | null {
16
+ if (!hasStorage()) return null;
17
+ try {
18
+ const raw = window.localStorage.getItem(STORAGE_KEY);
19
+ if (!raw) return null;
20
+ const parsed: unknown = JSON.parse(raw);
21
+ return isSavedInput(parsed) ? parsed : null;
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ export function saveInput(input: AllocationInput): void {
28
+ if (!hasStorage()) return;
29
+ try {
30
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(input));
31
+ } catch {}
32
+ }
@@ -0,0 +1,88 @@
1
+ export interface ParticipatoryBudgetUI {
2
+ [key: string]: string;
3
+ budgetLabel: string;
4
+ budgetHelp: string;
5
+ proposalsHeading: string;
6
+ proposalNameLabel: string;
7
+ proposalCostLabel: string;
8
+ proposalScoreLabel: string;
9
+ addProposal: string;
10
+ removeProposal: string;
11
+ calculateNote: string;
12
+ loadExample: string;
13
+ printAction: string;
14
+ resultHeading: string;
15
+ emptyResult: string;
16
+ excludedLabel: string;
17
+ spentLabel: string;
18
+ remainingLabel: string;
19
+ scoreLabel: string;
20
+ proposalCountLabel: string;
21
+ selectedStatus: string;
22
+ tooExpensiveStatus: string;
23
+ notSelectedStatus: string;
24
+ invalidStatus: string;
25
+ reasonLabel: string;
26
+ methodHeading: string;
27
+ methodText: string;
28
+ limitsHeading: string;
29
+ limitsText: string;
30
+ warningsHeading: string;
31
+ warningsText: string;
32
+ noProposalsWarning: string;
33
+ noFeasibleWarning: string;
34
+ invalidBudget: string;
35
+ budgetTooLarge: string;
36
+ invalidProposal: string;
37
+ costExceedsBudget: string;
38
+ tieBreakLabel: string;
39
+ budgetUsedLabel: string;
40
+ budgetLeftLabel: string;
41
+ selectedTilesLabel: string;
42
+ allocationLabel: string;
43
+ statusSaved: string;
44
+ }
45
+
46
+ export const ui: ParticipatoryBudgetUI = {
47
+ budgetLabel: 'Available budget',
48
+ budgetHelp: 'Use one currency and enter the ceiling that the allocation must not exceed.',
49
+ proposalsHeading: 'Add proposals with a comparable score',
50
+ proposalNameLabel: 'Proposal name',
51
+ proposalCostLabel: 'Cost',
52
+ proposalScoreLabel: 'Votes or benefit score',
53
+ addProposal: 'Add proposal',
54
+ removeProposal: 'Remove',
55
+ calculateNote: 'The allocation updates as you edit the rows. Every proposal is either fully funded or left out.',
56
+ loadExample: 'Load example',
57
+ printAction: 'Print review sheet',
58
+ resultHeading: 'Allocation mosaic',
59
+ emptyResult: 'Add proposals to see which complete set fits the budget.',
60
+ excludedLabel: 'Not funded',
61
+ spentLabel: 'Total cost',
62
+ remainingLabel: 'Budget left',
63
+ scoreLabel: 'Total score',
64
+ proposalCountLabel: 'proposals',
65
+ selectedStatus: 'Funded',
66
+ tooExpensiveStatus: 'Cannot fit',
67
+ notSelectedStatus: 'Left out',
68
+ invalidStatus: 'Needs correction',
69
+ reasonLabel: 'Why',
70
+ methodHeading: 'Method applied',
71
+ methodText: 'The tool solves a 0/1 knapsack model. Each proposal has a cost and a comparable score. It searches feasible combinations, maximizes total score, then uses lower cost, fewer proposals, and earlier input order as deterministic tie-breakers.',
72
+ limitsHeading: 'What this tool does not do',
73
+ limitsText: 'It does not decide whether a proposal is fair, legal, eligible, feasible to deliver, or socially preferable. It does not infer public value from a score and it does not replace a participatory budgeting rule or a vote.',
74
+ warningsHeading: 'Edge cases and data warnings',
75
+ warningsText: 'Keep scores comparable and use costs from the same price basis. A tie-breaker is not a public preference. Zero scores, duplicate names, incomplete estimates, dependencies between proposals, and benefits that can be split need human review.',
76
+ noProposalsWarning: 'No proposals were entered, so there is nothing to allocate.',
77
+ noFeasibleWarning: 'No proposal fits the available budget.',
78
+ invalidBudget: 'Enter a nonnegative budget. The browser model accepts up to 20,000.00 in the chosen currency.',
79
+ budgetTooLarge: 'The budget is above the browser model limit of 20,000.00. Reduce the ceiling or split the exercise into a smaller round.',
80
+ invalidProposal: 'Each row needs a name, a nonnegative cost, and a nonnegative score.',
81
+ costExceedsBudget: 'This cost exceeds the available budget. It cannot be part of the current allocation.',
82
+ tieBreakLabel: 'Deterministic rule',
83
+ budgetUsedLabel: 'used',
84
+ budgetLeftLabel: 'left',
85
+ selectedTilesLabel: 'Selected tiles',
86
+ allocationLabel: 'Budget capacity',
87
+ statusSaved: 'Draft saved locally in this browser.',
88
+ };
package/src/tools.ts CHANGED
@@ -5,6 +5,7 @@ import { CIVIC_PRIORITY_MATRIX_TOOL } from './tool/civic-priority-matrix';
5
5
  import { ELECTION_SEAT_APPORTIONMENT_CALCULATOR_TOOL } from './tool/election-seat-apportionment-calculator';
6
6
  import { REFERENDUM_THRESHOLD_CALCULATOR_TOOL } from './tool/referendum-threshold-calculator';
7
7
  import { VOTE_SEAT_DISPROPORTIONALITY_ANALYZER_TOOL } from './tool/vote-seat-disproportionality-analyzer';
8
+ import { PARTICIPATORY_BUDGET_ALLOCATOR_TOOL } from './tool/participatory-budget-allocator';
8
9
 
9
10
  export const ALL_TOOLS: ToolDefinition[] = [
10
11
  COALITION_MAJORITY_CALCULATOR_TOOL,
@@ -12,4 +13,5 @@ export const ALL_TOOLS: ToolDefinition[] = [
12
13
  ELECTION_SEAT_APPORTIONMENT_CALCULATOR_TOOL,
13
14
  REFERENDUM_THRESHOLD_CALCULATOR_TOOL,
14
15
  VOTE_SEAT_DISPROPORTIONALITY_ANALYZER_TOOL,
16
+ PARTICIPATORY_BUDGET_ALLOCATOR_TOOL,
15
17
  ];