refactor(timetable): extract computeStageRowHeight helper; StageHeaderCell takes :row-height-px prop (B2)

Pure structural seam — no layout changes yet (B3 wires the page through).

apps/app/src/lib/timetable/row-height.ts (NEW):
  computeStageRowHeight(laneCount, laneHeightPx, lanePadPx) — one-line pure
  function with the existing math: max(1, laneCount) * (laneHeight + lanePad) + lanePad.
  Math.max(1, laneCount) keeps an empty stage row visible at single-lane
  height instead of collapsing.

apps/app/src/components/timetable/StageRow.vue:
  Switches its inline rowHeightPx computation to call the helper. Behavior
  identical (the math was the helper's body).

apps/app/src/components/timetable/StageHeaderCell.vue:
  New optional `rowHeightPx?: number` prop. When provided (B3 will pass it
  from the page via the same helper), the header root applies blockSize
  inline so the sticky-left column aligns pixel-for-pixel with the row.
  When omitted, the legacy `block-size: 100%` CSS still applies — every
  existing call-site keeps working.

apps/app/src/lib/timetable/index.ts: re-export the new helper.

Tests still green (389 across 54 files); typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 21:49:06 +02:00
parent 006755ac1b
commit 4d2282f546
4 changed files with 37 additions and 2 deletions

View File

@@ -1,22 +1,37 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Stage } from '@/types/timetable'
defineProps<{
const props = defineProps<{
stage: Stage
/** Number of conflicts on this stage row for the active day. */
conflictCount?: number
/**
* Explicit row height in pixels. When provided (page passes it from
* computeStageRowHeight), the header sizes to match the corresponding
* StageRow exactly so the sticky-left column aligns pixel-for-pixel.
* When omitted, the legacy `block-size: 100%` CSS path takes over.
*/
rowHeightPx?: number
}>()
const emit = defineEmits<{
edit: [stage: Stage]
delete: [stage: Stage]
}>()
const heightStyle = computed(() =>
typeof props.rowHeightPx === 'number'
? { blockSize: `${props.rowHeightPx}px` }
: null,
)
</script>
<template>
<div
class="tt-stage-header"
:data-stage-id="stage.id"
:style="heightStyle"
>
<span
class="tt-stage-header__swatch"

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import PerformanceBlock from './PerformanceBlock.vue'
import { computeStageRowHeight } from '@/lib/timetable/row-height'
import { isoToMinutes, minutesToPx } from '@/lib/timetable/time-grid'
import type { Performance, Stage } from '@/types/timetable'
@@ -30,7 +31,7 @@ const emit = defineEmits<{
const laneHeightPx = 44
const lanePadPx = 4
const totalWidthPx = computed(() => props.totalMinutes * props.pxPerMin)
const rowHeightPx = computed(() => Math.max(1, props.laneCount) * (laneHeightPx + lanePadPx) + lanePadPx)
const rowHeightPx = computed(() => computeStageRowHeight(props.laneCount, laneHeightPx, lanePadPx))
interface PositionedBlock {
perf: Performance

View File

@@ -4,3 +4,4 @@ export * from './conflict'
export * from './b2b'
export * from './capacity'
export * from './lane'
export * from './row-height'

View File

@@ -0,0 +1,18 @@
/**
* Per-stage row height in pixels for the timetable canvas.
*
* The `Math.max(1, laneCount)` keeps an empty stage row visible at the
* single-lane height instead of collapsing to zero. Lanes are stacked
* with a small gap between them, plus an outer pad above and below.
*
* Used by both StageRow (to size its own positioned-absolute lane area)
* and StageHeaderCell (so the sticky-left header column aligns with the
* scrolling row content pixel-for-pixel).
*/
export function computeStageRowHeight(
laneCount: number,
laneHeightPx: number,
lanePadPx: number,
): number {
return Math.max(1, laneCount) * (laneHeightPx + lanePadPx) + lanePadPx
}