# Technical Concept: Grid, Sorting, Inline Editing, Pagination

**As of:** 7 Oct 2026
**Author:** Bolt (frontend rewrite)
**File:** `src/views/PollDetailView.vue` in the new frontend `frontend-v3` (4334 lines, ~223 KB) — not the Vue 2 frontend under `frontend/` in this repo
**Purpose:** Handover to the dev team — architecture documentation of the grid view, sorting logic, inline editing and API-side pagination.

---

## 1. Grid

### 1.1 DOM structure

The grid is a native HTML `<table>` with fixed column widths:

| Constant   | Value | Meaning                                  |
|------------|-------|------------------------------------------|
| `NAME_W`   | 210   | Name column (left, sticky)               |
| `COL_W`    | 160   | Each date/option column                  |
| `ROW_H`    | 44    | Row height (for virtualization)          |
| `OVERSCAN` | 3     | Extra rows/columns beyond the viewport   |

Total width: `totalW = NAME_W + colCount * COL_W` (line 1039).

### 1.2 Virtualization (client-side)

The grid renders only the visible rows and columns. The virtualization is **implemented by hand** — no library.

**Scroll state** (lines 1026–1037):
```
scrollTop, scrollLeft  → current scroll position
vpW, vpH               → viewport size (ResizeObserver)
```

**Visible ranges** (lines 1052–1063):
```
r0 = floor(scrollTop / ROW_H) - OVERSCAN
r1 = ceil((scrollTop + vpH) / ROW_H) + OVERSCAN
c0 = floor(scrollLeft / COL_W) - OVERSCAN
c1 = ceil((scrollLeft + vpW - NAME_W) / COL_W) + OVERSCAN

visRows = filteredRows.slice(r0, r1)
visCols = sortedCols.slice(c0, c1)
```

**Padding cells** stand in for the areas that are not rendered:
- `padT` = `r0 * ROW_H` — top padding row
- `padB` = `(filteredRows.length - r1) * ROW_H` — bottom padding row
- `padL` = `c0 * COL_W` — left padding column
- `padR` = `(colCount - c1) * COL_W` — right padding column

They are rendered as empty `<tr>`/`<th>` elements with the matching height/width (lines 3338–3405).

### 1.3 Row and column data flow

```
apiPoll (raw API data)
  ↓ mapPoll()
poll (typed poll object with .options)
  ↓
allCols = poll.options              (all date columns)
  ↓ showPast filter
cols = allCols.filter(!isPastOpt)  (past dates hidden if future ones exist)
  ↓ colSort
sortedCols                          (sorted by date / most / fewest)
  ↓ virtualization c0..c1
visCols                             (visible columns only)
```

```
apiPoll.participants
  ↓ otherParticipants (without own row, without stale temps)
  ↓ allParticipants (GridRow[] — name, sub, email, added)
  ↓ sortedRows (sorting + linked participants on top)
  ↓ filteredRows (participant search)
  ↓ participantRows (own row on top + sorted others)
  ↓ virtualization r0..r1
visRows (visible rows only)
```

### 1.4 Answer matrix

Answers are held in a two-level map (line 515):
```
answerMap: Map<participant_id, Map<choice_id, AnswerType>>
```

`rebuildAnswerMap()` (line 517) builds it from `apiPoll.participants[].preference_list`.
`ownAnswers` is an extracted reference for the own row.

Answer scales (rule 5 — never mix them):
- `preferences.value` per option: `-2` (maybe), `-1` (none), `0` (no), `1` (yes), `2` (if needed), `3` (waitlist)
- `participants.choice` per participant: guest lists only (type 2)

`RANK` (line 508) defines the sort priority: `yes=0 > ifneeded=1 > maybe=2 > no=3 > none=4`.

### 1.5 Panel width

`panelMaxW` (line 1046) follows the grid:
```
gridW = NAME_W + widthCols * COL_W + 48
panelMaxW = clamp(520, gridW, 1280)
```
With few dates, more of the background image stays visible. The width only grows in grid view mode with `showPast`; list views use `futureCount` instead of `colCount`.

