From f64221f4ade0469aa1fc935fa3429a74998c3a56 Mon Sep 17 00:00:00 2001 From: magalutfullaev Date: Fri, 6 Mar 2026 02:28:33 +0500 Subject: [PATCH 1/5] improve system prompt --- lib/prompts/content-plan.ts | 61 ++++++---- lib/prompts/post-generation.ts | 5 +- lib/prompts/prompts.test.ts | 84 +++++--------- lib/prompts/reply.ts | 3 +- lib/prompts/shared.ts | 204 ++++++++++----------------------- 5 files changed, 131 insertions(+), 226 deletions(-) diff --git a/lib/prompts/content-plan.ts b/lib/prompts/content-plan.ts index ffd9e76..41f575e 100644 --- a/lib/prompts/content-plan.ts +++ b/lib/prompts/content-plan.ts @@ -12,20 +12,19 @@ export function buildContentPlanSystemPrompt( postsPerDay: number, numberOfDays: number, platformUserInstructions?: string, + includeTitle = false, ): string { const totalPosts = postsPerDay * numberOfDays; const charLimit = platform?.metadata?.character_limit as number | undefined; - let prompt = `You ghostwrite ${numberOfDays} days of social media posts as the person described below. -Not as an AI. Not as a brand. As them. -Every post must feel like it came from one specific person with a consistent voice. + let prompt = `Write ${numberOfDays} days of social media posts as the person described below. -- Each post must be unique. No repetition of ideas, phrases, or structures across the period. -- Mix content types: observations, short stories, lessons learned, opinions, specific results. -- Vary emotional tone: some posts direct, others reflective, some bold. -- Build a coherent arc across ${numberOfDays} days but don't repeat yourself. -- Start strong every day. First line hooks or fails. +${totalPosts} unique posts. No repeated ideas, phrases, hooks, or structures. +Mix types: observations, opinions, lessons, results, short stories. +Mix tone: direct, reflective, bold, understated. +Never use the same structure twice in a row. +Day 1 and Day ${numberOfDays}: strongest hooks. `; @@ -34,27 +33,47 @@ Every post must feel like it came from one specific person with a consistent voi prompt += '\n'; prompt += assemblePersonalization({ globalMemory, sphere, platform, style, charLimit, platformUserInstructions }); - prompt += ` + if (includeTitle) { + prompt += ` Generate exactly ${totalPosts} posts (${postsPerDay} per day for ${numberOfDays} days). -Output as JSON array with this structure: -[ - {"dayIndex": 0, "timeSlot": "morning", "content": "..."}, - {"dayIndex": 0, "timeSlot": "afternoon", "content": "..."}, - {"dayIndex": 1, "timeSlot": "morning", "content": "..."}, - ... -] +Output as a JSON object with this structure: +{ + "title": "Short plan title (max 40 characters)", + "posts": ["post 1 text", "post 2 text", ...] +} + +Posts are ordered: first ${postsPerDay} posts are for day 1, next ${postsPerDay} for day 2, and so on. +title: concise label for the plan, max 40 characters, no quotes + +CRITICAL: +- Before outputting, count the posts. If not exactly ${totalPosts}, fix it. +- The "posts" array MUST contain EXACTLY ${totalPosts} strings. Not ${totalPosts - 1}, not ${totalPosts + 1}. Exactly ${totalPosts}. +- Output ONLY valid JSON. No explanation, no markdown code blocks. +- Each string in "posts" must be the complete, ready-to-publish post text. +- First character of response must be { +- All strings MUST use double quotes. Escape any double quotes inside post text with backslash: \\" +- Example: {"title": "Growth Tips", "posts": ["She said \\"wow\\" and I agreed", "Another post"]} + +`; + } else { + prompt += ` +Generate exactly ${totalPosts} posts (${postsPerDay} per day for ${numberOfDays} days). +Output as a JSON array of strings: +["post 1 text", "post 2 text", ...] -dayIndex: 0-${numberOfDays - 1} (day 0 is start date, day ${numberOfDays - 1} is end date) -timeSlot: "morning", "afternoon", or "evening" (for ordering within day) +Posts are ordered: first ${postsPerDay} posts are for day 1, next ${postsPerDay} for day 2, and so on. CRITICAL: +- Before outputting, count the posts. If not exactly ${totalPosts}, fix it. +- The array MUST contain EXACTLY ${totalPosts} strings. Not ${totalPosts - 1}, not ${totalPosts + 1}. Exactly ${totalPosts}. - Output ONLY valid JSON. No explanation, no markdown code blocks. -- Each content field must be the complete, ready-to-publish post text. +- Each string must be the complete, ready-to-publish post text. - First character of response must be [ -- All strings MUST use double quotes. Escape any double quotes inside content with backslash: \\" -- Example: {"dayIndex": 0, "timeSlot": "morning", "content": "She said \\"wow\\" and I agreed"} +- All strings MUST use double quotes. Escape any double quotes inside post text with backslash: \\" +- Example: ["She said \\"wow\\" and I agreed", "Another post"] `; + } return prompt; } diff --git a/lib/prompts/post-generation.ts b/lib/prompts/post-generation.ts index e174b3d..a583c5f 100644 --- a/lib/prompts/post-generation.ts +++ b/lib/prompts/post-generation.ts @@ -18,9 +18,8 @@ export interface IGenerationContext { export function assembleSystemPrompt(ctx: IGenerationContext): string { const charLimit = ctx.platform?.metadata?.character_limit as number | undefined; - let prompt = `You ghostwrite social media posts as the person described below. -Not as an AI. Not as a brand. As them. -The user gives you an idea, turn THAT idea into a post. Stay close to what they asked. + let prompt = `Write social media posts as the person described below. +Turn the user's idea into a post. Stay close to what they asked. Don't add context, lessons, or explanations they didn't ask for. `; diff --git a/lib/prompts/prompts.test.ts b/lib/prompts/prompts.test.ts index 99ad496..df936ba 100644 --- a/lib/prompts/prompts.test.ts +++ b/lib/prompts/prompts.test.ts @@ -36,88 +36,60 @@ describe('BASE_WRITING_RULES', () => { expect(BASE_WRITING_RULES.length).toBeGreaterThan(0); }); - it('should contain prohibited words section', () => { - expect(BASE_WRITING_RULES).toContain(''); + it('should contain constraints section', () => { + expect(BASE_WRITING_RULES).toContain(''); }); - it('should contain banned patterns section', () => { - expect(BASE_WRITING_RULES).toContain(''); + it('should contain voice section', () => { + expect(BASE_WRITING_RULES).toContain(''); }); - it('should contain writing rules section', () => { - expect(BASE_WRITING_RULES).toContain(''); + it('should contain format section', () => { + expect(BASE_WRITING_RULES).toContain(''); }); - it('should contain formatting rules section', () => { - expect(BASE_WRITING_RULES).toContain(''); + it('should contain examples section', () => { + expect(BASE_WRITING_RULES).toContain(''); }); it('should list key banned words', () => { expect(BASE_WRITING_RULES).toContain('leverage'); - expect(BASE_WRITING_RULES).toContain('utilize'); expect(BASE_WRITING_RULES).toContain('game-changer'); expect(BASE_WRITING_RULES).toContain('seamless'); expect(BASE_WRITING_RULES).toContain('craft'); }); - it('should contain bad/good examples', () => { - expect(BASE_WRITING_RULES).toContain(''); - expect(BASE_WRITING_RULES).toContain(''); + it('should contain grounding rule (no invented facts)', () => { + expect(BASE_WRITING_RULES).toContain('use only what the user provided'); }); - it('should contain grounding rule section', () => { - expect(BASE_WRITING_RULES).toContain(''); + it('should show grounding NEVER/INSTEAD examples', () => { + expect(BASE_WRITING_RULES).toContain('NEVER:'); + expect(BASE_WRITING_RULES).toContain('INSTEAD:'); }); - it('should instruct not to invent experiences in grounding rule', () => { - expect(BASE_WRITING_RULES).toContain('Never invent experiences'); - }); - - it('should distinguish topic input from personal story input in grounding rule', () => { - expect(BASE_WRITING_RULES).toContain('observer, not a participant'); - }); - - it('should contain hook craft section', () => { - expect(BASE_WRITING_RULES).toContain(''); - }); - - it('should list what works and what to avoid in hooks', () => { + it('should contain hook patterns in voice section', () => { expect(BASE_WRITING_RULES).toContain('Honest admission'); - expect(BASE_WRITING_RULES).toContain('Manufactured suspense'); - }); - - it('should contain engagement psychology section', () => { - expect(BASE_WRITING_RULES).toContain(''); - }); - - it('should mention saving as the goal over likes', () => { - expect(BASE_WRITING_RULES).toContain('saved, not just scrolled past'); + expect(BASE_WRITING_RULES).toContain('Direct challenge'); }); - it('should contain structural variety section', () => { - expect(BASE_WRITING_RULES).toContain(''); - }); - - it('should list available post structures', () => { + it('should contain structure examples', () => { expect(BASE_WRITING_RULES).toContain('Observation:'); expect(BASE_WRITING_RULES).toContain('Challenge:'); expect(BASE_WRITING_RULES).toContain('Lesson:'); expect(BASE_WRITING_RULES).toContain('Single statement:'); }); - it('should guard against story structure without user input', () => { - expect(BASE_WRITING_RULES).toContain("no personal story in the user's input"); - }); - - it('should contain measured tone rule merging calm and anti-hype', () => { - expect(BASE_WRITING_RULES).toContain(''); + it('should contain reference posts in examples section', () => { + expect(BASE_WRITING_RULES).toContain('800 lines down to 200'); + expect(BASE_WRITING_RULES).toContain("they're bad at saying no"); }); }); // ─── assembleSystemPrompt ───────────────────────────────────────────────────── describe('assembleSystemPrompt', () => { - it('should include ghostwriting intro', () => { + it('should include post writing intro', () => { const result = assembleSystemPrompt({ globalMemory: '', sphere: null, @@ -126,8 +98,8 @@ describe('assembleSystemPrompt', () => { postCount: 1, }); - expect(result).toContain('ghostwrite'); - expect(result).toContain('Not as an AI'); + expect(result).toContain('Write social media posts'); + expect(result).toContain("Stay close to what they asked"); }); it('should include base writing rules', () => { @@ -139,9 +111,9 @@ describe('assembleSystemPrompt', () => { postCount: 1, }); - expect(result).toContain(''); - expect(result).toContain(''); - expect(result).toContain(''); + expect(result).toContain(''); + expect(result).toContain(''); + expect(result).toContain(''); }); it('should include global memory with author identity framing', () => { @@ -436,7 +408,7 @@ describe('assembleReplyPrompt', () => { style: null, }); - expect(result).toContain('ghostwrite replies'); + expect(result).toContain('Write replies to tweets'); expect(result).toContain('280'); expect(result).toContain('===VARIATION==='); }); @@ -448,8 +420,8 @@ describe('assembleReplyPrompt', () => { style: null, }); - expect(result).toContain(''); - expect(result).toContain(''); + expect(result).toContain(''); + expect(result).toContain(''); }); it('should request exactly 3 variations', () => { diff --git a/lib/prompts/reply.ts b/lib/prompts/reply.ts index 7a54cf4..7aa9490 100644 --- a/lib/prompts/reply.ts +++ b/lib/prompts/reply.ts @@ -14,8 +14,7 @@ export interface IReplyContext { // ─── Reply Generation Prompt ────────────────────────────────────────────────── export function assembleReplyPrompt(ctx: IReplyContext): string { - let prompt = `You ghostwrite replies to tweets as the person described below. -Not as an AI. Not as a brand. As them. + let prompt = `Write replies to tweets as the person described below. React to the tweet with one thought. Don't lecture, don't summarize, don't teach. diff --git a/lib/prompts/shared.ts b/lib/prompts/shared.ts index 6c6c0ef..384830a 100644 --- a/lib/prompts/shared.ts +++ b/lib/prompts/shared.ts @@ -10,160 +10,77 @@ export const TWITTER_CHAR_LIMIT = 280; // Shared across all prompt builders. Contains the full writing standards. export const BASE_WRITING_RULES = ` - -Never invent experiences. The user's input is your only source of facts. -- Topic or idea with no personal story → write as an observer, not a participant. "Most engineers skip this" not "I skipped this last week." -- User shares their own experience → use ONLY the details they gave. Don't add numbers, timelines, or outcomes they didn't mention. -- Voice, tone, and structure are yours to shape. What happened is not yours to invent. - - - User input: "write about morning routines" → "I woke up at 5am every day for 90 days. Changed everything." - User input: "write about morning routines" → "the first hour shapes the whole day. most people spend it reacting." - - - - -The first line determines everything. Open strong or fail — there is no warm-up. - -What works: -- Honest admission or unspoken truth: "I was wrong about X." / Name what people feel but won't say. -- Specific observation: "After [N] interviews/projects/years, one pattern keeps appearing." -- Direct challenge: "Most [advice/belief] optimizes for the wrong thing." -- Unexpected angle: "The [worst/strangest] thing I did had the biggest effect." - -What to avoid: -- "I'm excited to announce..." (corporate, skippable) -- "Have you ever struggled with..." (rhetorical filler) -- "In today's world..." or "In today's landscape..." (cliché opener) -- "Here's the thing..." / "Here's why this matters" (stalling before the point) -- Manufactured suspense: "The CEO said 3 words that changed everything." - - - Have you ever wondered why some people always seem productive? - most people aren't bad at time management. they're bad at saying no. - - I'm excited to share something that completely changed how I work. - I was doing code reviews wrong for 3 years. - - - - -- One post, one idea. Don't explain background, don't generalize, don't add a second thought. -- Be specific: numbers, tool names, real outcomes. "a scraper that retries on rate limits" not "a tool that works." -- Clear, simple language. Short sentences. Active voice only. -- Address the reader with "you" and "your" when relevant. -- Contractions always. "You're" not "You are". -- Bullet points when listing. Never numbered lists. - - - -- No em dashes (—). Use commas, periods, or a new line instead. -- No semicolons. No markdown. No asterisks. -- No hashtags unless explicitly requested. -- Gentle spacing: every 1-2 sentences is its own paragraph. Each thought breathes on its own line. -- Between every paragraph: a BLANK LINE (an empty line — not just \n, but \n\n). No exceptions. -- Lists also get a blank line before and after each item group. -- Dense text blocks are never acceptable. If a reader sees a wall of text, the formatting has failed. - - - -I paused work on my SaaS this week. One question stopped me: what does it do that ChatGPT can't do better and for free? I'm now looking into high-friction areas where AI still struggles. It's a tough reality check. - - + +Facts: use only what the user provided. No invented numbers, timelines, or outcomes. +- No personal story in the input → write as an observer. "Most engineers skip this" not "I skipped this last week." +- User shares an experience → use ONLY their details. + +NEVER: "I woke up at 5am for 90 days" when the user just said "write about morning routines." +INSTEAD: "the first hour shapes the whole day. most people spend it reacting." + +Banned phrases (never use): +- "In today's world/landscape/age" / "Here's the thing" / "At the end of the day" +- "Let that sink in" / "Not gonna lie" / "In summary" / "In conclusion" +- "Stop doing X. Start doing Y." / ending with a question to drive engagement +- "Setup? Punchline." fragment structure: "Their entire strategy? One tweet." +- Stacked motivational lines: "Ship fast. Stay consistent. That's it." +- "No more X" pattern / "Not just X, but also Y" pattern +- Starting 3+ sentences with "I" in a row / adding a moral the user didn't ask for + +Banned words: craft, leverage, delve, embrace, unlock, journey, game-changer, seamless, robust, cutting-edge, innovative, transform, empower, dive in, navigate, incredible, amazing, excited, exciting +Alternatives: leverage/utilize → use, innovative → new, navigate → figure out, transform → change + + + +Hook: the first line decides everything. Use one of these: +- Honest admission: "I was wrong about X." +- Specific observation: "After [N] years, one pattern keeps appearing." +- Direct challenge: "Most [advice] optimizes for the wrong thing." +- Unexpected angle: "The worst thing I did had the biggest effect." + +Writing: +- One post, one idea. No background, no second thought. +- Specific: numbers, tool names, real outcomes. "a scraper that retries on rate limits" not "a tool that works." +- Short sentences. Cut any word that doesn't change the meaning. +- State facts, skip adjectives. Let the reader decide if it's good. +- Contractions always. Active voice only. "you" and "your" when relevant. + +Structures (pick one per post): +- Observation: "most teams hire for skills. the ones that grow hire for learning speed." +- Challenge: "everyone says ship fast. most MVPs fail because they shipped the wrong thing fast." +- Lesson (only with real experience from user): "migrated to Railway last month. deploys went from 3 min to 40 sec." +- Single statement: "the best marketing is a product people talk about without being asked." + + + +- Every 1-2 sentences: its own paragraph with a BLANK LINE after it. +- No em dashes (—), semicolons, markdown, or asterisks. +- No hashtags unless asked. +- Bullets for lists, never numbered. Blank line before and after bullet groups. + +Good spacing example: I paused work on my SaaS this week. One question stopped me: what does it do that ChatGPT can't do better and for free? -I'm now looking into high-friction areas where AI still struggles. - It's a tough reality check. - - - - - -Shorter is always better. Don't pad, don't elaborate, don't add "one more thought." + - I spent the entire weekend refactoring our authentication system. It was spaghetti code built up over months. After two days I reduced it from 800 lines to 200. The lesson? Sometimes you need to slow down to speed up. Technical debt is real. - rewrote our auth this weekend. 800 lines down to 200. should've done it months ago. - - - - -Not everything is exciting. Most things are just things. -Be measured. Say what something does — let readers decide if it's remarkable. Never hype. +--- - - Really excited to share that we hit 1,000 users! This is an incredible milestone! - hit 1,000 users today. took 4 months. slower than I wanted but retention is solid. +most people aren't bad at time management. they're bad at saying no. + +--- - The performance gains are insane. Absolutely game-changing. - 14x faster string parsing in benchmarks. +hit 1,000 users today. took 4 months. slower than I wanted but retention is solid. - - - -Write to be saved, not just scrolled past. -- Saved content is actionable and reference-worthy. Likes are cheap. -- Genuine replies come from real disagreement or resonance — not from "agree?" at the end. -- Authenticity beats performance. Readers detect manufactured vulnerability instantly. -- If a reader can't answer "so what?" in one sentence after reading, the post isn't done. - - - -Match the structure to the idea. Don't default to one format. -- Observation: notice something → why it matters → implication -- Challenge: state the common belief → challenge it → your reasoning -- Lesson: what happened (only if user gave a real experience) → what you learned → what changes -- List: promise value → deliver concisely → close with one insight -- Single statement: one true thing said well — nothing else needed - -If there's no personal story in the user's input, don't build a story structure. Use observation or challenge instead. - - - -Each pattern below is banned. One example each. - -"Setup? Punchline." structure: - Their entire strategy? One tweet. - I grew mostly by posting every day, nothing fancy. - -Stacked motivational lines: - Wake up early. Write every day. Ship fast. Stay consistent. That's it. - started writing every morning before work. 3 months later I had a full newsletter backlog. - -"No more..." pattern: - No more waiting 30 seconds for builds - builds finish in under 3 seconds now - -"Not just X, but also Y": - It's not just fast, but also reliable - it's fast and reliable - -Also never use: -- "In today's world/landscape/age" -- "Here's the thing" / "Here's why this matters" -- "At the end of the day" / "Stop doing X. Start doing Y." -- "Let that sink in" / "Not gonna lie" / "Let me be honest" -- "In summary" / "In conclusion" / "In closing" -- Starting 3+ sentences with "I" in a row -- Ending with a question to "drive engagement" -- Adding a moral the user didn't ask for -- Repeating the same idea in different words - - - -NEVER use: craft, crafted, crafting, embrace, leverage, delve, unleash, elevate, transform, transformative, empower, seamless, seamlessly, robust, cutting-edge, game-changer, dive in, deep dive, navigate, navigating, journey, unlock, harness, foster, thrive, resonate, pivotal, paradigm, synergy, holistic, streamline, utilize, facilitate, revolutionize, innovative, groundbreaking, moreover, furthermore, hence, thus, realm, landscape (metaphorical), tapestry, beacon, testament, pinnacle, spearhead, bolster, vital, crucial, compelling, ever-evolving, intricacies, intricate, multifaceted, commendable, noteworthy, bespoke, tailored, supercharge, daunting, flawless, meticulous, complexities, underpins, comprehensive, hit different, redefine, incredible, amazing, stunning, thrilled, excited, exciting, remarkable, powerful, embark, enlightening, esteemed, shed light, imagine, discover, skyrocket, abyss, disruptive, illuminate, unveil, elucidate, stark, boost, very, really, literally, actually - -Alternatives: seamless→easy, utilize/leverage→use, innovative→new, facilitate→help, comprehensive→full, navigate→figure out, transform→change, elevate→improve, remarkable→good/solid - `; // ─── Personalization Helper ─────────────────────────────────────────────────── @@ -178,12 +95,14 @@ export interface IPersonalizationContext { } export function assemblePersonalization(ctx: IPersonalizationContext): string { - let result = ''; + let result = `Priority: user instructions > author voice > platform rules > writing rules. +If anything below conflicts with what the user asked for, follow the user. + +`; if (ctx.globalMemory) { result += ` -You ARE this person. Match their vocabulary, opinions, perspective. -Don't write about them, write AS them: +Write as this person. Use their vocabulary, opinions, and perspective: ${ctx.globalMemory} @@ -192,7 +111,6 @@ ${ctx.globalMemory} if (ctx.sphere) { result += ` -Post about this world. Use language someone in this space naturally uses: ${ctx.sphere.content} @@ -211,7 +129,6 @@ ${ctx.platform.content}`; `; if (ctx.platformUserInstructions) { result += ` -Additional instructions from the user for this platform: ${ctx.platformUserInstructions} @@ -221,7 +138,6 @@ ${ctx.platformUserInstructions} if (ctx.style) { result += ` -This controls how you sound: ${ctx.style.content} From e2424bd0f607a59c6b403abc35260058d2422c04 Mon Sep 17 00:00:00 2001 From: magalutfullaev Date: Fri, 6 Mar 2026 10:49:44 +0500 Subject: [PATCH 2/5] feat(content-plan): improve everything --- actions/content-plan.test.ts | 57 +--- actions/content-plan.ts | 249 +++++++++--------- actions/media.test.ts | 95 +++++++ actions/media.ts | 46 +++- actions/publishing.test.ts | 17 +- actions/publishing.ts | 8 +- actions/schedules.ts | 5 + app/app/plan/[planId]/loading.tsx | 61 +++++ app/app/plan/[planId]/page.tsx | 41 ++- components/content-plan/plan-day-section.tsx | 4 + .../plan-iteration-input.test.tsx | 46 ++++ .../content-plan/plan-iteration-input.tsx | 12 +- .../content-plan/plan-post-card.test.tsx | 108 +++++++- components/content-plan/plan-post-card.tsx | 138 +++++++++- components/schedules/schedule-filters.tsx | 1 + components/schedules/scheduled-post-card.tsx | 2 + lib/constants.ts | 2 +- lib/mappers.ts | 15 ++ lib/navigation.ts | 2 +- lib/validations/schedules.ts | 2 +- ...306000001_add_post_media_update_policy.sql | 5 + 21 files changed, 717 insertions(+), 199 deletions(-) create mode 100644 actions/media.test.ts create mode 100644 app/app/plan/[planId]/loading.tsx create mode 100644 components/content-plan/plan-iteration-input.test.tsx create mode 100644 supabase/migrations/20260306000001_add_post_media_update_policy.sql diff --git a/actions/content-plan.test.ts b/actions/content-plan.test.ts index 3f00b3a..4358394 100644 --- a/actions/content-plan.test.ts +++ b/actions/content-plan.test.ts @@ -45,24 +45,12 @@ const validCreatePlanInput = { prompt: 'Create posts about productivity tips', }; -function generateMockPosts(postsPerDay: number): { dayIndex: number; timeSlot: string; content: string }[] { - const slots = ['morning', 'afternoon', 'evening']; - const posts: { dayIndex: number; timeSlot: string; content: string }[] = []; - for (let day = 0; day < 7; day++) { - for (let slot = 0; slot < postsPerDay; slot++) { - posts.push({ - dayIndex: day, - timeSlot: slots[slot] ?? 'morning', - content: `Day ${day} ${slots[slot] ?? 'morning'} post`, - }); - } - } - - return posts; +function generateMockPosts(count: number): string[] { + return Array.from({ length: count }, (_, i) => `Post ${i + 1} content`); } const mockGeminiResponse = { - content: JSON.stringify(generateMockPosts(2)), + content: JSON.stringify(generateMockPosts(14)), // 2 posts/day * 7 days model: 'openai/gpt-4o', usage: { inputTokens: 1000, outputTokens: 500 }, }; @@ -209,12 +197,12 @@ describe('createPlan', () => { expect(mockCallAI).toHaveBeenCalledTimes(1); }); - it('should handle Gemini response with unescaped quotes in content', async () => { + it('should handle AI response with unescaped quotes in content', async () => { setupFullMocks(); const onePostInput = { ...validCreatePlanInput, postsPerDay: 1 }; - // Build 7 malformed posts (1 per day) with unescaped quotes + // Build 7 posts with unescaped quotes — malformed JSON that the regex fallback should handle const malformedEntries = Array.from({ length: 7 }, (_, i) => - ` {\n "dayIndex": ${i},\n "timeSlot": "morning",\n "content": "Don't build "the Uber for X" - day ${i}"\n }`, + ` "Don't build "the Uber for X" - day ${i}"`, ); const malformedResponse = { content: `[\n${malformedEntries.join(',\n')}\n]`, @@ -229,36 +217,11 @@ describe('createPlan', () => { expect(result.data?.planId).toBe('plan-123'); }); - it('should handle Gemini response with single-quoted content values', async () => { - setupFullMocks(); - const onePostInput = { ...validCreatePlanInput, postsPerDay: 1 }; - // Build 7 malformed posts with single-quoted content - const singleQuoteEntries = Array.from({ length: 7 }, (_, i) => - ` {\n "dayIndex": ${i},\n "timeSlot": "morning",\n "content": 'It ain\\'t easy - day ${i}'\n }`, - ); - const singleQuoteResponse = { - content: `[\n${singleQuoteEntries.join(',\n')}\n]`, - model: 'openai/gpt-4o', - usage: { inputTokens: 1000, outputTokens: 500 }, - }; - mockCallAI.mockResolvedValue(singleQuoteResponse); - - const result = await createPlan(onePostInput); - - expect(result.success).toBe(true); - expect(result.data?.planId).toBe('plan-123'); - }); - - it('should return error when Gemini returns fewer posts than expected', async () => { + it('should return error when AI returns fewer posts than expected', async () => { setupFullMocks(); - // postsPerDay=2 expects 14 posts, but Gemini only returns 4 + // postsPerDay=2 expects 14 posts, but AI only returns 4 const partialResponse = { - content: JSON.stringify([ - { dayIndex: 0, timeSlot: 'morning', content: 'Monday morning' }, - { dayIndex: 0, timeSlot: 'afternoon', content: 'Monday afternoon' }, - { dayIndex: 1, timeSlot: 'morning', content: 'Tuesday morning' }, - { dayIndex: 1, timeSlot: 'afternoon', content: 'Tuesday afternoon' }, - ]), + content: JSON.stringify(['Monday morning', 'Monday afternoon', 'Tuesday morning', 'Tuesday afternoon']), model: 'openai/gpt-4o', usage: { inputTokens: 1000, outputTokens: 500 }, }; @@ -287,7 +250,7 @@ describe('createPlan', () => { const callArgs = mockCallAI.mock.calls[0][0]; expect(callArgs.system).toContain('ghostwrite'); expect(callArgs.system).toContain('content_strategy'); - expect(callArgs.messages[0].content).toBe(validCreatePlanInput.prompt); + expect(callArgs.messages[0].content).toContain(validCreatePlanInput.prompt); }); it('should deduct credits after successful plan creation', async () => { diff --git a/actions/content-plan.ts b/actions/content-plan.ts index edabe54..6d3ad0a 100644 --- a/actions/content-plan.ts +++ b/actions/content-plan.ts @@ -25,10 +25,9 @@ import type { IContentPlan, IGenerationHistoryMessage, IMemoryItem, IPost } from // ─── Types ─────────────────────────────────────────────────────────────────── -interface IPlanPost { - dayIndex: number; - timeSlot: string; - content: string; +interface IPlanResponse { + title?: string; + posts: string[]; } interface ICreatePlanResult { @@ -75,117 +74,97 @@ interface IActionResult { // ─── Helper Functions ──────────────────────────────────────────────────────── /** - * Parse Gemini's JSON response containing an array of plan posts. + * Parse AI's JSON response containing a flat array of post strings or a titled plan object. * - * LLMs sometimes produce invalid JSON: - * - Single-quoted content strings: `"content": 'text here'` - * - Unescaped double quotes inside values: `"content": "build "the app""` + * Handles two formats: + * - Array: ["post 1 text", "post 2 text", ...] + * - Object with title: {"title": "...", "posts": ["post 1 text", ...]} * - * We try multiple strategies: JSON.parse, normalization, then regex extraction. + * LLMs sometimes produce invalid JSON with unescaped double quotes inside strings. + * We try multiple strategies: JSON.parse, then regex extraction. */ -function tryParsePostsJson(raw: string): IPlanPost[] | null { +function tryParseResponse(raw: string): IPlanResponse | null { // 1. Try standard JSON.parse try { - return JSON.parse(raw) as IPlanPost[]; + const parsed = JSON.parse(raw) as unknown; + if (Array.isArray(parsed) && parsed.every((p) => typeof p === 'string')) { + return { posts: parsed as string[] }; + } + if (parsed && typeof parsed === 'object' && 'posts' in parsed) { + const obj = parsed as { title?: string; posts: unknown[] }; + if (Array.isArray(obj.posts) && obj.posts.every((p) => typeof p === 'string')) { + return { title: typeof obj.title === 'string' ? obj.title : undefined, posts: obj.posts as string[] }; + } + } } catch { // continue to fallbacks } - // 2. Try normalizing single-quoted content values to double-quoted - try { - const normalized = raw.replace( - /("content"\s*:\s*)'([\s\S]*?)'\s*(\n\s*\})/g, - (_match, prefix: string, content: string, suffix: string) => { - const escaped = content.replace(/"/g, '\\"'); - return `${prefix}"${escaped}"${suffix}`; - }, - ); - return JSON.parse(normalized) as IPlanPost[]; - } catch { - // continue to regex fallback + // 2. Try extracting title from object wrapper first + let extractedTitle: string | undefined; + const titleMatch = /"title"\s*:\s*"([^"]+)"/.exec(raw); + if (titleMatch) { + extractedTitle = titleMatch[1]; } - // 3. Regex fallback — extract posts from malformed JSON + // 3. Regex fallback — extract quoted strings from the posts array try { - const posts: IPlanPost[] = []; - - // Try double-quoted content first - const dqRegex = /"dayIndex"\s*:\s*(\d+)\s*,\s*"timeSlot"\s*:\s*"([^"]+)"\s*,\s*"content"\s*:\s*"([\s\S]*?)"\s*\n?\s*\}/g; - let match: RegExpExecArray | null = dqRegex.exec(raw); - while (match !== null) { - posts.push({ - dayIndex: parseInt(match[1] ?? '0', 10), - timeSlot: match[2] ?? 'morning', - content: (match[3] ?? '').replace(/\\n/g, '\n').replace(/\\"/g, '"'), - }); - match = dqRegex.exec(raw); - } - if (posts.length > 0) return posts; - - // Try single-quoted content (LLMs sometimes use ' as string delimiter) - const sqRegex = /"dayIndex"\s*:\s*(\d+)\s*,\s*"timeSlot"\s*:\s*"([^"]+)"\s*,\s*"content"\s*:\s*'([\s\S]*?)'\s*\n?\s*\}/g; - match = sqRegex.exec(raw); - while (match !== null) { - posts.push({ - dayIndex: parseInt(match[1] ?? '0', 10), - timeSlot: match[2] ?? 'morning', - content: (match[3] ?? '').replace(/\\n/g, '\n').replace(/\\'/g, "'"), - }); - match = sqRegex.exec(raw); + // Find the posts array (either top-level or inside "posts" key) + const postsArrayMatch = /"posts"\s*:\s*\[([^[\]]*(?:\[[^\]]*\][^[\]]*)*)\]/.exec(raw) + ?? /^\s*\[([^[\]]*(?:\[[^\]]*\][^[\]]*)*)\]\s*$/.exec(raw); + + if (postsArrayMatch) { + const arrayContent = postsArrayMatch[1] ?? ''; + const posts: string[] = []; + // Extract double-quoted strings, handling escaped quotes inside + const strRegex = /"((?:[^"\\]|\\.)*)"/g; + let match: RegExpExecArray | null = strRegex.exec(arrayContent); + while (match !== null) { + posts.push((match[1] ?? '').replace(/\\n/g, '\n').replace(/\\"/g, '"')); + match = strRegex.exec(arrayContent); + } + if (posts.length > 0) return { title: extractedTitle, posts }; } - - return posts.length > 0 ? posts : null; } catch { return null; } + + return null; } -function generateRandomTimes(postsPerDay: number): string[] { - const START_MINUTES = 7 * 60; - const END_MINUTES = 23 * 60; - const AVOID_START = 2 * 60; - const AVOID_END = 6 * 60; - const MIN_GAP_MINUTES = 45; - const totalMinutes = END_MINUTES - START_MINUTES; - const slotSize = Math.floor(totalMinutes / postsPerDay); +const SCHEDULE_START_HOUR = 8; +const SCHEDULE_END_HOUR = 20; + +/** + * Distributes post times evenly across a time window with a small random jitter. + * + * Algorithm: + * - Divide the window into equal slots of size `gap = totalMinutes / postsPerDay` + * - Place each post at the center of its slot: `slotStart + gap / 2` + * - Apply jitter of ±10% of `gap` to avoid mechanical uniformity + * - Use a random minute (0–59) within the computed hour + * - Returns a sorted list of "HH:mm" strings + */ +function distributeTimesInRange( + postsPerDay: number, + startHour: number = SCHEDULE_START_HOUR, + endHour: number = SCHEDULE_END_HOUR, +): string[] { + const startMinutes = startHour * 60; + const totalMinutes = (endHour - startHour) * 60; + const gap = totalMinutes / postsPerDay; + const jitterRange = gap * 0.1; const times: string[] = []; for (let i = 0; i < postsPerDay; i++) { - let candidate = -1; - let attempts = 0; - - while (attempts < 20) { - const slotStart = START_MINUTES + i * slotSize; - const jitter = Math.floor(Math.random() * slotSize); - const minuteOfDay = Math.min(slotStart + jitter, END_MINUTES - 1); - - const isInQuietHours = minuteOfDay >= AVOID_START && minuteOfDay < AVOID_END; - if (isInQuietHours) { - attempts++; - continue; - } + const slotCenter = startMinutes + i * gap + gap / 2; + const jitter = (Math.random() * 2 - 1) * jitterRange; + const baseMinuteOfDay = Math.round(Math.min(Math.max(startMinutes, slotCenter + jitter), endHour * 60 - 1)); - const tooClose = times.some((t) => { - const [h, m] = t.split(':').map(Number); - const existing = (h ?? 0) * 60 + (m ?? 0); - return Math.abs(existing - minuteOfDay) < MIN_GAP_MINUTES; - }); + const hour = Math.floor(baseMinuteOfDay / 60); + const minute = Math.floor(Math.random() * 60); - if (!tooClose) { - candidate = minuteOfDay; - break; - } - - attempts++; - } - - if (candidate === -1) { - candidate = START_MINUTES + i * slotSize; - } - - const hour = Math.floor(candidate / 60); - const minute = candidate % 60; times.push(`${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`); } @@ -317,7 +296,7 @@ export async function createPlan(data: CreatePlanFormData): Promise = {}; - for (let d = 0; d < numberOfDays; d++) { - timesByDay[d] = generateRandomTimes(validated.postsPerDay); - } + // Pre-generate times per day to ensure uniqueness within each day + const timesByDay: string[][] = Array.from({ length: numberOfDays }, () => + distributeTimesInRange(validated.postsPerDay), + ); + + const postsToInsert = planPosts.map((content, index) => { + const dayIndex = Math.floor(index / validated.postsPerDay); + const postIndexInDay = index % validated.postsPerDay; - const postsToInsert = planPosts.map((post) => { const dayDate = new Date(startDate); - dayDate.setDate(dayDate.getDate() + post.dayIndex); + dayDate.setDate(dayDate.getDate() + dayIndex); - const dayTimes = timesByDay[post.dayIndex] ?? generateRandomTimes(validated.postsPerDay); - const timeIndex = post.timeSlot === 'morning' ? 0 : post.timeSlot === 'afternoon' ? 1 : 2; - const time = dayTimes[Math.min(timeIndex, dayTimes.length - 1)] ?? '12:00'; + const dayTimes = timesByDay[dayIndex] ?? distributeTimesInRange(validated.postsPerDay); + const time = dayTimes[Math.min(postIndexInDay, dayTimes.length - 1)] ?? '09:00'; const [hours, minutes] = time.split(':'); const scheduledAt = new Date(dayDate); - scheduledAt.setHours(parseInt(hours ?? '12', 10), parseInt(minutes ?? '0', 10), 0, 0); + scheduledAt.setHours(parseInt(hours ?? '9', 10), parseInt(minutes ?? '0', 10), 0, 0); return { user_id: user.id, content_plan_id: plan.id, type: 'post' as const, - content: post.content, + content, original_input: validated.prompt, sphere_id: validated.sphereId ?? null, platform_id: validated.platformId ?? null, @@ -434,8 +419,7 @@ export async function createPlan(data: CreatePlanFormData): Promise ({ role: msg.role as 'user' | 'assistant', @@ -714,11 +701,13 @@ export async function regeneratePlan(data: RegeneratePlanFormData): Promise = {}; - for (let d = 0; d < numberOfDays; d++) { - timesByDay[d] = generateRandomTimes(plan.posts_per_day); - } + // Pre-generate times per day with even distribution + const timesByDay: string[][] = Array.from({ length: numberOfDays }, () => + distributeTimesInRange(plan.posts_per_day), + ); + + const postsToInsert = planPosts.map((content, index) => { + const dayIndex = Math.floor(index / plan.posts_per_day); + const postIndexInDay = index % plan.posts_per_day; - const postsToInsert = planPosts.map((post) => { const dayDate = new Date(planStartDate); - dayDate.setDate(dayDate.getDate() + post.dayIndex); + dayDate.setDate(dayDate.getDate() + dayIndex); - const dayTimes = timesByDay[post.dayIndex] ?? generateRandomTimes(plan.posts_per_day); - const timeIndex = post.timeSlot === 'morning' ? 0 : post.timeSlot === 'afternoon' ? 1 : 2; - const time = dayTimes[Math.min(timeIndex, dayTimes.length - 1)] ?? '12:00'; + const dayTimes = timesByDay[dayIndex] ?? distributeTimesInRange(plan.posts_per_day); + const time = dayTimes[Math.min(postIndexInDay, dayTimes.length - 1)] ?? '09:00'; const [hours, minutes] = time.split(':'); const scheduledAt = new Date(dayDate); - scheduledAt.setHours(parseInt(hours ?? '12', 10), parseInt(minutes ?? '0', 10), 0, 0); + scheduledAt.setHours(parseInt(hours ?? '9', 10), parseInt(minutes ?? '0', 10), 0, 0); return { user_id: user.id, content_plan_id: validated.planId, type: 'post' as const, - content: post.content, + content, sphere_id: plan.sphere_id, platform_id: plan.platform_id, style_id: plan.style_id, @@ -781,8 +771,7 @@ export async function regeneratePlan(data: RegeneratePlanFormData): Promise ({ + mockFrom: vi.fn(), + mockAdminFrom: vi.fn(), + mockAuth: vi.fn(), + mockCreateSignedUrl: vi.fn(), + mockRemove: vi.fn(), + mockRevalidatePath: vi.fn(), +})); + +vi.mock('@/lib/supabase/server', () => ({ + createServerClient: vi.fn().mockResolvedValue({ + from: mockFrom, + auth: { getUser: mockAuth }, + }), + createServiceRoleClient: vi.fn(() => ({ + from: mockAdminFrom, + storage: { + from: vi.fn(() => ({ + createSignedUrl: mockCreateSignedUrl, + remove: mockRemove, + })), + createBucket: vi.fn(), + }, + })), +})); + +vi.mock('next/cache', () => ({ + revalidatePath: mockRevalidatePath, +})); + +import { attachMediaToPost, deletePostMedia } from './media'; + +function createChainMock(result: { data: unknown; error: unknown }): Record> { + const chain: Record> & { then?: unknown } = {}; + + chain.select = vi.fn().mockReturnValue(chain); + chain.update = vi.fn().mockReturnValue(chain); + chain.delete = vi.fn().mockReturnValue(chain); + chain.eq = vi.fn().mockReturnValue(chain); + chain.in = vi.fn().mockReturnValue(chain); + chain.single = vi.fn().mockResolvedValue(result); + chain.then = (resolve: (value: unknown) => unknown) => Promise.resolve(result).then(resolve); + + return chain; +} + +describe('media actions', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } }); + mockCreateSignedUrl.mockResolvedValue({ data: { signedUrl: 'https://example.com/file.png' } }); + mockRemove.mockResolvedValue({ data: null, error: null }); + mockAdminFrom.mockImplementation((table: string) => { + if (table === 'post_media') { + return createChainMock({ data: null, error: null }); + } + + return createChainMock({ data: null, error: null }); + }); + }); + + it('should revalidate the specific plan detail route after attaching media', async () => { + const result = await attachMediaToPost(['media-1'], 'post-1', 'plan-1'); + + expect(result.success).toBe(true); + expect(mockAdminFrom).toHaveBeenCalledWith('post_media'); + expect(mockRevalidatePath).toHaveBeenCalledWith('/app/plan'); + expect(mockRevalidatePath).toHaveBeenCalledWith('/app/plan/plan-1'); + }); + + it('should revalidate the specific plan detail route after deleting media', async () => { + mockFrom.mockImplementation((table: string) => { + if (table === 'post_media') { + return createChainMock({ data: { storage_path: 'user-1/media-1.png' }, error: null }); + } + + return createChainMock({ data: null, error: null }); + }); + + const result = await deletePostMedia('media-1', 'plan-1'); + + expect(result.success).toBe(true); + expect(mockRevalidatePath).toHaveBeenCalledWith('/app/plan'); + expect(mockRevalidatePath).toHaveBeenCalledWith('/app/plan/plan-1'); + }); +}); diff --git a/actions/media.ts b/actions/media.ts index a08d157..81a6e5e 100644 --- a/actions/media.ts +++ b/actions/media.ts @@ -1,5 +1,7 @@ 'use server'; +import { revalidatePath } from 'next/cache'; + import { createServerClient, createServiceRoleClient } from '@/lib/supabase/server'; const MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB @@ -118,7 +120,10 @@ export async function uploadPostMedia(formData: FormData): Promise { +export async function deletePostMedia( + mediaId: string, + planId?: string, +): Promise { const supabase = await createServerClient(); const { data: { user } } = await supabase.auth.getUser(); @@ -142,5 +147,44 @@ export async function deletePostMedia(mediaId: string): Promise { + const supabase = await createServerClient(); + const admin = createServiceRoleClient(); + const { data: { user } } = await supabase.auth.getUser(); + + if (!user) return { success: false, error: 'Unauthorized' }; + + const { error } = await admin + .from('post_media') + .update({ post_id: postId }) + .in('id', mediaIds) + .eq('user_id', user.id); + + if (error) { + return { success: false, error: 'Failed to attach media to post' }; + } + + revalidatePath('/app/plan'); + if (planId) { + revalidatePath(`/app/plan/${planId}`); + } + return { success: true }; } diff --git a/actions/publishing.test.ts b/actions/publishing.test.ts index 01215e3..5c7f613 100644 --- a/actions/publishing.test.ts +++ b/actions/publishing.test.ts @@ -1,8 +1,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -const { mockGetUser, mockFrom, mockRevalidatePath, mockPublishTweet, mockRefreshTwitterToken, mockPublishLinkedInPost, mockRefreshLinkedInToken } = vi.hoisted(() => ({ +const { mockGetUser, mockFrom, mockAdminFrom, mockRevalidatePath, mockPublishTweet, mockRefreshTwitterToken, mockPublishLinkedInPost, mockRefreshLinkedInToken } = vi.hoisted(() => ({ mockGetUser: vi.fn(), mockFrom: vi.fn(), + mockAdminFrom: vi.fn(), mockRevalidatePath: vi.fn(), mockPublishTweet: vi.fn(), mockRefreshTwitterToken: vi.fn(), @@ -15,6 +16,9 @@ vi.mock('@/lib/supabase/server', () => ({ auth: { getUser: mockGetUser }, from: mockFrom, }), + createServiceRoleClient: vi.fn(() => ({ + from: mockAdminFrom, + })), })); vi.mock('next/cache', () => ({ @@ -37,6 +41,7 @@ import { publishPost, schedulePost } from './publishing'; beforeEach(() => { vi.clearAllMocks(); + mockAdminFrom.mockReturnValue(createMockChain()); }); // --- Helpers --- @@ -834,15 +839,19 @@ describe('schedulePost', () => { }), }; } - if (table === 'post_media') { - return { update: mockMediaUpdate }; - } if (table === 'post_publications') { return { insert: vi.fn().mockResolvedValue({ error: null }) }; } return createMockChain(); }); + mockAdminFrom.mockImplementation((table: string) => { + if (table === 'post_media') { + return { update: mockMediaUpdate }; + } + + return createMockChain(); + }); await schedulePost({ content: 'Post with media', diff --git a/actions/publishing.ts b/actions/publishing.ts index 1da0b0a..1fb2b14 100644 --- a/actions/publishing.ts +++ b/actions/publishing.ts @@ -7,7 +7,7 @@ import { publishToPlatform, downloadMediaBuffer, } from '@/lib/publish-service'; -import { createServerClient } from '@/lib/supabase/server'; +import { createServerClient, createServiceRoleClient } from '@/lib/supabase/server'; import type { IConnectionRow, IMediaBuffer } from '@/lib/publish-service'; @@ -60,6 +60,7 @@ interface IPublishResult { export async function publishPost(data: IPublishInput): Promise { const supabase = await createServerClient(); + const admin = createServiceRoleClient(); const { data: { user } } = await supabase.auth.getUser(); if (!user) { @@ -125,7 +126,7 @@ export async function publishPost(data: IPublishInput): Promise // Link media to post if (postId) { - await supabase + await admin .from('post_media') .update({ post_id: postId }) .in('id', data.mediaIds) @@ -245,6 +246,7 @@ export async function publishPost(data: IPublishInput): Promise export async function schedulePost(data: IScheduleInput): Promise { const supabase = await createServerClient(); + const admin = createServiceRoleClient(); const { data: { user } } = await supabase.auth.getUser(); if (!user) { @@ -301,7 +303,7 @@ export async function schedulePost(data: IScheduleInput): Promise 0) { - await supabase + await admin .from('post_media') .update({ post_id: postId }) .in('id', data.mediaIds) diff --git a/actions/schedules.ts b/actions/schedules.ts index cce00c6..d0903cd 100644 --- a/actions/schedules.ts +++ b/actions/schedules.ts @@ -68,8 +68,13 @@ export async function getScheduledPosts(filters: Partial): Prom query = query.eq('status', 'scheduled'); } else if (filters.status === 'published') { query = query.eq('status', 'published'); + } else if (filters.status === 'draft') { + query = query.eq('status', 'draft'); } // 'failed' is handled post-query since it's a publication-level status + } else { + // By default, exclude draft posts — they belong to unscheduled content plans + query = query.neq('status', 'draft'); } // Apply date range filters diff --git a/app/app/plan/[planId]/loading.tsx b/app/app/plan/[planId]/loading.tsx new file mode 100644 index 0000000..fa3b6c1 --- /dev/null +++ b/app/app/plan/[planId]/loading.tsx @@ -0,0 +1,61 @@ +import { Skeleton } from '@/components/ui/skeleton'; + +function PostSkeleton(): React.ReactElement { + return ( +
+
+ +
+ + + +
+
+ + + + +
+
+
+ ); +} + +export default function PlanDetailLoading(): React.ReactElement { + return ( +
+
+
+ + +
+ +
+ +
+
+ {Array.from({ length: 4 }, (_, dayIndex) => ( +
+
+ + {dayIndex < 3 && } +
+ +
+
+ + +
+ +
+ + +
+
+
+ ))} +
+
+
+ ); +} diff --git a/app/app/plan/[planId]/page.tsx b/app/app/plan/[planId]/page.tsx index 20e0667..bb3b270 100644 --- a/app/app/plan/[planId]/page.tsx +++ b/app/app/plan/[planId]/page.tsx @@ -7,10 +7,14 @@ import { PlanIterationInput } from '@/components/content-plan/plan-iteration-inp import { SchedulePlanButton } from '@/components/content-plan/schedule-plan-button'; import { Button } from '@/components/ui/button'; import { PLAN_SLUGS } from '@/lib/constants'; -import { mapContentPlan, mapPlatformConnection, mapPost, mapTwitterCommunity } from '@/lib/mappers'; -import { createServerClient } from '@/lib/supabase/server'; +import { mapContentPlan, mapPlatformConnection, mapPost, mapPostMedia, mapTwitterCommunity } from '@/lib/mappers'; +import { createServerClient, createServiceRoleClient } from '@/lib/supabase/server'; -import type { IPost, ITwitterCommunity } from '@/types/database'; +import type { IPost, IPostMedia, ITwitterCommunity } from '@/types/database'; + +export interface IPostMediaWithUrl extends IPostMedia { + url: string; +} interface IPlanDetailPageProps { params: Promise<{ planId: string }>; @@ -66,6 +70,36 @@ export default async function PlanDetailPage({ params }: IPlanDetailPageProps): const posts = (postsData ?? []).map((row) => mapPost(row as Record)); + // Fetch all media for posts in this plan + const postIds = posts.map((p) => p.id); + const { data: mediaData } = postIds.length > 0 + ? await supabase + .from('post_media') + .select('*') + .in('post_id', postIds) + .order('display_order', { ascending: true }) + : { data: [] }; + + const allMedia = (mediaData ?? []).map((row) => mapPostMedia(row as Record)); + + // Generate signed URLs for media preview (private bucket, 1 hour expiry) + const admin = createServiceRoleClient(); + const mediaWithUrls: IPostMediaWithUrl[] = await Promise.all( + allMedia.map(async (m) => { + const { data: signedData } = await admin.storage + .from('post-media') + .createSignedUrl(m.storagePath, 3600); + return { ...m, url: signedData?.signedUrl ?? '' }; + }), + ); + + const mediaByPostId: Record = {}; + for (const media of mediaWithUrls) { + if (media.postId) { + (mediaByPostId[media.postId] ??= []).push(media); + } + } + const { data: platformData } = plan.platformId ? await supabase .from('memory_items') @@ -149,6 +183,7 @@ export default async function PlanDetailPage({ params }: IPlanDetailPageProps): dayName={getDayName(dayDate)} date={dayDate} posts={dayPosts} + mediaByPostId={mediaByPostId} planId={plan.id} characterLimit={characterLimit} connections={connections} diff --git a/components/content-plan/plan-day-section.tsx b/components/content-plan/plan-day-section.tsx index 5051889..e83a27a 100644 --- a/components/content-plan/plan-day-section.tsx +++ b/components/content-plan/plan-day-section.tsx @@ -2,12 +2,14 @@ import { PlanPostCard } from './plan-post-card'; +import type { IPostMediaWithUrl } from '@/app/app/plan/[planId]/page'; import type { IPlatformConnection, IPost, ITwitterCommunity } from '@/types/database'; interface IPlanDaySectionProps { dayName: string; date: Date; posts: IPost[]; + mediaByPostId: Record; planId: string; characterLimit?: number; connections: IPlatformConnection[]; @@ -23,6 +25,7 @@ export function PlanDaySection({ dayName, date, posts, + mediaByPostId, planId, characterLimit, connections, @@ -54,6 +57,7 @@ export function PlanDaySection({ ({ + mockRegeneratePlan: vi.fn(), +})); + +vi.mock('@/actions/content-plan', () => ({ + regeneratePlan: mockRegeneratePlan, +})); + +vi.mock('sonner', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); + +import { PlanIterationInput } from './plan-iteration-input'; + +describe('PlanIterationInput', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should show generating indicator while the plan is regenerating', async () => { + let resolvePromise: ((value: { success: boolean; data: { posts: unknown[]; creditsUsed: number } }) => void) | undefined; + mockRegeneratePlan.mockImplementation(() => new Promise((resolve) => { + resolvePromise = resolve; + })); + + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByRole('button', { name: 'Regenerate' })); + await user.type(screen.getByPlaceholderText(/make posts shorter/i), 'Make them sharper'); + await user.click(screen.getByRole('button', { name: 'Submit plan refinement' })); + + expect(screen.getByTestId('generating-indicator')).toBeInTheDocument(); + + await act(async () => { + resolvePromise?.({ success: true, data: { posts: [], creditsUsed: 1 } }); + }); + }); +}); diff --git a/components/content-plan/plan-iteration-input.tsx b/components/content-plan/plan-iteration-input.tsx index e8a2420..05b9dcf 100644 --- a/components/content-plan/plan-iteration-input.tsx +++ b/components/content-plan/plan-iteration-input.tsx @@ -5,6 +5,7 @@ import { useRef, useEffect, useState } from 'react'; import { toast } from 'sonner'; import { regeneratePlan } from '@/actions/content-plan'; +import GeneratingIndicator from '@/components/composer/generating-indicator'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; @@ -15,6 +16,12 @@ interface IPlanIterationInputProps { const MIN_HEIGHT = 44; const MAX_HEIGHT = 120; +const REGENERATING_LABELS = [ + 'Refreshing your content plan...', + 'Rewriting posts with your feedback...', + 'Adjusting the plan to match your notes...', + 'Generating a better version of your plan...', +]; export function PlanIterationInput({ planId, isActive }: IPlanIterationInputProps): React.ReactElement { const [isOpen, setIsOpen] = useState(false); @@ -100,6 +107,7 @@ export function PlanIterationInput({ planId, isActive }: IPlanIterationInputProp )} /> + )} + + ))} + + )} - {/* Action buttons — visible on hover, pinned to the bottom */} -
+
{isDraft && ( + {/* File upload */} + {isDraft && ( + <> + + + + )} +
- +
diff --git a/components/content-plan/plan-iteration-input.tsx b/components/content-plan/plan-iteration-input.tsx index 05b9dcf..e140ef3 100644 --- a/components/content-plan/plan-iteration-input.tsx +++ b/components/content-plan/plan-iteration-input.tsx @@ -86,7 +86,11 @@ export function PlanIterationInput({ planId, isActive }: IPlanIterationInputProp >
- Refine your plan + {isIterating ? ( + + ) : ( + Refine your plan + )} @@ -116,11 +120,6 @@ export function PlanIterationInput({ planId, isActive }: IPlanIterationInputProp
- {isIterating && ( -
- -
- )}
diff --git a/components/content-plan/schedule-plan-button.tsx b/components/content-plan/schedule-plan-button.tsx index 7520c8d..66531ba 100644 --- a/components/content-plan/schedule-plan-button.tsx +++ b/components/content-plan/schedule-plan-button.tsx @@ -2,178 +2,46 @@ import { CalendarCheck } from 'lucide-react'; import { useState } from 'react'; -import { toast } from 'sonner'; -import { schedulePlan } from '@/actions/content-plan'; +import PublishModal from '@/components/publishing/publish-modal'; import { Button } from '@/components/ui/button'; -import { Checkbox } from '@/components/ui/checkbox'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from '@/components/ui/dialog'; -import { Label } from '@/components/ui/label'; -import { Separator } from '@/components/ui/separator'; import type { IPlatformConnection, ITwitterCommunity } from '@/types/database'; +// ─── Props ──────────────────────────────────────────────────────────────────── + interface ISchedulePlanButtonProps { planId: string; connections: IPlatformConnection[]; communities?: ITwitterCommunity[]; } +// ─── Component ──────────────────────────────────────────────────────────────── + export function SchedulePlanButton({ planId, connections, communities = [], }: ISchedulePlanButtonProps): React.ReactElement { const [isOpen, setIsOpen] = useState(false); - const [isScheduling, setIsScheduling] = useState(false); - const [selectedIds, setSelectedIds] = useState>( - new Set(connections.map((c) => c.id)), - ); - const [selectedCommunityIds, setSelectedCommunityIds] = useState>(new Set()); - - const isTwitterSelected = connections.some( - (c) => c.platform === 'twitter' && selectedIds.has(c.id), - ); - - function handleToggle(connectionId: string): void { - const next = new Set(selectedIds); - if (next.has(connectionId)) { - next.delete(connectionId); - } else { - next.add(connectionId); - } - setSelectedIds(next); - } - - function handleCommunityToggle(communityId: string): void { - const next = new Set(selectedCommunityIds); - if (next.has(communityId)) { - next.delete(communityId); - } else { - next.add(communityId); - } - setSelectedCommunityIds(next); - } - - async function handleSchedule(): Promise { - if (selectedIds.size === 0) { - toast.error('Select at least one platform'); - - return; - } - - setIsScheduling(true); - - const result = await schedulePlan({ - planId, - platformConnectionIds: Array.from(selectedIds), - communityIds: selectedCommunityIds.size > 0 ? Array.from(selectedCommunityIds) : undefined, - }); - - if (result.success) { - toast.success(`Scheduled ${result.data?.scheduledCount ?? 0} posts`); - setIsOpen(false); - } else { - toast.error(result.error ?? 'Failed to schedule'); - } - - setIsScheduling(false); - } return ( - - - - - - - Schedule All Posts - - Select the platforms where you want to publish all draft posts at their scheduled times. - - - -
- {connections.map((connection) => ( -
- handleToggle(connection.id)} - /> - -
- ))} -
- - {connections.length === 0 && ( -

- No platforms connected. Connect your accounts in Settings. -

- )} - - {isTwitterSelected && communities.length > 0 && ( - <> - -
-
- Post to communities - (optional) -
- {communities.map((community) => ( -
- handleCommunityToggle(community.id)} - /> - -
- ))} -
- - )} - - - - - -
-
+ <> + + + setIsOpen(false)} + /> + ); } diff --git a/components/publishing/community-select.test.tsx b/components/publishing/community-select.test.tsx index 27435a2..517d9af 100644 --- a/components/publishing/community-select.test.tsx +++ b/components/publishing/community-select.test.tsx @@ -34,14 +34,21 @@ const mockCommunities: ITwitterCommunity[] = [ }, ]; +const defaultProps = { + communities: mockCommunities, + selectedIds: new Set(), + isTwitterSelected: true, + shareWithFollowers: false, + onToggle: vi.fn(), + onShareWithFollowersChange: vi.fn(), +}; + describe('CommunitySelect', () => { it('should not render when twitter is not selected', () => { const { container } = render( , ); @@ -49,28 +56,14 @@ describe('CommunitySelect', () => { }); it('should render community options when twitter is selected', () => { - render( - , - ); + render(); expect(screen.getByText('Build in Public')).toBeInTheDocument(); expect(screen.getByText('Indie Hackers')).toBeInTheDocument(); }); it('should show label and optional hint', () => { - render( - , - ); + render(); expect(screen.getByText(/post to communities/i)).toBeInTheDocument(); expect(screen.getByText(/optional/i)).toBeInTheDocument(); @@ -79,14 +72,7 @@ describe('CommunitySelect', () => { it('should call onToggle with community id when clicked', async () => { const handleToggle = vi.fn(); - render( - , - ); + render(); await userEvent.click(screen.getByText('Build in Public')); @@ -96,13 +82,12 @@ describe('CommunitySelect', () => { it('should show checked state for selected communities', () => { render( , ); + // community checkboxes only (no share-with-followers visible yet — c1 is checked) const checkboxes = screen.getAllByRole('checkbox'); expect(checkboxes[0]).toBeChecked(); expect(checkboxes[1]).not.toBeChecked(); @@ -111,13 +96,57 @@ describe('CommunitySelect', () => { it('should render empty state when no communities saved', () => { render( , ); expect(screen.getByText(/no communities/i)).toBeInTheDocument(); }); + + it('should show share with followers checkbox when a community is selected', () => { + render( + , + ); + + expect(screen.getByText(/share with followers/i)).toBeInTheDocument(); + }); + + it('should not show share with followers checkbox when no community is selected', () => { + render(); + + expect(screen.queryByText(/share with followers/i)).not.toBeInTheDocument(); + }); + + it('should call onShareWithFollowersChange when share checkbox clicked', async () => { + const handleChange = vi.fn(); + + render( + , + ); + + await userEvent.click(screen.getByText(/share with followers/i)); + + expect(handleChange).toHaveBeenCalledWith(true); + }); + + it('should show share with followers checkbox as checked when enabled', () => { + render( + , + ); + + const shareCheckbox = screen.getByRole('checkbox', { name: /share with followers/i }); + expect(shareCheckbox).toBeChecked(); + }); }); diff --git a/components/publishing/community-select.tsx b/components/publishing/community-select.tsx index 365a18f..344ff1b 100644 --- a/components/publishing/community-select.tsx +++ b/components/publishing/community-select.tsx @@ -10,7 +10,9 @@ interface ICommunitySelectProps { communities: ITwitterCommunity[]; selectedIds: Set; isTwitterSelected: boolean; + shareWithFollowers: boolean; onToggle: (id: string) => void; + onShareWithFollowersChange: (value: boolean) => void; } // ─── Component ──────────────────────────────────────────────────────────────── @@ -19,10 +21,14 @@ export default function CommunitySelect({ communities, selectedIds, isTwitterSelected, + shareWithFollowers, onToggle, + onShareWithFollowersChange, }: ICommunitySelectProps): React.ReactElement | null { if (!isTwitterSelected) return null; + const hasSelectedCommunities = selectedIds.size > 0; + return (
@@ -64,6 +70,25 @@ export default function CommunitySelect({ ))}
)} + + {hasSelectedCommunities && ( + + )}
); } diff --git a/components/publishing/publish-modal.test.tsx b/components/publishing/publish-modal.test.tsx index 567284b..b773479 100644 --- a/components/publishing/publish-modal.test.tsx +++ b/components/publishing/publish-modal.test.tsx @@ -3,21 +3,27 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import PublishModal from './publish-modal'; -import type { IPlatformConnection } from '@/types/database'; +import type { IPlatformConnection, ITwitterCommunity } from '@/types/database'; -const { mockPublishPost, mockUploadPostMedia } = vi.hoisted(() => ({ +const { mockPublishPost, mockUploadPostMedia, mockSchedulePlan } = vi.hoisted(() => ({ mockPublishPost: vi.fn(), mockUploadPostMedia: vi.fn(), + mockSchedulePlan: vi.fn(), })); vi.mock('@/actions/publishing', () => ({ publishPost: mockPublishPost, + schedulePost: vi.fn(), })); vi.mock('@/actions/media', () => ({ uploadPostMedia: mockUploadPostMedia, })); +vi.mock('@/actions/content-plan', () => ({ + schedulePlan: mockSchedulePlan, +})); + vi.mock('sonner', () => ({ toast: { success: vi.fn(), @@ -58,6 +64,20 @@ const LINKEDIN_CONNECTION: IPlatformConnection = { updatedAt: '2025-01-01', }; +const TWITTER_COMMUNITY: ITwitterCommunity = { + id: 'comm-1', + userId: 'user-1', + communityId: 'tc-111', + name: 'Build in Public', + description: null, + memberCount: 5000, + bannerUrl: null, + joinPolicy: 'Open', + isNsfw: false, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', +}; + beforeEach(() => { vi.clearAllMocks(); }); @@ -139,7 +159,6 @@ describe('PublishModal', () => { />, ); - // Uncheck the pre-selected Twitter checkbox await user.click(screen.getAllByRole('checkbox')[0]); const publishButton = screen.getByRole('button', { name: /publish now/i }); @@ -175,11 +194,13 @@ describe('PublishModal', () => { await user.click(publishButton); await waitFor(() => { - expect(mockPublishPost).toHaveBeenCalledWith({ - content: 'Hello world', - postId: undefined, - platformConnectionIds: [TWITTER_CONNECTION.id], - }); + expect(mockPublishPost).toHaveBeenCalledWith( + expect.objectContaining({ + content: 'Hello world', + postId: undefined, + platformConnectionIds: [TWITTER_CONNECTION.id], + }), + ); }); await waitFor(() => { @@ -236,4 +257,155 @@ describe('PublishModal', () => { ); }); }); + + it('should pass shareWithFollowers to publishPost when community selected and checkbox checked', async () => { + const user = userEvent.setup(); + + mockPublishPost.mockResolvedValue({ + success: true, + results: [{ platform: 'twitter', success: true }], + }); + + render( + , + ); + + // Select the community + await user.click(screen.getByText('Build in Public')); + + // Wait for share with followers checkbox to appear, then check it + const shareLabel = await screen.findByText(/share with followers/i); + await user.click(shareLabel); + + await user.click(screen.getByRole('button', { name: /publish now/i })); + + await waitFor(() => { + expect(mockPublishPost).toHaveBeenCalledWith( + expect.objectContaining({ + communityIds: ['comm-1'], + shareWithFollowers: true, + }), + ); + }); + }); + + it('should not pass shareWithFollowers when no community selected', async () => { + const user = userEvent.setup(); + + mockPublishPost.mockResolvedValue({ + success: true, + results: [{ platform: 'twitter', success: true }], + }); + + render( + , + ); + + await user.click(screen.getByRole('button', { name: /publish now/i })); + + await waitFor(() => { + expect(mockPublishPost).toHaveBeenCalledWith( + expect.objectContaining({ + communityIds: undefined, + shareWithFollowers: undefined, + }), + ); + }); + }); + + describe('schedulePlan mode', () => { + it('should show "Schedule All Posts" title in schedulePlan mode', () => { + render( + , + ); + + expect(screen.getByText('Schedule All Posts')).toBeInTheDocument(); + }); + + it('should hide post preview in schedulePlan mode', () => { + render( + , + ); + + expect(screen.queryByText('some content')).not.toBeInTheDocument(); + }); + + it('should hide schedule toggle in schedulePlan mode', () => { + render( + , + ); + + expect(screen.queryByText(/schedule for later/i)).not.toBeInTheDocument(); + }); + + it('should call schedulePlan when Schedule Plan button clicked', async () => { + const user = userEvent.setup(); + + mockSchedulePlan.mockResolvedValue({ + success: true, + data: { scheduledCount: 3 }, + }); + + render( + , + ); + + await user.click(screen.getByRole('button', { name: /schedule plan/i })); + + await waitFor(() => { + expect(mockSchedulePlan).toHaveBeenCalledWith( + expect.objectContaining({ + planId: 'plan-1', + platformConnectionIds: [TWITTER_CONNECTION.id], + }), + ); + }); + }); + }); }); diff --git a/components/publishing/publish-modal.tsx b/components/publishing/publish-modal.tsx index 3bd247d..42e570b 100644 --- a/components/publishing/publish-modal.tsx +++ b/components/publishing/publish-modal.tsx @@ -7,6 +7,7 @@ import { toast } from 'sonner'; import { uploadPostMedia } from '@/actions/media'; import { publishPost, schedulePost } from '@/actions/publishing'; +import { schedulePlan } from '@/actions/content-plan'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -39,9 +40,11 @@ const KNOWN_PLATFORMS = [ interface IPublishModalProps { content: string; postId?: string; + planId?: string; pendingFiles?: File[]; connections: IPlatformConnection[]; communities?: ITwitterCommunity[]; + mode?: 'publish' | 'schedulePlan'; isOpen: boolean; onOpenChange: (open: boolean) => void; onPublished: () => void; @@ -52,9 +55,11 @@ interface IPublishModalProps { export default function PublishModal({ content, postId, + planId, pendingFiles, connections, communities = [], + mode = 'publish', isOpen, onOpenChange, onPublished, @@ -65,6 +70,7 @@ export default function PublishModal({ return new Set(connectedIds); }); const [selectedCommunityIds, setSelectedCommunityIds] = useState>(new Set()); + const [shareWithFollowers, setShareWithFollowers] = useState(false); const [isPublishing, setIsPublishing] = useState(false); const [isScheduling, setIsScheduling] = useState(false); const [scheduleDate, setScheduleDate] = useState(getTomorrowDate); @@ -75,6 +81,7 @@ export default function PublishModal({ : content; const hasSelection = selectedIds.size > 0; + const isSchedulePlanMode = mode === 'schedulePlan'; const isTwitterSelected = connections.some( (c) => c.platform === 'twitter' && selectedIds.has(c.id), @@ -108,11 +115,39 @@ export default function PublishModal({ }); } + async function handleSchedulePlan(): Promise { + if (!planId) return; + + setIsPublishing(true); + + try { + const result = await schedulePlan({ + planId, + platformConnectionIds: Array.from(selectedIds), + communityIds: selectedCommunityIds.size > 0 ? Array.from(selectedCommunityIds) : undefined, + shareWithFollowers: shareWithFollowers || undefined, + }); + + if (result.success) { + toast.success(`Scheduled ${result.data?.scheduledCount ?? 0} posts`); + onPublished(); + onOpenChange(false); + } else { + toast.error(result.error ?? 'Scheduling failed'); + } + } catch (error) { + if (error instanceof Error) { + toast.error(error.message); + } + } finally { + setIsPublishing(false); + } + } + async function handlePublish(): Promise { setIsPublishing(true); try { - // Upload pending files to storage first let mediaIds: string[] | undefined; if (pendingFiles && pendingFiles.length > 0) { @@ -134,7 +169,6 @@ export default function PublishModal({ } if (isScheduling) { - // Schedule the post const scheduledAt = `${scheduleDate}T${scheduleTime}:00`; const result = await schedulePost({ @@ -144,6 +178,7 @@ export default function PublishModal({ communityIds: selectedCommunityIds.size > 0 ? Array.from(selectedCommunityIds) : undefined, mediaIds, scheduledAt, + shareWithFollowers: shareWithFollowers || undefined, }); if (result.success) { @@ -160,13 +195,13 @@ export default function PublishModal({ toast.error(result.error ?? 'Scheduling failed'); } } else { - // Publish immediately const result = await publishPost({ content, postId, platformConnectionIds: Array.from(selectedIds), communityIds: selectedCommunityIds.size > 0 ? Array.from(selectedCommunityIds) : undefined, mediaIds, + shareWithFollowers: shareWithFollowers || undefined, }); if (result.success) { @@ -201,20 +236,38 @@ export default function PublishModal({ } } + function handleAction(): Promise { + if (isSchedulePlanMode) { + return handleSchedulePlan(); + } + + return handlePublish(); + } + + const actionLabel = isSchedulePlanMode ? 'Schedule Plan' : isScheduling ? 'Schedule' : 'Publish Now'; + const loadingLabel = isSchedulePlanMode ? 'Scheduling...' : isScheduling ? 'Scheduling...' : 'Publishing...'; + const actionIcon = isSchedulePlanMode || isScheduling ? : ; + return ( - Publish to platforms + + {isSchedulePlanMode ? 'Schedule All Posts' : 'Publish to platforms'} + - Select the platforms you want to publish this post to. + {isSchedulePlanMode + ? 'Select the platforms where you want to publish all draft posts at their scheduled times.' + : 'Select the platforms you want to publish this post to.'} - {/* Post preview */} -
-

{preview}

-
+ {/* Post preview — only in publish mode */} + {!isSchedulePlanMode && ( +
+

{preview}

+
+ )} {/* Platform list */}
@@ -245,29 +298,34 @@ export default function PublishModal({ communities={communities} selectedIds={selectedCommunityIds} isTwitterSelected={isTwitterSelected} + shareWithFollowers={shareWithFollowers} onToggle={handleCommunityToggle} + onShareWithFollowersChange={setShareWithFollowers} /> - {/* Schedule toggle */} -
- - -
- - {/* Schedule picker */} - {isScheduling && ( - + {/* Schedule toggle — only in publish mode */} + {!isSchedulePlanMode && ( + <> +
+ + +
+ + {isScheduling && ( + + )} + )} @@ -279,23 +337,18 @@ export default function PublishModal({ Cancel diff --git a/components/schedules/schedule-filters.tsx b/components/schedules/schedule-filters.tsx index 1473a31..7762f1a 100644 --- a/components/schedules/schedule-filters.tsx +++ b/components/schedules/schedule-filters.tsx @@ -77,7 +77,12 @@ export default function ScheduleFilters(): React.ReactElement { } const hasActiveFilters = platform !== 'all' || status !== 'all' || dateFrom !== '' || dateTo !== ''; - const dateFromDisplay = dateFrom ? format(new Date(`${dateFrom}T00:00:00`), 'MMM d') : 'From'; + const isImplicitToday = !searchParams.has('dateFrom'); + const dateFromDisplay = dateFrom + ? format(new Date(`${dateFrom}T00:00:00`), 'MMM d') + : isImplicitToday + ? 'Today' + : 'From'; const dateToDisplay = dateTo ? format(new Date(`${dateTo}T00:00:00`), 'MMM d') : 'To'; return ( @@ -118,7 +123,7 @@ export default function ScheduleFilters(): React.ReactElement {