feat: add settings to chose timzone and add markdown readability on main page

This commit is contained in:
2026-04-20 08:22:47 +02:00
parent 71513ea62c
commit 8a0edc3d59
7 changed files with 87 additions and 16 deletions

View File

@ -18,7 +18,9 @@
"Bash(fish -c \"which node\")", "Bash(fish -c \"which node\")",
"Read(//opt/**)", "Read(//opt/**)",
"Bash(/home/anthony/.config/JetBrains/WebStorm2026.1/node/versions/24.14.1/bin/node node_modules/.bin/tsc --noEmit)", "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\")"
] ]
} }
} }

View File

@ -116,10 +116,12 @@ func (p *Pipeline) GenerateForUser(ctx context.Context, userID string) (*models.
systemPrompt = DefaultSystemPrompt systemPrompt = DefaultSystemPrompt
} }
tz, _ := p.repo.GetSetting("timezone")
// Passe 2 : résumé complet // Passe 2 : résumé complet
fmt.Printf("[pipeline] Passe 2 — résumé : génération sur %d articles…\n", len(articles)) fmt.Printf("[pipeline] Passe 2 — résumé : génération sur %d articles…\n", len(articles))
t2 := time.Now() t2 := time.Now()
prompt := buildPrompt(systemPrompt, symbols, articles) prompt := buildPrompt(systemPrompt, symbols, articles, tz)
summary, err := provider.Summarize(ctx, prompt) summary, err := provider.Summarize(ctx, prompt)
if err != nil { if err != nil {
return nil, fmt.Errorf("AI summarize: %w", err) 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) 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 var sb strings.Builder
sb.WriteString(systemPrompt) sb.WriteString(systemPrompt)
sb.WriteString("\n\n") sb.WriteString("\n\n")
@ -275,7 +277,11 @@ func buildPrompt(systemPrompt string, symbols []string, articles []models.Articl
sb.WriteString(strings.Join(symbols, ", ")) sb.WriteString(strings.Join(symbols, ", "))
sb.WriteString(".\n\n") 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("/think\n\n")
sb.WriteString("## Actualités\n\n") sb.WriteString("## Actualités\n\n")

View File

@ -0,0 +1 @@
DELETE FROM settings WHERE key = 'timezone';

View File

@ -0,0 +1,2 @@
INSERT INTO settings (key, value) VALUES ('timezone', 'Europe/Paris')
ON CONFLICT (key) DO NOTHING;

View File

@ -57,9 +57,14 @@ func (s *Scheduler) loadSchedule() error {
return nil return nil
} }
tz, _ := s.repo.GetSetting("timezone")
if tz == "" {
tz = "UTC"
}
for _, slot := range slots { for _, slot := range slots {
// Format cron: "minute hour * * day_of_week" // TZ= prefix permet à robfig/cron d'interpréter les heures dans le fuseau configuré
spec := fmt.Sprintf("%d %d * * %d", slot.Minute, slot.Hour, slot.DayOfWeek) spec := fmt.Sprintf("TZ=%s %d %d * * %d", tz, slot.Minute, slot.Hour, slot.DayOfWeek)
id, err := s.cron.AddFunc(spec, s.run) id, err := s.cron.AddFunc(spec, s.run)
if err != nil { if err != nil {
fmt.Printf("scheduler: invalid cron spec %q: %v\n", spec, err) 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) 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 return nil
} }

View File

@ -126,17 +126,28 @@ function ContextPanel({
// ── Summary content renderer ──────────────────────────────────────────────── // ── Summary content renderer ────────────────────────────────────────────────
function renderInline(text: string): React.ReactNode {
const parts = text.split(/(\*\*[^*]+\*\*)/g)
return parts.map((part, i) =>
part.startsWith('**') && part.endsWith('**')
? <strong key={i}>{part.slice(2, -2)}</strong>
: part
)
}
function SummaryContent({ content }: { content: string }) { function SummaryContent({ content }: { content: string }) {
const lines = content.split('\n') const lines = content.split('\n')
return ( return (
<div className="space-y-2 text-sm leading-relaxed select-text"> <div className="space-y-1 text-sm leading-relaxed select-text">
{lines.map((line, i) => { {lines.map((line, i) => {
if (line.startsWith('## ')) return <h2 key={i} className="text-base font-semibold mt-4 first:mt-0">{line.slice(3)}</h2> if (line.startsWith('##### ')) return <h5 key={i} className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mt-3">{renderInline(line.slice(6))}</h5>
if (line.startsWith('### ')) return <h3 key={i} className="font-medium mt-3">{line.slice(4)}</h3> if (line.startsWith('#### ')) return <h4 key={i} className="text-sm font-semibold mt-3">{renderInline(line.slice(5))}</h4>
if (line.startsWith('- ')) return <li key={i} className="ml-4 text-muted-foreground">{line.slice(2)}</li> if (line.startsWith('### ')) return <h3 key={i} className="text-sm font-semibold text-primary mt-4">{renderInline(line.slice(4))}</h3>
if (line.startsWith('**') && line.endsWith('**')) return <p key={i} className="font-semibold">{line.slice(2, -2)}</p> if (line.startsWith('## ')) return <h2 key={i} className="text-base font-bold mt-5 first:mt-0 border-b pb-1">{renderInline(line.slice(3))}</h2>
if (line.trim() === '') return <div key={i} className="h-1" /> if (line.startsWith('# ')) return <h1 key={i} className="text-lg font-bold mt-5 first:mt-0">{renderInline(line.slice(2))}</h1>
return <p key={i} className="text-muted-foreground">{line}</p> if (line.startsWith('- ') || line.startsWith('* ')) return <li key={i} className="ml-4 text-muted-foreground list-disc">{renderInline(line.slice(2))}</li>
if (line.trim() === '') return <div key={i} className="h-2" />
return <p key={i} className="text-muted-foreground">{renderInline(line)}</p>
})} })}
</div> </div>
) )

View File

@ -8,19 +8,26 @@ import { Label } from '@/components/ui/label'
import { Spinner } from '@/components/ui/spinner' import { Spinner } from '@/components/ui/spinner'
const NUMERIC_SETTINGS: Record<string, { label: string; description: string }> = { const NUMERIC_SETTINGS: Record<string, { label: string; description: string }> = {
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' }, 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() { export function AdminSettings() {
const [settings, setSettings] = useState<Setting[]>([]) const [settings, setSettings] = useState<Setting[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [values, setValues] = useState<Record<string, string>>({}) const [values, setValues] = useState<Record<string, string>>({})
const [defaultPrompt, setDefaultPrompt] = useState('') const [defaultPrompt, setDefaultPrompt] = useState('')
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [savingTz, setSavingTz] = useState(false)
const [savingPrompt, setSavingPrompt] = useState(false) const [savingPrompt, setSavingPrompt] = useState(false)
const [saved, setSaved] = useState(false) const [saved, setSaved] = useState(false)
const [savedTz, setSavedTz] = useState(false)
const [savedPrompt, setSavedPrompt] = useState(false) const [savedPrompt, setSavedPrompt] = useState(false)
useEffect(() => { load() }, []) useEffect(() => { load() }, [])
@ -53,6 +60,13 @@ export function AdminSettings() {
setTimeout(() => setSaved(false), 2000) 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() { async function savePrompt() {
setSavingPrompt(true); setSavedPrompt(false) setSavingPrompt(true); setSavedPrompt(false)
await adminApi.updateSettings([{ key: 'ai_system_prompt', value: values['ai_system_prompt'] ?? '' }]) await adminApi.updateSettings([{ key: 'ai_system_prompt', value: values['ai_system_prompt'] ?? '' }])
@ -75,6 +89,35 @@ export function AdminSettings() {
<p className="text-muted-foreground text-sm">Configuration globale du service</p> <p className="text-muted-foreground text-sm">Configuration globale du service</p>
</div> </div>
{/* Fuseau horaire */}
<Card>
<CardHeader><CardTitle>Fuseau horaire</CardTitle></CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1">
<Label>Timezone (IANA)</Label>
<p className="text-xs text-muted-foreground">
Utilisé pour le planning de scraping et l'horodatage des résumés
</p>
<div className="flex gap-2 max-w-sm">
<Input
list="tz-list"
value={values['timezone'] ?? 'Europe/Paris'}
onChange={e => setValues(v => ({ ...v, timezone: e.target.value }))}
placeholder="Europe/Paris"
/>
<datalist id="tz-list">
{COMMON_TIMEZONES.map(tz => <option key={tz} value={tz} />)}
</datalist>
</div>
</div>
<Button onClick={saveTimezone} disabled={savingTz}>
{savingTz ? <Spinner className="h-4 w-4" /> : <Save className="h-4 w-4" />}
{savedTz ? 'Enregistré !' : 'Enregistrer'}
</Button>
</CardContent>
</Card>
{/* Paramètres généraux */}
<Card> <Card>
<CardHeader><CardTitle>Paramètres généraux</CardTitle></CardHeader> <CardHeader><CardTitle>Paramètres généraux</CardTitle></CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
@ -101,6 +144,7 @@ export function AdminSettings() {
</CardContent> </CardContent>
</Card> </Card>
{/* Contexte IA */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Contexte IA</CardTitle> <CardTitle>Contexte IA</CardTitle>