diff --git a/client/src/components/calendar-view.tsx b/client/src/components/calendar-view.tsx index f0616ca..6c8faaf 100644 --- a/client/src/components/calendar-view.tsx +++ b/client/src/components/calendar-view.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, Trash2, Edit, MoreHorizontal, Eye, Image, Smartphone } from "lucide-react"; +import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, Trash2, Edit, MoreHorizontal, Eye, Image, Smartphone, Clapperboard } from "lucide-react"; import { useQuery, useMutation } from "@tanstack/react-query"; import { useToast } from "@/hooks/use-toast"; import { apiRequest, queryClient } from "@/lib/queryClient"; @@ -126,6 +126,8 @@ export default function CalendarView() { ); + } else if (postType === 'reel') { + return ; } return null; }; diff --git a/client/src/components/datetime-picker.tsx b/client/src/components/datetime-picker.tsx index b036fb0..6e6f188 100644 --- a/client/src/components/datetime-picker.tsx +++ b/client/src/components/datetime-picker.tsx @@ -1,4 +1,5 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; +import type { DayContentProps } from "react-day-picker"; import { Calendar } from "@/components/ui/calendar"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Button } from "@/components/ui/button"; @@ -7,25 +8,32 @@ import { format } from "date-fns"; import { fr } from "date-fns/locale"; import { cn } from "@/lib/utils"; import { ScrollArea } from "@/components/ui/scroll-area"; +import { DayAgendaPanel } from "@/components/day-agenda-panel"; +import { usePublicationAgenda } from "@/hooks/use-publication-agenda"; +import { busyHours, conflictsFor, dayKey, groupByPage, occupiesSlot } from "@shared/agenda"; interface DateTimePickerProps { value: Date | undefined; onChange: (date: Date | undefined) => void; - occupiedDates?: Date[]; + /** Magasins visés : leurs publications déjà prévues sont signalées. */ + selectedPageIds?: string[]; placeholder?: string; } const OPTIMAL_HOURS = [8, 12, 13, 18, 19, 20]; -export function DateTimePicker({ value, onChange, occupiedDates = [], placeholder = "Sélectionner une date" }: DateTimePickerProps) { +export function DateTimePicker({ value, onChange, selectedPageIds = [], placeholder = "Sélectionner une date" }: DateTimePickerProps) { const [selectedDate, setSelectedDate] = useState(value); const [selectedHour, setSelectedHour] = useState(value?.getHours() || 8); const [selectedMinute, setSelectedMinute] = useState(value?.getMinutes() || 0); + const [month, setMonth] = useState(value ?? new Date()); + const { itemsByDay } = usePublicationAgenda(month); // Sync internal state with parent value prop useEffect(() => { if (value) { setSelectedDate(value); + setMonth(value); setSelectedHour(value.getHours()); setSelectedMinute(value.getMinutes()); } else { @@ -63,15 +71,44 @@ export function DateTimePicker({ value, onChange, occupiedDates = [], placeholde } }; - // Convert occupied dates to a Set of date strings for efficient lookup - const occupiedDateStrings = new Set( - occupiedDates.map(date => format(date, 'yyyy-MM-dd')) + // Publications qui occupent encore un créneau, jour par jour + const activeItemsOn = useCallback( + (date: Date) => (itemsByDay.get(dayKey(date)) ?? []).filter(occupiesSlot), + [itemsByDay] ); + const isSelectedStoreBusy = (date: Date) => conflictsFor(activeItemsOn(date), selectedPageIds).length > 0; + const isOtherStoreBusy = (date: Date) => activeItemsOn(date).length > 0 && !isSelectedStoreBusy(date); - // Check if a date is occupied - const isDateOccupied = (date: Date) => { - return occupiedDateStrings.has(format(date, 'yyyy-MM-dd')); - }; + const selectedDayItems = selectedDate ? itemsByDay.get(dayKey(selectedDate)) ?? [] : []; + const hoursTaken = busyHours(selectedDayItems, selectedPageIds); + const selectedKey = selectedPageIds.join(","); + + // Pastille du jour : publications sur les magasins choisis, sinon sur les autres + const AgendaDayContent = useCallback( + ({ date }: DayContentProps) => { + const items = activeItemsOn(date); + const mine = conflictsFor(items, selectedKey ? selectedKey.split(",") : []).length; + const title = groupByPage(items) + .map(({ page, items: pageItems }) => `${page.pageName} : ${pageItems.length}`) + .join("\n"); + return ( + + {date.getDate()} + {items.length > 0 && ( + 0 ? "bg-destructive text-destructive-foreground" : "bg-muted-foreground/70 text-background" + )} + > + {mine || items.length} + + )} + + ); + }, + [activeItemsOn, selectedKey] + ); return (
@@ -95,16 +132,34 @@ export function DateTimePicker({ value, onChange, occupiedDates = [], placeholde mode="single" selected={selectedDate} onSelect={handleDateSelect} + month={month} + onMonthChange={setMonth} locale={fr} modifiers={{ - occupied: (date) => isDateOccupied(date) + selectedStoreBusy: isSelectedStoreBusy, + otherStoreBusy: isOtherStoreBusy, }} modifiersClassNames={{ - occupied: "bg-destructive/15 text-destructive font-bold hover:bg-destructive/25" + selectedStoreBusy: "bg-destructive/15 text-destructive font-bold hover:bg-destructive/25", + otherStoreBusy: "bg-muted font-semibold", }} + components={{ DayContent: AgendaDayContent }} disabled={(date) => date < new Date(new Date().setHours(0, 0, 0, 0))} data-testid="calendar-picker" /> +
+
+ + Déjà prévu sur un magasin sélectionné +
+
+ + Prévu sur d'autres magasins +
+ {selectedPageIds.length === 0 && ( +

Cochez un magasin pour repérer ses jours déjà occupés.

+ )} +
{value && ( @@ -147,7 +202,9 @@ export function DateTimePicker({ value, onChange, occupiedDates = [], placeholde
Heures
- {hours.map((hour) => ( + {hours.map((hour) => { + const taken = hoursTaken.get(hour) ?? 0; + return ( - ))} + ); + })}
@@ -194,20 +260,21 @@ export function DateTimePicker({ value, onChange, occupiedDates = [], placeholde Heures optimales (8h, 12h-13h, 18h-20h)
+ {hoursTaken.size > 0 && ( +
+
+ + Déjà prévu sur vos magasins + +
+ )} )} - {occupiedDates.length > 0 && ( -
-
-
- - Les dates en fond rouge ont déjà des publications programmées - -
-
+ {selectedDate && ( + )} ); diff --git a/client/src/components/day-agenda-panel.tsx b/client/src/components/day-agenda-panel.tsx new file mode 100644 index 0000000..d9b98e7 --- /dev/null +++ b/client/src/components/day-agenda-panel.tsx @@ -0,0 +1,97 @@ +import { format } from "date-fns"; +import { fr } from "date-fns/locale"; +import { AlertTriangle, CalendarCheck, Clapperboard, Image, Layers, Loader2, Smartphone } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { PlatformIcon } from "@/components/platform-icon"; +import { cn } from "@/lib/utils"; +import { conflictsFor, groupByPage, type AgendaItem, type AgendaStatus } from "@shared/agenda"; + +const TYPE_META: Record = { + reel: { label: "Reel", icon: Clapperboard }, + story: { label: "Story", icon: Smartphone }, + both: { label: "Post + Story", icon: Layers }, + feed: { label: "Post", icon: Image }, +}; + +const STATUS_META: Record = { + scheduled: { label: "Programmé", className: "bg-info/15 text-info" }, + rendering: { label: "En montage", className: "bg-warning/15 text-warning" }, + published: { label: "Publié", className: "bg-success/15 text-success" }, + failed: { label: "Échec", className: "bg-destructive/15 text-destructive" }, +}; + +const time = (item: AgendaItem) => format(new Date(item.scheduledAt), "HH:mm"); + +interface DayAgendaPanelProps { + date: Date; + items: AgendaItem[]; + selectedPageIds: string[]; +} + +/** Ce qui est déjà prévu le jour choisi, magasin par magasin. */ +export function DayAgendaPanel({ date, items, selectedPageIds }: DayAgendaPanelProps) { + const dayLabel = format(date, "EEEE d MMMM", { locale: fr }); + const conflicts = conflictsFor(items, selectedPageIds); + const reelConflicts = conflicts.filter((item) => item.postType === "reel"); + + if (items.length === 0) { + return ( +
+ + Rien de prévu le {dayLabel} : la journée est libre sur tous les magasins. +
+ ); + } + + return ( +
+ {conflicts.length > 0 && ( +
+ +
+ {(reelConflicts.length > 0 ? reelConflicts : conflicts).map((item) => ( +

+ {item.pageName} a déjà {reelConflicts.length > 0 ? "un Reel" : "une publication"} à {time(item)} ce jour-là. +

+ ))} +

Vous pouvez quand même programmer, ou choisir un autre jour.

+
+
+ )} + +
+
+ Déjà prévu le {dayLabel} +
+
+ {groupByPage(items, selectedPageIds).map(({ page, selected, items: pageItems }) => ( +
+
+ + {page.pageName} + {selected && Sélectionné} +
+ {pageItems.map((item) => { + const type = TYPE_META[item.postType] ?? TYPE_META.feed; + const status = STATUS_META[item.status]; + const TypeIcon = type.icon; + return ( +
+ {time(item)} + + {type.label} + {item.preview} + + {item.status === "rendering" && } + {status.label} + +
+ ); + })} +
+ ))} +
+
+
+ ); +} diff --git a/client/src/hooks/use-publication-agenda.ts b/client/src/hooks/use-publication-agenda.ts new file mode 100644 index 0000000..652b43e --- /dev/null +++ b/client/src/hooks/use-publication-agenda.ts @@ -0,0 +1,24 @@ +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { addDays, endOfMonth, startOfMonth } from "date-fns"; +import { groupByDay, type AgendaItem } from "@shared/agenda"; + +/** + * Publications prévues autour du mois affiché, rangées par jour. + * + * La clé commence par `/api/scheduled-posts` : toute invalidation des + * publications planifiées (création, suppression, fin de montage) la rafraîchit. + */ +export function usePublicationAgenda(month: Date) { + // Le calendrier montre aussi les jours des mois voisins + const startDate = addDays(startOfMonth(month), -7).toISOString(); + const endDate = addDays(endOfMonth(month), 7).toISOString(); + + const { data: items = [], isLoading } = useQuery({ + queryKey: ["/api/scheduled-posts", "agenda", { startDate, endDate }], + refetchInterval: 30000, + }); + + const itemsByDay = useMemo(() => groupByDay(items), [items]); + return { items, itemsByDay, isLoading }; +} diff --git a/client/src/pages/new-post.tsx b/client/src/pages/new-post.tsx index 906a6a5..acca26a 100644 --- a/client/src/pages/new-post.tsx +++ b/client/src/pages/new-post.tsx @@ -38,7 +38,7 @@ import { Dialog, DialogContent } from "@/components/ui/dialog"; import { useToast } from "@/hooks/use-toast"; import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient"; import { MediaThumbnail } from "@/components/media-thumbnail"; -import type { SocialPage, Media, ScheduledPost } from "@shared/schema"; +import type { SocialPage, Media } from "@shared/schema"; import { PreviewModal } from "@/components/preview-modal"; import { DateTimePicker } from "@/components/datetime-picker"; @@ -110,13 +110,6 @@ function SortableMediaItem({ /** Nombre de médias récents proposés à la sélection. */ const RECENT_MEDIA_COUNT = 12; -/** Minuit, pour que la clé de requête reste stable d'un rendu à l'autre. */ -function startOfToday(): Date { - const date = new Date(); - date.setHours(0, 0, 0, 0); - return date; -} - const STEPS: StepDef[] = [ { id: "media", label: "Médias", icon: ImageIcon }, { id: "text", label: "Texte", icon: Type }, @@ -182,12 +175,6 @@ export default function NewPost() { .slice(0, RECENT_MEDIA_COUNT); }, [allMedia]); - // Ne sert qu'à griser les créneaux déjà pris dans le sélecteur de date : le - // passé n'a aucune influence sur une planification à venir. - const { data: scheduledPosts = [] } = useQuery({ - queryKey: ['/api/scheduled-posts', { startDate: startOfToday().toISOString() }], - }); - const uploadMutation = useMutation({ mutationFn: async (file: File) => { const formData = new FormData(); @@ -845,12 +832,10 @@ export default function NewPost() { post.scheduledAt) - .map(post => new Date(post.scheduledAt!))} + selectedPageIds={selectedPages} placeholder="Choisir une date" /> -

Les créneaux déjà occupés sont signalés dans le calendrier.

+

Les jours et heures déjà occupés sur vos magasins sont signalés dans le calendrier.

)} diff --git a/client/src/pages/new-reel.tsx b/client/src/pages/new-reel.tsx index b922d34..18fa89f 100644 --- a/client/src/pages/new-reel.tsx +++ b/client/src/pages/new-reel.tsx @@ -884,7 +884,7 @@ export default function NewReel() { )} diff --git a/client/src/pages/schedule-reel.tsx b/client/src/pages/schedule-reel.tsx index 113ea6e..5a7d33d 100644 --- a/client/src/pages/schedule-reel.tsx +++ b/client/src/pages/schedule-reel.tsx @@ -270,6 +270,7 @@ export default function ScheduleReel() { {isPast && ( diff --git a/server/routes.ts b/server/routes.ts index 85aa358..aaddc80 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -22,6 +22,7 @@ import { facebookRouter } from "./routes/facebook"; import { legalRouter } from "./routes/legal"; import { createVideoThumbnail } from "./services/thumbnail"; import { insertAudioTrackSchema } from "@shared/schema"; +import { agendaItemFromScheduledPost, agendaItemsFromReelJob, type AgendaItem, type AgendaPage } from "@shared/agenda"; import * as musicMetadata from "music-metadata"; // Types MIME autorisés pour les uploads @@ -1517,6 +1518,47 @@ export async function registerRoutes(app: Express): Promise { } }); + // Agenda : ce qui est déjà prévu par jour et par magasin, Reels en montage compris. + // Sous /api/scheduled-posts pour être rafraîchi avec les publications planifiées. + app.get("/api/scheduled-posts/agenda", requireAuth, async (req, res) => { + try { + const user = req.user as User; + const start = parseDateParam(req.query.startDate); + const end = parseDateParam(req.query.endDate); + + const pages = user.role === 'admin' + ? await storage.getAllSocialPages() + : await storage.getUserAccessiblePages(user.id); + if (pages.length === 0) return res.json([]); + + const pagesById = new Map(pages.map(p => [p.id, { + id: p.id, + pageName: p.pageName, + platform: p.platform, + avatarUrl: p.avatarUrl ?? null, + }])); + + const [scheduled, renderingJobs] = await Promise.all([ + storage.getScheduledPostsByPages(Array.from(pagesById.keys()), start, end), + storage.getRenderingReelJobs(), + ]); + + const items: AgendaItem[] = scheduled.map(agendaItemFromScheduledPost); + // Un rendu qui vient de finir peut avoir déjà créé ses publications. + const plannedPosts = new Set(items.map(i => i.postId)); + for (const job of renderingJobs) { + if (job.postId && plannedPosts.has(job.postId)) continue; + items.push(...agendaItemsFromReelJob(job, pagesById, start, end)); + } + + items.sort((a, b) => a.scheduledAt.localeCompare(b.scheduledAt)); + res.json(items); + } catch (error) { + console.error("Error fetching publication agenda:", error); + res.status(500).json({ error: "Failed to fetch publication agenda" }); + } + }); + // Scheduled posts app.get("/api/scheduled-posts", requireAuth, async (req, res) => { try { diff --git a/server/storage.ts b/server/storage.ts index c706d20..eab12a1 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -149,6 +149,7 @@ export interface IStorage { getPost(id: string): Promise; getPostWithMedia(id: string): Promise<{ post: Post; media: Media[] } | undefined>; getOngoingReelPosts(userId: string): Promise<(Post & { generationStep: string | null })[]>; + getRenderingReelJobs(): Promise<{ id: string; postId: string | null; createdAt: Date; params: unknown; content: string | null }[]>; createPost(post: InsertPost): Promise; updatePost(id: string, post: Partial): Promise; updatePostGenerationStatus(id: string, status: string, progress: number, error?: string): Promise; @@ -582,6 +583,24 @@ export class DatabaseStorage implements IStorage { return rows.map((post) => ({ ...post, generationStep: stepByPost.get(post.id) ?? null })); } + /** + * Reels vidéo en file ou en montage : leurs publications planifiées + * n'existent qu'à la fin du rendu, l'agenda les lit donc ici. + */ + async getRenderingReelJobs(): Promise<{ id: string; postId: string | null; createdAt: Date; params: unknown; content: string | null }[]> { + return await db + .select({ + id: reelJobs.id, + postId: reelJobs.postId, + createdAt: reelJobs.createdAt, + params: reelJobs.params, + content: posts.content, + }) + .from(reelJobs) + .leftJoin(posts, eq(reelJobs.postId, posts.id)) + .where(and(eq(reelJobs.kind, 'video'), inArray(reelJobs.status, ['pending', 'processing']))); + } + // AI Generations async getAiGenerations(userId: string): Promise { return await db.select().from(aiGenerations).where(eq(aiGenerations.userId, userId)).orderBy(desc(aiGenerations.createdAt)); diff --git a/shared/agenda.test.ts b/shared/agenda.test.ts new file mode 100644 index 0000000..a0d33ac --- /dev/null +++ b/shared/agenda.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { + agendaItemFromScheduledPost, + agendaItemsFromReelJob, + busyHours, + conflictsFor, + groupByDay, + groupByPage, + type AgendaItem, + type AgendaPage, +} from "./agenda"; + +const storeA: AgendaPage = { id: "a", pageName: "Magasin A", platform: "facebook", avatarUrl: null }; +const storeB: AgendaPage = { id: "b", pageName: "Magasin B", platform: "tiktok", avatarUrl: null }; +const pagesById = new Map([[storeA.id, storeA], [storeB.id, storeB]]); + +function item(overrides: Partial): AgendaItem { + return { + id: "x", + postId: "p", + pageId: "a", + pageName: "Magasin A", + platform: "facebook", + avatarUrl: null, + postType: "reel", + scheduledAt: new Date(2026, 9, 14, 18, 0).toISOString(), + status: "scheduled", + preview: "", + ...overrides, + }; +} + +describe("agendaItemFromScheduledPost", () => { + it("déduit le statut des colonnes de publication", () => { + const base = { id: "s", postId: "p", pageId: "a", postType: "reel", scheduledAt: new Date(), page: storeA, post: { content: "Promo" } }; + expect(agendaItemFromScheduledPost({ ...base, publishedAt: null, error: null }).status).toBe("scheduled"); + expect(agendaItemFromScheduledPost({ ...base, publishedAt: new Date(), error: null }).status).toBe("published"); + expect(agendaItemFromScheduledPost({ ...base, publishedAt: null, error: "boom" }).status).toBe("failed"); + }); +}); + +describe("agendaItemsFromReelJob", () => { + const when = new Date(2026, 9, 14, 18, 0); + + it("crée une entrée « en montage » par magasin visible", () => { + const items = agendaItemsFromReelJob( + { id: "j", postId: "p", createdAt: new Date(), params: { pageIds: ["a", "b", "inconnu"], scheduledFor: when.toISOString() } }, + pagesById, + ); + expect(items.map((i) => i.pageId)).toEqual(["a", "b"]); + expect(items.every((i) => i.status === "rendering" && i.postType === "reel")).toBe(true); + expect(items[0].scheduledAt).toBe(when.toISOString()); + }); + + it("place un Reel sans date à sa création et respecte la période", () => { + const createdAt = new Date(2026, 9, 12, 9, 0); + const job = { id: "j", postId: "p", createdAt, params: { pageIds: ["a"] } }; + expect(agendaItemsFromReelJob(job, pagesById)[0].scheduledAt).toBe(createdAt.toISOString()); + expect(agendaItemsFromReelJob(job, pagesById, new Date(2026, 9, 13))).toEqual([]); + }); +}); + +describe("regroupements", () => { + const items = [ + item({ id: "1", pageId: "b", pageName: "Magasin B", scheduledAt: new Date(2026, 9, 14, 12, 0).toISOString() }), + item({ id: "2", pageId: "a", scheduledAt: new Date(2026, 9, 14, 18, 0).toISOString() }), + item({ id: "3", pageId: "a", scheduledAt: new Date(2026, 9, 14, 18, 30).toISOString(), status: "failed" }), + item({ id: "4", pageId: "a", scheduledAt: new Date(2026, 9, 15, 8, 0).toISOString() }), + ]; + + it("range les publications par jour local", () => { + const days = groupByDay(items); + expect(days.get("2026-10-14")?.map((i) => i.id)).toEqual(["1", "2", "3"]); + expect(days.get("2026-10-15")?.map((i) => i.id)).toEqual(["4"]); + }); + + it("met les magasins choisis en tête", () => { + const groups = groupByPage(groupByDay(items).get("2026-10-14")!, ["b"]); + expect(groups.map((g) => [g.page.id, g.selected])).toEqual([["b", true], ["a", false]]); + }); + + it("ne signale que les magasins choisis et ignore les échecs", () => { + const day = groupByDay(items).get("2026-10-14")!; + expect(conflictsFor(day, ["a"]).map((i) => i.id)).toEqual(["2"]); + expect(conflictsFor(day, [])).toEqual([]); + expect(Array.from(busyHours(day, ["a", "b"]).entries())).toEqual([[12, 1], [18, 1]]); + }); +}); diff --git a/shared/agenda.ts b/shared/agenda.ts new file mode 100644 index 0000000..7166354 --- /dev/null +++ b/shared/agenda.ts @@ -0,0 +1,185 @@ +/** + * Agenda des publications : ce qui est déjà prévu, jour par jour et magasin par + * magasin, pour aider à choisir la date d'un nouveau post ou Reel. + * + * Il réunit les publications planifiées (`scheduled_posts`) et les Reels encore + * en montage, qui n'ont pas de ligne `scheduled_posts` avant la fin du rendu. + */ + +export type AgendaStatus = "scheduled" | "rendering" | "published" | "failed"; + +export interface AgendaItem { + id: string; + postId: string; + pageId: string; + pageName: string; + platform: string; + avatarUrl: string | null; + postType: string; + /** ISO 8601. */ + scheduledAt: string; + status: AgendaStatus; + preview: string; +} + +export interface AgendaPage { + id: string; + pageName: string; + platform: string; + avatarUrl: string | null; +} + +const PREVIEW_LENGTH = 80; + +function preview(text: string | null | undefined): string { + const flat = (text ?? "").replace(/\s+/g, " ").trim(); + return flat.length > PREVIEW_LENGTH ? `${flat.slice(0, PREVIEW_LENGTH - 1)}…` : flat; +} + +function inRange(date: Date, start?: Date, end?: Date): boolean { + return (!start || date >= start) && (!end || date <= end); +} + +/** Statut d'une publication planifiée : elle n'a pas de colonne dédiée. */ +export function scheduledPostStatus(row: { publishedAt: Date | string | null; error: string | null }): AgendaStatus { + if (row.publishedAt) return "published"; + if (row.error) return "failed"; + return "scheduled"; +} + +export function agendaItemFromScheduledPost(row: { + id: string; + postId: string; + pageId: string; + postType: string; + scheduledAt: Date | string; + publishedAt: Date | string | null; + error: string | null; + post?: { content: string | null } | null; + page?: AgendaPage | null; +}): AgendaItem { + return { + id: row.id, + postId: row.postId, + pageId: row.pageId, + pageName: row.page?.pageName ?? "Page supprimée", + platform: row.page?.platform ?? "facebook", + avatarUrl: row.page?.avatarUrl ?? null, + postType: row.postType, + scheduledAt: new Date(row.scheduledAt).toISOString(), + status: scheduledPostStatus(row), + preview: preview(row.post?.content), + }; +} + +/** + * Entrées d'un Reel en montage, une par page visée et visible de l'utilisateur. + * Sans date demandée, il part dès la fin du rendu : on le place à sa création. + */ +export function agendaItemsFromReelJob( + job: { + id: string; + postId: string | null; + createdAt: Date | string; + params: unknown; + content?: string | null; + }, + pagesById: Map, + start?: Date, + end?: Date, +): AgendaItem[] { + if (!job.postId) return []; + const params = (job.params ?? {}) as { pageIds?: unknown; scheduledFor?: unknown; description?: unknown; overlayText?: unknown }; + const pageIds = Array.isArray(params.pageIds) ? params.pageIds.filter((id): id is string => typeof id === "string") : []; + + const requested = typeof params.scheduledFor === "string" ? new Date(params.scheduledFor) : undefined; + const when = requested && !Number.isNaN(requested.getTime()) ? requested : new Date(job.createdAt); + if (!inRange(when, start, end)) return []; + + const text = job.content || (typeof params.description === "string" ? params.description : "") + || (typeof params.overlayText === "string" ? params.overlayText : ""); + + return pageIds.flatMap((pageId) => { + const page = pagesById.get(pageId); + if (!page) return []; + return [{ + id: `${job.id}:${pageId}`, + postId: job.postId!, + pageId, + pageName: page.pageName, + platform: page.platform, + avatarUrl: page.avatarUrl, + postType: "reel", + scheduledAt: when.toISOString(), + status: "rendering" as const, + preview: preview(text), + }]; + }); +} + +/** Une publication en échec ne prend plus de place dans le planning. */ +export function occupiesSlot(item: AgendaItem): boolean { + return item.status !== "failed"; +} + +/** Clé `yyyy-MM-dd` du jour local. */ +export function dayKey(date: Date | string): string { + const d = new Date(date); + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +export function groupByDay(items: AgendaItem[]): Map { + const days = new Map(); + for (const item of items) { + const key = dayKey(item.scheduledAt); + const list = days.get(key); + if (list) list.push(item); + else days.set(key, [item]); + } + days.forEach((list) => list.sort((a, b) => a.scheduledAt.localeCompare(b.scheduledAt))); + return days; +} + +export interface PageAgenda { + page: AgendaPage; + selected: boolean; + items: AgendaItem[]; +} + +/** Publications d'un jour regroupées par magasin, les magasins choisis en tête. */ +export function groupByPage(items: AgendaItem[], selectedPageIds: string[] = []): PageAgenda[] { + const selected = new Set(selectedPageIds); + const groups = new Map(); + for (const item of items) { + let group = groups.get(item.pageId); + if (!group) { + group = { + page: { id: item.pageId, pageName: item.pageName, platform: item.platform, avatarUrl: item.avatarUrl }, + selected: selected.has(item.pageId), + items: [], + }; + groups.set(item.pageId, group); + } + group.items.push(item); + } + return Array.from(groups.values()).sort( + (a, b) => Number(b.selected) - Number(a.selected) || a.page.pageName.localeCompare(b.page.pageName), + ); +} + +/** Publications déjà prévues sur les magasins choisis, à signaler. */ +export function conflictsFor(items: AgendaItem[], selectedPageIds: string[]): AgendaItem[] { + const selected = new Set(selectedPageIds); + return items.filter((item) => selected.has(item.pageId) && occupiesSlot(item)); +} + +/** Nombre de publications par heure locale sur les magasins choisis. */ +export function busyHours(items: AgendaItem[], selectedPageIds: string[]): Map { + const hours = new Map(); + for (const item of conflictsFor(items, selectedPageIds)) { + const hour = new Date(item.scheduledAt).getHours(); + hours.set(hour, (hours.get(hour) ?? 0) + 1); + } + return hours; +}