mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-18 12:46:43 +02:00
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:
11 files changed
+553
-43
No files matched your search
@@ -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