Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions DEVLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
142 changes: 122 additions & 20 deletions src/app/(app)/w/[slug]/issues/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand All @@ -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();
},
[],
Expand All @@ -152,6 +164,7 @@ export default function IssuesPage() {
view: string | null;
dueOn: string | null;
archived: boolean;
scope: IssueLifecycleScope;
}>,
opts?: { push?: boolean },
) => {
Expand All @@ -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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid persisting derived All scope

When a DONE/CANCELED status facet is active, scope is derived as "all" even if the URL never requested scope=all; any later filter change that does not pass next.scope uses this fallback and writes scope=all into the URL. For example, selecting a Done status and then toggling that same facet off removes the terminal filter but leaves the page in All scope, so completed issues continue to appear until the user manually clears filters or presses Open. Recompute the fallback from the next filters/requested URL scope instead of persisting the derived value.

Useful? React with 👍 / 👎.

});
const url = qs ? `${pathname}?${qs}` : pathname;
if (opts?.push) router.push(url);
Expand All @@ -178,6 +192,7 @@ export default function IssuesPage() {
viewIdFromUrl,
dueOn,
showArchived,
scope,
pathname,
router,
],
Expand Down Expand Up @@ -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() {
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -351,14 +397,41 @@ export default function IssuesPage() {
<Input
value={query}
onChange={(e) => 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 && (
<span className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2">
<Spinner size="sm" />
</span>
)}
{!searchPending && query && (
<button
type="button"
onClick={() => {
setQuery("");
setDebouncedQuery("");
commit({ query: "" });
}}
aria-label="Clear search"
className="focus-ring absolute right-1.5 top-1/2 -translate-y-1/2 rounded p-1 text-muted-foreground hover:text-foreground"
>
<X className="h-3 w-3" aria-hidden />
</button>
)}
</div>
)}
{(showArchived || view === "list") && !isWorkspaceEmpty && <DensityToggle />}
Expand Down Expand Up @@ -397,12 +470,13 @@ export default function IssuesPage() {
fields) + Sprint/Initiative (single-pick). Sort — and Group,
list view only — are pinned right. */}
<div className="flex flex-wrap items-center gap-2 pt-1">
{!showArchived && <IssueScopeToggle value={scope} onChange={setLifecycleScope} />}
<IssueFacetChips filters={filters} onChange={onChangeFilters} />
<ProjectFilterChip value={projectId} onChange={setProjectId} />
<CycleFilterChip value={cycleId} onChange={setCycleId} />
<InitiativeFilterChip value={initiativeId} onChange={setInitiativeId} />
{dueOn && <DueOnChip dueOn={dueOn} onClear={clearDueOn} />}
{hasFilters && (
{hasViewAdjustments && (
<button
type="button"
onClick={clearAllFilters}
Expand Down Expand Up @@ -451,17 +525,21 @@ export default function IssuesPage() {
<IssueList
workspaceKey={key}
extraFilters={issueQueryFilters}
includeDone={countIncludeDone}
sort={sort}
groupBy={groupBy}
dueOn={dueOn ?? undefined}
archived={showArchived}
emptyOverride={
showArchived && !hasFilters ? (
showArchived && !hasNarrowing ? (
<ArchivedEmptyState onReturn={() => setShowArchived(false)} />
) : hasFilters ? (
) : hasViewAdjustments ? (
<FilteredEmptyState
activeViewName={activeView?.name ?? null}
onClear={clearAllFilters}
query={query}
scope={scope}
onSearchAll={() => setLifecycleScope("all")}
/>
) : undefined
}
Expand All @@ -471,13 +549,17 @@ export default function IssuesPage() {
<IssueBoard
workspaceKey={key}
extraFilters={issueQueryFilters}
includeDone={countIncludeDone}
sort={sort}
dueOn={dueOn ?? undefined}
emptyOverride={
hasFilters ? (
hasViewAdjustments ? (
<FilteredEmptyState
activeViewName={activeView?.name ?? null}
onClear={clearAllFilters}
query={query}
scope={scope}
onSearchAll={() => setLifecycleScope("all")}
/>
) : undefined
}
Expand Down Expand Up @@ -521,9 +603,15 @@ function ArchivedEmptyState({ onReturn }: { onReturn: () => void }) {
function FilteredEmptyState({
activeViewName,
onClear,
query,
scope,
onSearchAll,
}: {
activeViewName: string | null;
onClear: () => void;
query: string;
scope: IssueLifecycleScope;
onSearchAll: () => void;
}) {
return (
<div className="flex h-60 items-center justify-center">
Expand All @@ -533,11 +621,25 @@ function FilteredEmptyState({
title="No issues match this view"
description={
<span>
{activeViewName ? (
{query && scope === "open" ? (
<>
No open issues match <span className="font-medium text-foreground">“{query}”</span>.{" "}
<button
type="button"
onClick={onSearchAll}
className="text-foreground underline-offset-2 hover:text-ember hover:underline"
>
Search all issues
</button>{" "}
to include completed work, or{" "}
</>
) : activeViewName ? (
<>
The view <span className="font-medium text-foreground">{activeViewName}</span> has
no matches right now.{" "}
</>
) : query ? (
"No issues match this search. "
) : (
"Your active filters returned nothing. "
)}
Expand All @@ -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"}
</button>{" "}
to see all issues.
</span>
Expand Down
Loading
Loading