Refine inbox ordering and alert-focused badges
This commit is contained in:
@@ -5,15 +5,15 @@ import { joinRequests } from "@paperclipai/db";
|
|||||||
import { sidebarBadgeService } from "../services/sidebar-badges.js";
|
import { sidebarBadgeService } from "../services/sidebar-badges.js";
|
||||||
import { issueService } from "../services/issues.js";
|
import { issueService } from "../services/issues.js";
|
||||||
import { accessService } from "../services/access.js";
|
import { accessService } from "../services/access.js";
|
||||||
|
import { dashboardService } from "../services/dashboard.js";
|
||||||
import { assertCompanyAccess } from "./authz.js";
|
import { assertCompanyAccess } from "./authz.js";
|
||||||
|
|
||||||
const INBOX_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked"] as const;
|
|
||||||
|
|
||||||
export function sidebarBadgeRoutes(db: Db) {
|
export function sidebarBadgeRoutes(db: Db) {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const svc = sidebarBadgeService(db);
|
const svc = sidebarBadgeService(db);
|
||||||
const issueSvc = issueService(db);
|
const issueSvc = issueService(db);
|
||||||
const access = accessService(db);
|
const access = accessService(db);
|
||||||
|
const dashboard = dashboardService(db);
|
||||||
|
|
||||||
router.get("/companies/:companyId/sidebar-badges", async (req, res) => {
|
router.get("/companies/:companyId/sidebar-badges", async (req, res) => {
|
||||||
const companyId = req.params.companyId as string;
|
const companyId = req.params.companyId as string;
|
||||||
@@ -36,19 +36,16 @@ export function sidebarBadgeRoutes(db: Db) {
|
|||||||
.then((rows) => Number(rows[0]?.count ?? 0))
|
.then((rows) => Number(rows[0]?.count ?? 0))
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const unreadTouchedIssueCount =
|
|
||||||
req.actor.type === "board" && req.actor.userId
|
|
||||||
? await issueSvc.countUnreadTouchedByUser(
|
|
||||||
companyId,
|
|
||||||
req.actor.userId,
|
|
||||||
INBOX_ISSUE_STATUSES.join(","),
|
|
||||||
)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const badges = await svc.get(companyId, {
|
const badges = await svc.get(companyId, {
|
||||||
joinRequests: joinRequestCount,
|
joinRequests: joinRequestCount,
|
||||||
unreadTouchedIssues: unreadTouchedIssueCount,
|
|
||||||
});
|
});
|
||||||
|
const summary = await dashboard.summary(companyId);
|
||||||
|
const staleIssueCount = await issueSvc.staleCount(companyId, 24 * 60);
|
||||||
|
const alertsCount =
|
||||||
|
(summary.agents.error > 0 ? 1 : 0) +
|
||||||
|
(summary.costs.monthBudgetCents > 0 && summary.costs.monthUtilizationPercent >= 80 ? 1 : 0);
|
||||||
|
badges.inbox = badges.failedRuns + alertsCount + staleIssueCount;
|
||||||
|
|
||||||
res.json(badges);
|
res.json(badges);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import { PageTabBar } from "../components/PageTabBar";
|
|||||||
import type { HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
|
import type { HeartbeatRun, Issue, JoinRequest } from "@paperclipai/shared";
|
||||||
|
|
||||||
const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000; // 24 hours
|
const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
|
const RECENT_ISSUES_LIMIT = 100;
|
||||||
const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]);
|
const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]);
|
||||||
const ACTIONABLE_APPROVAL_STATUSES = new Set(["pending", "revision_requested"]);
|
const ACTIONABLE_APPROVAL_STATUSES = new Set(["pending", "revision_requested"]);
|
||||||
|
|
||||||
@@ -390,7 +391,7 @@ export function Inbox() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const touchedIssues = useMemo(
|
const touchedIssues = useMemo(
|
||||||
() => [...touchedIssuesRaw].sort(sortByMostRecentActivity),
|
() => [...touchedIssuesRaw].sort(sortByMostRecentActivity).slice(0, RECENT_ISSUES_LIMIT),
|
||||||
[sortByMostRecentActivity, touchedIssuesRaw],
|
[sortByMostRecentActivity, touchedIssuesRaw],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -504,12 +505,8 @@ export function Inbox() {
|
|||||||
const hasStale = staleIssues.length > 0;
|
const hasStale = staleIssues.length > 0;
|
||||||
const hasJoinRequests = joinRequests.length > 0;
|
const hasJoinRequests = joinRequests.length > 0;
|
||||||
const hasTouchedIssues = touchedIssues.length > 0;
|
const hasTouchedIssues = touchedIssues.length > 0;
|
||||||
const unreadTouchedCount = touchedIssues.filter((issue) => issue.isUnreadForMe).length;
|
|
||||||
|
|
||||||
const newItemCount =
|
const newItemCount =
|
||||||
unreadTouchedCount +
|
|
||||||
joinRequests.length +
|
|
||||||
actionableApprovals.length +
|
|
||||||
failedRuns.length +
|
failedRuns.length +
|
||||||
staleIssues.length +
|
staleIssues.length +
|
||||||
(showAggregateAgentError ? 1 : 0) +
|
(showAggregateAgentError ? 1 : 0) +
|
||||||
@@ -539,12 +536,12 @@ export function Inbox() {
|
|||||||
const showStaleSection = tab === "new" ? hasStale : showStaleCategory && hasStale;
|
const showStaleSection = tab === "new" ? hasStale : showStaleCategory && hasStale;
|
||||||
|
|
||||||
const visibleSections = [
|
const visibleSections = [
|
||||||
showTouchedSection ? "issues_i_touched" : null,
|
|
||||||
showApprovalsSection ? "approvals" : null,
|
|
||||||
showJoinRequestsSection ? "join_requests" : null,
|
|
||||||
showFailedRunsSection ? "failed_runs" : null,
|
showFailedRunsSection ? "failed_runs" : null,
|
||||||
showAlertsSection ? "alerts" : null,
|
showAlertsSection ? "alerts" : null,
|
||||||
showStaleSection ? "stale_work" : null,
|
showStaleSection ? "stale_work" : null,
|
||||||
|
showApprovalsSection ? "approvals" : null,
|
||||||
|
showJoinRequestsSection ? "join_requests" : null,
|
||||||
|
showTouchedSection ? "issues_i_touched" : null,
|
||||||
].filter((key): key is SectionKey => key !== null);
|
].filter((key): key is SectionKey => key !== null);
|
||||||
|
|
||||||
const allLoaded =
|
const allLoaded =
|
||||||
@@ -592,7 +589,7 @@ export function Inbox() {
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="everything">All categories</SelectItem>
|
<SelectItem value="everything">All categories</SelectItem>
|
||||||
<SelectItem value="issues_i_touched">Issues I touched</SelectItem>
|
<SelectItem value="issues_i_touched">My recent issues</SelectItem>
|
||||||
<SelectItem value="join_requests">Join requests</SelectItem>
|
<SelectItem value="join_requests">Join requests</SelectItem>
|
||||||
<SelectItem value="approvals">Approvals</SelectItem>
|
<SelectItem value="approvals">Approvals</SelectItem>
|
||||||
<SelectItem value="failed_runs">Failed runs</SelectItem>
|
<SelectItem value="failed_runs">Failed runs</SelectItem>
|
||||||
@@ -638,48 +635,6 @@ export function Inbox() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showTouchedSection && (
|
|
||||||
<>
|
|
||||||
{showSeparatorBefore("issues_i_touched") && <Separator />}
|
|
||||||
<div>
|
|
||||||
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
|
||||||
Issues I Touched
|
|
||||||
</h3>
|
|
||||||
<div className="divide-y divide-border border border-border">
|
|
||||||
{touchedIssues.map((issue) => (
|
|
||||||
<Link
|
|
||||||
key={issue.id}
|
|
||||||
to={`/issues/${issue.identifier ?? issue.id}`}
|
|
||||||
className="flex cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50 no-underline text-inherit"
|
|
||||||
>
|
|
||||||
<span className="flex w-4 shrink-0 justify-center">
|
|
||||||
<span
|
|
||||||
className={`h-2.5 w-2.5 rounded-full ${
|
|
||||||
issue.isUnreadForMe
|
|
||||||
? "bg-blue-600 dark:bg-blue-400"
|
|
||||||
: "border border-muted-foreground/40 bg-transparent"
|
|
||||||
}`}
|
|
||||||
aria-label={issue.isUnreadForMe ? "Unread" : "Read"}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<PriorityIcon priority={issue.priority} />
|
|
||||||
<StatusIcon status={issue.status} />
|
|
||||||
<span className="text-xs font-mono text-muted-foreground">
|
|
||||||
{issue.identifier ?? issue.id.slice(0, 8)}
|
|
||||||
</span>
|
|
||||||
<span className="flex-1 truncate text-sm">{issue.title}</span>
|
|
||||||
<span className="shrink-0 text-xs text-muted-foreground">
|
|
||||||
{issue.lastExternalCommentAt
|
|
||||||
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
|
|
||||||
: `updated ${timeAgo(issue.updatedAt)}`}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showApprovalsSection && (
|
{showApprovalsSection && (
|
||||||
<>
|
<>
|
||||||
{showSeparatorBefore("approvals") && <Separator />}
|
{showSeparatorBefore("approvals") && <Separator />}
|
||||||
@@ -895,6 +850,48 @@ export function Inbox() {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showTouchedSection && (
|
||||||
|
<>
|
||||||
|
{showSeparatorBefore("issues_i_touched") && <Separator />}
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
My Recent Issues
|
||||||
|
</h3>
|
||||||
|
<div className="divide-y divide-border border border-border">
|
||||||
|
{touchedIssues.map((issue) => (
|
||||||
|
<Link
|
||||||
|
key={issue.id}
|
||||||
|
to={`/issues/${issue.identifier ?? issue.id}`}
|
||||||
|
className="flex cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50 no-underline text-inherit"
|
||||||
|
>
|
||||||
|
<span className="flex w-4 shrink-0 justify-center">
|
||||||
|
<span
|
||||||
|
className={`h-2.5 w-2.5 rounded-full ${
|
||||||
|
issue.isUnreadForMe
|
||||||
|
? "bg-blue-600 dark:bg-blue-400"
|
||||||
|
: "border border-muted-foreground/40 bg-transparent"
|
||||||
|
}`}
|
||||||
|
aria-label={issue.isUnreadForMe ? "Unread" : "Read"}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<PriorityIcon priority={issue.priority} />
|
||||||
|
<StatusIcon status={issue.status} />
|
||||||
|
<span className="text-xs font-mono text-muted-foreground">
|
||||||
|
{issue.identifier ?? issue.id.slice(0, 8)}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 truncate text-sm">{issue.title}</span>
|
||||||
|
<span className="shrink-0 text-xs text-muted-foreground">
|
||||||
|
{issue.lastExternalCommentAt
|
||||||
|
? `commented ${timeAgo(issue.lastExternalCommentAt)}`
|
||||||
|
: `updated ${timeAgo(issue.updatedAt)}`}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user