Skip to content

Mobile: time and date-time questions, new display logic, unknown question types - #1145

Open
idormenco wants to merge 21 commits into
mobilefrom
feat/mobile-time-datetime-questions
Open

idormenco wants to merge 21 commits into
mobilefrom
feat/mobile-time-datetime-questions

Conversation

@idormenco

@idormenco idormenco commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Summary

New question types

  • Time and date-time questions, in observer forms, the PSI questionnaire and citizen forms.
  • Answers are stored the way the API expects:
    • Time is a time of day, HH:mm:ss.
    • Date-time is an ISO timestamp.
  • Date questions pick a calendar day only and keep it without a time zone shift. Before, a date could move to the previous or next day depending on the phone's time zone.
  • On Android, date and time questions each open a single picker dialog, while date-time opens both, one after the other.

Display logic

  • Select parents support the new AnyOf / All conditions, matched against optionIds.
  • Older rules that name one option in value keep working.
  • Number and rating parents compare as numbers, so 10 > 9 is now true; before, they were compared as text.

Unknown question types

  • A question type this version doesn't know shows a notice asking the observer to update the app. It never blocks the form, even when the question is required.
  • Its existing answers are kept when the form is saved, not dropped.

Answer timestamps

  • Each answer carries createdAt / lastUpdatedAt. These stay the same when the answer is unchanged.

Translations

  • Placeholders for the new inputs and the unsupported-question notice, in every app language.

Splash screen

  • The logo on the loading screen was tiny. The old splash setting shrank the whole full-screen image into the small Android 12+ splash icon, so the logo ended up about 40dp wide.
  • The splash now uses the expo-splash-screen plugin with a logo-only image (assets/icons/splash-logo.png), 190dp wide on Android and 280pt on iOS. 190dp is the widest the logo can be while staying inside the circle Android shows.
  • Needs a new native build; an OTA update won't change the splash screen.

Deleting a submission

  • With multiple submissions per form enabled, pressing back after deleting a submission returned to that deleted submission's delete preview. Deleting now goes back to the form's submissions list the preview was opened from.

Checks

  • tsc --noEmit and ESLint pass.
  • Not yet tested on a device.

ion.dormenco added 2 commits October 28, 2026 11:38
… question types

- Answer time and date-time questions; date questions pick and keep a calendar day
  without a time zone shift.
- Display logic compares number and rating answers as numbers.
- A question type the app doesn't know shows a notice and never blocks the form.
- Translations for the new placeholders and notice.
@vercel

vercel Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
votemonitor Ready Ready Preview Nov 12, 2026 8:20am UTC

Request Review

