Merge mai/cronus/arch-phase2-questions: per-question-type module bundle (§3.A)

This commit is contained in:
mAi
2026-05-07 20:32:27 +02:00
47 changed files with 2786 additions and 1129 deletions

178
bun.lock
View File

@@ -13,15 +13,49 @@
"@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/kit": "^2.15.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.3.1",
"@types/bun": "^1.3.13",
"jsdom": "^29.1.1",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "^5.0.0",
"vite": "^6.0.0",
"vitest": "^4.1.5",
},
},
},
"packages": {
"@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.1.11", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@csstools/css-calc": "^3.2.0", "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg=="],
"@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@7.1.1", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1" } }, "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ=="],
"@asamuzakjp/generational-cache": ["@asamuzakjp/generational-cache@1.0.1", "", {}, "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg=="],
"@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="],
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
"@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="],
"@csstools/color-helpers": ["@csstools/color-helpers@6.0.2", "", {}, "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q=="],
"@csstools/css-calc": ["@csstools/css-calc@3.2.0", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w=="],
"@csstools/css-color-parser": ["@csstools/css-color-parser@4.1.0", "", { "dependencies": { "@csstools/color-helpers": "^6.0.2", "@csstools/css-calc": "^3.2.0" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ=="],
"@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="],
"@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.3", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg=="],
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
@@ -74,6 +108,8 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@exodus/bytes": ["@exodus/bytes@1.15.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
@@ -170,10 +206,24 @@
"@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@4.0.1", "", { "dependencies": { "debug": "^4.3.7" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^5.0.0", "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw=="],
"@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
"@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="],
"@testing-library/svelte": ["@testing-library/svelte@5.3.1", "", { "dependencies": { "@testing-library/dom": "9.x.x || 10.x.x", "@testing-library/svelte-core": "1.0.0" }, "peerDependencies": { "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0", "vite": "*", "vitest": "*" }, "optionalPeers": ["vite", "vitest"] }, "sha512-8Ez7ZOqW5geRf9PF5rkuopODe5RGy3I9XR+kc7zHh26gBiktLaxTfKmhlGaSHYUOTQE7wFsLMN9xCJVCszw47w=="],
"@testing-library/svelte-core": ["@testing-library/svelte-core@1.0.0", "", { "peerDependencies": { "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" } }, "sha512-VkUePoLV6oOYwSUvX6ShA8KLnJqZiYMIbP2JW2t0GLWLkJxKGvuH5qrrZBV/X7cXFnLGuFQEC7RheYiZOW68KQ=="],
"@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
"@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="],
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
@@ -184,30 +234,72 @@
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@vitest/expect": ["@vitest/expect@4.1.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.5", "@vitest/utils": "4.1.5", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw=="],
"@vitest/mocker": ["@vitest/mocker@4.1.5", "", { "dependencies": { "@vitest/spy": "4.1.5", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw=="],
"@vitest/pretty-format": ["@vitest/pretty-format@4.1.5", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g=="],
"@vitest/runner": ["@vitest/runner@4.1.5", "", { "dependencies": { "@vitest/utils": "4.1.5", "pathe": "^2.0.3" } }, "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ=="],
"@vitest/snapshot": ["@vitest/snapshot@4.1.5", "", { "dependencies": { "@vitest/pretty-format": "4.1.5", "@vitest/utils": "4.1.5", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ=="],
"@vitest/spy": ["@vitest/spy@4.1.5", "", {}, "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ=="],
"@vitest/utils": ["@vitest/utils@4.1.5", "", { "dependencies": { "@vitest/pretty-format": "4.1.5", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
"css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="],
"css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="],
"data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"devalue": ["devalue@5.8.0", "", {}, "sha512-2zA9pFEsnp7vWBZbXF5JAgAq0fsUIt/1XPbRiAmRV3lp/2C3upzH+sADiyy66aFCihoLEsrQHxNM5w1gIDfsBg=="],
"dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
"entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="],
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
@@ -216,6 +308,8 @@
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
@@ -224,20 +318,38 @@
"hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="],
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
"iceberg-js": ["iceberg-js@0.8.1", "", {}, "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA=="],
"indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
"is-module": ["is-module@1.0.0", "", {}, "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="],
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsdom": ["jsdom@29.1.1", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.3.5", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
"lru-cache": ["lru-cache@11.3.6", "", {}, "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A=="],
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
"min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
@@ -246,8 +358,14 @@
"nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
"parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
@@ -256,46 +374,106 @@
"postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="],
"pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
"rollup": ["rollup@4.60.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.3", "@rollup/rollup-android-arm64": "4.60.3", "@rollup/rollup-darwin-arm64": "4.60.3", "@rollup/rollup-darwin-x64": "4.60.3", "@rollup/rollup-freebsd-arm64": "4.60.3", "@rollup/rollup-freebsd-x64": "4.60.3", "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", "@rollup/rollup-linux-arm-musleabihf": "4.60.3", "@rollup/rollup-linux-arm64-gnu": "4.60.3", "@rollup/rollup-linux-arm64-musl": "4.60.3", "@rollup/rollup-linux-loong64-gnu": "4.60.3", "@rollup/rollup-linux-loong64-musl": "4.60.3", "@rollup/rollup-linux-ppc64-gnu": "4.60.3", "@rollup/rollup-linux-ppc64-musl": "4.60.3", "@rollup/rollup-linux-riscv64-gnu": "4.60.3", "@rollup/rollup-linux-riscv64-musl": "4.60.3", "@rollup/rollup-linux-s390x-gnu": "4.60.3", "@rollup/rollup-linux-x64-gnu": "4.60.3", "@rollup/rollup-linux-x64-musl": "4.60.3", "@rollup/rollup-openbsd-x64": "4.60.3", "@rollup/rollup-openharmony-arm64": "4.60.3", "@rollup/rollup-win32-arm64-msvc": "4.60.3", "@rollup/rollup-win32-ia32-msvc": "4.60.3", "@rollup/rollup-win32-x64-gnu": "4.60.3", "@rollup/rollup-win32-x64-msvc": "4.60.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A=="],
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
"saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
"set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="],
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
"sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
"std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="],
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"svelte": ["svelte@5.55.5", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.4", "esm-env": "^1.2.1", "esrap": "^2.2.4", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw=="],
"svelte-check": ["svelte-check@4.4.8", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-67adfgBox5eNSNIvIIwgFizKGdcRrGpiMoNO2obHcYuLz7iTa8Xgm/NGU3ntMFnNm8K1grFOIG6HhMLX/vcN8w=="],
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
"tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="],
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
"tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
"tldts": ["tldts@7.0.30", "", { "dependencies": { "tldts-core": "^7.0.30" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw=="],
"tldts-core": ["tldts-core@7.0.30", "", {}, "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q=="],
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
"tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="],
"tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="],
"undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
"vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="],
"vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
"vitest": ["vitest@4.1.5", "", { "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", "@vitest/pretty-format": "4.1.5", "@vitest/runner": "4.1.5", "@vitest/snapshot": "4.1.5", "@vitest/spy": "4.1.5", "@vitest/utils": "4.1.5", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.5", "@vitest/browser-preview": "4.1.5", "@vitest/browser-webdriverio": "4.1.5", "@vitest/coverage-istanbul": "4.1.5", "@vitest/coverage-v8": "4.1.5", "@vitest/ui": "4.1.5", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg=="],
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
"webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="],
"whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="],
"whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="],
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
"ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
"xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="],
"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
"zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
"zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"@rollup/plugin-commonjs/is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="],
"@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
"@vitest/mocker/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
}
}

View File

