Skip to content

Commit 1a8a8b7

Browse files
committed
feat: enhance company understanding strip with ICP and sharp opportunities
1 parent f80c8d9 commit 1a8a8b7

6 files changed

Lines changed: 251 additions & 62 deletions

File tree

‎apps/desktop/src/features/dashboard/components/CompanyUnderstandingHero.tsx‎

Lines changed: 32 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useState } from "react";
2-
import { AlertTriangleIcon, GlobeIcon, TrendingUpIcon, UsersIcon } from "lucide-react";
2+
import { GlobeIcon, TargetIcon, TrendingUpIcon } from "lucide-react";
33
import { Skeleton } from "@/components/ui/skeleton";
44
import type { CompanySummaryData } from "@/features/dashboard/lib/parse-workspace-summary";
55

@@ -39,11 +39,15 @@ function FaviconIcon({
3939

4040
function HeroSkeleton() {
4141
return (
42-
<div className="flex items-center gap-3">
43-
<Skeleton className="size-8 shrink-0 rounded-lg" />
44-
<div className="flex-1 space-y-1.5">
42+
<div className="space-y-3">
43+
<div className="flex items-center gap-3">
44+
<Skeleton className="size-8 shrink-0 rounded-lg" />
4545
<Skeleton className="h-5 w-36" />
46+
</div>
47+
<div className="space-y-1.5 pl-[54px]">
4648
<Skeleton className="h-3.5 w-full max-w-sm" />
49+
<Skeleton className="h-3 w-48" />
50+
<Skeleton className="h-3 w-56" />
4751
</div>
4852
</div>
4953
);
@@ -64,17 +68,16 @@ export function CompanyUnderstandingHero({
6468
);
6569
}
6670

67-
const hasAnyData = data ? Object.values(data).some(Boolean) : false;
71+
const hasAnyData = data ? Object.values(data).some((v) => Array.isArray(v) ? v.length > 0 : Boolean(v)) : false;
6872
const hasFavicon = domain && faviconSources && faviconSources.length > 0;
6973

70-
// Build inline bullets from available data
71-
const bullets: { key: string; icon: "opportunity" | "risk"; text: string }[] = [];
72-
if (data?.topOpportunity) {
73-
bullets.push({ key: "opportunity", icon: "opportunity", text: data.topOpportunity });
74-
}
75-
if (data?.mainRisk) {
76-
bullets.push({ key: "risk", icon: "risk", text: data.mainRisk });
77-
}
74+
// ICP line: prefer data.icp, fall back to data.targetAudience
75+
const icpLine = data?.icp ?? data?.targetAudience ?? null;
76+
77+
// Opportunity bullets: prefer data.opportunities array, fall back to legacy fields
78+
const oppBullets: string[] = data?.opportunities && data.opportunities.length > 0
79+
? data.opportunities
80+
: [data?.topOpportunity, data?.mainRisk].filter((b): b is string => Boolean(b));
7881

7982
return (
8083
<div className="mb-6 overflow-hidden rounded-xl border border-border/30 bg-gradient-to-br from-card via-card to-primary/[0.02] shadow-sm dark:border-white/[0.06] dark:from-[#18181B] dark:via-[#18181B] dark:to-primary/[0.03]">
@@ -101,18 +104,30 @@ export function CompanyUnderstandingHero({
101104
</div>
102105
</div>
103106

104-
{/* ── Summary + ICP hint ── */}
107+
{/* ── Summary + ICP + Opportunities ── */}
105108
{hasAnyData && data?.productSummary ? (
106109
<div className="mt-3 pl-[54px]">
107110
<p className="line-clamp-2 text-[14px] leading-[1.6] text-zinc-500 dark:text-zinc-400">
108111
{data.productSummary}
109112
</p>
110-
{data.targetAudience && (
113+
{icpLine && (
111114
<p className="mt-1.5 flex items-center gap-1.5 text-[13px] text-zinc-400 dark:text-zinc-500">
112-
<UsersIcon className="size-3 shrink-0" />
113-
<span className="line-clamp-1">{data.targetAudience}</span>
115+
<TargetIcon className="size-3 shrink-0" />
116+
<span className="line-clamp-1">{icpLine}</span>
114117
</p>
115118
)}
119+
{oppBullets.length > 0 && (
120+
<ul className="mt-2 space-y-1">
121+
{oppBullets.slice(0, 3).map((text) => (
122+
<li key={text} className="flex items-start gap-1.5">
123+
<TrendingUpIcon className="mt-[3px] size-3 shrink-0 text-primary/60" />
124+
<span className="line-clamp-1 text-[13px] leading-snug text-zinc-500 dark:text-zinc-400">
125+
{text}
126+
</span>
127+
</li>
128+
))}
129+
</ul>
130+
)}
116131
</div>
117132
) : error ? (
118133
<p className="mt-3 pl-[54px] text-[13px] text-muted-foreground">
@@ -123,24 +138,6 @@ export function CompanyUnderstandingHero({
123138
No project context yet
124139
</p>
125140
)}
126-
127-
{/* ── Inline opportunity/risk bullets ── */}
128-
{bullets.length > 0 && (
129-
<ul className="mt-3 space-y-1.5 pl-[54px]">
130-
{bullets.map((b) => (
131-
<li key={b.key} className="flex items-start gap-2">
132-
{b.icon === "risk" ? (
133-
<AlertTriangleIcon className="mt-[3px] size-3 shrink-0 text-amber-500" />
134-
) : (
135-
<TrendingUpIcon className="mt-[3px] size-3 shrink-0 text-primary" />
136-
)}
137-
<span className="line-clamp-1 text-[13px] leading-snug text-zinc-500 dark:text-zinc-400">
138-
{b.text}
139-
</span>
140-
</li>
141-
))}
142-
</ul>
143-
)}
144141
</div>
145142
</div>
146143
);

‎apps/desktop/src/features/dashboard/data/company-summary.test.ts‎

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,23 @@ const REALISTIC_GROWTH = `# GROWTH
125125
- Is the freemium ceiling limiting revenue per user?
126126
`;
127127

128-
void test("realistic bootstrap output: all 5 summary points extracted", () => {
129-
const result = parseWorkspaceSummary(REALISTIC_PRODUCT, null, REALISTIC_GROWTH);
128+
const REALISTIC_MARKET = `# MARKET
129+
130+
## ICP hypotheses
131+
- Mid-market sales teams (50-500 employees) booking 20+ external meetings/week, currently losing deals to scheduling friction.
132+
- Secondary: recruiting teams at high-growth startups running 30+ candidate interviews per week.
133+
134+
## Personas
135+
- VP Sales at a Series B SaaS company managing 15 AEs across US and EMEA timezones.
136+
- Head of Talent at a 200-person startup scaling from 5 to 20 hires per quarter.
137+
138+
## Main customer pains
139+
- Sales reps waste 17 min per meeting coordinating schedules, adding up to 3+ hours/week of lost selling time.
140+
- No-show rates spike when booking confirmation and reminders are manual.
141+
`;
142+
143+
void test("realistic bootstrap output: all summary points extracted including ICP and opportunities", () => {
144+
const result = parseWorkspaceSummary(REALISTIC_PRODUCT, REALISTIC_MARKET, REALISTIC_GROWTH);
130145

131146
assert.ok(result.productSummary, "productSummary should be present");
132147
assert.ok(
@@ -149,18 +164,26 @@ void test("realistic bootstrap output: all 5 summary points extracted", () => {
149164
);
150165

151166
assert.ok(result.topOpportunity, "topOpportunity should be present");
167+
168+
assert.ok(result.icp, "icp should be present");
169+
assert.ok(
170+
result.icp.includes("sales") || result.icp.includes("Sales") || result.icp.includes("Mid-market"),
171+
"icp should describe ideal customer segment",
172+
);
173+
174+
assert.ok(result.opportunities.length >= 2, "opportunities should have at least 2 bullets");
175+
assert.ok(result.opportunities.length <= 3, "opportunities should have at most 3 bullets");
152176
});
153177

154178
void test("summary data points are concise (under 300 chars each)", () => {
155-
const result = parseWorkspaceSummary(REALISTIC_PRODUCT, null, REALISTIC_GROWTH);
179+
const result = parseWorkspaceSummary(REALISTIC_PRODUCT, REALISTIC_MARKET, REALISTIC_GROWTH);
156180

157181
for (const [key, value] of Object.entries(result)) {
158-
if (value !== null) {
159-
assert.ok(
160-
value.length <= 300,
161-
`${key} is ${value.length} chars, should be ≤300 for compact display`,
162-
);
163-
}
182+
if (value === null || Array.isArray(value)) continue;
183+
assert.ok(
184+
value.length <= 300,
185+
`${key} is ${value.length} chars, should be ≤300 for compact display`,
186+
);
164187
}
165188
});
166189

@@ -188,13 +211,15 @@ void test("extractSection handles real GROWTH.md Experiment ideas heading", () =
188211
assert.ok(section.includes("LinkedIn") || section.includes("video") || section.includes("Product Hunt"));
189212
});
190213

191-
void test("CompanySummaryData type shape has exactly 5 fields", () => {
214+
void test("CompanySummaryData type shape has exactly 7 fields", () => {
192215
const empty: CompanySummaryData = {
193216
productSummary: null,
194217
targetAudience: null,
195218
valueProposition: null,
196219
mainRisk: null,
197220
topOpportunity: null,
221+
icp: null,
222+
opportunities: [],
198223
};
199-
assert.equal(Object.keys(empty).length, 5);
224+
assert.equal(Object.keys(empty).length, 7);
200225
});

‎apps/desktop/src/features/dashboard/data/workspace-summary-fetch.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,8 +121,10 @@ void test("CompanySummary contract: empty data shows error state, not null", ()
121121
valueProposition: null,
122122
mainRisk: null,
123123
topOpportunity: null,
124+
icp: null,
125+
opportunities: [] as string[],
124126
};
125-
const hasAnyData = data ? Object.values(data).some(Boolean) : false;
127+
const hasAnyData = data ? Object.values(data).some((v) => Array.isArray(v) ? v.length > 0 : Boolean(v)) : false;
126128
assert.equal(hasAnyData, false, "No data points present");
127129

128130
// The component should always show the error card when hasAnyData is false

‎apps/desktop/src/features/dashboard/lib/parse-workspace-summary.test.ts‎

Lines changed: 105 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
22
import test from "node:test";
33
import {
44
extractSection,
5+
extractBullets,
56
firstParagraphOrBullet,
67
parseWorkspaceSummary,
78
} from "./parse-workspace-summary";
@@ -138,6 +139,21 @@ const SAMPLE_PRODUCT = `# PRODUCT
138139
- Main value props: simplicity, speed, async-first
139140
`;
140141

142+
const SAMPLE_MARKET = `# MARKET
143+
144+
## ICP hypotheses
145+
- Remote-first engineering teams of 10-50 at seed/Series A startups that outgrew Notion for sprint planning.
146+
- Secondary: product managers at mid-market SaaS companies needing async standup visibility.
147+
148+
## Personas
149+
- VP Engineering at a Series A startup managing 3 squads across timezones.
150+
- Product manager juggling roadmap alignment across engineering, design, and GTM.
151+
152+
## Main customer pains
153+
- Scattered async communication across Slack, Notion, and Linear causes dropped context.
154+
- Standup meetings waste 30+ min/day for distributed teams with no persistent record.
155+
`;
156+
141157
const SAMPLE_GROWTH = `# GROWTH
142158
143159
## Strategic summary
@@ -161,8 +177,8 @@ const SAMPLE_GROWTH = `# GROWTH
161177
- Run a Product Hunt launch within 30 days
162178
`;
163179

164-
void test("parseWorkspaceSummary extracts all 5 data points", () => {
165-
const result = parseWorkspaceSummary(SAMPLE_PRODUCT, null, SAMPLE_GROWTH);
180+
void test("parseWorkspaceSummary extracts all data points including icp and opportunities", () => {
181+
const result = parseWorkspaceSummary(SAMPLE_PRODUCT, SAMPLE_MARKET, SAMPLE_GROWTH);
166182

167183
assert.ok(result.productSummary, "productSummary should be extracted");
168184
assert.ok(
@@ -193,6 +209,15 @@ void test("parseWorkspaceSummary extracts all 5 data points", () => {
193209
result.topOpportunity.includes("community") || result.topOpportunity.includes("Reddit"),
194210
"topOpportunity should capture an experiment or strategy",
195211
);
212+
213+
assert.ok(result.icp, "icp should be extracted from MARKET.md");
214+
assert.ok(
215+
result.icp.includes("engineering teams") || result.icp.includes("Remote-first"),
216+
"icp should describe ideal customer",
217+
);
218+
219+
assert.ok(result.opportunities.length >= 2, "opportunities should have at least 2 bullets");
220+
assert.ok(result.opportunities.length <= 3, "opportunities should have at most 3 bullets");
196221
});
197222

198223
void test("parseWorkspaceSummary returns nulls for missing files", () => {
@@ -202,6 +227,8 @@ void test("parseWorkspaceSummary returns nulls for missing files", () => {
202227
assert.equal(result.valueProposition, null);
203228
assert.equal(result.mainRisk, null);
204229
assert.equal(result.topOpportunity, null);
230+
assert.equal(result.icp, null);
231+
assert.deepEqual(result.opportunities, []);
205232
});
206233

207234
void test("parseWorkspaceSummary handles partial data (only product)", () => {
@@ -211,6 +238,9 @@ void test("parseWorkspaceSummary handles partial data (only product)", () => {
211238
assert.ok(result.valueProposition);
212239
assert.equal(result.mainRisk, null);
213240
assert.equal(result.topOpportunity, null);
241+
// ICP falls back to PRODUCT.md targetAudience when no MARKET.md
242+
assert.ok(result.icp, "icp should fall back to product target users");
243+
assert.deepEqual(result.opportunities, []);
214244
});
215245

216246
void test("parseWorkspaceSummary handles partial data (only growth)", () => {
@@ -220,6 +250,8 @@ void test("parseWorkspaceSummary handles partial data (only growth)", () => {
220250
assert.equal(result.valueProposition, null);
221251
assert.ok(result.mainRisk);
222252
assert.ok(result.topOpportunity);
253+
assert.equal(result.icp, null);
254+
assert.ok(result.opportunities.length > 0, "opportunities from GROWTH.md");
223255
});
224256

225257
// ---------------------------------------------------------------------------
@@ -309,3 +341,74 @@ void test("parseWorkspaceSummary never throws on malformed input", () => {
309341
const r3 = parseWorkspaceSummary("### Sub\n#### Deep\n##### Deeper", null, null);
310342
assert.equal(r3.productSummary, null);
311343
});
344+
345+
// ---------------------------------------------------------------------------
346+
// extractBullets
347+
// ---------------------------------------------------------------------------
348+
349+
void test("extractBullets extracts up to max bullets from section text", () => {
350+
const section = "- First bullet here.\n- Second bullet here.\n- Third bullet here.\n- Fourth bullet here.";
351+
const result = extractBullets(section, 3);
352+
assert.equal(result.length, 3);
353+
assert.ok(result[0].startsWith("First"));
354+
assert.ok(result[2].startsWith("Third"));
355+
});
356+
357+
void test("extractBullets returns empty array for empty input", () => {
358+
assert.deepEqual(extractBullets("", 3), []);
359+
assert.deepEqual(extractBullets(" ", 3), []);
360+
});
361+
362+
void test("extractBullets stops at sub-headings", () => {
363+
const section = "- First bullet.\n### Sub heading\n- Second bullet.";
364+
const result = extractBullets(section, 3);
365+
assert.equal(result.length, 1);
366+
});
367+
368+
void test("extractBullets skips label-like lines", () => {
369+
const section = "Key opportunity\n- Actual bullet with real content here.\n- Another real bullet with details.";
370+
const result = extractBullets(section, 3);
371+
assert.ok(result.length >= 1);
372+
assert.ok(result[0].includes("Actual bullet"));
373+
});
374+
375+
// ---------------------------------------------------------------------------
376+
// ICP extraction
377+
// ---------------------------------------------------------------------------
378+
379+
void test("parseWorkspaceSummary extracts ICP from MARKET.md 'ICP hypotheses'", () => {
380+
const result = parseWorkspaceSummary(null, SAMPLE_MARKET, null);
381+
assert.ok(result.icp, "icp should be extracted from MARKET.md");
382+
assert.ok(
383+
result.icp.includes("engineering teams") || result.icp.includes("Remote-first"),
384+
"icp should describe the ideal customer",
385+
);
386+
});
387+
388+
void test("parseWorkspaceSummary ICP falls back to MARKET.md 'Personas' heading", () => {
389+
const marketMd = `# MARKET\n\n## Personas\n- DevOps leads at mid-size SaaS companies managing CI/CD pipelines.\n`;
390+
const result = parseWorkspaceSummary(null, marketMd, null);
391+
assert.ok(result.icp, "icp should fall back to Personas heading");
392+
assert.ok(result.icp.includes("DevOps"));
393+
});
394+
395+
void test("parseWorkspaceSummary ICP falls back to PRODUCT.md when MARKET.md is null", () => {
396+
const result = parseWorkspaceSummary(SAMPLE_PRODUCT, null, null);
397+
assert.ok(result.icp, "icp should fall back to product target users");
398+
assert.ok(result.icp.includes("Remote-first") || result.icp.includes("startup"));
399+
});
400+
401+
// ---------------------------------------------------------------------------
402+
// Opportunities extraction
403+
// ---------------------------------------------------------------------------
404+
405+
void test("parseWorkspaceSummary extracts opportunities from GROWTH.md", () => {
406+
const result = parseWorkspaceSummary(null, null, SAMPLE_GROWTH);
407+
assert.ok(result.opportunities.length >= 2, "should extract at least 2 opportunity bullets");
408+
assert.ok(result.opportunities.length <= 3, "should extract at most 3 opportunity bullets");
409+
});
410+
411+
void test("parseWorkspaceSummary returns empty opportunities when GROWTH.md is null", () => {
412+
const result = parseWorkspaceSummary(SAMPLE_PRODUCT, null, null);
413+
assert.deepEqual(result.opportunities, []);
414+
});

0 commit comments

Comments
 (0)