Skip to content

Comments

feat: Add category filtering and bulk actions#2

Open
nikman21 wants to merge 3 commits intomainfrom
feature/category-filter-bulk-actions
Open

feat: Add category filtering and bulk actions#2
nikman21 wants to merge 3 commits intomainfrom
feature/category-filter-bulk-actions

Conversation

@nikman21
Copy link
Contributor

@nikman21 nikman21 commented Feb 4, 2026

What I Built

Added category filtering and bulk actions to Mission Control to significantly improve the workflow when reviewing and approving items.

Features Added

  1. Category Filtering

    • New dropdown to filter items by category (feature, workflow, template, research, automation)
    • Works in combination with status filter and search
  2. Bulk Selection & Actions

    • Checkbox on each item for selection
    • "Select all in section" button for quick batch selection
    • Bulk actions bar appears when items are selected with Approve/Reject/Delete options
  3. Keyboard Shortcuts

    • Space - Toggle item selection
    • a - Bulk approve selected items
    • r - Bulk reject selected items
  4. Enhanced Stats

    • Category breakdown added to stats section

Testing

Run npm run dev and test:

  • Category filtering via dropdown
  • Bulk selection via checkboxes
  • Bulk actions (approve/reject/delete)
  • Keyboard shortcuts (space, a, r)

Why It Matters

  • Time savings through bulk operations
  • Better organization via category filtering
  • Power user efficiency with keyboard shortcuts
  • Cleaner workflow with bulk delete

Built during Nightly Build Session

Summary by CodeRabbit

  • New Features
    • Added an Analytics tab with a dashboard of metrics, charts, and weekly trends.
    • Added category filtering, category stats, and a category filter UI.
    • Added CSV export for current filtered views.
    • Added keyboard shortcuts and a keyboard help modal.
    • Added item selection with select-all and bulk actions (approve/reject/delete); bulk approvals append workflow notes.
    • Added last-updated timestamps and improved keyboard navigation.

- Add keyboard navigation (j/k, arrows, Enter to view, n to add)
- Add keyboard shortcuts help modal (press ? to view)
- Add CSV export functionality for mission items
- Add last-updated timestamp with auto-refresh indicator
- Visual highlight for keyboard-selected items
- Add category filter dropdown to filter by feature, workflow, template, research, automation
- Add bulk selection with checkboxes on each item
- Add bulk actions bar with approve, reject, and delete
- Add keyboard shortcuts: space (select), a (bulk approve), r (bulk reject)
- Update stats to show category breakdown
- Update data layer with updateItem, deleteItem, and bulk operations
- Update API to handle bulk actions and individual item updates
@coderabbitai
Copy link

coderabbitai bot commented Feb 4, 2026

📝 Walkthrough

Walkthrough

Added bulk and single-item operations to the items API (approve/reject/delete/update), extended the data layer with CRUD and bulk functions, introduced a new AnalyticsDashboard component, and expanded the main page with category filtering, selection/bulk actions, keyboard shortcuts, CSV export, and enhanced item navigation.

Changes

Cohort / File(s) Summary
API Layer
src/app/api/items/route.ts
Added POST handling for bulkStatus (bulk approve/reject with workflow trigger and notes append), delete, and update actions before falling back to create. Imports updated to include updateItem and deleteItem.
Data Layer
src/data/items.ts
Added updateItem(id, data), deleteItem(id), bulkUpdateStatus(ids, status), and bulkDelete(ids) functions. Note: bulk helpers apply internal status filters (pending/rejected) in their implementations.
Main UI / Page
src/app/page.tsx
Introduced category filter, per-item selection and select-all per section, bulk approve/reject/delete actions with loading state, CSV export, keyboard shortcuts modal/navigation, last-updated timestamp, persisted item refs and scroll-to-select, tab switcher (Items ↔ Analytics).
Analytics Component
src/components/AnalyticsDashboard.tsx
New client-side dashboard rendering metrics, activity bar chart, category pie, status overview, and weekly summary using recharts. Exports AnalyticsDashboard.
Dependencies
package.json
Added recharts dependency (^3.7.0).
sequenceDiagram
    participant Client as Client/UI
    participant API as API Route Handler
    participant DataLayer as Data Layer
    participant Workflow as Approval Workflow

    Client->>API: POST /api/items { bulkStatus: "approved", ids: [...] }
    API->>DataLayer: bulkUpdateStatus(ids, "approved")
    DataLayer-->>API: list of updated items

    loop for each approved item
        API->>Workflow: triggerApprovalWorkflow(item)
        Workflow-->>API: workflow log
        API->>DataLayer: updateItem(item.id, { notes: log + item.notes })
        DataLayer-->>API: updated item
    end

    API-->>Client: 200 OK with updated items
Loading

🎯 4 (Complex) | ⏱️ ~45 minutes

