restrict drag-and-drop to ready and icebox columns only
Also add migration safety rules to AGENTS.md: never modify existing
migrations, and never lose data when writing new ones.
change vpkrunszvsoososzkzopruqsrqmksxty
commit d60f0ed8cf7442a6dc84815f7df27b5590c1a136
author Alpha Chen <alpha@kejadlen.dev>
date
parent knmsyyoq
diff --git a/AGENTS.md b/AGENTS.md
index cc4e24c..63c2d60 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -66,6 +66,8 @@ The integration test (`tests/cli.rs`) exercises the full workflow via the compil
 - The `xdg` crate resolves `$XDG_DATA_HOME/ranger/ranger.db`. Override with `RANGER_DB` env var or `--db` flag.
 - Backlogs are identified by name, not key. `RANGER_DEFAULT_BACKLOG` sets the default for `--backlog` flags.
 - Migration uses `CREATE TABLE IF NOT EXISTS` so it's idempotent (safe to run on every connect).
+- **Never modify existing migrations.** They have already been run against real databases. Schema changes go in new migration files only.
+- **Migrations must not lose data.** When recreating a table, always `INSERT INTO ... SELECT` all rows from the original, including data in join tables (e.g. `task_tags`). Test migrations against a database with real data, not just empty schemas.
 - SQLite doesn't support `ALTER TABLE DROP COLUMN` with foreign keys cleanly. When recreating a table, wrap in `PRAGMA foreign_keys = OFF/ON` to prevent `ON DELETE CASCADE` from wiping join tables (e.g. `task_tags`).
 
 ## VCS
diff --git a/src/bin/ranger/commands/serve.rs b/src/bin/ranger/commands/serve.rs
index 0edce19..98b5e6b 100644
--- a/src/bin/ranger/commands/serve.rs
+++ b/src/bin/ranger/commands/serve.rs
@@ -301,8 +301,8 @@ async fn render_board(
                 }
                 div.board {
                     (render_backlog_panel(&in_progress, &ready))
-                    (render_column_panel("Icebox", "state-icebox", "icebox", &icebox))
-                    (render_column_panel("Done", "state-done", "done", &done))
+                    (render_column_panel("Icebox", "state-icebox", Some("icebox"), &icebox))
+                    (render_column_panel("Done", "state-done", None, &done))
                 }
                 (keyboard_nav_script())
             }
@@ -318,9 +318,11 @@ fn render_backlog_panel(in_progress: &[TaskView], ready: &[TaskView]) -> Markup
                 h2 { "Backlog" }
                 span.count { (count) }
             }
