Add date-ranked-choice question type (Doodle-style scheduling) #1

Open
opened 2026-05-05 16:48:15 +00:00 by mAi · 2 comments

What

A new question type for feedback forms that lets the author offer a list of date/time options and asks each participant to rate how well each option would fit them (Doodle-with-ratings style).

User-facing behaviour (participant)

On /f/<slug>, this question renders as a list of date/time options. For each option, the participant picks a rating from a small scale:

  • 5 — works great
  • 4 — works
  • 3 — could work
  • 2 — tight, would prefer not
  • 1 — doesn't work

(Exact scale + labels TBD — could also be 3-point: works / maybe / no. Resolve before implementation.)

UI ideas:

  • A row per date-option, with a 1–5 button group on the right (similar to existing scale question)
  • Or columns: rating header at top, dates as rows, click cells to mark — Doodle-grid style

Mobile: column-stack.

Author-facing behaviour (admin)

In the FormBuilder (src/lib/components/FormBuilder.svelte):

  • New question type in the type picker: date_ranked_choice
  • Author adds N date/time options (datetime-local pickers, with optional duration field, and an optional free-text label like "08:00 in Hamburg office")
  • Configurable rating scale (default 1–5, optionally 1–3, with author-customisable labels)
  • Required toggle (do all options need a rating, or can participants skip)

JSON shape draft (under FeedbackQuestionSchema):

{
  id: 'kickoff_when',
  type: 'date_ranked_choice',
  label: 'Welcher Termin passt dir am besten?',
  options: [
    { id: 'opt1', start: '2026-05-20T09:00:00Z', end: '2026-05-20T10:00:00Z', label?: 'Mo morgens' },
    { id: 'opt2', start: '2026-05-21T14:00:00Z', end: '2026-05-21T15:00:00Z' },
    { id: 'opt3', start: '2026-05-22T17:00:00Z' }
  ],
  scale: { min: 1, max: 5, min_label: 'passt gar nicht', max_label: 'passt super' },
  required: false,
  allow_partial: true   // can the participant leave some options blank?
}

Answer shape:

{ kickoff_when: { opt1: 5, opt2: 3, opt3: null } }

Results / aggregation

Admin Results tab (Results.svelte + src/lib/server/results.ts) shows for each option:

  • Average rating (and count of N respondents)
  • Distribution histogram per option (how many gave 1, 2, 3, 4, 5)
  • Best-overall ranking (sort options by avg rating desc; tiebreak by count of "5"s, then "4"s)
  • Optional: heatmap with respondents as rows, options as columns — but that's v2

Export (CSV/JSON) includes the per-option ratings under the question id.

Open design questions (resolve before coding)

  1. Rating scale shape: 1–5 default, or 3-point (yes/maybe/no, like Doodle)? Lean Doodle-style 3-point for the common case, with 5-point as opt-in. m to confirm.
  2. Allow ranking on top of rating? I.e. force exactly one "5" so it acts as a real ranked-choice. Keep simple for v1: only ratings.
  3. Time zones: store options as UTC ISO strings; render in viewer's local TZ. Author writes in their local TZ via datetime-local inputs (already TZ-aware in browsers).
  4. Live results: same live_results_enabled flag governs whether participants can see the aggregate after submitting. Reuse, no new flag.

Scope

  • src/lib/schemas.ts — extend FeedbackQuestionSchema discriminated union with date_ranked_choice variant; add answer shape to submission schema
  • src/lib/components/FormBuilder.svelte — new question-type editor + option list
  • src/routes/f/[slug]/+page.svelte — renderer for the new question type
  • src/lib/server/results.ts — aggregation logic (averages, distributions, sort)
  • src/lib/components/Results.svelte — display block for date-ranked aggregates
  • src/routes/api/admin/feedback/[id]/export/+server.ts — include in CSV/JSON export

Out of scope (later)

  • Calendar invite generation once a winning option is picked
  • Notification to admin when threshold of "5" votes hit
  • Two-pass voting (first nominate options, then rate them)
  • Author can set a "deadline" — auto-close after a date passes

Acceptance

  • Author can build a form with one or more date_ranked_choice questions in the visual builder
  • Participant on /f/<slug> rates each option, submits, sees thank-you (or live results if enabled)
  • Admin Results tab shows per-option average + distribution
  • CSV/JSON export contains the ratings keyed by option id
  • Type-check + tests still pass

How to work

Branch off main when you grab this. Probably one PR / one branch with multiple commits (schema, builder, renderer, results, export). Smoke on the live site after merge.

