From fcdcd442716016b88d9d456769fa3beecb0a9b36 Mon Sep 17 00:00:00 2001 From: Sai Prakash Date: Wed, 15 Jul 2026 22:21:12 -0400 Subject: [PATCH 1/2] Add Work Items filters, rework Tech Debt Register, persist Plans view - Work Items: filter by Asset and Area, plus an "Owned by me" toggle matching the pattern already used on Plans/Tasks. - Tech Debt Register: rank asset groups by the same severity-weighted debt score used on Products/Analytics (manual override or derived), and surface each asset's health/score instead of raw item counts. - Code Plans: persist the card/list view choice across sessions via a cookie, mirroring the existing product-scope cookie pattern. Co-Authored-By: Claude Sonnet 5 --- app/(dashboard)/plans/page.tsx | 7 +- app/(dashboard)/plans/plans-client.tsx | 24 +++- app/(dashboard)/work-items/page.tsx | 7 +- .../work-items/work-items-client.tsx | 133 ++++++++++++++++-- lib/db/queries.ts | 17 +++ lib/plans-view-cookie.ts | 6 + 6 files changed, 177 insertions(+), 17 deletions(-) create mode 100644 lib/plans-view-cookie.ts diff --git a/app/(dashboard)/plans/page.tsx b/app/(dashboard)/plans/page.tsx index fc1655e..02b3ca3 100644 --- a/app/(dashboard)/plans/page.tsx +++ b/app/(dashboard)/plans/page.tsx @@ -1,6 +1,8 @@ +import { cookies } from 'next/headers' import { authAdapter } from '@/lib/auth' import { getCodePlans, getProducts } from '@/lib/db/queries' import { getProductScope } from '@/lib/product-scope' +import { PLANS_VIEW_COOKIE, parsePlansView } from '@/lib/plans-view-cookie' import { PlansClient } from './plans-client' import { PlanCreatePanel } from './plan-create-panel' @@ -16,6 +18,9 @@ export default async function PlansPage({ searchParams }: Props) { const scope = await getProductScope() const productId = productParam || scope || undefined + const cookieStore = await cookies() + const initialView = parsePlansView(cookieStore.get(PLANS_VIEW_COOKIE)?.value) + const [plans, products] = await Promise.all([ getCodePlans(user.id, { productId }), getProducts(user.id), @@ -37,7 +42,7 @@ export default async function PlansPage({ searchParams }: Props) { - + ) } diff --git a/app/(dashboard)/plans/plans-client.tsx b/app/(dashboard)/plans/plans-client.tsx index ad0efb3..8f6d0c3 100644 --- a/app/(dashboard)/plans/plans-client.tsx +++ b/app/(dashboard)/plans/plans-client.tsx @@ -13,6 +13,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@ import { Plus, Calendar, Users, Filter, ArrowUpRight, Clock, List, LayoutGrid } from 'lucide-react' import type { CodePlanStatus, CodePlanType } from '@/lib/types' import { cn, formatDate } from '@/lib/utils' +import { PLANS_VIEW_COOKIE, type PlansView } from '@/lib/plans-view-cookie' import { PlanCreatePanel } from './plan-create-panel' type Plan = { @@ -55,11 +56,26 @@ const typeStyles: Record = { bugfix: 'bg-chart-5/20 text-chart-5', } -export function PlansClient({ plans, products, currentUserId }: { plans: Plan[]; products: Product[]; currentUserId?: string }) { +export function PlansClient({ + plans, + products, + currentUserId, + initialView = 'card', +}: { + plans: Plan[] + products: Product[] + currentUserId?: string + initialView?: PlansView +}) { const [mineOnly, setMineOnly] = useState(false) const [statusFilter, setStatusFilter] = useState('open') const [productFilter, setProductFilter] = useState('all') - const [view, setView] = useState<'card' | 'list'>('card') + const [view, setView] = useState(initialView) + + function updateView(next: PlansView) { + setView(next) + document.cookie = `${PLANS_VIEW_COOKIE}=${next}; path=/; max-age=${60 * 60 * 24 * 365}` + } const filteredPlans = plans.filter((plan) => { if (mineOnly && plan.ownerId !== currentUserId && !plan.assigneeIds.includes(currentUserId ?? '')) return false @@ -152,10 +168,10 @@ export function PlansClient({ plans, products, currentUserId }: { plans: Plan[];
- -
diff --git a/app/(dashboard)/work-items/page.tsx b/app/(dashboard)/work-items/page.tsx index 591fb00..b9d1f1e 100644 --- a/app/(dashboard)/work-items/page.tsx +++ b/app/(dashboard)/work-items/page.tsx @@ -1,5 +1,5 @@ import { authAdapter } from '@/lib/auth' -import { getWorkItems, getCodePlans, getProducts, getAssetOptions, getTeamMembers } from '@/lib/db/queries' +import { getWorkItems, getCodePlans, getProducts, getAssetOptions, getAssetDebtInfo, getTeamMembers } from '@/lib/db/queries' import { db } from '@/lib/db' import { users } from '@/lib/db/schema' import { eq } from 'drizzle-orm' @@ -12,11 +12,12 @@ export default async function WorkItemsPage() { const scope = await getProductScope() - const [items, plans, products, assetOptions] = await Promise.all([ + const [items, plans, products, assetOptions, assetDebtInfo] = await Promise.all([ getWorkItems(user.id, { productId: scope ?? undefined }), getCodePlans(user.id, { productId: scope ?? undefined }), getProducts(user.id), // all accessible products — create form needs the full list getAssetOptions(user.id), + getAssetDebtInfo(user.id), ]) const profile = await db.query.users.findFirst({ where: eq(users.id, user.id) }) @@ -35,8 +36,10 @@ export default async function WorkItemsPage() { plans={planList} products={productList} assets={assetOptions} + assetDebtInfo={assetDebtInfo} members={memberList} scopedProductId={scope} + currentUserId={user.id} /> ) diff --git a/app/(dashboard)/work-items/work-items-client.tsx b/app/(dashboard)/work-items/work-items-client.tsx index db01141..03392c5 100644 --- a/app/(dashboard)/work-items/work-items-client.tsx +++ b/app/(dashboard)/work-items/work-items-client.tsx @@ -6,12 +6,13 @@ import { useSearchParams } from 'next/navigation' import { Button } from '@/components/ui/button' import { Card, CardContent } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' +import { Switch } from '@/components/ui/switch' import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import { Filter, Plus, Circle, Play, CheckCircle2, Wrench, ExternalLink, List, Layers } from 'lucide-react' import type { WorkItemStatus, WorkItemType } from '@/lib/types' -import type { WorkItemWithContext } from '@/lib/db/queries' +import type { WorkItemWithContext, AssetDebtInfo } from '@/lib/db/queries' import { cn } from '@/lib/utils' import { WorkItemPanel, @@ -27,25 +28,34 @@ import { } from './work-item-panel' const PAGE_SIZE = 25 +const UNASSIGNED_ASSET = '__unassigned__' +const UNASSIGNED_AREA = '__unassigned__' export function WorkItemsClient({ items, plans, products, assets, + assetDebtInfo = [], members = [], scopedProductId, + currentUserId, }: { items: WorkItemWithContext[] plans: PlanOption[] products: ProductOption[] assets: AssetOption[] + assetDebtInfo?: AssetDebtInfo[] members?: { id: string; name: string }[] scopedProductId: string | null + currentUserId?: string }) { const searchParams = useSearchParams() const [statusFilter, setStatusFilter] = useState('all') const [typeFilter, setTypeFilter] = useState('all') + const [assetFilter, setAssetFilter] = useState('all') + const [areaFilter, setAreaFilter] = useState('all') + const [mineOnly, setMineOnly] = useState(false) const [view, setView] = useState<'list' | 'debt'>('list') const [page, setPage] = useState(0) const [createOpen, setCreateOpen] = useState(false) @@ -65,9 +75,39 @@ export function WorkItemsClient({ if (openItemId) window.history.pushState(null, '', '/work-items') }, [openItemId]) + const assetFilterOptions = useMemo(() => { + const map = new Map() + let hasUnassigned = false + for (const item of items) { + if (item.assetId && item.assetName) map.set(item.assetId, item.assetName) + else hasUnassigned = true + } + return { + known: [...map.entries()].sort((a, b) => a[1].localeCompare(b[1])), + hasUnassigned, + } + }, [items]) + + const areaFilterOptions = useMemo(() => { + const set = new Set() + let hasUnassigned = false + for (const item of items) { + if (item.area) set.add(item.area) + else hasUnassigned = true + } + return { known: [...set].sort(), hasUnassigned } + }, [items]) + const filteredItems = items.filter((item) => { if (statusFilter !== 'all' && item.status !== statusFilter) return false if (typeFilter !== 'all' && item.type !== typeFilter) return false + if (assetFilter === UNASSIGNED_ASSET) { + if (item.assetId) return false + } else if (assetFilter !== 'all' && item.assetId !== assetFilter) return false + if (areaFilter === UNASSIGNED_AREA) { + if (item.area) return false + } else if (areaFilter !== 'all' && item.area !== areaFilter) return false + if (mineOnly && item.ownerId !== currentUserId) return false return true }) const pageItems = filteredItems.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE) @@ -156,10 +196,16 @@ export function WorkItemsClient({ Resolved -
+ {currentUserId && ( + + )} +
+ +
{view === 'debt' ? ( - + ) : ( @@ -278,23 +352,40 @@ export function WorkItemsClient({ ) } -/** Open tech-debt items grouped by asset — the debt register. */ +// Matches the weighting used server-side for asset debt scores (lib/db/queries.ts getProduct/getAnalytics) +// so the register's ranking agrees with the score shown on the Products/Assets pages. +const DEBT_WEIGHT: Record = { low: 3, medium: 8, high: 15, critical: 25 } + +const healthStyles: Record = { + healthy: 'bg-accent/20 text-accent', + warning: 'bg-warning/20 text-warning', + critical: 'bg-destructive/20 text-destructive', +} + +/** Open tech-debt items grouped by asset, ranked by debt score — the debt register. */ function DebtRegister({ items, + assetDebtInfo, onOpen, }: { items: WorkItemWithContext[] + assetDebtInfo: AssetDebtInfo[] onOpen: (item: WorkItemWithContext) => void }) { const openStatuses = ['open', 'planned', 'in_progress'] const debtItems = items.filter((i) => i.type === 'tech_debt' && openStatuses.includes(i.status)) + const assetById = new Map(assetDebtInfo.map((a) => [a.id, a])) - const groups = new Map() + const groups = new Map< + string, + { label: string; product: string; asset?: AssetDebtInfo; items: WorkItemWithContext[] } + >() for (const item of debtItems) { const key = item.assetId ?? `unassigned-${item.productId}` const group = groups.get(key) ?? { label: item.assetName ?? 'Unassigned', product: item.productName, + asset: item.assetId ? assetById.get(item.assetId) : undefined, items: [], } group.items.push(item) @@ -302,14 +393,22 @@ function DebtRegister({ } const severityRank = { critical: 0, high: 1, medium: 2, low: 3 } - const sortedGroups = [...groups.entries()].sort((a, b) => b[1].items.length - a[1].items.length) + const scored = [...groups.entries()].map(([key, group]) => { + const derivedScore = Math.min( + 100, + group.items.reduce((sum, item) => sum + (DEBT_WEIGHT[item.severity] ?? 8), 0), + ) + const effectiveScore = group.asset?.techDebtScore ?? derivedScore + return { key, group, effectiveScore } + }) + const sortedGroups = scored.sort((a, b) => b.effectiveScore - a.effectiveScore) if (debtItems.length === 0) { return (

No open tech debt

-

Tech-debt work items appear here grouped by asset

+

Tech-debt work items appear here grouped by asset, ranked by debt score

) @@ -317,7 +416,7 @@ function DebtRegister({ return (
- {sortedGroups.map(([key, group]) => { + {sortedGroups.map(({ key, group, effectiveScore }) => { const critical = group.items.filter((i) => i.severity === 'critical').length const high = group.items.filter((i) => i.severity === 'high').length const sorted = [...group.items].sort((a, b) => severityRank[a.severity] - severityRank[b.severity]) @@ -326,10 +425,24 @@ function DebtRegister({
-

{group.label}

+
+

{group.label}

+ {group.asset && ( + + {group.asset.health} + + )} +

{group.product}

+ = 60 ? severityStyles.critical : effectiveScore >= 30 ? severityStyles.high : severityStyles.medium)} + title={group.asset?.techDebtScore != null ? 'Manually set debt score' : 'Derived from open tech-debt severity'} + > + Debt score {effectiveScore} + {critical > 0 && ( {critical} critical )} diff --git a/lib/db/queries.ts b/lib/db/queries.ts index 573b883..f0666f2 100644 --- a/lib/db/queries.ts +++ b/lib/db/queries.ts @@ -762,6 +762,23 @@ export async function getAssetOptions( .orderBy(assets.name) } +export type AssetDebtInfo = { + id: string + health: 'healthy' | 'warning' | 'critical' + /** Manual override score, when set. */ + techDebtScore: number | null +} + +/** Health + manual debt-score override for accessible assets — for the tech debt register. */ +export async function getAssetDebtInfo(userId: string): Promise { + const productFilter = await productAccessWhere(userId) + return db + .select({ id: assets.id, health: assets.health, techDebtScore: assets.techDebtScore }) + .from(assets) + .innerJoin(products, eq(assets.productId, products.id)) + .where(productFilter) +} + // --------------------------------------------------------------------------- // Asset dependencies & impact analysis // --------------------------------------------------------------------------- diff --git a/lib/plans-view-cookie.ts b/lib/plans-view-cookie.ts new file mode 100644 index 0000000..def22e9 --- /dev/null +++ b/lib/plans-view-cookie.ts @@ -0,0 +1,6 @@ +export const PLANS_VIEW_COOKIE = 'plans_view' +export type PlansView = 'card' | 'list' + +export function parsePlansView(value: string | undefined): PlansView { + return value === 'list' ? 'list' : 'card' +} From 490488667688b696c8a0f64c6c53b8dad0a2732d Mon Sep 17 00:00:00 2001 From: Sai Prakash Date: Wed, 15 Jul 2026 23:18:10 -0400 Subject: [PATCH 2/2] v0.3.23 Co-Authored-By: Claude Fable 5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2fcdf5d..40fef35 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codeplans", - "version": "0.3.22", + "version": "0.3.23", "description": "Manage and track coordinated changes across your software architecture.", "author": "Sai Prakash ", "homepage": "https://codeplans.ai",