-            div.state-in-progress.drop-zone data-state="in_progress" {
-                @for task in in_progress {
-                    (render_task(task))
+            @if !in_progress.is_empty() {
+                div.state-in-progress {
+                    @for task in in_progress {
+                        (render_task(task))
+                    }
                 }
             }
             div.state-ready.drop-zone data-state="ready" {
@@ -335,17 +337,21 @@ fn render_backlog_panel(in_progress: &[TaskView], ready: &[TaskView]) -> Markup
 fn render_column_panel(
     label: &str,
     state_class: &str,
-    state_value: &str,
+    drop_state: Option<&str>,
     tasks: &[TaskView],
 ) -> Markup {
     let count = tasks.len();
+    let classes = match drop_state {
+        Some(_) => format!("{state_class} drop-zone"),
+        None => state_class.to_string(),
+    };
     html! {
         div.panel {
             div.panel-header {
                 h2 { (label) }
                 span.count { (count) }
             }
-            div class=(format!("{state_class} drop-zone")) data-state=(state_value) {
+            div class=(classes) data-state=[drop_state] {
                 @if tasks.is_empty() {
                     div.empty { "No " (label.to_lowercase()) " tasks" }
                 } @else {
@@ -385,7 +391,7 @@ fn render_task(task: &TaskView) -> Markup {
                 }
             }
         } @else {
-            div.task draggable="true" data-key=(task.key) tabindex="0" {
+            div.task data-key=(task.key) tabindex="0" {
                 div.task-header {
                     span.key {
                         span.key-prefix { (task.key_prefix) }
@@ -446,108 +452,90 @@ fn keyboard_nav_script() -> Markup {
                     }
                 });
 
-                // === Drag and drop ===
+                // === Drag and drop (ready + icebox only) ===
+                var DRAGGABLE_STATES = ['ready', 'icebox'];
+                document.querySelectorAll('.drop-zone').forEach(function(z) {
+                    if (DRAGGABLE_STATES.indexOf(z.dataset.state) !== -1) {
+                        z.querySelectorAll('[data-key]').forEach(function(t) { t.draggable = true; });
+                    }
+                });
                 var draggedKey = null;
                 var draggedEl = null;
 
-                function getTaskEl(el) {
-                    return el.closest('[data-key]');
-                }
-
-                function getDropZone(el) {
-                    return el.closest('.drop-zone');
+                function getTaskEl(el) { return el.closest('[data-key]'); }
+                function getDropZone(el) { return el.closest('.drop-zone'); }
+                function isDraggableZone(zone) {
+                    return zone && DRAGGABLE_STATES.indexOf(zone.dataset.state) !== -1;
                 }
 
                 document.addEventListener('dragstart', function(e) {
                     var task = getTaskEl(e.target);
                     if (!task) return;
+                    var zone = getDropZone(task);
+                    if (!isDraggableZone(zone)) { e.preventDefault(); return; }
                     draggedKey = task.dataset.key;
                     draggedEl = task;
                     task.classList.add('dragging');
+                    document.querySelectorAll('.drop-zone').forEach(function(z) {
+                        if (isDraggableZone(z)) z.classList.add('drag-active');
+                    });
                     e.dataTransfer.effectAllowed = 'move';
                     e.dataTransfer.setData('text/plain', draggedKey);
                 });
 
-                document.addEventListener('dragend', function(e) {
+                document.addEventListener('dragend', function() {
                     if (draggedEl) draggedEl.classList.remove('dragging');
                     document.querySelectorAll('.drop-indicator').forEach(function(el) { el.remove(); });
                     document.querySelectorAll('.drop-zone-active').forEach(function(el) { el.classList.remove('drop-zone-active'); });
+                    document.querySelectorAll('.drag-active').forEach(function(el) { el.classList.remove('drag-active'); });
                     draggedKey = null;
                     draggedEl = null;
                 });
 
                 document.addEventListener('dragover', function(e) {
                     var zone = getDropZone(e.target);
-                    if (!zone) return;
+                    if (!isDraggableZone(zone) || !draggedKey) return;
                     e.preventDefault();
                     e.dataTransfer.dropEffect = 'move';
 
-                    // Clear previous indicators
                     document.querySelectorAll('.drop-indicator').forEach(function(el) { el.remove(); });
                     document.querySelectorAll('.drop-zone-active').forEach(function(el) { el.classList.remove('drop-zone-active'); });
 
                     var tasks = Array.from(zone.querySelectorAll('[data-key]'));
-                    if (tasks.length === 0) {
-                        zone.classList.add('drop-zone-active');
-                        return;
-                    }
+                    if (tasks.length === 0) { zone.classList.add('drop-zone-active'); return; }
 
-                    // Find insertion point
-                    var closestTask = null;
-                    var insertBefore = true;
-                    var minDist = Infinity;
+                    var closestTask = null, insertBefore = true, minDist = Infinity;
                     for (var i = 0; i < tasks.length; i++) {
                         var rect = tasks[i].getBoundingClientRect();
                         var midY = rect.top + rect.height / 2;
                         var dist = Math.abs(e.clientY - midY);
-                        if (dist < minDist) {
-                            minDist = dist;
-                            closestTask = tasks[i];
-                            insertBefore = e.clientY < midY;
-                        }
+                        if (dist < minDist) { minDist = dist; closestTask = tasks[i]; insertBefore = e.clientY < midY; }
                     }
-
                     if (closestTask) {
                         var indicator = document.createElement('div');
                         indicator.className = 'drop-indicator';
-                        if (insertBefore) {
-                            closestTask.parentNode.insertBefore(indicator, closestTask);
-                        } else {
-                            closestTask.parentNode.insertBefore(indicator, closestTask.nextSibling);
-                        }
+                        closestTask.parentNode.insertBefore(indicator, insertBefore ? closestTask : closestTask.nextSibling);
                     }
                 });
 
                 document.addEventListener('drop', function(e) {
                     e.preventDefault();
                     var zone = getDropZone(e.target);
-                    if (!zone || !draggedKey) return;
+                    if (!isDraggableZone(zone) || !draggedKey) return;
 
                     var targetState = zone.dataset.state;
                     var tasks = Array.from(zone.querySelectorAll('[data-key]'))
                         .filter(function(t) { return t.dataset.key !== draggedKey; });
 
-                    // Find drop position
-                    var beforeKey = null;
-                    var afterKey = null;
-
-                    if (tasks.length === 0) {
-                        // Empty zone — just change state
-                    } else {
-                        var closestTask = null;
-                        var insertBefore = true;
-                        var minDist = Infinity;
+                    var beforeKey = null, afterKey = null;
+                    if (tasks.length > 0) {
+                        var closestTask = null, insertBefore = true, minDist = Infinity;
                         for (var i = 0; i < tasks.length; i++) {
                             var rect = tasks[i].getBoundingClientRect();
                             var midY = rect.top + rect.height / 2;
                             var dist = Math.abs(e.clientY - midY);
-                            if (dist < minDist) {
-                                minDist = dist;
-                                closestTask = tasks[i];
-                                insertBefore = e.clientY < midY;
-                            }
+                            if (dist < minDist) { minDist = dist; closestTask = tasks[i]; insertBefore = e.clientY < midY; }
                         }
-
                         if (closestTask) {
                             var idx = tasks.indexOf(closestTask);
                             if (insertBefore) {
@@ -561,13 +549,9 @@ fn keyboard_nav_script() -> Markup {
                     }
 
                     var body = {};
-                    // Determine current state of dragged element
                     var draggedZone = draggedEl ? getDropZone(draggedEl) : null;
                     var currentState = draggedZone ? draggedZone.dataset.state : null;
-
-                    if (targetState !== currentState) {
-                        body.state = targetState;
-                    }
+                    if (targetState !== currentState) body.state = targetState;
                     if (beforeKey) body.before = beforeKey;
                     if (afterKey) body.after = afterKey;
 
@@ -576,11 +560,8 @@ fn keyboard_nav_script() -> Markup {
                         headers: { 'Content-Type': 'application/json' },
                         body: JSON.stringify(body)
                     }).then(function(res) {
-                        if (res.ok) {
-                            window.location.reload();
-                        } else {
-                            res.text().then(function(t) { console.error('Move failed:', t); });
-                        }
+                        if (res.ok) window.location.reload();
+                        else res.text().then(function(t) { console.error('Move failed:', t); });
                     });
                 });
             })();
diff --git a/static/style.css b/static/style.css
index d30c1b1..f2998d1 100644
--- a/static/style.css
+++ b/static/style.css
@@ -192,6 +192,12 @@ header .counts {
   overflow-y: auto;
   background: rgba(255, 255, 255, 0.05);
   border-radius: var(--radius);
+  display: flex;
+  flex-direction: column;
+}
+
+.panel > .drop-zone {
+  flex: 1;
 }
 
 .panel-header {
@@ -424,10 +430,13 @@ details.task[open] > summary .expand-icon {
 }
 
 .drop-zone {
-  min-height: 40px;
   transition: background 0.15s ease;
 }
 
+.drop-zone.drag-active {
+  min-height: 40px;
+}
+
 .drop-zone-active {
   background: rgba(106, 159, 216, 0.1);
   border-radius: var(--radius);