Merge mai/cronus/arch-phase2-questions: per-question-type module bundle (§3.A)
This commit is contained in:
178
bun.lock
178
bun.lock
@@ -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=="],
|
||||
}
|
||||
}
|
||||
|
||||
10
package.json
10
package.json
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
10
src/lib/components/SmokeTest.svelte
Normal file
10
src/lib/components/SmokeTest.svelte
Normal 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>
|
||||
21
src/lib/components/SmokeTest.svelte.test.ts
Normal file
21
src/lib/components/SmokeTest.svelte.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
20
src/lib/questions/_base.ts
Normal file
20
src/lib/questions/_base.ts
Normal 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>;
|
||||
8
src/lib/questions/boolean.builder.svelte
Normal file
8
src/lib/questions/boolean.builder.svelte
Normal 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>
|
||||
26
src/lib/questions/boolean.input.svelte
Normal file
26
src/lib/questions/boolean.input.svelte
Normal 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>
|
||||
31
src/lib/questions/boolean.results.svelte
Normal file
31
src/lib/questions/boolean.results.svelte
Normal 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}
|
||||
94
src/lib/questions/boolean.test.ts
Normal file
94
src/lib/questions/boolean.test.ts
Normal 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('—');
|
||||
});
|
||||
});
|
||||
69
src/lib/questions/boolean.ts
Normal file
69
src/lib/questions/boolean.ts
Normal 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,
|
||||
};
|
||||
54
src/lib/questions/choice.builder.svelte
Normal file
54
src/lib/questions/choice.builder.svelte
Normal 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}
|
||||
35
src/lib/questions/choice.results.svelte
Normal file
35
src/lib/questions/choice.results.svelte
Normal 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}
|
||||
93
src/lib/questions/choice.test.ts
Normal file
93
src/lib/questions/choice.test.ts
Normal 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('—');
|
||||
});
|
||||
});
|
||||
180
src/lib/questions/date_ranked_choice.builder.svelte
Normal file
180
src/lib/questions/date_ranked_choice.builder.svelte
Normal 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}
|
||||
82
src/lib/questions/date_ranked_choice.input.svelte
Normal file
82
src/lib/questions/date_ranked_choice.input.svelte
Normal 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}
|
||||
253
src/lib/questions/date_ranked_choice.results.svelte
Normal file
253
src/lib/questions/date_ranked_choice.results.svelte
Normal 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}
|
||||
200
src/lib/questions/date_ranked_choice.test.ts
Normal file
200
src/lib/questions/date_ranked_choice.test.ts
Normal 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('—');
|
||||
});
|
||||
});
|
||||
199
src/lib/questions/date_ranked_choice.ts
Normal file
199
src/lib/questions/date_ranked_choice.ts
Normal 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,
|
||||
};
|
||||
17
src/lib/questions/long_text.input.svelte
Normal file
17
src/lib/questions/long_text.input.svelte
Normal 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}
|
||||
65
src/lib/questions/long_text.ts
Normal file
65
src/lib/questions/long_text.ts
Normal 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,
|
||||
};
|
||||
30
src/lib/questions/multi_choice.input.svelte
Normal file
30
src/lib/questions/multi_choice.input.svelte
Normal 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}
|
||||
81
src/lib/questions/multi_choice.ts
Normal file
81
src/lib/questions/multi_choice.ts
Normal 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,
|
||||
};
|
||||
30
src/lib/questions/registry.test.ts
Normal file
30
src/lib/questions/registry.test.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
58
src/lib/questions/registry.ts
Normal file
58
src/lib/questions/registry.ts
Normal 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);
|
||||
}
|
||||
60
src/lib/questions/scale.builder.svelte
Normal file
60
src/lib/questions/scale.builder.svelte
Normal 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}
|
||||
25
src/lib/questions/scale.input.svelte
Normal file
25
src/lib/questions/scale.input.svelte
Normal 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}
|
||||
38
src/lib/questions/scale.results.svelte
Normal file
38
src/lib/questions/scale.results.svelte
Normal 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}
|
||||
100
src/lib/questions/scale.test.ts
Normal file
100
src/lib/questions/scale.test.ts
Normal 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('—');
|
||||
});
|
||||
});
|
||||
88
src/lib/questions/scale.ts
Normal file
88
src/lib/questions/scale.ts
Normal 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,
|
||||
};
|
||||
23
src/lib/questions/short_text.builder.svelte
Normal file
23
src/lib/questions/short_text.builder.svelte
Normal 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}
|
||||
17
src/lib/questions/short_text.input.svelte
Normal file
17
src/lib/questions/short_text.input.svelte
Normal 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}
|
||||
66
src/lib/questions/short_text.ts
Normal file
66
src/lib/questions/short_text.ts
Normal 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,
|
||||
};
|
||||
21
src/lib/questions/single_choice.input.svelte
Normal file
21
src/lib/questions/single_choice.input.svelte
Normal 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}
|
||||
76
src/lib/questions/single_choice.ts
Normal file
76
src/lib/questions/single_choice.ts
Normal 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,
|
||||
};
|
||||
27
src/lib/questions/text.results.svelte
Normal file
27
src/lib/questions/text.results.svelte
Normal 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}
|
||||
98
src/lib/questions/text.test.ts
Normal file
98
src/lib/questions/text.test.ts
Normal 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
122
src/lib/questions/types.ts
Normal 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']>;
|
||||
@@ -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. */
|
||||
|
||||
@@ -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) => ({
|
||||
const questions: QuestionResult[] = current.questions.map((q) => {
|
||||
const mod = getQuestion(q.type);
|
||||
return {
|
||||
id: q.id,
|
||||
label: q.label,
|
||||
type: q.type,
|
||||
stats: emptyStats(q),
|
||||
}));
|
||||
const byId = new Map(questions.map((q) => [q.id, q]));
|
||||
// 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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)}
|
||||
<Input
|
||||
question={q}
|
||||
answer={answers[q.id]}
|
||||
setAnswer={(v) => setAnswer(q.id, v)}
|
||||
/>
|
||||
{: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}
|
||||
|
||||
{#if q.help}
|
||||
<div class="fb-question__help">{q.help}</div>
|
||||
|
||||
10
src/test-setup/vitest.ts
Normal file
10
src/test-setup/vitest.ts
Normal 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
33
vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user