소스 검색

Content calendar: configurable posts-per-week, suggested times, and scheduled drafts

- Posts-per-week stepper (1–7, default 3) controls how many posts AI generates
  per platform; covers all 4 weeks of the month (~postsPerWeek * 4 per platform,
  capped at 60 total)
- AI prompt now includes suggestedTime (HH:MM) for each post; gateway normalises
  to 09:00 fallback if model omits or malforms the field
- Post cards show the suggested time alongside the day label
- computeScheduledDate() converts week + suggestedDay + suggestedTime + month into
  a naive datetime string used by saveAllDrafts() and draftPost()
- "Save as Scheduled Drafts" stores a proper scheduledAt on each draft so they
  appear in the Scheduler calendar on the right day/time
- "Draft this post" also pre-populates composeStore.scheduledAt before navigating
  to Compose
- CSV export gains a Suggested Time column

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Benjamin Harris 3 주 전
부모
커밋
30955a6145
4개의 변경된 파일117개의 추가작업 그리고 44개의 파일을 삭제
  1. 36 12
      services/gateway/server.js
  2. 5 1
      ui/src/locales/en.ts
  3. 4 0
      ui/src/locales/tr.ts
  4. 72 31
      ui/src/views/CalendarPlan.vue

+ 36 - 12
services/gateway/server.js

@@ -1423,10 +1423,10 @@ Return ONLY valid JSON.`;
 });
 
 // POST /ai/content-calendar — generate a monthly content plan with narrative brief + sample posts
-// Body: { accountKey?, platforms[], month? (YYYY-MM), approvedBrief? }
+// Body: { accountKey?, platforms[], month? (YYYY-MM), approvedBrief?, postsPerWeek? }
 app.post('/ai/content-calendar', async (request, reply) => {
   const ws = request.workspaceId;
-  const { accountKey, platforms = [], month, approvedBrief } = request.body || {};
+  const { accountKey, platforms = [], month, approvedBrief, postsPerWeek: rawPpw } = request.body || {};
   if (!platforms.length) return reply.code(400).send({ error: 'Select at least one platform' });
 
   const db = await getDb();
@@ -1434,6 +1434,9 @@ app.post('/ai/content-calendar', async (request, reply) => {
   const [year, mon] = calMonth.split('-');
   const monthName = new Date(`${calMonth}-01`).toLocaleString('en', { month: 'long', year: 'numeric' });
 
+  // Validate postsPerWeek: 1–7, default 3
+  const postsPerWeek = Math.min(7, Math.max(1, parseInt(rawPpw) || 3));
+
   // Load account profile for context
   const profileKey = accountKey || null;
   const profile = profileKey
@@ -1454,13 +1457,15 @@ app.post('/ai/content-calendar', async (request, reply) => {
 
   const activePlatforms = platforms.slice(0, 5);
   const platformList = activePlatforms.join(', ');
-  const postsPerPlatform = 2;
-  const totalPosts = activePlatforms.length * postsPerPlatform;
+  // Cap total posts to 60 to keep response size manageable
+  const postsPerPlatform = postsPerWeek * 4;
+  const totalPosts = Math.min(activePlatforms.length * postsPerPlatform, 60);
+  const cappedPerPlatform = Math.ceil(totalPosts / activePlatforms.length);
 
   const system = 'You are a social media content strategist. Return ONLY a valid JSON object — no markdown, no explanation, no code fences.';
 
   const platformNoteKeys = activePlatforms.map((p) => `"${p}"`).join(', ');
-  const postSchema = `{ "platform": "<one of: ${platformList}>", "week": <1 or 2>, "content": "<ready-to-publish post>", "hashtags": ["<tag>"], "postType": "<educational|promotional|engagement|storytelling>", "suggestedDay": "<day>" }`;
+  const postSchema = `{ "platform": "<one of: ${platformList}>", "week": <1-4>, "suggestedDay": "<Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday>", "suggestedTime": "<HH:MM in 24h>", "content": "<ready-to-publish post>", "hashtags": ["<tag>"], "postType": "<educational|promotional|engagement|storytelling>" }`;
 
   // If an approved brief was passed, use it directly — only generate posts
   const briefSection = approvedBrief
@@ -1476,14 +1481,25 @@ Generate posts that faithfully follow this approved brief.`
 
 ${briefSection}
 Create a ${monthName} content calendar for: ${platformList}.