---

## 2. Sorting

### 2.1 Row sorting (participants)

Three modes (`SortMode`, line 501):

| Mode     | Meaning                              | Comparator                                       |
|----------|--------------------------------------|--------------------------------------------------|
| `'az'`   | Alphabetical by name                 | `a.name.localeCompare(b.name, 'de')`             |
| `'added'`| By order of entry                    | `a.added - b.added`                              |
| `'col'`  | By answer in a specific column       | `RANK[answerVal(a,col)] - RANK[answerVal(b,col)]`|

- `sortDir` (1 or -1) reverses the direction. Clicking the same mode again toggles the direction.
- `sortCol` (index into `cols`) selects the column for `'col'`.
- **Linked participants** (same email / same device) are always sorted to the top, regardless of mode (lines 908–913).
- Secondary sort for `'col'`: `localeCompare` as tie-breaker.

Controls:
- `setRowSort(m)` — switch mode or toggle direction (line 1069)
- `sortByCol(idx)` — after clicking a column header (line 1075)
- After a sort change: `resetScrollTop()` sets the scroll position to 0

### 2.2 Column sorting (dates)

Three modes (`ColSort`, line 502):

| Mode    | Meaning                 |
|---------|-------------------------|
| `'date'`| Original order          |
| `'yes'` | Most yes answers first  |
| `'few'` | Fewest yes answers first|

`colYesCount(opt)` counts `yes` + `ifneeded` across all participants + own row (line 988).

Control: `setColSort(s)` (line 1082), with `resetScrollLeft()`.

### 2.3 Participant search

`participantSearch` (line 905) filters `sortedRows` by name and email (case-insensitive, lines 958–965). The search runs **after sorting**, so it does not affect the order, only the visible subset.

---

## 3. Inline editing

### 3.1 Model

**One row at a time** (`openRowId`, line 556). When a row is opened:
1. `draftAnswers` is initialized as a copy of the server answers (`openRow()`, line 588)
2. The row shows stacked buttons instead of icons (`STACK_OPTIONS`, line 570)
3. Each click updates `draftAnswers` via `setDraftAnswer()` (line 657)
4. Save: `submitRow()` → `saveDraftAnswers(pid)` → API
5. Cancel: `cancelRow()` discards `draftAnswers`

### 3.2 Editing paths

| Who edits | Flow |
|-----------|------|
| Own row, not yet submitted (temp) | `onAnswerSubmit()` → `showSubmitModal = true` (name/email dialog) → `confirmSubmit()` |
| Own row, already submitted | `submitRow()` → `saveDraftAnswers(myPid)` → `api.saveAllChoices()` → `loadPoll()` |
| Admin edits another participant's row (row edit) | `submitRow()` → `saveDraftAnswers(pid)` with `adminKey` → API |
| Admin edits a single cell (inline) | `adminSetAnswer(pid, optId, ans)` → rebuilds all choices → API |

### 3.3 Click-outside auto-submit

`onGridClickOutside()` (line 813): a click outside the active row and outside `[data-editor-scope]` submits automatically — as in the mockup. For temp participants the submit modal opens instead.

### 3.4 oneChoicePerParticipant

When `oneChoicePerParticipant` is on and `'yes'` is set, all other `'yes'` answers are set to `'no'` (lines 662–673). This applies only to `draftAnswers`, not to server logic.

### 3.5 Capacity / seat limit logic

- `isOptLockedForUser(opt)` shows "VOLL" (full) when a limit is reached
- `saveChoicesChecked()` (line 565) uses `utils/choiceLimit.ts` to catch HTTP 200 responses carrying `{ error: [...] }` — the backend reports full options with HTTP 200, not with an error status
- After a limit error, `loadPoll()` is called so the row shows what was actually saved

### 3.6 API calls

