diff --git a/client/src/components/recent-publications.tsx b/client/src/components/recent-publications.tsx
index c430a67..93e0374 100644
--- a/client/src/components/recent-publications.tsx
+++ b/client/src/components/recent-publications.tsx
@@ -1,6 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
-import { ArrowRight, Clock, CheckCircle2, XCircle, Eye, Image as ImageIcon, Smartphone, Clapperboard } from "lucide-react";
+import { ArrowRight, Clock, CheckCircle2, XCircle, RotateCw, Eye, Image as ImageIcon, Smartphone, Clapperboard } from "lucide-react";
import { Link } from "wouter";
import { Badge } from "@/components/ui/badge";
import { EmptyState } from "@/components/empty-state";
@@ -197,7 +197,11 @@ export default function RecentPublications() {
{scheduledPost.page?.pageName || "Page inconnue"}
- {scheduledPost.error ? (
+ {scheduledPost.error && !scheduledPost.publishedAt && scheduledPost.nextAttemptAt ? (
+
+ Nouvel essai
+
+ ) : scheduledPost.error ? (
Échec
diff --git a/client/src/pages/history.tsx b/client/src/pages/history.tsx
index 2e88174..0a042e8 100644
--- a/client/src/pages/history.tsx
+++ b/client/src/pages/history.tsx
@@ -2,7 +2,7 @@ import { useState } from "react";
import { Page } from "@/components/layout/app-shell";
import { PageHeader } from "@/components/layout/page-header";
import { useQuery } from "@tanstack/react-query";
-import { CheckCircle, XCircle, Calendar, History as HistoryIcon, Eye, Image as ImageIcon, Smartphone, Loader2, Clapperboard, Search, Sparkles } from "lucide-react";
+import { CheckCircle, XCircle, RotateCw, Calendar, History as HistoryIcon, Eye, Image as ImageIcon, Smartphone, Loader2, Clapperboard, Search, Sparkles } from "lucide-react";
import { Link } from "wouter";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
@@ -27,10 +27,17 @@ function isAwaitingPublication(scheduledPost: ScheduledPostWithRelations): boole
return (
!!scheduledPost.scheduledAt &&
new Date(scheduledPost.scheduledAt) <= new Date() &&
- !scheduledPost.publishedAt
+ !scheduledPost.publishedAt &&
+ // Un échec ne bouge plus avant sa prochaine tentative (minutes ou heures)
+ !scheduledPost.error
);
}
+/** Échec dont le scheduler fera une nouvelle tentative automatique. */
+function isRetrying(scheduledPost: ScheduledPostWithRelations): boolean {
+ return !!scheduledPost.error && !scheduledPost.publishedAt && !!scheduledPost.nextAttemptAt;
+}
+
type StatusFilter = "all" | "published" | "pending" | "failed";
const STATUS_TABS: { value: StatusFilter; label: string }[] = [
@@ -115,8 +122,13 @@ export default function History() {
return null;
};
+ // Un échec avec un nouvel essai programmé est encore « en cours »
const statusOf = (sp: ScheduledPostWithRelations): StatusFilter =>
- sp.error ? "failed" : !sp.publishedAt ? "pending" : "published";
+ sp.publishedAt && !sp.error
+ ? "published"
+ : sp.error && !isRetrying(sp)
+ ? "failed"
+ : "pending";
const counts = publishedPosts.reduce(
(acc, sp) => {
@@ -200,6 +212,8 @@ export default function History() {
{scheduledPost.page?.pageName || "Page inconnue"}
{status === "failed" ? (
Échec
+ ) : isRetrying(scheduledPost) ? (
+ Nouvel essai
) : status === "pending" ? (
En cours
) : (
@@ -238,7 +252,7 @@ export default function History() {
{scheduledPost.nextAttemptAt
? `Nouvelle tentative automatique le ${format(new Date(scheduledPost.nextAttemptAt), "d MMM yyyy 'à' HH:mm", { locale: fr })}`
: scheduledPost.attempts > 0
- ? `Abandon après ${scheduledPost.attempts} tentatives : reprogrammez la publication pour réessayer.`
+ ? "Plus de nouvelle tentative automatique : reprogrammez la publication pour réessayer."
: null}
)}
diff --git a/server/src/services/scheduler.rs b/server/src/services/scheduler.rs
index acd0d7d..9185b91 100644
--- a/server/src/services/scheduler.rs
+++ b/server/src/services/scheduler.rs
@@ -37,6 +37,15 @@ pub fn start(state: AppState) {
/// (il faut alors la reprogrammer).
pub const MAX_PUBLISH_ATTEMPTS: i32 = 5;
+/// Retard au-delà duquel une publication n'est plus envoyée : une promotion
+/// ou une story publiée un jour trop tard fait plus de mal que de bien.
+const MAX_PUBLISH_DELAY_HOURS: i64 = 24;
+
+/// Publication trop en retard sur son heure prévue pour être encore envoyée.
+fn is_stale(scheduled_at: Ts, now: Ts) -> bool {
+ scheduled_at.add(chrono::Duration::hours(MAX_PUBLISH_DELAY_HOURS)) < now
+}
+
/// Délai avant la tentative suivante, selon le nombre d'échecs déjà subis.
/// Une limite de débit (quota d'appels, blocage anti-spam) appelle des délais
/// plus longs : réessayer tout de suite prolonge le blocage.
@@ -106,6 +115,36 @@ pub async fn process_pending_posts(state: &AppState) {
let mut cooldowns: HashMap = HashMap::new();
for scheduled_post in pending {
+ if is_stale(scheduled_post.scheduled_at, Ts::now()) {
+ tracing::warn!(
+ "[Scheduler] Post {} abandonné : plus de {MAX_PUBLISH_DELAY_HOURS} h de retard",
+ scheduled_post.id
+ );
+ // Le dernier message d'erreur est conservé s'il y en a un
+ let reason = format!(
+ "Publication abandonnée : plus de {MAX_PUBLISH_DELAY_HOURS} h de retard sur l'heure prévue."
+ );
+ let error = match scheduled_post.error.as_deref().filter(|e| !e.is_empty()) {
+ Some(last) => format!("{reason} Dernière erreur : {last}"),
+ None => reason,
+ };
+ let abandoned = state
+ .db
+ .update_scheduled_post(
+ &scheduled_post.id,
+ Patch::new()
+ .set("error", error)
+ .set("attempts", MAX_PUBLISH_ATTEMPTS)
+ .set("next_attempt_at", None::),
+ )
+ .await;
+ if let Err(error) = abandoned {
+ tracing::error!("Error processing pending posts: {error}");
+ return;
+ }
+ continue;
+ }
+
if !cooldowns.is_empty() {
let platform = page_platform(state, &scheduled_post.page_id).await;
if let Some(until) = platform.and_then(|p| cooldowns.get(&p)).copied() {
@@ -399,6 +438,14 @@ mod tests {
assert!(!is_rate_limited("Post or page not found"));
}
+ #[test]
+ fn posts_more_than_a_day_late_are_stale() {
+ let now = Ts::parse("2026-10-12T19:00:00Z").unwrap();
+ assert!(is_stale(Ts::parse("2026-10-11T16:30:00Z").unwrap(), now));
+ assert!(!is_stale(Ts::parse("2026-10-12T16:30:00Z").unwrap(), now));
+ assert!(!is_stale(Ts::parse("2026-10-11T19:00:00Z").unwrap(), now));
+ }
+
#[test]
fn retry_delays_grow_and_stay_bounded() {
assert_eq!(retry_delay(1, false), chrono::Duration::minutes(5));