@jjlmoya/utils-civic 1.1.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 (119) hide show
  1. package/.github/workflows/npm-publish.yml +40 -0
  2. package/.gitignore +6 -0
  3. package/.stylelintrc.json +98 -0
  4. package/astro.config.mjs +19 -0
  5. package/eslint.config.js +201 -0
  6. package/package.json +79 -0
  7. package/prompts/create_tool.md +98 -0
  8. package/prompts/i18n/de.md +16 -0
  9. package/prompts/i18n/en.md +16 -0
  10. package/prompts/i18n/es.md +16 -0
  11. package/prompts/i18n/fr.md +16 -0
  12. package/prompts/i18n/id.md +16 -0
  13. package/prompts/i18n/it.md +16 -0
  14. package/prompts/i18n/ja.md +16 -0
  15. package/prompts/i18n/ko.md +16 -0
  16. package/prompts/i18n/nl.md +16 -0
  17. package/prompts/i18n/pl.md +16 -0
  18. package/prompts/i18n/pt.md +16 -0
  19. package/prompts/i18n/ru.md +16 -0
  20. package/prompts/i18n/sv.md +16 -0
  21. package/prompts/i18n/tr.md +16 -0
  22. package/prompts/i18n/zh.md +16 -0
  23. package/prompts/seo.md +58 -0
  24. package/prompts/translations/french.md +33 -0
  25. package/scripts/postinstall.mjs +27 -0
  26. package/src/category/CivicCategorySEO.astro +9 -0
  27. package/src/category/i18n/de.ts +21 -0
  28. package/src/category/i18n/en.ts +25 -0
  29. package/src/category/i18n/es.ts +21 -0
  30. package/src/category/i18n/fr.ts +21 -0
  31. package/src/category/i18n/id.ts +21 -0
  32. package/src/category/i18n/it.ts +21 -0
  33. package/src/category/i18n/ja.ts +21 -0
  34. package/src/category/i18n/ko.ts +21 -0
  35. package/src/category/i18n/nl.ts +21 -0
  36. package/src/category/i18n/pl.ts +21 -0
  37. package/src/category/i18n/pt.ts +21 -0
  38. package/src/category/i18n/ru.ts +21 -0
  39. package/src/category/i18n/sv.ts +21 -0
  40. package/src/category/i18n/tr.ts +21 -0
  41. package/src/category/i18n/zh.ts +21 -0
  42. package/src/category/index.ts +24 -0
  43. package/src/components/PreviewNavSidebar.astro +116 -0
  44. package/src/components/PreviewToolbar.astro +143 -0
  45. package/src/data.ts +10 -0
  46. package/src/entries.ts +9 -0
  47. package/src/env.d.ts +5 -0
  48. package/src/index.ts +20 -0
  49. package/src/layouts/PreviewLayout.astro +117 -0
  50. package/src/pages/[locale]/[slug].astro +163 -0
  51. package/src/pages/[locale].astro +253 -0
  52. package/src/pages/index.astro +4 -0
  53. package/src/tests/bibliography_wellformed_export.test.ts +46 -0
  54. package/src/tests/category_seo_quality.test.ts +79 -0
  55. package/src/tests/diacritics_density.test.ts +118 -0
  56. package/src/tests/faq_count.test.ts +18 -0
  57. package/src/tests/i18n_coverage.test.ts +34 -0
  58. package/src/tests/inverted_punctuation.test.ts +84 -0
  59. package/src/tests/locale_completeness.test.ts +23 -0
  60. package/src/tests/mocks/astro_mock.js +2 -0
  61. package/src/tests/no_em_dash.test.ts +47 -0
  62. package/src/tests/no_en_dash.test.ts +70 -0
  63. package/src/tests/no_h1_in_components.test.ts +48 -0
  64. package/src/tests/pagespeed_best_practices.test.ts +198 -0
  65. package/src/tests/qa-test-helpers.ts +31 -0
  66. package/src/tests/qa_bibliography_links.test.ts +53 -0
  67. package/src/tests/qa_claim_evidence.test.ts +68 -0
  68. package/src/tests/qa_logic_reference_coverage.test.ts +45 -0
  69. package/src/tests/qa_runtime_i18n.test.ts +100 -0
  70. package/src/tests/registry_contract.test.ts +67 -0
  71. package/src/tests/schemas_fulfillment.test.ts +23 -0
  72. package/src/tests/script_density.test.ts +94 -0
  73. package/src/tests/seo_length.test.ts +23 -0
  74. package/src/tests/seo_parity.test.ts +60 -0
  75. package/src/tests/seo_translation_completeness.test.ts +75 -0
  76. package/src/tests/seo_wellformed_export.test.ts +65 -0
  77. package/src/tests/shared-test-helpers.ts +56 -0
  78. package/src/tests/slug_language_code_format.test.ts +23 -0
  79. package/src/tests/slug_uniqueness.test.ts +81 -0
  80. package/src/tests/spanish_leakage.test.ts +175 -0
  81. package/src/tests/title_quality.test.ts +55 -0
  82. package/src/tests/tool_exports.test.ts +34 -0
  83. package/src/tests/tool_validation.test.ts +16 -0
  84. package/src/tests/translation_copy.test.ts +123 -0
  85. package/src/tool/election-seat-apportionment-calculator/bibliography.astro +6 -0
  86. package/src/tool/election-seat-apportionment-calculator/bibliography.ts +16 -0
  87. package/src/tool/election-seat-apportionment-calculator/component.astro +69 -0
  88. package/src/tool/election-seat-apportionment-calculator/controller.ts +181 -0
  89. package/src/tool/election-seat-apportionment-calculator/dom-views.ts +75 -0
  90. package/src/tool/election-seat-apportionment-calculator/election-seat-apportionment-calculator.css +585 -0
  91. package/src/tool/election-seat-apportionment-calculator/entry.ts +28 -0
  92. package/src/tool/election-seat-apportionment-calculator/evaluator.ts +20 -0
  93. package/src/tool/election-seat-apportionment-calculator/i18n/de.ts +55 -0
  94. package/src/tool/election-seat-apportionment-calculator/i18n/en.ts +108 -0
  95. package/src/tool/election-seat-apportionment-calculator/i18n/es.ts +50 -0
  96. package/src/tool/election-seat-apportionment-calculator/i18n/fr.ts +48 -0
  97. package/src/tool/election-seat-apportionment-calculator/i18n/id.ts +48 -0
  98. package/src/tool/election-seat-apportionment-calculator/i18n/it.ts +48 -0
  99. package/src/tool/election-seat-apportionment-calculator/i18n/ja.ts +48 -0
  100. package/src/tool/election-seat-apportionment-calculator/i18n/ko.ts +48 -0
  101. package/src/tool/election-seat-apportionment-calculator/i18n/nl.ts +48 -0
  102. package/src/tool/election-seat-apportionment-calculator/i18n/pl.ts +48 -0
  103. package/src/tool/election-seat-apportionment-calculator/i18n/pt.ts +48 -0
  104. package/src/tool/election-seat-apportionment-calculator/i18n/ru.ts +48 -0
  105. package/src/tool/election-seat-apportionment-calculator/i18n/sv.ts +48 -0
  106. package/src/tool/election-seat-apportionment-calculator/i18n/tr.ts +48 -0
  107. package/src/tool/election-seat-apportionment-calculator/i18n/zh.ts +48 -0
  108. package/src/tool/election-seat-apportionment-calculator/index.ts +11 -0
  109. package/src/tool/election-seat-apportionment-calculator/logic.test.ts +90 -0
  110. package/src/tool/election-seat-apportionment-calculator/logic.ts +268 -0
  111. package/src/tool/election-seat-apportionment-calculator/seo.astro +14 -0
  112. package/src/tool/election-seat-apportionment-calculator/sharing.test.ts +34 -0
  113. package/src/tool/election-seat-apportionment-calculator/sharing.ts +88 -0
  114. package/src/tool/election-seat-apportionment-calculator/storage.ts +28 -0
  115. package/src/tool/election-seat-apportionment-calculator/ui.ts +92 -0
  116. package/src/tools.ts +7 -0
  117. package/src/types.ts +69 -0
  118. package/tsconfig.json +15 -0
  119. package/vitest.config.ts +20 -0
