Skip to content
Open
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
12 changes: 9 additions & 3 deletions apps/api/internal/handler/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,10 @@ func (h *IssueHandler) ListWorkspaceDrafts(c *gin.Context) {
slug := c.Param("slug")
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
if limit <= 0 || limit > 100 {
if limit <= 0 {
limit = 50
} else if limit > 100 {
limit = 100
}
list, err := h.Issue.ListDraftsForWorkspace(c.Request.Context(), slug, user.ID, limit, offset)
if err != nil {
Expand All @@ -75,8 +77,10 @@ func (h *IssueHandler) ListWorkspaceArchived(c *gin.Context) {
slug := c.Param("slug")
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
if limit <= 0 || limit > 100 {
if limit <= 0 {
limit = 50
} else if limit > 100 {
limit = 100
}
list, err := h.Issue.ListArchivedForWorkspace(c.Request.Context(), slug, user.ID, limit, offset)
if err != nil {
Expand Down Expand Up @@ -106,8 +110,10 @@ func (h *IssueHandler) List(c *gin.Context) {
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
if limit <= 0 || limit > 100 {
if limit <= 0 {
limit = 50
} else if limit > 100 {
limit = 100
}
list, err := h.Issue.List(c.Request.Context(), slug, projectID, user.ID, limit, offset)
if err != nil {
Expand Down
4 changes: 3 additions & 1 deletion apps/api/internal/handler/issue_archive.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,10 @@ func (h *IssueHandler) ListArchived(c *gin.Context) {
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
if limit <= 0 || limit > 100 {
if limit <= 0 {
limit = 50
} else if limit > 100 {
limit = 100
}
list, err := h.Issue.ListArchived(c.Request.Context(), slug, projectID, user.ID, limit, offset)
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/AddExistingWorkItemModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export function AddExistingWorkItemModal({
setLoading(true);
setError(null);
Promise.all([
issueService.list(workspaceSlug, projectId, { limit: 2000 }),
issueService.listAll(workspaceSlug, projectId),
moduleService.listIssueIds(workspaceSlug, projectId, moduleId),
])
.then(([issues, ids]) => {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/layout/PageHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export function PageHeader() {
.get(workspaceSlug, projectId)
.then((p) => {
if (!cancelled) setProject(p ?? null);
return p ? issueService.list(workspaceSlug, projectId, { limit: 1000 }) : [];
return p ? issueService.listAll(workspaceSlug, projectId) : [];
})
.then((issues) => {
if (!cancelled && Array.isArray(issues)) setProjectIssueCount(issues.length);
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/settings/modals/ExportModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,8 @@ export function ExportModal({
project_name?: string;
}
> = [];
const limit = 2000;
// The server caps `limit` at 100, so page through in 100s.
const limit = 100;
for (const pid of projectIds) {
const proj = projects.find((p) => p.id === pid);
let offset = 0;
Expand Down
4 changes: 1 addition & 3 deletions apps/web/src/pages/AnalyticsOverviewPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,7 @@ export function AnalyticsOverviewPage() {
setMembers(mem ?? []);
}
if (!cancelled && projs?.length) {
return Promise.all(
projs.map((p) => issueService.list(workspaceSlug!, p.id, { limit: 200 })),
);
return Promise.all(projs.map((p) => issueService.listAll(workspaceSlug!, p.id)));
}
return [];
})
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/pages/AnalyticsWorkItemsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ export function AnalyticsWorkItemsPage() {
if (!cancelled && projs?.length) setProjects(projs);
if (!cancelled && projs?.length) {
return Promise.all([
...projs.map((p) => issueService.list(workspaceSlug!, p.id, { limit: 200 })),
...projs.map((p) => issueService.listAll(workspaceSlug!, p.id)),
...projs.map((p) => stateService.list(workspaceSlug!, p.id)),
]);
}
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/pages/CycleDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ export function CycleDetailPage() {
workspaceService.getBySlug(workspaceSlug),
projectService.get(workspaceSlug, projectId),
cycleService.list(workspaceSlug, projectId),
issueService.list(workspaceSlug, projectId, { limit: 1000 }),
issueService.listAll(workspaceSlug, projectId),
stateService.list(workspaceSlug, projectId),
labelService.list(workspaceSlug, projectId),
moduleService.list(workspaceSlug, projectId),
Expand Down Expand Up @@ -309,7 +309,7 @@ export function CycleDetailPage() {
setCycle(res.cycle);
// Some work items may have moved out; refresh the list and the snapshot.
const [allIssues, snap] = await Promise.all([
issueService.list(workspaceSlug, projectId, { limit: 500 }),
issueService.listAll(workspaceSlug, projectId),
cycleService.getProgress(workspaceSlug, projectId, cycle.id),
]);
setIssues(allIssues ?? []);
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/pages/CyclesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,7 @@ export function CyclesPage() {
projectService.get(workspaceSlug, projectId),
cycleService.list(workspaceSlug, projectId),
workspaceService.listMembers(workspaceSlug),
issueService.list(workspaceSlug, projectId, { limit: 500 }),
issueService.listAll(workspaceSlug, projectId),
stateService.list(workspaceSlug, projectId),
labelService.list(workspaceSlug, projectId),
])
Expand Down Expand Up @@ -471,7 +471,7 @@ export function CyclesPage() {
}
Promise.all([
cycleService.list(workspaceSlug, projectId),
issueService.list(workspaceSlug, projectId, { limit: 500 }),
issueService.listAll(workspaceSlug, projectId),
])
.then(([list, iss]) => {
setCycles(list ?? []);
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/pages/EpicDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export function EpicDetailPage() {
projectService.get(workspaceSlug, projectId),
epicService.get(workspaceSlug, projectId, epicId),
epicService.listIssues(workspaceSlug, projectId, epicId),
issueService.list(workspaceSlug, projectId, { limit: 250 }),
issueService.listAll(workspaceSlug, projectId),
stateService.list(workspaceSlug, projectId),
epicService.listLinks(workspaceSlug, projectId, epicId).catch(() => []),
epicService.listProgress(workspaceSlug, projectId).catch(() => ({})),
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/pages/IntakePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ export function IntakePage() {
projectService.get(workspaceSlug, projectId),
intakeService.list(workspaceSlug, projectId, 'pending'),
intakeService.pendingCount(workspaceSlug, projectId),
issueService.list(workspaceSlug, projectId, { limit: 500 }),
issueService.listAll(workspaceSlug, projectId),
])
.then(([w, p, pending, count, issues]) => {
if (cancelled) return;
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/pages/IssueDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ export function IssueDetailPage() {
cycleService.list(workspaceSlug, projectId),
moduleService.list(workspaceSlug, projectId),
workspaceService.listMembers(workspaceSlug),
issueService.list(workspaceSlug, projectId, { limit: 250 }),
issueService.listAll(workspaceSlug, projectId),
commentService.list(workspaceSlug, projectId, issueId),
issueService
.listActivities(workspaceSlug, projectId, issueId)
Expand Down Expand Up @@ -1594,7 +1594,7 @@ export function IssueDetailPage() {
.catch(() => {});
}
const refreshedAll = await issueService
.list(workspaceSlug, project.id, { limit: 250 })
.listAll(workspaceSlug, project.id)
.catch(() => null);
if (refreshedAll) setAllIssues(refreshedAll);
setSubCreateOpen(false);
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/pages/ModuleDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ export function ModuleDetailPage() {
const refetchIssues = () => {
if (!workspaceSlug || !projectId || !resolvedModuleId) return;
issueService
.list(workspaceSlug, projectId, { limit: 1000 })
.listAll(workspaceSlug, projectId)
.then((list) => {
setIssues((list ?? []).filter((i) => i.module_ids?.includes(resolvedModuleId)));
})
Expand All @@ -293,7 +293,7 @@ export function ModuleDetailPage() {
workspaceService.getBySlug(workspaceSlug),
projectService.get(workspaceSlug, projectId),
moduleService.list(workspaceSlug, projectId),
issueService.list(workspaceSlug, projectId, { limit: 1000 }),
issueService.listAll(workspaceSlug, projectId),
stateService.list(workspaceSlug, projectId),
labelService.list(workspaceSlug, projectId),
cycleService.list(workspaceSlug, projectId),
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/pages/ViewDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ export function ViewDetailPage() {
const refetchIssues = () => {
if (!workspaceSlug || !projectId) return;
issueService
.list(workspaceSlug, projectId, { limit: 500 })
.listAll(workspaceSlug, projectId)
.then(setIssues)
.catch(() => {});
};
Expand Down Expand Up @@ -279,7 +279,7 @@ export function ViewDetailPage() {
workspaceService.getBySlug(workspaceSlug),
projectService.get(workspaceSlug, projectId),
projectService.list(workspaceSlug),
issueService.list(workspaceSlug, projectId, { limit: 500 }),
issueService.listAll(workspaceSlug, projectId),
stateService.list(workspaceSlug, projectId),
labelService.list(workspaceSlug, projectId),
workspaceService.listMembers(workspaceSlug),
Expand Down
19 changes: 19 additions & 0 deletions apps/web/src/services/issueService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,25 @@ export const issueService = {
return data;
},

/**
* Fetch every issue for a project by paging through `list` in chunks of 100
* (the server caps `limit` at 100). Use this instead of passing a large
* `limit`, which the server silently caps and truncates.
*/
async listAll(workspaceSlug: string, projectId: string): Promise<IssueApiResponse[]> {
const pageSize = 100;
const all: IssueApiResponse[] = [];
let offset = 0;
// Bound the loop so a misbehaving backend can't spin forever.
for (let page = 0; page < 500; page++) {
const batch = await this.list(workspaceSlug, projectId, { limit: pageSize, offset });
all.push(...batch);
if (batch.length < pageSize) break;
offset += pageSize;
}
return all;
},

Comment on lines +66 to +84

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

Sequential fetching of the entire issue dataset causes severe latency and connection starvation.

Replacing single bounded requests with a client-side sequential loop (listAll) means projects with thousands of issues will trigger dozens of sequential round-trips. Furthermore, listAll lacks cancellation support (AbortSignal), meaning rapid navigation or unmounting will leave orphaned loops running in the background, starving the browser's connection pool. When triggered simultaneously across multiple projects (e.g., in Analytics), this risks immediate API rate-limiting.

  • apps/web/src/services/issueService.ts#L66-L84: Provide a bulk retrieval backend endpoint, or parallelize chunk fetches if the total count can be pre-fetched, instead of forcing a sequential client-side loop.
  • apps/web/src/components/layout/PageHeader.tsx#L95-L95: Request an endpoint that returns just the issue_count instead of downloading the entire issue dataset sequentially.
  • apps/web/src/pages/AnalyticsOverviewPage.tsx#L59-L59: Compute aggregated analytics metrics on the backend rather than pulling all issues for all projects into the client.
  • apps/web/src/pages/AnalyticsWorkItemsPage.tsx#L129-L129: Retrieve aggregated metrics directly from the backend to avoid overwhelming the client with data.
  • apps/web/src/pages/CycleDetailPage.tsx#L158-L162: Fetch only the issues associated with the current cycle (via backend filtering) rather than the complete project dataset.
  • apps/web/src/pages/CycleDetailPage.tsx#L309-L314: Limit the refresh request to issues affected by the cycle completion.
  • apps/web/src/pages/CyclesPage.tsx#L372-L379: Fetch cycle stats/progress from the backend instead of retrieving all project issues for client-side aggregations.
  • apps/web/src/pages/CyclesPage.tsx#L471-L477: Fetch cycle stats/progress from the backend instead of retrieving all project issues for client-side aggregations.
📍 Affects 6 files
  • apps/web/src/services/issueService.ts#L66-L84 (this comment)
  • apps/web/src/components/layout/PageHeader.tsx#L95-L95
  • apps/web/src/pages/AnalyticsOverviewPage.tsx#L59-L59
  • apps/web/src/pages/AnalyticsWorkItemsPage.tsx#L129-L129
  • apps/web/src/pages/CycleDetailPage.tsx#L158-L162
  • apps/web/src/pages/CycleDetailPage.tsx#L309-L314
  • apps/web/src/pages/CyclesPage.tsx#L372-L379
  • apps/web/src/pages/CyclesPage.tsx#L471-L477
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/services/issueService.ts` around lines 66 - 84, Replace the
sequential full-dataset retrieval with backend-scoped or aggregated endpoints.
In apps/web/src/services/issueService.ts:66-84, remove or stop using listAll and
provide bulk/filtered retrieval with cancellation support; update
apps/web/src/components/layout/PageHeader.tsx:95-95 to request only issue_count,
apps/web/src/pages/AnalyticsOverviewPage.tsx:59-59 and
apps/web/src/pages/AnalyticsWorkItemsPage.tsx:129-129 to request backend
analytics aggregates, apps/web/src/pages/CycleDetailPage.tsx:158-162 and 309-314
to fetch only current-cycle or affected issues, and
apps/web/src/pages/CyclesPage.tsx:372-379 and 471-477 to request backend cycle
stats/progress.

async get(workspaceSlug: string, projectId: string, issueId: string): Promise<IssueApiResponse> {
const { data } = await apiClient.get<IssueApiResponse>(
`/api/workspaces/${encodeURIComponent(workspaceSlug)}/projects/${encodeURIComponent(projectId)}/issues/${encodeURIComponent(issueId)}/`,
Expand Down
Loading