From c2d939be0200e8e025695039198da2402d5ebf9e Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Sat, 2 Aug 2025 17:26:25 +0200 Subject: [PATCH 1/4] feat(web-ui): add invalidity reason alongside ballot summary --- packages/web-ui/src/App.svelte | 8 ++-- packages/web-ui/src/BallotSummary.svelte | 46 ++++++++++++++++++++-- packages/web-ui/src/FillBallotForm.svelte | 6 +-- packages/web-ui/src/ballotSummary.ts | 11 ++++-- packages/web-ui/src/fetchDataFromGitHub.ts | 24 +++++------ 5 files changed, 72 insertions(+), 23 deletions(-) diff --git a/packages/web-ui/src/App.svelte b/packages/web-ui/src/App.svelte index e568bef..56a54e1 100644 --- a/packages/web-ui/src/App.svelte +++ b/packages/web-ui/src/App.svelte @@ -14,6 +14,7 @@ let encryptDataPromise = new Promise(() => {}); let shouldSummarize = false; let ballotSummary = new Promise(() => {}); + let fetchVoteFile: undefined | (() => Promise); let url = globalThis.location?.hash.slice(1); @@ -35,11 +36,11 @@ function maybeUpdateSummary() { ballotSummary = shouldSummarize && ballot ? (async () => { // Lazy-loading as the summary is only a nice-to-have. - const { getSummarizedBallot } = await import("./ballotSummary.ts"); - return getSummarizedBallot(ballot); + const [{ getSummarizedBallot }, voteFileContents] = await Promise.all([import("./ballotSummary.ts"), fetchVoteFile().catch((e) => console.warn(e))]); + return getSummarizedBallot(ballot, voteFileContents); })() : new Promise(() => {}); } - function registerBallot(ballotContent, publicKey) { + function registerBallot(ballotContent, publicKey, _fetchVoteFile) { encryptDataPromise = (async () => { const { encryptedSecret, saltedCiphertext } = await encryptData( textEncoder.encode(ballotContent) as Uint8Array, @@ -51,6 +52,7 @@ }); })(); ballot = ballotContent; + fetchVoteFile = _fetchVoteFile; step = 2; maybeUpdateSummary(); } diff --git a/packages/web-ui/src/BallotSummary.svelte b/packages/web-ui/src/BallotSummary.svelte index 8f8ec37..a4e0921 100644 --- a/packages/web-ui/src/BallotSummary.svelte +++ b/packages/web-ui/src/BallotSummary.svelte @@ -1,5 +1,8 @@ Ballot summary (requires downloading YAML parser) @@ -7,8 +10,45 @@ {#await ballotSummary} {:then data} - + {#if data[1]?.missingCandidates?.length} +
Your ballot is missing some candidates +
    + {#each data[1].missingCandidates as candidate} +
  • {candidate}
  • + {/each} +
+

Only ballots setting scores for all candidates will be taken into account.

+

Hint: it could be due to a typo or a failed copy-pasting.

+
+
+ {/if} + {#if data[1]?.candidatesWithInvalidScores?.length} +
Your ballot contains invalid scores +
    + {#each data[1].candidatesWithInvalidScores as {title, score }} +
  • {score} for {title}
  • + {/each} +
+

Use only "safe" integers, i.e. values that can be represented as an IEEE-754 double precision number.

+
+
+ {/if} +
{data[0]}
{:catch error} - An error occurred: {error?.message ?? error} + An error occurred: {console.log(error) ?? error?.message ?? error} {/await} + + diff --git a/packages/web-ui/src/FillBallotForm.svelte b/packages/web-ui/src/FillBallotForm.svelte index d5a3926..3b6755b 100644 --- a/packages/web-ui/src/FillBallotForm.svelte +++ b/packages/web-ui/src/FillBallotForm.svelte @@ -5,18 +5,18 @@ export let url, username, token, registerBallot; - let fetchedBallot: Promise, fetchedPublicKey; + let fetchedBallot: Promise, fetchedPublicKey, fetchVoteFile; function onSubmit(this: HTMLFormElement, event: SubmitEvent) { event.preventDefault(); const textarea = this.elements.namedItem("ballot") as HTMLInputElement; - registerBallot(textarea.value, fetchedPublicKey); + registerBallot(textarea.value, fetchedPublicKey, fetchVoteFile); } fetchedBallot = fetchedPublicKey = Promise.reject("no data"); beforeUpdate(() => { fetchFromGitHub({ url, username, token }, (errOfResult) => { - [fetchedBallot, fetchedPublicKey] = errOfResult; + [fetchedBallot, fetchedPublicKey, fetchVoteFile] = errOfResult; }); }); diff --git a/packages/web-ui/src/ballotSummary.ts b/packages/web-ui/src/ballotSummary.ts index 02296b3..90c464a 100644 --- a/packages/web-ui/src/ballotSummary.ts +++ b/packages/web-ui/src/ballotSummary.ts @@ -3,16 +3,21 @@ import { getSummarizedBallot as _getSummarizedBallot, summarizeCondorcetBallotForVoter, } from "@node-core/caritat/summary/condorcet"; +import { + getReasonForInvalidateBallot, +} from "@node-core/caritat/getReasonForInvalidateBallot"; +import type { BallotFileFormat, VoteFileFormat } from "@node-core/caritat/parser"; -export function getSummarizedBallot(ballotStr: string) { +export function getSummarizedBallot(ballotStr: string, voteFileString?: string) { const ballot = yaml.load(ballotStr) as { preferences: Array<{ title: string; score: number }> }; if (!Array.isArray(ballot?.preferences)) { throw new Error("Ballot does not contain a list of preferences"); } - return summarizeCondorcetBallotForVoter( + const invalidityReason = voteFileString && getReasonForInvalidateBallot(ballot as BallotFileFormat, yaml.load(voteFileString) as VoteFileFormat); + return [summarizeCondorcetBallotForVoter( _getSummarizedBallot({ voter: {}, preferences: ballot.preferences.map(({ title, score }) => [title, score]), }), - ); + ), invalidityReason]; } diff --git a/packages/web-ui/src/fetchDataFromGitHub.ts b/packages/web-ui/src/fetchDataFromGitHub.ts index 06df7f8..362699c 100644 --- a/packages/web-ui/src/fetchDataFromGitHub.ts +++ b/packages/web-ui/src/fetchDataFromGitHub.ts @@ -196,17 +196,18 @@ async function act( voteFile.patch, ); + const fetchAsText = (response: Response) => + response.ok + ? response.text() + : Promise.reject( + new Error( + `Fetch error: ${response.status} ${response.statusText}`, + ), + ); + return [ fetch(ballotFile.contents_url, contentsFetchOptions) - .then(response => - response.ok - ? response.text() - : Promise.reject( - new Error( - `Fetch error: ${response.status} ${response.statusText}`, - ), - ), - ) + .then(fetchAsText) .then( shouldShuffleCandidates ? (ballotData) => { @@ -266,7 +267,8 @@ async function act( ), ), ), - ] as [Promise, Promise]; + () => fetch(voteFile.contents_url, contentsFetchOptions).then(fetchAsText), + ] as [Promise, Promise, () => Promise]; } catch (err) { const error = Promise.reject(err); return [error, error] as [never, never]; @@ -277,7 +279,7 @@ let previousURL: string | null; export default function fetchFromGitHub( { url, username, token }: { url: string; username?: string; token?: string }, callback: ( - errOfResult: [Promise, Promise] + errOfResult: [Promise, Promise, () => Promise] ) => void | Promise, ) { const options From a4a9e3a80fd5389f322bbd676dedcadc58e3d35b Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Tue, 5 Aug 2025 18:54:59 +0200 Subject: [PATCH 2/4] fixup! --- packages/web-ui/src/BallotSummary.svelte | 14 +++++++++++--- packages/web-ui/src/ballotSummary.ts | 7 ++++++- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/web-ui/src/BallotSummary.svelte b/packages/web-ui/src/BallotSummary.svelte index a4e0921..3f1e433 100644 --- a/packages/web-ui/src/BallotSummary.svelte +++ b/packages/web-ui/src/BallotSummary.svelte @@ -10,7 +10,8 @@ {#await ballotSummary} {:then data} - {#if data[1]?.missingCandidates?.length} + {#if data[1]} + {#if data[1].missingCandidates?.length}
Your ballot is missing some candidates
    {#each data[1].missingCandidates as candidate} @@ -22,10 +23,10 @@
{/if} - {#if data[1]?.candidatesWithInvalidScores?.length} + {#if data[1].candidatesWithInvalidScores?.length}
Your ballot contains invalid scores
    - {#each data[1].candidatesWithInvalidScores as {title, score }} + {#each data[1].candidatesWithInvalidScores as { title, score }}
  • {score} for {title}
  • {/each}
@@ -33,6 +34,13 @@
{/if} + {:else} +
We failed to check your ballot +

Something wrong happened, it doesn't mean your ballot is invalid.

+

Hint: authenticated API calls are more likely to succeed.

+
+
+ {/if}
{data[0]}
{:catch error} An error occurred: {console.log(error) ?? error?.message ?? error} diff --git a/packages/web-ui/src/ballotSummary.ts b/packages/web-ui/src/ballotSummary.ts index 90c464a..5a1a0fb 100644 --- a/packages/web-ui/src/ballotSummary.ts +++ b/packages/web-ui/src/ballotSummary.ts @@ -13,7 +13,12 @@ export function getSummarizedBallot(ballotStr: string, voteFileString?: string) if (!Array.isArray(ballot?.preferences)) { throw new Error("Ballot does not contain a list of preferences"); } - const invalidityReason = voteFileString && getReasonForInvalidateBallot(ballot as BallotFileFormat, yaml.load(voteFileString) as VoteFileFormat); + let invalidityReason: ReturnType; + try { + invalidityReason = voteFileString && getReasonForInvalidateBallot(ballot as BallotFileFormat, yaml.load(voteFileString) as VoteFileFormat); + } catch (e) { + console.warn(e); + } return [summarizeCondorcetBallotForVoter( _getSummarizedBallot({ voter: {}, From 5239cf0ee682991e097dd3af4ac30cfd31b57222 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Wed, 6 Aug 2025 16:08:27 +0200 Subject: [PATCH 3/4] fixup! fixup! --- packages/web-ui/src/BallotSummary.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web-ui/src/BallotSummary.svelte b/packages/web-ui/src/BallotSummary.svelte index 3f1e433..7914e73 100644 --- a/packages/web-ui/src/BallotSummary.svelte +++ b/packages/web-ui/src/BallotSummary.svelte @@ -36,7 +36,7 @@ {/if} {:else}
We failed to check your ballot -

Something wrong happened, it doesn't mean your ballot is invalid.

+

Something wrong happened when trying to check the ballot validity; it doesn't mean your ballot is invalid, we just can't tell.

Hint: authenticated API calls are more likely to succeed.


From 9cf5c22b95307283d65818cff1464e18f93d1346 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Wed, 6 Aug 2025 17:09:55 +0200 Subject: [PATCH 4/4] fixup! --- packages/web-ui/src/BallotSummary.svelte | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/web-ui/src/BallotSummary.svelte b/packages/web-ui/src/BallotSummary.svelte index 7914e73..eb19fb2 100644 --- a/packages/web-ui/src/BallotSummary.svelte +++ b/packages/web-ui/src/BallotSummary.svelte @@ -57,6 +57,7 @@ details > summary::before { pre { border: 1px solid currentColor; padding: 1ch; + white-space: pre-wrap; }