فهرست منبع

Cell popover: replace per-cell status select with slider + status pills

Per-cell chevron <select> is gone; clicking a task-day input (or the
read-only span for non-admin users) now opens a single body-attached
popover anchored 8 px right of the cell.

Layout:
  ┌─────────────┐
  │   2.5       │  ┌─ ●  Zugewiesen
  │ ──┼──────── │  ├─ ●  Gestartet
  └─────────────┘  ├─ ●  Abgeschlossen
                   └─ ●  Abgebrochen

- Left column: 0 .. assignment_slider_max in 0.5 steps. The bound
  input value is shown above the track; dragging mirrors the new value
  back into input.value and dispatches `change`, so the existing
  400 ms debounced PATCH /sprints/{id}/week-cells path saves and the
  row total / capacity recompute fire unchanged. Slider hidden when
  there is no input to mirror (non-admin path).
- Right column: four status pills with coloured bullets matching the
  Status filter dropdown palette. Active status carries a soft outline.
  Picking a pill is terminal — sets data-status + .assign-status-* on
  the cell, queues the existing PATCH /tasks/{id}/assignments/status
  save, refreshes filters, closes the popover.

Slider max is configurable via /settings → Cell slider max (days):
- New AppSettingsRepository::getInt; new whitelisted key
  assignment_slider_max (1..100, default 10) clamped server-side.
- SettingsController::KEYS shape changes from `key=>label` to
  `key=>[type, label]`; bool keys keep the checkbox path, int keys
  POST a number. Audit semantics unchanged (entity_type='app_setting').
- SprintController::loadSprintPage exposes the value as
  `assignmentSliderMax`; the shared partial stamps it on
  [data-task-section] as data-assignment-slider-max so JS reads it
  without an extra round-trip.

Close behaviour (per requirements):
- Click on a status pill — immediate close (terminal).
- Outside pointerdown (capture phase, 50 ms grace after open).
- Escape.
- Mouse leaves the popover with a 250 ms grace; cancelled if you
  re-enter the popover or hover the bound input.
