Merge pull request #126 from R0m1k3/claude/busy-meitner-b7azzi

Afficher les publications déjà prévues par jour et par magasin
This commit is contained in:
LogiFlow authored and GitHub committed 2026-10-12 12:21:41 +02:00
commit 51f4406173
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;
}