| Action | Endpoint | Body |
|--------|----------|------|
| Save all answers | `POST /v2/participant/{pid}` | `{ choices: [...], oneChoicePerParticipant, adminKey? }` |
| Create participant (with email) | `POST /invitation` (selfRegistration) | name + email + pattributes |
| Create participant (anonymous, no email) | `POST /poll/{pollId}` | name + preferences + pattributes |
| Single preference (legacy) | `POST /participant/{pid}` | `{ choice_id, value }` |
| Delete participant | `DELETE /participant/{pid}` | — |

---

## 4. Pagination (API-side)

### 4.1 Problem

`GET /api/poll/{id}/{segment}` without parameters returns the full answer matrix. For large polls (many dates × many participants) the server fails with HTTP 500.

### 4.2 Solution: chunked preference loading

Implemented in `src/utils/pagedPoll.ts` and `src/api.ts` (lines 547–567).

**Step 1 — load the shell:**
```
GET /api/poll/{id}/{segment}?paginate=1
```
Returns the poll **without** `preference_list` (null). Participants, choices and attributes are complete.

**Step 2 — load answers in chunks:**
```
GET /api/poll/{id}/{segment}/preferences?choice_start=X&choice_count=N
```
- `PREFERENCE_CHUNK_SIZE = 50` (dates per chunk)
- Chunks are loaded in parallel (`PREFERENCE_CHUNK_PARALLEL` at a time)
- 50 × 500 participants ≈ 25,000 entries in the worst case

**Step 3 — merge:**
`attachPreferences(poll, prefs)` (pagedPoll.ts, line 22) inserts the answers per participant, ordered by date order (not by ID — rule 4: never sort hashids numerically).

**Fallback:** if a chunk fails, the full request without `?paginate=1` is tried. Small polls therefore always work.

### 4.3 No UI pagination

There is **no page-by-page navigation** in the grid. Virtualization (section 1.2) handles any number of rows client-side. All participants are scrolled in a single view. The API pagination exists only to avoid the single large initial load, not to split up the display.

### 4.4 Guest lists (type 2)

Guest lists have no `preference_list` — answers are in `participant.choice`. `?paginate=1` is still loaded, but `attachPreferences(shell, [])` is a no-op (api.ts lines 553–554).

---

## 5. Optimization suggestions

PollDetailView.vue has **4334 lines in one file**. That is the biggest structural problem. Concrete suggestions, ordered by impact:

### 5.1 Split the file (highest priority)

The file contains at least 8 independent views and ~15 modals/panels. Suggested split:

| New component | Contents | Lines (estimate) |
|---------------|----------|------------------|
| `GridView.vue` | Grid table, virtualization, row edit | ~400 |
| `DateView.vue` | Date list (admin + participant) | ~350 |
| `ParticipantView.vue` | Participant panel view | ~200 |
| `GuestView.vue` | Guest list view with start screen | ~400 |
| `InvitePanelContent.vue` | Invitation panel (link + email) | ~300 |
| `SubmitModal.vue` | Name/email dialog after answering | ~80 |
| `LinkResultModal.vue` | "Done!" dialog with personal link | ~50 |
| `DeleteParticipantModal.vue` | Participant delete confirmation | ~50 |
| `EditParticipantModal.vue` | Edit participant (name, email, fields) | ~100 |
| `GridToolbar.vue` | View bar (tabs + showPast toggle) | ~50 |

Remaining in `PollDetailView.vue`: ~1500 lines — data loading, state management, computed properties, routing. Still large, but manageable.

**Important:** the rule modules (rule 2) — pure data functions such as `sortedRows`, `colYesCount`, `isPastOpt` — should move to `src/utils/gridSort.ts` or similar, testable without a component.

### 5.2 Extract composables

| Composable | Contents | Existing tests |
|------------|----------|----------------|
| `useGridVirtualization()` | Scroll state, r0/r1/c0/c1, padding | no |
| `useRowEdit()` | openRowId, draftAnswers, submit/cancel | no |
| `usePollSorting()` | sortMode, sortDir, sortCol, sortedRows/Cols | no |
| `useGridAnswers()` | answerMap, rebuildAnswerMap, getAns | no |
| `useGuestView()` | Guest-specific state (draft, edit, save) | no |

