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 */} -
-
-

- Pinned Tickets -

- -
- - {!isPinnedCollapsed && ( - <> - -
- setPinInput(e.target.value)} - className="h-8 text-sm" - disabled={isPinning} - /> - -
- - {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 && ( + <> + +
+ setPinInput(e.target.value)} + className="h-8 text-sm" + disabled={isPinning} /> -
- )) + + + + "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