Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/CAPABILITIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ QueueMint includes a preview-first time logging workflow while keeping Jira work
- The relevant-today filter requires evidence from today: an existing worklog, an update today, or completion today. In Progress by itself is not enough. At most eight issues are ranked.
- Estimates never decide what the user worked on. After selection, they can either weight a chosen target duration or be used exactly through Estimate only mode, which uses Jira remaining estimate and does not stretch worklogs to the daily target.
- Each issue row exposes status, assignee, sprint/backlog, estimate, time already logged today, and last update.
- Worklog context can be copied/downloaded as an AI-ready JSON package with selected/visible issue metadata and already-logged time.
- Worklog context can be copied/downloaded as an AI-ready JSON package with selected/visible issue metadata, already-logged time, and the full per-issue Jira description. Images are opt-in, embedded as Base64 from Jira attachments only on explicit export, with 20-image, 1.5 MiB/image, and 10 MiB total caps; skipped images are reported in the JSON and UI.
- Worklog descriptions never submit blank: per-issue descriptions override the shared draft description, and otherwise QueueMint generates `Worked on ISSUE-KEY: issue summary` with a final issue-key-only fallback in the Jira writer.
- AI-produced JSON can be pasted or uploaded and is converted into the same editable review table before any Jira write.
- Optional OpenAI allocation uses only the explicit selected issue set plus the user's work note and disclosed context.
Expand Down
4 changes: 2 additions & 2 deletions public/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -258,12 +258,12 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
return true
}

if (message.type === "QUEUEMINT_JIRA_FETCH_AVATAR") {
if (message.type === "QUEUEMINT_JIRA_FETCH_AVATAR" || message.type === "QUEUEMINT_JIRA_FETCH_IMAGE") {
loadConnection().then((connection) => {
if (!connection || !isSafeSameOriginAssetUrl(message.url, connection.origin)) {
return { ok: false, error: { code: "INVALID_AVATAR_URL", message: "Blocked invalid Jira avatar URL." } }
}
return forwardToJiraTab({ type: "QUEUEMINT_JIRA_FETCH_AVATAR", url: message.url })
return forwardToJiraTab({ type: message.type, url: message.url })
}).then(sendResponse)
return true
}
Expand Down
2 changes: 1 addition & 1 deletion public/jira-bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@
return true
}

