Afficher les publications déjà prévues par jour et par magasin

Le sélecteur de date des Reels ne montrait rien de ce qui était déjà
programmé, et celui des posts ne marquait qu'un jour « occupé » sans
dire pour quel magasin ni à quelle heure.

- GET /api/scheduled-posts/agenda : publications planifiées et Reels
  encore en montage (qui n'ont pas de scheduled_posts avant la fin du
  rendu), limités aux magasins accessibles.
- Calendrier : pastille par jour, en rouge si un magasin coché a déjà
  une publication, en gris s'il s'agit d'autres magasins.
- Heures : badge sur les heures déjà prises sur les magasins cochés.
- Panneau « Déjà prévu ce jour-là » groupé par magasin, avec une
  alerte non bloquante en cas de doublon.
- Branché sur Nouveau post, Reel et Programmer un Reel.
- Icône Reel dans la vue calendrier.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TMU8FG2v2EE54YWHhqehPw
This commit is contained in:
Claude committed 2026-10-12 10:19:51 +00:00
1 parent 62475eb43b
commit 53ab6f489a
11 files changed
+553 -43

No files matched your search

+3 -1
View File
@@ -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() {
<Smartphone className="w-2.5 h-2.5" />
</div>
);
} else if (postType === 'reel') {
return <Clapperboard className="w-3 h-3" />;
}
return null;
};
+90 -23
View File
@@ -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<Date | undefined>(value);
const [selectedHour, setSelectedHour] = useState<number>(value?.getHours() || 8);
const [selectedMinute, setSelectedMinute] = useState<number>(value?.getMinutes() || 0);
const [month, setMonth] = useState<Date>(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 (
<span className="relative flex h-full w-full items-center justify-center" title={title || undefined}>
{date.getDate()}
{items.length > 0 && (
<span
className={cn(
"absolute right-0 top-0 min-w-[14px] rounded-full px-1 text-[9px] font-bold leading-[14px]",
mine > 0 ? "bg-destructive text-destructive-foreground" : "bg-muted-foreground/70 text-background"
)}
>
{mine || items.length}
</span>
)}
</span>
);
},
[activeItemsOn, selectedKey]
);
return (
<div className="space-y-3">
@@ -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"
/>
<div className="space-y-1 border-t bg-muted/50 px-3 py-2 text-[10px]">
<div className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-destructive" />
<span>Déjà prévu sur un magasin sélectionné</span>
</div>
<div className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-muted-foreground/70" />
<span>Prévu sur d'autres magasins</span>
</div>
{selectedPageIds.length === 0 && (
<p className="text-muted-foreground">Cochez un magasin pour repérer ses jours déjà occupés.</p>
)}
</div>
</PopoverContent>
</Popover>
{value && (
@@ -147,7 +202,9 @@ export function DateTimePicker({ value, onChange, occupiedDates = [], placeholde
<ScrollArea className="h-48 min-w-[96px] flex-1">
<div className="p-1.5">
<div className="text-[10px] font-semibold mb-1.5 px-1.5 text-muted-foreground">Heures</div>
{hours.map((hour) => (
{hours.map((hour) => {
const taken = hoursTaken.get(hour) ?? 0;
return (
<Button
key={hour}
variant="ghost"
@@ -163,8 +220,17 @@ export function DateTimePicker({ value, onChange, occupiedDates = [], placeholde
{isOptimalHour(hour) && selectedHour !== hour && (
<span className="ml-1 text-[10px]">✨</span>
)}
{taken > 0 && (
<span
className="ml-1 rounded-full bg-destructive px-1 text-[9px] font-bold leading-[14px] text-destructive-foreground"
title={`${taken} publication(s) déjà prévue(s) sur vos magasins`}
>
{taken}
</span>
)}
</Button>
))}
);
})}
</div>
</ScrollArea>
<ScrollArea className="h-48 min-w-[80px] flex-1 border-l">
@@ -194,20 +260,21 @@ export function DateTimePicker({ value, onChange, occupiedDates = [], placeholde
Heures optimales (8h, 12h-13h, 18h-20h)
</span>
</div>
{hoursTaken.size > 0 && (
<div className="mt-1 flex items-center gap-1.5">
<div className="w-1.5 h-1.5 rounded-full bg-destructive"></div>
<span className="text-[9px] font-semibold text-destructive">
Déjà prévu sur vos magasins
</span>
</div>
)}
</div>
</PopoverContent>
</Popover>
)}
{occupiedDates.length > 0 && (
<div className="p-3 bg-destructive/10 rounded-lg border border-destructive/20">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-destructive"></div>
<span className="text-xs font-semibold text-destructive">
Les dates en fond rouge ont déjà des publications programmées
</span>
</div>
</div>
{selectedDate && (
<DayAgendaPanel date={selectedDate} items={selectedDayItems} selectedPageIds={selectedPageIds} />
)}
</div>
);
@@ -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<string, { label: string; icon: typeof Image }> = {
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<AgendaStatus, { label: string; className: string }> = {
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 (
<div className="flex items-center gap-2 rounded-lg border border-dashed p-3 text-xs text-muted-foreground" data-testid="day-agenda-empty">
<CalendarCheck className="h-4 w-4 text-success" />
Rien de prévu le {dayLabel} : la journée est libre sur tous les magasins.
</div>
);
}
return (
<div className="space-y-2" data-testid="day-agenda-panel">
{conflicts.length > 0 && (
<div className="flex gap-2 rounded-lg border border-warning/30 bg-warning/10 p-3 text-xs text-warning" data-testid="day-agenda-conflict">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div className="space-y-0.5">
{(reelConflicts.length > 0 ? reelConflicts : conflicts).map((item) => (
<p key={item.id}>
<span className="font-semibold">{item.pageName}</span> a déjà {reelConflicts.length > 0 ? "un Reel" : "une publication"} à {time(item)} ce jour-là.
</p>
))}
<p className="text-warning/80">Vous pouvez quand même programmer, ou choisir un autre jour.</p>
</div>
</div>
)}
<div className="rounded-lg border">
<div className="border-b bg-muted/40 px-3 py-2 text-xs font-semibold">
Déjà prévu le {dayLabel}
</div>
<div className="divide-y">
{groupByPage(items, selectedPageIds).map(({ page, selected, items: pageItems }) => (
<div key={page.id} className={cn("space-y-1.5 px-3 py-2", selected && "bg-primary/5")}>
<div className="flex items-center gap-2">
<PlatformIcon platform={page.platform} size="sm" className="h-5 w-5 rounded" />
<span className="min-w-0 flex-1 truncate text-xs font-medium">{page.pageName}</span>
{selected && <Badge variant="outline" className="h-5 px-1.5 text-[10px]">Sélectionné</Badge>}
</div>
{pageItems.map((item) => {
const type = TYPE_META[item.postType] ?? TYPE_META.feed;
const status = STATUS_META[item.status];
const TypeIcon = type.icon;
return (
<div key={item.id} className="flex items-center gap-2 pl-7 text-xs" data-testid={`day-agenda-item-${item.id}`}>
<span className="w-10 shrink-0 font-mono font-semibold">{time(item)}</span>
<TypeIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="shrink-0">{type.label}</span>
<span className="min-w-0 flex-1 truncate text-muted-foreground">{item.preview}</span>
<span className={cn("inline-flex shrink-0 items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium", status.className)}>
{item.status === "rendering" && <Loader2 className="h-3 w-3 animate-spin" />}
{status.label}
</span>
</div>
);
})}
</div>
))}
</div>
</div>
</div>
);
}
@@ -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<AgendaItem[]>({
queryKey: ["/api/scheduled-posts", "agenda", { startDate, endDate }],
refetchInterval: 30000,
});
const itemsByDay = useMemo(() => groupByDay(items), [items]);
return { items, itemsByDay, isLoading };
}
+3 -18
View File
@@ -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<ScheduledPost[]>({
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() {
<DateTimePicker
value={scheduledDate}
onChange={setScheduledDate}
occupiedDates={scheduledPosts
.filter(post => post.scheduledAt)
.map(post => new Date(post.scheduledAt!))}
selectedPageIds={selectedPages}
placeholder="Choisir une date"
/>
<p className="text-xs text-muted-foreground">Les créneaux déjà occupés sont signalés dans le calendrier.</p>
<p className="text-xs text-muted-foreground">Les jours et heures déjà occupés sur vos magasins sont signalés dans le calendrier.</p>
</div>
)}
</CardContent>
+1 -1
View File
@@ -884,7 +884,7 @@ export default function NewReel() {
<DateTimePicker
value={scheduledDate}
onChange={setScheduledDate}
occupiedDates={[]}
selectedPageIds={selectedPages}
placeholder="Choisir une date"
/>
)}
+1
View File
@@ -270,6 +270,7 @@ export default function ScheduleReel() {
<DateTimePicker
value={scheduledDate}
onChange={setScheduledDate}
selectedPageIds={selectedPages}
placeholder="Choisir la date et l'heure"
/>
{isPast && (
+42
View File
@@ -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<Server> {
}
});
// 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<string, AgendaPage>(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 {
+19
View File
@@ -149,6 +149,7 @@ export interface IStorage {
getPost(id: string): Promise<Post | undefined>;
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<Post>;
updatePost(id: string, post: Partial<InsertPost>): Promise<Post>;
updatePostGenerationStatus(id: string, status: string, progress: number, error?: string): Promise<Post>;
@@ -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<AiGeneration[]> {
return await db.select().from(aiGenerations).where(eq(aiGenerations.userId, userId)).orderBy(desc(aiGenerations.createdAt));
+88
View File
@@ -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>): 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]]);
});
});
+185
View File
@@ -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<string, AgendaPage>,
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<string, AgendaItem[]> {
const days = new Map<string, AgendaItem[]>();
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<string, PageAgenda>();
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<number, number> {
const hours = new Map<number, number>();
for (const item of conflictsFor(items, selectedPageIds)) {
const hour = new Date(item.scheduledAt).getHours();
hours.set(hour, (hours.get(hour) ?? 0) + 1);
}
return hours;
}