Repository navigation
feat: integrate Gingiris launch playbook + upgrade AI generation #6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
73a1762
734e00d
e9be345
6bd715f
2b77340
defbf36
6cccca0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -9,6 +9,7 @@ import { ContextReview } from '@/components/context-review/ContextReview' | |||||||||||||||||||||||||||||||||
| import { PostKit } from '@/components/post-kit/PostKit' | ||||||||||||||||||||||||||||||||||
| import { GeneratingKit } from '@/components/generating/GeneratingKit' | ||||||||||||||||||||||||||||||||||
| import { platformToMarkdown } from '@/lib/export/to-markdown' | ||||||||||||||||||||||||||||||||||
| import { planToMarkdown } from '@/lib/export/plan-to-markdown' | ||||||||||||||||||||||||||||||||||
| import { PLATFORMS, type PlatformId } from '@/lib/platforms' | ||||||||||||||||||||||||||||||||||
| import { useRun } from '@/lib/runs/useRun' | ||||||||||||||||||||||||||||||||||
| import { findByPath } from '@/lib/runs/store' | ||||||||||||||||||||||||||||||||||
|
|
@@ -109,6 +110,30 @@ function RunWizard({ id }: { id: string }) { | |||||||||||||||||||||||||||||||||
| toast.success(`${name} kit copied`) | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| function planMarkdown(): string | null { | ||||||||||||||||||||||||||||||||||
| if (!state.generation?.plan) return null | ||||||||||||||||||||||||||||||||||
| return planToMarkdown(state.generation.core, state.generation.plan) | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| function exportPlan() { | ||||||||||||||||||||||||||||||||||
| const md = planMarkdown() | ||||||||||||||||||||||||||||||||||
| if (!md) return | ||||||||||||||||||||||||||||||||||
| const blob = new Blob([md], { type: 'text/markdown' }) | ||||||||||||||||||||||||||||||||||
| const url = URL.createObjectURL(blob) | ||||||||||||||||||||||||||||||||||
| const a = document.createElement('a') | ||||||||||||||||||||||||||||||||||
| a.href = url | ||||||||||||||||||||||||||||||||||
| a.download = 'launch-plan.md' | ||||||||||||||||||||||||||||||||||
| a.click() | ||||||||||||||||||||||||||||||||||
| URL.revokeObjectURL(url) | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| async function copyPlan() { | ||||||||||||||||||||||||||||||||||
| const md = planMarkdown() | ||||||||||||||||||||||||||||||||||
| if (!md) return | ||||||||||||||||||||||||||||||||||
| await navigator.clipboard.writeText(md) | ||||||||||||||||||||||||||||||||||
| toast.success('Launch plan copied') | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+130
to
+135
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Handle clipboard failures in
Suggested fix async function copyPlan() {
const md = planMarkdown()
if (!md) return
- await navigator.clipboard.writeText(md)
- toast.success('Launch plan copied')
+ try {
+ await navigator.clipboard.writeText(md)
+ toast.success('Launch plan copied')
+ } catch {
+ toast.error('Failed to copy launch plan')
+ }
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| if (status === 'loading') { | ||||||||||||||||||||||||||||||||||
| return <p className="px-6 py-16 text-center text-sm text-muted-foreground">Loading run…</p> | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
@@ -166,6 +191,8 @@ function RunWizard({ id }: { id: string }) { | |||||||||||||||||||||||||||||||||
| regeneratingPlatform={regeneratingPlatform} | ||||||||||||||||||||||||||||||||||
| onExportMarkdown={exportMarkdown} | ||||||||||||||||||||||||||||||||||
| onCopyAll={copyAll} | ||||||||||||||||||||||||||||||||||
| onCopyPlan={copyPlan} | ||||||||||||||||||||||||||||||||||
| onExportPlan={exportPlan} | ||||||||||||||||||||||||||||||||||
| onStartOver={() => router.push('/runs')} | ||||||||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| import { describe, it, expect, vi } from 'vitest' | ||
| import { render, screen } from '@testing-library/react' | ||
| import userEvent from '@testing-library/user-event' | ||
| import { LaunchPlan } from './LaunchPlan' | ||
| import type { LaunchPlan as LaunchPlanData } from '@/lib/types' | ||
|
|
||
| const plan: LaunchPlanData = { | ||
| phases: [{ window: 'L-6 weeks', goal: 'Lock strategy', tasks: ['Define the ICP'] }], | ||
| countdown30: ['Build the landing page'], | ||
| countdown7: ['Confirm the hunter'], | ||
| countdown48h: ['Final QA'], | ||
| seoGeo: ['Configure IndexNow'], | ||
| momentum: ['Post a recap'], | ||
| } | ||
|
|
||
| describe('LaunchPlan', () => { | ||
| it('renders the timeline and every checklist section', () => { | ||
| render(<LaunchPlan plan={plan} onCopy={() => {}} onExport={() => {}} />) | ||
| expect(screen.getByText('L-6 weeks')).toBeInTheDocument() | ||
| expect(screen.getByText('Define the ICP')).toBeInTheDocument() | ||
| expect(screen.getByText('Configure IndexNow')).toBeInTheDocument() | ||
| expect(screen.getByText('Post a recap')).toBeInTheDocument() | ||
| }) | ||
|
|
||
| it('wires the copy and export actions', async () => { | ||
| const onCopy = vi.fn() | ||
| const onExport = vi.fn() | ||
| render(<LaunchPlan plan={plan} onCopy={onCopy} onExport={onExport} />) | ||
| await userEvent.click(screen.getByRole('button', { name: 'Copy' })) | ||
| await userEvent.click(screen.getByRole('button', { name: 'Export' })) | ||
| expect(onCopy).toHaveBeenCalledOnce() | ||
| expect(onExport).toHaveBeenCalledOnce() | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| import { SectionCard } from '@/components/common/SectionCard' | ||
| import { Button } from '@/components/ui/button' | ||
| import type { LaunchPlanProps } from './LaunchPlan.types' | ||
|
|
||
| function Checklist({ title, items }: { title: string; items: string[] }) { | ||
| if (items.length === 0) return null | ||
| return ( | ||
| <div> | ||
| <div className="text-xs uppercase text-muted-foreground">{title}</div> | ||
| <ul className="mt-1 list-disc pl-5">{items.map((i) => <li key={i}>{i}</li>)}</ul> | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use collision-safe keys for generated list items. These lists are AI-generated text; duplicate strings are plausible, so value-only keys can collide and cause unstable reconciliation. Proposed fix- <ul className="mt-1 list-disc pl-5">{items.map((i) => <li key={i}>{i}</li>)}</ul>
+ <ul className="mt-1 list-disc pl-5">{items.map((i, idx) => <li key={`${i}-${idx}`}>{i}</li>)}</ul>
...
- {plan.phases.map((phase) => (
- <li key={phase.window} className="border-l-2 border-primary/40 pl-3">
+ {plan.phases.map((phase, idx) => (
+ <li key={`${phase.window}-${idx}`} className="border-l-2 border-primary/40 pl-3">
...
- <ul className="mt-1 list-disc pl-5">{phase.tasks.map((t) => <li key={t}>{t}</li>)}</ul>
+ <ul className="mt-1 list-disc pl-5">{phase.tasks.map((t, tIdx) => <li key={`${t}-${tIdx}`}>{t}</li>)}</ul>Also applies to: 29-29, 34-34 🧰 Tools🪛 React Doctor (0.5.6)[warning] 10-10: This JSX crashes because If you're on React 17+ with the new JSX transform, disable this rule. Otherwise import (react-in-jsx-scope) [warning] 10-10: This JSX crashes because If you're on React 17+ with the new JSX transform, disable this rule. Otherwise import (react-in-jsx-scope) [warning] 10-10: Your users can see & submit the wrong data when this list reorders or filters, so use a stable id like Use a stable id from the item, like (no-array-index-as-key) 🤖 Prompt for AI Agents |
||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| export function LaunchPlan({ plan, onCopy, onExport }: LaunchPlanProps) { | ||
| const actions = ( | ||
| <div className="flex gap-2"> | ||
| <Button variant="outline" size="sm" onClick={onCopy}>Copy</Button> | ||
| <Button size="sm" onClick={onExport}>Export</Button> | ||
| </div> | ||
| ) | ||
|
|
||
| return ( | ||
| <SectionCard title="Launch plan" action={actions}> | ||
| <div> | ||
| <div className="text-xs uppercase text-muted-foreground">Timeline · L-6 weeks → L+4 weeks</div> | ||
| <ol className="mt-2 space-y-3"> | ||
| {plan.phases.map((phase) => ( | ||
| <li key={phase.window} className="border-l-2 border-primary/40 pl-3"> | ||
| <div className="flex flex-wrap items-baseline gap-x-2"> | ||
| <span className="font-medium text-primary">{phase.window}</span> | ||
| <span className="text-muted-foreground">— {phase.goal}</span> | ||
| </div> | ||
| <ul className="mt-1 list-disc pl-5">{phase.tasks.map((t) => <li key={t}>{t}</li>)}</ul> | ||
| </li> | ||
| ))} | ||
| </ol> | ||
| </div> | ||
|
|
||
| <div className="grid gap-4 sm:grid-cols-3"> | ||
| <Checklist title="30-day countdown" items={plan.countdown30} /> | ||
| <Checklist title="7-day countdown" items={plan.countdown7} /> | ||
| <Checklist title="Final 48 hours" items={plan.countdown48h} /> | ||
| </div> | ||
|
|
||
| <Checklist title="SEO / GEO" items={plan.seoGeo} /> | ||
| <Checklist title="Post-launch momentum" items={plan.momentum} /> | ||
| </SectionCard> | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| import type { LaunchPlan as LaunchPlanData } from '@/lib/types' | ||
|
|
||
| export type LaunchPlanProps = { | ||
| plan: LaunchPlanData | ||
| onCopy: () => void | ||
| onExport: () => void | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { LaunchPlan } from './LaunchPlan' |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| import { describe, it, expect, beforeAll, vi } from 'vitest' | ||
| import { render, screen } from '@testing-library/react' | ||
| import userEvent from '@testing-library/user-event' | ||
| import { PostKit } from './PostKit' | ||
| import { RuntimeConfigProvider } from '@/lib/config/RuntimeConfigProvider' | ||
| import type { RuntimeConfig } from '@/lib/config/runtime-config' | ||
| import { MockProvider } from '@/lib/generation/mock-provider' | ||
| import type { GenerateInput, Generation } from '@/lib/types' | ||
|
|
||
| const input: GenerateInput = { | ||
| context: { | ||
| name: 'Acme', oneLiner: 'Ship faster', summary: 'Acme helps teams ship faster.', | ||
| features: ['Fast builds'], techStack: ['Next.js'], differentiators: ['Zero config'], | ||
| links: [], readmeExcerpt: '', warnings: [], | ||
| }, | ||
| refinements: { tone: 'bold', audience: 'developers' }, | ||
| } | ||
|
|
||
| const config: RuntimeConfig = { provider: 'mock', hasKey: false, model: 'mock', localFsAvailable: true } | ||
|
|
||
| let generation: Generation | ||
|
|
||
| beforeAll(async () => { | ||
| const p = new MockProvider() | ||
| const core = await p.generateCore(input) | ||
| const [ph, hn] = await Promise.all([ | ||
| p.generatePlatform('product-hunt', core, input), | ||
| p.generatePlatform('hacker-news', core, input), | ||
| ]) | ||
| generation = { core, platforms: { 'product-hunt': ph, 'hacker-news': hn } as Generation['platforms'] } | ||
| }) | ||
|
|
||
| function renderKit() { | ||
| return render( | ||
| <RuntimeConfigProvider value={config}> | ||
| <PostKit | ||
| generation={generation} | ||
| productName="Acme" | ||
| onRegeneratePlatform={vi.fn()} | ||
| regeneratingPlatform={null} | ||
| onExportMarkdown={vi.fn()} | ||
| onCopyAll={vi.fn()} | ||
| onCopyPlan={vi.fn()} | ||
| onExportPlan={vi.fn()} | ||
| onStartOver={vi.fn()} | ||
| /> | ||
| </RuntimeConfigProvider>, | ||
| ) | ||
| } | ||
|
|
||
| describe('PostKit launch-ops tab', () => { | ||
| it('shows a Launch ops tab for Product Hunt and keeps ops out of the Edit view', async () => { | ||
| renderKit() | ||
| // Product Hunt is the default platform; the third tab is present. | ||
| expect(screen.getByRole('button', { name: 'Launch ops' })).toBeInTheDocument() | ||
| // Edit view (default) no longer renders the launch-day ops. | ||
| expect(screen.queryByText('Hour-by-hour (PT)')).not.toBeInTheDocument() | ||
| // Switching to the tab reveals the ops content. | ||
| await userEvent.click(screen.getByRole('button', { name: 'Launch ops' })) | ||
| expect(screen.getByText('Hour-by-hour (PT)')).toBeInTheDocument() | ||
| }) | ||
|
|
||
| it('hides the Launch ops tab for platforms without launch-day ops', async () => { | ||
| renderKit() | ||
| await userEvent.click(screen.getByRole('button', { name: 'Hacker News' })) | ||
| expect(screen.queryByRole('button', { name: 'Launch ops' })).not.toBeInTheDocument() | ||
| }) | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial | ⚡ Quick win
Consider logging the full error stack for plan failures.
The current error logging only captures
err?.message ?? err, which loses stack trace information that could be valuable for debugging plan generation failures in production.📋 Proposed enhancement
const planPromise = provider.generatePlan(core, input).catch((err) => { - console.error('[api/generate] plan failed:', err?.message ?? err) + console.error('[api/generate] plan failed:', err) return undefined })🤖 Prompt for AI Agents