if (message.type === "QUEUEMINT_JIRA_FETCH_AVATAR") {
if (message.type === "QUEUEMINT_JIRA_FETCH_AVATAR" || message.type === "QUEUEMINT_JIRA_FETCH_IMAGE") {
const url = safeSameOriginAssetUrl(message.url)
if (!url) {
sendResponse({ ok: false, error: { code: "INVALID_AVATAR_URL", message: "Blocked unsafe Jira avatar URL." } })
Expand Down
97 changes: 97 additions & 0 deletions src/features/worklog/WorklogExportSheet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { useEffect, useState } from "react"
import { ClipboardCopy, Download, LoaderCircle } from "lucide-react"
import { toast } from "sonner"

import { Button } from "@/components/ui/button"
import { Sheet, SheetBody, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { getJiraImageDataUrl, getJiraIssueExportDetails } from "@/lib/jira"
import { downloadJson } from "@/lib/utils"
import type { AppLocale, JiraLiveIssue } from "@/types"
import type { buildWorklogAiPackage } from "./worklog-json"
import { enrichWorklogAiPackage, type ExportProgress } from "./worklog-rich-export"

type PreparedExport = { withImages: boolean; payload: unknown; skipped: number }

export function WorklogExportSheet({ open, onOpenChange, locale, issues, date, buildPackage }: {
open: boolean
onOpenChange: (open: boolean) => void
locale: AppLocale
issues: JiraLiveIssue[]
date: Date
buildPackage: () => ReturnType<typeof buildWorklogAiPackage>
}) {
const isFa = locale === "fa"
const [includeImages, setIncludeImages] = useState(false)
const [busy, setBusy] = useState(false)
const [progress, setProgress] = useState<ExportProgress | null>(null)
const [prepared, setPrepared] = useState<PreparedExport | null>(null)
const [skipped, setSkipped] = useState(0)
const [error, setError] = useState("")

useEffect(() => {
if (!open) { setIncludeImages(false); setPrepared(null); setProgress(null); setSkipped(0); setError("") }
}, [open])

async function exportData(action: "copy" | "download") {
if (busy || !issues.length) return
setBusy(true)
setError("")
try {
let payload: unknown = prepared?.payload
let missing = prepared?.skipped ?? 0
if (!prepared || prepared.withImages !== includeImages) {
const result = await enrichWorklogAiPackage(buildPackage(), {
includeImages,
loadDetails: getJiraIssueExportDetails,
loadImage: getJiraImageDataUrl,
onProgress: setProgress,
})
payload = result.payload
missing = result.skipped.length
setPrepared({ withImages: includeImages, payload, skipped: missing })
}
if (action === "copy") await navigator.clipboard.writeText(JSON.stringify(payload, null, 2))
else {
const day = date.getFullYear() + "-" + String(date.getMonth() + 1).padStart(2, "0") + "-" + String(date.getDate()).padStart(2, "0")
downloadJson("queuemint-worklog-ai-" + day + ".json", payload)
}
setSkipped(missing)
if (missing) toast.warning(isFa ? "برخی تصاویر در خروجی قرار نگرفتند؛ جزئیات در JSON ثبت شده است." : "Some images were skipped; details are in the JSON.")
else toast.success(action === "copy" ? (isFa ? "JSON برای AI کپی شد" : "AI JSON copied") : (isFa ? "خروجی JSON آماده شد" : "JSON downloaded"))
} catch (reason) {
const message = reason instanceof Error ? reason.message : "Could not export Jira issues."
setError(message)
toast.error(isFa ? "ساخت خروجی ناموفق بود" : "Could not export Jira issues")
} finally { setBusy(false); setProgress(null) }
}

return (
<Sheet open={open} onOpenChange={(value) => { if (!busy) onOpenChange(value) }}>
<SheetContent side={isFa ? "left" : "right"} className="w-full sm:max-w-xl">
<SheetHeader>
<SheetTitle>{isFa ? "خروجی تسک‌ها برای AI" : "Export Jira issues for AI"}</SheetTitle>
<SheetDescription>{isFa ? "عنوان، وضعیت و توضیحات کامل هر تسک در خروجی قرار می‌گیرد." : "Export issue metadata and full Jira descriptions. Images are optional."}</SheetDescription>
</SheetHeader>
<SheetBody className="space-y-4">
<p className="text-sm text-muted-foreground">{isFa ? "تعداد تسک‌ها" : "Issues to export"}: <strong className="text-foreground">{issues.length}</strong></p>
<label className="flex cursor-pointer items-start gap-3 rounded-[var(--qm-panel-radius)] border bg-muted/10 p-4">
<input type="checkbox" checked={includeImages} disabled={busy} onChange={(event) => { setIncludeImages(event.target.checked); setPrepared(null); setSkipped(0) }} className="qm-selection-checkbox mt-0.5 size-4 shrink-0" />
<span className="min-w-0 space-y-1">
<span className="block text-sm font-semibold">{isFa ? "افزودن تصاویر ضمیمه" : "Include attached images"}</span>
<span className="block text-xs leading-5 text-muted-foreground">{isFa ? "تصاویر در JSON به صورت Base64 قرار می‌گیرند (حداکثر ۲۰ تصویر، هر تصویر ۱٫۵ مگابایت و در مجموع ۱۰ مگابایت)." : "Embed images as Base64 in the JSON (up to 20 images, 1.5 MB each, 10 MB total). This increases the file size."}</span>
</span>
</label>
{includeImages ? <p className="text-xs leading-5 text-muted-foreground">{isFa ? "تصاویر ممکن است اطلاعات حساس داشته باشند. فقط در صورتی که قصد اشتراک‌گذاری دارید آن‌ها را اضافه کنید." : "Images may contain sensitive information. Only enable this when you intend to share them."}</p> : null}
{progress ? <div role="status" className="flex items-center gap-2 rounded-[var(--qm-control-radius)] border bg-muted/10 px-3 py-2 text-sm"><LoaderCircle className="size-4 animate-spin" />{progress.phase === "details" ? (isFa ? "دریافت توضیحات" : "Loading descriptions") : (isFa ? "دریافت تصاویر" : "Loading images")} {progress.completed}/{progress.total}</div> : null}
{skipped > 0 ? <p className="text-xs text-warning">{isFa ? "تعداد تصاویر اضافه‌نشده: " : "Skipped images: "}{skipped}</p> : null}
{error ? <p role="alert" className="rounded-[var(--qm-control-radius)] border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">{error}</p> : null}
</SheetBody>
<SheetFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>{isFa ? "بستن" : "Close"}</Button>
<Button variant="outline" onClick={() => void exportData("copy")} disabled={busy || !issues.length}><ClipboardCopy className="size-4" />{isFa ? "کپی برای AI" : "Copy for AI"}</Button>
<Button onClick={() => void exportData("download")} disabled={busy || !issues.length}>{busy ? <LoaderCircle className="size-4 animate-spin" /> : <Download className="size-4" />}{isFa ? "دانلود JSON" : "Download JSON"}</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
10 changes: 4 additions & 6 deletions src/features/worklog/WorklogIssuePicker.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useMemo, useState } from "react"
import { Activity, ArrowUpDown, BookOpen, Bug, CheckSquare2, Clock3, ClipboardCopy, Columns3, Download, Filter, Grid2X2, ListFilter, Search, Shapes, Table2, Upload, UserRound, UserX, UsersRound, X } from "lucide-react"
import { Activity, ArrowUpDown, BookOpen, Bug, CheckSquare2, Clock3, Columns3, Download, Filter, Grid2X2, ListFilter, Search, Shapes, Table2, Upload, UserRound, UserX, UsersRound, X } from "lucide-react"

import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
Expand All @@ -20,7 +20,7 @@ function itemLabel(items: WorklogFilterOption[], value: string) { return items.f
function userAvatar(user?: JiraUser) { return user?.avatarUrls?.["24x24"] ?? user?.avatarUrls?.["32x32"] ?? user?.avatarUrls?.["48x48"] }
function typeIcon(type: string) { const value = type.toLowerCase(); return value.includes("bug") ? Bug : value.includes("story") ? BookOpen : value.includes("task") ? CheckSquare2 : Shapes }

export function WorklogIssuePicker({ locale, date, issues, selectedKeys, currentUser, boardId, sprintFilter, dailyCandidateKeys, loggedMinutesByIssue, onSelectedKeysChange, onMoveIssueStatus, onCopyForAi, onDownloadForAi, onOpenImport }: {
export function WorklogIssuePicker({ locale, date, issues, selectedKeys, currentUser, boardId, sprintFilter, dailyCandidateKeys, loggedMinutesByIssue, onSelectedKeysChange, onMoveIssueStatus, onOpenExport, onOpenImport }: {
locale: AppLocale
date: Date
issues: JiraLiveIssue[]
Expand All @@ -32,8 +32,7 @@ export function WorklogIssuePicker({ locale, date, issues, selectedKeys, current
loggedMinutesByIssue: Record<string, number>
onSelectedKeysChange: (keys: Set<string>) => void
onMoveIssueStatus?: (issueKey: string, target: Pick<JiraBoardColumn, "name" | "statusIds">) => Promise<boolean>
onCopyForAi: (issues: JiraLiveIssue[]) => void
onDownloadForAi: (issues: JiraLiveIssue[]) => void
onOpenExport: (issues: JiraLiveIssue[]) => void
onOpenImport: () => void
}) {
const isFa = locale === "fa"
Expand Down Expand Up @@ -101,8 +100,7 @@ export function WorklogIssuePicker({ locale, date, issues, selectedKeys, current
<Button variant="outline" size="sm" className="h-9" onClick={() => setFiltersOpen((value) => !value)} aria-expanded={filtersOpen}><Filter className="size-3.5" />{isFa ? "فیلترها" : "Filters"}{activeFilters ? <Badge className="ms-1 h-4 min-w-4 px-1 text-[9px]">{activeFilters}</Badge> : null}</Button>
<WorklogFilterSelect ariaLabel={isFa ? "مرتب‌سازی تسک‌ها" : "Sort issues"} value={sort} items={sortItems} onValueChange={(value) => setSort(value as WorklogIssueSort)} className="h-9 min-w-[210px]" />
<span className="hidden h-7 w-px bg-border 2xl:block" />
<Button variant="ghost" size="sm" className="h-9" onClick={() => onCopyForAi(exportIssues)} disabled={!exportIssues.length}><ClipboardCopy className="size-3.5" /><span className="hidden 2xl:inline">{isFa ? "کپی برای AI" : "Copy for AI"}</span></Button>
<Button variant="ghost" size="icon-sm" className="size-9" onClick={() => onDownloadForAi(exportIssues)} disabled={!exportIssues.length} aria-label={isFa ? "دانلود JSON" : "Download JSON"}><Download className="size-3.5" /></Button>
<Button variant="outline" size="sm" className="h-9" onClick={() => onOpenExport(exportIssues)} disabled={!exportIssues.length}><Download className="size-3.5" />{isFa ? "خروجی برای AI" : "Export for AI"}</Button>
<Button variant="outline" size="sm" className="h-9" onClick={onOpenImport}><Upload className="size-3.5" />{isFa ? "Import" : "Import JSON"}</Button>
</div>

Expand Down
12 changes: 5 additions & 7 deletions src/features/worklog/WorklogScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ import { toast } from "sonner"

import { Button } from "@/components/ui/button"
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
import { downloadJson } from "@/lib/utils"
import type { ActivityEntry } from "@/lib/storage"
import type { AppLocale, JiraBoard, JiraBoardColumn, JiraLiveIssue, JiraProject, JiraSprint, JiraUser } from "@/types"
import { WorklogContextBar } from "./WorklogContextBar"
import { WorklogDateToolbar } from "./WorklogDateToolbar"
import { WorklogDraftSidebar } from "./WorklogDraftSidebar"
import { WorklogExportSheet } from "./WorklogExportSheet"
import { WorklogImportSheet } from "./WorklogImportSheet"
import { WorklogIssuePicker } from "./WorklogIssuePicker"
import { buildWorklogAiPackage } from "./worklog-json"
Expand Down Expand Up @@ -42,6 +42,8 @@ export function WorklogScreen({ locale, issues, selectedKeys, onSelectedKeysChan
const [strategy, setStrategy] = useState<WorklogDistributionStrategy>("estimate")
const [sprintFilter, setSprintFilter] = useState("all")
const [importOpen, setImportOpen] = useState(false)
const [exportOpen, setExportOpen] = useState(false)
const [exportIssues, setExportIssues] = useState<JiraLiveIssue[]>([])
const [compactDraftOpen, setCompactDraftOpen] = useState(false)
const scopeRef = useRef<HTMLDivElement>(null)
const issueRef = useRef<HTMLDivElement>(null)
Expand All @@ -68,11 +70,6 @@ export function WorklogScreen({ locale, issues, selectedKeys, onSelectedKeysChan
return buildWorklogAiPackage(exportIssues, model.targetMinutes, model.dayMinutes, target, model.note, model.loggedMinutesByIssue, date)
}

async function copyForAi(exportIssues: JiraLiveIssue[]) {
try { await navigator.clipboard.writeText(JSON.stringify(aiPackage(exportIssues), null, 2)); toast.success(isFa ? "JSON برای AI کپی شد" : "AI JSON copied") }
catch { toast.error(isFa ? "کپی JSON ناموفق بود" : "Could not copy JSON") }
}

function changeDate(next: Date) { setDate(next) }

function deselect(issueKey: string) {
Expand Down Expand Up @@ -109,7 +106,7 @@ export function WorklogScreen({ locale, issues, selectedKeys, onSelectedKeysChan
<WorklogSummaryStrip locale={locale} targetMinutes={model.targetMinutes} dayMinutes={model.dayMinutes} scopeMinutes={model.scopeMinutes} remainingMinutes={model.remainingMinutes} draftMinutes={model.draftMinutes} daySummary={model.daySummary} date={date} />
<WorklogWorkflowRail locale={locale} currentStep={currentStep} scopeReady={scopeReady} selectedCount={selectedKeys.size} draftCount={model.draft.length} draftMinutes={model.draftMinutes} onStep={scrollTo} />
<div ref={scopeRef} className="scroll-mt-[calc(var(--qm-topbar-height)+72px)]"><WorklogContextBar locale={locale} projects={projects} projectKey={projectKey} boards={boards} boardId={boardId} sprints={sprints} sprintFilter={sprintFilter} loading={contextLoading} onProjectChange={onProjectChange} onBoardChange={onBoardChange} onSprintFilter={setSprintFilter} /></div>
<div ref={issueRef} className="qm-worklog-issue-stage scroll-mt-[calc(var(--qm-topbar-height)+72px)]"><WorklogIssuePicker locale={locale} date={date} issues={issues} selectedKeys={selectedKeys} currentUser={currentUser} boardId={boardId} sprintFilter={sprintFilter} dailyCandidateKeys={dailyCandidateKeys} loggedMinutesByIssue={model.loggedMinutesByIssue} onSelectedKeysChange={onSelectedKeysChange} onMoveIssueStatus={onMoveIssueStatus} onCopyForAi={(items) => void copyForAi(items)} onDownloadForAi={(items) => downloadJson(`queuemint-worklog-ai-${date.toISOString().slice(0, 10)}.json`, aiPackage(items))} onOpenImport={() => setImportOpen(true)} /></div>
<div ref={issueRef} className="qm-worklog-issue-stage scroll-mt-[calc(var(--qm-topbar-height)+72px)]"><WorklogIssuePicker locale={locale} date={date} issues={issues} selectedKeys={selectedKeys} currentUser={currentUser} boardId={boardId} sprintFilter={sprintFilter} dailyCandidateKeys={dailyCandidateKeys} loggedMinutesByIssue={model.loggedMinutesByIssue} onSelectedKeysChange={onSelectedKeysChange} onMoveIssueStatus={onMoveIssueStatus} onOpenExport={(items) => { setExportIssues(items); setExportOpen(true) }} onOpenImport={() => setImportOpen(true)} /></div>
</div>

<div ref={draftRef} className="qm-worklog-draft-column min-w-0 scroll-mt-[calc(var(--qm-topbar-height)+20px)]">{draftSidebar()}</div>
Expand All @@ -125,6 +122,7 @@ export function WorklogScreen({ locale, issues, selectedKeys, onSelectedKeysChan
</SheetContent>
</Sheet>

<WorklogExportSheet open={exportOpen} onOpenChange={setExportOpen} locale={locale} issues={exportIssues} date={date} buildPackage={() => aiPackage(exportIssues)} />
<WorklogImportSheet open={importOpen} onOpenChange={setImportOpen} locale={locale} issues={issues} onImport={(entries) => { model.setDraft(entries); onSelectedKeysChange(new Set(entries.map((entry) => entry.issueKey))); autoBuiltFor.current = entries.map((entry) => entry.issueKey).sort().join("|"); window.setTimeout(() => scrollTo(4), 40) }} />
</div>
)
Expand Down
Loading
Loading