-Generate ${postsPerPlatform} posts per platform (${totalPosts} posts total across weeks 1 and 2).
-
-Platform conventions to follow:
-- LinkedIn: professional hook in first line, insights, 1300-char max
+Generate exactly ${postsPerWeek} posts per platform per week, covering all 4 weeks (${cappedPerPlatform} posts per platform, ${totalPosts} posts total).
+Distribute evenly: week 1 = days 1–7, week 2 = days 8–14, week 3 = days 15–21, week 4 = days 22–28.
+
+For "suggestedTime" use these platform-native peak times (24-hour HH:MM):
+- LinkedIn: 08:00, 09:00, 10:00, or 17:00 on weekdays
+- Instagram: 07:00, 12:00, 19:00, or 20:00
+- Facebook: 09:00, 12:00, or 15:00
+- Twitter/X: 08:00, 12:00, or 17:00
+- TikTok: 07:00, 19:00, 20:00, or 21:00
+- Pinterest: 14:00, 20:00, or 21:00
+- YouTube: 14:00 or 15:00, prefer Thursday–Saturday
+- Mastodon/Bluesky: 09:00 or 17:00
+
+Platform content conventions:
+- LinkedIn: professional hook first line, insights, 1300-char max
 - Instagram: visual-first, emojis ok, 2200-char max, 5-10 hashtags
 - Facebook: conversational, question or story, 500-char ideal
-- Twitter: concise, punchy, under 280 chars
-- TikTok: caption hook in first 3 words, trending angle
+- Twitter/X: concise, punchy, under 280 chars
+- TikTok: hook in first 3 words, trending angle, keep under 150 chars
 - Pinterest: keyword-rich description, action-oriented
 - Mastodon/Bluesky: authentic, community-focused, 300/500 chars
 
@@ -1535,7 +1551,14 @@ Return ONLY the JSON object.`;
       if (!calendar.brief || !Array.isArray(calendar.posts)) throw new Error('Missing brief or posts array');
       if (!Array.isArray(calendar.brief.pillars)) calendar.brief.pillars = [];
       if (typeof calendar.brief.theme !== 'string') calendar.brief.theme = '';
-      calendar.posts = calendar.posts.filter((p) => p && typeof p.content === 'string').slice(0, totalPosts);
+      // Normalise suggestedTime: ensure HH:MM format, default 09:00
+      calendar.posts = calendar.posts
+        .filter((p) => p && typeof p.content === 'string')
+        .slice(0, totalPosts)
+        .map((p) => ({
+          ...p,
+          suggestedTime: /^\d{2}:\d{2}$/.test(p.suggestedTime || '') ? p.suggestedTime : '09:00',
+        }));
       if (calendar.posts.length === 0) throw new Error('No valid posts in response');
     } catch (parseErr) {
       log.warn({ action: 'content_calendar', outcome: 'parse_failure', err: parseErr.message });
@@ -1547,6 +1570,7 @@ Return ONLY the JSON object.`;
       month: calMonth,
       monthName,
       platforms,
+      postsPerWeek,
       brief: calendar.brief,
       posts: calendar.posts,
       workspaceId: ws,

+ 5 - 1
ui/src/locales/en.ts

@@ -125,11 +125,15 @@ export default {
     theme: 'Monthly theme',
     pillars: 'Content pillars',
     toneGuidance: 'Tone guidance',
-    saveAllDrafts: 'Save {count} posts as Drafts',
+    postsPerWeek: 'Posts per week',
+    postsPerWeekHint: '≈ {total} posts per platform · {grand} total',
+    saveAllDrafts: 'Save {count} as Drafts',
+    saveAllDraftsScheduled: 'Save {count} as Scheduled Drafts',
     savingAll: 'Saving drafts…',
     draft: 'Draft',
     week: 'Week {n}',
     exportCsv: 'Export CSV',
+    at: 'at',
   },
 
   media: {

+ 4 - 0
ui/src/locales/tr.ts

@@ -125,11 +125,15 @@ export default {
     theme: 'Aylık tema',
     pillars: 'İçerik sütunları',
     toneGuidance: 'Ton rehberi',
+    postsPerWeek: 'Haftada gönderi',
+    postsPerWeekHint: '≈ Platform başına {total} gönderi · Toplam {grand}',
     saveAllDrafts: '{count} gönderiyi Taslak olarak kaydet',
+    saveAllDraftsScheduled: '{count} gönderiyi Zamanlanmış Taslak olarak kaydet',
     savingAll: 'Taslaklar kaydediliyor…',
     draft: 'Taslağa al',
     week: '{n}. hafta',
     exportCsv: 'CSV Dışa Aktar',
+    at: 'saat',
   },
 
   media: {

+ 72 - 31
ui/src/views/CalendarPlan.vue

@@ -33,12 +33,51 @@
             </select>
           </div>
 
-          <!-- Step buttons -->
-          <div class="flex items-end gap-2">
+          <!-- Posts per week -->
+          <div>
+            <label class="block text-xs text-gray-400 mb-1.5">{{ $t('calendarPlan.postsPerWeek') }}</label>
+            <div class="flex items-center gap-2">
+              <button
+                @click="postsPerWeek = Math.max(1, postsPerWeek - 1)"
+                class="w-8 h-9 flex items-center justify-center rounded-lg bg-gray-700 hover:bg-gray-600 text-gray-300 text-lg font-medium transition-colors"
+              >−</button>
+              <span class="flex-1 text-center text-sm font-semibold text-white">{{ postsPerWeek }}</span>
+              <button
+                @click="postsPerWeek = Math.min(7, postsPerWeek + 1)"
+                class="w-8 h-9 flex items-center justify-center rounded-lg bg-gray-700 hover:bg-gray-600 text-gray-300 text-lg font-medium transition-colors"
+              >+</button>
+            </div>
+            <p class="text-[11px] text-gray-600 mt-1 text-center">
+              {{ $t('calendarPlan.postsPerWeekHint', { total: postsPerWeek * 4, grand: Math.min(selectedPlatforms.length * postsPerWeek * 4, 60) }) }}
+            </p>
+          </div>
+        </div>
+
+        <!-- Platform checkboxes + action buttons row -->
+        <div class="flex flex-wrap items-end gap-4">
+          <div class="flex-1 min-w-0">
+            <label class="block text-xs text-gray-400 mb-2">{{ $t('calendarPlan.platforms') }}</label>
+            <div class="flex flex-wrap gap-2">
+              <label
+                v-for="p in PLATFORMS"
+                :key="p.key"
+                class="flex items-center gap-1.5 px-2.5 py-1 rounded-lg border text-xs cursor-pointer transition-colors"
+                :class="selectedPlatforms.includes(p.key)
+                  ? 'text-white border-transparent'
+                  : 'border-gray-700 text-gray-400 hover:border-gray-500'"
+                :style="selectedPlatforms.includes(p.key) ? { background: p.color + '33', borderColor: p.color } : {}"
+              >
+                <input type="checkbox" class="sr-only" :value="p.key" v-model="selectedPlatforms" />
+                <i :class="p.icon" class="text-[11px]"></i>
+                {{ p.label }}
+              </label>
+            </div>
+          </div>
+          <div class="flex gap-2 shrink-0">
             <button
               @click="generateBrief"
               :disabled="briefLoading || loading || !selectedPlatforms.length"
-              class="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-sky-700 hover:bg-sky-600 disabled:opacity-40 rounded-lg text-sm font-medium text-white transition-colors"
+              class="flex items-center justify-center gap-2 px-4 py-2 bg-sky-700 hover:bg-sky-600 disabled:opacity-40 rounded-lg text-sm font-medium text-white transition-colors"
             >
               <i class="fa-solid fa-file-lines text-xs" :class="{ 'animate-pulse': briefLoading }"></i>
               {{ briefLoading ? $t('calendarPlan.generatingBrief') : $t('calendarPlan.generateBrief') }}
@@ -46,33 +85,13 @@
             <button
               @click="generate"
               :disabled="loading || !selectedPlatforms.length"
-              class="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-violet-700 hover:bg-violet-600 disabled:opacity-40 rounded-lg text-sm font-medium text-white transition-colors"
+              class="flex items-center justify-center gap-2 px-4 py-2 bg-violet-700 hover:bg-violet-600 disabled:opacity-40 rounded-lg text-sm font-medium text-white transition-colors"
             >
               <i class="fa-solid fa-calendar-days text-xs" :class="{ 'animate-pulse': loading }"></i>
               {{ loading ? $t('calendarPlan.generating') : $t('calendarPlan.generate') }}
             </button>
           </div>
         </div>
-
-        <!-- Platform checkboxes -->
-        <div>
-          <label class="block text-xs text-gray-400 mb-2">{{ $t('calendarPlan.platforms') }}</label>
-          <div class="flex flex-wrap gap-2">
-            <label
-              v-for="p in PLATFORMS"
-              :key="p.key"
-              class="flex items-center gap-1.5 px-2.5 py-1 rounded-lg border text-xs cursor-pointer transition-colors"
-              :class="selectedPlatforms.includes(p.key)
-                ? 'text-white border-transparent'
-                : 'border-gray-700 text-gray-400 hover:border-gray-500'"
-              :style="selectedPlatforms.includes(p.key) ? { background: p.color + '33', borderColor: p.color } : {}"
-            >
-              <input type="checkbox" class="sr-only" :value="p.key" v-model="selectedPlatforms" />
-              <i :class="p.icon" class="text-[11px]"></i>
-              {{ p.label }}
-            </label>
-          </div>
-        </div>
       </div>
 
       <!-- Brief preview (approval step) -->
@@ -152,7 +171,7 @@
                 class="flex items-center gap-1.5 text-sm px-4 py-2 bg-emerald-700 hover:bg-emerald-600 disabled:opacity-40 rounded-lg text-white transition-colors"
               >
                 <i class="fa-solid fa-floppy-disk text-xs"></i>
-                {{ savingAll ? $t('calendarPlan.savingAll') : $t('calendarPlan.saveAllDrafts', { count: calendar.posts.length }) }}
+                {{ savingAll ? $t('calendarPlan.savingAll') : $t('calendarPlan.saveAllDraftsScheduled', { count: calendar.posts.length }) }}
               </button>
             </div>
           </div>
@@ -204,6 +223,7 @@
                     <div class="flex items-center gap-2 mb-1.5">
                       <span class="text-xs text-gray-500">{{ $t('calendarPlan.week', { n: post.week }) }}</span>
                       <span v-if="post.suggestedDay" class="text-xs text-gray-500">· {{ post.suggestedDay }}</span>
+                      <span v-if="post.suggestedTime" class="text-xs text-gray-500">{{ $t('calendarPlan.at') }} {{ post.suggestedTime }}</span>
                       <span
                         class="text-xs px-1.5 py-0.5 rounded capitalize"
                         :class="{
@@ -220,7 +240,7 @@
                     </div>
                   </div>
                   <button
-                    @click="draftPost(post.content)"
+                    @click="draftPost(post)"
                     class="shrink-0 flex items-center gap-1 text-xs px-2.5 py-1.5 bg-violet-700 hover:bg-violet-600 text-white rounded-lg"
                   >
                     <i class="fa-solid fa-pen-to-square text-[10px]"></i>
@@ -264,6 +284,7 @@ const PLATFORMS = [
 const selectedMonth   = ref(new Date().toISOString().slice(0, 7))
 const selectedAccount = ref('')
 const selectedPlatforms = ref<string[]>(['linkedin', 'instagram'])
+const postsPerWeek = ref(3)
 const loading    = ref(false)
 const savingAll  = ref(false)
 const error      = ref('')
@@ -332,6 +353,7 @@ async function approveAndGenerate() {
       platforms: selectedPlatforms.value,
       month: selectedMonth.value,
       approvedBrief: pendingBrief.value,
+      postsPerWeek: postsPerWeek.value,
     })
     calendar.value = res.data
     pendingBrief.value = null
@@ -352,6 +374,7 @@ async function generate() {
       accountKey: selectedAccount.value || undefined,
       platforms: selectedPlatforms.value,
       month: selectedMonth.value,
+      postsPerWeek: postsPerWeek.value,
     })
     calendar.value = res.data
   } catch (err: any) {
@@ -361,12 +384,24 @@ async function generate() {
   }
 }
 
+function computeScheduledDate(month: string, week: number, suggestedDay: string, suggestedTime: string): string {
+  const DAY_MAP: Record<string, number> = { Sunday: 0, Monday: 1, Tuesday: 2, Wednesday: 3, Thursday: 4, Friday: 5, Saturday: 6 }
+  const firstDay = new Date(month + '-01')
+  const targetDow = DAY_MAP[suggestedDay] ?? 1
+  const firstDow = firstDay.getDay()
+  const offset = (targetDow - firstDow + 7) % 7
+  const d = new Date(firstDay)
+  d.setDate(1 + offset + (week - 1) * 7)
+  const pad = (n: number) => String(n).padStart(2, '0')
+  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${suggestedTime || '09:00'}`
+}
+
 function exportCalendarCsv() {
   if (!calendar.value?.posts?.length) return
   const escape = (v: string) => `"${String(v).replace(/"/g, '""')}"`
-  const header = ['Platform', 'Week', 'Suggested Day', 'Post Type', 'Content', 'Hashtags']
+  const header = ['Platform', 'Week', 'Suggested Day', 'Suggested Time', 'Post Type', 'Content', 'Hashtags']
   const rows = calendar.value.posts.map((p: any) => [
-    p.platform, p.week, p.suggestedDay || '', p.postType || '', escape(p.content), (p.hashtags || []).join(' '),
+    p.platform, p.week, p.suggestedDay || '', p.suggestedTime || '', p.postType || '', escape(p.content), (p.hashtags || []).join(' '),
   ].join(','))
   const csv = '' + [header.join(','), ...rows].join('\r\n')
   const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
@@ -378,8 +413,11 @@ function exportCalendarCsv() {
   URL.revokeObjectURL(url)
 }
 
-function draftPost(content: string) {
-  composeStore.content = content
+function draftPost(post: any) {
+  composeStore.content = post.content
+  if (post.week && post.suggestedDay) {
+    composeStore.scheduledAt = computeScheduledDate(selectedMonth.value, post.week, post.suggestedDay, post.suggestedTime || '09:00')
+  }
   router.push('/compose')
 }
 
@@ -388,10 +426,13 @@ async function saveAllDrafts() {
   savingAll.value = true
   try {
     for (const post of calendar.value.posts) {
+      const scheduledAt = post.week && post.suggestedDay
+        ? computeScheduledDate(calendar.value.month, post.week, post.suggestedDay, post.suggestedTime || '09:00')
+        : ''
       await axios.post('/api/drafts', {
         content: post.content,
         mediaUrl: '',
-        scheduledAt: '',
+        scheduledAt,
         destinations: [],
       })
     }