@@ -0,0 +1,268 @@
1
+ export type AllocationMethod = 'dhondt' | 'sainte-lague' | 'hare';
2
+
3
+ export interface PartyInput {
4
+ name: string;
5
+ votes: number;
6
+ }
7
+
8
+ export interface ElectionInput {
9
+ parties: PartyInput[];
10
+ totalSeats: number;
11
+ thresholdPercent: number;
12
+ method: AllocationMethod;
13
+ districtSeats?: number[];
14
+ }
15
+
16
+ export interface PartyAllocation {
17
+ name: string;
18
+ votes: number;
19
+ sharePercent: number;
20
+ seats: number;
21
+ seatSharePercent: number;
22
+ excluded: boolean;
23
+ }
24
+
25
+ export interface SeatEvent {
26
+ partyName: string;
27
+ seatNumber: number;
28
+ quotient: number;
29
+ remainder?: number;
30
+ }
31
+
32
+ export interface DistrictAllocation {
33
+ label: string;
34
+ seats: number;
35
+ quota: number;
36
+ parties: PartyAllocation[];
37
+ events: SeatEvent[];
38
+ }
39
+
40
+ export interface SensitivityChange {
41
+ thresholdPercent: number;
42
+ seatsByParty: Record<string, number>;
43
+ changedParties: string[];
44
+ }
45
+
46
+ export interface ApportionmentResult {
47
+ method: AllocationMethod;
48
+ totalSeats: number;
49
+ validVotes: number;
50
+ eligibleVotes: number;
51
+ excludedVotes: number;
52
+ thresholdPercent: number;
53
+ effectiveThresholdPercent: number;
54
+ partyAllocations: PartyAllocation[];
55
+ events: SeatEvent[];
56
+ districts: DistrictAllocation[];
57
+ sensitivity: {
58
+ lower: SensitivityChange;
59
+ higher: SensitivityChange;
60
+ };
61
+ }
62
+
63
+ interface EligibleParty extends PartyInput {
64
+ index: number;
65
+ sharePercent: number;
66
+ }
67
+
68
+ interface BaseAllocation {
69
+ partyAllocations: PartyAllocation[];
70
+ events: SeatEvent[];
71
+ districts: DistrictAllocation[];
72
+ eligibleVotes: number;
73
+ }
74
+
75
+ const roundValue = (value: number, digits = 6): number => {
76
+ const factor = 10 ** digits;
77
+ return Math.round(value * factor) / factor;
78
+ };
79
+
80
+ const assertSeats = (totalSeats: number): void => {
81
+ if (!Number.isInteger(totalSeats) || totalSeats < 1 || totalSeats > 500) {
82
+ throw new RangeError('Total seats must be an integer from 1 to 500.');
83
+ }
84
+ };
85
+
86
+ const assertThreshold = (thresholdPercent: number): void => {
87
+ if (!Number.isFinite(thresholdPercent) || thresholdPercent < 0 || thresholdPercent > 100) {
88
+ throw new RangeError('Threshold must be between 0 and 100 percent.');
89
+ }
90
+ };
91
+
92
+ const assertMethod = (method: AllocationMethod): void => {
93
+ if (!['dhondt', 'sainte-lague', 'hare'].includes(method)) {
94
+ throw new RangeError('Choose a supported allocation method.');
95
+ }
96
+ };
97
+
98
+ const assertParties = (parties: PartyInput[]): void => {
99
+ if (parties.length < 2) throw new RangeError('Add at least two parties.');
100
+ const names = new Set<string>();
101
+ for (const party of parties) {
102
+ const name = party.name.trim();
103
+ if (!name || names.has(name.toLowerCase())) throw new RangeError('Each party needs a unique name.');
104
+ if (!Number.isFinite(party.votes) || party.votes < 0) throw new RangeError('Votes must be zero or a positive number.');
105
+ names.add(name.toLowerCase());
106
+ }
107
+ if (parties.every((party) => party.votes === 0)) throw new RangeError('Enter at least one vote before calculating.');
108
+ };
109
+
110
+ const assertDistricts = (districtSeats: number[] | undefined, totalSeats: number): void => {
111
+ if (!districtSeats) return;
112
+ if (districtSeats.length < 2 || districtSeats.some((seats) => !Number.isInteger(seats) || seats < 1)) {
113
+ throw new RangeError('District seats must contain at least two positive integers.');
114
+ }
115
+ const districtTotal = districtSeats.reduce((sum, seats) => sum + seats, 0);
116
+ if (districtTotal !== totalSeats) throw new RangeError('District seats must add up to the total seats.');
117
+ };
118
+
119
+ const assertValidInput = (input: ElectionInput): void => {
120
+ assertSeats(input.totalSeats);
121
+ assertThreshold(input.thresholdPercent);
122
+ assertMethod(input.method);
123
+ assertParties(input.parties);
124
+ assertDistricts(input.districtSeats, input.totalSeats);
125
+ };
126
+
127
+ const getEligibleParties = (input: ElectionInput, validVotes: number): EligibleParty[] => input.parties
128
+ .map((party, index) => ({
129
+ ...party,
130
+ index,
131
+ sharePercent: (party.votes / validVotes) * 100,
132
+ }))
133
+ .filter((party) => party.sharePercent >= input.thresholdPercent);
134
+
135
+ const compareNumbers = (left: number, right: number): number => {
136
+ if (Math.abs(left - right) < 1e-10) return 0;
137
+ return left > right ? -1 : 1;
138
+ };
139
+
140
+ const compareEvents = (left: SeatEvent & { index: number; votes: number }, right: SeatEvent & { index: number; votes: number }): number => (
141
+ compareNumbers(left.quotient, right.quotient)
142
+ || compareNumbers(left.votes, right.votes)
143
+ || left.index - right.index
144
+ );
145
+
146
+ const allocateHighestAverage = (parties: EligibleParty[], seats: number, method: AllocationMethod): { events: SeatEvent[]; quota: number } => {
147
+ const divisors = method === 'dhondt' ? (seat: number) => seat : (seat: number) => (seat * 2) - 1;
148
+ const events = parties.flatMap((party) => Array.from({ length: seats }, (_, index) => ({
149
+ partyName: party.name,
150
+ seatNumber: index + 1,
151
+ quotient: party.votes / divisors(index + 1),
152
+ index: party.index,
153
+ votes: party.votes,
154
+ })));
155
+ return { events: events.sort(compareEvents).slice(0, seats).map(({ partyName, seatNumber, quotient }) => ({ partyName, seatNumber, quotient })), quota: 0 };
156
+ };
157
+
158
+ const allocateLargestRemainder = (parties: EligibleParty[], seats: number): { events: SeatEvent[]; quota: number } => {
159
+ const totalVotes = parties.reduce((sum, party) => sum + party.votes, 0);
160
+ const quota = totalVotes / seats;
161
+ const initial = parties.map((party) => ({
162
+ party,
163
+ fullSeats: quota > 0 ? Math.floor(party.votes / quota) : 0,
164
+ remainder: quota > 0 ? party.votes % quota : 0,
165
+ }));
166
+ const remainingSeats = seats - initial.reduce((sum, item) => sum + item.fullSeats, 0);
167
+ const ranked = [...initial].sort((left, right) => (
168
+ compareNumbers(left.remainder, right.remainder)
169
+ || compareNumbers(left.party.votes, right.party.votes)
170
+ || left.party.index - right.party.index
171
+ ));
172
+ const events: SeatEvent[] = [];
173
+ for (const item of initial) {
174
+ for (let seat = 1; seat <= item.fullSeats; seat += 1) {
175
+ events.push({ partyName: item.party.name, seatNumber: seat, quotient: quota, remainder: item.remainder });
176
+ }
177
+ }
178
+ ranked.slice(0, remainingSeats).forEach((item) => {
179
+ const currentSeats = initial.find((candidate) => candidate.party.index === item.party.index)?.fullSeats ?? 0;
180
+ events.push({ partyName: item.party.name, seatNumber: currentSeats + 1, quotient: quota, remainder: item.remainder });
181
+ });
182
+ return { events, quota };
183
+ };
184
+
185
+ const allocateDistrict = (parties: EligibleParty[], seats: number, label: string, method: AllocationMethod): DistrictAllocation => {
186
+ const allocation = method === 'hare'
187
+ ? allocateLargestRemainder(parties, seats)
188
+ : allocateHighestAverage(parties, seats, method);
189
+ const seatsByParty = new Map<string, number>();
190
+ allocation.events.forEach((event) => seatsByParty.set(event.partyName, (seatsByParty.get(event.partyName) ?? 0) + 1));
191
+ return {
192
+ label,
193
+ seats,
194
+ quota: allocation.quota,
195
+ events: allocation.events,
196
+ parties: parties.map((party) => ({
197
+ name: party.name,
198
+ votes: party.votes,
199
+ sharePercent: roundValue(party.sharePercent, 2),
200
+ seats: seatsByParty.get(party.name) ?? 0,
201
+ seatSharePercent: roundValue(((seatsByParty.get(party.name) ?? 0) / seats) * 100, 2),
202
+ excluded: false,
203
+ })),
204
+ };
205
+ };
206
+
207
+ const calculateBase = (input: ElectionInput): BaseAllocation => {
208
+ const validVotes = input.parties.reduce((sum, party) => sum + party.votes, 0);
209
+ const eligibleParties = getEligibleParties(input, validVotes);
210
+ const eligibleVotes = eligibleParties.reduce((sum, party) => sum + party.votes, 0);
211
+ const districtSeats = input.districtSeats ?? [input.totalSeats];
212
+ const districts = districtSeats.map((seats, index) => allocateDistrict(eligibleParties, seats, `District ${index + 1}`, input.method));
213
+ const seatsByParty = new Map<string, number>();
214
+ const events = districts.flatMap((district) => district.events);
215
+ events.forEach((event) => seatsByParty.set(event.partyName, (seatsByParty.get(event.partyName) ?? 0) + 1));
216
+ const partyAllocations = input.parties.map((party) => {
217
+ const eligible = eligibleParties.some((candidate) => candidate.name === party.name);
218
+ const seats = seatsByParty.get(party.name) ?? 0;
219
+ return {
220
+ name: party.name,
221
+ votes: party.votes,
222
+ sharePercent: roundValue((party.votes / validVotes) * 100, 2),
223
+ seats,
224
+ seatSharePercent: roundValue((seats / input.totalSeats) * 100, 2),
225
+ excluded: !eligible,
226
+ };
227
+ });
228
+ return { partyAllocations, events, districts: input.districtSeats ? districts : [], eligibleVotes };
229
+ };
230
+
231
+ const getSeatMap = (allocations: PartyAllocation[]): Record<string, number> => Object.fromEntries(allocations.map((party) => [party.name, party.seats]));
232
+
233
+ const getChangedParties = (base: Record<string, number>, comparison: Record<string, number>): string[] => Object.keys(base)
234
+ .filter((name) => base[name] !== comparison[name]);
235
+
236
+ const buildSensitivity = (input: ElectionInput, base: BaseAllocation, thresholdPercent: number): SensitivityChange => {
237
+ const comparison = calculateBase({ ...input, thresholdPercent });
238
+ const seatsByParty = getSeatMap(comparison.partyAllocations);
239
+ return {
240
+ thresholdPercent,
241
+ seatsByParty,
242
+ changedParties: getChangedParties(getSeatMap(base.partyAllocations), seatsByParty),
243
+ };
244
+ };
245
+
246
+ export const calculateApportionment = (input: ElectionInput): ApportionmentResult => {
247
+ assertValidInput(input);
248
+ const validVotes = input.parties.reduce((sum, party) => sum + party.votes, 0);
249
+ const base = calculateBase(input);
250
+ const winners = base.partyAllocations.filter((party) => party.seats > 0);
251
+ const effectiveThresholdPercent = winners.length > 0 ? Math.min(...winners.map((party) => party.sharePercent)) : 0;
252
+ return {
253
+ method: input.method,
254
+ totalSeats: input.totalSeats,
255
+ validVotes,
256
+ eligibleVotes: base.eligibleVotes,
257
+ excludedVotes: validVotes - base.eligibleVotes,
258
+ thresholdPercent: roundValue(input.thresholdPercent, 2),
259
+ effectiveThresholdPercent: roundValue(effectiveThresholdPercent, 2),
260
+ partyAllocations: base.partyAllocations,
261
+ events: base.events,
262
+ districts: base.districts,
263
+ sensitivity: {
264
+ lower: buildSensitivity(input, base, Math.max(0, roundValue(input.thresholdPercent - 1, 2))),
265
+ higher: buildSensitivity(input, base, Math.min(100, roundValue(input.thresholdPercent + 1, 2))),
266
+ },
267
+ };
268
+ };
@@ -0,0 +1,14 @@
1
+ ---
2
+ import { SEORenderer } from '@jjlmoya/utils-shared';
3
+ import { electionSeatApportionmentCalculator } from './index';
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 electionSeatApportionmentCalculator.i18n[locale]?.();
12
+ ---
13
+
14
+ {content && <SEORenderer content={{ locale, sections: content.seo }} />}
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buildShareUrl, decodeSharePayload, encodeSharePayload, hasSharePayload, readSharePayload } from './sharing';
3
+ import type { ElectionInput } from './logic';
4
+
5
+ const input: ElectionInput = {
6
+ parties: [
7
+ { name: 'Árbol', votes: 42000 },
8
+ { name: 'Río', votes: 31000 },
9
+ ],
10
+ totalSeats: 12,
11
+ thresholdPercent: 5,
12
+ method: 'hare',
13
+ districtSeats: [5, 7],
14
+ };
15
+
16
+ describe('shareable election scenarios', () => {
17
+ it('round-trips the complete election input, including Unicode names and districts', () => {
18
+ expect(decodeSharePayload(encodeSharePayload(input))).toEqual(input);
19
+ });
20
+
21
+ it('builds a stable result query parameter without dropping the page route', () => {
22
+ const url = buildShareUrl('https://example.test/en/election-seat-apportionment-calculator?theme=dark', input);
23
+ expect(url).toContain('/en/election-seat-apportionment-calculator?theme=dark&result=');
24
+ expect(readSharePayload(new URL(url).search)).toEqual(input);
25
+ expect(hasSharePayload(new URL(url).search)).toBe(true);
26
+ });
27
+
28
+ it('rejects malformed or unsupported shared results', () => {
29
+ expect(decodeSharePayload('{"v":1,"method":"unknown"}')).toBeNull();
30
+ expect(decodeSharePayload('{"v":2}')).toBeNull();
31
+ expect(readSharePayload('?result=not-json')).toBeNull();
32
+ expect(hasSharePayload('')).toBe(false);
33
+ });
34
+ });
@@ -0,0 +1,88 @@
1
+ import type { ElectionInput, AllocationMethod, PartyInput } from './logic';
2
+
3
+ export const SHARED_RESULT_PARAM = 'result';
4
+
5
+ interface SharePayload {
6
+ v: 1;
7
+ parties: PartyInput[];
8
+ totalSeats: number;
9
+ thresholdPercent: number;
10
+ method: AllocationMethod;
11
+ districtSeats?: number[];
12
+ }
13
+
14
+ const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null;
15
+
16
+ const isMethod = (value: unknown): value is AllocationMethod => value === 'dhondt' || value === 'sainte-lague' || value === 'hare';
17
+
18
+ const isFiniteNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value);
19
+
20
+ const readParty = (value: unknown): PartyInput | null => {
21
+ if (!isRecord(value)) return null;
22
+ if (typeof value.name !== 'string') return null;
23
+ if (!isFiniteNumber(value.votes)) return null;
24
+ return { name: value.name, votes: value.votes };
25
+ };
26
+
27
+ const readParties = (value: unknown): PartyInput[] | null => {
28
+ if (!Array.isArray(value)) return null;
29
+ const parties = value.map(readParty);
30
+ return parties.every((party): party is PartyInput => party !== null) ? parties : null;
31
+ };
32
+
33
+ const isValidMetadata = (value: unknown): value is SharePayload => {
34
+ if (!isRecord(value)) return false;
35
+ if (value.v !== 1) return false;
36
+ if (!isFiniteNumber(value.totalSeats)) return false;
37
+ if (!isFiniteNumber(value.thresholdPercent)) return false;
38
+ return isMethod(value.method);
39
+ };
40
+
41
+ const readDistrictSeats = (value: unknown): number[] | null | undefined => {
42
+ if (value === undefined) return undefined;
43
+ if (!Array.isArray(value)) return null;
44
+ if (value.some((seats) => !isFiniteNumber(seats))) return null;
45
+ return value;
46
+ };
47
+
48
+ export const encodeSharePayload = (input: ElectionInput): string => JSON.stringify({
49
+ v: 1,
50
+ parties: input.parties.map(({ name, votes }) => ({ name, votes })),
51
+ totalSeats: input.totalSeats,
52
+ thresholdPercent: input.thresholdPercent,
53
+ method: input.method,
54
+ ...(input.districtSeats ? { districtSeats: input.districtSeats } : {}),
55
+ } satisfies SharePayload);
56
+
57
+ export const decodeSharePayload = (raw: string | null): ElectionInput | null => {
58
+ if (!raw) return null;
59
+ try {
60
+ const payload = JSON.parse(raw) as unknown;
61
+ if (!isValidMetadata(payload)) return null;
62
+ const parties = readParties(payload.parties);
63
+ if (!parties) return null;
64
+ const districtSeats = readDistrictSeats(payload.districtSeats);
65
+ if (districtSeats === null) return null;
66
+ return {
67
+ parties,
68
+ totalSeats: payload.totalSeats,
69
+ thresholdPercent: payload.thresholdPercent,
70
+ method: payload.method,
71
+ ...(districtSeats ? { districtSeats } : {}),
72
+ };
73
+ } catch {
74
+ return null;
75
+ }
76
+ };
77
+
78
+ export const readSharePayload = (search: string): ElectionInput | null => (
79
+ decodeSharePayload(new URLSearchParams(search).get(SHARED_RESULT_PARAM))
80
+ );
81
+
82
+ export const hasSharePayload = (search: string): boolean => new URLSearchParams(search).has(SHARED_RESULT_PARAM);
83
+
84
+ export const buildShareUrl = (currentUrl: string, input: ElectionInput): string => {
85
+ const url = new URL(currentUrl);
86
+ url.searchParams.set(SHARED_RESULT_PARAM, encodeSharePayload(input));
87
+ return url.toString();
88
+ };
@@ -0,0 +1,28 @@
1
+ import type { ElectionInput } from './logic';
2
+
3
+ const STORAGE_KEY = 'civic-election-seat-model';
4
+
5
+ export const loadElectionInput = (): ElectionInput | null => {
6
+ try {
7
+ const raw = localStorage.getItem(STORAGE_KEY);
8
+ return raw ? JSON.parse(raw) as ElectionInput : null;
9
+ } catch {
10
+ return null;
11
+ }
12
+ };
13
+
14
+ export const saveElectionInput = (input: ElectionInput): void => {
15
+ try {
16
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(input));
17
+ } catch {
18
+ return;
19
+ }
20
+ };
21
+
22
+ export const clearElectionInput = (): void => {
23
+ try {
24
+ localStorage.removeItem(STORAGE_KEY);
25
+ } catch {
26
+ return;
27
+ }
28
+ };
@@ -0,0 +1,92 @@
1
+ export interface ElectionSeatUI {
2
+ [key: string]: string;
3
+ methodLabel: string;
4
+ allocationTitle: string;
5
+ eventsTitle: string;
6
+ sensitivityTitle: string;
7
+ dhondtLabel: string;
8
+ sainteLagueLabel: string;
9
+ hareLabel: string;
10
+ partiesLabel: string;
11
+ partyNameLabel: string;
12
+ votesLabel: string;
13
+ addParty: string;
14
+ removeParty: string;
15
+ shareResult: string;
16
+ shareCopied: string;
17
+ shareCopyFallback: string;
18
+ invalidSharedResult: string;
19
+ totalSeatsLabel: string;
20
+ thresholdLabel: string;
21
+ districtsLabel: string;
22
+ districtsHelp: string;
23
+ districtsPlaceholder: string;
24
+ reset: string;
25
+ allocatedSeatsLabel: string;
26
+ voteShareLabel: string;
27
+ seatShareLabel: string;
28
+ excludedLabel: string;
29
+ chamberLabel: string;
30
+ quotientLabel: string;
31
+ remainderLabel: string;
32
+ eventLabel: string;
33
+ eligibleVotesLabel: string;
34
+ excludedVotesLabel: string;
35
+ effectiveThresholdLabel: string;
36
+ sensitivityLabel: string;
37
+ lowerThresholdLabel: string;
38
+ higherThresholdLabel: string;
39
+ changedLabel: string;
40
+ noChangesLabel: string;
41
+ districtAssumption: string;
42
+ modelNotice: string;
43
+ emptyState: string;
44
+ invalidNumber: string;
45
+ seatUnit: string;
46
+ }
47
+
48
+ export const ui: ElectionSeatUI = {
49
+ methodLabel: 'Allocation method',
50
+ allocationTitle: 'Seat allocation',
51
+ eventsTitle: 'The next seats are decided here',
52
+ sensitivityTitle: 'What changes around the threshold',
53
+ dhondtLabel: "D'Hondt highest averages",
54
+ sainteLagueLabel: 'Sainte-Lague highest averages',
55
+ hareLabel: 'Hare quota and largest remainder',
56
+ partiesLabel: 'Votes by party or list',
57
+ partyNameLabel: 'Party or list name',
58
+ votesLabel: 'Votes',
59
+ addParty: 'Add party',
60
+ removeParty: 'Remove party',
61
+ shareResult: 'Copy share link',
62
+ shareCopied: 'Share link copied.',
63
+ shareCopyFallback: 'Copy the share link from the dialog.',
64
+ invalidSharedResult: 'This shared result link is invalid. Showing the example instead.',
65
+ totalSeatsLabel: 'Seats to allocate',
66
+ thresholdLabel: 'Eligibility threshold',
67
+ districtsLabel: 'Optional district grouping',
68
+ districtsHelp: 'Enter seat counts separated by commas. The model applies the same vote profile in each district.',
69
+ districtsPlaceholder: 'Example: 10, 8, 7',
70
+ reset: 'Reset example',
71
+ allocatedSeatsLabel: 'Seats allocated',
72
+ voteShareLabel: 'Vote share',
73
+ seatShareLabel: 'Seat share',
74
+ excludedLabel: 'Excluded by threshold',
75
+ chamberLabel: 'Seat chamber',
76
+ quotientLabel: 'Quotient',
77
+ remainderLabel: 'Remainder',
78
+ eventLabel: 'Seat award order',
79
+ eligibleVotesLabel: 'Eligible votes',
80
+ excludedVotesLabel: 'Votes excluded',
81
+ effectiveThresholdLabel: 'Observed seat threshold',
82
+ sensitivityLabel: 'Threshold sensitivity',
83
+ lowerThresholdLabel: 'At threshold minus 1 point',
84
+ higherThresholdLabel: 'At threshold plus 1 point',
85
+ changedLabel: 'Changed seats',
86
+ noChangesLabel: 'No seat changes in this test',
87
+ districtAssumption: 'District mode is a scenario: it repeats the entered national vote profile in every district and does not replace official district results.',
88
+ modelNotice: 'Educational model only. This is not an official election result, legal advice, or a prediction.',
89
+ emptyState: 'Enter votes to reveal the seat chamber.',
90
+ invalidNumber: 'Use a valid non-negative number.',
91
+ seatUnit: 'seats',
92
+ };
package/src/tools.ts ADDED
@@ -0,0 +1,7 @@
1
+ export { ALL_ENTRIES } from './entries';
2
+ import type { ToolDefinition } from './types';
3
+ import { ELECTION_SEAT_APPORTIONMENT_CALCULATOR_TOOL } from './tool/election-seat-apportionment-calculator';
4
+
5
+ export const ALL_TOOLS: ToolDefinition[] = [
6
+ ELECTION_SEAT_APPORTIONMENT_CALCULATOR_TOOL,
7
+ ];
package/src/types.ts ADDED
@@ -0,0 +1,69 @@
1
+ import type { SEOSection } from '@jjlmoya/utils-shared';
2
+
3
+ export type { SEOSection };
4
+
5
+ export type KnownLocale =
6
+ | 'ar' | 'da' | 'de' | 'en' | 'es' | 'fi'
7
+ | 'fr' | 'id' | 'it' | 'ja' | 'ko' | 'nb' | 'nl'
8
+ | 'pl' | 'pt' | 'ru' | 'sv' | 'tr' | 'zh';
9
+
10
+ export interface FAQItem {
11
+ question: string;
12
+ answer: string;
13
+ }
14
+
15
+ export interface BibliographyEntry {
16
+ name: string;
17
+ url: string;
18
+ }
19
+
20
+ export interface HowToStep {
21
+ name: string;
22
+ text: string;
23
+ }
24
+
25
+ export interface ToolLocaleContent<TUI extends Record<string, string> = Record<string, string>> {
26
+ slug: string;
27
+ title: string;
28
+ description: string;
29
+ ui: TUI;
30
+ seo: SEOSection[];
31
+ faq: FAQItem[];
32
+ bibliography: BibliographyEntry[];
33
+ howTo: HowToStep[];
34
+ schemas: Record<string, unknown>[];
35
+ }
36
+
37
+ export interface CategoryLocaleContent {
38
+ slug: string;
39
+ title: string;
40
+ description: string;
41
+ seo: SEOSection[];
42
+ }
43
+
44
+ export type LocaleLoader<T> = () => Promise<T>;
45
+
46
+ export type LocaleMap<T> = Partial<Record<KnownLocale, LocaleLoader<T>>>;
47
+
48
+ export interface CivicToolEntry<TUI extends Record<string, string> = Record<string, string>> {
49
+ id: string;
50
+ icons: {
51
+ bg: string;
52
+ fg: string;
53
+ };
54
+ i18n: LocaleMap<ToolLocaleContent<TUI>>;
55
+ }
56
+
57
+ export interface CivicCategoryEntry {
58
+ icon: string;
59
+ tools: CivicToolEntry[];
60
+ i18n: LocaleMap<CategoryLocaleContent>;
61
+ }
62
+
63
+ export interface ToolDefinition {
64
+ entry: CivicToolEntry;
65
+ Component: unknown;
66
+ SEOComponent: unknown;
67
+ BibliographyComponent: unknown;
68
+ }
69
+
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "extends": "astro/tsconfigs/strictest",
3
+ "include": ["src/**/*", ".astro/types.d.ts"],
4
+ "compilerOptions": {
5
+ "baseUrl": ".",
6
+ "strictNullChecks": true,
7
+ "noImplicitAny": true,
8
+ "strict": true,
9
+ "types": ["astro/client", "astro/jsx-runtime"],
10
+ "jsx": "preserve",
11
+ "jsxImportSource": "astro"
12
+ },
13
+ "exclude": ["node_modules", "dist"]
14
+ }
15
+
@@ -0,0 +1,20 @@
1
+ import { defineConfig } from 'vitest/config';
2
+ import path from 'path';
3
+
4
+ export default defineConfig({
5
+ test: {
6
+ environment: 'node',
7
+ },
8
+ resolve: {
9
+ alias: [
10
+ {
11
+ find: /^(.*)\.astro$/,
12
+ replacement: path.resolve(__dirname, './src/tests/mocks/astro_mock.js')
13
+ }
14
+ ]
15
+ },
16
+ ssr: {
17
+ external: ['astro']
18
+ }
19
+ });
20
+