## What A new question type for feedback forms that lets the author offer a list of **date/time options** and asks each participant to **rate how well each option would fit** them (Doodle-with-ratings style). ## User-facing behaviour (participant) On `/f/<slug>`, this question renders as a list of date/time options. For each option, the participant picks a rating from a small scale: - 5 — works great - 4 — works - 3 — could work - 2 — tight, would prefer not - 1 — doesn't work (Exact scale + labels TBD — could also be 3-point: works / maybe / no. Resolve before implementation.) UI ideas: - A row per date-option, with a 1–5 button group on the right (similar to existing `scale` question) - Or columns: rating header at top, dates as rows, click cells to mark — Doodle-grid style Mobile: column-stack. ## Author-facing behaviour (admin) In the FormBuilder (`src/lib/components/FormBuilder.svelte`): - New question type in the type picker: `date_ranked_choice` - Author adds **N date/time options** (datetime-local pickers, with optional duration field, and an optional free-text label like "08:00 in Hamburg office") - Configurable rating scale (default 1–5, optionally 1–3, with author-customisable labels) - Required toggle (do all options need a rating, or can participants skip) JSON shape draft (under `FeedbackQuestionSchema`): ```ts { id: 'kickoff_when', type: 'date_ranked_choice', label: 'Welcher Termin passt dir am besten?', options: [ { id: 'opt1', start: '2026-05-20T09:00:00Z', end: '2026-05-20T10:00:00Z', label?: 'Mo morgens' }, { id: 'opt2', start: '2026-05-21T14:00:00Z', end: '2026-05-21T15:00:00Z' }, { id: 'opt3', start: '2026-05-22T17:00:00Z' } ], scale: { min: 1, max: 5, min_label: 'passt gar nicht', max_label: 'passt super' }, required: false, allow_partial: true // can the participant leave some options blank? } ``` Answer shape: ```ts { kickoff_when: { opt1: 5, opt2: 3, opt3: null } } ``` ## Results / aggregation Admin Results tab (`Results.svelte` + `src/lib/server/results.ts`) shows for each option: - Average rating (and count of N respondents) - Distribution histogram per option (how many gave 1, 2, 3, 4, 5) - Best-overall ranking (sort options by avg rating desc; tiebreak by count of "5"s, then "4"s) - Optional: heatmap with respondents as rows, options as columns — but that's v2 Export (CSV/JSON) includes the per-option ratings under the question id. ## Open design questions (resolve before coding) 1. **Rating scale shape**: 1–5 default, or 3-point (yes/maybe/no, like Doodle)? Lean Doodle-style 3-point for the common case, with 5-point as opt-in. m to confirm. 2. **Allow ranking on top of rating?** I.e. force exactly one "5" so it acts as a real ranked-choice. Keep simple for v1: only ratings. 3. **Time zones**: store options as UTC ISO strings; render in viewer's local TZ. Author writes in their local TZ via datetime-local inputs (already TZ-aware in browsers). 4. **Live results**: same `live_results_enabled` flag governs whether participants can see the aggregate after submitting. Reuse, no new flag. ## Scope - `src/lib/schemas.ts` — extend `FeedbackQuestionSchema` discriminated union with `date_ranked_choice` variant; add answer shape to submission schema - `src/lib/components/FormBuilder.svelte` — new question-type editor + option list - `src/routes/f/[slug]/+page.svelte` — renderer for the new question type - `src/lib/server/results.ts` — aggregation logic (averages, distributions, sort) - `src/lib/components/Results.svelte` — display block for date-ranked aggregates - `src/routes/api/admin/feedback/[id]/export/+server.ts` — include in CSV/JSON export ## Out of scope (later) - Calendar invite generation once a winning option is picked - Notification to admin when threshold of "5" votes hit - Two-pass voting (first nominate options, then rate them) - Author can set a "deadline" — auto-close after a date passes ## Acceptance - Author can build a form with one or more `date_ranked_choice` questions in the visual builder - Participant on `/f/<slug>` rates each option, submits, sees thank-you (or live results if enabled) - Admin Results tab shows per-option average + distribution - CSV/JSON export contains the ratings keyed by option id - Type-check + tests still pass ## How to work Branch off main when you grab this. Probably one PR / one branch with multiple commits (schema, builder, renderer, results, export). Smoke on the live site after merge.
Author