- Any scroll or resize — close (don't try to follow a moving anchor).

Tests: +1 (getInt round-trip + numeric guard). Strict CSP unchanged
— no inline handlers, no new external hosts, popover element is
script-built and appended once.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa 3 روز پیش
والد
کامیت
10ea4b885f

+ 94 - 27
assets/css/input.css

@@ -78,40 +78,107 @@
     .dark .assign-status-abgebrochen   input[data-assign],
     .dark .assign-status-abgebrochen   > span.font-mono { background-color: theme('colors.red.700'); }
 
-    /* Compact chevron-only <select> sitting inline next to the day
-       input. ≥ 18px wide with a clear bordered button affordance so
-       it's obvious users can click it; selected option text is hidden
-       with text-indent so only the native dropdown arrow shows. */
-    .assign-status-select {
-        display: inline-block;
-        vertical-align: middle;
-        width: 22px;
-        min-width: 22px;
-        height: 22px;
-        margin-left: 4px;
-        padding: 0;
-        text-indent: -9999px;
-        border: 1px solid theme('colors.slate.300');
-        border-radius: 3px;
+    /* Cell popover — replaces the per-cell chevron status select.
+       Body-attached, position: absolute (set in JS), shown to the
+       right of the bound input/cell. Carries a 0..N slider (max
+       configured via /settings → assignment_slider_max) and a
+       column of status pills with coloured bullets. */
+    .cell-popover {
+        position: absolute;
+        z-index: 50;
+        min-width: 18rem;
+        padding: 0.75rem;
+        border-radius: 0.5rem;
+        border: 1px solid theme('colors.slate.200');
         background-color: theme('colors.white');
+        box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
+    }
+    .dark .cell-popover {
+        border-color: theme('colors.slate.700');
+        background-color: theme('colors.slate.800');
+    }
+    .cell-popover-grid {
+        display: grid;
+        grid-template-columns: 1fr auto;
+        gap: 1rem;
+        align-items: center;
+    }
+    .cell-popover-slider {
+        display: flex;
+        flex-direction: column;
+        gap: 0.4rem;
+        min-width: 8rem;
+    }
+    .cell-popover-slider input[type="range"] {
+        width: 100%;
+        accent-color: theme('colors.slate.600');
+        cursor: pointer;
+    }
+    .dark .cell-popover-slider input[type="range"] {
+        accent-color: theme('colors.slate.400');
+    }
+    .cell-popover-value {
+        text-align: center;
+        font-family: theme('fontFamily.mono');
+        font-size: 0.875rem;
+        color: theme('colors.slate.700');
+    }
+    .dark .cell-popover-value {
+        color: theme('colors.slate.200');
+    }
+    .cell-popover-status {
+        display: flex;
+        flex-direction: column;
+        gap: 0.25rem;
+        min-width: 9rem;
+    }
+    .cell-popover-status-btn {
+        display: flex;
+        align-items: center;
+        gap: 0.5rem;
+        padding: 0.25rem 0.5rem;
+        border-radius: 4px;
+        text-align: left;
+        font-size: 0.875rem;
+        color: theme('colors.slate.700');
+        background-color: transparent;
+        border: 1px solid transparent;
         cursor: pointer;
     }
-    .dark .assign-status-select {
+    .dark .cell-popover-status-btn {
+        color: theme('colors.slate.200');
+    }
+    .cell-popover-status-btn:hover {
+        background-color: theme('colors.slate.100');
+    }
+    .dark .cell-popover-status-btn:hover {
+        background-color: theme('colors.slate.700');
+    }
+    .cell-popover-status-btn.cell-popover-active {
+        border-color: theme('colors.slate.300');
+        background-color: theme('colors.slate.50');
+    }
+    .dark .cell-popover-status-btn.cell-popover-active {
         border-color: theme('colors.slate.600');
         background-color: theme('colors.slate.700');
     }
-    .assign-status-select:focus {
-        outline: 2px solid theme('colors.slate.400');
-        outline-offset: 1px;
+    .cell-popover-bullet {
+        display: inline-block;
+        width: 0.75rem;
+        height: 0.75rem;
+        border-radius: 9999px;
+        flex-shrink: 0;
     }
-    .assign-status-select option {
-        text-indent: 0;
-        font-size: 0.875rem;
-        color: theme('colors.slate.900');
-        background-color: theme('colors.white');
+    .cell-popover-bullet.bullet-zugewiesen {
+        border: 1px solid theme('colors.slate.300');
     }
-    .dark .assign-status-select option {
-        color: theme('colors.slate.100');
-        background-color: theme('colors.slate.800');
+    .dark .cell-popover-bullet.bullet-zugewiesen {
+        border-color: theme('colors.slate.600');
     }
+    .cell-popover-bullet.bullet-gestartet     { background-color: theme('colors.yellow.300'); }
+    .cell-popover-bullet.bullet-abgeschlossen { background-color: theme('colors.green.300'); }
+    .cell-popover-bullet.bullet-abgebrochen   { background-color: theme('colors.red.300'); }
+    .dark .cell-popover-bullet.bullet-gestartet     { background-color: theme('colors.yellow.500'); }
+    .dark .cell-popover-bullet.bullet-abgeschlossen { background-color: theme('colors.green.500'); }
+    .dark .cell-popover-bullet.bullet-abgebrochen   { background-color: theme('colors.red.500'); }
 }

+ 209 - 32
public/assets/js/sprint-planner.js

@@ -448,23 +448,6 @@
             inp.setAttribute('data-sw-id', String(sw.id));
             inp.className = 'w-14 rounded border border-slate-200 px-1 py-1 text-center font-mono focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500';
             td.appendChild(inp);
-
-            if (taskStatusEnabled) {
-                const sSel = document.createElement('select');
-                sSel.setAttribute('data-assign-status', '');
-                sSel.setAttribute('data-sw-id', String(sw.id));
-                sSel.setAttribute('aria-label', 'Status');
-                sSel.className = 'assign-status-select';
-                STATUSES.forEach(function (s) {
-                    const opt = document.createElement('option');
-                    opt.value = s;
-                    opt.textContent = s;
-                    if (s === 'zugewiesen') { opt.selected = true; }
-                    sSel.appendChild(opt);
-                });
-                td.appendChild(sSel);
-            }
-
             tr.appendChild(td);
         });
 
@@ -647,23 +630,217 @@
             .catch((e) => flash(e.message, true));
     }
 
-    if (taskStatusEnabled) {
-        on(root, 'change', '[data-assign-status]', function () {
-            const next = String(this.value || '');
+    // --- Cell popover (replaces the chevron status select) --------------
+    //
+    // Single body-attached panel that opens to the right of the bound
+    // input (admin) or read-only span (non-admin) on click. Carries a
+    // 0..max slider for the days value (admin only — slider is hidden
+    // when there's no input to mirror) plus a vertical list of status
+    // pills with coloured bullets. Slider drag mirrors into
+    // input.value + dispatches `change` so the existing
+    // queueAssign / queueStatus / applyFilters pipelines fire
+    // unchanged. Closes on outside-pointerdown, Escape, scroll, resize,
+    // mouseleave-grace (250 ms), and any status pick (terminal).
+
+    const sliderMax = (function () {
+        const raw = taskSection ? taskSection.getAttribute('data-assignment-slider-max') : '10';
+        const v = parseInt(raw, 10);
+        if (!Number.isFinite(v) || v < 1) { return 10; }
+        return Math.min(100, v);
+    })();
+    const STATUS_LABELS = {
+        zugewiesen:    'Zugewiesen',
+        gestartet:     'Gestartet',
+        abgeschlossen: 'Abgeschlossen',
+        abgebrochen:   'Abgebrochen',
+    };
+
+    let cellPopover      = null;
+    let cellPopoverInput = null;   // bound <input data-assign> or null (non-admin)
+    let cellPopoverCell  = null;   // bound <td data-assign-cell>
+    let cellPopoverGrace = null;
+    let cellPopoverOpenAt = 0;
+
+    function cancelCellPopoverGrace() {
+        if (cellPopoverGrace) { clearTimeout(cellPopoverGrace); cellPopoverGrace = null; }
+    }
+    function scheduleCellPopoverGrace() {
+        cancelCellPopoverGrace();
+        cellPopoverGrace = setTimeout(closeCellPopover, 250);
+    }
+
+    function buildCellPopover() {
+        if (cellPopover) { return cellPopover; }
+        const r = document.createElement('div');
+        r.className = 'cell-popover hidden';
+        r.setAttribute('role', 'dialog');
+        r.setAttribute('aria-label', 'Edit cell');
+
+        const grid = document.createElement('div');
+        grid.className = 'cell-popover-grid';
+
+        const sliderWrap = document.createElement('div');
+        sliderWrap.className = 'cell-popover-slider';
+        const valEl = document.createElement('span');
+        valEl.className = 'cell-popover-value';
+        valEl.textContent = '0';
+        const slider = document.createElement('input');
+        slider.type = 'range';
+        slider.min  = '0';
+        slider.max  = String(sliderMax);
+        slider.step = '0.5';
+        slider.value = '0';
+        slider.setAttribute('aria-label', 'Days');
+        sliderWrap.appendChild(valEl);
+        sliderWrap.appendChild(slider);
+        grid.appendChild(sliderWrap);
+
+        const statusList = document.createElement('div');
+        statusList.className = 'cell-popover-status';
+        STATUSES.forEach(function (s) {
+            const btn = document.createElement('button');
+            btn.type = 'button';
+            btn.className = 'cell-popover-status-btn';
+            btn.setAttribute('data-status-pick', s);
+            const bullet = document.createElement('span');
+            bullet.className = 'cell-popover-bullet bullet-' + s;
+            const lbl = document.createElement('span');
+            lbl.textContent = STATUS_LABELS[s] || s;
+            btn.appendChild(bullet);
+            btn.appendChild(lbl);
+            statusList.appendChild(btn);
+        });
+        grid.appendChild(statusList);
+        r.appendChild(grid);
+        document.body.appendChild(r);
+
+        // Slider → mirror into bound input.value + dispatch change so the
+        // existing 400 ms days-save pipeline fires.
+        slider.addEventListener('input', function () {
+            const v = Number(slider.value);
+            valEl.textContent = fmtDays(v);
+            if (!cellPopoverInput) { return; }
+            cellPopoverInput.value = fmtDays(v);
+            cellPopoverInput.dispatchEvent(new Event('change', { bubbles: true }));
+        });
+
+        // Status pick → set status, save, close (terminal action).
+        statusList.addEventListener('click', function (ev) {
+            const btn = ev.target.closest('[data-status-pick]');
+            if (!btn || !cellPopoverCell) { return; }
+            const next = btn.getAttribute('data-status-pick');
             if (STATUSES.indexOf(next) === -1) { return; }
-            const cell = this.closest('[data-assign-cell]');
-            if (!cell) { return; }
-            const tr = this.closest('tr');
-            const taskId = parseInt(tr.getAttribute('data-task-id'), 10);
-            const swId   = parseInt(this.getAttribute('data-sw-id'), 10);
-            if (!Number.isFinite(taskId) || !Number.isFinite(swId)) { return; }
-
-            STATUSES.forEach((s) => cell.classList.remove('assign-status-' + s));
-            cell.classList.add('assign-status-' + next);
-            cell.setAttribute('data-status', next);
-
-            queueStatus(taskId, swId, next);
+            STATUSES.forEach(function (s) { cellPopoverCell.classList.remove('assign-status-' + s); });
+            cellPopoverCell.classList.add('assign-status-' + next);
+            cellPopoverCell.setAttribute('data-status', next);
+            const tr = cellPopoverCell.closest('tr');
+            const taskId = parseInt(tr ? tr.getAttribute('data-task-id') : 'NaN', 10);
+            const swId   = parseInt(cellPopoverCell.getAttribute('data-sw-id') || '', 10);
+            if (Number.isFinite(taskId) && Number.isFinite(swId)) {
+                queueStatus(taskId, swId, next);
+            }
             applyFilters();
+            closeCellPopover();
+        });
+
+        r.addEventListener('mouseenter', cancelCellPopoverGrace);
+        r.addEventListener('mouseleave', scheduleCellPopoverGrace);
+
+        cellPopover = r;
+        return r;
+    }
+
+    function positionCellPopover() {
+        if (!cellPopover || !cellPopoverCell) { return; }
+        const anchor = cellPopoverInput
+            || cellPopoverCell.querySelector('[data-assign-readonly]')
+            || cellPopoverCell;
+        const rect = anchor.getBoundingClientRect();
+        const ph = cellPopover.offsetHeight;
+        const pw = cellPopover.offsetWidth;
+        let top  = window.scrollY + rect.top + rect.height / 2 - ph / 2;
+        let left = window.scrollX + rect.right + 8;
+        const vw = document.documentElement.clientWidth;
+        if (left + pw > window.scrollX + vw - 8) {
+            left = window.scrollX + rect.left - pw - 8;
+        }
+        if (top < window.scrollY + 8) { top = window.scrollY + 8; }
+        cellPopover.style.top  = top + 'px';
+        cellPopover.style.left = left + 'px';
+    }
+
+    function openCellPopover(cell) {
+        if (!taskStatusEnabled || !cell) { return; }
+        buildCellPopover();
+        cellPopoverCell  = cell;
+        cellPopoverInput = cell.querySelector('input[data-assign]');
+
+        const slider = cellPopover.querySelector('input[type="range"]');
+        const valEl  = cellPopover.querySelector('.cell-popover-value');
+        let cur = 0;
+        if (cellPopoverInput) {
+            cur = Number(cellPopoverInput.value) || 0;
+        } else {
+            const ro = cell.querySelector('[data-assign-readonly]');
+            cur = ro ? (Number(ro.textContent) || 0) : 0;
+        }
+        valEl.textContent = fmtDays(cur);
+        slider.value = String(Math.min(sliderMax, Math.max(0, cur)));
+
+        // Slider only meaningful when an input exists (admin path).
+        const showSlider = cellPopoverInput !== null;
+        slider.disabled = !showSlider;
+        slider.parentElement.style.display = showSlider ? '' : 'none';
+
+        const cs = String(cell.getAttribute('data-status') || 'zugewiesen');
+        cellPopover.querySelectorAll('[data-status-pick]').forEach(function (b) {
+            b.classList.toggle('cell-popover-active',
+                b.getAttribute('data-status-pick') === cs);
+        });
+
+        cancelCellPopoverGrace();
+        cellPopover.classList.remove('hidden');
+        positionCellPopover();
+        cellPopoverOpenAt = Date.now();
+    }
+
+    function closeCellPopover() {
+        cancelCellPopoverGrace();
+        if (cellPopover) { cellPopover.classList.add('hidden'); }
+        cellPopoverInput = null;
+        cellPopoverCell  = null;
+    }
+
+    if (taskStatusEnabled) {
+        on(root, 'click',
+            '[data-assign-cell] input[data-assign], [data-assign-cell] [data-assign-readonly]',
+            function () {
+                const cell = this.closest('[data-assign-cell]');
+                if (cell) { openCellPopover(cell); }
+            });
+
+        // Outside-pointerdown closes (capture so a downstream stopPropagation
+        // can't strand the popup). 50 ms grace skips the opening click itself.
+        document.addEventListener('pointerdown', function (ev) {
+            if (!cellPopover || cellPopover.classList.contains('hidden')) { return; }
+            if (Date.now() - cellPopoverOpenAt < 50) { return; }
+            if (cellPopover.contains(ev.target)) { return; }
+            if (cellPopoverCell && cellPopoverCell.contains(ev.target)) { return; }
+            closeCellPopover();
+        }, true);
+
+        document.addEventListener('keydown', function (ev) {
+            if (ev.key === 'Escape' && cellPopover && !cellPopover.classList.contains('hidden')) {
+                closeCellPopover();
+            }
+        });
+
+        // Any scroll / resize closes — don't try to follow a moving anchor.
+        window.addEventListener('scroll', function () {
+            if (cellPopover && !cellPopover.classList.contains('hidden')) { closeCellPopover(); }
+        }, true);
+        window.addEventListener('resize', function () {
+            if (cellPopover && !cellPopover.classList.contains('hidden')) { closeCellPopover(); }
         });
     }
 

+ 39 - 11
src/Controllers/SettingsController.php

@@ -18,8 +18,13 @@ use Throwable;
  * /settings — admin-only global configuration.
  *
  * Holds keys with app-wide effect. Currently:
- *   task_status_enabled — toggles Phase 18 per-cell status selectors +
- *                         filter on /sprints/{id} and /sprints/{id}/present.
+ *   task_status_enabled    bool  — toggles Phase 18 per-cell status
+ *                                  selectors + filter on /sprints/{id} +
+ *                                  /sprints/{id}/present.
+ *   assignment_slider_max  int   — upper bound for the per-cell popover
+ *                                  slider (1..100). Default 10. The day
+ *                                  input itself stays unbounded; only the
+ *                                  slider is capped.
  *
  * Updates POST a form (CSRF via _csrf input, like /auth/logout) and audit
  * with entity_type='app_setting', entity_id=null. Pure admin surface; no
@@ -27,11 +32,16 @@ use Throwable;
  */
 final class SettingsController
 {
-    /** Whitelisted keys + their human descriptions. */
+    /** Whitelisted keys + their type ('bool'|'int') + label. */
     public const KEYS = [
-        'task_status_enabled' => 'Task status colors',
+        'task_status_enabled'   => ['bool', 'Task status colors'],
+        'assignment_slider_max' => ['int',  'Cell slider max (days)'],
     ];
 
+    public const ASSIGNMENT_SLIDER_MAX_DEFAULT = 10;
+    public const ASSIGNMENT_SLIDER_MAX_FLOOR   = 1;
+    public const ASSIGNMENT_SLIDER_MAX_CEIL    = 100;
+
     public function __construct(
         private readonly PDO                   $pdo,
         private readonly UserRepository        $users,
@@ -50,8 +60,15 @@ final class SettingsController
         }
 
         $values = [];
-        foreach (array_keys(self::KEYS) as $k) {
-            $values[$k] = $this->settings->getBool($k, false);
+        foreach (self::KEYS as $key => [$type]) {
+            if ($type === 'bool') {
+                $values[$key] = $this->settings->getBool($key, false);
+            } else { // int
+                $values[$key] = $this->settings->getInt(
+                    $key,
+                    self::ASSIGNMENT_SLIDER_MAX_DEFAULT,
+                );
+            }
         }
 
         return Response::html($this->view->render('settings/index', [
@@ -59,7 +76,7 @@ final class SettingsController
             'currentUser' => $actor,
             'csrfToken'   => SessionGuard::csrfToken(),
             'values'      => $values,
-            'keyLabels'   => self::KEYS,
+            'keyLabels'   => array_map(fn(array $meta) => $meta[1], self::KEYS),
             'flash'       => $req->queryString('updated') !== '' ? 'Saved' : null,
         ]));
     }
@@ -79,11 +96,22 @@ final class SettingsController
 
         $this->pdo->beginTransaction();
         try {
-            foreach (array_keys(self::KEYS) as $key) {
-                // Checkboxes that are unchecked don't post a value at all,
-                // so absence == off.
+            foreach (self::KEYS as $key => [$type]) {
                 $raw = isset($req->post[$key]) ? (string) $req->post[$key] : '';
-                $next = in_array($raw, ['1', 'on', 'true'], true) ? '1' : '0';
+
+                if ($type === 'bool') {
+                    $next = in_array($raw, ['1', 'on', 'true'], true) ? '1' : '0';
+                } else { // int — clamp into [floor, ceil]
+                    $n = is_numeric($raw)
+                        ? (int) $raw
+                        : self::ASSIGNMENT_SLIDER_MAX_DEFAULT;
+                    if ($n < self::ASSIGNMENT_SLIDER_MAX_FLOOR) {
+                        $n = self::ASSIGNMENT_SLIDER_MAX_FLOOR;
+                    } elseif ($n > self::ASSIGNMENT_SLIDER_MAX_CEIL) {
+                        $n = self::ASSIGNMENT_SLIDER_MAX_CEIL;
+                    }
+                    $next = (string) $n;
+                }
 
                 $result = $this->settings->set($key, $next);
                 if ($result['before'] === $result['after']) {

+ 6 - 1
src/Controllers/SprintController.php

@@ -219,6 +219,7 @@ final class SprintController
      *   statusGrid: array<int, array<int, string>>,
      *   ownerChoices: list<\App\Domain\Worker>,
      *   taskStatusEnabled: bool,
+     *   assignmentSliderMax: int,
      * }|null
      */
     private function loadSprintPage(int $id): ?array
@@ -264,7 +265,11 @@ final class SprintController
             'taskGrid'          => $taskGrid,
             'statusGrid'        => $statusGrid,
             'ownerChoices'      => $ownerChoices,
-            'taskStatusEnabled' => $this->appSettings->getBool('task_status_enabled', false),
+            'taskStatusEnabled'   => $this->appSettings->getBool('task_status_enabled', false),
+            'assignmentSliderMax' => max(
+                1,
+                min(100, $this->appSettings->getInt('assignment_slider_max', 10)),
+            ),
         ];
     }
 

+ 9 - 0
src/Repositories/AppSettingsRepository.php

@@ -41,6 +41,15 @@ final class AppSettingsRepository
         return $v === '1' || strtolower($v) === 'true';
     }
 
+    public function getInt(string $key, int $default = 0): int
+    {
+        $v = $this->get($key);
+        if ($v === null || !is_numeric($v)) {
+            return $default;
+        }
+        return (int) $v;
+    }
+
     /** Returns ['before' => ?string, 'after' => string]. */
     public function set(string $key, string $value): array
     {

+ 14 - 0
tests/Repositories/AppSettingsRepositoryTest.php

@@ -64,4 +64,18 @@ final class AppSettingsRepositoryTest extends TestCase
         $this->assertSame('1', $r['after']);
         $this->assertSame('1', $repo->get('new_flag'));
     }
+
+    public function testGetIntReadsNumericStringsAndFallsBackOnMissing(): void
+    {
+        $pdo  = $this->makeDb();
+        $repo = new AppSettingsRepository($pdo);
+
+        $this->assertSame(10, $repo->getInt('assignment_slider_max', 10));
+
+        $repo->set('assignment_slider_max', '20');
+        $this->assertSame(20, $repo->getInt('assignment_slider_max', 10));
+
+        $repo->set('garbage_value', 'not-a-number');
+        $this->assertSame(7, $repo->getInt('garbage_value', 7));
+    }
 }

+ 16 - 0
views/settings/index.twig

@@ -43,6 +43,22 @@
             </span>
         </label>
 
+        <label class="flex items-start gap-3">
+            <input type="number" name="assignment_slider_max"
+                   min="1" max="100" step="1"
+                   value="{{ values.assignment_slider_max|default(10) }}"
+                   class="mt-1 w-20 rounded border-slate-300 px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
+            <span>
+                <span class="font-medium">{{ keyLabels.assignment_slider_max|default('Cell slider max (days)') }}</span>
+                <span class="block text-xs text-slate-500 mt-0.5 dark:text-slate-400">
+                    Upper bound for the slider in the per-cell popover that opens
+                    when you click a task-day input. Values 1–100. The day input
+                    itself stays unbounded — typing &gt; max is still allowed —
+                    but dragging the slider clamps to this max. Default 10.
+                </span>
+            </span>
+        </label>
+
         <div>
             <button type="submit"
                     class="rounded-md bg-slate-900 text-white px-3 py-2 text-sm font-medium hover:bg-slate-800 dark:bg-slate-700 dark:hover:bg-slate-600">

+ 5 - 12
views/sprints/_task_list.twig

@@ -6,7 +6,8 @@
 
 <section class="rounded-lg border bg-white {% if isBeamer|default(false) %}m-2 {% endif %}dark:bg-slate-800 dark:border-slate-700"
          data-task-section
-         data-task-status-enabled="{{ taskStatusEnabled ? '1' : '0' }}">
+         data-task-status-enabled="{{ taskStatusEnabled ? '1' : '0' }}"
+         data-assignment-slider-max="{{ assignmentSliderMax|default(10) }}">
     <div class="rounded-t-lg px-4 py-3 border-b bg-slate-50 flex flex-wrap items-center gap-2 dark:bg-slate-700 dark:border-slate-700">
         <h2 class="text-sm font-semibold text-slate-700 uppercase tracking-wider dark:text-slate-200">Tasks</h2>
 
@@ -243,17 +244,9 @@
                                                data-sw-id="{{ sw.id }}"
                                                class="w-14 rounded border border-slate-200 px-1 py-1 text-center font-mono focus:outline-none focus:ring-2 focus:ring-slate-400 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-100 dark:focus:ring-slate-500">
                                     {% else %}
-                                        <span class="font-mono">{{ fmt_days(d) }}</span>
-                                    {% endif %}
-                                    {% if taskStatusEnabled %}
-                                        <select data-assign-status
-                                                data-sw-id="{{ sw.id }}"
-                                                aria-label="Status"
-                                                class="assign-status-select">
-                                            {% for opt in TaskAssignment_STATUSES %}
-                                                <option value="{{ opt }}" {{ opt == st ? 'selected' : '' }}>{{ opt }}</option>
-                                            {% endfor %}
-                                        </select>
+                                        <span class="font-mono inline-block min-w-[2rem]{% if taskStatusEnabled %} cursor-pointer{% endif %}"
+                                              data-assign-readonly
+                                              data-sw-id="{{ sw.id }}">{{ fmt_days(d) }}</span>
                                     {% endif %}
                                 </td>
                             {% endfor %}