Files
crewli-old/apps/app/src/composables/useTimeSlotDropdown.ts
bert.hausmans 1289b217d0 fix(app): resolve Bucket E.2-E.5 lint findings
WS-3 session 1b-ii Task 5b+c (audit Bucket E.2-E.5 — 6 items resolved,
2 promise/no-promise-in-callback warnings remain on dynamic-import
sites — see deviations).

This commit is split out from the originally-planned grouped Task 5
because the API stream timed out mid-session. E.1 (isAxiosError) is in
the preceding commit 0f155d9.

E.2 — vitest spec to Composition API (1× vue/component-api-style):
- useFormFailures.spec.ts: rewrote the test wrapper from
  \`{ setup() { return { result } }, render: () => h('div') }\`
  to \`setup(_, { expose }) { expose({ result }); return () => h('div') }\`.
  Pure Composition API: setup returns the render function; expose()
  declares the instance-visible \`result\` that the 7 \`vm.result.*\`
  assertions consume. Tests still pass green (49 tests).

E.3 — REAL BUG: missing return in computed (1× vue/return-in-computed-property):
- useTimeSlotDropdown.ts:80: the \`fetchParams\` computed had a switch
  over the \`DropdownScenario\` type (4 string-literal cases) without
  a \`default\` branch. If \`scenario.value\` ever returned a value
  outside the four narrowed cases (e.g. via a future type-assertion
  drift), the computed silently returned \`undefined\`, and the
  consumer code (\`fetchParams.value.includeParent\`) would throw
  \`Cannot read property 'includeParent' of undefined\`. Added a
  \`default\` branch returning \`{ includeParent: false, includeChildren: false }\`
  — same as the 'flat' case (the safest baseline: include only own
  slots, no hierarchy).

E.4 — SECURITY (1× vue/no-template-target-blank):
- pages/organisation/index.vue:343: the external website anchor had
  \`target='_blank'\` with \`rel='noopener'\` (only one). The rule
  requires the full \`rel='noopener noreferrer'\` pair. Updated.
  Mitigates reverse-tabnabbing (window.opener) AND referrer-leakage
  to the linked third-party site.

E.5 — axios fire-and-forget (3× promise/no-promise-in-callback,
1 fully resolved + 2 warnings remain):
- lib/axios.ts:42: changed \`error => Promise.reject(error)\` to
  \`async error => { throw error }\`. Semantically identical (axios
  interceptor onRejected returns a rejected promise either way) and
  satisfies the lint rule.
- lib/axios.ts:61, 73: prefixed the dynamic-import chains with \`void\`
  per Q4's option-a decision (\`void import('@/stores/...').then(...)\`).
  This makes the discard intent explicit, but empirically does NOT
  satisfy promise/no-promise-in-callback — the rule fires on any
  promise creation inside a callback, regardless of the discard
  pattern. The 2 warnings remain in the post-Task-5 baseline.
  Resolution path is Bert's call: either keep \`void\` and accept
  the warnings as documentation, or rewrite to \`async error => {
  const { useStore } = await import(...); ... }\` which sequentializes
  the dynamic-import resolution with the rejection. Out of scope for
  this session per the literal Q4 recipe.

Tests + typecheck verified green.

Lint baseline: 34 → 32.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 15:15:29 +02:00

162 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { type Ref, computed } from 'vue'
import type { EventItem } from '@/types/event'
import type { FestivalSection } from '@/types/section'
import type { TimeSlot } from '@/types/timeSlot'
export type DropdownScenario = 'flat' | 'sub_event_standard' | 'cross_event' | 'festival_standard'
export interface TimeSlotDropdownItem {
id: string
name: string
timeRange: string
displayLabel: string
_isDimmed: boolean
groupName: string
}
interface TooltipContent {
main: string
tip: string
}
export function useTimeSlotDropdown(
event: Ref<EventItem | null | undefined>,
section: Ref<FestivalSection | null | undefined>,
) {
const scenario = computed<DropdownScenario>(() => {
if (!event.value)
return 'flat'
const isSubEvent = !!event.value.parent_event_id
const hasChildren = event.value.has_children
const isCrossEvent = section.value?.type === 'cross_event'
// Flat event — no hierarchy
if (!isSubEvent && !hasChildren)
return 'flat'
// Cross_event section — needs all time slots
if (isCrossEvent)
return 'cross_event'
// Standard section on sub-event — own + parent
if (isSubEvent)
return 'sub_event_standard'
// Standard section on festival level — own only
return 'festival_standard'
})
const showInfoTooltip = computed(() => scenario.value !== 'flat')
const hasGroups = computed(() => scenario.value !== 'flat')
const tooltipText = computed<TooltipContent | null>(() => {
const eventName = event.value?.name ?? ''
const sectionName = section.value?.name ?? ''
const festivalName = event.value?.parent?.name ?? eventName
switch (scenario.value) {
case 'sub_event_standard':
return {
main: `Kies het tijdvenster voor deze dienst. Je ziet de tijdsloten van ${eventName} en de algemene tijdsloten van ${festivalName}.`,
tip: `Voor een reguliere dienst kies je een tijdslot van ${eventName}. Voor een opbouw- of afbraakdienst kies je een festivaltijdslot.`,
}
case 'cross_event':
return {
main: `${sectionName} is een festival-brede sectie — actief bij elk programmaonderdeel. Je kunt tijdsloten kiezen van ${festivalName} en van alle programmaonderdelen.`,
tip: 'Plan diensten per programmaonderdeel (bijv. een showavond) of festival-breed (bijv. opbouw, nachtsecurity).',
}
case 'festival_standard':
return {
main: `Kies een tijdslot van ${eventName} voor deze dienst.`,
tip: 'Alleen tijdsloten op festivalniveau zijn beschikbaar voor deze sectie.',
}
default:
return null
}
})
const fetchParams = computed(() => {
switch (scenario.value) {
case 'flat':
case 'festival_standard':
return { includeParent: false, includeChildren: false }
case 'sub_event_standard':
return { includeParent: true, includeChildren: false }
case 'cross_event':
return { includeParent: false, includeChildren: true }
default:
return { includeParent: false, includeChildren: false }
}
})
/**
* Returns a flat array of selectable items sorted by group
* (own event first, then others). Group boundaries are detected
* in the template by comparing adjacent items' groupName.
*/
function sortedItems(timeSlots: TimeSlot[]): TimeSlotDropdownItem[] {
if (scenario.value === 'flat')
return timeSlots.map(ts => toDropdownItem(ts, false, ''))
// Classify each slot into a group and determine isOwn per group
const groups = new Map<string, { slots: TimeSlot[]; isOwn: boolean }>()
for (const ts of timeSlots) {
const key = ts.event_name ?? 'Onbekend'
if (!groups.has(key)) {
const isOwn = scenario.value === 'sub_event_standard'
? ts.source === 'sub_event'
: ts.source === 'own'
groups.set(key, { slots: [], isOwn })
}
groups.get(key)!.slots.push(ts)
}
// Own group first, then others alphabetically
const sorted = [...groups.entries()].sort(([nameA, a], [nameB, b]) => {
if (a.isOwn && !b.isOwn)
return -1
if (!a.isOwn && b.isOwn)
return 1
return nameA.localeCompare(nameB)
})
const items: TimeSlotDropdownItem[] = []
for (const [groupName, { slots, isOwn }] of sorted) {
const isDimmed = scenario.value === 'sub_event_standard' && !isOwn
for (const ts of slots)
items.push(toDropdownItem(ts, isDimmed, groupName))
}
return items
}
return {
scenario,
showInfoTooltip,
hasGroups,
tooltipText,
fetchParams,
sortedItems,
}
}
function toDropdownItem(ts: TimeSlot, isDimmed: boolean, groupName: string): TimeSlotDropdownItem {
const timeRange = `${ts.start_time} ${ts.end_time}`
return {
id: ts.id,
name: ts.name,
timeRange,
displayLabel: ts.name,
_isDimmed: isDimmed,
groupName,
}
}