The legacy splash config shrank the whole full-screen image into the
Android 12+ splash icon, leaving the logo about 40dp wide. Use the
expo-splash-screen plugin with a logo-only image: 190dp on Android (the
widest that stays inside the splash icon's visible circle) and 280pt on iOS.
… preview

Deleting pushed the form's submissions screen on top of the delete preview,
so back went to the preview of the deleted submission. Go back to the
submissions screen it was opened from instead.
…e delete button

Long forms pushed the answers and the Delete button below the screen with no way to scroll, so observers couldn't confirm the deletion.
Saving updated the polling station information card on the home screen while
Android was still re-attaching it during the back transition, which crashed
with "addViewAt: failed to insert view". The home screen now shows changes
to that card only once its transition has ended.
They are expected on observers' phones and buried the real errors. One check,
isExpectedApiError, drops them in the API error reporter and in Sentry's
beforeSend, and the polling station and citizen form loaders no longer send
their own messages for them. The [API FAILED] console log, which Sentry kept as
a breadcrumb with the full stack, now prints only in development.
Add a "Time and date settings" page, reached from the More screen for
observers and citizens, where the user picks a 24 or 12-hour clock and a
date format (YYYY-MM-DD, DD.MM.YYYY, DD/MM/YYYY or MM/DD/YYYY). The choice
is stored on the device and used by every date and time shown in the app
and by the time pickers. Translations added for all app languages.
Comment thread mobile/components/TimeSelect.tsx Fixed
Comment thread mobile/components/TimeSelect.tsx Fixed
ion.dormenco added 2 commits November 4, 2026 10:38
A picker quality below 1 makes expo-image-picker on Android decode the
whole photo into a bitmap before re-encoding it, which runs out of
memory on 50-200MP cameras. Keep quality at 1 and let
react-native-compressor do the downsampled resize.
Each 10MB part was read as a base64 string, turned into a Buffer and
re-encoded by the network layer, holding ~40MB of JS memory per part.
Upload parts natively with FileSystem.uploadAsync: single-part files go
straight from disk, larger files copy each byte range to a temp file
first.
ion.dormenco added 4 commits November 6, 2026 15:22
…t stick on Android

On Android, tamagui's Button kept its internal pressed state after the
press ended (e.g. a submit that only shows validation errors), so the
button stayed drawn faded with a grey border, or grey like a disabled
one, until the screen was left. Build the Button on a plain XStack and
drive the pressed and disabled looks from our own state and props; the
pressed look is also cleared in onPress, as onPressOut is not always
delivered.
…drawer

Only started election rounds are listed, so the "Started - " prefix
said nothing. Show the English title under the title when they differ.
Incident reporting forms no longer show in the observation forms list.
"Report new issue" on the report tab now offers the quick report first,
then each incident reporting form (asking for the form language like
the observation forms). An incident report asks for the location like a
quick report, then walks through the form questions, with notes and
media per question, and a review before sending.

Reports are sent to the incident-reports endpoints. The report is
created first, as its attachments and notes reference it: attachments
are uploaded once it exists, and notes share the report's mutation
scope so they are sent after it, also when queued offline.

The report tab lists quick and incident reports together (newest
first) under a "Reports" header, with a read-only details screen for
incident reports. New strings are translated in all app languages.
ion.dormenco added 2 commits November 6, 2026 17:20
Only queries whose last fetch succeeded were persisted, so a refetch that
failed while offline (status "error", data still there) dropped that
data from the persisted cache: starting the app offline a second time
found no election rounds or visits and showed the generic error screen.
Also persist queries in error that still have data.
…in one tap

An incident report is now saved as it is filled in, so the organization
gets whatever the observer managed to send even if they can not finish:
- the draft is created once the location is chosen and saved again with
  all the answers on every next / previous question
- notes are sent when saved, edited or deleted, and media files are
  uploaded (and can be deleted) as soon as they are picked
- "Send report" is available on every question and sends the report as
  it is, completed, in one tap (no review step)
Drafts show "In progress" on the report list and can be continued;
completed reports show "Done", open read-only and show when they were
sent.

Sending never waits for the server: the saves share the report's
mutation scope, are queued while offline (also across app restarts) and
requests that get no response are retried until they go through.

The question form is typed instead of useForm<any> (the answer mapper
now also accepts the Date of date and time questions), and the controls
are labelled "Previous question" / "Next question" in all languages.
The full-width "Send report" button sat right under "Next question", so
it was easy to hit by mistake, which locks the report. Sending is still
a single tap with no confirmation (an observer may be in a hurry or in
danger), but it is now kept away from the navigation:
- a "Send" action (send icon + label) in the top right of the header, on
  every question; the form title wraps / shrinks next to it
- the footer uses the same Previous / Next controls as the other forms;
  on the last question "Next" becomes "Send report"
- a toast confirms the report was sent, or that it will be sent once
  back online

Header gets an optional rightAction slot for a labelled action and
WizzardControls an optional label for its back button. Also fixes the
success toast background colour (whsl -> hsl), which left its white text
unreadable.
…ports

The incident report now behaves like the other forms:
- the header has the form menu (⋮) with "Change language" for forms
  that have translations; the questions and the title switch right away,
  the answers are kept and the choice is saved as the form's language
- "Send report" (one tap) sits under the progress bar instead of the
  header, so the header's rightAction slot is removed again

A sent report can no longer be edited:
- while its send is still pending (queued offline or behind earlier
  saves) a refetch of the report list keeps it completed, with the
  answers that were sent, instead of turning it back into a draft
- once "Send report" is pressed the screen saves nothing more (e.g. a
  second tap on Next)
Comment thread mobile/services/form.parser.ts Outdated
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T00:00:00Z`;

/** A date answer back to a local Date on that day (only the first 10 characters count). */
export const fromDayIso = (value: string) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Datele salvate înainte de PR au ora completă în UTC (pickerul vechi era datetime). Dacă tai la 10 caractere, unele apar cu o zi mai devreme, de ex. 2 mai 01:30 → 2024-05-01T22:30Z → 1 mai.

Suggested change
export const fromDayIso = (value: string) => {
export const fromDayIso = (value: string) => {
// old answers are full UTC instants: take the local day
if (value.length > 10 && !/T00:00:00(\.0+)?Z$/.test(value)) {
const d = new Date(value);
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}

La fel în formatDayIso.

that got no response is retried (with a growing delay, paused while offline) until it goes through.
Errors the server answered (e.g. a locked report) are not retried.
*/
const retryWhileNoConnection = (_failureCount: number, error: unknown) => isNetworkError(error);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aici failureCount e ignorat, deci la timeout sau server căzut face retry la infinit. Notele din același scope rămân blocate în spate. Offline e deja acoperit de pauză, așa că o limită ajunge:

Suggested change
const retryWhileNoConnection = (_failureCount: number, error: unknown) => isNetworkError(error);
const retryWhileNoConnection = (failureCount: number, error: unknown) =>
isNetworkError(error) && failureCount < 10;

// otherwise starting the app offline a second time finds nothing (no election rounds, visits, ...)
return (
defaultShouldDehydrateQuery(query) ||
(query.state.status === "error" && query.state.data !== undefined)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Așa se persistă și state.error (un AxiosError cu config/request) prin SuperJSON, iar query-ul revine după restart cu status: "error". Poți verifica că nu crapă serializarea și că ecranele cu isError nu arată eroare deși au date?

Comment thread mobile/app/_layout.tsx
tracesSampleRate: process.env.EXPO_PUBLIC_ENVIRONMENT === "production" ? 0.2 : 0,
integrations: [Sentry.reactNativeTracingIntegration()],
// 404s, timeouts and lost connectivity are expected (see isExpectedApiError)
beforeSend: (event, hint) => (isExpectedApiError(hint.originalException) ? null : event),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Asta scoate din Sentry toate 404-urile și erorile de rețea, inclusiv captureException de la upload. Dacă se strică un endpoint după un deploy, nu mai vedem nimic. Poate limităm filtrul de 404 doar la endpoint-urile unde e normal?

@@ -59,6 +83,7 @@ export const DateInput: React.FC<DateInputProps> = ({
onChange(selectedDate);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pe Android rămân secundele din momentul alegerii, deci aceeași dată și oră aleasă din nou e văzută ca o modificare și se sincronizează iar. Pe linia de mai sus:

selectedDate.setMinutes(eventTime.getMinutes(), 0, 0);

const handle = source.open();
try {
handle.offset = start;
const bytes = handle.readBytes(Math.min(partSize, fileSize - start));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: la multipart, fiecare parte tot ajunge în memoria JS aici. E mult mai bine ca base64, dar de ținut cont de mărimea părții pe telefoanele slabe.

@@ -150,7 +155,19 @@ const PollingStationQuestionnaire = () => {
return {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: switch-ul ăsta dublează mapFormSubmissionDataToAPIFormSubmissionAnswer / setFormDefaultValues, iar cazurile de dată/oră sunt acum în două locuri.

setIs24Hour,
dateFormat,
setDateFormat,
formatTime: (value: Date) => formatTime(value, is24Hour),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: un useMemo pe [is24Hour, dateFormat] pentru value, ca să nu se re-randeze toți consumatorii la fiecare render al root-ului.

Review comments:
- date answers saved before the day picker are full UTC instants: show
  the local day of that instant instead of its first 10 characters
  (2 May 01:30 in UTC+3, stored as 2024-05-01T22:30Z, showed 1 May)
- incident report saves stop retrying after 10 network failures while
  online (offline still pauses them), so notes queued behind are not
  blocked forever; a send that fails for good shows a toast
- the persisted cache no longer stores request errors: a query whose
  refetch failed is restored with its data as a success, and the
  AxiosError (with the auth header in its config) is not written
- Sentry only ignores 404s from the endpoints where it means there is
  nothing to fetch, so a broken endpoint is reported again
- the Android date-time picker drops the seconds
- the polling station questionnaire uses the shared answer mapping
- memoized date/time format context value; CodeQL useless conditionals

Date, time and date-time answers (common/utils/date-answers.ts):
- date-time: the picked moment as a UTC instant, without seconds
- date: the day shown, time: the time shown (no time zone shift)

Tests: npm test runs tests/ with node --test (Node 22.18+): date
answers in several time zones, Sentry error filter, persisted cache and
incident report retries.
"Send report" moves from under the progress bar to the form menu (⋮),
which is now shown on every question; "Change language" stays there for
forms with translations. Sending still takes the answer on screen and
locks the report in one tap; on the last question "Next" still becomes
"Send report". The send icon, no longer used, is removed.

This branch was successfully deployed

1 active deployment
Preview — ba886d7a Deployed Nov 12, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants