Skip to content

Commit 4c05919

Browse files
committed
Add version statuses to portfolio cards and keyboard shortcuts for navigation
1 parent fa8a5c6 commit 4c05919

5 files changed

Lines changed: 143 additions & 33 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
- Fix resubmission after App Review rejection – handle UNRESOLVED_ISSUES submissions to avoid ITEM_PART_OF_ANOTHER_SUBMISSION errors
66
- Rename "Resubmit for review" button to "Update review" to match App Store Connect terminology
77
- Revamp review insights prompt with three categories (strengths, weaknesses, potential) and stricter rules
8+
- Show version statuses in portfolio app cards – non-live versions display platform, version, and state like App Store Connect
9+
- Add keyboard shortcuts: ⌘P portfolio, ⌘1–9 switch apps, ⌘O overview, ⌘L store listing, ⌘R reviews, ⌘A analytics, ⌘B builds
810

911
## 1.4.1
1012

src/app/dashboard/page.tsx

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ import { formatDateShort } from "@/lib/format";
3737
import { parseRange, filterByDateRange } from "@/lib/analytics-range";
3838
import { usePersistedRange } from "@/lib/hooks/use-persisted-range";
3939
import type { AnalyticsData } from "@/lib/asc/analytics";
40+
import {
41+
PLATFORM_LABELS,
42+
STATE_DOT_COLORS,
43+
stateLabel,
44+
type AscVersion,
45+
} from "@/lib/asc/version-types";
4046
import { ReportInitiatedBanner } from "@/components/report-initiated-banner";
4147

4248
const CHART_COLORS = [
@@ -61,6 +67,7 @@ export default function DashboardPage() {
6167
const { apps, loading, truncated, needsAppSelection, refresh: refreshApps } = useApps();
6268
const devSimulate = searchParams.get("analyticsState") === "initiated";
6369
const [analytics, setAnalytics] = useState<Record<string, AppAnalytics>>({});
70+
const [appVersions, setAppVersions] = useState<Record<string, AscVersion[]>>({});
6471
const [range, setRange] = usePersistedRange("range:portfolio-proceeds");
6572

6673
// entry=1 means proxy redirected here on app launch – restore last URL
@@ -147,6 +154,16 @@ export default function DashboardPage() {
147154
}
148155
}, []);
149156

157+
const fetchVersions = useCallback(async (appId: string) => {
158+
try {
159+
const res = await fetch(`/api/apps/${appId}/versions`);
160+
const json = await res.json();
161+
setAppVersions((prev) => ({ ...prev, [appId]: json.versions ?? [] }));
162+
} catch {
163+
// Non-critical
164+
}
165+
}, []);
166+
150167
useEffect(() => {
151168
if (loading || apps.length === 0 || needsAppSelection) return;
152169

@@ -158,8 +175,9 @@ export default function DashboardPage() {
158175

159176
for (const app of apps) {
160177
fetchAnalytics(app.id);
178+
fetchVersions(app.id);
161179
}
162-
}, [apps, loading, needsAppSelection, fetchAnalytics]);
180+
}, [apps, loading, needsAppSelection, fetchAnalytics, fetchVersions]);
163181

164182
// Poll while any app is pending
165183
const hasPending = Object.values(analytics).some((a) => a.pending);
@@ -187,11 +205,13 @@ export default function DashboardPage() {
187205
fetch(`/api/apps/${app.id}/analytics/refresh`, { method: "POST" }),
188206
),
189207
);
190-
await Promise.all(currentApps.map((app) => fetchAnalytics(app.id)));
208+
await Promise.all(
209+
currentApps.flatMap((app) => [fetchAnalytics(app.id), fetchVersions(app.id)]),
210+
);
191211
} finally {
192212
setRefreshing(false);
193213
}
194-
}, [fetchAnalytics]);
214+
}, [fetchAnalytics, fetchVersions]);
195215

