diff --git a/DEVLOG.md b/DEVLOG.md index bf60e79c..b1c0aaad 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -13173,3 +13173,35 @@ fresh production E2E build completed. The browser run passed 35 of 38 journeys before Chromium crashed on a memory-saturated host; each of the three reported journeys passed immediately in a fresh isolated browser process. Release, deployment, and production smoke results follow after the pull request lands. + +--- + +## 2026-07-15 — Projects and issues lifecycle UX + +Audited the projects, project creation, global issues, and project issue flows +from captured browser evidence. The same completed issue could previously be +absent from the default list, visible on the default board, and hidden again +behind an implicit Done filter, while duplicate quick filters and an empty +saved-view row obscured what the page was actually showing. + +Added one explicit Open/All lifecycle scope shared by global List and Kanban +views and by project List and Board tabs. Counts, columns, results, URLs, +search labels, and filtered-empty recovery now use that same scope; searches +with no open match offer a direct completed-work recovery path. Removed the +duplicate Done quick filter, clarified updated-time filtering versus sorting, +hid the saved-view bar when it has nothing to show, and added a clear-search +control. + +Project creation now identifies required fields before submission, explains +that the project key becomes an immutable issue-ID prefix, clears validation +feedback as the form is corrected, gives color choices accessible names and +selected state, and navigates directly to the created project. Starter +templates follow the same destination. Project cards now expose explicit +completed/total progress text and a semantic progress bar. + +Verification: formatting and typecheck passed; lint passed with existing +repository warnings; the focused saved-view filter suite passed 13 tests; the +fresh production E2E build succeeded; and both new Playwright journeys passed. +The unconfigured full Vitest command could not run integration tests because +this shell did not provide `DATABASE_URL` or `AUTH_SECRET`; its focused, +dependency-free coverage passed separately. diff --git a/src/app/(app)/w/[slug]/issues/page.tsx b/src/app/(app)/w/[slug]/issues/page.tsx index 2bf5e2a8..43b68f50 100644 --- a/src/app/(app)/w/[slug]/issues/page.tsx +++ b/src/app/(app)/w/[slug]/issues/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter, usePathname, useSearchParams } from "next/navigation"; -import { Archive, ArchiveRestore, Sparkles, Folder } from "lucide-react"; +import { Archive, ArchiveRestore, Sparkles, Folder, X } from "lucide-react"; import Link from "next/link"; import { Topbar } from "@/components/topbar"; import { IssueList } from "@/components/issue-list"; @@ -22,11 +22,13 @@ import { import { GroupChip, IssueFacetChips, SortChip } from "@/components/saved-views/facet-chips"; import { QuickFilterChips } from "@/components/saved-views/quick-filter-chips"; import { SavedViewsBar, SaveViewDialog } from "@/components/saved-views/saved-views-bar"; +import { IssueScopeToggle, type IssueLifecycleScope } from "@/components/issue-scope-toggle"; import { trpc } from "@/lib/trpc"; import { cn } from "@/lib/utils"; import { useWorkspace } from "@/hooks/use-workspace"; import { doneStatusIds, + filtersTargetDone, filtersEqual, isEmptyFilters, ISSUE_GROUP_VALUES, @@ -75,6 +77,7 @@ export default function IssuesPage() { const fParam = searchParams?.get("f") ?? null; const dueOnFromUrl = searchParams?.get("dueOn") ?? null; const showArchived = searchParams?.get("archived") === "1"; + const requestedScope: IssueLifecycleScope = searchParams?.get("scope") === "all" ? "all" : "open"; // Reject a malformed or calendar-invalid ?dueOn (e.g. 2026-13-45, which // passes the shape regex but rolls over) before it reaches the chip/query. const dueOn = dueOnFromUrl && isRealDateString(dueOnFromUrl) ? dueOnFromUrl : null; @@ -114,6 +117,13 @@ export default function IssuesPage() { const { data: wsCount, isLoading: wsLoading } = trpc.workspace.current.useQuery(); const { data: me } = trpc.user.me.useQuery(); const { data: views } = trpc.savedView.list.useQuery(); + const { data: statuses } = trpc.status.list.useQuery(); + const doneIds = useMemo(() => doneStatusIds(statuses ?? []), [statuses]); + // Legacy saved views may already encode terminal inclusion in their + // filter blob. Reflect that truth in the visible scope control instead + // of claiming the view is "Open" while rendering completed work. + const scope: IssueLifecycleScope = + filters.includeDone || filtersTargetDone(filters, doneIds) ? "all" : requestedScope; const key = wsCount?.key ?? ws.key; // Canonical URL writer. Every mutation routes through here, building the @@ -129,6 +139,7 @@ export default function IssuesPage() { view: string | null; dueOn: string | null; archived: boolean; + scope: IssueLifecycleScope; }) => { const p = new URLSearchParams(); if (s.view) p.set("view", s.view); @@ -138,6 +149,7 @@ export default function IssuesPage() { if (s.group !== "status") p.set("group", s.group); if (s.dueOn) p.set("dueOn", s.dueOn); if (s.archived) p.set("archived", "1"); + if (s.scope === "all") p.set("scope", "all"); return p.toString(); }, [], @@ -152,6 +164,7 @@ export default function IssuesPage() { view: string | null; dueOn: string | null; archived: boolean; + scope: IssueLifecycleScope; }>, opts?: { push?: boolean }, ) => { @@ -163,6 +176,7 @@ export default function IssuesPage() { view: next.view !== undefined ? next.view : (activeViewId ?? viewIdFromUrl), dueOn: next.dueOn !== undefined ? next.dueOn : dueOn, archived: next.archived ?? showArchived, + scope: next.scope ?? scope, }); const url = qs ? `${pathname}?${qs}` : pathname; if (opts?.push) router.push(url); @@ -178,6 +192,7 @@ export default function IssuesPage() { viewIdFromUrl, dueOn, showArchived, + scope, pathname, router, ], @@ -289,7 +304,30 @@ export default function IssuesPage() { setActiveViewId(null); // One URL write that drops filters, query, view AND dueOn together — // the old two-call clear read a stale snapshot and could re-add ?view=. - commit({ filters: {}, query: "", view: null, dueOn: null }, { push: true }); + commit({ filters: {}, query: "", view: null, dueOn: null, scope: "open" }, { push: true }); + } + + function setLifecycleScope(nextScope: IssueLifecycleScope) { + if (nextScope === "all") { + commit({ scope: "all" }, { push: true }); + return; + } + + // Returning to Open also clears terminal-only selections and legacy + // `includeDone` flags so the visible scope is authoritative. + const nextStatusIds = filters.statusIds?.filter((id) => !doneIds.has(id)); + const nextStatusCategories = filters.statusCategories?.filter( + (category) => category !== "DONE" && category !== "CANCELED", + ); + const nextFilters: SavedViewFilters = { + ...filters, + statusIds: nextStatusIds?.length ? nextStatusIds : undefined, + statusCategories: nextStatusCategories?.length ? nextStatusCategories : undefined, + includeDone: undefined, + }; + setFilters(nextFilters); + setActiveViewId(null); + commit({ filters: nextFilters, view: null, scope: "open" }, { push: true }); } function clearDueOn() { @@ -300,8 +338,10 @@ export default function IssuesPage() { commit({ archived }, { push: true }); } - const hasFilters = !isEmptyFilters(filters) || !!query || !!dueOn; - const isWorkspaceEmpty = !showArchived && !!wsCount && wsCount._count.issues === 0 && !hasFilters; + const hasNarrowing = !isEmptyFilters(filters) || !!query || !!dueOn; + const hasViewAdjustments = hasNarrowing || scope !== "open"; + const isWorkspaceEmpty = + !showArchived && !!wsCount && wsCount._count.issues === 0 && !hasViewAdjustments; // The query object we pass down. `query` is kept separate from the // saved-view filter blob (it's not persisted with views by default to @@ -313,13 +353,11 @@ export default function IssuesPage() { // Honest header count. Tracks what the current view actually shows // instead of a raw workspace total (which over-counts soft-deleted + - // done + snoozed). The board includes done; the list applies the same - // effective includeDone as IssueList. Shares `issue.count`, so it can't - // drift from the list's own where-clause. - const { data: statuses } = trpc.status.list.useQuery(); - const doneIds = useMemo(() => doneStatusIds(statuses ?? []), [statuses]); - const countIncludeDone = - showArchived || view === "board" ? true : resolveIncludeDone(issueQueryFilters, false, doneIds); + // done + snoozed). List and board share this exact lifecycle baseline, + // so changing presentation cannot change which issues are eligible. + const countIncludeDone = showArchived + ? true + : resolveIncludeDone(issueQueryFilters, scope === "all", doneIds); const { data: countData } = trpc.issue.count.useQuery({ ...issueQueryFilters, includeDone: countIncludeDone, @@ -335,7 +373,15 @@ export default function IssuesPage() { title={showArchived ? "Archived issues" : "All issues"} subtitle={ countData - ? `${countData.count} ${hasFilters ? "matching" : showArchived ? "archived" : "issues"}` + ? `${countData.count} ${ + hasNarrowing + ? "matching" + : showArchived + ? "archived" + : scope === "open" + ? "open" + : "issues" + }` : !showArchived && wsCount ? `${wsCount._count.issues} issues` : undefined @@ -351,14 +397,41 @@ export default function IssuesPage() { setQuery(e.target.value)} - placeholder="Search…" - className="h-8 w-[min(44vw,12rem)] pr-7 text-xs sm:h-7 sm:w-48" + placeholder={ + showArchived + ? "Search archived issues…" + : scope === "open" + ? "Search open issues…" + : "Search all issues…" + } + aria-label={ + showArchived + ? "Search archived issues" + : scope === "open" + ? "Search open issues" + : "Search all issues" + } + className="h-8 w-[min(55vw,15rem)] pr-8 text-xs sm:h-7 sm:w-60" /> {searchPending && ( )} + {!searchPending && query && ( + + )} )} {(showArchived || view === "list") && !isWorkspaceEmpty && } @@ -397,12 +470,13 @@ export default function IssuesPage() { fields) + Sprint/Initiative (single-pick). Sort — and Group, list view only — are pinned right. */}
+ {!showArchived && } {dueOn && } - {hasFilters && ( + {hasViewAdjustments && ( {" "} + to include completed work, or{" "} + + ) : activeViewName ? ( <> The view {activeViewName} has no matches right now.{" "} + ) : query ? ( + "No issues match this search. " ) : ( "Your active filters returned nothing. " )} @@ -546,7 +648,7 @@ function FilteredEmptyState({ onClick={onClear} className="text-foreground underline-offset-2 hover:text-ember hover:underline" > - Clear filters + {query ? "Clear search and filters" : "Clear filters"} {" "} to see all issues. diff --git a/src/app/(app)/w/[slug]/projects/[id]/page.tsx b/src/app/(app)/w/[slug]/projects/[id]/page.tsx index b7f071a5..4189cd61 100644 --- a/src/app/(app)/w/[slug]/projects/[id]/page.tsx +++ b/src/app/(app)/w/[slug]/projects/[id]/page.tsx @@ -18,6 +18,7 @@ import type { EventKind, StatusCategory } from "@prisma/client"; import { Topbar } from "@/components/topbar"; import { IssueList } from "@/components/issue-list"; import { IssueBoard } from "@/components/issue-board"; +import { IssueScopeToggle, type IssueLifecycleScope } from "@/components/issue-scope-toggle"; import { Avatar } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -48,6 +49,7 @@ export default function ProjectDetailPage({ params }: { params: Promise<{ id: st const tabParam = (searchParams?.get("view") ?? "overview") as Tab; const tab: Tab = tabParam === "list" || tabParam === "board" ? tabParam : "overview"; + const issueScope: IssueLifecycleScope = searchParams?.get("scope") === "all" ? "all" : "open"; const setTab = (next: Tab) => { const params = new URLSearchParams(searchParams?.toString() ?? ""); @@ -57,6 +59,14 @@ export default function ProjectDetailPage({ params }: { params: Promise<{ id: st router.replace(qs ? `${pathname}?${qs}` : pathname); }; + const setIssueScope = (scope: IssueLifecycleScope) => { + const params = new URLSearchParams(searchParams?.toString() ?? ""); + if (scope === "all") params.set("scope", "all"); + else params.delete("scope"); + const qs = params.toString(); + router.replace(qs ? `${pathname}?${qs}` : pathname); + }; + const [editOpen, setEditOpen] = useState(false); const [archiveOpen, setArchiveOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); @@ -176,6 +186,9 @@ export default function ProjectDetailPage({ params }: { params: Promise<{ id: st setTab("board")}> Board + {tab !== "overview" && ( + + )}
@@ -186,10 +199,20 @@ export default function ProjectDetailPage({ params }: { params: Promise<{ id: st )} {tab === "list" && (
- +
)} - {tab === "board" && } + {tab === "board" && ( + + )}
@@ -950,9 +973,7 @@ function EditProjectDialog({ const [repoBranch, setRepoBranch] = useState(project.repoBranch ?? ""); const [completionAutomation, setCompletionAutomation] = useState< "INHERIT" | "OFF" | "RECOMMEND" | "AUTO_WHEN_SAFE" - >( - project.completionAutomation ?? "INHERIT", - ); + >(project.completionAutomation ?? "INHERIT"); const update = trpc.project.update.useMutation({ onSuccess: () => { @@ -1039,11 +1060,7 @@ function EditProjectDialog({ value={completionAutomation} onChange={(value) => setCompletionAutomation( - (value ?? "INHERIT") as - | "INHERIT" - | "OFF" - | "RECOMMEND" - | "AUTO_WHEN_SAFE", + (value ?? "INHERIT") as "INHERIT" | "OFF" | "RECOMMEND" | "AUTO_WHEN_SAFE", ) } options={[ diff --git a/src/app/(app)/w/[slug]/projects/page.tsx b/src/app/(app)/w/[slug]/projects/page.tsx index 653cc4b8..5b54e90b 100644 --- a/src/app/(app)/w/[slug]/projects/page.tsx +++ b/src/app/(app)/w/[slug]/projects/page.tsx @@ -24,7 +24,7 @@ export default function ProjectsPage() { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); - const { data, isLoading, refetch } = trpc.project.list.useQuery({ archived: false, limit: 50 }); + const { data, isLoading } = trpc.project.list.useQuery({ archived: false, limit: 50 }); const [starterOpen, setStarterOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false); const existingKeys = new Set((data?.items ?? []).map((p) => p.key)); @@ -50,7 +50,7 @@ export default function ProjectsPage() { }) { setPendingTemplate(s.id); try { - await create.mutateAsync({ + const project = await create.mutateAsync({ key: s.suggestedKey, name: s.name, description: s.description ?? undefined, @@ -58,7 +58,7 @@ export default function ProjectsPage() { icon: s.icon ?? undefined, }); toast.success(`Created ${s.name}.`); - refetch(); + router.push(`/w/${slug}/projects/${project.id}`); } catch (e) { toast.error(e instanceof Error ? e.message : "Failed to create project."); } finally { @@ -120,9 +120,9 @@ export default function ProjectsPage() { Projects group related issues and roll up to initiatives. - - Start blank, or kick off from a template. Press{" "} - ⇧C from anywhere to quick-create. + + Start blank, or kick off from a template. Press ⇧C from anywhere to + quick-create. } @@ -132,11 +132,7 @@ export default function ProjectsPage() { New project {previewStarters.length === 0 && (starters?.length ?? 0) === 0 && ( - )} @@ -168,19 +164,17 @@ export default function ProjectsPage() { style={{ backgroundColor: s.color ?? "#78716c" }} /> {s.icon && } - - {s.name} - - + {s.name} + {s.suggestedKey} {s.description && ( -

+

{s.description}

)} -

+

{pendingTemplate === s.id ? "Adding…" : "Tap to add →"}

@@ -218,7 +212,7 @@ export default function ProjectsPage() { style={{ backgroundColor: p.color ?? "#78716c" }} /> {p.key} - + {p._count.issues} issues @@ -232,16 +226,31 @@ export default function ProjectsPage() { )} {p._count.issues > 0 && ( -
+
+
+ Progress + + {p._count.doneIssues}/{p._count.issues} done + +
+ className="h-1 w-full overflow-hidden rounded-full bg-subtle" + role="progressbar" + aria-label={`${p.name} completion`} + aria-valuemin={0} + aria-valuemax={p._count.issues} + aria-valuenow={p._count.doneIssues} + > +
+
)} -
+
Updated {relativeTime(p.updatedAt)} {p.initiative && ( @@ -262,12 +271,12 @@ export default function ProjectsPage() { open={starterOpen} onClose={() => setStarterOpen(false)} existingKeys={existingKeys} - onCreated={() => refetch()} + onCreated={(project) => router.push(`/w/${slug}/projects/${project.id}`)} /> setCreateOpen(false)} - onCreated={() => refetch()} + onCreated={(project) => router.push(`/w/${slug}/projects/${project.id}`)} /> ); @@ -282,7 +291,7 @@ function StarterDialog({ open: boolean; onClose: () => void; existingKeys: Set; - onCreated: () => void; + onCreated: (project: { id: string }) => void; }) { const { slug } = useWorkspace(); const { data: starters } = trpc.projectTemplate.list.useQuery(undefined, { enabled: open }); @@ -299,7 +308,7 @@ function StarterDialog({ }) { setPending(s.id); try { - await create.mutateAsync({ + const project = await create.mutateAsync({ key: s.suggestedKey, name: s.name, description: s.description ?? undefined, @@ -307,7 +316,7 @@ function StarterDialog({ icon: s.icon ?? undefined, }); toast.success(`Created ${s.name}.`); - onCreated(); + onCreated(project); } catch (e) { toast.error(e instanceof Error ? e.message : "Failed to create project."); } finally { @@ -329,7 +338,7 @@ function StarterDialog({

- Managed in Settings. Each row below invokes the standard project.create API. + Choose a prepared starting point. You can customize it after creation.

    @@ -348,12 +357,10 @@ function StarterDialog({
    {s.icon && {s.icon}} {s.name} - - {s.suggestedKey} - + {s.suggestedKey}
    {s.description && ( -
    {s.description}
    +
    {s.description}
    )}
+ ))} +
+ ); +} diff --git a/src/components/projects/new-project-dialog.tsx b/src/components/projects/new-project-dialog.tsx index f87b8110..12cb5290 100644 --- a/src/components/projects/new-project-dialog.tsx +++ b/src/components/projects/new-project-dialog.tsx @@ -15,6 +15,16 @@ const DEFAULT_COLORS = [ "#78716c", ]; +const COLOR_NAMES: Record = { + "#d97706": "Ember", + "#ca8a04": "Ochre", + "#65a30d": "Moss", + "#0ea5e9": "Sky", + "#7c3aed": "Violet", + "#be185d": "Rose", + "#78716c": "Stone", +}; + /** * Shared "New project" dialog. Used by the projects page and by the * context-aware quick-create (`⇧C` on `/projects`). @@ -29,7 +39,7 @@ export function NewProjectDialog({ }: { open: boolean; onClose: () => void; - onCreated?: () => void; + onCreated?: (project: { id: string; name: string; key: string }) => void; }) { const [name, setName] = useState(""); const [key, setKey] = useState(""); @@ -39,7 +49,7 @@ export function NewProjectDialog({ const utils = trpc.useUtils(); const create = trpc.project.create.useMutation({ - onSuccess: () => { + onSuccess: (project) => { toast.success("Project created."); setName(""); setKey(""); @@ -47,7 +57,7 @@ export function NewProjectDialog({ setColor(DEFAULT_COLORS[0]); setIcon(""); utils.project.list.invalidate(); - onCreated?.(); + onCreated?.(project); }, }); @@ -73,6 +83,7 @@ export function NewProjectDialog({ if (!o) onClose(); }} title="New project" + description="Start with the identity people will use to recognize and reference this project." primaryLabel={create.isPending ? "Creating…" : "Create project"} loading={create.isPending} draftKey="project.create" @@ -94,23 +105,30 @@ export function NewProjectDialog({ }} >
- + onNameChange(e.target.value)} autoFocus + aria-required="true" /> - + - setKey(e.target.value.replace(/[^a-zA-Z0-9]/g, "").toUpperCase()) - } + onChange={(e) => setKey(e.target.value.replace(/[^a-zA-Z0-9]/g, "").toUpperCase())} placeholder="FRG" maxLength={8} + aria-required="true" />
@@ -131,6 +149,7 @@ export function NewProjectDialog({ value={color} onChange={(e) => setColor(e.target.value)} className="h-8 w-10 cursor-pointer rounded border border-input bg-background" + aria-label="Custom project color" />
{DEFAULT_COLORS.map((c) => ( @@ -138,21 +157,19 @@ export function NewProjectDialog({ key={c} type="button" onClick={() => setColor(c)} - className="h-5 w-5 rounded border border-border" + className={`h-5 w-5 rounded border border-border ${ + color === c ? "ring-2 ring-ember/50 ring-offset-1 ring-offset-background" : "" + }`} style={{ backgroundColor: c }} - aria-label={c} + aria-label={`Choose ${COLOR_NAMES[c] ?? c}`} + aria-pressed={color === c} /> ))}
- setIcon(e.target.value)} - maxLength={8} - /> + setIcon(e.target.value)} maxLength={8} /> diff --git a/src/components/saved-views/facet-chips.tsx b/src/components/saved-views/facet-chips.tsx index 6f50cb5e..cd903561 100644 --- a/src/components/saved-views/facet-chips.tsx +++ b/src/components/saved-views/facet-chips.tsx @@ -39,10 +39,7 @@ function priorityLabel(p: Priority): string { } /** Toggle a string id within an optional array; empty collapses to undefined. */ -function toggleId( - arr: readonly string[] | undefined, - id: string, -): string[] | undefined { +function toggleId(arr: readonly string[] | undefined, id: string): string[] | undefined { const set = new Set(arr ?? []); if (set.has(id)) set.delete(id); else set.add(id); @@ -134,9 +131,7 @@ export function IssueFacetChips({ label={l.name} color={l.color} checked={filters.labelIds?.includes(l.id) ?? false} - onToggle={() => - onChange({ ...filters, labelIds: toggleId(filters.labelIds, l.id) }) - } + onToggle={() => onChange({ ...filters, labelIds: toggleId(filters.labelIds, l.id) })} /> ))} @@ -159,7 +154,7 @@ export function SortChip({ priority: "Priority", newest: "Newest", oldest: "Oldest", - updated: "Recently updated", + updated: "Last updated", title: "Title A–Z", }; return ( @@ -236,9 +231,7 @@ function usePopoverKeys( e.preventDefault(); const idx = items.indexOf(document.activeElement as HTMLElement); const next = - e.key === "ArrowDown" - ? (idx + 1) % items.length - : (idx - 1 + items.length) % items.length; + e.key === "ArrowDown" ? (idx + 1) % items.length : (idx - 1 + items.length) % items.length; items[next]?.focus(); } }; @@ -285,9 +278,7 @@ function FacetChip({ > {label} {active && ( - - {count} - + {count} )} @@ -407,9 +398,7 @@ function SelectChip({ )} > {o.label} - {value === o.value && ( - - )} + {value === o.value && } ))} diff --git a/src/components/saved-views/quick-filter-chips.tsx b/src/components/saved-views/quick-filter-chips.tsx index 32a7cf16..73f7b52f 100644 --- a/src/components/saved-views/quick-filter-chips.tsx +++ b/src/components/saved-views/quick-filter-chips.tsx @@ -1,5 +1,5 @@ "use client"; -import { CheckCircle2, CircleSlash, Inbox, Layers, ShieldAlert, Sparkles } from "lucide-react"; +import { CircleSlash, Inbox, Layers, ShieldAlert, Sparkles } from "lucide-react"; import { StatusCategory } from "@prisma/client"; import { cn } from "@/lib/utils"; import { trpc } from "@/lib/trpc"; @@ -39,11 +39,7 @@ export function QuickFilterChips({ // dedicated "backlog category" column on Workspace yet because the // category enum already serves that role. const backlogStatusIds = (statuses ?? []) - .filter( - (s) => - s.category === StatusCategory.BACKLOG || - s.category === StatusCategory.TODO, - ) + .filter((s) => s.category === StatusCategory.BACKLOG || s.category === StatusCategory.TODO) .map((s) => s.id); // -- Predicate helpers — derive `active` and `toggle` per chip from @@ -66,24 +62,16 @@ export function QuickFilterChips({ const toggleMyBacklog = () => { if (!meId) return; if (myBacklogActive) { - const nextAssignees = (filters.assigneeIds ?? []).filter( - (id) => id !== meId, - ); - const nextStatuses = (filters.statusIds ?? []).filter( - (id) => !backlogStatusIds.includes(id), - ); + const nextAssignees = (filters.assigneeIds ?? []).filter((id) => id !== meId); + const nextStatuses = (filters.statusIds ?? []).filter((id) => !backlogStatusIds.includes(id)); onChange({ ...filters, assigneeIds: nextAssignees.length ? nextAssignees : undefined, statusIds: nextStatuses.length ? nextStatuses : undefined, }); } else { - const nextAssignees = Array.from( - new Set([...(filters.assigneeIds ?? []), meId]), - ); - const nextStatuses = Array.from( - new Set([...(filters.statusIds ?? []), ...backlogStatusIds]), - ); + const nextAssignees = Array.from(new Set([...(filters.assigneeIds ?? []), meId])); + const nextStatuses = Array.from(new Set([...(filters.statusIds ?? []), ...backlogStatusIds])); onChange({ ...filters, assigneeIds: nextAssignees, @@ -111,20 +99,6 @@ export function QuickFilterChips({ }); }; - // Done / completed work. Use category instead of ids so custom terminal - // statuses stay covered and the server/client both infer includeDone. - const doneActive = filters.statusCategories?.includes(StatusCategory.DONE) ?? false; - const toggleDone = () => { - const nextCategories = doneActive - ? (filters.statusCategories ?? []).filter((c) => c !== StatusCategory.DONE) - : Array.from(new Set([...(filters.statusCategories ?? []), StatusCategory.DONE])); - onChange({ - ...filters, - statusCategories: nextCategories.length ? nextCategories : undefined, - includeDone: doneActive ? undefined : true, - }); - }; - // Epics — pin kinds to [EPIC] to see just the top-level parents. const epicsActive = filters.kinds?.includes("EPIC") ?? false; const toggleEpics = () => { @@ -170,15 +144,7 @@ export function QuickFilterChips({ active={updatedActive} onClick={toggleUpdated} icon={} - label="Recently updated" - /> - -
  • - } - label="Done" + label={`Updated in ${filters.updatedSince ?? "3d"}`} />
  • @@ -216,7 +182,7 @@ function Chip({ title={title} aria-pressed={active} className={cn( - "focus-ring inline-flex h-6 items-center gap-1.5 rounded-md border px-2 text-meta transition-colors", + "focus-ring text-meta inline-flex h-6 items-center gap-1.5 rounded-md border px-2 transition-colors", active ? "border-ember/60 bg-ember/15 text-foreground" : "border-border/80 bg-background/40 text-muted-foreground hover:border-ember/30 hover:bg-subtle/60 hover:text-foreground", diff --git a/src/components/saved-views/saved-views-bar.tsx b/src/components/saved-views/saved-views-bar.tsx index 78bed226..c87fe976 100644 --- a/src/components/saved-views/saved-views-bar.tsx +++ b/src/components/saved-views/saved-views-bar.tsx @@ -146,12 +146,14 @@ export function SavedViewsBar({ // sortKey, since the saved-view bar doesn't track sort yet. const matchedView = useMemo(() => { if (!filtersDirty || !views) return null; - return ( - views.find((v) => filtersEqual(safeParseFilters(v.filters), currentFilters)) ?? - null - ); + return views.find((v) => filtersEqual(safeParseFilters(v.filters), currentFilters)) ?? null; }, [views, currentFilters, filtersDirty]); + // Avoid dedicating a permanent toolbar row to an empty-state sentence. + // The row appears as soon as there is a saved view or something useful + // to save. + if (!hasViews && !filtersDirty) return null; + return ( <>
    @@ -181,16 +183,12 @@ export function SavedViewsBar({ e.preventDefault(); setOverId(v.id); }} - onDragLeave={() => - setOverId((o) => (o === v.id ? null : o)) - } + onDragLeave={() => setOverId((o) => (o === v.id ? null : o))} onDrop={(e) => { e.preventDefault(); onDrop(v.id); }} - onClick={() => - onApply(v.id, safeParseFilters(v.filters)) - } + onClick={() => onApply(v.id, safeParseFilters(v.filters))} className={cn( "focus-ring inline-flex h-6 items-center gap-1.5 rounded-full border px-2.5 transition-colors", "text-meta", @@ -202,15 +200,10 @@ export function SavedViewsBar({ title="Click to apply · drag to reorder" > - - {v.name} - + {v.name} {/* Phase 1A: per-workspace pin toggle. Lives between the chip body and the ⋯ menu so it's discoverable @@ -328,7 +321,7 @@ export function SavedViewsBar({ filters: currentFilters, }) } - className="focus-ring inline-flex h-6 items-center gap-1 rounded-full border border-ember/40 px-2.5 text-meta text-foreground hover:bg-ember/10" + className="focus-ring text-meta inline-flex h-6 items-center gap-1 rounded-full border border-ember/40 px-2.5 text-foreground hover:bg-ember/10" title={`Overwrite "${matchedView.name}" with the current filters.`} disabled={update.isPending} > @@ -338,7 +331,7 @@ export function SavedViewsBar({