🐰 I hopped through code with eager paws,
Bulk approves and filters, oh what a cause,
Charts and CSVs in a joyful spin,
Shortcuts tapping — let the workflows begin,
A carrot-toast to items, new and thin! 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the main features added in this changeset: category filtering and bulk actions are the primary additions across the modified files.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/category-filter-bulk-actions

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8afb2d364f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +187 to +193
// Select all visible items
const selectAllVisible = () => {
if (selectedItems.size === filteredItems.length) {
setSelectedItems(new Set());
} else {
setSelectedItems(new Set(filteredItems.map((i) => i.id)));
}

Choose a reason for hiding this comment

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

P2 Badge Restrict “select all in section” to that section

The “Select all in section” control calls selectAllVisible, which toggles selection based on filteredItems (all statuses). When multiple status sections are shown, clicking the button in one section selects items from other sections too, so bulk actions can unintentionally apply to items outside the clicked section. This is user-visible when category/status filters leave multiple sections populated.

Useful? React with 👍 / 👎.

Comment on lines +300 to +303
const handleKeyDown = (e: KeyboardEvent) => {
// Ignore if typing in an input
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return;

Choose a reason for hiding this comment

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

P2 Badge Avoid intercepting keys while a select is focused

The keyboard handler only ignores HTMLInputElement and HTMLTextAreaElement. When focus is on a <select> (e.g., the status/category dropdowns), ArrowUp/ArrowDown/Space are still intercepted and preventDefault() is called, so the dropdown cannot be navigated and hotkeys can trigger unintended actions. This happens whenever a user tries to operate the filters via keyboard.

Useful? React with 👍 / 👎.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🤖 Fix all issues with AI agents
In `@src/app/api/items/route.ts`:
- Around line 207-211: The update branch currently returns whatever
updateItem(id, updateData) yields (possibly null) with a 200; modify the handler
so after calling updateItem(id, updateData) you check for a null result and, if
null, return a 404 response (using NextResponse with status 404 and an error
payload consistent with the PATCH handler), otherwise return the updated item as
before; reference the body.action === "update" branch, the updateItem function
call, and the NextResponse.json return to locate and change the code.
- Around line 184-200: The response currently returns updated.length which
equals body.ids.length even when some updateItemStatus calls returned null;
change the success count to only include actually updated items (e.g., compute
const successful = updated.filter(Boolean) and use successful.length) and use
that same filtered list when iterating to trigger workflows and update notes;
ensure you reference updateItemStatus, triggerApprovalWorkflow, and
updateItemNotes so the loop and final NextResponse.json({ success: true, count:
... }) reflect only successful updates.

In `@src/app/page.tsx`:
- Around line 299-362: The keyboard shortcut effect uses functions that can
close over stale state—wrap toggleSelectItem, handleBulkApprove, and
handleBulkReject in useCallback (including selectedItems in their callback deps
where they rely on selection) and then add those callback functions to the
useEffect dependency array (replace the current deps [filteredItems,
selectedIndex, selectedItems] with [filteredItems, selectedIndex,
toggleSelectItem, handleBulkApprove, handleBulkReject] or equivalent) so the
handleKeyDown closure always calls the latest implementations.
- Around line 569-581: The button currently calls selectAllVisible which toggles
selection for filteredItems; change it to operate on the current section's items
(statusItems) instead. Add or update a handler (e.g., selectAllInSection or
modify selectAllVisible to accept an items parameter) to toggle selection only
for the provided items array, and update the button onClick to pass statusItems
(or call the new selectAllInSection when rendering each section). Ensure you
still use selectedItems to check selection state (replace filteredItems.length
checks with statusItems.length) so the icon and label reflect selection of the
section only.

In `@src/data/items.ts`:
- Around line 82-100: The bug: both bulkUpdateStatus and bulkDelete ignore the
ids parameter and currently update/delete all rows matching only the status
filter; fix by adding an ids membership filter to the query (use drizzle-orm's
inArray or equivalent) so the WHERE clause includes both eq(missionItems.status,
...) and inArray(missionItems.id, ids) (or missionItems.id.in(ids)) for
bulkUpdateStatus and bulkDelete; ensure you import and use the correct helper
(inArray) and keep the existing updatedAt set in bulkUpdateStatus and
returning() usage to return the affected count.
🧹 Nitpick comments (2)
src/app/page.tsx (2)

272-296: Revoke blob URL to prevent memory leak.

URL.createObjectURL allocates memory that persists until explicitly released. Each export click creates a new blob URL that's never revoked.

♻️ Proposed fix
     const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
+    const url = URL.createObjectURL(blob);
     const link = document.createElement("a");
-    link.href = URL.createObjectURL(blob);
+    link.href = url;
     link.download = `mission-items-${new Date().toISOString().split("T")[0]}.csv`;
     link.click();
+    URL.revokeObjectURL(url);
   };

243-261: Sequential deletes are slow; consider parallel or batch delete.

handleBulkDelete performs deletes sequentially using a for...of loop. For many items, this is slower than parallel execution and doesn't provide user feedback on partial failures.

♻️ Proposed parallel implementation
   const handleBulkDelete = async () => {
     if (selectedItems.size === 0) return;
     setBulkActionLoading(true);
     try {
-      for (const id of selectedItems) {
-        await fetch("/api/items", {
+      await Promise.all(
+        Array.from(selectedItems).map((id) =>
+          fetch("/api/items", {
-          method: "POST",
-          headers: { "Content-Type": "application/json" },
-          body: JSON.stringify({ action: "delete", id }),
-        });
-      }
+            method: "POST",
+            headers: { "Content-Type": "application/json" },
+            body: JSON.stringify({ action: "delete", id }),
+          })
+        )
+      );
       setSelectedItems(new Set());
       fetchItems();

Comment on lines +184 to +200
if (body.action === "bulkStatus" && body.ids && body.status) {
const updated = await Promise.all(
body.ids.map((id: string) => updateItemStatus(id, body.status))
);
// Trigger workflows for approved items
for (const item of updated.filter(Boolean)) {
if (item && body.status === "approved") {
const triggerLog = await triggerApprovalWorkflow(item);
const currentNotes = item.notes || "";
const updatedNotes = currentNotes
? `${currentNotes}\n\n${triggerLog}`
: triggerLog;
await updateItemNotes(item.id, updatedNotes);
}
}
return NextResponse.json({ success: true, count: updated.length });
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Response count may not reflect actual updates.

updated.length will always equal body.ids.length since Promise.all preserves array length. Items that don't exist return null but are still counted, giving a misleading success count.

🔧 Proposed fix to count only successful updates
       const updated = await Promise.all(
         body.ids.map((id: string) => updateItemStatus(id, body.status))
       );
+      const successfulUpdates = updated.filter(Boolean);
       // Trigger workflows for approved items
-      for (const item of updated.filter(Boolean)) {
+      for (const item of successfulUpdates) {
         if (item && body.status === "approved") {
           const triggerLog = await triggerApprovalWorkflow(item);
           const currentNotes = item.notes || "";
           const updatedNotes = currentNotes
             ? `${currentNotes}\n\n${triggerLog}`
             : triggerLog;
           await updateItemNotes(item.id, updatedNotes);
         }
       }
-      return NextResponse.json({ success: true, count: updated.length });
+      return NextResponse.json({ success: true, count: successfulUpdates.length });
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (body.action === "bulkStatus" && body.ids && body.status) {
const updated = await Promise.all(
body.ids.map((id: string) => updateItemStatus(id, body.status))
);
// Trigger workflows for approved items
for (const item of updated.filter(Boolean)) {
if (item && body.status === "approved") {
const triggerLog = await triggerApprovalWorkflow(item);
const currentNotes = item.notes || "";
const updatedNotes = currentNotes
? `${currentNotes}\n\n${triggerLog}`
: triggerLog;
await updateItemNotes(item.id, updatedNotes);
}
}
return NextResponse.json({ success: true, count: updated.length });
}
if (body.action === "bulkStatus" && body.ids && body.status) {
const updated = await Promise.all(
body.ids.map((id: string) => updateItemStatus(id, body.status))
);
const successfulUpdates = updated.filter(Boolean);
// Trigger workflows for approved items
for (const item of successfulUpdates) {
if (item && body.status === "approved") {
const triggerLog = await triggerApprovalWorkflow(item);
const currentNotes = item.notes || "";
const updatedNotes = currentNotes
? `${currentNotes}\n\n${triggerLog}`
: triggerLog;
await updateItemNotes(item.id, updatedNotes);
}
}
return NextResponse.json({ success: true, count: successfulUpdates.length });
}
🤖 Prompt for AI Agents
In `@src/app/api/items/route.ts` around lines 184 - 200, The response currently
returns updated.length which equals body.ids.length even when some
updateItemStatus calls returned null; change the success count to only include
actually updated items (e.g., compute const successful = updated.filter(Boolean)
and use successful.length) and use that same filtered list when iterating to
trigger workflows and update notes; ensure you reference updateItemStatus,
triggerApprovalWorkflow, and updateItemNotes so the loop and final
NextResponse.json({ success: true, count: ... }) reflect only successful
updates.

Comment on lines +207 to +211
if (body.action === "update" && body.id) {
const { id, action, ...updateData } = body;
const item = await updateItem(id, updateData);
return NextResponse.json(item);
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Missing 404 handling for update action.

When updateItem returns null (item not found), the response still returns null with a 200 status. Consider returning a 404 error for consistency with the PATCH handler.

🔧 Proposed fix
     if (body.action === "update" && body.id) {
       const { id, action, ...updateData } = body;
       const item = await updateItem(id, updateData);
+      if (!item) {
+        return NextResponse.json({ error: "Item not found" }, { status: 404 });
+      }
       return NextResponse.json(item);
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (body.action === "update" && body.id) {
const { id, action, ...updateData } = body;
const item = await updateItem(id, updateData);
return NextResponse.json(item);
}
if (body.action === "update" && body.id) {
const { id, action, ...updateData } = body;
const item = await updateItem(id, updateData);
if (!item) {
return NextResponse.json({ error: "Item not found" }, { status: 404 });
}
return NextResponse.json(item);
}
🤖 Prompt for AI Agents
In `@src/app/api/items/route.ts` around lines 207 - 211, The update branch
currently returns whatever updateItem(id, updateData) yields (possibly null)
with a 200; modify the handler so after calling updateItem(id, updateData) you
check for a null result and, if null, return a 404 response (using NextResponse
with status 404 and an error payload consistent with the PATCH handler),
otherwise return the updated item as before; reference the body.action ===
"update" branch, the updateItem function call, and the NextResponse.json return
to locate and change the code.

Comment on lines +569 to +581
<div className="flex items-center gap-2 mb-2">
<button
onClick={selectAllVisible}
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1"
>
{selectedItems.size === filteredItems.length ? (
<CheckSquare className="w-3 h-3" />
) : (
<Square className="w-3 h-3" />
)}
Select all in section
</button>
</div>
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

"Select all in section" selects all filtered items, not section items.

The button label says "Select all in section" but selectAllVisible() toggles selection for all filteredItems, not just items within the current status section (statusItems). This is misleading to users.

🐛 Proposed fix to select only items in the current section
-                <div className="flex items-center gap-2 mb-2">
-                  <button
-                    onClick={selectAllVisible}
-                    className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1"
-                  >
-                    {selectedItems.size === filteredItems.length ? (
-                      <CheckSquare className="w-3 h-3" />
-                    ) : (
-                      <Square className="w-3 h-3" />
-                    )}
-                    Select all in section
-                  </button>
-                </div>
+                <div className="flex items-center gap-2 mb-2">
+                  <button
+                    onClick={() => {
+                      const sectionIds = new Set(statusItems.map((i) => i.id));
+                      const allSelected = statusItems.every((i) => selectedItems.has(i.id));
+                      if (allSelected) {
+                        setSelectedItems(new Set([...selectedItems].filter((id) => !sectionIds.has(id))));
+                      } else {
+                        setSelectedItems(new Set([...selectedItems, ...sectionIds]));
+                      }
+                    }}
+                    className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1"
+                  >
+                    {statusItems.every((i) => selectedItems.has(i.id)) ? (
+                      <CheckSquare className="w-3 h-3" />
+                    ) : (
+                      <Square className="w-3 h-3" />
+                    )}
+                    Select all in section
+                  </button>
+                </div>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div className="flex items-center gap-2 mb-2">
<button
onClick={selectAllVisible}
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1"
>
{selectedItems.size === filteredItems.length ? (
<CheckSquare className="w-3 h-3" />
) : (
<Square className="w-3 h-3" />
)}
Select all in section
</button>
</div>
<div className="flex items-center gap-2 mb-2">
<button
onClick={() => {
const sectionIds = new Set(statusItems.map((i) => i.id));
const allSelected = statusItems.every((i) => selectedItems.has(i.id));
if (allSelected) {
setSelectedItems(new Set([...selectedItems].filter((id) => !sectionIds.has(id))));
} else {
setSelectedItems(new Set([...selectedItems, ...sectionIds]));
}
}}
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1"
>
{statusItems.every((i) => selectedItems.has(i.id)) ? (
<CheckSquare className="w-3 h-3" />
) : (
<Square className="w-3 h-3" />
)}
Select all in section
</button>
</div>
🤖 Prompt for AI Agents
In `@src/app/page.tsx` around lines 569 - 581, The button currently calls
selectAllVisible which toggles selection for filteredItems; change it to operate
on the current section's items (statusItems) instead. Add or update a handler
(e.g., selectAllInSection or modify selectAllVisible to accept an items
parameter) to toggle selection only for the provided items array, and update the
button onClick to pass statusItems (or call the new selectAllInSection when
rendering each section). Ensure you still use selectedItems to check selection
state (replace filteredItems.length checks with statusItems.length) so the icon
and label reflect selection of the section only.

Comment on lines +82 to +100
export async function bulkUpdateStatus(
ids: string[],
status: MissionItem["status"]
): Promise<number> {
const result = await db
.update(missionItems)
.set({ status, updatedAt: new Date() })
.where(eq(missionItems.status, "pending")) // Only affect pending items
.returning();
return result.length;
}

export async function bulkDelete(ids: string[]): Promise<number> {
const result = await db
.delete(missionItems)
.where(eq(missionItems.status, "rejected")) // Only delete rejected items
.returning();
return result.length;
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Critical bug: ids parameter is ignored in both functions.

Both bulkUpdateStatus and bulkDelete accept an ids parameter but never use it. The WHERE clause only filters by status, meaning these functions will affect all items matching the status filter, not just the specified IDs.

For example, calling bulkUpdateStatus(['single-id'], 'approved') will update every pending item in the database.

🐛 Proposed fix using `inArray` from drizzle-orm
-import { eq, desc } from "drizzle-orm";
+import { eq, desc, inArray, and } from "drizzle-orm";
 export async function bulkUpdateStatus(
   ids: string[],
   status: MissionItem["status"]
 ): Promise<number> {
+  if (ids.length === 0) return 0;
   const result = await db
     .update(missionItems)
     .set({ status, updatedAt: new Date() })
-    .where(eq(missionItems.status, "pending")) // Only affect pending items
+    .where(and(inArray(missionItems.id, ids), eq(missionItems.status, "pending")))
     .returning();
   return result.length;
 }

 export async function bulkDelete(ids: string[]): Promise<number> {
+  if (ids.length === 0) return 0;
   const result = await db
     .delete(missionItems)
-    .where(eq(missionItems.status, "rejected")) // Only delete rejected items
+    .where(and(inArray(missionItems.id, ids), eq(missionItems.status, "rejected")))
     .returning();
   return result.length;
 }
🤖 Prompt for AI Agents
In `@src/data/items.ts` around lines 82 - 100, The bug: both bulkUpdateStatus and
bulkDelete ignore the ids parameter and currently update/delete all rows
matching only the status filter; fix by adding an ids membership filter to the
query (use drizzle-orm's inArray or equivalent) so the WHERE clause includes
both eq(missionItems.status, ...) and inArray(missionItems.id, ids) (or
missionItems.id.in(ids)) for bulkUpdateStatus and bulkDelete; ensure you import
and use the correct helper (inArray) and keep the existing updatedAt set in
bulkUpdateStatus and returning() usage to return the affected count.

- Added AnalyticsDashboard component with interactive charts (recharts)
- Shows category distribution (pie chart) and activity trends (bar chart)
- Displays productivity metrics: approval rate, velocity, time to approve
- Added tab system to switch between Items and Analytics views
- Weekly summary with created/approved counts and efficiency percentage

🤖 Nightly Build Session
@nikman21
Copy link
Contributor Author

Nightly Build Summary

I added an Analytics Dashboard to Mission Control with visual productivity metrics and charts.

What I Built

AnalyticsDashboard Component with:

  • Interactive bar chart showing items created vs approved over the last 7 days
  • Pie chart showing category distribution (features, workflows, templates, research, automation)
  • Productivity metrics cards (total items, approval rate, velocity %, in-progress count)
  • Status overview with progress bar visualization
  • Weekly summary with efficiency percentage

Tab System to switch between Items and Analytics views in the header

Files Changed

  • src/components/AnalyticsDashboard.tsx - New analytics component (250+ lines)
  • src/app/page.tsx - Added tab system and conditional rendering
  • package.json - Added recharts dependency

How to Test

  1. Run npm run dev in mission-control directory
  2. Open http://localhost:3000
  3. Click the Analytics tab in the header
  4. Verify charts render correctly with your existing data

Why It Matters

  • Provides visibility into workflow productivity
  • Helps identify bottlenecks in the approval process
  • Makes it easy to track team velocity over time
  • Visual analytics encourage data-driven decisions

🤖 Built during Nightly Build Session

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 7

🤖 Fix all issues with AI agents
In `@src/app/page.tsx`:
- Around line 489-494: The stats grid currently only renders
categoryStats.feature; update the JSX that renders the grid to display all
computed categories instead of just feature: iterate over the categoryStats
object (keys: feature, workflow, template, research, automation) or build an
array of those entries and render a card for each (showing the count and label)
so the counts computed in categoryStats are all shown; ensure you update the
element that currently references categoryStats.feature to use the mapped values
(or an aggregate if you intentionally want a single summary) and keep the
existing classNames (bg-card, border, rounded-lg, p-4) for each rendered card.
- Around line 764-766: The "Press any key to dismiss" text is inaccurate because
only the close (✕) button and Escape currently close the modal; fix it by wiring
a keydown handler that calls the same close routine used by the ✕/Escape
controls. Inside the modal component (the element containing the <p
className="mt-4 text-xs text-muted-foreground text-center">Press any key to
dismiss</p>), add a keydown listener (either attach onKeyDown to the modal
container or use useEffect to add window.addEventListener('keydown', ...)) that
calls the existing close function (e.g., the onClose prop, closeModal, or
setOpen(false) used for the ✕ button), and ensure the handler ignores
modifier-only keys and stops propagation so other shortcuts (like 'j'/'n') don’t
fire when the modal is active; alternatively, if you prefer not to implement
this behavior, change the paragraph text to "Press Esc to dismiss" to match
current behavior.
- Around line 247-265: handleBulkDelete currently issues sequential fetches per
id and lacks a user confirmation; change it to show a confirmation dialog first,
then send a single batched request (e.g., POST to "/api/items" with action:
"bulkDelete" and ids: [...selectedItems]) so the server can perform an atomic
batch delete and return success/failure for the whole operation, then on success
call fetchItems() and clear selectedItems, and on failure show an error and do
not partially clear selection; also add a corresponding server-side bulkDelete
handler in the API route that accepts the ids array and performs an atomic
delete/transaction.
- Around line 303-366: The global keyboard handler (handleKeyDown inside the
useEffect) needs to be no-op unless the items list tab is active: at the top of
handleKeyDown check activeTab === "items" and return early if not, so shortcuts
like "a", "r", "j", "k", " " only operate when viewing items; also add activeTab
to the useEffect dependency array so the listener behavior updates when the tab
changes. Ensure you reference the existing filteredItems/selectedIndex logic and
call sites like toggleSelectItem, handleBulkApprove, handleBulkReject,
setShowAddModal, setSelectedItem, and setKeyboardHelpOpen remain unchanged.

In `@src/components/AnalyticsDashboard.tsx`:
- Around line 327-337: Guard against division-by-zero when computing the width
for each status segment: inside the rendering that maps statusData (the JSX
block using statusData.map and metrics.total), compute a safe percentage like
sharePercent = metrics.total ? (status.value / metrics.total) * 100 : 0 and use
that value for the inline style width (e.g., `${sharePercent}%`) so the width
never becomes NaN%; ensure the fallback yields 0% for empty items.
- Around line 144-149: The current avgTimeToApprove computation is bogus
(approvedItems.length * 0.5) and should be replaced with a real average of time
deltas: filter items to approvedItems (items.filter(i => i.status ===
"approved")) and for each ensure both created and updated timestamps exist,
parse them as Dates, compute (updated - created) in hours, sum those
hour-deltas, divide by approvedItems.length and round (or return 0 when no valid
items); update the avgTimeToApprove variable to use this calculation and ensure
NaN/infinite values are handled by skipping invalid entries or falling back to
0.
- Around line 163-174: The "weeklyTrend" useMemo currently uses items.slice(0,
7) which picks the first seven items by order instead of items from the last 7
days; update the logic in the weeklyTrend block to filter items by created date
(e.g., compare each item's createdAt or created_date field against now minus 7
days) before computing created, approved, and trend; keep the same output shape
({ created, approved, trend }) but compute created = filtered.length, approved =
filtered.filter(i => i.status === "approved").length, and calculate trend using
those counts so the "This Week's Summary" reflects items from the past week
rather than the first seven items.
🧹 Nitpick comments (2)
src/components/AnalyticsDashboard.tsx (1)

29-39: Duplicate MissionItem type — consider importing a shared definition.

There are at least three separate MissionItem definitions: here, in src/app/page.tsx, and the DB-inferred type in src/lib/schema.ts. This creates drift risk — e.g., this local type already lacks the files field present in page.tsx. Import or re-export a single canonical type to keep them in sync.

src/app/page.tsx (1)

295-300: Object URL is never revoked — minor memory leak.

URL.createObjectURL pins the blob in memory until revoked or the page unloads. Add a cleanup call after the click.

🔧 Proposed fix
     const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
     const link = document.createElement("a");
-    link.href = URL.createObjectURL(blob);
+    const url = URL.createObjectURL(blob);
+    link.href = url;
     link.download = `mission-items-${new Date().toISOString().split("T")[0]}.csv`;
     link.click();
+    URL.revokeObjectURL(url);
   };

Comment on lines +247 to +265
const handleBulkDelete = async () => {
if (selectedItems.size === 0) return;
setBulkActionLoading(true);
try {
for (const id of selectedItems) {
await fetch("/api/items", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "delete", id }),
});
}
setSelectedItems(new Set());
fetchItems();
} catch (error) {
console.error("Failed to bulk delete:", error);
} finally {
setBulkActionLoading(false);
}
};
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Sequential delete calls are slow and non-atomic; also missing a confirmation dialog.

handleBulkDelete fires one fetch per item sequentially. For N selected items, this means N round-trips, any of which can fail mid-way leaving a partial delete. Additionally, there's no confirmation prompt before a destructive bulk delete — users can accidentally lose data.

🔧 Proposed fix: batch request + confirmation
   const handleBulkDelete = async () => {
     if (selectedItems.size === 0) return;
+    if (!confirm(`Delete ${selectedItems.size} item(s)? This cannot be undone.`)) return;
     setBulkActionLoading(true);
     try {
-      for (const id of selectedItems) {
-        await fetch("/api/items", {
-          method: "POST",
-          headers: { "Content-Type": "application/json" },
-          body: JSON.stringify({ action: "delete", id }),
-        });
-      }
+      await fetch("/api/items", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ action: "bulkDelete", ids: Array.from(selectedItems) }),
+      });
       setSelectedItems(new Set());
       fetchItems();

This requires a corresponding bulkDelete handler in the API route.

🤖 Prompt for AI Agents
In `@src/app/page.tsx` around lines 247 - 265, handleBulkDelete currently issues
sequential fetches per id and lacks a user confirmation; change it to show a
confirmation dialog first, then send a single batched request (e.g., POST to
"/api/items" with action: "bulkDelete" and ids: [...selectedItems]) so the
server can perform an atomic batch delete and return success/failure for the
whole operation, then on success call fetchItems() and clear selectedItems, and
on failure show an error and do not partially clear selection; also add a
corresponding server-side bulkDelete handler in the API route that accepts the
ids array and performs an atomic delete/transaction.

Comment on lines +303 to +366
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ignore if typing in an input
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return;
}

const allItems = filteredItems;
const currentIndex = selectedIndex;

switch (e.key) {
case "j":
case "ArrowDown":
e.preventDefault();
setSelectedIndex(Math.min(currentIndex + 1, allItems.length - 1));
break;
case "k":
case "ArrowUp":
e.preventDefault();
setSelectedIndex(Math.max(currentIndex - 1, 0));
break;
case "Enter":
e.preventDefault();
if (allItems[currentIndex]) {
setSelectedItem(allItems[currentIndex]);
}
break;
case "n":
e.preventDefault();
setShowAddModal(true);
break;
case "Escape":
e.preventDefault();
setSelectedItem(null);
setKeyboardHelpOpen(false);
break;
case "?":
e.preventDefault();
setKeyboardHelpOpen(true);
break;
case " ":
e.preventDefault();
if (allItems[currentIndex]) {
toggleSelectItem(allItems[currentIndex].id);
}
break;
case "a":
if (!e.ctrlKey && !e.metaKey) {
e.preventDefault();
handleBulkApprove();
}
break;
case "r":
if (!e.ctrlKey && !e.metaKey) {
e.preventDefault();
handleBulkReject();
}
break;
}
};

window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [filteredItems, selectedIndex, selectedItems]);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Keyboard shortcuts fire on the Analytics tab, causing invisible side effects.

Pressing a or r while viewing the Analytics tab will silently bulk-approve or bulk-reject any previously selected items. Navigation keys (j/k/Space) also manipulate item selection that isn't visible. Guard these shortcuts behind an activeTab === "items" check.

🔧 Proposed fix
     const handleKeyDown = (e: KeyboardEvent) => {
       if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
         return;
       }
+      // Allow only universal shortcuts (Escape, ?) on non-items tabs
+      if (activeTab !== "items" && e.key !== "Escape" && e.key !== "?") {
+        return;
+      }
+
       const allItems = filteredItems;
🤖 Prompt for AI Agents
In `@src/app/page.tsx` around lines 303 - 366, The global keyboard handler
(handleKeyDown inside the useEffect) needs to be no-op unless the items list tab
is active: at the top of handleKeyDown check activeTab === "items" and return
early if not, so shortcuts like "a", "r", "j", "k", " " only operate when
viewing items; also add activeTab to the useEffect dependency array so the
listener behavior updates when the tab changes. Ensure you reference the
existing filteredItems/selectedIndex logic and call sites like toggleSelectItem,
handleBulkApprove, handleBulkReject, setShowAddModal, setSelectedItem, and
setKeyboardHelpOpen remain unchanged.

Comment on lines +489 to +494
<div className="bg-card border border-border rounded-lg p-4">
<div className="text-2xl font-bold text-purple-500">
{categoryStats.feature}
</div>
<div className="text-sm text-muted-foreground">Features</div>
</div>
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Stats grid only shows the "Features" category count, ignoring the other four.

categoryStats computes counts for all five categories (lines 384-390), but only feature is displayed. The remaining four (workflow, template, research, automation) are computed but unused. This appears to be an oversight — either show a meaningful aggregate or all categories.

🤖 Prompt for AI Agents
In `@src/app/page.tsx` around lines 489 - 494, The stats grid currently only
renders categoryStats.feature; update the JSX that renders the grid to display
all computed categories instead of just feature: iterate over the categoryStats
object (keys: feature, workflow, template, research, automation) or build an
array of those entries and render a card for each (showing the count and label)
so the counts computed in categoryStats are all shown; ensure you update the
element that currently references categoryStats.feature to use the mapped values
(or an aggregate if you intentionally want a single summary) and keep the
existing classNames (bg-card, border, rounded-lg, p-4) for each rendered card.

Comment on lines +764 to +766
<p className="mt-4 text-xs text-muted-foreground text-center">
Press any key to dismiss
</p>
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

"Press any key to dismiss" is not implemented.

The modal only closes via the ✕ button or Escape. Pressing other keys (e.g., j, n) triggers their shortcuts instead. Either implement the behavior or update the text to say "Press Esc to dismiss."

🤖 Prompt for AI Agents
In `@src/app/page.tsx` around lines 764 - 766, The "Press any key to dismiss" text
is inaccurate because only the close (✕) button and Escape currently close the
modal; fix it by wiring a keydown handler that calls the same close routine used
by the ✕/Escape controls. Inside the modal component (the element containing the
<p className="mt-4 text-xs text-muted-foreground text-center">Press any key to
dismiss</p>), add a keydown listener (either attach onKeyDown to the modal
container or use useEffect to add window.addEventListener('keydown', ...)) that
calls the existing close function (e.g., the onClose prop, closeModal, or
setOpen(false) used for the ✕ button), and ensure the handler ignores
modifier-only keys and stops propagation so other shortcuts (like 'j'/'n') don’t
fire when the modal is active; alternatively, if you prefer not to implement
this behavior, change the paragraph text to "Press Esc to dismiss" to match
current behavior.

Comment on lines +144 to +149
// Calculate average time to approval (simplified)
const approvedItems = items.filter((i) => i.status === "approved");
const avgTimeToApprove =
approvedItems.length > 0
? Math.round(approvedItems.length * 0.5) // Simplified calculation
: 0;
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

avgTimeToApprove is a fabricated metric — it misleads users.

approvedItems.length * 0.5 has no relation to actual approval time. It's displayed as "{N}h avg to approve", producing nonsensical values (e.g., 10 approved items → "5h"). Either compute the real time delta between created and updated timestamps, or remove the metric entirely to avoid misleading users.

🔧 Proposed fix: compute actual average time
-    // Calculate average time to approval (simplified)
-    const approvedItems = items.filter((i) => i.status === "approved");
-    const avgTimeToApprove =
-      approvedItems.length > 0
-        ? Math.round(approvedItems.length * 0.5) // Simplified calculation
-        : 0;
+    // Calculate average time to approval
+    const approvedItems = items.filter((i) => i.status === "approved");
+    const avgTimeToApprove =
+      approvedItems.length > 0
+        ? Math.round(
+            approvedItems.reduce((sum, i) => {
+              const created = new Date(i.created).getTime();
+              const updated = new Date(i.updated).getTime();
+              return sum + (updated - created);
+            }, 0) /
+              approvedItems.length /
+              (1000 * 60 * 60) // Convert ms to hours
+          )
+        : 0;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Calculate average time to approval (simplified)
const approvedItems = items.filter((i) => i.status === "approved");
const avgTimeToApprove =
approvedItems.length > 0
? Math.round(approvedItems.length * 0.5) // Simplified calculation
: 0;
// Calculate average time to approval
const approvedItems = items.filter((i) => i.status === "approved");
const avgTimeToApprove =
approvedItems.length > 0
? Math.round(
approvedItems.reduce((sum, i) => {
const created = new Date(i.created).getTime();
const updated = new Date(i.updated).getTime();
return sum + (updated - created);
}, 0) /
approvedItems.length /
(1000 * 60 * 60) // Convert ms to hours
)
: 0;
🤖 Prompt for AI Agents
In `@src/components/AnalyticsDashboard.tsx` around lines 144 - 149, The current
avgTimeToApprove computation is bogus (approvedItems.length * 0.5) and should be
replaced with a real average of time deltas: filter items to approvedItems
(items.filter(i => i.status === "approved")) and for each ensure both created
and updated timestamps exist, parse them as Dates, compute (updated - created)
in hours, sum those hour-deltas, divide by approvedItems.length and round (or
return 0 when no valid items); update the avgTimeToApprove variable to use this
calculation and ensure NaN/infinite values are handled by skipping invalid
entries or falling back to 0.

Comment on lines +163 to +174
// Weekly trend (simplified)
const weeklyTrend = useMemo(() => {
const recentItems = items.slice(0, 7);
const created = recentItems.length;
const approved = recentItems.filter((i) => i.status === "approved").length;

return {
created,
approved,
trend: approved > 0 ? "+" + Math.round((approved / created) * 100) + "%" : "0%",
};
}, [items]);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

"Weekly trend" doesn't filter by week — it just takes the first 7 items.

items.slice(0, 7) is order-dependent and unrelated to the current week. The section is titled "This Week's Summary," which is misleading. Filter items by date (e.g., items created in the last 7 days) to match the label.

🔧 Proposed fix: filter by date range
   const weeklyTrend = useMemo(() => {
-    const recentItems = items.slice(0, 7);
-    const created = recentItems.length;
-    const approved = recentItems.filter((i) => i.status === "approved").length;
+    const oneWeekAgo = new Date();
+    oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
+    const recentItems = items.filter(
+      (i) => new Date(i.created) >= oneWeekAgo
+    );
+    const created = recentItems.length;
+    const approved = recentItems.filter((i) => i.status === "approved").length;
🤖 Prompt for AI Agents
In `@src/components/AnalyticsDashboard.tsx` around lines 163 - 174, The
"weeklyTrend" useMemo currently uses items.slice(0, 7) which picks the first
seven items by order instead of items from the last 7 days; update the logic in
the weeklyTrend block to filter items by created date (e.g., compare each item's
createdAt or created_date field against now minus 7 days) before computing
created, approved, and trend; keep the same output shape ({ created, approved,
trend }) but compute created = filtered.length, approved = filtered.filter(i =>
i.status === "approved").length, and calculate trend using those counts so the
"This Week's Summary" reflects items from the past week rather than the first
seven items.

Comment on lines +327 to +337
<div className="mt-6 h-4 bg-accent rounded-full overflow-hidden flex">
{statusData.map((status) => (
<div
key={status.name}
style={{
width: `${(status.value / metrics.total) * 100}%`,
backgroundColor: status.color,
}}
/>
))}
</div>
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Division by zero when there are no items.

If items is empty, metrics.total is 0 and the width becomes NaN%. Guard against it:

🛡️ Proposed fix
               style={{
-                width: `${(status.value / metrics.total) * 100}%`,
+                width: `${metrics.total > 0 ? (status.value / metrics.total) * 100 : 0}%`,
                 backgroundColor: status.color,
               }}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div className="mt-6 h-4 bg-accent rounded-full overflow-hidden flex">
{statusData.map((status) => (
<div
key={status.name}
style={{
width: `${(status.value / metrics.total) * 100}%`,
backgroundColor: status.color,
}}
/>
))}
</div>
<div className="mt-6 h-4 bg-accent rounded-full overflow-hidden flex">
{statusData.map((status) => (
<div
key={status.name}
style={{
width: `${metrics.total > 0 ? (status.value / metrics.total) * 100 : 0}%`,
backgroundColor: status.color,
}}
/>
))}
</div>
🤖 Prompt for AI Agents
In `@src/components/AnalyticsDashboard.tsx` around lines 327 - 337, Guard against
division-by-zero when computing the width for each status segment: inside the
rendering that maps statusData (the JSX block using statusData.map and
metrics.total), compute a safe percentage like sharePercent = metrics.total ?
(status.value / metrics.total) * 100 : 0 and use that value for the inline style
width (e.g., `${sharePercent}%`) so the width never becomes NaN%; ensure the
fallback yields 0% for empty items.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant