diff --git a/package-lock.json b/package-lock.json
index 65c8270..6655921 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,13 +1,17 @@
{
"name": "jiratime",
- "version": "0.0.0",
+ "version": "1.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "jiratime",
- "version": "0.0.0",
+ "version": "1.5.0",
"dependencies": {
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/modifiers": "^9.0.0",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
@@ -387,6 +391,73 @@
"fsevents": "~2.3.2"
}
},
+ "node_modules/@dnd-kit/accessibility": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
+ "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/core": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
+ "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@dnd-kit/accessibility": "^3.1.1",
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/modifiers": {
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz",
+ "integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==",
+ "license": "MIT",
+ "dependencies": {
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "@dnd-kit/core": "^6.3.0",
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/sortable": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
+ "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
+ "license": "MIT",
+ "dependencies": {
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "@dnd-kit/core": "^6.3.0",
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/utilities": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
+ "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -4383,7 +4454,6 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
- "dev": true,
"license": "0BSD"
},
"node_modules/type-check": {
diff --git a/package.json b/package.json
index b70c131..39ab2d0 100644
--- a/package.json
+++ b/package.json
@@ -10,6 +10,10 @@
"preview": "vite preview"
},
"dependencies": {
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/modifiers": "^9.0.0",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
@@ -35,4 +39,4 @@
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
-}
\ No newline at end of file
+}
diff --git a/src/components/SortableTicketItem.tsx b/src/components/SortableTicketItem.tsx
new file mode 100644
index 0000000..b9ebfe3
--- /dev/null
+++ b/src/components/SortableTicketItem.tsx
@@ -0,0 +1,41 @@
+import { useSortable } from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
+import { TicketItem } from "./TicketItem";
+import type { JiraTicket, AppSettings } from "../lib/types";
+import type { ActiveTimer } from "../hooks/useActiveTimer";
+
+interface SortableTicketItemProps {
+ id: string; // Unique ID for dnd-kit
+ ticket: JiraTicket;
+ settings: AppSettings;
+ activeTimer: ActiveTimer | null;
+ onStartTimer: (id: string) => void;
+ onStopTimer: () => void;
+ onRefresh: () => void;
+ onRemove?: () => void;
+}
+
+export const SortableTicketItem = (props: SortableTicketItemProps) => {
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging
+ } = useSortable({ id: props.id });
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ zIndex: isDragging ? 10 : 1,
+ opacity: isDragging ? 0.5 : 1,
+ position: 'relative' as const,
+ };
+
+ return (
+
+
+
+ );
+};
diff --git a/src/components/TicketList.tsx b/src/components/TicketList.tsx
index e3c21c6..c2fb4f6 100644
--- a/src/components/TicketList.tsx
+++ b/src/components/TicketList.tsx
@@ -4,10 +4,27 @@ import { fetchInProgressTickets, fetchDoneTickets, fetchTicketsByKeys, checkWork
import { useActiveTimer } from "../hooks/useActiveTimer";
import { saveSettings } from "../lib/storage";
import { TicketItem } from "./TicketItem";
+import { SortableTicketItem } from "./SortableTicketItem";
import { Loader2, AlertCircle, RefreshCw, Pin, Plus, Clock, ChevronDown } from "lucide-react";
import { Button } from "./ui/Button";
import { Input } from "./ui/Input";
import { formatDurationFromStart } from "../lib/utils";
+import {
+ DndContext,
+ pointerWithin,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ type DragEndEvent
+} from '@dnd-kit/core';
+import {
+ arrayMove,
+ SortableContext,
+ sortableKeyboardCoordinates,
+ verticalListSortingStrategy,
+} from '@dnd-kit/sortable';
+import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
interface TicketListProps {
settings: AppSettings;
@@ -25,6 +42,18 @@ export const TicketList = ({ settings, onSettingsChange, onTimeUpdate }: TicketL
const [refreshing, setRefreshing] = useState(false);
const [elapsedTime, setElapsedTime] = useState("");
+ // DnD Sensors
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: {
+ distance: 8,
+ },
+ }),
+ useSensor(KeyboardSensor, {
+ coordinateGetter: sortableKeyboardCoordinates,
+ })
+ );
+
// Collapsible section states
const [isPinnedCollapsed, setIsPinnedCollapsed] = useState(() => {
const stored = localStorage.getItem('jiratime_pinned_collapsed');
@@ -95,13 +124,77 @@ export const TicketList = ({ settings, onSettingsChange, onTimeUpdate }: TicketL
setLoading(true);
setError("");
- const inProgress = await fetchInProgressTickets(settings);
- setTickets(inProgress);
+ const fetchedTickets = await fetchInProgressTickets(settings);
+
+ // Apply Sort Order Logic
+ setTickets(() => {
+ const savedOrder = localStorage.getItem('jiratime_ticket_order');
+ let mergedTickets = [...fetchedTickets];
+
+ if (savedOrder) {
+ const orderIds: string[] = JSON.parse(savedOrder);
+
+ // 1. Sort fetched tickets based on saved order
+ // Separate tickets that are in the saved order vs new ones
+ const orderedTickets: JiraTicket[] = [];
+ const ticketMap = new Map(fetchedTickets.map(t => [t.id, t]));
+
+ // Add existing tickets in order
+ for (const id of orderIds) {
+ if (ticketMap.has(id)) {
+ orderedTickets.push(ticketMap.get(id)!);
+ ticketMap.delete(id);
+ }
+ }
+
+ // Remaining tickets in map are new
+ // Add new "In Progress" tickets to the TOP of the new batch
+ // Add others (To Do) to the bottom
+ const remaining = Array.from(ticketMap.values());
+ const newInProgress = remaining.filter(t => t.status.categoryKey === 'indeterminate');
+ const newToDo = remaining.filter(t => t.status.categoryKey !== 'indeterminate');
+
+ // Strategy:
+ // If we have a saved order, maybe we interpret "In Progress" priority as:
+ // New In-Progress tickets should probably jump to the top of the WHOLE list
+ // or just the top of the new bunch?
+ // Let's put new In-Progress at the very top to ensure visibility.
+
+ mergedTickets = [
+ ...newInProgress,
+ ...orderedTickets,
+ ...newToDo
+ ];
+ } else {
+ // Default Sort: In Progress (indeterminate) first, then others (To Do/New)
+ mergedTickets.sort((a, b) => {
+ const aInProgress = a.status.categoryKey === 'indeterminate';
+ const bInProgress = b.status.categoryKey === 'indeterminate';
+
+ if (aInProgress && !bInProgress) return -1;
+ if (!aInProgress && bInProgress) return 1;
+ return 0; // Keep relative order (fetched by updated DESC)
+ });
+ }
+
+ return mergedTickets;
+ });
+
// Fetch Pinned
if (settings.pinnedTicketKeys.length > 0) {
const pinned = await fetchTicketsByKeys(settings, settings.pinnedTicketKeys);
- setPinnedTickets(pinned);
+
+ // Sort pinned tickets to match the order in settings.pinnedTicketKeys
+ const pinnedMap = new Map(pinned.map(t => [t.key, t]));
+ const orderedPinned: JiraTicket[] = [];
+
+ settings.pinnedTicketKeys.forEach(key => {
+ const ticket = pinnedMap.get(key);
+ if (ticket) orderedPinned.push(ticket);
+ });
+
+ setPinnedTickets(orderedPinned);
} else {
setPinnedTickets([]);
}
@@ -114,6 +207,57 @@ export const TicketList = ({ settings, onSettingsChange, onTimeUpdate }: TicketL
}
};
+ const handleDragEnd = (event: DragEndEvent) => {
+ const { active, over } = event;
+
+ if (!over) return;
+ if (active.id === over.id) return;
+
+ const activeId = String(active.id);
+ const overId = String(over.id);
+
+ // Handle Pinned Tickets DnD
+ if (activeId.startsWith('pinned-')) {
+ // Ensure we are dropping within pinned list
+ if (!overId.startsWith('pinned-')) return;
+
+ setPinnedTickets((items) => {
+ const oldIndex = items.findIndex(t => `pinned-${t.id}` === activeId);
+ const newIndex = items.findIndex(t => `pinned-${t.id}` === overId);
+
+ if (oldIndex !== -1 && newIndex !== -1) {
+ const newOrder = arrayMove(items, oldIndex, newIndex);
+
+ const newKeys = newOrder.map(t => t.key);
+ saveSettings({ ...settings, pinnedTicketKeys: newKeys }).catch(console.error);
+
+ return newOrder;
+ }
+ return items;
+ });
+ }
+ // Handle My Work DnD
+ else {
+ // Ensure we are dropping within My Work list
+ if (overId.startsWith('pinned-')) return;
+
+ setTickets((items) => {
+ const oldIndex = items.findIndex((item) => item.id === activeId);
+ const newIndex = items.findIndex((item) => item.id === overId);
+
+ if (oldIndex !== -1 && newIndex !== -1) {
+ const newOrder = arrayMove(items, oldIndex, newIndex);
+
+ const orderIds = newOrder.map(t => t.id);
+ localStorage.setItem('jiratime_ticket_order', JSON.stringify(orderIds));
+
+ return newOrder;
+ }
+ return items;
+ });
+ }
+ };
+
const handleAddPin = async (e: React.FormEvent) => {
e.preventDefault();
const key = pinInput.trim().toUpperCase();
@@ -267,117 +411,98 @@ export const TicketList = ({ settings, onSettingsChange, onTimeUpdate }: TicketL
- {/* Pinned Tickets Section */}
-
-
-
- {!isPinnedCollapsed && (
- <>
-
-
-
- {pinnedTickets.map((ticket) => (
-
- handleRemovePin(ticket.key)}
- />
-
- ))}
- >
- )}
-
-
-
-
-
-
My Work
-
-
-
-
-
-
- {!isMyWorkCollapsed && (
+ {/* Pinned Tickets Section */}
- {tickets.length === 0 ? (
-
-
No tickets in progress found.
-
- ) : (
- tickets.map((ticket) => (
-
-
+
+ Pinned Tickets
+
+
+
+
+ {!isPinnedCollapsed && (
+ <>
+
+
- ))
+
+
+
+
"pinned-" + t.id)}
+ strategy={verticalListSortingStrategy}
+ >
+ {pinnedTickets.map((ticket) => (
+ handleRemovePin(ticket.key)}
+ />
+ ))}
+
+ >
)}
- )}
-
-
-
-
-
+
+
+
+
+
My Work
+
+
+
+
- {showDone && (
+ {!isMyWorkCollapsed && (
- {doneTickets.length === 0 ? (
-
No completed tickets in the last 7 days.
+ {tickets.length === 0 ? (
+
+
No tickets in progress found.
+
) : (
- doneTickets.map((ticket) => (
-
- t.id)}
+ strategy={verticalListSortingStrategy}
+ >
+ {tickets.map((ticket) => (
+
-
- ))
+ ))}
+
)}
)}
-
- {activeTimer && !tickets.find(t => t.id === activeTimer.ticketId) && !pinnedTickets.find(t => t.id === activeTimer.ticketId) && !doneTickets.find(t => t.id === activeTimer.ticketId) && (
-
-
- Timer running on hidden ticket
+
+
+
+
-
+
+ {showDone && (
+
+ {doneTickets.length === 0 ? (
+
No completed tickets in the last 7 days.
+ ) : (
+ doneTickets.map((ticket) => (
+
+
+
+ ))
+ )}
+
+ )}
- )}
+
+ {activeTimer && !tickets.find(t => t.id === activeTimer.ticketId) && !pinnedTickets.find(t => t.id === activeTimer.ticketId) && !doneTickets.find(t => t.id === activeTimer.ticketId) && (
+
+
+ Timer running on hidden ticket
+
+
+
+ )}
+
>
);
diff --git a/src/lib/jira.ts b/src/lib/jira.ts
index 8db36f9..59c6661 100644
--- a/src/lib/jira.ts
+++ b/src/lib/jira.ts
@@ -126,7 +126,8 @@ const searchTickets = async (settings: AppSettings, jql: string): Promise