Przeglądaj źródła

Auto Date/Time Scheduling

Benjamin Harris 1 miesiąc temu
rodzic
commit
8b7d59142f

+ 1 - 1
.env.example

@@ -54,4 +54,4 @@ BLUESKY_APP_PASSWORD=
 REDDIT_CLIENT_ID=
 REDDIT_CLIENT_SECRET=
 REDDIT_USERNAME=
-REDDIT_PASSWORD=
+REDDIT_PASSWORD=

+ 82 - 0
services/gateway/server.js

@@ -771,6 +771,88 @@ app.get('/credentials', async () => {
   };
 });
 
+// ─── Schedule Suggestions ────────────────────────────────────────────────────
+
+// [dayOfWeek (0=Sun), hourUTC] pairs — research-based best-practice defaults
+const INDUSTRY_DEFAULTS = {
+  facebook:  [[2,9],[3,9],[4,9],[2,12],[4,10]],
+  instagram: [[1,11],[2,11],[3,11],[2,14],[3,14]],
+  twitter:   [[2,9],[3,9],[4,9],[2,12],[3,12]],
+  linkedin:  [[2,8],[3,8],[4,8],[3,12],[4,12]],
+  mastodon:  [[2,10],[3,10],[4,10],[1,11],[2,11]],
+  bluesky:   [[1,10],[2,10],[3,10],[1,11],[2,11]],
+  reddit:    [[1,7],[2,7],[3,7],[4,7],[0,9]],
+  youtube:   [[4,12],[5,12],[6,12],[4,15],[5,15]],
+};
+const DEFAULT_SLOTS = [[2,9],[3,9],[4,9],[2,12],[3,12]];
+
+// Returns the next UTC Date that falls on `dayOfWeek` at `hourUTC`:00,
+// at least `afterMs` milliseconds in the future.
+function nextOccurrence(dayOfWeek, hourUTC, afterMs) {
+  const candidate = new Date(afterMs);
+  candidate.setUTCHours(hourUTC, 0, 0, 0);
+
+  const daysAhead = (dayOfWeek - candidate.getUTCDay() + 7) % 7;
+  if (daysAhead === 0 && candidate.getTime() <= afterMs) {
+    candidate.setUTCDate(candidate.getUTCDate() + 7);
+  } else {
+    candidate.setUTCDate(candidate.getUTCDate() + daysAhead);
+  }
+  return candidate;
+}
+
+const DAY_ABBR = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
+
+app.get('/schedule/suggestions', async (request, reply) => {
+  const { platform, accountId } = request.query;
+  if (!platform) return reply.code(400).send({ error: 'platform is required' });
+
+  const db = await getDb();
+  const query = { platform, ...(accountId && { accountId }) };
+  const dataPoints = await db.collection('post_metrics').countDocuments(query);
+
+  let slots;
+  let source;
+
+  if (dataPoints >= 10) {
+    const agg = await db.collection('post_metrics').aggregate([
+      { $match: query },
+      { $group: {
+        _id: { day: '$dayOfWeek', hour: '$hourOfDay' },
+        avgEngagement: { $avg: '$metrics.engagementTotal' },
+        count: { $sum: 1 },
+      }},
+      { $sort: { avgEngagement: -1 } },
+      { $limit: 5 },
+    ]).toArray();
+    slots = agg.map((r) => [r._id.day, r._id.hour]);
+    source = 'history';
+  } else {
+    slots = INDUSTRY_DEFAULTS[platform] || DEFAULT_SLOTS;
+    source = 'default';
+  }
+
+  // 30-minute lead time so the user has time to finish writing
+  const afterMs = Date.now() + 30 * 60 * 1000;
+  const suggestions = slots
+    .map(([day, hour]) => {
+      const dt = nextOccurrence(day, hour, afterMs);
+      const h12 = hour % 12 || 12;
+      const ampm = hour < 12 ? 'am' : 'pm';
+      return {
+        utc: dt.toISOString(),
+        dayOfWeek: day,
+        hour,
+        label: `${DAY_ABBR[day]} ${h12}${ampm}`,
+      };
+    })
+    .sort((a, b) => new Date(a.utc) - new Date(b.utc))
+    .slice(0, 4);
+
+  app.log.info({ action: 'schedule_suggestions', platform, source, count: suggestions.length });
+  return { source, suggestions };
+});
+
 // ─── Analytics Metrics Crawl ─────────────────────────────────────────────────
 
 async function crawlFacebookMetrics(db) {

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

@@ -136,6 +136,11 @@ export default {
     savingDraft: 'Saving…',
     draftSaved: 'Draft saved.',
 
+    suggestedTimes: 'Suggested times',
+    suggestionsFromHistory: 'based on your history',
+    suggestionsFromDefaults: 'industry best practices',
+    suggestionsLoading: 'Loading suggestions…',
+
     hashtagSuggestions: 'Suggested hashtags',
     hashtagsLoading: 'Suggesting…',
     hashtagsRefresh: 'Refresh',

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

@@ -136,6 +136,11 @@ export default {
     savingDraft: 'Kaydediliyor…',
     draftSaved: 'Taslak kaydedildi.',
 
+    suggestedTimes: 'Önerilen zamanlar',
+    suggestionsFromHistory: 'geçmişinize göre',
+    suggestionsFromDefaults: 'sektör en iyi uygulamaları',
+    suggestionsLoading: 'Öneriler yükleniyor…',
+
     hashtagSuggestions: 'Önerilen hashtagler',
     hashtagsLoading: 'Öneriliyor…',
     hashtagsRefresh: 'Yenile',

+ 16 - 0
ui/src/utils/timezone.ts

@@ -113,6 +113,22 @@ export function naiveDatetimeToUtc(localStr: string, timezone: string): string {
   return guess.toISOString()
 }
 
+// Convert a UTC ISO string to a naive "YYYY-MM-DDTHH:MM" string in the given timezone.
+// This is the inverse of naiveDatetimeToUtc — used to fill datetime-local inputs from UTC suggestions.
+export function utcToNaiveDatetimeString(utcIso: string, timezone: string): string {
+  const date = new Date(utcIso)
+  const fmt = new Intl.DateTimeFormat('en-CA', {
+    timeZone: timezone || 'UTC',
+    year: 'numeric', month: '2-digit', day: '2-digit',
+    hour: '2-digit', minute: '2-digit',
+    hour12: false,
+  })
+  const parts = fmt.formatToParts(date)
+  const get = (type: string) => parts.find((p) => p.type === type)?.value ?? '00'
+  const hour = get('hour') === '24' ? '00' : get('hour')
+  return `${get('year')}-${get('month')}-${get('day')}T${hour}:${get('minute')}`
+}
+
 // Return the short timezone abbreviation shown in the UI badge.
 export function getTimezoneAbbr(timezone: string): string {
   try {

+ 67 - 1
ui/src/views/Compose.vue

@@ -334,6 +334,22 @@
               class="text-gray-600 hover:text-gray-400 text-xs flex-shrink-0"
             >✕</button>
           </div>
+          <!-- Suggested times strip -->
+          <div v-if="suggestionsLoading || suggestions.length" class="flex items-start gap-2 flex-wrap">
+            <span class="text-xs text-gray-500 shrink-0 mt-0.5">{{ $t('compose.suggestedTimes') }}</span>
+            <span v-if="suggestionsLoading" class="text-xs text-gray-600 animate-pulse">{{ $t('compose.suggestionsLoading') }}</span>
+            <template v-else>
+              <button
+                v-for="s in suggestions"
+                :key="s.utc"
+                @click="applySuggestion(s)"
+                class="text-xs px-2.5 py-0.5 rounded-full border border-amber-700/60 text-amber-300 hover:bg-amber-900/30 transition-colors"
+                :class="{ 'opacity-50 ring-1 ring-amber-500': composeStore.scheduledAt === utcToNaiveDatetimeString(s.utc, scheduleTimezone) }"
+              >{{ formatSuggestionChip(s) }}</button>
+              <span class="text-xs text-gray-600 self-center">— {{ $t(suggestionsSource === 'history' ? 'compose.suggestionsFromHistory' : 'compose.suggestionsFromDefaults') }}</span>
+            </template>
+          </div>
+
           <!-- Row 2: actions -->
           <div class="flex items-center justify-end gap-2">
             <button
@@ -395,7 +411,7 @@ import { useComposeStore } from '../stores/compose'
 import { usePlatformsStore } from '../stores/platforms'
 import { useAiStore } from '../stores/ai'
 import PostPreview from '../components/compose/PostPreview.vue'
-import { COMMON_TIMEZONES, getBrowserTimezone, getTimezoneAbbr } from '../utils/timezone'
+import { COMMON_TIMEZONES, getBrowserTimezone, getTimezoneAbbr, utcToNaiveDatetimeString } from '../utils/timezone'
 
 const { t } = useI18n()
 const composeStore = useComposeStore()
@@ -512,6 +528,56 @@ function removeMedia() {
 const scheduleTimezone = ref(getBrowserTimezone())
 const timezoneAbbr = computed(() => getTimezoneAbbr(scheduleTimezone.value))
 
+// ─── Schedule Suggestions ────────────────────────────────────────────────────
+
+interface Suggestion { utc: string; dayOfWeek: number; hour: number; label: string }
+
+const suggestions     = ref<Suggestion[]>([])
+const suggestionsSource = ref<'history' | 'default' | ''>('')
+const suggestionsLoading = ref(false)
+
+async function loadSuggestions() {
+  const first = composeStore.selectedDestinations[0]
+  if (!first) { suggestions.value = []; return }
+
+  suggestionsLoading.value = true
+  try {
+    const params: Record<string, string> = { platform: first.platform }
+    if (first.accountId) params.accountId = first.accountId
+    const res = await axios.get('/api/schedule/suggestions', { params })
+    suggestions.value = res.data.suggestions ?? []
+    suggestionsSource.value = res.data.source ?? ''
+  } catch {
+    suggestions.value = []
+  } finally {
+    suggestionsLoading.value = false
+  }
+}
+
+function applySuggestion(s: Suggestion) {
+  composeStore.scheduledAt = utcToNaiveDatetimeString(s.utc, scheduleTimezone.value)
+}
+
+function formatSuggestionChip(s: Suggestion): string {
+  return new Intl.DateTimeFormat(undefined, {
+    weekday: 'short', month: 'short', day: 'numeric',
+    hour: 'numeric', minute: '2-digit',
+    timeZone: scheduleTimezone.value,
+    hour12: true,
+  }).format(new Date(s.utc))
+}
+
+// Reload suggestions when the selected platform changes (debounced to avoid
+// multiple requests when selecting several destinations quickly)
+let suggestionTimer: ReturnType<typeof setTimeout> | null = null
+watch(
+  () => composeStore.selectedDestinations[0]?.key,
+  () => {
+    if (suggestionTimer) clearTimeout(suggestionTimer)
+    suggestionTimer = setTimeout(loadSuggestions, 300)
+  }
+)
+
 // Auto-populate timezone from the first selected destination's profile.
 watch(
   () => composeStore.selectedDestinations[0]?.key,