196216
useRegisterRefresh({
197217
onRefresh: handleRefresh,
@@ -496,6 +516,7 @@ export default function DashboardPage() {
496516
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
497517
{apps.map((app) => {
498518
const entry = analytics[app.id];
519+
const versions = pickPendingVersions(appVersions[app.id] ?? []);
499520
return (
500521
<Link key={app.id} href={`/dashboard/apps/${app.id}`}>
501522
<Card className="h-full transition-colors hover:bg-muted/50">
@@ -517,6 +538,20 @@ export default function DashboardPage() {
517538
) : (
518539
<p className="text-xs text-muted-foreground">No data</p>
519540
)}
541+
{versions.length > 0 && (
542+
<div className="mt-3 space-y-0.5 border-t pt-3">
543+
{versions.map((v) => (
544+
<div key={v.id} className="flex items-center gap-1.5 text-xs text-muted-foreground">
545+
<span className={`size-1.5 shrink-0 rounded-full ${STATE_DOT_COLORS[v.attributes.appVersionState] ?? "bg-muted-foreground"}`} />
546+
<span className="truncate">
547+
{PLATFORM_LABELS[v.attributes.platform] ?? v.attributes.platform}{" "}
548+
{v.attributes.versionString}{" "}
549+
{stateLabel(v.attributes.appVersionState)}
550+
</span>
551+
</div>
552+
))}
553+
</div>
554+
)}
520555
</CardContent>
521556
</Card>
522557
</Link>
@@ -537,6 +572,22 @@ export default function DashboardPage() {
537572
);
538573
}
539574

575+
const LIVE_STATES = new Set(["READY_FOR_SALE", "READY_FOR_DISTRIBUTION"]);
576+
577+
/** Pick non-live versions to show in portfolio cards (newest per platform). */
578+
function pickPendingVersions(versions: AscVersion[]): AscVersion[] {
579+
const seen = new Set<string>();
580+
const result: AscVersion[] = [];
581+
for (const v of versions) {
582+
if (LIVE_STATES.has(v.attributes.appVersionState)) continue;
583+
const p = v.attributes.platform;
584+
if (seen.has(p)) continue;
585+
seen.add(p);
586+
result.push(v);
587+
}
588+
return result;
589+
}
590+
540591
function AppCardStats({ data }: { data: AnalyticsData }) {
541592
const downloads = data.dailyDownloads.reduce(
542593
(sum, d) => sum + d.firstTime + d.redownload,

src/components/layout/app-sidebar.tsx

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
"use client";
22

3-
import { useEffect, useMemo, useState } from "react";
3+
import { useCallback, useEffect, useMemo, useState } from "react";
44
import Link from "next/link";
55
import { useParams, usePathname, useRouter } from "next/navigation";
66
import { Package, SquaresFour } from "@phosphor-icons/react";
7-
import { getLastAppId } from "@/lib/nav-state";
7+
import { getLastAppId, getAppState } from "@/lib/nav-state";
88
import {
99
Sidebar,
1010
SidebarContent,
@@ -62,7 +62,7 @@ function PortfolioButton() {
6262
return (
6363
<SidebarMenu>
6464
<SidebarMenuItem>
65-
<SidebarMenuButton asChild tooltip="Portfolio" isActive={isActive}>
65+
<SidebarMenuButton asChild tooltip="Portfolio ⌘P" isActive={isActive}>
6666
<Link
6767
href="/dashboard"
6868
onNavigate={(e) => {
@@ -73,6 +73,7 @@ function PortfolioButton() {
7373
>
7474
<SquaresFour size={16} />
7575
<span>Portfolio</span>
76+
<kbd className="ml-auto text-[13px] text-muted-foreground/50">⌘P</kbd>
7677
</Link>
7778
</SidebarMenuButton>
7879
</SidebarMenuItem>
@@ -82,7 +83,10 @@ function PortfolioButton() {
8283

8384
export function AppSidebar() {
8485
const { appId } = useParams<{ appId?: string }>();
86+
const router = useRouter();
87+
const pathname = usePathname();
8588
const { apps } = useApps();
89+
const { guardNavigation } = useFormDirty();
8690
const [lastAppId, setLastAppId] = useState<string>();
8791
useEffect(() => {
8892
if (!appId) setLastAppId(getLastAppId());
@@ -93,6 +97,49 @@ export function AppSidebar() {
9397
const appIds = useMemo(() => apps.map((a) => a.id), [apps]);
9498
useUnreadReviewsPoller(appIds);
9599

100+
// Cmd+P → Portfolio, Cmd+1..9 → switch apps, Cmd+O/L/R/A/B → nav pages
101+
const PAGE_SHORTCUTS: Record<string, string> = {
102+
o: "", // Overview
103+
l: "/store-listing", // Store listing
104+
r: "/reviews", // Reviews
105+
a: "/analytics", // Analytics
106+
b: "/testflight", // Builds
107+
};
108+
109+
const handleKeyDown = useCallback(
110+
(e: KeyboardEvent) => {
111+
if (!e.metaKey && !e.ctrlKey) return;
112+
if (e.key === "p") {
113+
e.preventDefault();
114+
guardNavigation(() => router.push("/dashboard"));
115+
return;
116+
}
117+
const n = parseInt(e.key, 10);
118+
if (n >= 1 && n <= 9 && n <= apps.length) {
119+
e.preventDefault();
120+
const target = apps[n - 1];
121+
const saved = getAppState(target.id);
122+
const url = saved
123+
? `/dashboard/apps/${target.id}${saved}`
124+
: `/dashboard/apps/${target.id}`;
125+
guardNavigation(() => router.push(url));
126+
return;
127+
}
128+
const activeId = appId ?? lastAppId;
129+
const subpath = PAGE_SHORTCUTS[e.key];
130+
if (activeId && subpath !== undefined) {
131+
e.preventDefault();
132+
guardNavigation(() => router.push(`/dashboard/apps/${activeId}${subpath}`));
133+
}
134+
},
135+
[apps, appId, lastAppId, router, guardNavigation],
136+
);
137+
138+
useEffect(() => {
139+
window.addEventListener("keydown", handleKeyDown);
140+
return () => window.removeEventListener("keydown", handleKeyDown);
141+
}, [handleKeyDown]);
142+
96143
return (
97144
<Sidebar collapsible="icon">
98145
<SidebarHeader className="drag pt-8">

src/components/layout/app-switcher.tsx

Lines changed: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -125,27 +125,33 @@ export function AppSwitcher() {
125125
No matching apps
126126
</div>
127127
)}
128-
{filteredApps.map((app) => (
129-
<DropdownMenuItem
130-
key={app.id}
131-
onClick={() => guardNavigation(() => router.push(buildAppUrl(app.id)))}
132-
className="gap-2 p-2"
133-
>
134-
<AppIcon
135-
iconUrl={app.iconUrl}
136-
name={app.name}
137-
className="size-6"
138-
iconSize={12}
139-
rounded="rounded-md"
140-
/>
141-
<div className="grid flex-1 leading-tight">
142-
<span className="truncate font-medium">{app.name}</span>
143-
<span className="truncate text-xs font-mono text-muted-foreground">
144-
{app.id}
145-
</span>
146-
</div>
147-
</DropdownMenuItem>
148-
))}
128+
{filteredApps.map((app) => {
129+
const idx = apps.indexOf(app);
130+
return (
131+
<DropdownMenuItem
132+
key={app.id}
133+
onClick={() => guardNavigation(() => router.push(buildAppUrl(app.id)))}
134+
className="gap-2 p-2"
135+
>
136+
<AppIcon
137+
iconUrl={app.iconUrl}
138+
name={app.name}
139+
className="size-6"
140+
iconSize={12}
141+
rounded="rounded-md"
142+
/>
143+
<div className="grid flex-1 leading-tight">
144+
<span className="truncate font-medium">{app.name}</span>
145+
<span className="truncate text-xs font-mono text-muted-foreground">
146+
{app.id}
147+
</span>
148+
</div>
149+
{idx >= 0 && idx < 9 && !search && (
150+
<kbd className="shrink-0 text-[13px] text-muted-foreground/50">{idx + 1}</kbd>
151+
)}
152+
</DropdownMenuItem>
153+
);
154+
})}
149155
</div>
150156
{truncated && (
151157
<DropdownMenuItem

src/components/layout/nav-main.tsx

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ interface NavItem {
3131
title: string;
3232
href: string;
3333
icon: Icon;
34+
shortcut?: string;
3435
}
3536

3637
interface NavGroup {
@@ -45,8 +46,8 @@ function getNavGroups(appId: string): NavGroup[] {
4546
{
4647
label: "Release",
4748
items: [
48-
{ title: "Overview", href: base, icon: Gauge },
49-
{ title: "Store listing", href: `${base}/store-listing`, icon: Storefront },
49+
{ title: "Overview", href: base, icon: Gauge, shortcut: "⌘O" },
50+
{ title: "Store listing", href: `${base}/store-listing`, icon: Storefront, shortcut: "⌘L" },
5051
{ title: "Screenshots", href: `${base}/screenshots`, icon: Images },
5152
{ title: "App details", href: `${base}/details`, icon: Info },
5253
{ title: "App review", href: `${base}/review`, icon: Stamp },
@@ -55,15 +56,15 @@ function getNavGroups(appId: string): NavGroup[] {
5556
{
5657
label: "Insights",
5758
items: [
58-
{ title: "Reviews", href: `${base}/reviews`, icon: ChatsCircle },
59-
{ title: "Analytics", href: `${base}/analytics`, icon: ChartLineUp },
59+
{ title: "Reviews", href: `${base}/reviews`, icon: ChatsCircle, shortcut: "⌘R" },
60+
{ title: "Analytics", href: `${base}/analytics`, icon: ChartLineUp, shortcut: "⌘A" },
6061
{ title: "Keywords", href: `${base}/aso/keywords`, icon: MagnifyingGlass },
6162
],
6263
},
6364
{
6465
label: "TestFlight",
6566
items: [
66-
{ title: "Builds", href: `${base}/testflight`, icon: Truck },
67+
{ title: "Builds", href: `${base}/testflight`, icon: Truck, shortcut: "⌘B" },
6768
{ title: "Groups", href: `${base}/testflight/groups`, icon: UsersThree },
6869
{ title: "Beta app info", href: `${base}/testflight/info`, icon: Info },
6970
{ title: "Feedback", href: `${base}/testflight/feedback`, icon: ChatDots },
@@ -121,7 +122,7 @@ export function NavMain({ appId }: { appId: string }) {
121122
<SidebarMenuItem key={item.href}>
122123
<SidebarMenuButton
123124
asChild
124-
tooltip={item.title}
125+
tooltip={item.shortcut ? `${item.title} ${item.shortcut}` : item.title}
125126
isActive={isActive(item.href)}
126127
>
127128
<Link
@@ -134,6 +135,9 @@ export function NavMain({ appId }: { appId: string }) {
134135
>
135136
<item.icon size={16} />
136137
<span>{item.title}</span>
138+
{item.shortcut && (
139+
<kbd className="ml-auto text-[13px] text-muted-foreground/50">{item.shortcut}</kbd>
140+
)}
137141
</Link>
138142
</SidebarMenuButton>
139143
{item.href === `${base}/reviews` && <ReviewsBadge appId={appId} />}

0 commit comments

Comments
 (0)