mAi: #1 - schema: date_ranked_choice question type

New discriminated-union variant for `FeedbackQuestionSchema`:

  { type: 'date_ranked_choice',
    options: [{ id, start, end?, label? }, …],
    scale?: { min_label?, max_label? },
    allow_partial?: boolean }

- Times stored as UTC ISO 8601 strings (datetime with offset). Author UI
  feeds them through datetime-local inputs that the browser already treats
  as local time; renderer converts back to viewer-local on display.
- Rating scale is locked at 1-5 (5-point Likert) per design — the `scale`
  field exposes only labels, not min/max bounds.
- Per-option ids are 1-64 chars, alphanumeric + `-`/`_`, must be unique.
- 2-50 options per question.

Submission answer union extended with a `Record<string, 1|2|3|4|5|null>`
shape for the per-option rating map (`{ opt1: 5, opt2: null }`).

Refs m/fdbck#1.
This commit is contained in:
mAi
2026-05-06 14:13:01 +02:00
parent 4259f16d45
commit 91098e0965

View File

@@ -10,6 +10,16 @@ const FeedbackQuestionBaseSchema = z.object({
help: z.string().max(500).optional(),
});
/** One date/time option in a `date_ranked_choice` question. Times are stored as UTC ISO 8601 strings. */
export const DateRankedOptionSchema = z.object({
id: z.string().min(1).max(64).regex(/^[a-zA-Z0-9_-]+$/, {
message: 'option id may only contain letters, digits, "-" and "_"',
}),
start: z.string().datetime({ offset: true }),
end: z.string().datetime({ offset: true }).optional(),
label: z.string().max(200).optional(),
});
export const FeedbackQuestionSchema = z.discriminatedUnion('type', [
FeedbackQuestionBaseSchema.extend({
type: z.literal('short_text'),
@@ -37,6 +47,20 @@ export const FeedbackQuestionSchema = z.discriminatedUnion('type', [
FeedbackQuestionBaseSchema.extend({
type: z.literal('boolean'),
}),
FeedbackQuestionBaseSchema.extend({
type: z.literal('date_ranked_choice'),
options: z.array(DateRankedOptionSchema).min(2).max(50)
.refine(
(opts) => new Set(opts.map((o) => o.id)).size === opts.length,
{ message: 'date_ranked_choice option ids must be unique' },
),
// Scale is locked at 1-5 (5-point Likert) per design — only the labels are author-configurable.
scale: z.object({
min_label: z.string().max(50).optional(),
max_label: z.string().max(50).optional(),
}).optional(),
allow_partial: z.boolean().optional(),
}),
]);
/** Version stamp like `0.260505` (YYMMDD) or `0.260505.b` for same-day re-edits. */
@@ -77,6 +101,14 @@ export const FeedbackInstanceUpdateSchema = z.object({
const ANSWER_MAX = 5000;
/** Per-option rating map for `date_ranked_choice` answers, e.g. `{ opt1: 5, opt2: null }`. */
const DateRankedAnswerSchema = z.record(
z.string().min(1).max(64),
z.number().int().min(1).max(5).nullable(),
).refine((m) => Object.keys(m).length <= 50, {
message: 'too many rated options',
});
export const FeedbackSubmissionSchema = z.object({
display_name: z.string().max(80).nullable().optional()
.transform((v) => (v && v.trim() ? v.trim().replace(/[\r\n]+/g, ' ') : null)),
@@ -88,6 +120,7 @@ export const FeedbackSubmissionSchema = z.object({
z.number(),
z.boolean(),
z.array(z.string().max(200)).max(20),
DateRankedAnswerSchema,
z.null(),
]),
),
@@ -121,3 +154,5 @@ export const ShareCreateSchema = z.object({
export type FeedbackQuestion = z.infer<typeof FeedbackQuestionSchema>;
export type FeedbackFormDefinition = z.infer<typeof FeedbackFormDefinitionSchema>;
export type DateRankedOption = z.infer<typeof DateRankedOptionSchema>;
export type DateRankedAnswer = z.infer<typeof DateRankedAnswerSchema>;