diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 6d020bd..3c153bc 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -18,7 +18,9 @@ "Bash(fish -c \"which node\")", "Read(//opt/**)", "Bash(/home/anthony/.config/JetBrains/WebStorm2026.1/node/versions/24.14.1/bin/node node_modules/.bin/tsc --noEmit)", - "Bash(chmod +x /home/anthony/Documents/Projects/Tradarr/build-push.sh)" + "Bash(chmod +x /home/anthony/Documents/Projects/Tradarr/build-push.sh)", + "Bash(fish -c \"npm install react-markdown\")", + "Bash(fish -c \"which npm; which pnpm; which bun\")" ] } } diff --git a/backend/internal/ai/pipeline.go b/backend/internal/ai/pipeline.go index fe0b000..15bcb5b 100644 --- a/backend/internal/ai/pipeline.go +++ b/backend/internal/ai/pipeline.go @@ -116,10 +116,12 @@ func (p *Pipeline) GenerateForUser(ctx context.Context, userID string) (*models. systemPrompt = DefaultSystemPrompt } + tz, _ := p.repo.GetSetting("timezone") + // Passe 2 : résumé complet fmt.Printf("[pipeline] Passe 2 — résumé : génération sur %d articles…\n", len(articles)) t2 := time.Now() - prompt := buildPrompt(systemPrompt, symbols, articles) + prompt := buildPrompt(systemPrompt, symbols, articles, tz) summary, err := provider.Summarize(ctx, prompt) if err != nil { return nil, fmt.Errorf("AI summarize: %w", err) @@ -266,7 +268,7 @@ func (p *Pipeline) callProviderForReport(ctx context.Context, excerpt, question return provider.Summarize(ctx, prompt) } -func buildPrompt(systemPrompt string, symbols []string, articles []models.Article) string { +func buildPrompt(systemPrompt string, symbols []string, articles []models.Article, tz string) string { var sb strings.Builder sb.WriteString(systemPrompt) sb.WriteString("\n\n") @@ -275,7 +277,11 @@ func buildPrompt(systemPrompt string, symbols []string, articles []models.Articl sb.WriteString(strings.Join(symbols, ", ")) sb.WriteString(".\n\n") } - sb.WriteString(fmt.Sprintf("Date d'analyse : %s\n\n", time.Now().Format("02/01/2006 15:04"))) + loc, err := time.LoadLocation(tz) + if err != nil || tz == "" { + loc = time.UTC + } + sb.WriteString(fmt.Sprintf("Date d'analyse : %s\n\n", time.Now().In(loc).Format("02/01/2006 15:04"))) sb.WriteString("/think\n\n") sb.WriteString("## Actualités\n\n") diff --git a/backend/internal/database/migrations/000007_timezone.down.sql b/backend/internal/database/migrations/000007_timezone.down.sql new file mode 100644 index 0000000..2de0254 --- /dev/null +++ b/backend/internal/database/migrations/000007_timezone.down.sql @@ -0,0 +1 @@ +DELETE FROM settings WHERE key = 'timezone'; diff --git a/backend/internal/database/migrations/000007_timezone.up.sql b/backend/internal/database/migrations/000007_timezone.up.sql new file mode 100644 index 0000000..8c5347e --- /dev/null +++ b/backend/internal/database/migrations/000007_timezone.up.sql @@ -0,0 +1,2 @@ +INSERT INTO settings (key, value) VALUES ('timezone', 'Europe/Paris') +ON CONFLICT (key) DO NOTHING; diff --git a/backend/internal/scheduler/scheduler.go b/backend/internal/scheduler/scheduler.go index eee6077..50b3adf 100644 --- a/backend/internal/scheduler/scheduler.go +++ b/backend/internal/scheduler/scheduler.go @@ -57,9 +57,14 @@ func (s *Scheduler) loadSchedule() error { return nil } + tz, _ := s.repo.GetSetting("timezone") + if tz == "" { + tz = "UTC" + } + for _, slot := range slots { - // Format cron: "minute hour * * day_of_week" - spec := fmt.Sprintf("%d %d * * %d", slot.Minute, slot.Hour, slot.DayOfWeek) + // TZ= prefix permet à robfig/cron d'interpréter les heures dans le fuseau configuré + spec := fmt.Sprintf("TZ=%s %d %d * * %d", tz, slot.Minute, slot.Hour, slot.DayOfWeek) id, err := s.cron.AddFunc(spec, s.run) if err != nil { fmt.Printf("scheduler: invalid cron spec %q: %v\n", spec, err) @@ -68,7 +73,7 @@ func (s *Scheduler) loadSchedule() error { s.entryIDs = append(s.entryIDs, id) } - fmt.Printf("scheduler: %d time slots loaded\n", len(s.entryIDs)) + fmt.Printf("scheduler: %d time slots loaded (timezone: %s)\n", len(s.entryIDs), tz) return nil } diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index f612114..7185b80 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -126,17 +126,28 @@ function ContextPanel({ // ── Summary content renderer ──────────────────────────────────────────────── +function renderInline(text: string): React.ReactNode { + const parts = text.split(/(\*\*[^*]+\*\*)/g) + return parts.map((part, i) => + part.startsWith('**') && part.endsWith('**') + ? {part.slice(2, -2)} + : part + ) +} + function SummaryContent({ content }: { content: string }) { const lines = content.split('\n') return ( -
+
{lines.map((line, i) => { - if (line.startsWith('## ')) return

{line.slice(3)}

- if (line.startsWith('### ')) return

{line.slice(4)}

- if (line.startsWith('- ')) return
  • {line.slice(2)}
  • - if (line.startsWith('**') && line.endsWith('**')) return

    {line.slice(2, -2)}

    - if (line.trim() === '') return
    - return

    {line}

    + if (line.startsWith('##### ')) return
    {renderInline(line.slice(6))}
    + if (line.startsWith('#### ')) return

    {renderInline(line.slice(5))}

    + if (line.startsWith('### ')) return

    {renderInline(line.slice(4))}

    + if (line.startsWith('## ')) return

    {renderInline(line.slice(3))}

    + if (line.startsWith('# ')) return

    {renderInline(line.slice(2))}

    + if (line.startsWith('- ') || line.startsWith('* ')) return
  • {renderInline(line.slice(2))}
  • + if (line.trim() === '') return
    + return

    {renderInline(line)}

    })}
    ) diff --git a/frontend/src/pages/admin/AdminSettings.tsx b/frontend/src/pages/admin/AdminSettings.tsx index e6c8977..2053c1d 100644 --- a/frontend/src/pages/admin/AdminSettings.tsx +++ b/frontend/src/pages/admin/AdminSettings.tsx @@ -8,19 +8,26 @@ import { Label } from '@/components/ui/label' import { Spinner } from '@/components/ui/spinner' const NUMERIC_SETTINGS: Record = { - scrape_interval_minutes: { label: 'Intervalle de scraping (minutes)', description: 'Fréquence de récupération des actualités' }, - articles_lookback_hours: { label: 'Fenêtre d\'analyse (heures)', description: 'Période couverte pour les résumés IA' }, summary_max_articles: { label: 'Articles max par résumé', description: 'Nombre maximum d\'articles envoyés à l\'IA' }, } +const COMMON_TIMEZONES = [ + 'Europe/Paris', 'Europe/London', 'Europe/Berlin', 'Europe/Madrid', 'Europe/Rome', + 'America/New_York', 'America/Chicago', 'America/Los_Angeles', + 'Asia/Tokyo', 'Asia/Hong_Kong', 'Asia/Singapore', + 'UTC', +] + export function AdminSettings() { const [settings, setSettings] = useState([]) const [loading, setLoading] = useState(true) const [values, setValues] = useState>({}) const [defaultPrompt, setDefaultPrompt] = useState('') const [saving, setSaving] = useState(false) + const [savingTz, setSavingTz] = useState(false) const [savingPrompt, setSavingPrompt] = useState(false) const [saved, setSaved] = useState(false) + const [savedTz, setSavedTz] = useState(false) const [savedPrompt, setSavedPrompt] = useState(false) useEffect(() => { load() }, []) @@ -53,6 +60,13 @@ export function AdminSettings() { setTimeout(() => setSaved(false), 2000) } + async function saveTimezone() { + setSavingTz(true); setSavedTz(false) + await adminApi.updateSettings([{ key: 'timezone', value: values['timezone'] ?? 'Europe/Paris' }]) + setSavingTz(false); setSavedTz(true) + setTimeout(() => setSavedTz(false), 2000) + } + async function savePrompt() { setSavingPrompt(true); setSavedPrompt(false) await adminApi.updateSettings([{ key: 'ai_system_prompt', value: values['ai_system_prompt'] ?? '' }]) @@ -75,6 +89,35 @@ export function AdminSettings() {

    Configuration globale du service

    + {/* Fuseau horaire */} + + Fuseau horaire + +
    + +

    + Utilisé pour le planning de scraping et l'horodatage des résumés +

    +
    + setValues(v => ({ ...v, timezone: e.target.value }))} + placeholder="Europe/Paris" + /> + + {COMMON_TIMEZONES.map(tz => +
    +
    + +
    +
    + + {/* Paramètres généraux */} Paramètres généraux @@ -101,6 +144,7 @@ export function AdminSettings() { + {/* Contexte IA */} Contexte IA