diff --git a/manifest.json b/manifest.json index 17ed1cb..f40ad06 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "JiraTime", - "version": "1.3.1", + "version": "1.4.0", "description": "Simple Jira Time Tracking for Developers. By yours truly Bernhard Dorn.", "author": "Bernhard Dorn", "action": { diff --git a/package.json b/package.json index 64d38e1..822072f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "jiratime", "private": true, - "version": "1.3.1", + "version": "1.4.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src/App.tsx b/src/App.tsx index 9423135..8538557 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,7 +5,8 @@ import type { AppSettings } from "./lib/types"; import { Settings as SettingsIcon, Clock, ListChecks, HelpCircle } from "lucide-react"; import { Button } from "./components/ui/Button"; import { TicketList } from "./components/TicketList"; -import { cn } from "./lib/utils"; +import { cn, formatDuration } from "./lib/utils"; +import { fetchTodaysTime } from "./lib/jira"; @@ -15,6 +16,22 @@ function App() { const [view, setView] = useState("list"); const [settings, setSettings] = useState(null); const [loading, setLoading] = useState(true); + const [todaysTimeSeconds, setTodaysTimeSeconds] = useState(null); + const [isTimeRefreshing, setIsTimeRefreshing] = useState(false); + + const fetchTime = async (currentSettings: AppSettings) => { + setIsTimeRefreshing(true); + try { + const time = await fetchTodaysTime(currentSettings); + setTodaysTimeSeconds(time); + } catch (e) { + console.error("Failed to fetch time:", e); + // Don't reset to 0 on error, keep last known good value or 0 if none + if (todaysTimeSeconds === null) setTodaysTimeSeconds(0); + } finally { + setIsTimeRefreshing(false); + } + }; // Easter Egg State const [logoClicks, setLogoClicks] = useState(0); @@ -76,6 +93,10 @@ function App() { setView("settings"); } setLoading(false); + // Fetch time after config is loaded + if (s?.jiraHost && s?.jiraPat) { + fetchTime(s); + } }; const handleSaveSettings = () => { @@ -105,6 +126,27 @@ function App() {

JiraTime

+ {/* Today's Time Display - Centered/Right in header */} + {settings && view !== "settings" && ( +
+ Today + {todaysTimeSeconds !== null ? ( + + {formatDuration(todaysTimeSeconds)} + + ) : ( +
+
+
+
+
+ )} +
+ )} +
{settings && view !== "about" && (
) : settings ? ( - + fetchTime(settings)} + /> ) : (
Please configure settings first. diff --git a/src/components/TicketList.tsx b/src/components/TicketList.tsx index c86ac00..deea447 100644 --- a/src/components/TicketList.tsx +++ b/src/components/TicketList.tsx @@ -4,16 +4,18 @@ import { fetchInProgressTickets, fetchDoneTickets, fetchTicketsByKeys } from ".. import { useActiveTimer } from "../hooks/useActiveTimer"; import { saveSettings } from "../lib/storage"; import { TicketItem } from "./TicketItem"; -import { Loader2, AlertCircle, RefreshCw, Pin, Plus } from "lucide-react"; +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"; interface TicketListProps { settings: AppSettings; onSettingsChange?: () => void; + onTimeUpdate?: () => void; } -export const TicketList = ({ settings, onSettingsChange }: TicketListProps) => { +export const TicketList = ({ settings, onSettingsChange, onTimeUpdate }: TicketListProps) => { const [tickets, setTickets] = useState([]); const [pinnedTickets, setPinnedTickets] = useState([]); const [doneTickets, setDoneTickets] = useState([]); @@ -21,6 +23,17 @@ export const TicketList = ({ settings, onSettingsChange }: TicketListProps) => { const [error, setError] = useState(""); const [showDone, setShowDone] = useState(false); const [refreshing, setRefreshing] = useState(false); + const [elapsedTime, setElapsedTime] = useState(""); + + // Collapsible section states + const [isPinnedCollapsed, setIsPinnedCollapsed] = useState(() => { + const stored = localStorage.getItem('jiratime_pinned_collapsed'); + return stored ? JSON.parse(stored) : false; + }); + const [isMyWorkCollapsed, setIsMyWorkCollapsed] = useState(() => { + const stored = localStorage.getItem('jiratime_mywork_collapsed'); + return stored ? JSON.parse(stored) : false; + }); // Manual Pin Binding const [pinInput, setPinInput] = useState(""); @@ -32,18 +45,57 @@ export const TicketList = ({ settings, onSettingsChange }: TicketListProps) => { loadTickets(); }, [settings]); // If settings change, reload + // Auto-scroll to active ticket on load + useEffect(() => { + if (activeTimer && !loading) { + // Wait a bit for the DOM to render + const timer = setTimeout(() => { + const el = document.getElementById(`ticket-${activeTimer.ticketId}`); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + }, 300); + return () => clearTimeout(timer); + } + }, [activeTimer?.ticketId, loading]); + + // Update elapsed time display + useEffect(() => { + let interval: number; + if (activeTimer) { + const update = () => { + setElapsedTime(formatDurationFromStart(activeTimer.startTime)); + }; + update(); // Initial update + interval = window.setInterval(update, 1000); + } else { + setElapsedTime(""); + } + return () => clearInterval(interval); + }, [activeTimer?.ticketId, activeTimer?.startTime]); + + // Fetch today's time AFTER tickets load (doesn't block initial ticket display) + + + // Toggle functions with localStorage persistence + const togglePinnedCollapse = () => { + const newValue = !isPinnedCollapsed; + setIsPinnedCollapsed(newValue); + localStorage.setItem('jiratime_pinned_collapsed', JSON.stringify(newValue)); + }; + + const toggleMyWorkCollapse = () => { + const newValue = !isMyWorkCollapsed; + setIsMyWorkCollapsed(newValue); + localStorage.setItem('jiratime_mywork_collapsed', JSON.stringify(newValue)); + }; + const loadTickets = async () => { try { setLoading(true); setError(""); - const inProgress = await fetchInProgressTickets(settings); - - // Filter duplicates: Tickets in progress might also be in pinned list. - // Requirement: "visual distinction between my tickets and the tickets i dded manuallly" - // We'll keep them in separate lists. If a ticket is in both, maybe duplicate is okay? - // Or remove from pinned if it's in progress? - // Let's keep them separate for now as requested. + const inProgress = await fetchInProgressTickets(settings); setTickets(inProgress); // Fetch Pinned @@ -115,6 +167,9 @@ export const TicketList = ({ settings, onSettingsChange }: TicketListProps) => { const handleRefresh = async () => { setRefreshing(true); + // Refresh time in parent + onTimeUpdate?.(); + await loadTickets(); if (showDone) { try { @@ -157,119 +212,188 @@ export const TicketList = ({ settings, onSettingsChange }: TicketListProps) => { ); } + return ( -
- {/* Pinned Tickets Section */} -
-
-

- Pinned Tickets -

+ <> +
+
+ {pinnedTickets.length > 0 && ( + + )} + {tickets.length > 0 && ( + + )}
+
-
- setPinInput(e.target.value)} - className="h-8 text-sm" - disabled={isPinning} - /> - -
- - {pinnedTickets.map((ticket) => ( - handleRemovePin(ticket.key)} - /> - ))} + {activeTimer && ( + + )} +
-
- -
-

In Progress

- -
-
- {tickets.length === 0 ? ( -
-

No tickets in progress found.

-
- ) : ( - tickets.map((ticket) => ( - + {/* 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

+
+ + +
- {showDone && ( + {!isMyWorkCollapsed && (
- {doneTickets.length === 0 ? ( -

No completed tickets in the last 7 days.

+ {tickets.length === 0 ? ( +
+

No tickets in progress found.

+
) : ( - doneTickets.map((ticket) => ( - + tickets.map((ticket) => ( +
+ +
)) )}
)} -
- {activeTimer && !tickets.find(t => t.id === activeTimer.ticketId) && !doneTickets.find(t => t.id === activeTimer.ticketId) && ( - // Edge case: Timer running on a ticket that is not in the list anymore? - // Should probably fetch it or show a sticky footer. - // For now, let's assume it's rare or user will see it update in background. - // A sticky footer "Active Timer: KEY-123 (2m 3s)" would be nice. -
-
- 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 4e62499..98596eb 100644 --- a/src/lib/jira.ts +++ b/src/lib/jira.ts @@ -177,3 +177,96 @@ export const addWorklog = async ( throw new Error(`Failed to log time: ${response.status} ${response.statusText} - ${errorText}`); } }; + +export const fetchTodaysTime = async (settings: AppSettings): Promise => { + try { + console.log('[fetchTodaysTime] Starting...'); + // 1. Get current user's account ID + const myselfResponse = await fetch(`${settings.jiraHost}/rest/api/3/myself`, { + headers: createHeaders(settings), + }); + if (!myselfResponse.ok) { + console.warn('[fetchTodaysTime] Failed to get user info'); + return 0; + } + const myself = await myselfResponse.json(); + const accountId = myself.accountId; + console.log('[fetchTodaysTime] Account ID:', accountId); + + // 2. Find recently updated issues + // worklogDate/worklogAuthor JQL fields return HTTP 410 (deprecated/removed) + // Search broadly (30 days, no assignee filter) and filter worklogs client-side + // This catches all tickets user worked on, regardless of assignment/status + const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD in UTC + console.log('[fetchTodaysTime] Today (UTC):', today); + + const searchResponse = await fetch(`${settings.jiraHost}/rest/api/3/search/jql`, { + method: "POST", + headers: createHeaders(settings), + body: JSON.stringify({ + jql: `updated >= -30d ORDER BY updated DESC`, + fields: ["key"], + maxResults: 300 + }) + }); + + if (!searchResponse.ok) { + console.warn('[fetchTodaysTime] Search failed'); + return 0; + } + const searchData = await searchResponse.json(); + const issueKeys = searchData.issues.map((i: any) => i.key); + console.log('[fetchTodaysTime] Issues with worklogs today:', issueKeys); + + if (issueKeys.length === 0) { + console.log('[fetchTodaysTime] No issues found'); + return 0; + } + + // 3. Fetch worklogs for each issue and sum up today's entries + // today is already declared above + let totalSeconds = 0; + + // Fetch worklogs in parallel + const worklogPromises = issueKeys.map(async (key: string) => { + const wlResponse = await fetch(`${settings.jiraHost}/rest/api/3/issue/${key}/worklog`, { + headers: createHeaders(settings), + }); + if (!wlResponse.ok) return []; + const wlData = await wlResponse.json(); + return wlData.worklogs || []; + }); + + const allWorklogsResults = await Promise.all(worklogPromises); + + // Get today's date in local timezone (not UTC) for accurate date matching + const now = new Date(); + const todayLocal = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; + console.log('[fetchTodaysTime] Comparing with local date:', todayLocal, '(UTC was:', today, ')'); + + for (const worklogs of allWorklogsResults) { + for (const wl of worklogs) { + const isMine = wl.author.accountId === accountId; + // Extract just the date part from "2026-01-14T08:00.000+0100" -> "2026-01-14" + const worklogDate = wl.started.split('T')[0]; + const isToday = worklogDate === todayLocal; + console.log('[fetchTodaysTime] Worklog:', { + started: wl.started, + worklogDate, + isMine, + isToday, + seconds: wl.timeSpentSeconds + }); + if (isMine && isToday) { + totalSeconds += wl.timeSpentSeconds; + } + } + } + + console.log('[fetchTodaysTime] Total seconds:', totalSeconds); + return totalSeconds; + } catch (error) { + console.error("Failed to fetch today's time:", error); + return 0; + } +};