@@ -9,17 +9,23 @@
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"start": "node build/index.js",
"test": "bun test ./src/lib/server/rate-limit.test.ts ./src/lib/server/public-scope.test.ts ./src/lib/server/results.test.ts ./src/lib/server/admin-route.test.ts ./src/lib/server/feedback-pure.test.ts"
"test:server": "bun test ./src/lib/server/rate-limit.test.ts ./src/lib/server/public-scope.test.ts ./src/lib/server/results.test.ts ./src/lib/server/admin-route.test.ts ./src/lib/server/feedback-pure.test.ts ./src/lib/questions/registry.test.ts ./src/lib/questions/boolean.test.ts ./src/lib/questions/text.test.ts ./src/lib/questions/scale.test.ts ./src/lib/questions/choice.test.ts ./src/lib/questions/date_ranked_choice.test.ts",
"test:components": "bun --bun vitest run --config vitest.config.ts",
"test": "bun run test:server && bun run test:components"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/kit": "^2.15.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.3.1",
"@types/bun": "^1.3.13",
"jsdom": "^29.1.1",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "^5.0.0",
"vite": "^6.0.0"
"vite": "^6.0.0",
"vitest": "^4.1.5"
},
"dependencies": {
"@supabase/supabase-js": "^2.104.1",

View File

@@ -1,92 +1,16 @@
<script lang="ts">
import type { FeedbackFormDefinition, FeedbackQuestion } from '$lib/schemas';
import Icon from '$lib/components/Icon.svelte';
import { QUESTION_MODULES, getQuestion } from '$lib/questions/registry';
let { value = $bindable() }: { value: FeedbackFormDefinition } = $props();
const TYPE_LABELS: Record<FeedbackQuestion['type'], string> = {
short_text: 'Short text',
long_text: 'Long text',
single_choice: 'Single choice',
multi_choice: 'Multiple choice',
scale: 'Scale',
boolean: 'Yes / No',
date_ranked_choice: 'Date ranked choice',
};
const TYPES: FeedbackQuestion['type'][] = [
'short_text',
'long_text',
'single_choice',
'multi_choice',
'scale',
'boolean',
'date_ranked_choice',
];
function uid(): string {
const buf = new Uint8Array(6);
(globalThis.crypto ?? (window as unknown as { crypto: Crypto }).crypto).getRandomValues(buf);
return 'q_' + Array.from(buf, (b) => b.toString(36)).join('').slice(0, 8);
}
function optUid(): string {
const buf = new Uint8Array(4);
(globalThis.crypto ?? (window as unknown as { crypto: Crypto }).crypto).getRandomValues(buf);
return 'opt_' + Array.from(buf, (b) => b.toString(36)).join('').slice(0, 6);
}
/** Round `now` to the next full hour and return as ISO 8601 UTC. */
function defaultStartIso(offsetHours = 0): string {
const d = new Date();
d.setMinutes(0, 0, 0);
d.setHours(d.getHours() + 1 + offsetHours);
return d.toISOString();
}
/** Convert a stored UTC ISO string into the `YYYY-MM-DDTHH:MM` shape that `<input type="datetime-local">` expects, in the viewer's local time. */
function isoToLocalInput(iso: string | undefined | null): string {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d.getTime())) return '';
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/** Convert a `<input type="datetime-local">` value (local time, no offset) into a UTC ISO string. */
function localInputToIso(local: string): string | null {
if (!local) return null;
const d = new Date(local);
if (isNaN(d.getTime())) return null;
return d.toISOString();
}
function defaultQuestion(type: FeedbackQuestion['type']): FeedbackQuestion {
const base = { id: uid(), label: 'New question', required: false } as const;
switch (type) {
case 'short_text':
case 'long_text':
return { ...base, type };
case 'single_choice':
case 'multi_choice':
return { ...base, type, options: ['Option A', 'Option B'] };
case 'scale':
return { ...base, type, min: 1, max: 5 };
case 'boolean':
return { ...base, type };
case 'date_ranked_choice':
return {
...base,
type,
options: [
{ id: optUid(), start: defaultStartIso(0) },
{ id: optUid(), start: defaultStartIso(24) },
],
allow_partial: true,
};
}
}
function update(idx: number, patch: Partial<FeedbackQuestion>): void {
const next = [...value.questions];
next[idx] = { ...next[idx], ...patch } as FeedbackQuestion;
@@ -95,7 +19,7 @@
function changeType(idx: number, type: FeedbackQuestion['type']): void {
const old = value.questions[idx];
const fresh = defaultQuestion(type);
const fresh = getQuestion(type).defaultStub() as FeedbackQuestion;
const next = [...value.questions];
next[idx] = { ...fresh, id: old.id, label: old.label, required: old.required, help: old.help };
value = { ...value, questions: next };
@@ -115,80 +39,8 @@
}
function add(type: FeedbackQuestion['type']): void {
value = { ...value, questions: [...value.questions, defaultQuestion(type)] };
}
function setOption(idx: number, optIdx: number, val: string): void {
const q = value.questions[idx];
if (q.type !== 'single_choice' && q.type !== 'multi_choice') return;
const options = [...q.options];
options[optIdx] = val;
update(idx, { options } as Partial<FeedbackQuestion>);
}
function addOption(idx: number): void {
const q = value.questions[idx];
if (q.type !== 'single_choice' && q.type !== 'multi_choice') return;
update(idx, { options: [...q.options, `Option ${q.options.length + 1}`] } as Partial<FeedbackQuestion>);
}
function removeOption(idx: number, optIdx: number): void {
const q = value.questions[idx];
if (q.type !== 'single_choice' && q.type !== 'multi_choice') return;
if (q.options.length <= 2) return;
update(idx, { options: q.options.filter((_, i) => i !== optIdx) } as Partial<FeedbackQuestion>);
}
function setDateOption(idx: number, optIdx: number, patch: { start?: string; end?: string | null; label?: string | null }): void {
const q = value.questions[idx];
if (q.type !== 'date_ranked_choice') return;
const options = q.options.map((opt, i) => {
if (i !== optIdx) return opt;
const next = { ...opt };
if (patch.start !== undefined) next.start = patch.start;
if (patch.end !== undefined) {
if (patch.end === null || patch.end === '') delete next.end;
else next.end = patch.end;
}
if (patch.label !== undefined) {
if (patch.label === null || patch.label === '') delete next.label;
else next.label = patch.label;
}
return next;
});
update(idx, { options } as Partial<FeedbackQuestion>);
}
function addDateOption(idx: number): void {
const q = value.questions[idx];
if (q.type !== 'date_ranked_choice') return;
if (q.options.length >= 50) return;
update(idx, {
options: [...q.options, { id: optUid(), start: defaultStartIso(24 * (q.options.length + 1)) }],
} as Partial<FeedbackQuestion>);
}
function removeDateOption(idx: number, optIdx: number): void {
const q = value.questions[idx];
if (q.type !== 'date_ranked_choice') return;
if (q.options.length <= 2) return;
update(idx, { options: q.options.filter((_, i) => i !== optIdx) } as Partial<FeedbackQuestion>);
}
function setScaleLabel(idx: number, which: 'min_label' | 'max_label', val: string): void {
const q = value.questions[idx];
if (q.type !== 'date_ranked_choice') return;
const scale = { ...(q.scale ?? {}) };
if (val === '') delete scale[which];
else scale[which] = val;
const empty = !scale.min_label && !scale.max_label;
update(idx, { scale: empty ? undefined : scale } as Partial<FeedbackQuestion>);
}
function setAllowPartial(idx: number, allow: boolean): void {
const q = value.questions[idx];
if (q.type !== 'date_ranked_choice') return;
update(idx, { allow_partial: allow } as Partial<FeedbackQuestion>);
const fresh = { ...(getQuestion(type).defaultStub() as FeedbackQuestion), id: uid() };
value = { ...value, questions: [...value.questions, fresh] };
}
</script>
@@ -209,6 +61,7 @@
<div class="fb-builder__list">
{#each value.questions as q, i (q.id)}
{@const Editor = getQuestion(q.type).BuilderEditor}
<div class="fb-builder__card">
<div class="fb-builder__card-head">
<span class="fb-builder__num">{i + 1}.</span>
@@ -217,8 +70,8 @@
value={q.type}
onchange={(e) => changeType(i, (e.target as HTMLSelectElement).value as FeedbackQuestion['type'])}
>
{#each TYPES as t (t)}
<option value={t}>{TYPE_LABELS[t]}</option>
{#each QUESTION_MODULES as m (m.type)}
<option value={m.type}>{m.label}</option>
{/each}
</select>
<div class="fb-builder__card-actions">
@@ -229,8 +82,9 @@
</div>
<div class="fb-question">
<label class="fb-question__label">Label</label>
<label class="fb-question__label" for={`fb-builder-${q.id}-label`}>Label</label>
<input
id={`fb-builder-${q.id}-label`}
class="fb-input"
maxlength="200"
value={q.label}
@@ -247,177 +101,12 @@
<span>Required</span>
</label>
{#if q.type === 'short_text' || q.type === 'long_text'}
<div class="fb-question">
<label class="fb-question__label">Placeholder (optional)</label>
<input
class="fb-input"
maxlength="100"
value={q.placeholder ?? ''}
oninput={(e) => update(i, { placeholder: (e.target as HTMLInputElement).value || undefined } as Partial<FeedbackQuestion>)}
/>
</div>
{:else if q.type === 'single_choice' || q.type === 'multi_choice'}
<div class="fb-question">
<label class="fb-question__label">Options</label>
<div class="fb-builder__options">
{#each q.options as opt, optIdx (optIdx)}
<div class="fb-builder__option-row">
<input
class="fb-input"
maxlength="200"
value={opt}
oninput={(e) => setOption(i, optIdx, (e.target as HTMLInputElement).value)}
/>
<button
type="button"
class="fb-builder__icon-btn fb-builder__icon-btn--danger"
disabled={q.options.length <= 2}
onclick={() => removeOption(i, optIdx)}
aria-label="Remove option"
><Icon name="x" size={14} /></button>
</div>
{/each}
</div>
<button type="button" class="fb-btn fb-btn--secondary fb-btn--sm fb-builder__add-option" onclick={() => addOption(i)}><Icon name="plus" /> Option</button>
</div>
{:else if q.type === 'scale'}
<div class="fb-builder__scale">
<div class="fb-question">
<label class="fb-question__label">Min</label>
<input
type="number"
class="fb-input"
min="0"
max="100"
value={q.min}
oninput={(e) => update(i, { min: Number((e.target as HTMLInputElement).value) } as Partial<FeedbackQuestion>)}
/>
</div>
<div class="fb-question">
<label class="fb-question__label">Max</label>
<input
type="number"
class="fb-input"
min="1"
max="100"
value={q.max}
oninput={(e) => update(i, { max: Number((e.target as HTMLInputElement).value) } as Partial<FeedbackQuestion>)}
/>
</div>
<div class="fb-question">
<label class="fb-question__label">Min label (optional)</label>
<input
class="fb-input"
maxlength="50"
value={q.min_label ?? ''}
oninput={(e) => update(i, { min_label: (e.target as HTMLInputElement).value || undefined } as Partial<FeedbackQuestion>)}
/>
</div>
<div class="fb-question">
<label class="fb-question__label">Max label (optional)</label>
<input
class="fb-input"
maxlength="50"
value={q.max_label ?? ''}
oninput={(e) => update(i, { max_label: (e.target as HTMLInputElement).value || undefined } as Partial<FeedbackQuestion>)}
/>
</div>
</div>
{:else if q.type === 'date_ranked_choice'}
<div class="fb-question">
<label class="fb-question__label">Date / time options</label>
<div class="fb-builder__date-ranked">
{#each q.options as opt, optIdx (opt.id)}
<div class="fb-builder__date-row">
<div class="fb-builder__date-fields">
<label class="fb-question__label">Start</label>
<input
type="datetime-local"
class="fb-input"
value={isoToLocalInput(opt.start)}
oninput={(e) => {
const iso = localInputToIso((e.target as HTMLInputElement).value);
if (iso) setDateOption(i, optIdx, { start: iso });
}}
/>
<label class="fb-question__label">End (optional)</label>
<input
type="datetime-local"
class="fb-input"
value={isoToLocalInput(opt.end)}
oninput={(e) => {
const raw = (e.target as HTMLInputElement).value;
if (!raw) setDateOption(i, optIdx, { end: null });
else {
const iso = localInputToIso(raw);
if (iso) setDateOption(i, optIdx, { end: iso });
}
}}
/>
<label class="fb-question__label">Label (optional)</label>
<input
class="fb-input"
maxlength="200"
placeholder="e.g. Office, 09:00 sharp"
value={opt.label ?? ''}
oninput={(e) => setDateOption(i, optIdx, { label: (e.target as HTMLInputElement).value })}
/>
</div>
<button
type="button"
class="fb-builder__icon-btn fb-builder__icon-btn--danger"
disabled={q.options.length <= 2}
onclick={() => removeDateOption(i, optIdx)}
aria-label="Remove option"
><Icon name="x" size={14} /></button>
</div>
{/each}
</div>
<button
type="button"
class="fb-btn fb-btn--secondary fb-btn--sm fb-builder__add-option"
onclick={() => addDateOption(i)}
disabled={q.options.length >= 50}
><Icon name="plus" /> Date option</button>
</div>
<div class="fb-builder__scale">
<div class="fb-question">
<label class="fb-question__label">Rating-1 label (optional)</label>
<input
class="fb-input"
maxlength="50"
placeholder="e.g. doesn't work"
value={q.scale?.min_label ?? ''}
oninput={(e) => setScaleLabel(i, 'min_label', (e.target as HTMLInputElement).value)}
/>
</div>
<div class="fb-question">
<label class="fb-question__label">Rating-5 label (optional)</label>
<input
class="fb-input"
maxlength="50"
placeholder="e.g. works great"
value={q.scale?.max_label ?? ''}
oninput={(e) => setScaleLabel(i, 'max_label', (e.target as HTMLInputElement).value)}
/>
</div>
</div>
<label class="fb-option-row" style="display:inline-flex;">
<input
type="checkbox"
checked={q.allow_partial !== false}
onchange={(e) => setAllowPartial(i, (e.target as HTMLInputElement).checked)}
/>
<span>Allow participants to skip individual options</span>
</label>
{/if}
<Editor question={q} update={(patch) => update(i, patch as Partial<FeedbackQuestion>)} />
<div class="fb-question">
<label class="fb-question__label">Help text (optional)</label>
<label class="fb-question__label" for={`fb-builder-${q.id}-help`}>Help text (optional)</label>
<input
id={`fb-builder-${q.id}-help`}
class="fb-input"
maxlength="500"
value={q.help ?? ''}
@@ -431,8 +120,8 @@
</div>
<div class="fb-builder__add">
{#each TYPES as t (t)}
<button type="button" class="fb-btn fb-btn--ghost fb-btn--sm" onclick={() => add(t)}><Icon name="plus" /> {TYPE_LABELS[t]}</button>
{#each QUESTION_MODULES as m (m.type)}
<button type="button" class="fb-btn fb-btn--ghost fb-btn--sm" onclick={() => add(m.type)}><Icon name="plus" /> {m.label}</button>
{/each}
</div>
</div>

View File

@@ -1,187 +1,8 @@
<script lang="ts">
import type { AggregatedResults, DateRankedOptionStats, QuestionResult } from '$lib/server/results';
import type { AggregatedResults } from '$lib/server/results';
import { getQuestion } from '$lib/questions/registry';
let { results }: { results: AggregatedResults } = $props();
// Per-question view mode for date_ranked_choice. Calendar default; toggle to bars.
let drcView = $state<Record<string, 'calendar' | 'bars'>>({});
function viewFor(qid: string): 'calendar' | 'bars' {
return drcView[qid] ?? 'calendar';
}
function setView(qid: string, v: 'calendar' | 'bars'): void {
drcView = { ...drcView, [qid]: v };
}
function pct(part: number, whole: number): number {
if (whole === 0) return 0;
return Math.round((part / whole) * 100);
}
function fmtMean(m: number | null): string {
if (m === null) return '—';
return m.toFixed(2).replace(/\.?0+$/, '');
}
function maxOf(values: number[]): number {
if (values.length === 0) return 0;
return values.reduce((a, b) => (a > b ? a : b), 0);
}
/* Date-ranked-choice helpers */
function mixHex(a: string, b: string, t: number): string {
const tt = Math.max(0, Math.min(1, t));
const ar = parseInt(a.slice(1, 3), 16);
const ag = parseInt(a.slice(3, 5), 16);
const ab = parseInt(a.slice(5, 7), 16);
const br = parseInt(b.slice(1, 3), 16);
const bg = parseInt(b.slice(3, 5), 16);
const bb = parseInt(b.slice(5, 7), 16);
return `rgb(${Math.round(ar + (br - ar) * tt)}, ${Math.round(ag + (bg - ag) * tt)}, ${Math.round(ab + (bb - ab) * tt)})`;
}
const COLOR_LOW = '#ef4444'; // 1
const COLOR_MID = '#f59e0b'; // 3
const COLOR_HIGH = '#16a34a'; // 5
function colorForRating(value: number): string {
if (value <= 1) return COLOR_LOW;
if (value >= 5) return COLOR_HIGH;
if (value < 3) return mixHex(COLOR_LOW, COLOR_MID, (value - 1) / 2);
return mixHex(COLOR_MID, COLOR_HIGH, (value - 3) / 2);
}
function colorForMean(mean: number | null): string {
if (mean === null) return 'var(--color-bg-secondary)';
return colorForRating(mean);
}
const dayFmt = new Intl.DateTimeFormat([], { day: '2-digit' });
const monthFmt = new Intl.DateTimeFormat([], { month: 'short' });
const weekdayFmt = new Intl.DateTimeFormat([], { weekday: 'short' });
const timeFmt = new Intl.DateTimeFormat([], { hour: '2-digit', minute: '2-digit' });
const fullDateFmt = new Intl.DateTimeFormat([], {
weekday: 'short',
day: '2-digit',
month: 'short',
year: 'numeric',
});
function localDateKey(iso: string): string {
const d = new Date(iso);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function fmtTimeRange(start: string, end: string | null): string {
const s = timeFmt.format(new Date(start));
if (!end) return s;
const e = timeFmt.format(new Date(end));
return `${s}${e}`;
}
function fmtFullDate(iso: string): string {
try {
return fullDateFmt.format(new Date(iso));
} catch {
return iso;
}
}
interface CalendarCell {
key: string; // YYYY-MM-DD
date: Date; // midnight local
options: DateRankedOptionStats[];
}
function buildCalendar(options: DateRankedOptionStats[]): { cells: CalendarCell[]; collapsed: boolean } {
if (options.length === 0) return { cells: [], collapsed: false };
const byDay = new Map<string, CalendarCell>();
for (const opt of options) {
const key = localDateKey(opt.start);
let cell = byDay.get(key);
if (!cell) {
const d = new Date(opt.start);
d.setHours(0, 0, 0, 0);
cell = { key, date: d, options: [] };
byDay.set(key, cell);
}
cell.options.push(opt);
}
const occupied = Array.from(byDay.values()).sort((a, b) => a.date.getTime() - b.date.getTime());
if (occupied.length <= 1) return { cells: occupied, collapsed: false };
const first = occupied[0].date;
const last = occupied[occupied.length - 1].date;
const dayMs = 24 * 60 * 60 * 1000;
const span = Math.round((last.getTime() - first.getTime()) / dayMs) + 1;
// > 30 days → suppress empty days; otherwise contiguous strip with empties.
if (span > 30) return { cells: occupied, collapsed: true };
const cells: CalendarCell[] = [];
for (let i = 0; i < span; i++) {
const d = new Date(first.getTime() + i * dayMs);
const k = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
cells.push(byDay.get(k) ?? { key: k, date: d, options: [] });
}
return { cells, collapsed: false };
}
function cellTitle(cell: CalendarCell): string {
const date = fmtFullDate(cell.date.toISOString());
if (cell.options.length === 0) return date;
const lines = cell.options.map((opt) => {
const time = fmtTimeRange(opt.start, opt.end);
const label = opt.label ? ` · ${opt.label}` : '';
const mean = opt.mean === null ? '—' : opt.mean.toFixed(2).replace(/\.?0+$/, '');
return `${time}${label}${mean} avg (${opt.count})`;
});
return `${date}\n${lines.join('\n')}`;
}
function shortDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString();
} catch {
return iso;
}
}
const dateOptionFmt = new Intl.DateTimeFormat([], {
weekday: 'short',
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
const dateOptionTimeFmt = new Intl.DateTimeFormat([], {
hour: '2-digit',
minute: '2-digit',
});
function fmtDateOption(start: string, end: string | null | undefined): string {
try {
const startStr = dateOptionFmt.format(new Date(start));
if (!end) return startStr;
const sd = new Date(start);
const ed = new Date(end);
if (sd.toDateString() === ed.toDateString()) {
return `${startStr}${dateOptionTimeFmt.format(ed)}`;
}
return `${startStr} ${dateOptionFmt.format(ed)}`;
} catch {
return start;
}
}
function questionDenominator(q: QuestionResult): number {
// For multi_choice, the count is the number of submissions that ticked
// at least one option; we render bars relative to that so percentages
// add up sensibly even when one user picks two options.
return q.stats.count;
}
</script>
<div class="fb-results">
@@ -194,181 +15,10 @@
<div class="fb-results__empty">Noch keine Antworten.</div>
{:else}
{#each results.questions as q (q.id)}
{@const Block = getQuestion(q.type).ResultsBlock}
<div class="fb-results__q">
<div class="fb-results__label">{q.label}</div>
{#if q.stats.type === 'scale'}
{@const denom = maxOf(q.stats.histogram.map((b) => b.count))}
<div class="fb-results__meta">
Schnitt: <strong>{fmtMean(q.stats.mean)}</strong> · {q.stats.count} Antworten
</div>
<div class="fb-results__bars">
{#each q.stats.histogram as bucket (bucket.value)}
<div class="fb-results__row">
<span class="fb-results__row-label">{bucket.value}</span>
<div class="fb-results__bar-track">
<div class="fb-results__bar-fill" style="width: {pct(bucket.count, denom)}%"></div>
</div>
<span class="fb-results__row-count">{bucket.count}</span>
</div>
{/each}
</div>
{:else if q.stats.type === 'single_choice' || q.stats.type === 'multi_choice'}
{@const denom = questionDenominator(q)}
<div class="fb-results__meta">{q.stats.count} Antworten</div>
<div class="fb-results__bars">
{#each q.stats.options as opt (opt.option)}
<div class="fb-results__row">
<span class="fb-results__row-label fb-results__row-label--wide">{opt.option}</span>
<div class="fb-results__bar-track">
<div class="fb-results__bar-fill" style="width: {pct(opt.count, denom)}%"></div>
</div>
<span class="fb-results__row-count">{opt.count} · {pct(opt.count, denom)}%</span>
</div>
{/each}
{#if q.stats.other_count > 0}
<div class="fb-results__row fb-results__row--muted">
<span class="fb-results__row-label fb-results__row-label--wide">Andere (frühere Versionen)</span>
<div class="fb-results__bar-track">
<div class="fb-results__bar-fill fb-results__bar-fill--muted" style="width: {pct(q.stats.other_count, denom)}%"></div>
</div>
<span class="fb-results__row-count">{q.stats.other_count}</span>
</div>
{/if}
</div>
{:else if q.stats.type === 'boolean'}
{@const denom = q.stats.count}
<div class="fb-results__meta">{q.stats.count} Antworten</div>
<div class="fb-results__bars">
<div class="fb-results__row">
<span class="fb-results__row-label">Ja</span>
<div class="fb-results__bar-track">
<div class="fb-results__bar-fill" style="width: {pct(q.stats.yes, denom)}%"></div>
</div>
<span class="fb-results__row-count">{q.stats.yes} · {pct(q.stats.yes, denom)}%</span>
</div>
<div class="fb-results__row">
<span class="fb-results__row-label">Nein</span>
<div class="fb-results__bar-track">
<div class="fb-results__bar-fill" style="width: {pct(q.stats.no, denom)}%"></div>
</div>
<span class="fb-results__row-count">{q.stats.no} · {pct(q.stats.no, denom)}%</span>
</div>
</div>
{:else if q.stats.type === 'date_ranked_choice'}
{@const drcStats = q.stats}
{@const calendar = buildCalendar(drcStats.options)}
{@const showCalendar = drcStats.options.length > 1}
{@const view = showCalendar ? viewFor(q.id) : 'bars'}
<div class="fb-results__meta">
{drcStats.count} {drcStats.count === 1 ? 'Antwort' : 'Antworten'}
</div>
{#if drcStats.count === 0}
<p class="fb-results__meta">Noch keine Bewertungen.</p>
{:else}
{#if showCalendar}
<div class="fb-tabs fb-results__drc-tabs" role="tablist" aria-label="Ergebnis-Ansicht">
<button
type="button"
class="fb-tab"
class:fb-tab--active={view === 'calendar'}
role="tab"
aria-selected={view === 'calendar'}
onclick={() => setView(q.id, 'calendar')}
>Kalender</button>
<button
type="button"
class="fb-tab"
class:fb-tab--active={view === 'bars'}
role="tab"
aria-selected={view === 'bars'}
onclick={() => setView(q.id, 'bars')}
>Balken</button>
</div>
{/if}
{#if view === 'calendar'}
<div class="fb-results__cal" class:fb-results__cal--collapsed={calendar.collapsed}>
{#each calendar.cells as cell (cell.key)}
<div
class="fb-results__cal-day"
class:fb-results__cal-day--empty={cell.options.length === 0}
title={cellTitle(cell)}
>
<div class="fb-results__cal-head">
<span class="fb-results__cal-weekday">{weekdayFmt.format(cell.date)}</span>
<span class="fb-results__cal-num">{dayFmt.format(cell.date)}</span>
<span class="fb-results__cal-month">{monthFmt.format(cell.date)}</span>
</div>
{#if cell.options.length > 0}
<div class="fb-results__cal-slots">
{#each cell.options as opt (opt.id)}
<div
class="fb-results__cal-slot"
style="background: {colorForMean(opt.mean)};"
>
<span class="fb-results__cal-slot-time">{fmtTimeRange(opt.start, opt.end)}</span>
<span class="fb-results__cal-slot-mean">{fmtMean(opt.mean)}</span>
<span class="fb-results__cal-slot-count">{opt.count}</span>
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{:else}
<div class="fb-results__drc-bars">
{#each drcStats.options as opt, optIdx (opt.id)}
{@const total = opt.count}
<div class="fb-results__drc-row">
<div class="fb-results__drc-rank">#{optIdx + 1}</div>
<div class="fb-results__drc-when">
<div>{fmtDateOption(opt.start, opt.end)}</div>
{#if opt.label}<div class="fb-results__date-label">{opt.label}</div>{/if}
</div>
<div class="fb-results__drc-avg" style="color: {colorForMean(opt.mean)};">
{fmtMean(opt.mean)}
</div>
<div class="fb-results__drc-bar" aria-label="Verteilung">
{#if total === 0}
<div class="fb-results__drc-bar-empty">Keine Bewertung</div>
{:else}
{#each opt.histogram as bucket (bucket.value)}
{#if bucket.count > 0}
<div
class="fb-results__drc-bar-seg"
style="width: {pct(bucket.count, total)}%; background: {colorForRating(bucket.value)};"
title="{bucket.count}× {bucket.value}"
>
<span>{bucket.value}·{bucket.count}</span>
</div>
{/if}
{/each}
{/if}
</div>
<div class="fb-results__drc-count">
{opt.count}
</div>
</div>
{/each}
</div>
{/if}
{/if}
{:else if q.stats.type === 'short_text' || q.stats.type === 'long_text'}
<div class="fb-results__meta">{q.stats.count} Antworten</div>
{#if q.stats.answers.length > 0}
<ul class="fb-results__answers">
{#each q.stats.answers as a (a.created_at + a.value.slice(0, 20))}
<li>
<span class="fb-results__answer-date">{shortDate(a.created_at)}</span>
<span class="fb-results__answer-text">{a.value}</span>
</li>
{/each}
</ul>
{/if}
{/if}
<Block question={q} stats={q.stats} />
</div>
{/each}
{/if}

View File

@@ -0,0 +1,10 @@
<script lang="ts">
let { label = 'hello' }: { label?: string } = $props();
let count = $state(0);
</script>
<div data-testid="smoke">
<span data-testid="label">{label}</span>
<button type="button" data-testid="bump" onclick={() => count++}>bump</button>
<span data-testid="count">{count}</span>
</div>

View File

@@ -0,0 +1,21 @@
import { describe, test, expect } from 'vitest';
import { render } from '@testing-library/svelte';
import SmokeTest from './SmokeTest.svelte';
// Verifies the vitest + jsdom + @testing-library/svelte stack is wired up.
// If this fails, every other *.svelte.test.ts in the codebase is unreliable.
//
// Run via: bun run test:components (which delegates to `bun --bun vitest run`).
describe('SmokeTest (test runner sanity)', () => {
test('renders props into the DOM', () => {
const { getByTestId } = render(SmokeTest, { props: { label: 'hello world' } });
expect(getByTestId('label').textContent).toBe('hello world');
expect(getByTestId('count').textContent).toBe('0');
});
test('component initial state is observable', () => {
const { getByTestId } = render(SmokeTest, { props: { label: 'second' } });
expect(getByTestId('count').textContent).toBe('0');
});
});

View File

@@ -0,0 +1,20 @@
/**
* Shared base schema for every question type. Each per-type module extends
* this with its type-specific fields (placeholder for text types, options
* for choice types, etc.).
*
* Lives outside `types.ts` because it's a runtime zod schema, not just a
* type alias — keeping it in a sibling file avoids circular imports between
* `types.ts` (which the schemas.ts compiled union eventually reads) and
* per-type modules.
*/
import { z } from 'zod';
export const FeedbackQuestionBaseSchema = z.object({
id: z.string().min(1).max(64),
label: z.string().min(1).max(200),
required: z.boolean().optional(),
help: z.string().max(500).optional(),
});
export type FeedbackQuestionBase = z.infer<typeof FeedbackQuestionBaseSchema>;

View File

@@ -0,0 +1,8 @@
<script lang="ts">
// boolean has no type-specific fields beyond the base (label / required /
// help) — the parent FormBuilder card handles those. This component
// renders nothing of its own; it exists so the registry slot is
// consistent across all seven types.
import type { BuilderEditorProps } from './types';
let { question: _q, update: _u }: BuilderEditorProps = $props();
</script>

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import type { ParticipantInputProps } from './types';
let { question, answer, setAnswer }: ParticipantInputProps = $props();
</script>
<div class="fb-options">
<label class="fb-option-row">
<input
type="radio"
name={`q-${question.id}`}
checked={answer === true}
onchange={() => setAnswer(true)}
/>
<span>Ja</span>
</label>
<label class="fb-option-row">
<input
type="radio"
name={`q-${question.id}`}
checked={answer === false}
onchange={() => setAnswer(false)}
/>
<span>Nein</span>
</label>
</div>

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import type { ResultsBlockProps } from './types';
let { stats }: ResultsBlockProps = $props();
function pct(part: number, whole: number): number {
if (whole === 0) return 0;
return Math.round((part / whole) * 100);
}
</script>
{#if stats.type === 'boolean'}
{@const denom = stats.count}
<div class="fb-results__meta">{stats.count} Antworten</div>
<div class="fb-results__bars">
<div class="fb-results__row">
<span class="fb-results__row-label">Ja</span>
<div class="fb-results__bar-track">
<div class="fb-results__bar-fill" style="width: {pct(stats.yes, denom)}%"></div>
</div>
<span class="fb-results__row-count">{stats.yes} · {pct(stats.yes, denom)}%</span>
</div>
<div class="fb-results__row">
<span class="fb-results__row-label">Nein</span>
<div class="fb-results__bar-track">
<div class="fb-results__bar-fill" style="width: {pct(stats.no, denom)}%"></div>
</div>
<span class="fb-results__row-count">{stats.no} · {pct(stats.no, denom)}%</span>
</div>
</div>
{/if}

View File

@@ -0,0 +1,94 @@
import { describe, test, expect } from 'bun:test';
import { BooleanQuestion, BooleanQuestionSchema } from './boolean';
describe('BooleanQuestion.schema', () => {
test('accepts a valid boolean question', () => {
const r = BooleanQuestionSchema.safeParse({
id: 'q1',
label: 'Recommend?',
required: true,
type: 'boolean',
});
expect(r.success).toBe(true);
});
test('rejects when type is wrong', () => {
const r = BooleanQuestionSchema.safeParse({ id: 'q1', label: 'X', type: 'scale' });
expect(r.success).toBe(false);
});
test('rejects when label is missing', () => {
const r = BooleanQuestionSchema.safeParse({ id: 'q1', type: 'boolean' });
expect(r.success).toBe(false);
});
});
describe('BooleanQuestion.isAnswerEmpty', () => {
const q = BooleanQuestion.defaultStub();
test('undefined → empty', () => {
expect(BooleanQuestion.isAnswerEmpty(q, undefined)).toBe(true);
});
test('null → empty', () => {
expect(BooleanQuestion.isAnswerEmpty(q, null)).toBe(true);
});
test('true → not empty', () => {
expect(BooleanQuestion.isAnswerEmpty(q, true)).toBe(false);
});
test('false → not empty (Nein is a valid answer)', () => {
expect(BooleanQuestion.isAnswerEmpty(q, false)).toBe(false);
});
test('non-boolean → empty', () => {
expect(BooleanQuestion.isAnswerEmpty(q, 'true')).toBe(true);
expect(BooleanQuestion.isAnswerEmpty(q, 1)).toBe(true);
});
});
describe('BooleanQuestion.ingest + finalise', () => {
const q = BooleanQuestion.defaultStub();
test('counts yes/no separately, ignores garbage', () => {
const stats = BooleanQuestion.emptyStats(q);
BooleanQuestion.ingest(stats, q, true, 'now');
BooleanQuestion.ingest(stats, q, true, 'now');
BooleanQuestion.ingest(stats, q, false, 'now');
BooleanQuestion.ingest(stats, q, 'oops', 'now');
BooleanQuestion.ingest(stats, q, null, 'now');
BooleanQuestion.finalise(stats);
expect(stats.count).toBe(3);
expect(stats.yes).toBe(2);
expect(stats.no).toBe(1);
});
});
describe('BooleanQuestion.csv', () => {
const q = BooleanQuestion.defaultStub();
test('one column per question', () => {
const cols = BooleanQuestion.csvColumns({ ...q, id: 'recommend' });
expect(cols).toEqual([{ header: 'recommend', qid: 'recommend' }]);
});
test('cell renders true/false/empty literals', () => {
const [col] = BooleanQuestion.csvColumns(q);
expect(BooleanQuestion.csvCellFor(q, true, col)).toBe('true');
expect(BooleanQuestion.csvCellFor(q, false, col)).toBe('false');
expect(BooleanQuestion.csvCellFor(q, null, col)).toBe('');
expect(BooleanQuestion.csvCellFor(q, undefined, col)).toBe('');
});
});
describe('BooleanQuestion.adminCellSummary', () => {
const q = BooleanQuestion.defaultStub();
test('formats Yes / No / em-dash', () => {
expect(BooleanQuestion.adminCellSummary(q, true)).toBe('Yes');
expect(BooleanQuestion.adminCellSummary(q, false)).toBe('No');
expect(BooleanQuestion.adminCellSummary(q, null)).toBe('—');
expect(BooleanQuestion.adminCellSummary(q, undefined)).toBe('—');
});
});

View File

@@ -0,0 +1,69 @@
/**
* `boolean` question type — Yes/No radio pair on the participant side, count
* + percent bars in the results.
*/
import { z } from 'zod';
import type { QuestionTypeModule, CsvColumn } from './types';
import { FeedbackQuestionBaseSchema } from './_base';
import BooleanInput from './boolean.input.svelte';
import BooleanBuilder from './boolean.builder.svelte';
import BooleanResults from './boolean.results.svelte';
export const BooleanQuestionSchema = FeedbackQuestionBaseSchema.extend({
type: z.literal('boolean'),
});
type Q = z.infer<typeof BooleanQuestionSchema>;
export const BooleanQuestion: QuestionTypeModule<'boolean'> = {
type: 'boolean',
label: 'Yes / No',
schema: BooleanQuestionSchema,
defaultStub() {
return { id: 'q1', label: 'New question', required: false, type: 'boolean' };
},
isAnswerEmpty(_q: Q, answer: unknown): boolean {
return answer !== true && answer !== false;
},
emptyStats() {
return { type: 'boolean', count: 0, yes: 0, no: 0 };
},
ingest(stats, _q, answer) {
if (typeof answer !== 'boolean') return;
stats.count++;
if (answer) stats.yes++;
else stats.no++;
},
finalise() {
// boolean stats are complete after ingest — nothing to compute.
},
sanitizeForPublic(stats) {
return stats;
},
csvColumns(q: Q): CsvColumn[] {
return [{ header: q.id, qid: q.id }];
},
csvCellFor(_q, answer) {
if (answer === true) return 'true';
if (answer === false) return 'false';
return '';
},
adminCellSummary(_q, answer) {
if (answer === true) return 'Yes';
if (answer === false) return 'No';
return '—';
},
ParticipantInput: BooleanInput,
BuilderEditor: BooleanBuilder,
ResultsBlock: BooleanResults,
};

View File

@@ -0,0 +1,54 @@
<script lang="ts">
import Icon from '$lib/components/Icon.svelte';
import type { BuilderEditorProps } from './types';
let { question, update }: BuilderEditorProps = $props();
function setOption(idx: number, val: string): void {
if (question.type !== 'single_choice' && question.type !== 'multi_choice') return;
const options = [...question.options];
options[idx] = val;
update({ options });
}
function addOption(): void {
if (question.type !== 'single_choice' && question.type !== 'multi_choice') return;
update({ options: [...question.options, `Option ${question.options.length + 1}`] });
}
function removeOption(idx: number): void {
if (question.type !== 'single_choice' && question.type !== 'multi_choice') return;
if (question.options.length <= 2) return;
update({ options: question.options.filter((_, i) => i !== idx) });
}
</script>
{#if question.type === 'single_choice' || question.type === 'multi_choice'}
<div class="fb-question">
<label class="fb-question__label" for={`fb-builder-${question.id}-options`}>Options</label>
<div id={`fb-builder-${question.id}-options`} class="fb-builder__options">
{#each question.options as opt, optIdx (optIdx)}
<div class="fb-builder__option-row">
<input
class="fb-input"
maxlength="200"
value={opt}
oninput={(e) => setOption(optIdx, (e.target as HTMLInputElement).value)}
/>
<button
type="button"
class="fb-builder__icon-btn fb-builder__icon-btn--danger"
disabled={question.options.length <= 2}
onclick={() => removeOption(optIdx)}
aria-label="Remove option"
><Icon name="x" size={14} /></button>
</div>
{/each}
</div>
<button
type="button"
class="fb-btn fb-btn--secondary fb-btn--sm fb-builder__add-option"
onclick={addOption}
><Icon name="plus" /> Option</button>
</div>
{/if}

View File

@@ -0,0 +1,35 @@
<script lang="ts">
import type { ResultsBlockProps } from './types';
let { stats }: ResultsBlockProps = $props();
function pct(part: number, whole: number): number {
if (whole === 0) return 0;
return Math.round((part / whole) * 100);
}
</script>
{#if stats.type === 'single_choice' || stats.type === 'multi_choice'}
{@const denom = stats.count}
<div class="fb-results__meta">{stats.count} Antworten</div>
<div class="fb-results__bars">
{#each stats.options as opt (opt.option)}
<div class="fb-results__row">
<span class="fb-results__row-label fb-results__row-label--wide">{opt.option}</span>
<div class="fb-results__bar-track">
<div class="fb-results__bar-fill" style="width: {pct(opt.count, denom)}%"></div>
</div>
<span class="fb-results__row-count">{opt.count} · {pct(opt.count, denom)}%</span>
</div>
{/each}
{#if stats.other_count > 0}
<div class="fb-results__row fb-results__row--muted">
<span class="fb-results__row-label fb-results__row-label--wide">Andere (frühere Versionen)</span>
<div class="fb-results__bar-track">
<div class="fb-results__bar-fill fb-results__bar-fill--muted" style="width: {pct(stats.other_count, denom)}%"></div>
</div>
<span class="fb-results__row-count">{stats.other_count}</span>
</div>
{/if}
</div>
{/if}

View File

@@ -0,0 +1,93 @@
import { describe, test, expect } from 'bun:test';
import { SingleChoiceQuestion, SingleChoiceQuestionSchema } from './single_choice';
import { MultiChoiceQuestion, MultiChoiceQuestionSchema } from './multi_choice';
describe('SingleChoiceQuestion', () => {
const q = SingleChoiceQuestion.defaultStub();
test('schema requires ≥ 2 options', () => {
expect(SingleChoiceQuestionSchema.safeParse(q).success).toBe(true);
expect(
SingleChoiceQuestionSchema.safeParse({ ...q, options: ['only-one'] }).success,
).toBe(false);
});
test('isAnswerEmpty: blank or non-string → empty', () => {
expect(SingleChoiceQuestion.isAnswerEmpty(q, undefined)).toBe(true);
expect(SingleChoiceQuestion.isAnswerEmpty(q, '')).toBe(true);
expect(SingleChoiceQuestion.isAnswerEmpty(q, ' ')).toBe(true);
expect(SingleChoiceQuestion.isAnswerEmpty(q, 'Option A')).toBe(false);
});
test('ingest: matches options + tracks other_count', () => {
const stats = SingleChoiceQuestion.emptyStats(q);
SingleChoiceQuestion.ingest(stats, q, 'Option A', 'now');
SingleChoiceQuestion.ingest(stats, q, 'Option A', 'now');
SingleChoiceQuestion.ingest(stats, q, 'Option B', 'now');
SingleChoiceQuestion.ingest(stats, q, 'Renamed-since-snapshot', 'now');
SingleChoiceQuestion.finalise(stats);
expect(stats.count).toBe(4);
const a = stats.options.find((o) => o.option === 'Option A')!;
const b = stats.options.find((o) => o.option === 'Option B')!;
expect(a.count).toBe(2);
expect(b.count).toBe(1);
expect(stats.other_count).toBe(1);
});
test('csv: one column, cell = the chosen string', () => {
const [col] = SingleChoiceQuestion.csvColumns(q);
expect(col).toEqual({ header: 'q1', qid: 'q1' });
expect(SingleChoiceQuestion.csvCellFor(q, 'A', col)).toBe('A');
expect(SingleChoiceQuestion.csvCellFor(q, null, col)).toBe('');
});
test('adminCellSummary passes through string', () => {
expect(SingleChoiceQuestion.adminCellSummary(q, 'A')).toBe('A');
expect(SingleChoiceQuestion.adminCellSummary(q, null)).toBe('—');
});
});
describe('MultiChoiceQuestion', () => {
const q = MultiChoiceQuestion.defaultStub();
test('schema requires ≥ 2 options', () => {
expect(MultiChoiceQuestionSchema.safeParse(q).success).toBe(true);
expect(MultiChoiceQuestionSchema.safeParse({ ...q, options: ['only-one'] }).success).toBe(false);
});
test('isAnswerEmpty: missing array OR empty array → empty', () => {
expect(MultiChoiceQuestion.isAnswerEmpty(q, undefined)).toBe(true);
expect(MultiChoiceQuestion.isAnswerEmpty(q, null)).toBe(true);
expect(MultiChoiceQuestion.isAnswerEmpty(q, [])).toBe(true);
expect(MultiChoiceQuestion.isAnswerEmpty(q, 'Option A')).toBe(true);
expect(MultiChoiceQuestion.isAnswerEmpty(q, ['Option A'])).toBe(false);
});
test('ingest: counts each picked option, increments stats.count once per submission', () => {
const stats = MultiChoiceQuestion.emptyStats(q);
MultiChoiceQuestion.ingest(stats, q, ['Option A', 'Option B'], 'now');
MultiChoiceQuestion.ingest(stats, q, ['Option A'], 'now');
MultiChoiceQuestion.ingest(stats, q, ['ghost'], 'now');
MultiChoiceQuestion.ingest(stats, q, [], 'now'); // empty array — skipped
MultiChoiceQuestion.finalise(stats);
expect(stats.count).toBe(3);
const a = stats.options.find((o) => o.option === 'Option A')!;
const b = stats.options.find((o) => o.option === 'Option B')!;
expect(a.count).toBe(2);
expect(b.count).toBe(1);
expect(stats.other_count).toBe(1);
});
test('csv: pipe-joined values', () => {
const [col] = MultiChoiceQuestion.csvColumns(q);
expect(MultiChoiceQuestion.csvCellFor(q, ['x', 'y'], col)).toBe('x|y');
expect(MultiChoiceQuestion.csvCellFor(q, [], col)).toBe('');
expect(MultiChoiceQuestion.csvCellFor(q, null, col)).toBe('');
});
test('adminCellSummary: comma-joined or em-dash', () => {
expect(MultiChoiceQuestion.adminCellSummary(q, ['A', 'B'])).toBe('A, B');
expect(MultiChoiceQuestion.adminCellSummary(q, [])).toBe('—');
expect(MultiChoiceQuestion.adminCellSummary(q, null)).toBe('—');
});
});

View File

@@ -0,0 +1,180 @@
<script lang="ts">
import Icon from '$lib/components/Icon.svelte';
import type { BuilderEditorProps } from './types';
let { question, update }: BuilderEditorProps = $props();
function optUid(): string {
const buf = new Uint8Array(4);
(globalThis.crypto ?? (window as unknown as { crypto: Crypto }).crypto).getRandomValues(buf);
return 'opt_' + Array.from(buf, (b) => b.toString(36)).join('').slice(0, 6);
}
function defaultStartIso(offsetHours = 0): string {
const d = new Date();
d.setMinutes(0, 0, 0);
d.setHours(d.getHours() + 1 + offsetHours);
return d.toISOString();
}
function isoToLocalInput(iso: string | undefined | null): string {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d.getTime())) return '';
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function localInputToIso(local: string): string | null {
if (!local) return null;
const d = new Date(local);
if (isNaN(d.getTime())) return null;
return d.toISOString();
}
function setDateOption(optIdx: number, patch: { start?: string; end?: string | null; label?: string | null }): void {
if (question.type !== 'date_ranked_choice') return;
const options = question.options.map((opt, i) => {
if (i !== optIdx) return opt;
const next = { ...opt };
if (patch.start !== undefined) next.start = patch.start;
if (patch.end !== undefined) {
if (patch.end === null || patch.end === '') delete next.end;
else next.end = patch.end;
}
if (patch.label !== undefined) {
if (patch.label === null || patch.label === '') delete next.label;
else next.label = patch.label;
}
return next;
});
update({ options });
}
function addDateOption(): void {
if (question.type !== 'date_ranked_choice') return;
if (question.options.length >= 50) return;
update({
options: [
...question.options,
{ id: optUid(), start: defaultStartIso(24 * (question.options.length + 1)) },
],
});
}
function removeDateOption(optIdx: number): void {
if (question.type !== 'date_ranked_choice') return;
if (question.options.length <= 2) return;
update({ options: question.options.filter((_, i) => i !== optIdx) });
}
function setScaleLabel(which: 'min_label' | 'max_label', val: string): void {
if (question.type !== 'date_ranked_choice') return;
const scale = { ...(question.scale ?? {}) };
if (val === '') delete scale[which];
else scale[which] = val;
const empty = !scale.min_label && !scale.max_label;
update({ scale: empty ? undefined : scale });
}
function setAllowPartial(allow: boolean): void {
if (question.type !== 'date_ranked_choice') return;
update({ allow_partial: allow });
}
</script>
{#if question.type === 'date_ranked_choice'}
<div class="fb-question">
<label class="fb-question__label">Date / time options</label>
<div class="fb-builder__date-ranked">
{#each question.options as opt, optIdx (opt.id)}
<div class="fb-builder__date-row">
<div class="fb-builder__date-fields">
<label class="fb-question__label" for={`fb-builder-${question.id}-${opt.id}-start`}>Start</label>
<input
id={`fb-builder-${question.id}-${opt.id}-start`}
type="datetime-local"
class="fb-input"
value={isoToLocalInput(opt.start)}
oninput={(e) => {
const iso = localInputToIso((e.target as HTMLInputElement).value);
if (iso) setDateOption(optIdx, { start: iso });
}}
/>
<label class="fb-question__label" for={`fb-builder-${question.id}-${opt.id}-end`}>End (optional)</label>
<input
id={`fb-builder-${question.id}-${opt.id}-end`}
type="datetime-local"
class="fb-input"
value={isoToLocalInput(opt.end)}
oninput={(e) => {
const raw = (e.target as HTMLInputElement).value;
if (!raw) setDateOption(optIdx, { end: null });
else {
const iso = localInputToIso(raw);
if (iso) setDateOption(optIdx, { end: iso });
}
}}
/>
<label class="fb-question__label" for={`fb-builder-${question.id}-${opt.id}-label`}>Label (optional)</label>
<input
id={`fb-builder-${question.id}-${opt.id}-label`}
class="fb-input"
maxlength="200"
placeholder="e.g. Office, 09:00 sharp"
value={opt.label ?? ''}
oninput={(e) => setDateOption(optIdx, { label: (e.target as HTMLInputElement).value })}
/>
</div>
<button
type="button"
class="fb-builder__icon-btn fb-builder__icon-btn--danger"
disabled={question.options.length <= 2}
onclick={() => removeDateOption(optIdx)}
aria-label="Remove option"
><Icon name="x" size={14} /></button>
</div>
{/each}
</div>
<button
type="button"
class="fb-btn fb-btn--secondary fb-btn--sm fb-builder__add-option"
onclick={addDateOption}
disabled={question.options.length >= 50}
><Icon name="plus" /> Date option</button>
</div>
<div class="fb-builder__scale">
<div class="fb-question">
<label class="fb-question__label" for={`fb-builder-${question.id}-rating-1`}>Rating-1 label (optional)</label>
<input
id={`fb-builder-${question.id}-rating-1`}
class="fb-input"
maxlength="50"
placeholder="e.g. doesn't work"
value={question.scale?.min_label ?? ''}
oninput={(e) => setScaleLabel('min_label', (e.target as HTMLInputElement).value)}
/>
</div>
<div class="fb-question">
<label class="fb-question__label" for={`fb-builder-${question.id}-rating-5`}>Rating-5 label (optional)</label>
<input
id={`fb-builder-${question.id}-rating-5`}
class="fb-input"
maxlength="50"
placeholder="e.g. works great"
value={question.scale?.max_label ?? ''}
oninput={(e) => setScaleLabel('max_label', (e.target as HTMLInputElement).value)}
/>
</div>
</div>
<label class="fb-option-row" style="display:inline-flex;">
<input
type="checkbox"
checked={question.allow_partial !== false}
onchange={(e) => setAllowPartial((e.target as HTMLInputElement).checked)}
/>
<span>Allow participants to skip individual options</span>
</label>
{/if}

View File

@@ -0,0 +1,82 @@
<script lang="ts">
import type { ParticipantInputProps } from './types';
let { question, answer, setAnswer }: ParticipantInputProps = $props();
const dateOptionFmt = new Intl.DateTimeFormat([], {
weekday: 'short',
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
const dateOptionTimeFmt = new Intl.DateTimeFormat([], {
hour: '2-digit',
minute: '2-digit',
});
function fmtDateOption(start: string, end?: string): string {
try {
const startStr = dateOptionFmt.format(new Date(start));
if (!end) return startStr;
const sd = new Date(start);
const ed = new Date(end);
if (sd.toDateString() === ed.toDateString()) {
return `${startStr}${dateOptionTimeFmt.format(ed)}`;
}
return `${startStr} ${dateOptionFmt.format(ed)}`;
} catch {
return start;
}
}
function ratingFor(optId: string): number | null {
if (!answer || typeof answer !== 'object') return null;
const v = (answer as Record<string, unknown>)[optId];
return typeof v === 'number' ? v : null;
}
function setRating(optId: string, rating: number | null): void {
const cur = (answer && typeof answer === 'object' ? answer : {}) as Record<string, number | null>;
setAnswer({ ...cur, [optId]: rating });
}
</script>
{#if question.type === 'date_ranked_choice'}
<div class="fb-date-ranked">
{#if question.scale?.min_label || question.scale?.max_label}
<div class="fb-scale__labels" style="margin-bottom: 0.5rem;">
<span>1 — {question.scale.min_label ?? 'passt nicht'}</span>
<span>5 — {question.scale.max_label ?? 'passt super'}</span>
</div>
{/if}
{#each question.options as opt (opt.id)}
<div class="fb-date-ranked__row">
<div class="fb-date-ranked__opt">
<div class="fb-date-ranked__when">{fmtDateOption(opt.start, opt.end)}</div>
{#if opt.label}<div class="fb-date-ranked__label">{opt.label}</div>{/if}
</div>
<div class="fb-scale fb-date-ranked__scale" role="radiogroup" aria-label={opt.label ?? fmtDateOption(opt.start, opt.end)}>
{#each [1, 2, 3, 4, 5] as v (v)}
<button
type="button"
class="fb-scale__btn {ratingFor(opt.id) === v ? 'fb-scale__btn--active' : ''}"
aria-pressed={ratingFor(opt.id) === v}
onclick={() => setRating(opt.id, v)}
>
{v}
</button>
{/each}
<button
type="button"
class="fb-date-ranked__skip {ratingFor(opt.id) === null ? 'fb-date-ranked__skip--active' : ''}"
onclick={() => setRating(opt.id, null)}
aria-label="Skip option"
>
</button>
</div>
</div>
{/each}
</div>
{/if}

View File

@@ -0,0 +1,253 @@
<script lang="ts">
import type { ResultsBlockProps } from './types';
import type { DateRankedOptionStats } from '$lib/server/results';
let { question, stats }: ResultsBlockProps = $props();
let view = $state<'calendar' | 'bars'>('calendar');
function pct(part: number, whole: number): number {
if (whole === 0) return 0;
return Math.round((part / whole) * 100);
}
function fmtMean(m: number | null): string {
if (m === null) return '—';
return m.toFixed(2).replace(/\.?0+$/, '');
}
function mixHex(a: string, b: string, t: number): string {
const tt = Math.max(0, Math.min(1, t));
const ar = parseInt(a.slice(1, 3), 16);
const ag = parseInt(a.slice(3, 5), 16);
const ab = parseInt(a.slice(5, 7), 16);
const br = parseInt(b.slice(1, 3), 16);
const bg = parseInt(b.slice(3, 5), 16);
const bb = parseInt(b.slice(5, 7), 16);
return `rgb(${Math.round(ar + (br - ar) * tt)}, ${Math.round(ag + (bg - ag) * tt)}, ${Math.round(ab + (bb - ab) * tt)})`;
}
const COLOR_LOW = '#ef4444';
const COLOR_MID = '#f59e0b';
const COLOR_HIGH = '#16a34a';
function colorForRating(value: number): string {
if (value <= 1) return COLOR_LOW;
if (value >= 5) return COLOR_HIGH;
if (value < 3) return mixHex(COLOR_LOW, COLOR_MID, (value - 1) / 2);
return mixHex(COLOR_MID, COLOR_HIGH, (value - 3) / 2);
}
function colorForMean(mean: number | null): string {
if (mean === null) return 'var(--color-bg-secondary)';
return colorForRating(mean);
}
const dayFmt = new Intl.DateTimeFormat([], { day: '2-digit' });
const monthFmt = new Intl.DateTimeFormat([], { month: 'short' });
const weekdayFmt = new Intl.DateTimeFormat([], { weekday: 'short' });
const timeFmt = new Intl.DateTimeFormat([], { hour: '2-digit', minute: '2-digit' });
const fullDateFmt = new Intl.DateTimeFormat([], {
weekday: 'short',
day: '2-digit',
month: 'short',
year: 'numeric',
});
const dateOptionFmt = new Intl.DateTimeFormat([], {
weekday: 'short',
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
const dateOptionTimeFmt = new Intl.DateTimeFormat([], {
hour: '2-digit',
minute: '2-digit',
});
function localDateKey(iso: string): string {
const d = new Date(iso);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function fmtTimeRange(start: string, end: string | null): string {
const s = timeFmt.format(new Date(start));
if (!end) return s;
const e = timeFmt.format(new Date(end));
return `${s}${e}`;
}
function fmtDateOption(start: string, end: string | null | undefined): string {
try {
const startStr = dateOptionFmt.format(new Date(start));
if (!end) return startStr;
const sd = new Date(start);
const ed = new Date(end);
if (sd.toDateString() === ed.toDateString()) {
return `${startStr}${dateOptionTimeFmt.format(ed)}`;
}
return `${startStr} ${dateOptionFmt.format(ed)}`;
} catch {
return start;
}
}
interface CalendarCell {
key: string;
date: Date;
options: DateRankedOptionStats[];
}
function buildCalendar(options: DateRankedOptionStats[]): { cells: CalendarCell[]; collapsed: boolean } {
if (options.length === 0) return { cells: [], collapsed: false };
const byDay = new Map<string, CalendarCell>();
for (const opt of options) {
const key = localDateKey(opt.start);
let cell = byDay.get(key);
if (!cell) {
const d = new Date(opt.start);
d.setHours(0, 0, 0, 0);
cell = { key, date: d, options: [] };
byDay.set(key, cell);
}
cell.options.push(opt);
}
const occupied = Array.from(byDay.values()).sort((a, b) => a.date.getTime() - b.date.getTime());
if (occupied.length <= 1) return { cells: occupied, collapsed: false };
const first = occupied[0].date;
const last = occupied[occupied.length - 1].date;
const dayMs = 24 * 60 * 60 * 1000;
const span = Math.round((last.getTime() - first.getTime()) / dayMs) + 1;
// > 30 days → suppress empty days; otherwise contiguous strip with empties.
if (span > 30) return { cells: occupied, collapsed: true };
const cells: CalendarCell[] = [];
for (let i = 0; i < span; i++) {
const d = new Date(first.getTime() + i * dayMs);
const k = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
cells.push(byDay.get(k) ?? { key: k, date: d, options: [] });
}
return { cells, collapsed: false };
}
function cellTitle(cell: CalendarCell): string {
const date = fullDateFmt.format(cell.date);
if (cell.options.length === 0) return date;
const lines = cell.options.map((opt) => {
const time = fmtTimeRange(opt.start, opt.end);
const label = opt.label ? ` · ${opt.label}` : '';
const mean = opt.mean === null ? '—' : opt.mean.toFixed(2).replace(/\.?0+$/, '');
return `${time}${label}${mean} avg (${opt.count})`;
});
return `${date}\n${lines.join('\n')}`;
}
</script>
{#if stats.type === 'date_ranked_choice'}
{@const calendar = buildCalendar(stats.options)}
{@const showCalendar = stats.options.length > 1}
{@const effectiveView = showCalendar ? view : 'bars'}
<div class="fb-results__meta">
{stats.count} {stats.count === 1 ? 'Antwort' : 'Antworten'}
</div>
{#if stats.count === 0}
<p class="fb-results__meta">Noch keine Bewertungen.</p>
{:else}
{#if showCalendar}
<div class="fb-tabs fb-results__drc-tabs" role="tablist" aria-label="Ergebnis-Ansicht">
<button
type="button"
class="fb-tab"
class:fb-tab--active={effectiveView === 'calendar'}
role="tab"
aria-selected={effectiveView === 'calendar'}
onclick={() => (view = 'calendar')}
>Kalender</button>
<button
type="button"
class="fb-tab"
class:fb-tab--active={effectiveView === 'bars'}
role="tab"
aria-selected={effectiveView === 'bars'}
onclick={() => (view = 'bars')}
>Balken</button>
</div>
{/if}
{#if effectiveView === 'calendar'}
<div class="fb-results__cal" class:fb-results__cal--collapsed={calendar.collapsed}>
{#each calendar.cells as cell (cell.key)}
<div
class="fb-results__cal-day"
class:fb-results__cal-day--empty={cell.options.length === 0}
title={cellTitle(cell)}
>
<div class="fb-results__cal-head">
<span class="fb-results__cal-weekday">{weekdayFmt.format(cell.date)}</span>
<span class="fb-results__cal-num">{dayFmt.format(cell.date)}</span>
<span class="fb-results__cal-month">{monthFmt.format(cell.date)}</span>
</div>
{#if cell.options.length > 0}
<div class="fb-results__cal-slots">
{#each cell.options as opt (opt.id)}
<div
class="fb-results__cal-slot"
style="background: {colorForMean(opt.mean)};"
>
<span class="fb-results__cal-slot-time">{fmtTimeRange(opt.start, opt.end)}</span>
<span class="fb-results__cal-slot-mean">{fmtMean(opt.mean)}</span>
<span class="fb-results__cal-slot-count">{opt.count}</span>
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{:else}
<div class="fb-results__drc-bars">
{#each stats.options as opt, optIdx (opt.id)}
{@const total = opt.count}
<div class="fb-results__drc-row">
<div class="fb-results__drc-rank">#{optIdx + 1}</div>
<div class="fb-results__drc-when">
<div>{fmtDateOption(opt.start, opt.end)}</div>
{#if opt.label}<div class="fb-results__date-label">{opt.label}</div>{/if}
</div>
<div class="fb-results__drc-avg" style="color: {colorForMean(opt.mean)};">
{fmtMean(opt.mean)}
</div>
<div class="fb-results__drc-bar" aria-label="Verteilung">
{#if total === 0}
<div class="fb-results__drc-bar-empty">Keine Bewertung</div>
{:else}
{#each opt.histogram as bucket (bucket.value)}
{#if bucket.count > 0}
<div
class="fb-results__drc-bar-seg"
style="width: {pct(bucket.count, total)}%; background: {colorForRating(bucket.value)};"
title="{bucket.count}× {bucket.value}"
>
<span>{bucket.value}·{bucket.count}</span>
</div>
{/if}
{/each}
{/if}
</div>
<div class="fb-results__drc-count">
{opt.count}
</div>
</div>
{/each}
</div>
{/if}
{/if}
<!-- question is part of the prop contract; date_ranked_choice's render
happens to read everything from `stats`, so we just acknowledge it. -->
{#if false}{question}{/if}
{/if}

View File

@@ -0,0 +1,200 @@
import { describe, test, expect } from 'bun:test';
import {
DateRankedChoiceQuestion,
DateRankedChoiceQuestionSchema,
} from './date_ranked_choice';
const baseQ = {
id: 'when',
label: 'Pick a slot',
required: true,
type: 'date_ranked_choice' as const,
options: [
{ id: 'a', start: '2026-05-20T09:00:00Z' },
{ id: 'b', start: '2026-05-21T09:00:00Z' },
{ id: 'c', start: '2026-05-22T09:00:00Z' },
],
allow_partial: true,
};
describe('DateRankedChoiceQuestion.schema', () => {
test('accepts a valid question', () => {
expect(DateRankedChoiceQuestionSchema.safeParse(baseQ).success).toBe(true);
});
test('rejects duplicate option ids', () => {
const r = DateRankedChoiceQuestionSchema.safeParse({
...baseQ,
options: [
{ id: 'a', start: '2026-05-20T09:00:00Z' },
{ id: 'a', start: '2026-05-21T09:00:00Z' },
],
});
expect(r.success).toBe(false);
});
test('rejects fewer than 2 options', () => {
const r = DateRankedChoiceQuestionSchema.safeParse({
...baseQ,
options: [{ id: 'a', start: '2026-05-20T09:00:00Z' }],
});
expect(r.success).toBe(false);
});
test('rejects malformed start ISO', () => {
const r = DateRankedChoiceQuestionSchema.safeParse({
...baseQ,
options: [
{ id: 'a', start: 'tomorrow morning' },
{ id: 'b', start: '2026-05-21T09:00:00Z' },
],
});
expect(r.success).toBe(false);
});
test('rejects option ids with disallowed characters', () => {
const r = DateRankedChoiceQuestionSchema.safeParse({
...baseQ,
options: [
{ id: 'has space', start: '2026-05-20T09:00:00Z' },
{ id: 'b', start: '2026-05-21T09:00:00Z' },
],
});
expect(r.success).toBe(false);
});
});
describe('DateRankedChoiceQuestion.isAnswerEmpty (closes the validation gap)', () => {
test('undefined → empty', () => {
expect(DateRankedChoiceQuestion.isAnswerEmpty(baseQ, undefined)).toBe(true);
});
test('null → empty', () => {
expect(DateRankedChoiceQuestion.isAnswerEmpty(baseQ, null)).toBe(true);
});
test('empty object → empty (this is the gap closed by construction)', () => {
// Legacy server-side gate matched only string/array empties. An empty
// object passed through and submitted as a "rated zero options" answer,
// even when the question was required. Now caught.
expect(DateRankedChoiceQuestion.isAnswerEmpty(baseQ, {})).toBe(true);
});
test('object with all-null ratings → empty', () => {
expect(
DateRankedChoiceQuestion.isAnswerEmpty(baseQ, { a: null, b: null, c: null }),
).toBe(true);
});
test('object with at least one valid rating → not empty', () => {
expect(DateRankedChoiceQuestion.isAnswerEmpty(baseQ, { a: 5 })).toBe(false);
expect(DateRankedChoiceQuestion.isAnswerEmpty(baseQ, { a: 1, b: null })).toBe(false);
});
test('rejects out-of-range or non-integer ratings', () => {
expect(DateRankedChoiceQuestion.isAnswerEmpty(baseQ, { a: 0 })).toBe(true);
expect(DateRankedChoiceQuestion.isAnswerEmpty(baseQ, { a: 6 })).toBe(true);
expect(DateRankedChoiceQuestion.isAnswerEmpty(baseQ, { a: 3.5 })).toBe(true);
expect(DateRankedChoiceQuestion.isAnswerEmpty(baseQ, { a: '4' })).toBe(true);
});
test('array → empty (DRC answers are objects, not arrays)', () => {
expect(DateRankedChoiceQuestion.isAnswerEmpty(baseQ, [5, 4, 3])).toBe(true);
});
});
describe('DateRankedChoiceQuestion.ingest + finalise', () => {
test('counts ratings per option, computes mean, sorts by mean desc', () => {
const stats = DateRankedChoiceQuestion.emptyStats(baseQ);
DateRankedChoiceQuestion.ingest(stats, baseQ, { a: 5, b: 3, c: null }, 'now');
DateRankedChoiceQuestion.ingest(stats, baseQ, { a: 4, b: 2, c: 1 }, 'now');
DateRankedChoiceQuestion.ingest(stats, baseQ, { a: 5 }, 'now');
DateRankedChoiceQuestion.finalise(stats);
expect(stats.count).toBe(3);
// After sort by mean desc: a (mean 4.667) > b (2.5) > c (1.0)
expect(stats.options[0].id).toBe('a');
expect(stats.options[1].id).toBe('b');
expect(stats.options[2].id).toBe('c');
});
test('finalise drops _sum on every option', () => {
const stats = DateRankedChoiceQuestion.emptyStats(baseQ);
DateRankedChoiceQuestion.ingest(stats, baseQ, { a: 5, b: 3 }, 'now');
DateRankedChoiceQuestion.finalise(stats);
for (const opt of stats.options) {
expect((opt as { _sum?: unknown })._sum).toBeUndefined();
}
});
test('option with no ratings has mean=null and count=0', () => {
const stats = DateRankedChoiceQuestion.emptyStats(baseQ);
DateRankedChoiceQuestion.ingest(stats, baseQ, { a: 5 }, 'now');
DateRankedChoiceQuestion.finalise(stats);
const c = stats.options.find((o) => o.id === 'c')!;
expect(c.count).toBe(0);
expect(c.mean).toBeNull();
});
test('ignores out-of-range and non-numeric ratings', () => {
const stats = DateRankedChoiceQuestion.emptyStats(baseQ);
DateRankedChoiceQuestion.ingest(stats, baseQ, { a: 5, b: 'oops', c: 99 }, 'now');
DateRankedChoiceQuestion.ingest(stats, baseQ, { a: 0, b: 6, c: 3.5 }, 'now');
DateRankedChoiceQuestion.finalise(stats);
const a = stats.options.find((o) => o.id === 'a')!;
const b = stats.options.find((o) => o.id === 'b')!;
const c = stats.options.find((o) => o.id === 'c')!;
expect(a.count).toBe(1);
expect(a.mean).toBe(5);
expect(b.count).toBe(0);
expect(c.count).toBe(0);
});
test('handles missing answers without crashing', () => {
const stats = DateRankedChoiceQuestion.emptyStats(baseQ);
DateRankedChoiceQuestion.ingest(stats, baseQ, undefined, 'now');
DateRankedChoiceQuestion.ingest(stats, baseQ, null, 'now');
DateRankedChoiceQuestion.ingest(stats, baseQ, [], 'now');
DateRankedChoiceQuestion.ingest(stats, baseQ, 'oops', 'now');
DateRankedChoiceQuestion.finalise(stats);
expect(stats.count).toBe(0);
});
});
describe('DateRankedChoiceQuestion.csv', () => {
test('one column per option, header includes the option id', () => {
const cols = DateRankedChoiceQuestion.csvColumns(baseQ);
expect(cols).toHaveLength(3);
expect(cols[0]).toEqual({ header: 'when[a]', qid: 'when', optId: 'a' });
expect(cols[1]).toEqual({ header: 'when[b]', qid: 'when', optId: 'b' });
expect(cols[2]).toEqual({ header: 'when[c]', qid: 'when', optId: 'c' });
});
test('cell pulls the rating for the column option', () => {
const cols = DateRankedChoiceQuestion.csvColumns(baseQ);
const answer = { a: 5, b: 3, c: null };
expect(DateRankedChoiceQuestion.csvCellFor(baseQ, answer, cols[0])).toBe('5');
expect(DateRankedChoiceQuestion.csvCellFor(baseQ, answer, cols[1])).toBe('3');
expect(DateRankedChoiceQuestion.csvCellFor(baseQ, answer, cols[2])).toBe('');
});
test('cell empty when answer is missing or wrong shape', () => {
const cols = DateRankedChoiceQuestion.csvColumns(baseQ);
expect(DateRankedChoiceQuestion.csvCellFor(baseQ, null, cols[0])).toBe('');
expect(DateRankedChoiceQuestion.csvCellFor(baseQ, [5, 4, 3], cols[0])).toBe('');
});
});
describe('DateRankedChoiceQuestion.adminCellSummary', () => {
test('formats average + count', () => {
expect(DateRankedChoiceQuestion.adminCellSummary(baseQ, { a: 5, b: 4, c: 3 })).toBe(
'4 avg (3 rated)',
);
expect(DateRankedChoiceQuestion.adminCellSummary(baseQ, { a: 5 })).toBe('5 avg (1 rated)');
});
test('em-dash when no ratings', () => {
expect(DateRankedChoiceQuestion.adminCellSummary(baseQ, {})).toBe('—');
expect(DateRankedChoiceQuestion.adminCellSummary(baseQ, null)).toBe('—');
});
});

View File

@@ -0,0 +1,199 @@
/**
* `date_ranked_choice` question type — author lists date/time slots,
* participants rate each on a 1..5 Likert (or skip).
*
* Closes the server-side validation gap that the audit doc flagged: the
* legacy submit endpoint's `(typeof v === 'string' && v.trim() === '')`
* check doesn't catch "answer object exists but has zero rated options",
* so the gate was only enforced client-side. `isAnswerEmpty` here is THE
* source of truth for "is this answer missing?", and once submit/+server.ts
* is wired to call `getQuestion(q.type).isAnswerEmpty(q, answer)` (commit 11),
* the gap closes by construction. The `allow_partial: false` invariant —
* "every option must be rated, not just at least one" — moves into a small
* dedicated check on the participant side; isAnswerEmpty stays focused on
* the basic "did they touch this question" rule.
*/
import { z } from 'zod';
import type { QuestionTypeModule, CsvColumn, StatsForType } from './types';
import { FeedbackQuestionBaseSchema } from './_base';
import DateRankedInput from './date_ranked_choice.input.svelte';
import DateRankedBuilder from './date_ranked_choice.builder.svelte';
import DateRankedResults from './date_ranked_choice.results.svelte';
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 DateRankedChoiceQuestionSchema = 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',
}),
// 5-point Likert is locked 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(),
});
type Q = z.infer<typeof DateRankedChoiceQuestionSchema>;
// Per-option accumulator — _sum is read by finalise to compute mean and
// dropped before the public stats shape is observed.
type OptStatsWip = StatsForType<'date_ranked_choice'>['options'][number] & { _sum: number };
/** True if the answer object has at least one numeric rating (1..5). */
function hasAnyRating(answer: unknown): boolean {
if (!answer || typeof answer !== 'object' || Array.isArray(answer)) return false;
for (const v of Object.values(answer as Record<string, unknown>)) {
if (typeof v === 'number' && Number.isInteger(v) && v >= 1 && v <= 5) return true;
}
return false;
}
export const DateRankedChoiceQuestion: QuestionTypeModule<'date_ranked_choice'> = {
type: 'date_ranked_choice',
label: 'Date ranked choice',
schema: DateRankedChoiceQuestionSchema,
defaultStub() {
const now = new Date();
now.setMinutes(0, 0, 0);
const startA = new Date(now.getTime() + 60 * 60 * 1000).toISOString();
const startB = new Date(now.getTime() + 25 * 60 * 60 * 1000).toISOString();
return {
id: 'q1',
label: 'New question',
required: false,
type: 'date_ranked_choice',
options: [
{ id: 'opt_a', start: startA },
{ id: 'opt_b', start: startB },
],
allow_partial: true,
};
},
/**
* THE source of truth for "did the participant touch this question?".
* Used by both client- and server-side validators after the wiring step
* in commit 11. Closes the legacy gap where the server only matched on
* `(typeof v === 'string' && v.trim() === '')`.
*
* Per-option "must rate everything" enforcement (`allow_partial: false`)
* is a separate rule that lives in submit-time validation; this method
* only answers the basic question.
*/
isAnswerEmpty(_q: Q, answer: unknown): boolean {
return !hasAnyRating(answer);
},
emptyStats(question) {
return {
type: 'date_ranked_choice',
count: 0,
options: question.options.map((opt) => {
const wip: OptStatsWip = {
id: opt.id,
start: opt.start,
end: opt.end ?? null,
label: opt.label ?? null,
count: 0,
mean: null,
histogram: [1, 2, 3, 4, 5].map((value) => ({ value, count: 0 })),
_sum: 0,
};
return wip;
}),
};
},
ingest(stats, _q, answer) {
if (!answer || typeof answer !== 'object' || Array.isArray(answer)) return;
const ratings = answer as Record<string, unknown>;
let touched = false;
for (const opt of stats.options) {
const raw = ratings[opt.id];
if (raw === undefined || raw === null) continue;
if (typeof raw !== 'number' || !Number.isInteger(raw) || raw < 1 || raw > 5) continue;
const acc = opt as OptStatsWip;
acc.count++;
const bucket = acc.histogram.find((b) => b.value === raw);
if (bucket) bucket.count++;
acc._sum += raw;
touched = true;
}
if (touched) stats.count++;
},
finalise(stats) {
for (const opt of stats.options) {
const acc = opt as OptStatsWip;
opt.mean = opt.count > 0 ? acc._sum / opt.count : null;
delete (opt as Partial<OptStatsWip>)._sum;
}
// Sort options by mean desc with tiebreaks (5-count, 4-count, total count, id).
stats.options.sort((a, b) => {
const am = a.mean ?? -Infinity;
const bm = b.mean ?? -Infinity;
if (am !== bm) return bm - am;
const a5 = a.histogram.find((h) => h.value === 5)?.count ?? 0;
const b5 = b.histogram.find((h) => h.value === 5)?.count ?? 0;
if (a5 !== b5) return b5 - a5;
const a4 = a.histogram.find((h) => h.value === 4)?.count ?? 0;
const b4 = b.histogram.find((h) => h.value === 4)?.count ?? 0;
if (a4 !== b4) return b4 - a4;
if (a.count !== b.count) return b.count - a.count;
return a.id.localeCompare(b.id);
});
},
sanitizeForPublic(stats) {
return stats;
},
csvColumns(q: Q): CsvColumn[] {
// One column per option. Header format: <qid>[<optId>], matches what the
// legacy export endpoint was producing.
return q.options.map((opt) => ({
header: `${q.id}[${opt.id}]`,
qid: q.id,
optId: opt.id,
}));
},
csvCellFor(_q, answer, col) {
if (!answer || typeof answer !== 'object' || Array.isArray(answer)) return '';
if (!col.optId) return '';
const r = (answer as Record<string, unknown>)[col.optId];
if (r === null || r === undefined) return '';
return typeof r === 'number' ? String(r) : '';
},
adminCellSummary(_q, answer) {
if (!answer || typeof answer !== 'object' || Array.isArray(answer)) return '—';
const ratings = Object.values(answer as Record<string, unknown>).filter(
(x): x is number => typeof x === 'number' && Number.isFinite(x),
);
if (ratings.length === 0) return '—';
const avg = ratings.reduce((a, b) => a + b, 0) / ratings.length;
const fmt = avg.toFixed(1).replace(/\.0$/, '');
return `${fmt} avg (${ratings.length} rated)`;
},
ParticipantInput: DateRankedInput,
BuilderEditor: DateRankedBuilder,
ResultsBlock: DateRankedResults,
};

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import type { ParticipantInputProps } from './types';
let { question, answer, setAnswer }: ParticipantInputProps = $props();
</script>
{#if question.type === 'long_text'}
<textarea
id={`q-${question.id}`}
class="fb-textarea"
placeholder={question.placeholder ?? ''}
maxlength="5000"
rows="4"
value={(answer as string) ?? ''}
oninput={(e) => setAnswer((e.target as HTMLTextAreaElement).value)}
></textarea>
{/if}

View File

@@ -0,0 +1,65 @@
/**
* `long_text` question type — multi-line textarea on participant side,
* same answer-list / count-only result rendering as short_text.
*/
import { z } from 'zod';
import type { QuestionTypeModule, CsvColumn } from './types';
import { FeedbackQuestionBaseSchema } from './_base';
import LongTextInput from './long_text.input.svelte';
import TextBuilder from './short_text.builder.svelte';
import TextResults from './text.results.svelte';
export const LongTextQuestionSchema = FeedbackQuestionBaseSchema.extend({
type: z.literal('long_text'),
placeholder: z.string().max(100).optional(),
});
export const LongTextQuestion: QuestionTypeModule<'long_text'> = {
type: 'long_text',
label: 'Long text',
schema: LongTextQuestionSchema,
defaultStub() {
return { id: 'q1', label: 'New question', required: false, type: 'long_text' };
},
isAnswerEmpty(_q, answer) {
if (typeof answer !== 'string') return true;
return answer.trim() === '';
},
emptyStats() {
return { type: 'long_text', count: 0, answers: [] };
},
ingest(stats, _q, answer, createdAt) {
if (typeof answer !== 'string' || answer.trim() === '') return;
stats.count++;
stats.answers.push({ value: answer, created_at: createdAt });
},
finalise() {
// nothing to finalise — answers are appended in ingest order.
},
sanitizeForPublic(stats) {
return { type: 'long_text', count: stats.count, answers: [] };
},
csvColumns(q): CsvColumn[] {
return [{ header: q.id, qid: q.id }];
},
csvCellFor(_q, answer) {
return typeof answer === 'string' ? answer : '';
},
adminCellSummary(_q, answer) {
if (answer === undefined || answer === null) return '—';
return typeof answer === 'string' ? answer : String(answer);
},
ParticipantInput: LongTextInput,
BuilderEditor: TextBuilder,
ResultsBlock: TextResults,
};

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import type { ParticipantInputProps } from './types';
let { question, answer, setAnswer }: ParticipantInputProps = $props();
function toggle(opt: string): void {
const cur = (Array.isArray(answer) ? answer : []) as string[];
const next = cur.includes(opt) ? cur.filter((x) => x !== opt) : [...cur, opt];
setAnswer(next);
}
function isChecked(opt: string): boolean {
return Array.isArray(answer) && (answer as string[]).includes(opt);
}
</script>
{#if question.type === 'multi_choice'}
<div class="fb-options">
{#each question.options as opt (opt)}
<label class="fb-option-row">
<input
type="checkbox"
checked={isChecked(opt)}
onchange={() => toggle(opt)}
/>
<span>{opt}</span>
</label>
{/each}
</div>
{/if}

View File

@@ -0,0 +1,81 @@
/**
* `multi_choice` question type — checkbox list. Zero or more choices per
* submission, returned as a string[].
*/
import { z } from 'zod';
import type { QuestionTypeModule, CsvColumn } from './types';
import { FeedbackQuestionBaseSchema } from './_base';
import MultiChoiceInput from './multi_choice.input.svelte';
import ChoiceBuilder from './choice.builder.svelte';
import ChoiceResults from './choice.results.svelte';
export const MultiChoiceQuestionSchema = FeedbackQuestionBaseSchema.extend({
type: z.literal('multi_choice'),
options: z.array(z.string().min(1).max(200)).min(2).max(20),
});
export const MultiChoiceQuestion: QuestionTypeModule<'multi_choice'> = {
type: 'multi_choice',
label: 'Multiple choice',
schema: MultiChoiceQuestionSchema,
defaultStub() {
return {
id: 'q1',
label: 'New question',
required: false,
type: 'multi_choice',
options: ['Option A', 'Option B'],
};
},
isAnswerEmpty(_q, answer) {
return !Array.isArray(answer) || answer.length === 0;
},
emptyStats(question) {
return {
type: 'multi_choice',
count: 0,
options: question.options.map((option) => ({ option, count: 0 })),
other_count: 0,
};
},
ingest(stats, _q, answer) {
if (!Array.isArray(answer) || answer.length === 0) return;
stats.count++;
for (const choice of answer) {
if (typeof choice !== 'string') continue;
const hit = stats.options.find((o) => o.option === choice);
if (hit) hit.count++;
else stats.other_count++;
}
},
finalise() {
// counts are final.
},
sanitizeForPublic(stats) {
return stats;
},
csvColumns(q): CsvColumn[] {
return [{ header: q.id, qid: q.id }];
},
csvCellFor(_q, answer) {
if (Array.isArray(answer)) return answer.join('|');
return '';
},
adminCellSummary(_q, answer) {
if (Array.isArray(answer)) return answer.length === 0 ? '—' : answer.join(', ');
return '—';
},
ParticipantInput: MultiChoiceInput,
BuilderEditor: ChoiceBuilder,
ResultsBlock: ChoiceResults,
};

View File

@@ -0,0 +1,30 @@
import { describe, test, expect } from 'bun:test';
import { QUESTION_MODULES, getQuestion, hasQuestion, listQuestionTypes } from './registry';
// Once per-type modules land, this file's expectations grow to:
// - all 7 types present, in the documented picker order
// - schemas registry assembles a working discriminatedUnion
// - cross-type smoke (round-trip a tiny form through the registry)
//
// For now, the registry is empty by design (legacy paths still own dispatch).
// These cases lock the contract that getQuestion throws on unknown types.
describe('registry shape', () => {
test('QUESTION_MODULES is a readonly array', () => {
expect(Array.isArray(QUESTION_MODULES)).toBe(true);
});
test('hasQuestion returns false for unknown types', () => {
expect(hasQuestion('definitely_not_a_real_type')).toBe(false);
});
test('getQuestion throws a helpful error for missing modules', () => {
// Synthetic type literal that no module will ever register. Stays
// stable as the seven real types are added in subsequent commits.
expect(() => getQuestion('not_a_real_type' as never)).toThrow(/lib\/questions\/<type>\.ts/);
});
test('listQuestionTypes returns the array of registered type literals', () => {
expect(listQuestionTypes()).toEqual(QUESTION_MODULES.map((m) => m.type));
});
});

View File

@@ -0,0 +1,58 @@
/**
* Central question-type registry.
*
* One entry per type. The schemas index, the FormBuilder's "+ Add" picker,
* the participant input dispatcher, the results aggregator, and the CSV
* export all read from here. Adding a new question type = create
* `lib/questions/<type>.ts` + add the import + push into QUESTION_MODULES.
*
* Order in the array matters for the FormBuilder picker — that's the order
* "+ Add" buttons render.
*/
import type { FeedbackQuestion } from '$lib/schemas';
import type { AnyQuestionTypeModule } from './types';
import { BooleanQuestion } from './boolean';
import { ShortTextQuestion } from './short_text';
import { LongTextQuestion } from './long_text';
import { ScaleQuestion } from './scale';
import { SingleChoiceQuestion } from './single_choice';
import { MultiChoiceQuestion } from './multi_choice';
import { DateRankedChoiceQuestion } from './date_ranked_choice';
// Order matters — drives the FormBuilder "+ Add" picker layout.
// The wiring step at the end of Phase 2 flips legacy `q.type === '...'`
// strips in FormBuilder / participant / Results.svelte / results.ts /
// submit / export over to `getQuestion(q.type).method(...)`.
export const QUESTION_MODULES: readonly AnyQuestionTypeModule[] = [
ShortTextQuestion as AnyQuestionTypeModule,
LongTextQuestion as AnyQuestionTypeModule,
SingleChoiceQuestion as AnyQuestionTypeModule,
MultiChoiceQuestion as AnyQuestionTypeModule,
ScaleQuestion as AnyQuestionTypeModule,
BooleanQuestion as AnyQuestionTypeModule,
DateRankedChoiceQuestion as AnyQuestionTypeModule,
];
/** Look up the module for a question type. Throws on unknown — every type
* in `FeedbackQuestion['type']` must have a module registered. */
export function getQuestion<T extends FeedbackQuestion['type']>(type: T): AnyQuestionTypeModule {
const mod = QUESTION_MODULES.find((m) => m.type === type);
if (!mod) {
throw new Error(
`Unknown question type: ${type}. Add a module under lib/questions/<type>.ts and register it in lib/questions/registry.ts.`,
);
}
return mod;
}
/** Test if a question type has a registered module. Used by the wiring
* step's runtime sanity check. */
export function hasQuestion(type: string): type is FeedbackQuestion['type'] {
return QUESTION_MODULES.some((m) => m.type === type);
}
/** Ordered list of registered type literals — drives the FormBuilder's
* "+ Add" picker order. */
export function listQuestionTypes(): FeedbackQuestion['type'][] {
return QUESTION_MODULES.map((m) => m.type);
}

View File

@@ -0,0 +1,60 @@
<script lang="ts">
import type { BuilderEditorProps } from './types';
let { question, update }: BuilderEditorProps = $props();
</script>
{#if question.type === 'scale'}
<div class="fb-builder__scale">
<div class="fb-question">
<label class="fb-question__label" for={`fb-builder-${question.id}-min`}>Min</label>
<input
id={`fb-builder-${question.id}-min`}
type="number"
class="fb-input"
min="0"
max="100"
value={question.min}
oninput={(e) => update({ min: Number((e.target as HTMLInputElement).value) })}
/>
</div>
<div class="fb-question">
<label class="fb-question__label" for={`fb-builder-${question.id}-max`}>Max</label>
<input
id={`fb-builder-${question.id}-max`}
type="number"
class="fb-input"
min="1"
max="100"
value={question.max}
oninput={(e) => update({ max: Number((e.target as HTMLInputElement).value) })}
/>
</div>
<div class="fb-question">
<label class="fb-question__label" for={`fb-builder-${question.id}-minlbl`}>Min label (optional)</label>
<input
id={`fb-builder-${question.id}-minlbl`}
class="fb-input"
maxlength="50"
value={question.min_label ?? ''}
oninput={(e) => {
const v = (e.target as HTMLInputElement).value;
update({ min_label: v || undefined });
}}
/>
</div>
<div class="fb-question">
<label class="fb-question__label" for={`fb-builder-${question.id}-maxlbl`}>Max label (optional)</label>
<input
id={`fb-builder-${question.id}-maxlbl`}
class="fb-input"
maxlength="50"
value={question.max_label ?? ''}
oninput={(e) => {
const v = (e.target as HTMLInputElement).value;
update({ max_label: v || undefined });
}}
/>
</div>
</div>
{/if}

View File

@@ -0,0 +1,25 @@
<script lang="ts">
import type { ParticipantInputProps } from './types';
let { question, answer, setAnswer }: ParticipantInputProps = $props();
</script>
{#if question.type === 'scale'}
<div class="fb-scale">
{#each Array.from({ length: question.max - question.min + 1 }, (_, i) => i + question.min) as v (v)}
<button
type="button"
class="fb-scale__btn {answer === v ? 'fb-scale__btn--active' : ''}"
onclick={() => setAnswer(v)}
>
{v}
</button>
{/each}
</div>
{#if question.min_label || question.max_label}
<div class="fb-scale__labels">
<span>{question.min_label ?? question.min}</span>
<span>{question.max_label ?? question.max}</span>
</div>
{/if}
{/if}

View File

@@ -0,0 +1,38 @@
<script lang="ts">
import type { ResultsBlockProps } from './types';
let { stats }: ResultsBlockProps = $props();
function pct(part: number, whole: number): number {
if (whole === 0) return 0;
return Math.round((part / whole) * 100);
}
function maxOf(values: number[]): number {
if (values.length === 0) return 0;
return values.reduce((a, b) => (a > b ? a : b), 0);
}
function fmtMean(m: number | null): string {
if (m === null) return '—';
return m.toFixed(2).replace(/\.?0+$/, '');
}
</script>
{#if stats.type === 'scale'}
{@const denom = maxOf(stats.histogram.map((b) => b.count))}
<div class="fb-results__meta">
Schnitt: <strong>{fmtMean(stats.mean)}</strong> · {stats.count} Antworten
</div>
<div class="fb-results__bars">
{#each stats.histogram as bucket (bucket.value)}
<div class="fb-results__row">
<span class="fb-results__row-label">{bucket.value}</span>
<div class="fb-results__bar-track">
<div class="fb-results__bar-fill" style="width: {pct(bucket.count, denom)}%"></div>
</div>
<span class="fb-results__row-count">{bucket.count}</span>
</div>
{/each}
</div>
{/if}

View File

@@ -0,0 +1,100 @@
import { describe, test, expect } from 'bun:test';
import { ScaleQuestion, ScaleQuestionSchema } from './scale';
describe('ScaleQuestion.schema', () => {
test('accepts a valid 1-5 scale', () => {
expect(
ScaleQuestionSchema.safeParse({
id: 'rate',
label: 'How was it?',
required: true,
type: 'scale',
min: 1,
max: 5,
min_label: 'bad',
max_label: 'great',
}).success,
).toBe(true);
});
test('rejects when min ≥ max bounds (out of zod range — boundary check)', () => {
// schemas only enforce ranges, not min<max — that's the form-level
// invariant and lives in the FormBuilder UX. Schema accepts min=5/max=5
// (a degenerate scale) which is fine, and rejects max=0.
expect(ScaleQuestionSchema.safeParse({ id: 'q', label: 'x', type: 'scale', min: 1, max: 0 }).success).toBe(false);
});
test('rejects when type is wrong', () => {
expect(ScaleQuestionSchema.safeParse({ id: 'q', label: 'x', type: 'boolean' }).success).toBe(false);
});
});
describe('ScaleQuestion.isAnswerEmpty', () => {
const q = ScaleQuestion.defaultStub();
test('non-numeric → empty', () => {
expect(ScaleQuestion.isAnswerEmpty(q, undefined)).toBe(true);
expect(ScaleQuestion.isAnswerEmpty(q, null)).toBe(true);
expect(ScaleQuestion.isAnswerEmpty(q, '5')).toBe(true);
expect(ScaleQuestion.isAnswerEmpty(q, NaN)).toBe(true);
});
test('finite number → not empty (range-checking is not isAnswerEmpty\'s job)', () => {
expect(ScaleQuestion.isAnswerEmpty(q, 1)).toBe(false);
expect(ScaleQuestion.isAnswerEmpty(q, 0)).toBe(false);
expect(ScaleQuestion.isAnswerEmpty(q, 100)).toBe(false);
});
});
describe('ScaleQuestion.ingest + finalise', () => {
const q = ScaleQuestion.defaultStub();
test('histograms counts, computes mean, ignores garbage', () => {
const stats = ScaleQuestion.emptyStats(q);
ScaleQuestion.ingest(stats, q, 1, 'now');
ScaleQuestion.ingest(stats, q, 3, 'now');
ScaleQuestion.ingest(stats, q, 5, 'now');
ScaleQuestion.ingest(stats, q, 5, 'now');
ScaleQuestion.ingest(stats, q, 'bad', 'now');
ScaleQuestion.ingest(stats, q, NaN, 'now');
ScaleQuestion.finalise(stats);
expect(stats.count).toBe(4);
expect(stats.mean).toBe(3.5);
const hist = Object.fromEntries(stats.histogram.map((b) => [b.value, b.count]));
expect(hist).toEqual({ 1: 1, 2: 0, 3: 1, 4: 0, 5: 2 });
});
test('mean is null when count is zero', () => {
const stats = ScaleQuestion.emptyStats(q);
ScaleQuestion.finalise(stats);
expect(stats.count).toBe(0);
expect(stats.mean).toBeNull();
});
test('finalise drops the internal _sum accumulator', () => {
const stats = ScaleQuestion.emptyStats(q);
ScaleQuestion.ingest(stats, q, 4, 'now');
ScaleQuestion.finalise(stats);
expect((stats as { _sum?: unknown })._sum).toBeUndefined();
});
});
describe('ScaleQuestion.csv + adminCellSummary', () => {
const q = ScaleQuestion.defaultStub();
test('one column with the question id', () => {
expect(ScaleQuestion.csvColumns({ ...q, id: 'rate' })).toEqual([{ header: 'rate', qid: 'rate' }]);
});
test('cell formats numbers, blank for missing', () => {
const [col] = ScaleQuestion.csvColumns(q);
expect(ScaleQuestion.csvCellFor(q, 4, col)).toBe('4');
expect(ScaleQuestion.csvCellFor(q, null, col)).toBe('');
expect(ScaleQuestion.csvCellFor(q, undefined, col)).toBe('');
});
test('adminCellSummary same shape', () => {
expect(ScaleQuestion.adminCellSummary(q, 3)).toBe('3');
expect(ScaleQuestion.adminCellSummary(q, null)).toBe('—');
});
});

View File

@@ -0,0 +1,88 @@
/**
* `scale` question type — N-button rating row (defaults 1..5) on the
* participant side, histogram + mean on the results side.
*/
import { z } from 'zod';
import type { QuestionTypeModule, CsvColumn, StatsForType } from './types';
import { FeedbackQuestionBaseSchema } from './_base';
import ScaleInput from './scale.input.svelte';
import ScaleBuilder from './scale.builder.svelte';
import ScaleResults from './scale.results.svelte';
export const ScaleQuestionSchema = FeedbackQuestionBaseSchema.extend({
type: z.literal('scale'),
min: z.number().int().min(0).max(100),
max: z.number().int().min(1).max(100),
min_label: z.string().max(50).optional(),
max_label: z.string().max(50).optional(),
});
// Aggregator-internal accumulator. _sum gets read by finalise to compute
// mean, then deleted before the public stats shape is observed.
type ScaleStatsWip = StatsForType<'scale'> & { _sum: number };
export const ScaleQuestion: QuestionTypeModule<'scale'> = {
type: 'scale',
label: 'Scale',
schema: ScaleQuestionSchema,
defaultStub() {
return { id: 'q1', label: 'New question', required: false, type: 'scale', min: 1, max: 5 };
},
isAnswerEmpty(_q, answer) {
return typeof answer !== 'number' || !Number.isFinite(answer);
},
emptyStats(question) {
const wip: ScaleStatsWip = {
type: 'scale',
count: 0,
min: question.min,
max: question.max,
mean: null,
histogram: Array.from({ length: question.max - question.min + 1 }, (_, i) => ({
value: question.min + i,
count: 0,
})),
_sum: 0,
};
return wip;
},
ingest(stats, _q, answer) {
if (typeof answer !== 'number' || !Number.isFinite(answer)) return;
const acc = stats as ScaleStatsWip;
acc.count++;
const bucket = acc.histogram.find((b) => b.value === answer);
if (bucket) bucket.count++;
acc._sum += answer;
},
finalise(stats) {
const acc = stats as ScaleStatsWip;
stats.mean = stats.count > 0 ? acc._sum / stats.count : null;
delete (stats as Partial<ScaleStatsWip>)._sum;
},
sanitizeForPublic(stats) {
return stats;
},
csvColumns(q): CsvColumn[] {
return [{ header: q.id, qid: q.id }];
},
csvCellFor(_q, answer) {
return typeof answer === 'number' ? String(answer) : '';
},
adminCellSummary(_q, answer) {
if (typeof answer === 'number' && Number.isFinite(answer)) return String(answer);
return '—';
},
ParticipantInput: ScaleInput,
BuilderEditor: ScaleBuilder,
ResultsBlock: ScaleResults,
};

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import type { BuilderEditorProps } from './types';
let { question, update }: BuilderEditorProps = $props();
</script>
{#if question.type === 'short_text' || question.type === 'long_text'}
<div class="fb-question">
<label class="fb-question__label" for={`fb-builder-${question.id}-placeholder`}>
Placeholder (optional)
</label>
<input
id={`fb-builder-${question.id}-placeholder`}
class="fb-input"
maxlength="100"
value={question.placeholder ?? ''}
oninput={(e) => {
const v = (e.target as HTMLInputElement).value;
update({ placeholder: v || undefined });
}}
/>
</div>
{/if}

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import type { ParticipantInputProps } from './types';
let { question, answer, setAnswer }: ParticipantInputProps = $props();
</script>
{#if question.type === 'short_text'}
<input
id={`q-${question.id}`}
type="text"
class="fb-input"
placeholder={question.placeholder ?? ''}
maxlength="500"
value={(answer as string) ?? ''}
oninput={(e) => setAnswer((e.target as HTMLInputElement).value)}
/>
{/if}

View File

@@ -0,0 +1,66 @@
/**
* `short_text` question type — single-line text input on participant side,
* answer list (or count-only when sanitised) in results.
*/
import { z } from 'zod';
import type { QuestionTypeModule, CsvColumn } from './types';
import { FeedbackQuestionBaseSchema } from './_base';
import ShortTextInput from './short_text.input.svelte';
import TextBuilder from './short_text.builder.svelte';
import TextResults from './text.results.svelte';
export const ShortTextQuestionSchema = FeedbackQuestionBaseSchema.extend({
type: z.literal('short_text'),
placeholder: z.string().max(100).optional(),
});
export const ShortTextQuestion: QuestionTypeModule<'short_text'> = {
type: 'short_text',
label: 'Short text',
schema: ShortTextQuestionSchema,
defaultStub() {
return { id: 'q1', label: 'New question', required: false, type: 'short_text' };
},
isAnswerEmpty(_q, answer) {
if (typeof answer !== 'string') return true;
return answer.trim() === '';
},
emptyStats() {
return { type: 'short_text', count: 0, answers: [] };
},
ingest(stats, _q, answer, createdAt) {
if (typeof answer !== 'string' || answer.trim() === '') return;
stats.count++;
stats.answers.push({ value: answer, created_at: createdAt });
},
finalise() {
// nothing to finalise — answers are appended in ingest order.
},
sanitizeForPublic(stats) {
// PII / contributor identity: drop the text bodies, keep the count.
return { type: 'short_text', count: stats.count, answers: [] };
},
csvColumns(q): CsvColumn[] {
return [{ header: q.id, qid: q.id }];
},
csvCellFor(_q, answer) {
return typeof answer === 'string' ? answer : '';
},
adminCellSummary(_q, answer) {
if (answer === undefined || answer === null) return '—';
return typeof answer === 'string' ? answer : String(answer);
},
ParticipantInput: ShortTextInput,
BuilderEditor: TextBuilder,
ResultsBlock: TextResults,
};

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import type { ParticipantInputProps } from './types';
let { question, answer, setAnswer }: ParticipantInputProps = $props();
</script>
{#if question.type === 'single_choice'}
<div class="fb-options">
{#each question.options as opt (opt)}
<label class="fb-option-row">
<input
type="radio"
name={`q-${question.id}`}
checked={answer === opt}
onchange={() => setAnswer(opt)}
/>
<span>{opt}</span>
</label>
{/each}
</div>
{/if}

View File

@@ -0,0 +1,76 @@
/**
* `single_choice` question type — radio-button list. One choice per submission.
*/
import { z } from 'zod';
import type { QuestionTypeModule, CsvColumn } from './types';
import { FeedbackQuestionBaseSchema } from './_base';
import SingleChoiceInput from './single_choice.input.svelte';
import ChoiceBuilder from './choice.builder.svelte';
import ChoiceResults from './choice.results.svelte';
export const SingleChoiceQuestionSchema = FeedbackQuestionBaseSchema.extend({
type: z.literal('single_choice'),
options: z.array(z.string().min(1).max(200)).min(2).max(20),
});
export const SingleChoiceQuestion: QuestionTypeModule<'single_choice'> = {
type: 'single_choice',
label: 'Single choice',
schema: SingleChoiceQuestionSchema,
defaultStub() {
return {
id: 'q1',
label: 'New question',
required: false,
type: 'single_choice',
options: ['Option A', 'Option B'],
};
},
isAnswerEmpty(_q, answer) {
return typeof answer !== 'string' || answer.trim() === '';
},
emptyStats(question) {
return {
type: 'single_choice',
count: 0,
options: question.options.map((option) => ({ option, count: 0 })),
other_count: 0,
};
},
ingest(stats, _q, answer) {
if (typeof answer !== 'string') return;
stats.count++;
const hit = stats.options.find((o) => o.option === answer);
if (hit) hit.count++;
else stats.other_count++;
},
finalise() {
// nothing to compute — counts are final.
},
sanitizeForPublic(stats) {
return stats;
},
csvColumns(q): CsvColumn[] {
return [{ header: q.id, qid: q.id }];
},
csvCellFor(_q, answer) {
return typeof answer === 'string' ? answer : '';
},
adminCellSummary(_q, answer) {
if (answer === undefined || answer === null) return '—';
return typeof answer === 'string' ? answer : String(answer);
},
ParticipantInput: SingleChoiceInput,
BuilderEditor: ChoiceBuilder,
ResultsBlock: ChoiceResults,
};

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import type { ResultsBlockProps } from './types';
let { stats }: ResultsBlockProps = $props();
function shortDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString();
} catch {
return iso;
}
}
</script>
{#if stats.type === 'short_text' || stats.type === 'long_text'}
<div class="fb-results__meta">{stats.count} Antworten</div>
{#if stats.answers.length > 0}
<ul class="fb-results__answers">
{#each stats.answers as a (a.created_at + a.value.slice(0, 20))}
<li>
<span class="fb-results__answer-date">{shortDate(a.created_at)}</span>
<span class="fb-results__answer-text">{a.value}</span>
</li>
{/each}
</ul>
{/if}
{/if}

View File

@@ -0,0 +1,98 @@
import { describe, test, expect } from 'bun:test';
import { ShortTextQuestion, ShortTextQuestionSchema } from './short_text';
import { LongTextQuestion, LongTextQuestionSchema } from './long_text';
// Both modules share the same shape (single-line vs multi-line input is the
// only material difference at the participant level). Test the contract once
// for each — kept in one file so behaviour drift between the two is easy to
// spot.
describe('ShortTextQuestion', () => {
const q = ShortTextQuestion.defaultStub();
test('schema accepts valid + rejects bad', () => {
expect(ShortTextQuestionSchema.safeParse(q).success).toBe(true);
expect(
ShortTextQuestionSchema.safeParse({ id: 'q1', label: 'X', type: 'short_text', placeholder: 'p' })
.success,
).toBe(true);
expect(ShortTextQuestionSchema.safeParse({ id: 'q1', label: 'X', type: 'long_text' }).success).toBe(
false,
);
});
test('isAnswerEmpty: blank/whitespace/non-string → empty; real text → not', () => {
expect(ShortTextQuestion.isAnswerEmpty(q, '')).toBe(true);
expect(ShortTextQuestion.isAnswerEmpty(q, ' ')).toBe(true);
expect(ShortTextQuestion.isAnswerEmpty(q, undefined)).toBe(true);
expect(ShortTextQuestion.isAnswerEmpty(q, null)).toBe(true);
expect(ShortTextQuestion.isAnswerEmpty(q, 42)).toBe(true);
expect(ShortTextQuestion.isAnswerEmpty(q, 'hi')).toBe(false);
});
test('ingest: appends in order, ignores blank/non-string, increments count', () => {
const stats = ShortTextQuestion.emptyStats(q);
ShortTextQuestion.ingest(stats, q, 'first', '2026-01-01');
ShortTextQuestion.ingest(stats, q, '', '2026-01-02');
ShortTextQuestion.ingest(stats, q, 'second', '2026-01-03');
ShortTextQuestion.ingest(stats, q, 99, '2026-01-04');
ShortTextQuestion.finalise(stats);
expect(stats.count).toBe(2);
expect(stats.answers.map((a) => a.value)).toEqual(['first', 'second']);
});
test('sanitizeForPublic: drops text, keeps count', () => {
const stats = ShortTextQuestion.emptyStats(q);
ShortTextQuestion.ingest(stats, q, 'secret', '2026-01-01');
ShortTextQuestion.ingest(stats, q, 'sauce', '2026-01-02');
const pub = ShortTextQuestion.sanitizeForPublic(stats);
expect(pub.count).toBe(2);
expect(pub.answers).toEqual([]);
});
test('csv: one column with the question id, cell is the string', () => {
const cols = ShortTextQuestion.csvColumns({ ...q, id: 'name' });
expect(cols).toEqual([{ header: 'name', qid: 'name' }]);
expect(ShortTextQuestion.csvCellFor(q, 'value', cols[0])).toBe('value');
expect(ShortTextQuestion.csvCellFor(q, null, cols[0])).toBe('');
});
test('adminCellSummary: passes through string, em-dash for missing', () => {
expect(ShortTextQuestion.adminCellSummary(q, 'hi')).toBe('hi');
expect(ShortTextQuestion.adminCellSummary(q, null)).toBe('—');
expect(ShortTextQuestion.adminCellSummary(q, undefined)).toBe('—');
});
});
describe('LongTextQuestion', () => {
const q = LongTextQuestion.defaultStub();
test('schema accepts valid + rejects bad', () => {
expect(LongTextQuestionSchema.safeParse(q).success).toBe(true);
expect(LongTextQuestionSchema.safeParse({ id: 'q1', label: 'X', type: 'short_text' }).success).toBe(
false,
);
});
test('isAnswerEmpty: behaves like short_text', () => {
expect(LongTextQuestion.isAnswerEmpty(q, '')).toBe(true);
expect(LongTextQuestion.isAnswerEmpty(q, ' ')).toBe(true);
expect(LongTextQuestion.isAnswerEmpty(q, 'multi\nline')).toBe(false);
});
test('ingest: preserves multi-line text', () => {
const stats = LongTextQuestion.emptyStats(q);
LongTextQuestion.ingest(stats, q, 'line1\nline2', '2026-01-01');
LongTextQuestion.finalise(stats);
expect(stats.count).toBe(1);
expect(stats.answers[0].value).toBe('line1\nline2');
});
test('sanitizeForPublic: same strip behaviour', () => {
const stats = LongTextQuestion.emptyStats(q);
LongTextQuestion.ingest(stats, q, 'abc', '2026-01-01');
const pub = LongTextQuestion.sanitizeForPublic(stats);
expect(pub.count).toBe(1);
expect(pub.answers).toEqual([]);
});
});

122
src/lib/questions/types.ts Normal file
View File

@@ -0,0 +1,122 @@
/**
* Shape of a per-question-type module. One such module lives in
* `lib/questions/<type>.ts` for each kind of question fdbck supports.
*
* The registry (`./registry.ts`) holds the seven concrete modules. Anywhere
* that used to dispatch on `q.type === '...'` now calls
* `getQuestion(q.type).method(q, ...)`. Adding a new type = creating one
* file + one line in the registry array.
*
* The Svelte component slots (`ParticipantInput`, `BuilderEditor`,
* `ResultsBlock`) accept broadly-typed props and narrow internally on
* `question.type`. The dispatch is always sound (callers look up via
* `getQuestion(q.type)` so the module already matches the question), but
* TypeScript can't prove the cross-component relationship without a lot of
* generic gymnastics — the runtime check inside each component is cheaper
* and it makes the registry literal stay simple.
*/
import type { ZodTypeAny } from 'zod';
import type { Component } from 'svelte';
import type { FeedbackQuestion } from '$lib/schemas';
import type { QuestionStats, QuestionResult } from '$lib/server/results';
/** Pull the variant of FeedbackQuestion that has the given `type` literal. */
export type QuestionForType<T extends FeedbackQuestion['type']> = Extract<
FeedbackQuestion,
{ type: T }
>;
/** Pull the variant of QuestionStats that has the given `type` literal.
* Uses intersection rather than `Extract` because `TextStats` declares
* `type: 'short_text' | 'long_text'` as a union (the two share one stats
* shape), and `Extract<T, U>` returns never when T's discriminator is a
* union and U narrows it. Intersection narrows correctly here. */
export type StatsForType<T extends FeedbackQuestion['type']> = QuestionStats & { type: T };
export interface CsvColumn {
/** Column header in the exported CSV (e.g. `q1` or `kickoff_when[opt1]`). */
header: string;
/** Question id this column belongs to. */
qid: string;
/** Option id, only set for multi-column types like date_ranked_choice. */
optId?: string;
}
export interface ParticipantInputProps {
question: FeedbackQuestion;
answer: unknown;
setAnswer(value: unknown): void;
}
export interface BuilderEditorProps {
question: FeedbackQuestion;
update(patch: Partial<FeedbackQuestion>): void;
}
export interface ResultsBlockProps {
question: QuestionResult;
stats: QuestionStats;
}
export interface QuestionTypeModule<T extends FeedbackQuestion['type']> {
/** Discriminator literal — matches the question's `type` field. */
readonly type: T;
/** Human-readable name for the FormBuilder type picker. */
readonly label: string;
/** Zod schema for this question's shape. The schemas registry assembles
* the discriminated union from all modules' schemas. */
readonly schema: ZodTypeAny;
/** Build a fresh question of this type for the FormBuilder "+ Add" button.
* The caller will overwrite `id` to a fresh uid. */
defaultStub(): QuestionForType<T>;
/** Empty / required-violation answer test. The single source of truth for
* "is this answer missing?" — used by both the client-side validator on
* /f/[slug] AND the server-side gate in /api/.../submit. */
isAnswerEmpty(question: QuestionForType<T>, answer: unknown): boolean;
/** Initial aggregator state for this question. */
emptyStats(question: QuestionForType<T>): StatsForType<T>;
/** Fold one answer into the aggregator. Mutates `stats` in place. */
ingest(
stats: StatsForType<T>,
question: QuestionForType<T>,
answer: unknown,
createdAt: string,
): void;
/** Aggregator close-out: compute means, sort options, drop accumulators. */
finalise(stats: StatsForType<T>): void;
/** Strip PII / contributor-identifying answer text for the public results
* endpoint that anonymous participants see after submitting. */
sanitizeForPublic(stats: StatsForType<T>): StatsForType<T>;
/** CSV column expansion. Most types return one column; date_ranked_choice
* returns one column per option. */
csvColumns(question: QuestionForType<T>): CsvColumn[];
/** CSV cell value for a given column (used after `csvColumns` to fill rows). */
csvCellFor(
question: QuestionForType<T>,
answer: unknown,
col: CsvColumn,
): string;
/** One-line cell summary for the admin /[id] submissions table. */
adminCellSummary(question: QuestionForType<T>, answer: unknown): string;
/** Svelte components — see ParticipantInputProps / BuilderEditorProps /
* ResultsBlockProps for the shared shapes. */
ParticipantInput: Component<ParticipantInputProps>;
BuilderEditor: Component<BuilderEditorProps>;
ResultsBlock: Component<ResultsBlockProps>;
}
/** Erased shape — the registry stores modules at this level since the
* per-type generic only matters at the call site. */
export type AnyQuestionTypeModule = QuestionTypeModule<FeedbackQuestion['type']>;

View File

@@ -1,66 +1,37 @@
/**
* Zod schemas for fdbck request body validation.
*
* The per-question-type schemas now live in `lib/questions/<type>.ts`. This
* file imports them directly and assembles the discriminated union — adding
* a new question type means creating one file under `lib/questions/`,
* registering it in `lib/questions/registry.ts`, AND adding one line here
* (TypeScript's discriminated-union inference can't pick up a runtime-built
* array, so the tuple stays explicit to keep `FeedbackQuestion` properly
* narrowed at the call sites).
*/
import { z } from 'zod';
import { ShortTextQuestionSchema } from './questions/short_text';
import { LongTextQuestionSchema } from './questions/long_text';
import { SingleChoiceQuestionSchema } from './questions/single_choice';
import { MultiChoiceQuestionSchema } from './questions/multi_choice';
import { ScaleQuestionSchema } from './questions/scale';
import { BooleanQuestionSchema } from './questions/boolean';
import {
DateRankedChoiceQuestionSchema,
DateRankedOptionSchema as PerTypeDateRankedOptionSchema,
} from './questions/date_ranked_choice';
const FeedbackQuestionBaseSchema = z.object({
id: z.string().min(1).max(64),
label: z.string().min(1).max(200),
required: z.boolean().optional(),
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(),
});
/** Re-exported for callers that need the option shape standalone. */
export const DateRankedOptionSchema = PerTypeDateRankedOptionSchema;
export const FeedbackQuestionSchema = z.discriminatedUnion('type', [
FeedbackQuestionBaseSchema.extend({
type: z.literal('short_text'),
placeholder: z.string().max(100).optional(),
}),
FeedbackQuestionBaseSchema.extend({
type: z.literal('long_text'),
placeholder: z.string().max(100).optional(),
}),
FeedbackQuestionBaseSchema.extend({
type: z.literal('single_choice'),
options: z.array(z.string().min(1).max(200)).min(2).max(20),
}),
FeedbackQuestionBaseSchema.extend({
type: z.literal('multi_choice'),
options: z.array(z.string().min(1).max(200)).min(2).max(20),
}),
FeedbackQuestionBaseSchema.extend({
type: z.literal('scale'),
min: z.number().int().min(0).max(100),
max: z.number().int().min(1).max(100),
min_label: z.string().max(50).optional(),
max_label: z.string().max(50).optional(),
}),
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(),
}),
ShortTextQuestionSchema,
LongTextQuestionSchema,
SingleChoiceQuestionSchema,
MultiChoiceQuestionSchema,
ScaleQuestionSchema,
BooleanQuestionSchema,
DateRankedChoiceQuestionSchema,
]);
/** Version stamp like `0.260505` (YYMMDD) or `0.260505.b` for same-day re-edits. */

View File

@@ -3,8 +3,15 @@
*
* Aggregations are computed from the form_snapshot stored on each submission,
* so historical results stay correct even after the form is later edited.
*
* The per-type aggregation logic (emptyStats / ingest / finalise / sanitize)
* lives in `$lib/questions/<type>.ts`. This file is now a thin dispatcher:
* `aggregateResults` walks the questions and routes each one through the
* registry. Adding a new question type means writing one module file — no
* edit here.
*/
import type { FeedbackFormDefinition, FeedbackQuestion } from '../schemas';
import { getQuestion } from '../questions/registry';
export interface ScaleStats {
type: 'scale';
@@ -81,184 +88,49 @@ export function aggregateResults(
current: FeedbackFormDefinition,
subs: SubmissionRow[],
): AggregatedResults {
const questions: QuestionResult[] = current.questions.map((q) => ({
id: q.id,
label: q.label,
type: q.type,
stats: emptyStats(q),
}));
const byId = new Map(questions.map((q) => [q.id, q]));
const questions: QuestionResult[] = current.questions.map((q) => {
const mod = getQuestion(q.type);
return {
id: q.id,
label: q.label,
type: q.type,
// Each module's emptyStats is typed by its own question variant; the
// top-level FeedbackQuestion[] has been narrowed by getQuestion's
// dispatch on `q.type` so the cast is sound.
stats: mod.emptyStats(q as never) as QuestionStats,
};
});
const qById = new Map(current.questions.map((q) => [q.id, q]));
const resultById = new Map(questions.map((q) => [q.id, q]));
for (const sub of subs) {
for (const q of questions) {
for (const q of current.questions) {
const v = sub.answers?.[q.id];
if (v === undefined || v === null) continue;
ingest(byId.get(q.id)!, current.questions.find((cq) => cq.id === q.id)!, v, sub.created_at);
const result = resultById.get(q.id)!;
getQuestion(q.type).ingest(result.stats as never, q as never, v, sub.created_at);
}
}
for (const q of questions) finalise(q.stats);
for (const q of questions) {
const def = qById.get(q.id);
if (!def) continue;
getQuestion(def.type).finalise(q.stats as never);
}
return { total_submissions: subs.length, questions };
}
function emptyStats(q: FeedbackQuestion): QuestionStats {
switch (q.type) {
case 'scale':
return {
type: 'scale',
count: 0,
min: q.min,
max: q.max,
mean: null,
histogram: Array.from({ length: q.max - q.min + 1 }, (_, i) => ({
value: q.min + i,
count: 0,
})),
};
case 'single_choice':
case 'multi_choice':
return {
type: q.type,
count: 0,
options: q.options.map((option) => ({ option, count: 0 })),
other_count: 0,
};
case 'boolean':
return { type: 'boolean', count: 0, yes: 0, no: 0 };
case 'short_text':
case 'long_text':
return { type: q.type, count: 0, answers: [] };
case 'date_ranked_choice':
return {
type: 'date_ranked_choice',
count: 0,
options: q.options.map((opt) => ({
id: opt.id,
start: opt.start,
end: opt.end ?? null,
label: opt.label ?? null,
count: 0,
mean: null,
histogram: [1, 2, 3, 4, 5].map((value) => ({ value, count: 0 })),
})),
};
}
}
function ingest(
out: QuestionResult,
q: FeedbackQuestion,
v: unknown,
created_at: string,
): void {
const s = out.stats;
switch (s.type) {
case 'scale': {
if (typeof v !== 'number' || !Number.isFinite(v)) return;
s.count++;
const bucket = s.histogram.find((b) => b.value === v);
if (bucket) bucket.count++;
(s as ScaleStats & { _sum?: number })._sum =
((s as ScaleStats & { _sum?: number })._sum ?? 0) + v;
return;
}
case 'single_choice': {
if (typeof v !== 'string') return;
s.count++;
const hit = s.options.find((o) => o.option === v);
if (hit) hit.count++;
else s.other_count++;
return;
}
case 'multi_choice': {
if (!Array.isArray(v)) return;
if (v.length === 0) return;
s.count++;
for (const choice of v) {
if (typeof choice !== 'string') continue;
const hit = s.options.find((o) => o.option === choice);
if (hit) hit.count++;
else s.other_count++;
}
return;
}
case 'boolean': {
if (typeof v !== 'boolean') return;
s.count++;
if (v) s.yes++;
else s.no++;
return;
}
case 'short_text':
case 'long_text': {
if (typeof v !== 'string' || v.trim() === '') return;
s.count++;
s.answers.push({ value: v, created_at });
return;
}
case 'date_ranked_choice': {
if (!v || typeof v !== 'object' || Array.isArray(v)) return;
const ratings = v as Record<string, unknown>;
let touched = false;
for (const opt of s.options) {
const raw = ratings[opt.id];
if (raw === undefined || raw === null) continue;
if (typeof raw !== 'number' || !Number.isInteger(raw) || raw < 1 || raw > 5) continue;
opt.count++;
const bucket = opt.histogram.find((b) => b.value === raw);
if (bucket) bucket.count++;
(opt as DateRankedOptionStats & { _sum?: number })._sum =
((opt as DateRankedOptionStats & { _sum?: number })._sum ?? 0) + raw;
touched = true;
}
if (touched) s.count++;
return;
}
}
void q; // unused after switch covers all branches
}
function finalise(s: QuestionStats): void {
if (s.type === 'scale') {
const sum = (s as ScaleStats & { _sum?: number })._sum;
s.mean = s.count > 0 && typeof sum === 'number' ? sum / s.count : null;
delete (s as ScaleStats & { _sum?: number })._sum;
return;
}
if (s.type === 'date_ranked_choice') {
for (const opt of s.options) {
const sum = (opt as DateRankedOptionStats & { _sum?: number })._sum;
opt.mean = opt.count > 0 && typeof sum === 'number' ? sum / opt.count : null;
delete (opt as DateRankedOptionStats & { _sum?: number })._sum;
}
// Sort by mean desc with tiebreaks: count of "5"s, then "4"s, then count desc, then id.
s.options.sort((a, b) => {
const am = a.mean ?? -Infinity;
const bm = b.mean ?? -Infinity;
if (am !== bm) return bm - am;
const a5 = a.histogram.find((h) => h.value === 5)?.count ?? 0;
const b5 = b.histogram.find((h) => h.value === 5)?.count ?? 0;
if (a5 !== b5) return b5 - a5;
const a4 = a.histogram.find((h) => h.value === 4)?.count ?? 0;
const b4 = b.histogram.find((h) => h.value === 4)?.count ?? 0;
if (a4 !== b4) return b4 - a4;
if (a.count !== b.count) return b.count - a.count;
return a.id.localeCompare(b.id);
});
}
}
/** Public-safe results: drop free-text answers (PII / open data). */
/** Public-safe results: drop free-text answers (PII / open data).
* Each module's sanitizeForPublic decides what survives — text types strip
* the answers list and keep only the count; everything else passes through. */
export function publicResults(r: AggregatedResults): AggregatedResults {
return {
total_submissions: r.total_submissions,
questions: r.questions.map((q) => {
if (q.stats.type === 'short_text' || q.stats.type === 'long_text') {
return { ...q, stats: { type: q.stats.type, count: q.stats.count, answers: [] } };
}
return q;
}),
questions: r.questions.map((q) => ({
...q,
stats: getQuestion(q.type).sanitizeForPublic(q.stats as never) as QuestionStats,
})),
};
}

View File

@@ -4,6 +4,7 @@
import { goto, invalidateAll } from '$app/navigation';
import type { PageData } from './$types';
import { FeedbackFormDefinitionSchema, type FeedbackFormDefinition } from '$lib/schemas';
import { getQuestion } from '$lib/questions/registry';
import type { AggregatedResults } from '$lib/server/results';
import Results from '$lib/components/Results.svelte';
import FormBuilder from '$lib/components/FormBuilder.svelte';
@@ -294,25 +295,12 @@
return new Date(iso).toLocaleString();
}
function summarizeAnswer(v: unknown): string {
if (v === null || v === undefined) return '—';
if (typeof v === 'boolean') return v ? 'Yes' : 'No';
if (Array.isArray(v)) return v.join(', ');
if (typeof v === 'object') {
// date_ranked_choice answers are { optId: 1..5 | null } — terse summary for the table cell.
const ratings = Object.values(v as Record<string, unknown>).filter(
(x): x is number => typeof x === 'number' && Number.isFinite(x),
);
if (ratings.length === 0) return '—';
const avg = ratings.reduce((a, b) => a + b, 0) / ratings.length;
const fmt = avg.toFixed(1).replace(/\.0$/, '');
return `${fmt} avg (${ratings.length} rated)`;
}
return String(v);
}
function answerCellFor(qid: string, sub: { answers: Record<string, unknown> }): string {
return summarizeAnswer(sub.answers?.[qid]);
function answerCellFor(
q: { id: string; type: import('$lib/schemas').FeedbackQuestion['type'] },
sub: { answers: Record<string, unknown> },
): string {
const answer = sub.answers?.[q.id];
return getQuestion(q.type).adminCellSummary(q as never, answer);
}
// Click-outside-to-close + Escape for any open <details class="fb-menu">.
@@ -548,7 +536,7 @@
<td class="fb-detail-table__date">{fmtDateTime(s.created_at)}</td>
<td>{s.display_name ?? 'anonymous'}</td>
{#each questions as q (q.id)}
<td class="fb-detail-table__cell">{answerCellFor(q.id, s)}</td>
<td class="fb-detail-table__cell">{answerCellFor(q, s)}</td>
{/each}
</tr>
{/each}

View File

@@ -5,6 +5,8 @@ import { badRequest } from '$lib/server/errors';
import { fdb } from '$lib/server/fdb';
import { withOwnedInstance } from '$lib/server/admin-route';
import type { FeedbackFormDefinition } from '$lib/schemas';
import { getQuestion } from '$lib/questions/registry';
import type { CsvColumn } from '$lib/questions/types';
interface SubmissionRow {
id: string;
@@ -92,16 +94,14 @@ export const GET = withOwnedInstance(async ({ inst, event }) => {
const formDef = inst.form_definition as FeedbackFormDefinition | null;
const questions = formDef?.questions ?? [];
// Expand `date_ranked_choice` questions into one column per option (e.g. `kickoff_when[opt1]`).
// Other types stay as a single column keyed by question id.
const colSpecs: { qid: string; optId?: string; header: string }[] = [];
// Per-question column expansion. date_ranked_choice yields one column
// per option; everything else is a single column keyed by question id.
// The shape is owned by each module's csvColumns / csvCellFor.
const colSpecs: (CsvColumn & { question: typeof questions[number] })[] = [];
for (const q of questions) {
if (q.type === 'date_ranked_choice') {
for (const opt of q.options) {
colSpecs.push({ qid: q.id, optId: opt.id, header: `${q.id}[${opt.id}]` });
}
} else {
colSpecs.push({ qid: q.id, header: q.id });
const mod = getQuestion(q.type);
for (const col of mod.csvColumns(q)) {
colSpecs.push({ ...col, question: q });
}
}
@@ -109,13 +109,8 @@ export const GET = withOwnedInstance(async ({ inst, event }) => {
const subRows = submissions.map((row) => [
row.id, row.created_at, row.display_name, row.client_session_id,
...colSpecs.map((c) => {
const v = row.answers?.[c.qid];
if (c.optId) {
if (!v || typeof v !== 'object' || Array.isArray(v)) return '';
const r = (v as Record<string, unknown>)[c.optId];
return r === null || r === undefined ? '' : r;
}
return v ?? '';
const answer = row.answers?.[c.qid];
return getQuestion(c.question.type).csvCellFor(c.question, answer, c);
}),
]);
const submissionsCsv = rowsToCsv(subHeaders, subRows);

View File

@@ -12,6 +12,7 @@ import {
findExistingSubmission,
isHoneypotTrap,
} from '$lib/server/feedback';
import { getQuestion } from '$lib/questions/registry';
import { checkRate } from '$lib/server/rate-limit';
import { fdb } from '$lib/server/fdb';
@@ -40,15 +41,16 @@ export const POST: RequestHandler = async ({ params, request, getClientAddress }
for (const id of Object.keys(body.answers)) {
if (!knownIds.has(id)) return badRequest(`Unknown question id: ${id}`);
}
// Required-question gate. Per-type isAnswerEmpty is THE source of truth —
// the legacy inline empty-check missed `date_ranked_choice` answers of
// shape `{}` (object exists but no options rated). The registry rule
// now closes that gap by construction.
for (const q of formDef.questions) {
if (q.required) {
const v = body.answers[q.id];
const empty =
v === undefined ||
v === null ||
(typeof v === 'string' && v.trim() === '') ||
(Array.isArray(v) && v.length === 0);
if (empty) return badRequest(`Missing answer for required question: ${q.id}`);
const mod = getQuestion(q.type);
if (mod.isAnswerEmpty(q, body.answers[q.id])) {
return badRequest(`Missing answer for required question: ${q.id}`);
}
}
}

View File

@@ -3,6 +3,7 @@
import { onMount, onDestroy } from 'svelte';
import type { FeedbackFormDefinition, FeedbackQuestion } from '$lib/schemas';
import type { AggregatedResults } from '$lib/server/results';
import { getQuestion } from '$lib/questions/registry';
import Results from '$lib/components/Results.svelte';
let { data }: { data: PageData } = $props();
@@ -220,33 +221,25 @@
if (formDef) {
for (const q of formDef.questions) {
if (q.type === 'date_ranked_choice') {
const mod = getQuestion(q.type);
// Required gate — same isAnswerEmpty rule the server uses.
if (q.required && mod.isAnswerEmpty(q, answers[q.id])) {
submitError = `Bitte beantworte: ${q.label}`;
submitInFlight = false;
return;
}
// date_ranked_choice has an extra "rate every option" rule when
// allow_partial is explicitly false. Lives here (client-only) since
// it's a UX nudge, not a security gate — the server treats partial
// answers as valid as long as at least one option is rated.
if (q.type === 'date_ranked_choice' && q.allow_partial === false) {
const map = (answers[q.id] as Record<string, number | null> | undefined) ?? {};
const rated = q.options.filter((opt) => typeof map[opt.id] === 'number');
if (q.required && rated.length === 0) {
submitError = `Bitte bewerte mindestens eine Option: ${q.label}`;
submitInFlight = false;
return;
}
if (q.allow_partial === false && rated.length < q.options.length) {
if (rated.length < q.options.length) {
submitError = `Bitte bewerte alle Optionen: ${q.label}`;
submitInFlight = false;
return;
}
continue;
}
if (q.required) {
const v = answers[q.id];
const empty =
v === undefined ||
v === null ||
(typeof v === 'string' && v.trim() === '') ||
(Array.isArray(v) && v.length === 0);
if (empty) {
submitError = `Bitte beantworte: ${q.label}`;
submitInFlight = false;
return;
}
}
}
}
@@ -324,28 +317,19 @@
}
function summariseSubmittedAnswer(q: FeedbackQuestion, v: unknown): string {
if (v === undefined || v === null) return '—';
if (q.type === 'short_text' || q.type === 'long_text') {
return typeof v === 'string' ? v : String(v);
}
if (q.type === 'single_choice') {
return typeof v === 'string' ? v : String(v);
}
if (q.type === 'multi_choice') {
if (Array.isArray(v)) return v.length === 0 ? '—' : v.join(', ');
return String(v);
}
if (q.type === 'scale') {
if (typeof v !== 'number') return String(v);
const labels: string[] = [];
if (q.min_label && v === q.min) labels.push(q.min_label);
if (q.max_label && v === q.max) labels.push(q.max_label);
return labels.length ? `${v}${labels.join(', ')}` : String(v);
}
// Boolean is the one case where the registry's English "Yes/No"
// would clash with the German participant page; keep the German label
// for now (i18n is m/fdbck#3, separate). Everything else delegates to
// the registry's adminCellSummary, which is already locale-neutral
// for these types.
if (q.type === 'boolean') {
return v === true ? 'Ja' : v === false ? 'Nein' : '';
if (v === true) return 'Ja';
if (v === false) return 'Nein';
return '—';
}
if (q.type === 'date_ranked_choice') {
// Per-option breakdown is German-flavoured ("X/5" format) and
// includes the formatted date — not what the admin table needs.
if (!v || typeof v !== 'object' || Array.isArray(v)) return '—';
const map = v as Record<string, unknown>;
const lines: string[] = [];
@@ -358,7 +342,7 @@
}
return lines.length ? lines.join('\n') : '—';
}
return String(v);
return getQuestion(q.type).adminCellSummary(q, v);
}
function isMine(p: ChatPost): boolean {
@@ -387,29 +371,10 @@
return dayBeforeFmt.format(d);
}
function toggleMultiChoice(q: FeedbackQuestion, opt: string): void {
if (q.type !== 'multi_choice') return;
const cur = (answers[q.id] as string[] | undefined) ?? [];
const next = cur.includes(opt) ? cur.filter((x) => x !== opt) : [...cur, opt];
answers = { ...answers, [q.id]: next };
}
function setAnswer(qid: string, value: unknown): void {
answers = { ...answers, [qid]: value };
}
function setDateRankedRating(qid: string, optId: string, rating: number | null): void {
const cur = (answers[qid] as Record<string, number | null> | undefined) ?? {};
answers = { ...answers, [qid]: { ...cur, [optId]: rating } };
}
function dateRankedRating(qid: string, optId: string): number | null {
const cur = answers[qid] as Record<string, number | null> | undefined;
if (!cur) return null;
const v = cur[optId];
return typeof v === 'number' ? v : null;
}
const dateOptionFmt = new Intl.DateTimeFormat([], {
weekday: 'short',
day: '2-digit',
@@ -626,136 +591,17 @@
/>
{#each formDef.questions as q (q.id)}
{@const Input = getQuestion(q.type).ParticipantInput}
<div class="fb-question">
<label class="fb-question__label" for={`q-${q.id}`}>
{q.label}{#if q.required}<span class="fb-question__required">*</span>{/if}
</label>
{#if q.type === 'short_text'}
<input
id={`q-${q.id}`}
type="text"
class="fb-input"
placeholder={q.placeholder ?? ''}
maxlength="500"
value={(answers[q.id] as string) ?? ''}
oninput={(e) => setAnswer(q.id, (e.target as HTMLInputElement).value)}
/>
{:else if q.type === 'long_text'}
<textarea
id={`q-${q.id}`}
class="fb-textarea"
placeholder={q.placeholder ?? ''}
maxlength="5000"
rows="4"
value={(answers[q.id] as string) ?? ''}
oninput={(e) => setAnswer(q.id, (e.target as HTMLTextAreaElement).value)}
></textarea>
{:else if q.type === 'single_choice'}
<div class="fb-options">
{#each q.options as opt (opt)}
<label class="fb-option-row">
<input
type="radio"
name={`q-${q.id}`}
checked={answers[q.id] === opt}
onchange={() => setAnswer(q.id, opt)}
/>
<span>{opt}</span>
</label>
{/each}
</div>
{:else if q.type === 'multi_choice'}
<div class="fb-options">
{#each q.options as opt (opt)}
<label class="fb-option-row">
<input
type="checkbox"
checked={Array.isArray(answers[q.id]) &&
(answers[q.id] as string[]).includes(opt)}
onchange={() => toggleMultiChoice(q, opt)}
/>
<span>{opt}</span>
</label>
{/each}
</div>
{:else if q.type === 'scale'}
<div class="fb-scale">
{#each Array.from({ length: q.max - q.min + 1 }, (_, i) => i + q.min) as v (v)}
<button
type="button"
class="fb-scale__btn {answers[q.id] === v ? 'fb-scale__btn--active' : ''}"
onclick={() => setAnswer(q.id, v)}
>
{v}
</button>
{/each}
</div>
{#if q.min_label || q.max_label}
<div class="fb-scale__labels">
<span>{q.min_label ?? q.min}</span>
<span>{q.max_label ?? q.max}</span>
</div>
{/if}
{:else if q.type === 'boolean'}
<div class="fb-options">
<label class="fb-option-row">
<input
type="radio"
name={`q-${q.id}`}
checked={answers[q.id] === true}
onchange={() => setAnswer(q.id, true)}
/>
<span>Ja</span>
</label>
<label class="fb-option-row">
<input
type="radio"
name={`q-${q.id}`}
checked={answers[q.id] === false}
onchange={() => setAnswer(q.id, false)}
/>
<span>Nein</span>
</label>
</div>
{:else if q.type === 'date_ranked_choice'}
<div class="fb-date-ranked">
{#if q.scale?.min_label || q.scale?.max_label}
<div class="fb-scale__labels" style="margin-bottom: 0.5rem;">
<span>1 — {q.scale.min_label ?? 'passt nicht'}</span>
<span>5 — {q.scale.max_label ?? 'passt super'}</span>
</div>
{/if}
{#each q.options as opt (opt.id)}
<div class="fb-date-ranked__row">
<div class="fb-date-ranked__opt">
<div class="fb-date-ranked__when">{fmtDateOption(opt.start, opt.end)}</div>
{#if opt.label}<div class="fb-date-ranked__label">{opt.label}</div>{/if}
</div>
<div class="fb-scale fb-date-ranked__scale" role="radiogroup" aria-label={opt.label ?? fmtDateOption(opt.start, opt.end)}>
{#each [1, 2, 3, 4, 5] as v (v)}
<button
type="button"
class="fb-scale__btn {dateRankedRating(q.id, opt.id) === v ? 'fb-scale__btn--active' : ''}"
aria-pressed={dateRankedRating(q.id, opt.id) === v}
onclick={() => setDateRankedRating(q.id, opt.id, v)}
>
{v}
</button>
{/each}
<button
type="button"
class="fb-date-ranked__skip {dateRankedRating(q.id, opt.id) === null ? 'fb-date-ranked__skip--active' : ''}"
onclick={() => setDateRankedRating(q.id, opt.id, null)}
aria-label="Skip option"
>
</button>
</div>
</div>
{/each}
</div>
{/if}
<Input
question={q}
answer={answers[q.id]}
setAnswer={(v) => setAnswer(q.id, v)}
/>
{#if q.help}
<div class="fb-question__help">{q.help}</div>

10
src/test-setup/vitest.ts Normal file
View File

@@ -0,0 +1,10 @@
/**
* Vitest setup — auto-cleanup the DOM between component tests so consecutive
* render() calls don't leak elements into each other's `getByTestId` lookups.
*/
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/svelte';
afterEach(() => {
cleanup();
});

33
vitest.config.ts Normal file
View File

@@ -0,0 +1,33 @@
import { defineConfig } from 'vitest/config';
import { svelte } from '@sveltejs/vite-plugin-svelte';
/**
* Vitest config — runs *.svelte.test.ts files under jsdom with the Svelte
* plugin, so per-type ParticipantInput / BuilderEditor / ResultsBlock
* components in lib/questions/<type>.svelte (or any .svelte component) can
* be mounted via @testing-library/svelte.
*
* Server-side tests (.test.ts under lib/server/) stay on `bun test` per
* `bun run test:server`. Component tests live behind `bun run test:components`.
*
* The runtime split exists because:
* 1. Bun test doesn't apply `browser` export conditions when resolving ESM,
* so it picks Svelte's `index-server.js` and @testing-library/svelte's
* mount() throws lifecycle_function_unavailable.
* 2. Vitest reuses the existing svelte vite plugin; no extra runtime split.
*
* Both runners share the same expect()/describe()/test() API surface, so
* component tests look identical in spirit to the server-side ones.
*/
export default defineConfig({
plugins: [svelte({ hot: false })],
resolve: {
conditions: ['browser'],
},
test: {
environment: 'jsdom',
include: ['src/**/*.svelte.test.ts'],
setupFiles: ['./src/test-setup/vitest.ts'],
globals: false,
},
});