diff --git a/Pipeline.code-workspace b/Pipeline.code-workspace new file mode 100644 index 0000000..876a149 --- /dev/null +++ b/Pipeline.code-workspace @@ -0,0 +1,8 @@ +{ + "folders": [ + { + "path": "." + } + ], + "settings": {} +} \ No newline at end of file diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 2ea2a43..61bc193 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -1236,10 +1236,6 @@ export default function DashboardPage() {

Git Activity

-

- Issue contributions across all tracked repositories in the - last 12 months. -

= 0 ? "ago" : "from now"; + const units = [ + { max: 60, value: 1, name: "second" }, + { max: 60 * 60, value: 60, name: "minute" }, + { max: 60 * 60 * 24, value: 60 * 60, name: "hour" }, + { max: 60 * 60 * 24 * 30, value: 60 * 60 * 24, name: "day" }, + { max: 60 * 60 * 24 * 365, value: 60 * 60 * 24 * 30, name: "month" }, + { max: Number.POSITIVE_INFINITY, value: 60 * 60 * 24 * 365, name: "year" }, + ]; + const unit = + units.find((candidate) => absSeconds < candidate.max) ?? units[0]; + const amount = Math.max(1, Math.round(absSeconds / unit.value)); + + if (amount <= 5 && unit.name === "second") { + return diffSeconds >= 0 ? "just now" : "in a few seconds"; + } + + return `${amount} ${unit.name}${amount === 1 ? "" : "s"} ${direction}`; +} + +function getAssigneeLogin(assignee: Record): string | null { + const login = assignee.login ?? assignee.username ?? assignee.name; + return typeof login === "string" && login.trim() ? login : null; +} + +function getInitials(login: string): string { + const normalized = login.trim(); + if (!normalized) return "?"; + const parts = normalized.split(/[\s._-]+/).filter(Boolean); + if (parts.length >= 2) { + return `${parts[0][0] ?? ""}${parts[1][0] ?? ""}`.toUpperCase(); + } + return normalized.slice(0, 2).toUpperCase(); +} + +function AssigneeStack({ issue }: { issue: ForgejoIssue }) { + const assignees = issue.assignees + .map(getAssigneeLogin) + .filter((login): login is string => login !== null); + + if (assignees.length === 0) { + return ( + Unassigned + ); + } + + const visible = assignees.slice(0, 3); + const overflow = assignees.length - visible.length; + + return ( +
+ {visible.map((login) => ( + + {getInitials(login)} + + ))} + {overflow > 0 ? ( + + +{overflow} + + ) : null} +
+ ); +} + +function IssueStateIcon({ state }: { state: string }) { + const isOpen = state === "open"; + const Icon = isOpen ? CircleDot : CheckCircle2; + + return ( + + + + ); +} + +function LoadingIssuesList() { + return ( +
+ {Array.from({ length: 5 }).map((_, index) => ( +
+
+
+
+
+
+ +
+ ))} +
+ ); +} + export type ForgejoIssuesTableProps = { issues: ForgejoIssue[]; repositories: ForgejoRepository[]; @@ -81,173 +206,159 @@ export function ForgejoIssuesTable({ } return map; }, [repositories]); - const columns: ColumnDef[] = useMemo( - () => [ - { - id: "repository", - header: "Repository", - cell: ({ row }) => { - const fallback = row.original.repository_id; - return ( - - {repositoryNameById.get(row.original.repository_id) ?? fallback} - - ); - }, - }, - { - accessorKey: "forgejo_issue_number", - header: "#", - cell: ({ row }) => ( - - #{row.original.forgejo_issue_number} - - - ), - }, - { - accessorKey: "title", - header: "Title", - cell: ({ row }) => ( -
- {row.original.title} -
- ), - }, - { - accessorKey: "state", - header: "State", - cell: ({ row }) => { - const state = row.original.state; - return ( - - {state} - - ); - }, - }, - { - accessorKey: "labels", - header: "Labels", - cell: ({ row }) => { - const labels = row.original.labels; - if (!labels || labels.length === 0) return null; - const visible = labels.slice(0, 3); - const overflow = labels.length - visible.length; - return ( -
- {visible.map((label, i) => ( - - ))} - {overflow > 0 && ( - - +{overflow} - - )} -
- ); - }, - }, - { - id: "assignee", - header: "Assignee", - cell: ({ row }) => { - const assignee = row.original.assignees?.[0]; - const login = - assignee && - typeof assignee === "object" && - "login" in assignee && - typeof assignee.login === "string" - ? assignee.login - : null; - - return ( - - {login || "Unassigned"} - - ); - }, - }, - { - accessorKey: "forgejo_updated_at", - header: "Updated", - cell: ({ row }) => { - try { - return ( - - {new Date(row.original.forgejo_updated_at).toLocaleDateString()} - - ); - } catch { - return ( - - {row.original.forgejo_updated_at} - - ); + const issueCounts = useMemo( + () => + issues.reduce( + (counts, issue) => { + if (issue.state === "closed") { + counts.closed += 1; + } else { + counts.open += 1; } + return counts; }, - }, - { - id: "actions", - header: "Actions", - cell: ({ row }) => { - const issue = row.original; - const canShowClose = - canClose && issue.state === "open" && !issue.is_pull_request; - if (!canShowClose) { - return null; - } - const repositoryName = - repositoryNameById.get(issue.repository_id) ?? issue.repository_id; - return ( - - ); - }, - }, - ], - [canClose, repositoryNameById], + { open: 0, closed: 0 }, + ), + [issues], ); - const table = useReactTable({ - data: issues, - columns, - getCoreRowModel: getCoreRowModel(), - }); return ( <>
- , - title: "No Git Project issues found", - description: - "Sync a repository to pull issues into Pipeline, or adjust your filters.", - }} - /> +
+
+ + + {issueCounts.open} Open + + + + {issueCounts.closed} Closed + +
+ + {issues.length} visible issue{issues.length === 1 ? "" : "s"} + +
+ + {isLoading ? ( + + ) : issues.length === 0 ? ( +
+
+ +
+

+ No Git Project issues found +

+

+ Sync a repository to pull issues into Pipeline, or adjust your + filters. +

+
+ ) : ( +
+ {issues.map((issue) => { + const repositoryName = + repositoryNameById.get(issue.repository_id) ?? + issue.repository_id; + const visibleLabels = issue.labels.slice(0, 4); + const hiddenLabelCount = + issue.labels.length - visibleLabels.length; + const updatedAt = + issue.state === "closed" + ? issue.forgejo_closed_at || issue.forgejo_updated_at + : issue.forgejo_updated_at; + const stateVerb = issue.state === "closed" ? "closed" : "updated"; + const canShowClose = + canClose && issue.state === "open" && !issue.is_pull_request; + + return ( +
+ + +
+
+ + {issue.title} + + {visibleLabels.map((label, i) => ( + + ))} + {hiddenLabelCount > 0 ? ( + + +{hiddenLabelCount} + + ) : null} +
+ +
+ + {repositoryName} + + + #{issue.forgejo_issue_number} + + + {stateVerb} {formatRelativeTime(updatedAt)} + + {issue.author ? by {issue.author} : null} + {issue.milestone?.title ? ( + + + + {issue.milestone.title} + + + ) : null} +
+
+ +
+ +
+ + + + {canShowClose ? ( + + ) : null} +
+
+
+ ); + })} +
+ )}