Design questions resolved (m + paul/head, 2026-05-06):

  1. Rating scale: 5-point Likert (1–5) with author-customisable min/max labels. (scale: { min: 1, max: 5, min_label: '...', max_label: '...' })
  2. Force exactly one 'best'?: No — rating-only. Admin Results sorts options by avg desc, tiebreak by count of '5's then '4's.
  3. Time zones: Store all option timestamps as UTC ISO strings. Render in viewer's local TZ on participant page. Author writes in their local TZ via <input type="datetime-local"> (browser handles conversion).
  4. allow_partial: Default true. Participants can leave options blank. If required: true is also set on the question, blank = blank for that option but the question itself counts as answered iff at least one option got a rating (TBD by implementer — pick the sensible default and document).

Implementation can proceed. Ready for hire.

**Design questions resolved (m + paul/head, 2026-05-06):** 1. **Rating scale**: 5-point Likert (1–5) with author-customisable min/max labels. (`scale: { min: 1, max: 5, min_label: '...', max_label: '...' }`) 2. **Force exactly one 'best'?**: No — rating-only. Admin Results sorts options by avg desc, tiebreak by count of '5's then '4's. 3. **Time zones**: Store all option timestamps as UTC ISO strings. Render in viewer's local TZ on participant page. Author writes in their local TZ via `<input type="datetime-local">` (browser handles conversion). 4. **`allow_partial`**: Default `true`. Participants can leave options blank. If `required: true` is also set on the question, blank = blank for that option but the question itself counts as answered iff at least one option got a rating (TBD by implementer — pick the sensible default and document). Implementation can proceed. Ready for hire.
Author

Done — date_ranked_choice ready for review

Branch: mai/hermes/date-ranked-choice (compare)

Locked-in design (from your 2026-05-06 comment)

  • 5-point Likert (1-5), author sets min/max labels
  • Rating-only (no forced ranking)
  • UTC storage, viewer-local rendering, datetime-local author inputs
  • allow_partial: true default

What landed

Schemasrc/lib/schemas.ts

  • New date_ranked_choice variant in the FeedbackQuestionSchema discriminated union: { options: [{ id, start, end?, label? }, …], scale?: { min_label?, max_label? }, allow_partial? }. Times are ISO 8601 with offset; option ids are 1-64 chars [A-Za-z0-9_-], must be unique; 2-50 options per question.
  • Submission answer-value union extended with a Record<optId, 1|2|3|4|5|null> shape for the per-option rating map.

Aggregationsrc/lib/server/results.ts

  • Per-option count, mean, and histogram[1..5] from the rating maps. Question-level count = submissions that rated at least one option.
  • Sort order: mean desc → count of "5"s → "4"s → total count → id.
  • Out-of-range, fractional, and non-numeric ratings are silently dropped.
  • New results.test.ts (5 cases) wired into bun test.

Exportsrc/routes/api/admin/feedback/[id]/export/+server.ts

  • CSV expands each date_ranked_choice question into one column per option, named <qid>[<optid>]. Empty cell when an option was skipped or unrated.
  • JSON export unchanged — rating maps serialise as-is.

Author UIsrc/lib/components/FormBuilder.svelte

  • Date ranked choice in the type picker. Per-option editor: datetime-local Start (required), datetime-local End (optional), free-text label. Below the list: optional rating-1 / rating-5 label fields, and an Allow participants to skip individual options toggle (default on). Local↔UTC helpers keep storage in UTC.

Participant UIsrc/routes/f/[slug]/+page.svelte

  • Each option renders as a row: (date · optional label) on the left, 1-5 button group + skip button on the right. Mobile stacks. Required check: "at least one rated" for required: true; "all rated" when allow_partial: false. Dates render in viewer's local TZ via Intl.DateTimeFormat.

Results UIsrc/lib/components/Results.svelte

  • Ranked list of options (rank chip + local-time date + label + mean + count) with a per-option 1-5 distribution histogram. Sort order from the server aggregator.

Verification

  • bun run check — 0 errors. 32 warnings (26 pre-existing + 6 a11y label-has-associated-control on new FormBuilder labels — same pattern as the existing builder labels, so consistent with codebase style. Worth a follow-up sweep if you want zero a11y warnings).
  • bun run test — 25 pass, 0 fail (5 new aggregation tests).
  • bun run build — succeeds.

Commits

  • 91098e0 schema: date_ranked_choice question type
  • 439b030 server: aggregation + export + tests
  • 5ef08e5 UI: builder + participant renderer + results display

Parallel safety

Did not touch src/routes/admin/feedback/new/+page.svelte (cronus). Rebased onto current main after cronus's mai/cronus/builder-on-new landed — no conflicts.