### 5.3 Answer matrix as a pure function

`rebuildAnswerMap()` (line 517) is a pure data function that lives in the component. It should move to `src/utils/answerMap.ts` with its own test. The same goes for `colYesCount`, `rowYesCount`, `answerVal`, `RANK`.

### 5.4 Virtualization: edge-case tests

The hand-written virtualization has no tests. Known edge-case risks:
- `scrollEl` is null at mount (the ResizeObserver only starts after `onMounted`)
- `vpW`/`vpH` start at 800/400 — on very narrow viewports `c1 < c0` is possible
- `OVERSCAN = 3` is small for fast scrolling — can cause brief flicker
- No `requestAnimationFrame` throttling on `onScroll` — can be costly at high refresh rates

### 5.5 Reduce redundant `loadPoll()` calls

Several actions call `loadPoll()` after the API call:
- `setOwnAnswer()` → `saveChoicesChecked()` (with reload) → `loadPoll()` (line 650)
- `adminSetAnswer()` → `saveChoicesChecked()` (with reload) → `loadPoll()` (line 787)
- `submitRow()` → `saveDraftAnswers()` → `loadPoll()` (line 804)

`saveChoicesChecked()` already calls `loadPoll` when `reload=true` (the default). This leads to a **double `loadPoll()`** in `setOwnAnswer` and `adminSetAnswer` — once inside `saveChoicesChecked`, once explicitly afterwards. Recommendation: either pass `reload=false` or remove the explicit call, but not both.

### 5.6 Template simplification

The `<template>` section is ~1500 lines. Several blocks are near-identical copies:
- Own row (lines 3282–3336) and visible rows (lines 3343–3400) share ~90% of their logic
- Admin date view (lines 3471–3586) and participant date view (lines 3589–3699) share ~70%
- The three expanded participant blocks (yes/waitlist/other) are nearly identical three times (lines 3519–3575)

A `GridRow.vue` component for the table row would save ~120 lines of template.

### 5.7 CSS classes vs. inline styles

The file contains ~200 inline `style` bindings using `var(--k-*)`. Rule 12 requires Tailwind classes. Most inline styles could move to `src/index.css` as component classes (`bg-surface`, `text-ink-soft`, etc.). The `designTokens.spec.ts` test has a per-file budget for this — splitting into several files would ease the budget per file.

### 5.8 `PREFERENCE_CHUNK_PARALLEL` is hardcoded

`api.ts` uses `PREFERENCE_CHUNK_PARALLEL`, but it is neither exported as a constant nor configurable. With very many dates (200+), too many parallel requests could strain the server. Recommendation: make it configurable or adaptive (e.g. `min(4, chunks.length)`).

---

## 6. Known backend dependencies

| Issue | Status | Handling in code |
|-------|--------|------------------|
| G6: `PUT /participant/name/{id}` returns 500 | open | Frontend shows an error message; `saveGuestChoice()` as workaround |
| G8: `key` is null with token auth | open | Falls back to `user_id` as legacy admin key (lines 442–456) |
| A0: hiddenPoll still returns other participants | open | `otherParticipants` uses them for the "full" count; would break if the backend changes |
| API pagination `?paginate=1` | works | Implemented in api.ts, tested in `pagedPoll.spec.ts` |

---

## 7. Test coverage

| Area | Test file | Coverage |
|------|-----------|----------|
| Pagination | `pagedPoll.spec.ts` | Chunk building, URL format, fallback |
| Sorting | — | **none** |
| Virtualization | — | **none** |
| Inline editing | — | **none** |
| choiceLimit / full | `choiceLimit.spec.ts` | Limit logic |
| seatLimits | `seatLimits.spec.ts` | `isSeatFull`, `blocksNewYes` |
| mappers | `mappers.spec.ts` | `answerToValue`, `valueToAnswer` |
| Architecture rules | `architecture.spec.ts` | Views must not import `api`/`http` directly |

**Recommendation:** sorting and virtualization are the largest untested areas. Both are pure functions (computeds) and can be tested without a component once they are moved to `src/utils/`.
