mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-19 21:18:18 +02:00
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:
11 files changed
+553
-43
No files matched your search
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -884,7 +884,7 @@ export default function NewReel() {
|
||||
<DateTimePicker
|
||||
value={scheduledDate}
|
||||
onChange={setScheduledDate}
|
||||
occupiedDates={[]}
|
||||
selectedPageIds={selectedPages}
|
||||
placeholder="Choisir une date"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -270,6 +270,7 @@ export default function ScheduleReel() {
|
||||
<DateTimePicker
|
||||
value={scheduledDate}
|
||||
onChange={setScheduledDate}
|
||||
selectedPageIds={selectedPages}
|
||||
placeholder="Choisir la date et l'heure"
|
||||
/>
|
||||
{isPast && (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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]]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user