Smoke-test path (post-merge)

  1. /admin/feedback/new → add a question, switch type to Date ranked choice, add 2-3 datetime options, save.
  2. Open /f/<slug> in another browser, rate the options, submit.
  3. Back in /admin/feedback/<id> → Results tab shows the ranked list with per-option histograms.
  4. Export CSV — confirm one column per option named <qid>[<optid>].

Out of scope per brief: calendar invites, deadlines/auto-close, two-pass voting, cross-respondent heatmap.

## Done — date_ranked_choice ready for review Branch: `mai/hermes/date-ranked-choice` ([compare](https://mgit.msbls.de/m/fdbck/compare/main...mai/hermes/date-ranked-choice)) ### Locked-in design (from your 2026-05-06 comment) - 5-point Likert (1-5), author sets min/max labels - Rating-only (no forced ranking) - UTC storage, viewer-local rendering, datetime-local author inputs - `allow_partial: true` default ### What landed **Schema** — `src/lib/schemas.ts` - New `date_ranked_choice` variant in the `FeedbackQuestionSchema` discriminated union: `{ options: [{ id, start, end?, label? }, …], scale?: { min_label?, max_label? }, allow_partial? }`. Times are ISO 8601 with offset; option ids are 1-64 chars `[A-Za-z0-9_-]`, must be unique; 2-50 options per question. - Submission answer-value union extended with a `Record<optId, 1|2|3|4|5|null>` shape for the per-option rating map. **Aggregation** — `src/lib/server/results.ts` - Per-option `count`, `mean`, and `histogram[1..5]` from the rating maps. Question-level `count` = submissions that rated at least one option. - Sort order: mean desc → count of "5"s → "4"s → total count → id. - Out-of-range, fractional, and non-numeric ratings are silently dropped. - New `results.test.ts` (5 cases) wired into `bun test`. **Export** — `src/routes/api/admin/feedback/[id]/export/+server.ts` - CSV expands each `date_ranked_choice` question into one column per option, named `<qid>[<optid>]`. Empty cell when an option was skipped or unrated. - JSON export unchanged — rating maps serialise as-is. **Author UI** — `src/lib/components/FormBuilder.svelte` - `Date ranked choice` in the type picker. Per-option editor: datetime-local Start (required), datetime-local End (optional), free-text label. Below the list: optional rating-1 / rating-5 label fields, and an `Allow participants to skip individual options` toggle (default on). Local↔UTC helpers keep storage in UTC. **Participant UI** — `src/routes/f/[slug]/+page.svelte` - Each option renders as a row: `(date · optional label)` on the left, 1-5 button group + skip `—` button on the right. Mobile stacks. Required check: "at least one rated" for `required: true`; "all rated" when `allow_partial: false`. Dates render in viewer's local TZ via `Intl.DateTimeFormat`. **Results UI** — `src/lib/components/Results.svelte` - Ranked list of options (rank chip + local-time date + label + mean + count) with a per-option 1-5 distribution histogram. Sort order from the server aggregator. ### Verification - `bun run check` — 0 errors. 32 warnings (26 pre-existing + 6 a11y label-has-associated-control on new FormBuilder labels — same pattern as the existing builder labels, so consistent with codebase style. Worth a follow-up sweep if you want zero a11y warnings). - `bun run test` — 25 pass, 0 fail (5 new aggregation tests). - `bun run build` — succeeds. ### Commits - [`91098e0`](https://mgit.msbls.de/m/fdbck/commit/91098e0) schema: date_ranked_choice question type - [`439b030`](https://mgit.msbls.de/m/fdbck/commit/439b030) server: aggregation + export + tests - [`5ef08e5`](https://mgit.msbls.de/m/fdbck/commit/5ef08e5) UI: builder + participant renderer + results display ### Parallel safety Did not touch `src/routes/admin/feedback/new/+page.svelte` (cronus). Rebased onto current `main` after cronus's `mai/cronus/builder-on-new` landed — no conflicts. ### Smoke-test path (post-merge) 1. `/admin/feedback/new` → add a question, switch type to `Date ranked choice`, add 2-3 datetime options, save. 2. Open `/f/<slug>` in another browser, rate the options, submit. 3. Back in `/admin/feedback/<id>` → Results tab shows the ranked list with per-option histograms. 4. Export CSV — confirm one column per option named `<qid>[<optid>]`. Out of scope per brief: calendar invites, deadlines/auto-close, two-pass voting, cross-respondent heatmap.
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: m/fdbck#1
No description provided.