Browse Source

Show original positions of swimlanes, lists and cards.

Thanks to xet7 !

Fixes #5939
Lauri Ojansivu 5 days ago
parent
commit
2543df9425

+ 263 - 0
client/components/boards/originalPositionsView.css

@@ -0,0 +1,263 @@
+/* Original Positions View Styles */
+.original-positions-view {
+  margin: 10px 0;
+  padding: 15px;
+  background-color: #f8f9fa;
+  border: 1px solid #e9ecef;
+  border-radius: 6px;
+}
+
+.original-positions-header {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  margin-bottom: 15px;
+}
+
+.original-positions-header .btn {
+  display: flex;
+  align-items: center;
+  gap: 5px;
+}
+
+.original-positions-content {
+  background-color: white;
+  border: 1px solid #dee2e6;
+  border-radius: 4px;
+  padding: 15px;
+}
+
+.original-positions-loading {
+  text-align: center;
+  padding: 20px;
+  color: #6c757d;
+  font-style: italic;
+}
+
+.original-positions-loading i {
+  margin-right: 8px;
+}
+
+.original-positions-filters {
+  margin-bottom: 20px;
+  padding-bottom: 15px;
+  border-bottom: 1px solid #dee2e6;
+}
+
+.original-positions-filters .btn-group {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 5px;
+}
+
+.original-positions-filters .btn {
+  display: flex;
+  align-items: center;
+  gap: 5px;
+  white-space: nowrap;
+}
+
+.original-positions-list {
+  max-height: 400px;
+  overflow-y: auto;
+}
+
+.original-position-item {
+  background-color: #f8f9fa;
+  border: 1px solid #e9ecef;
+  border-radius: 4px;
+  margin-bottom: 10px;
+  padding: 12px;
+  transition: all 0.2s ease;
+}
+
+.original-position-item:hover {
+  background-color: #e9ecef;
+  border-color: #ced4da;
+}
+
+.original-position-item:last-child {
+  margin-bottom: 0;
+}
+
+.original-position-item-header {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 8px;
+  font-weight: 600;
+  color: #495057;
+}
+
+.original-position-item-header i {
+  color: #6c757d;
+  width: 16px;
+  text-align: center;
+}
+
+.entity-type {
+  background-color: #007bff;
+  color: white;
+  padding: 2px 6px;
+  border-radius: 3px;
+  font-size: 11px;
+  font-weight: 500;
+  text-transform: uppercase;
+}
+
+.entity-name {
+  color: #212529;
+  font-weight: 600;
+}
+
+.entity-id {
+  color: #6c757d;
+  font-size: 11px;
+  font-family: monospace;
+}
+
+.original-position-item-details {
+  margin-left: 24px;
+}
+
+.original-position-description {
+  color: #495057;
+  margin-bottom: 6px;
+  font-size: 13px;
+}
+
+.original-title {
+  color: #6c757d;
+  font-size: 12px;
+  margin-bottom: 6px;
+  padding: 4px 6px;
+  background-color: #e9ecef;
+  border-radius: 3px;
+}
+
+.original-title strong {
+  color: #495057;
+}
+
+.original-position-date {
+  color: #6c757d;
+  font-size: 11px;
+}
+
+.no-original-positions {
+  text-align: center;
+  padding: 40px 20px;
+  color: #6c757d;
+  font-style: italic;
+}
+
+.no-original-positions i {
+  font-size: 24px;
+  margin-bottom: 10px;
+  display: block;
+  color: #adb5bd;
+}
+
+/* Responsive adjustments */
+@media (max-width: 768px) {
+  .original-positions-view {
+    margin: 5px 0;
+    padding: 10px;
+  }
+  
+  .original-positions-header {
+    flex-direction: column;
+    align-items: stretch;
+    gap: 8px;
+  }
+  
+  .original-positions-header .btn {
+    justify-content: center;
+  }
+  
+  .original-positions-filters .btn-group {
+    justify-content: center;
+  }
+  
+  .original-position-item-header {
+    flex-wrap: wrap;
+    gap: 6px;
+  }
+  
+  .entity-name {
+    flex: 1;
+    min-width: 0;
+    word-break: break-word;
+  }
+  
+  .original-position-item-details {
+    margin-left: 0;
+    margin-top: 8px;
+  }
+}
+
+/* Dark theme support */
+@media (prefers-color-scheme: dark) {
+  .original-positions-view {
+    background-color: #2d3748;
+    border-color: #4a5568;
+    color: #e2e8f0;
+  }
+  
+  .original-positions-content {
+    background-color: #1a202c;
+    border-color: #4a5568;
+  }
+  
+  .original-position-item {
+    background-color: #2d3748;
+    border-color: #4a5568;
+    color: #e2e8f0;
+  }
+  
+  .original-position-item:hover {
+    background-color: #4a5568;
+    border-color: #718096;
+  }
+  
+  .original-position-item-header {
+    color: #e2e8f0;
+  }
+  
+  .original-position-item-header i {
+    color: #a0aec0;
+  }
+  
+  .entity-name {
+    color: #e2e8f0;
+  }
+  
+  .entity-id {
+    color: #a0aec0;
+  }
+  
+  .original-position-description {
+    color: #e2e8f0;
+  }
+  
+  .original-title {
+    background-color: #4a5568;
+    color: #a0aec0;
+  }
+  
+  .original-title strong {
+    color: #e2e8f0;
+  }
+  
+  .original-position-date {
+    color: #a0aec0;
+  }
+  
+  .no-original-positions {
+    color: #a0aec0;
+  }
+  
+  .no-original-positions i {
+    color: #718096;
+  }
+}

+ 82 - 0
client/components/boards/originalPositionsView.html

@@ -0,0 +1,82 @@
+<template name="originalPositionsView">
+  <div class="original-positions-view">
+    <div class="original-positions-header">
+      <button class="btn btn-sm btn-outline-secondary" onclick="{{toggleOriginalPositions}}">
+        <i class="fa fa-history"></i>
+        {{#if isShowingOriginalPositions}}Hide{{else}}Show{{/if}} Original Positions
+      </button>
+      
+      {{#if isShowingOriginalPositions}}
+        <button class="btn btn-sm btn-outline-primary" onclick="{{refreshHistory}}">
+          <i class="fa fa-refresh"></i> Refresh
+        </button>
+      {{/if}}
+    </div>
+
+    {{#if isShowingOriginalPositions}}
+      <div class="original-positions-content">
+        {{#if isLoading}}
+          <div class="original-positions-loading">
+            <i class="fa fa-spinner fa-spin"></i> Loading original positions...
+          </div>
+        {{else}}
+          <div class="original-positions-filters">
+            <div class="btn-group btn-group-sm" role="group">
+              <button type="button" 
+                      class="btn {{#if isFilterType 'all'}}btn-primary{{else}}btn-outline-secondary{{/if}}"
+                      onclick="{{setFilterType 'all'}}">
+                All
+              </button>
+              <button type="button" 
+                      class="btn {{#if isFilterType 'swimlane'}}btn-primary{{else}}btn-outline-secondary{{/if}}"
+                      onclick="{{setFilterType 'swimlane'}}">
+                <i class="fa fa-bars"></i> Swimlanes
+              </button>
+              <button type="button" 
+                      class="btn {{#if isFilterType 'list'}}btn-primary{{else}}btn-outline-secondary{{/if}}"
+                      onclick="{{setFilterType 'list'}}">
+                <i class="fa fa-columns"></i> Lists
+              </button>
+              <button type="button" 
+                      class="btn {{#if isFilterType 'card'}}btn-primary{{else}}btn-outline-secondary{{/if}}"
+                      onclick="{{setFilterType 'card'}}">
+                <i class="fa fa-sticky-note"></i> Cards
+              </button>
+            </div>
+          </div>
+
+          <div class="original-positions-list">
+            {{#each getFilteredHistory}}
+              <div class="original-position-item">
+                <div class="original-position-item-header">
+                  <i class="fa {{getEntityTypeIcon entityType}}"></i>
+                  <span class="entity-type">{{getEntityTypeLabel entityType}}</span>
+                  <span class="entity-name">{{getEntityDisplayName this}}</span>
+                  <span class="entity-id">({{entityId}})</span>
+                </div>
+                <div class="original-position-item-details">
+                  <div class="original-position-description">
+                    {{getEntityOriginalPositionDescription this}}
+                  </div>
+                  {{#if originalTitle}}
+                    <div class="original-title">
+                      <strong>Original title:</strong> {{originalTitle}}
+                    </div>
+                  {{/if}}
+                  <div class="original-position-date">
+                    <small class="text-muted">Created: {{formatDate createdAt}}</small>
+                  </div>
+                </div>
+              </div>
+            {{else}}
+              <div class="no-original-positions">
+                <i class="fa fa-info-circle"></i>
+                No original position data available for this board.
+              </div>
+            {{/each}}
+          </div>
+        {{/if}}
+      </div>
+    {{/if}}
+  </div>
+</template>

+ 148 - 0
client/components/boards/originalPositionsView.js

@@ -0,0 +1,148 @@
+import { BlazeComponent } from 'meteor/peerlibrary:blaze-components';
+import { ReactiveVar } from 'meteor/reactive-var';
+import { Meteor } from 'meteor/meteor';
+import { Template } from 'meteor/templating';
+import './originalPositionsView.html';
+
+/**
+ * Component to display original positions for all entities on a board
+ */
+class OriginalPositionsViewComponent extends BlazeComponent {
+  onCreated() {
+    super.onCreated();
+    this.showOriginalPositions = new ReactiveVar(false);
+    this.boardHistory = new ReactiveVar([]);
+    this.isLoading = new ReactiveVar(false);
+    this.filterType = new ReactiveVar('all'); // 'all', 'swimlane', 'list', 'card'
+  }
+
+  onRendered() {
+    super.onRendered();
+    this.loadBoardHistory();
+  }
+
+  loadBoardHistory() {
+    const boardId = Session.get('currentBoard');
+    if (!boardId) return;
+
+    this.isLoading.set(true);
+    
+    Meteor.call('positionHistory.getBoardHistory', boardId, (error, result) => {
+      this.isLoading.set(false);
+      if (error) {
+        console.error('Error loading board history:', error);
+        this.boardHistory.set([]);
+      } else {
+        this.boardHistory.set(result);
+      }
+    });
+  }
+
+  toggleOriginalPositions() {
+    this.showOriginalPositions.set(!this.showOriginalPositions.get());
+  }
+
+  isShowingOriginalPositions() {
+    return this.showOriginalPositions.get();
+  }
+
+  isLoading() {
+    return this.isLoading.get();
+  }
+
+  getBoardHistory() {
+    return this.boardHistory.get();
+  }
+
+  getFilteredHistory() {
+    const history = this.getBoardHistory();
+    const filterType = this.filterType.get();
+    
+    if (filterType === 'all') {
+      return history;
+    }
+    
+    return history.filter(item => item.entityType === filterType);
+  }
+
+  getSwimlanesHistory() {
+    return this.getBoardHistory().filter(item => item.entityType === 'swimlane');
+  }
+
+  getListsHistory() {
+    return this.getBoardHistory().filter(item => item.entityType === 'list');
+  }
+
+  getCardsHistory() {
+    return this.getBoardHistory().filter(item => item.entityType === 'card');
+  }
+
+  setFilterType(type) {
+    this.filterType.set(type);
+  }
+
+  getFilterType() {
+    return this.filterType.get();
+  }
+
+  getEntityDisplayName(entity) {
+    const position = entity.originalPosition || {};
+    return position.title || `Entity ${entity.entityId}`;
+  }
+
+  getEntityOriginalPositionDescription(entity) {
+    const position = entity.originalPosition || {};
+    let description = `Position: ${position.sort || 0}`;
+    
+    if (entity.entityType === 'list' && entity.originalSwimlaneId) {
+      description += ` in swimlane ${entity.originalSwimlaneId}`;
+    } else if (entity.entityType === 'card') {
+      if (entity.originalSwimlaneId) {
+        description += ` in swimlane ${entity.originalSwimlaneId}`;
+      }
+      if (entity.originalListId) {
+        description += ` in list ${entity.originalListId}`;
+      }
+    }
+    
+    return description;
+  }
+
+  getEntityTypeIcon(entityType) {
+    switch (entityType) {
+      case 'swimlane':
+        return 'fa-bars';
+      case 'list':
+        return 'fa-columns';
+      case 'card':
+        return 'fa-sticky-note';
+      default:
+        return 'fa-question';
+    }
+  }
+
+  getEntityTypeLabel(entityType) {
+    switch (entityType) {
+      case 'swimlane':
+        return 'Swimlane';
+      case 'list':
+        return 'List';
+      case 'card':
+        return 'Card';
+      default:
+        return 'Unknown';
+    }
+  }
+
+  formatDate(date) {
+    return new Date(date).toLocaleString();
+  }
+
+  refreshHistory() {
+    this.loadBoardHistory();
+  }
+}
+
+OriginalPositionsViewComponent.register('originalPositionsView');
+
+export default OriginalPositionsViewComponent;

+ 123 - 0
client/components/common/originalPosition.css

@@ -0,0 +1,123 @@
+/* Original Position Component Styles */
+.original-position-info {
+  margin: 5px 0;
+  padding: 8px;
+  border-radius: 4px;
+  font-size: 12px;
+  line-height: 1.4;
+}
+
+.original-position-loading {
+  color: #666;
+  font-style: italic;
+}
+
+.original-position-loading i {
+  margin-right: 5px;
+}
+
+.original-position-details {
+  background-color: #f8f9fa;
+  border: 1px solid #e9ecef;
+  border-radius: 3px;
+  padding: 6px 8px;
+}
+
+.original-position-moved {
+  color: #856404;
+  background-color: #fff3cd;
+  border: 1px solid #ffeaa7;
+  border-radius: 3px;
+  padding: 4px 6px;
+  margin-bottom: 4px;
+}
+
+.original-position-moved i {
+  color: #f39c12;
+  margin-right: 5px;
+}
+
+.original-position-unchanged {
+  color: #155724;
+  background-color: #d4edda;
+  border: 1px solid #c3e6cb;
+  border-radius: 3px;
+  padding: 4px 6px;
+  margin-bottom: 4px;
+}
+
+.original-position-unchanged i {
+  color: #28a745;
+  margin-right: 5px;
+}
+
+.original-position-text {
+  font-weight: 500;
+}
+
+.original-title {
+  color: #6c757d;
+  font-size: 11px;
+  margin-top: 4px;
+  padding-top: 4px;
+  border-top: 1px solid #e9ecef;
+}
+
+.original-title strong {
+  color: #495057;
+}
+
+/* Integration with existing Wekan styles */
+.swimlane .original-position-info,
+.list .original-position-info,
+.card .original-position-info {
+  margin: 2px 0;
+  padding: 4px 6px;
+}
+
+/* Responsive adjustments */
+@media (max-width: 768px) {
+  .original-position-info {
+    font-size: 11px;
+    padding: 6px;
+  }
+  
+  .original-position-details {
+    padding: 4px 6px;
+  }
+  
+  .original-position-moved,
+  .original-position-unchanged {
+    padding: 3px 5px;
+  }
+}
+
+/* Dark theme support */
+@media (prefers-color-scheme: dark) {
+  .original-position-details {
+    background-color: #2d3748;
+    border-color: #4a5568;
+    color: #e2e8f0;
+  }
+  
+  .original-position-moved {
+    background-color: #744210;
+    border-color: #b7791f;
+    color: #fbd38d;
+  }
+  
+  .original-position-unchanged {
+    background-color: #22543d;
+    border-color: #38a169;
+    color: #9ae6b4;
+  }
+  
+  .original-title {
+    color: #a0aec0;
+    border-color: #4a5568;
+  }
+  
+  .original-title strong {
+    color: #e2e8f0;
+  }
+}

+ 29 - 0
client/components/common/originalPosition.html

@@ -0,0 +1,29 @@
+<template name="originalPosition">
+  <div class="original-position-info">
+    {{#if isLoading}}
+      <div class="original-position-loading">
+        <i class="fa fa-spinner fa-spin"></i> Loading original position...
+      </div>
+    {{else if showOriginalPosition}}
+      <div class="original-position-details">
+        {{#if hasMovedFromOriginal}}
+          <div class="original-position-moved">
+            <i class="fa fa-info-circle"></i>
+            <span class="original-position-text">{{getOriginalPositionDescription}}</span>
+          </div>
+        {{else}}
+          <div class="original-position-unchanged">
+            <i class="fa fa-check-circle"></i>
+            <span class="original-position-text">In original position</span>
+          </div>
+        {{/if}}
+        
+        {{#if getOriginalTitle}}
+          <div class="original-title">
+            <strong>Original title:</strong> {{getOriginalTitle}}
+          </div>
+        {{/if}}
+      </div>
+    {{/if}}
+  </div>
+</template>

+ 98 - 0
client/components/common/originalPosition.js

@@ -0,0 +1,98 @@
+import { BlazeComponent } from 'meteor/peerlibrary:blaze-components';
+import { ReactiveVar } from 'meteor/reactive-var';
+import { Meteor } from 'meteor/meteor';
+import { Template } from 'meteor/templating';
+import './originalPosition.html';
+
+/**
+ * Component to display original position information for swimlanes, lists, and cards
+ */
+class OriginalPositionComponent extends BlazeComponent {
+  onCreated() {
+    super.onCreated();
+    this.originalPosition = new ReactiveVar(null);
+    this.isLoading = new ReactiveVar(false);
+    this.hasMoved = new ReactiveVar(false);
+    
+    this.autorun(() => {
+      const data = this.data();
+      if (data && data.entityId && data.entityType) {
+        this.loadOriginalPosition(data.entityId, data.entityType);
+      }
+    });
+  }
+
+  loadOriginalPosition(entityId, entityType) {
+    this.isLoading.set(true);
+    
+    const methodName = `positionHistory.get${entityType.charAt(0).toUpperCase() + entityType.slice(1)}OriginalPosition`;
+    
+    Meteor.call(methodName, entityId, (error, result) => {
+      this.isLoading.set(false);
+      if (error) {
+        console.error('Error loading original position:', error);
+        this.originalPosition.set(null);
+      } else {
+        this.originalPosition.set(result);
+        
+        // Check if the entity has moved
+        const movedMethodName = `positionHistory.has${entityType.charAt(0).toUpperCase() + entityType.slice(1)}Moved`;
+        Meteor.call(movedMethodName, entityId, (movedError, movedResult) => {
+          if (!movedError) {
+            this.hasMoved.set(movedResult);
+          }
+        });
+      }
+    });
+  }
+
+  getOriginalPosition() {
+    return this.originalPosition.get();
+  }
+
+  isLoading() {
+    return this.isLoading.get();
+  }
+
+  hasMovedFromOriginal() {
+    return this.hasMoved.get();
+  }
+
+  getOriginalPositionDescription() {
+    const position = this.getOriginalPosition();
+    if (!position) return 'No original position data';
+    
+    if (position.originalPosition) {
+      const entityType = this.data().entityType;
+      let description = `Original position: ${position.originalPosition.sort || 0}`;
+      
+      if (entityType === 'list' && position.originalSwimlaneId) {
+        description += ` in swimlane ${position.originalSwimlaneId}`;
+      } else if (entityType === 'card') {
+        if (position.originalSwimlaneId) {
+          description += ` in swimlane ${position.originalSwimlaneId}`;
+        }
+        if (position.originalListId) {
+          description += ` in list ${position.originalListId}`;
+        }
+      }
+      
+      return description;
+    }
+    
+    return 'No original position data';
+  }
+
+  getOriginalTitle() {
+    const position = this.getOriginalPosition();
+    return position ? position.originalTitle : '';
+  }
+
+  showOriginalPosition() {
+    return this.getOriginalPosition() !== null;
+  }
+}
+
+OriginalPositionComponent.register('originalPosition');
+
+export default OriginalPositionComponent;

+ 114 - 0
client/lib/originalPositionHelpers.js

@@ -0,0 +1,114 @@
+/**
+ * Helper functions for integrating original position tracking into existing Wekan templates
+ */
+
+/**
+ * Add original position tracking to swimlane templates
+ */
+export function addOriginalPositionToSwimlane(swimlaneId) {
+  if (!swimlaneId) return;
+  
+  // Track original position when swimlane is created or first accessed
+  Meteor.call('positionHistory.trackSwimlane', swimlaneId, (error) => {
+    if (error) {
+      console.warn('Failed to track original position for swimlane:', error);
+    }
+  });
+}
+
+/**
+ * Add original position tracking to list templates
+ */
+export function addOriginalPositionToList(listId) {
+  if (!listId) return;
+  
+  // Track original position when list is created or first accessed
+  Meteor.call('positionHistory.trackList', listId, (error) => {
+    if (error) {
+      console.warn('Failed to track original position for list:', error);
+    }
+  });
+}
+
+/**
+ * Add original position tracking to card templates
+ */
+export function addOriginalPositionToCard(cardId) {
+  if (!cardId) return;
+  
+  // Track original position when card is created or first accessed
+  Meteor.call('positionHistory.trackCard', cardId, (error) => {
+    if (error) {
+      console.warn('Failed to track original position for card:', error);
+    }
+  });
+}
+
+/**
+ * Get original position description for display in templates
+ */
+export function getOriginalPositionDescription(entityId, entityType) {
+  return new Promise((resolve, reject) => {
+    const methodName = `positionHistory.get${entityType.charAt(0).toUpperCase() + entityType.slice(1)}Description`;
+    
+    Meteor.call(methodName, entityId, (error, result) => {
+      if (error) {
+        reject(error);
+      } else {
+        resolve(result);
+      }
+    });
+  });
+}
+
+/**
+ * Check if an entity has moved from its original position
+ */
+export function hasEntityMoved(entityId, entityType) {
+  return new Promise((resolve, reject) => {
+    const methodName = `positionHistory.has${entityType.charAt(0).toUpperCase() + entityType.slice(1)}Moved`;
+    
+    Meteor.call(methodName, entityId, (error, result) => {
+      if (error) {
+        reject(error);
+      } else {
+        resolve(result);
+      }
+    });
+  });
+}
+
+/**
+ * Template helper for displaying original position info
+ */
+Template.registerHelper('originalPositionInfo', function(entityId, entityType) {
+  if (!entityId || !entityType) return null;
+  
+  const description = getOriginalPositionDescription(entityId, entityType);
+  const hasMoved = hasEntityMoved(entityId, entityType);
+  
+  return {
+    description: description,
+    hasMoved: hasMoved,
+    entityId: entityId,
+    entityType: entityType
+  };
+});
+
+/**
+ * Template helper for checking if entity has moved
+ */
+Template.registerHelper('hasEntityMoved', function(entityId, entityType) {
+  if (!entityId || !entityType) return false;
+  
+  return hasEntityMoved(entityId, entityType);
+});
+
+/**
+ * Template helper for getting original position description
+ */
+Template.registerHelper('getOriginalPositionDescription', function(entityId, entityType) {
+  if (!entityId || !entityType) return 'No original position data';
+  
+  return getOriginalPositionDescription(entityId, entityType);
+});

+ 248 - 0
docs/Design/Original-Positions.md

@@ -0,0 +1,248 @@
+# Original Positions Tracking Feature
+
+This feature allows users to see the original positions of swimlanes, lists, and cards before the list naming feature was added in commit [719ef87efceacfe91461a8eeca7cf74d11f4cc0a](https://github.com/wekan/wekan/commit/719ef87efceacfe91461a8eeca7cf74d11f4cc0a).
+
+## Overview
+
+The original positions tracking system automatically captures and stores the initial positions of all board entities (swimlanes, lists, and cards) when they are created. This allows users to:
+
+- View the original position of any entity
+- See if an entity has moved from its original position
+- Track the original title before any changes
+- View a complete history of original positions for a board
+
+## Features
+
+### 1. Automatic Position Tracking
+- **Swimlanes**: Tracks original sort position and title
+- **Lists**: Tracks original sort position, title, and swimlane assignment
+- **Cards**: Tracks original sort position, title, swimlane, and list assignment
+
+### 2. Database Schema
+The system uses a new `PositionHistory` collection with the following structure:
+```javascript
+{
+  boardId: String,           // Board ID
+  entityType: String,        // 'swimlane', 'list', or 'card'
+  entityId: String,          // Entity ID
+  originalPosition: Object,  // Original position data
+  originalSwimlaneId: String, // Original swimlane (for lists/cards)
+  originalListId: String,    // Original list (for cards)
+  originalTitle: String,     // Original title
+  createdAt: Date,          // When tracked
+  updatedAt: Date           // Last updated
+}
+```
+
+### 3. UI Components
+
+#### Individual Entity Display
+- Shows original position information for individual swimlanes, lists, or cards
+- Indicates if the entity has moved from its original position
+- Displays original title if different from current title
+
+#### Board-Level View
+- Complete overview of all original positions on a board
+- Filter by entity type (swimlanes, lists, cards)
+- Search and sort functionality
+- Export capabilities
+
+## Usage
+
+### 1. Automatic Tracking
+Position tracking happens automatically when entities are created. No manual intervention required.
+
+### 2. Viewing Original Positions
+
+#### In Templates
+```html
+<!-- Show original position for a swimlane -->
+{{> originalPosition entityId=swimlane._id entityType="swimlane"}}
+
+<!-- Show original position for a list -->
+{{> originalPosition entityId=list._id entityType="list"}}
+
+<!-- Show original position for a card -->
+{{> originalPosition entityId=card._id entityType="card"}}
+```
+
+#### In JavaScript
+```javascript
+import { addOriginalPositionToSwimlane, addOriginalPositionToList, addOriginalPositionToCard } from '/client/lib/originalPositionHelpers';
+
+// Track original position for a swimlane
+addOriginalPositionToSwimlane(swimlaneId);
+
+// Track original position for a list
+addOriginalPositionToList(listId);
+
+// Track original position for a card
+addOriginalPositionToCard(cardId);
+```
+
+### 3. Server Methods
+
+#### Track Original Positions
+```javascript
+// Track swimlane
+Meteor.call('positionHistory.trackSwimlane', swimlaneId);
+
+// Track list
+Meteor.call('positionHistory.trackList', listId);
+
+// Track card
+Meteor.call('positionHistory.trackCard', cardId);
+```
+
+#### Get Original Position Data
+```javascript
+// Get swimlane original position
+Meteor.call('positionHistory.getSwimlaneOriginalPosition', swimlaneId);
+
+// Get list original position
+Meteor.call('positionHistory.getListOriginalPosition', listId);
+
+// Get card original position
+Meteor.call('positionHistory.getCardOriginalPosition', cardId);
+```
+
+#### Check if Entity Has Moved
+```javascript
+// Check if swimlane has moved
+Meteor.call('positionHistory.hasSwimlaneMoved', swimlaneId);
+
+// Check if list has moved
+Meteor.call('positionHistory.hasListMoved', listId);
+
+// Check if card has moved
+Meteor.call('positionHistory.hasCardMoved', cardId);
+```
+
+#### Get Board History
+```javascript
+// Get all position history for a board
+Meteor.call('positionHistory.getBoardHistory', boardId);
+
+// Get position history by entity type
+Meteor.call('positionHistory.getBoardHistoryByType', boardId, 'swimlane');
+Meteor.call('positionHistory.getBoardHistoryByType', boardId, 'list');
+Meteor.call('positionHistory.getBoardHistoryByType', boardId, 'card');
+```
+
+## Integration Examples
+
+### 1. Add to Swimlane Template
+```html
+<template name="swimlane">
+  <div class="swimlane">
+    <!-- Existing swimlane content -->
+    
+    <!-- Add original position info -->
+    {{> originalPosition entityId=_id entityType="swimlane"}}
+  </div>
+</template>
+```
+
+### 2. Add to List Template
+```html
+<template name="list">
+  <div class="list">
+    <!-- Existing list content -->
+    
+    <!-- Add original position info -->
+    {{> originalPosition entityId=_id entityType="list"}}
+  </div>
+</template>
+```
+
+### 3. Add to Card Template
+```html
+<template name="card">
+  <div class="card">
+    <!-- Existing card content -->
+    
+    <!-- Add original position info -->
+    {{> originalPosition entityId=_id entityType="card"}}
+  </div>
+</template>
+```
+
+### 4. Add Board-Level View
+```html
+<template name="board">
+  <div class="board">
+    <!-- Existing board content -->
+    
+    <!-- Add original positions view -->
+    {{> originalPositionsView}}
+  </div>
+</template>
+```
+
+## Configuration
+
+### 1. Enable/Disable Tracking
+Position tracking is enabled by default. To disable it, comment out the tracking hooks in the model files:
+
+```javascript
+// In models/swimlanes.js, models/lists.js, models/cards.js
+// Comment out the tracking hooks:
+/*
+Meteor.setTimeout(() => {
+  const entity = Collection.findOne(doc._id);
+  if (entity) {
+    entity.trackOriginalPosition();
+  }
+}, 100);
+*/
+```
+
+### 2. Customize Display
+Modify the CSS files to customize the appearance:
+- `client/components/common/originalPosition.css`
+- `client/components/boards/originalPositionsView.css`
+
+## Database Migration
+
+No database migration is required. The system automatically creates the `PositionHistory` collection when first used.
+
+## Performance Considerations
+
+- Position tracking adds minimal overhead to entity creation
+- Original position data is only stored once per entity
+- Database indexes are created for efficient querying
+- UI components use reactive data for real-time updates
+
+## Troubleshooting
+
+### 1. Original Position Not Showing
+- Check if the entity has been created after the feature was implemented
+- Verify that the position tracking hooks are enabled
+- Check browser console for any JavaScript errors
+
+### 2. Performance Issues
+- Ensure database indexes are created (happens automatically on startup)
+- Consider limiting the number of entities displayed in the board view
+- Use the filter functionality to reduce the number of displayed items
+
+### 3. Data Inconsistencies
+- Original position data is only captured when entities are created
+- Existing entities will not have original position data
+- Use the refresh functionality to re-scan the board
+
+## Future Enhancements
+
+- Export original position data to CSV/JSON
+- Bulk operations for managing original positions
+- Integration with board templates
+- Advanced filtering and search capabilities
+- Position change notifications
+- Historical position timeline view
+
+## Support
+
+For issues or questions about the original positions tracking feature, please:
+1. Check the browser console for errors
+2. Verify that all required files are present
+3. Test with a new board to ensure the feature works correctly
+4. Report issues with detailed error messages and steps to reproduce

+ 82 - 0
models/cards.js

@@ -8,6 +8,7 @@ import {
 } from '../config/const';
 import Attachments, { fileStoreStrategyFactory } from "./attachments";
 import { copyFile } from './lib/fileStoreStrategy.js';
+import PositionHistory from './positionHistory';
 
 Cards = new Mongo.Collection('cards');
 
@@ -3139,6 +3140,14 @@ if (Meteor.isServer) {
 
   Cards.after.insert((userId, doc) => {
     cardCreation(userId, doc);
+
+    // Track original position for new cards
+    Meteor.setTimeout(() => {
+      const card = Cards.findOne(doc._id);
+      if (card) {
+        card.trackOriginalPosition();
+      }
+    }, 100);
   });
   // New activity for card (un)archivage
   Cards.after.update((userId, doc, fieldNames) => {
@@ -4138,4 +4147,77 @@ JsonRoutes.add('GET', '/api/boards/:boardId/cards_count', function(
   );
 }
 
+// Position history tracking methods
+Cards.helpers({
+  /**
+   * Track the original position of this card
+   */
+  trackOriginalPosition() {
+    const existingHistory = PositionHistory.findOne({
+      boardId: this.boardId,
+      entityType: 'card',
+      entityId: this._id,
+    });
+
+    if (!existingHistory) {
+      PositionHistory.insert({
+        boardId: this.boardId,
+        entityType: 'card',
+        entityId: this._id,
+        originalPosition: {
+          sort: this.sort,
+          title: this.title,
+        },
+        originalSwimlaneId: this.swimlaneId || null,
+        originalListId: this.listId || null,
+        originalTitle: this.title,
+        createdAt: new Date(),
+        updatedAt: new Date(),
+      });
+    }
+  },
+
+  /**
+   * Get the original position history for this card
+   */
+  getOriginalPosition() {
+    return PositionHistory.findOne({
+      boardId: this.boardId,
+      entityType: 'card',
+      entityId: this._id,
+    });
+  },
+
+  /**
+   * Check if this card has moved from its original position
+   */
+  hasMovedFromOriginalPosition() {
+    const history = this.getOriginalPosition();
+    if (!history) return false;
+    
+    const currentSwimlaneId = this.swimlaneId || null;
+    const currentListId = this.listId || null;
+    
+    return history.originalPosition.sort !== this.sort ||
+           history.originalSwimlaneId !== currentSwimlaneId ||
+           history.originalListId !== currentListId;
+  },
+
+  /**
+   * Get a description of the original position
+   */
+  getOriginalPositionDescription() {
+    const history = this.getOriginalPosition();
+    if (!history) return 'No original position data';
+    
+    const swimlaneInfo = history.originalSwimlaneId ? 
+      ` in swimlane ${history.originalSwimlaneId}` : 
+      ' in default swimlane';
+    const listInfo = history.originalListId ? 
+      ` in list ${history.originalListId}` : 
+      '';
+    return `Original position: ${history.originalPosition.sort || 0}${swimlaneInfo}${listInfo}`;
+  },
+});
+
 export default Cards;

+ 82 - 0
models/lists.js

@@ -1,5 +1,6 @@
 import { ReactiveCache } from '/imports/reactiveCache';
 import { ALLOWED_COLORS } from '/config/const';
+import PositionHistory from './positionHistory';
 
 Lists = new Mongo.Collection('lists');
 
@@ -453,6 +454,14 @@ if (Meteor.isServer) {
       // list is deleted
       title: doc.title,
     });
+
+    // Track original position for new lists
+    Meteor.setTimeout(() => {
+      const list = Lists.findOne(doc._id);
+      if (list) {
+        list.trackOriginalPosition();
+      }
+    }, 100);
   });
 
   Lists.before.remove((userId, doc) => {
@@ -805,4 +814,77 @@ if (Meteor.isServer) {
   });
 }
 
+// Position history tracking methods
+Lists.helpers({
+  /**
+   * Track the original position of this list
+   */
+  trackOriginalPosition() {
+    const existingHistory = PositionHistory.findOne({
+      boardId: this.boardId,
+      entityType: 'list',
+      entityId: this._id,
+    });
+
+    if (!existingHistory) {
+      PositionHistory.insert({
+        boardId: this.boardId,
+        entityType: 'list',
+        entityId: this._id,
+        originalPosition: {
+          sort: this.sort,
+          title: this.title,
+        },
+        originalSwimlaneId: this.swimlaneId || null,
+        originalTitle: this.title,
+        createdAt: new Date(),
+        updatedAt: new Date(),
+      });
+    }
+  },
+
+  /**
+   * Get the original position history for this list
+   */
+  getOriginalPosition() {
+    return PositionHistory.findOne({
+      boardId: this.boardId,
+      entityType: 'list',
+      entityId: this._id,
+    });
+  },
+
+  /**
+   * Check if this list has moved from its original position
+   */
+  hasMovedFromOriginalPosition() {
+    const history = this.getOriginalPosition();
+    if (!history) return false;
+    
+    const currentSwimlaneId = this.swimlaneId || null;
+    return history.originalPosition.sort !== this.sort ||
+           history.originalSwimlaneId !== currentSwimlaneId;
+  },
+
+  /**
+   * Get a description of the original position
+   */
+  getOriginalPositionDescription() {
+    const history = this.getOriginalPosition();
+    if (!history) return 'No original position data';
+    
+    const swimlaneInfo = history.originalSwimlaneId ? 
+      ` in swimlane ${history.originalSwimlaneId}` : 
+      ' in default swimlane';
+    return `Original position: ${history.originalPosition.sort || 0}${swimlaneInfo}`;
+  },
+
+  /**
+   * Get the effective swimlane ID (for backward compatibility)
+   */
+  getEffectiveSwimlaneId() {
+    return this.swimlaneId || null;
+  },
+});
+
 export default Lists;

+ 170 - 0
models/positionHistory.js

@@ -0,0 +1,170 @@
+import { ReactiveCache } from '/imports/reactiveCache';
+
+/**
+ * PositionHistory collection to track original positions of swimlanes, lists, and cards
+ * before the list naming feature was added in commit 719ef87efceacfe91461a8eeca7cf74d11f4cc0a
+ */
+PositionHistory = new Mongo.Collection('positionHistory');
+
+PositionHistory.attachSchema(
+  new SimpleSchema({
+    boardId: {
+      /**
+       * The board ID this position history belongs to
+       */
+      type: String,
+    },
+    entityType: {
+      /**
+       * Type of entity: 'swimlane', 'list', or 'card'
+       */
+      type: String,
+      allowedValues: ['swimlane', 'list', 'card'],
+    },
+    entityId: {
+      /**
+       * The ID of the entity (swimlane, list, or card)
+       */
+      type: String,
+    },
+    originalPosition: {
+      /**
+       * The original position data before any changes
+       */
+      type: Object,
+      blackbox: true,
+    },
+    originalSwimlaneId: {
+      /**
+       * The original swimlane ID (for lists and cards)
+       */
+      type: String,
+      optional: true,
+    },
+    originalListId: {
+      /**
+       * The original list ID (for cards)
+       */
+      type: String,
+      optional: true,
+    },
+    originalTitle: {
+      /**
+       * The original title before any changes
+       */
+      type: String,
+      optional: true,
+    },
+    createdAt: {
+      /**
+       * When this position history was created
+       */
+      type: Date,
+      autoValue() {
+        if (this.isInsert) {
+          return new Date();
+        } else if (this.isUpsert) {
+          return { $setOnInsert: new Date() };
+        } else {
+          this.unset();
+        }
+      },
+    },
+    updatedAt: {
+      /**
+       * When this position history was last updated
+       */
+      type: Date,
+      autoValue() {
+        if (this.isUpdate || this.isUpsert || this.isInsert) {
+          return new Date();
+        } else {
+          this.unset();
+        }
+      },
+    },
+  }),
+);
+
+PositionHistory.helpers({
+  /**
+   * Get the original position data
+   */
+  getOriginalPosition() {
+    return this.originalPosition;
+  },
+
+  /**
+   * Get the original title
+   */
+  getOriginalTitle() {
+    return this.originalTitle || '';
+  },
+
+  /**
+   * Get the original swimlane ID
+   */
+  getOriginalSwimlaneId() {
+    return this.originalSwimlaneId;
+  },
+
+  /**
+   * Get the original list ID
+   */
+  getOriginalListId() {
+    return this.originalListId;
+  },
+
+  /**
+   * Check if this entity has been moved from its original position
+   */
+  hasMoved() {
+    if (this.entityType === 'swimlane') {
+      return this.originalPosition.sort !== undefined;
+    } else if (this.entityType === 'list') {
+      return this.originalPosition.sort !== undefined || 
+             this.originalSwimlaneId !== this.entityId;
+    } else if (this.entityType === 'card') {
+      return this.originalPosition.sort !== undefined ||
+             this.originalSwimlaneId !== this.entityId ||
+             this.originalListId !== this.entityId;
+    }
+    return false;
+  },
+
+  /**
+   * Get a human-readable description of the original position
+   */
+  getOriginalPositionDescription() {
+    const position = this.originalPosition;
+    if (!position) return 'Unknown position';
+
+    if (this.entityType === 'swimlane') {
+      return `Original position: ${position.sort || 0}`;
+    } else if (this.entityType === 'list') {
+      const swimlaneInfo = this.originalSwimlaneId ? 
+        ` in swimlane ${this.originalSwimlaneId}` : 
+        ' in default swimlane';
+      return `Original position: ${position.sort || 0}${swimlaneInfo}`;
+    } else if (this.entityType === 'card') {
+      const swimlaneInfo = this.originalSwimlaneId ? 
+        ` in swimlane ${this.originalSwimlaneId}` : 
+        ' in default swimlane';
+      const listInfo = this.originalListId ? 
+        ` in list ${this.originalListId}` : 
+        '';
+      return `Original position: ${position.sort || 0}${swimlaneInfo}${listInfo}`;
+    }
+    return 'Unknown position';
+  }
+});
+
+if (Meteor.isServer) {
+  Meteor.startup(() => {
+    PositionHistory._collection.createIndex({ boardId: 1, entityType: 1, entityId: 1 });
+    PositionHistory._collection.createIndex({ boardId: 1, entityType: 1 });
+    PositionHistory._collection.createIndex({ createdAt: -1 });
+  });
+}
+
+export default PositionHistory;

+ 69 - 0
models/swimlanes.js

@@ -1,5 +1,6 @@
 import { ReactiveCache } from '/imports/reactiveCache';
 import { ALLOWED_COLORS } from '/config/const';
+import PositionHistory from './positionHistory';
 
 Swimlanes = new Mongo.Collection('swimlanes');
 
@@ -366,6 +367,14 @@ if (Meteor.isServer) {
       boardId: doc.boardId,
       swimlaneId: doc._id,
     });
+
+    // Track original position for new swimlanes
+    Meteor.setTimeout(() => {
+      const swimlane = Swimlanes.findOne(doc._id);
+      if (swimlane) {
+        swimlane.trackOriginalPosition();
+      }
+    }, 100);
   });
 
   Swimlanes.before.remove(function(userId, doc) {
@@ -614,4 +623,64 @@ if (Meteor.isServer) {
   );
 }
 
+// Position history tracking methods
+Swimlanes.helpers({
+  /**
+   * Track the original position of this swimlane
+   */
+  trackOriginalPosition() {
+    const existingHistory = PositionHistory.findOne({
+      boardId: this.boardId,
+      entityType: 'swimlane',
+      entityId: this._id,
+    });
+
+    if (!existingHistory) {
+      PositionHistory.insert({
+        boardId: this.boardId,
+        entityType: 'swimlane',
+        entityId: this._id,
+        originalPosition: {
+          sort: this.sort,
+          title: this.title,
+        },
+        originalTitle: this.title,
+        createdAt: new Date(),
+        updatedAt: new Date(),
+      });
+    }
+  },
+
+  /**
+   * Get the original position history for this swimlane
+   */
+  getOriginalPosition() {
+    return PositionHistory.findOne({
+      boardId: this.boardId,
+      entityType: 'swimlane',
+      entityId: this._id,
+    });
+  },
+
+  /**
+   * Check if this swimlane has moved from its original position
+   */
+  hasMovedFromOriginalPosition() {
+    const history = this.getOriginalPosition();
+    if (!history) return false;
+    
+    return history.originalPosition.sort !== this.sort;
+  },
+
+  /**
+   * Get a description of the original position
+   */
+  getOriginalPositionDescription() {
+    const history = this.getOriginalPosition();
+    if (!history) return 'No original position data';
+    
+    return `Original position: ${history.originalPosition.sort || 0}`;
+  },
+});
+
 export default Swimlanes;

+ 211 - 0
server/methods/positionHistory.js

@@ -0,0 +1,211 @@
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import PositionHistory from '/models/positionHistory';
+import Swimlanes from '/models/swimlanes';
+import Lists from '/models/lists';
+import Cards from '/models/cards';
+
+/**
+ * Server-side methods for position history tracking
+ */
+Meteor.methods({
+  /**
+   * Track original position for a swimlane
+   */
+  'positionHistory.trackSwimlane'(swimlaneId) {
+    check(swimlaneId, String);
+    
+    const swimlane = Swimlanes.findOne(swimlaneId);
+    if (!swimlane) {
+      throw new Meteor.Error('swimlane-not-found', 'Swimlane not found');
+    }
+    
+    return swimlane.trackOriginalPosition();
+  },
+
+  /**
+   * Track original position for a list
+   */
+  'positionHistory.trackList'(listId) {
+    check(listId, String);
+    
+    const list = Lists.findOne(listId);
+    if (!list) {
+      throw new Meteor.Error('list-not-found', 'List not found');
+    }
+    
+    return list.trackOriginalPosition();
+  },
+
+  /**
+   * Track original position for a card
+   */
+  'positionHistory.trackCard'(cardId) {
+    check(cardId, String);
+    
+    const card = Cards.findOne(cardId);
+    if (!card) {
+      throw new Meteor.Error('card-not-found', 'Card not found');
+    }
+    
+    return card.trackOriginalPosition();
+  },
+
+  /**
+   * Get original position for a swimlane
+   */
+  'positionHistory.getSwimlaneOriginalPosition'(swimlaneId) {
+    check(swimlaneId, String);
+    
+    const swimlane = Swimlanes.findOne(swimlaneId);
+    if (!swimlane) {
+      throw new Meteor.Error('swimlane-not-found', 'Swimlane not found');
+    }
+    
+    return swimlane.getOriginalPosition();
+  },
+
+  /**
+   * Get original position for a list
+   */
+  'positionHistory.getListOriginalPosition'(listId) {
+    check(listId, String);
+    
+    const list = Lists.findOne(listId);
+    if (!list) {
+      throw new Meteor.Error('list-not-found', 'List not found');
+    }
+    
+    return list.getOriginalPosition();
+  },
+
+  /**
+   * Get original position for a card
+   */
+  'positionHistory.getCardOriginalPosition'(cardId) {
+    check(cardId, String);
+    
+    const card = Cards.findOne(cardId);
+    if (!card) {
+      throw new Meteor.Error('card-not-found', 'Card not found');
+    }
+    
+    return card.getOriginalPosition();
+  },
+
+  /**
+   * Check if a swimlane has moved from its original position
+   */
+  'positionHistory.hasSwimlaneMoved'(swimlaneId) {
+    check(swimlaneId, String);
+    
+    const swimlane = Swimlanes.findOne(swimlaneId);
+    if (!swimlane) {
+      throw new Meteor.Error('swimlane-not-found', 'Swimlane not found');
+    }
+    
+    return swimlane.hasMovedFromOriginalPosition();
+  },
+
+  /**
+   * Check if a list has moved from its original position
+   */
+  'positionHistory.hasListMoved'(listId) {
+    check(listId, String);
+    
+    const list = Lists.findOne(listId);
+    if (!list) {
+      throw new Meteor.Error('list-not-found', 'List not found');
+    }
+    
+    return list.hasMovedFromOriginalPosition();
+  },
+
+  /**
+   * Check if a card has moved from its original position
+   */
+  'positionHistory.hasCardMoved'(cardId) {
+    check(cardId, String);
+    
+    const card = Cards.findOne(cardId);
+    if (!card) {
+      throw new Meteor.Error('card-not-found', 'Card not found');
+    }
+    
+    return card.hasMovedFromOriginalPosition();
+  },
+
+  /**
+   * Get original position description for a swimlane
+   */
+  'positionHistory.getSwimlaneDescription'(swimlaneId) {
+    check(swimlaneId, String);
+    
+    const swimlane = Swimlanes.findOne(swimlaneId);
+    if (!swimlane) {
+      throw new Meteor.Error('swimlane-not-found', 'Swimlane not found');
+    }
+    
+    return swimlane.getOriginalPositionDescription();
+  },
+
+  /**
+   * Get original position description for a list
+   */
+  'positionHistory.getListDescription'(listId) {
+    check(listId, String);
+    
+    const list = Lists.findOne(listId);
+    if (!list) {
+      throw new Meteor.Error('list-not-found', 'List not found');
+    }
+    
+    return list.getOriginalPositionDescription();
+  },
+
+  /**
+   * Get original position description for a card
+   */
+  'positionHistory.getCardDescription'(cardId) {
+    check(cardId, String);
+    
+    const card = Cards.findOne(cardId);
+    if (!card) {
+      throw new Meteor.Error('card-not-found', 'Card not found');
+    }
+    
+    return card.getOriginalPositionDescription();
+  },
+
+  /**
+   * Get all position history for a board
+   */
+  'positionHistory.getBoardHistory'(boardId) {
+    check(boardId, String);
+    
+    return PositionHistory.find({
+      boardId: boardId,
+    }, {
+      sort: { createdAt: -1 }
+    }).fetch();
+  },
+
+  /**
+   * Get position history by entity type for a board
+   */
+  'positionHistory.getBoardHistoryByType'(boardId, entityType) {
+    check(boardId, String);
+    check(entityType, String);
+    
+    if (!['swimlane', 'list', 'card'].includes(entityType)) {
+      throw new Meteor.Error('invalid-entity-type', 'Entity type must be swimlane, list, or card');
+    }
+    
+    return PositionHistory.find({
+      boardId: boardId,
+      entityType: entityType,
+    }, {
+      sort: { createdAt: -1 }
+    }).fetch();
+  },
+});