feat: add schedule UI — day picker, time, emails in theme management

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
master
oabrivard 3 months ago
parent a068d04fa8
commit f989f592a9

@ -0,0 +1,35 @@
import { api } from './client';
// ---- Schedule types ----
export interface ScheduleResponse {
id: string;
theme_id: string;
enabled: boolean;
days: string[];
time_utc: string;
emails: string[];
last_run_at: string | null;
}
export interface UpsertScheduleRequest {
enabled: boolean;
days: string[];
time_utc: string;
emails: string[];
}
/** Schedules API endpoints (per-theme generation schedule). */
export const schedulesApi = {
/** GET /themes/:themeId/schedule -- retrieve the schedule for a theme (404 if none). */
get: (themeId: string): Promise<ScheduleResponse> =>
api.get<ScheduleResponse>(`/themes/${themeId}/schedule`),
/** PUT /themes/:themeId/schedule -- create or update the schedule for a theme. */
upsert: (themeId: string, data: UpsertScheduleRequest): Promise<ScheduleResponse> =>
api.put<ScheduleResponse>(`/themes/${themeId}/schedule`, data),
/** DELETE /themes/:themeId/schedule -- remove the schedule for a theme. */
remove: (themeId: string): Promise<void> =>
api.delete<void>(`/themes/${themeId}/schedule`),
};

@ -0,0 +1,286 @@
import { type Component, createSignal, onMount, For, Show } from 'solid-js';
import { Trash2, Plus } from 'lucide-solid';
import { schedulesApi } from '~/api/schedules';
import type { ScheduleResponse } from '~/api/schedules';
import { useI18n } from '~/i18n';
import { isApiError } from '~/types';
import { useToast } from '~/components/ui/Toast';
interface SettingsScheduleProps {
themeId: string;
}
const DAYS = [
{ code: 'mon', labelKey: 'schedule.dayMon' as const },
{ code: 'tue', labelKey: 'schedule.dayTue' as const },
{ code: 'wed', labelKey: 'schedule.dayWed' as const },
{ code: 'thu', labelKey: 'schedule.dayThu' as const },
{ code: 'fri', labelKey: 'schedule.dayFri' as const },
{ code: 'sat', labelKey: 'schedule.daySat' as const },
{ code: 'sun', labelKey: 'schedule.daySun' as const },
];
/**
* Schedule configuration section within the ThemeManager.
*
* Loads the existing schedule on mount (treats 404 as "no schedule yet"),
* and auto-saves on every change. Grays out the form when the schedule is
* disabled, but keeps it visible so users can still configure it before
* re-enabling.
*/
const SettingsSchedule: Component<SettingsScheduleProps> = (props) => {
const { t } = useI18n();
const { addToast } = useToast();
const [schedule, setSchedule] = createSignal<ScheduleResponse | null>(null);
const [enabled, setEnabled] = createSignal(false);
const [selectedDays, setSelectedDays] = createSignal<string[]>([]);
const [timeUtc, setTimeUtc] = createSignal('08:00');
const [emails, setEmails] = createSignal<string[]>([]);
const [newEmail, setNewEmail] = createSignal('');
const [saving, setSaving] = createSignal(false);
// ---- Load schedule on mount ----
onMount(async () => {
try {
const data = await schedulesApi.get(props.themeId);
setSchedule(data);
setEnabled(data.enabled);
setSelectedDays(data.days);
setTimeUtc(data.time_utc);
setEmails(data.emails);
} catch (err) {
// 404 means no schedule configured yet — that's fine
if (isApiError(err) && err.status === 404) {
setSchedule(null);
} else {
console.error('Failed to load schedule:', err);
}
}
});
// ---- Auto-save helper ----
const save = async (patch: {
enabled?: boolean;
days?: string[];
time_utc?: string;
emails?: string[];
}) => {
setSaving(true);
try {
const updated = await schedulesApi.upsert(props.themeId, {
enabled: patch.enabled ?? enabled(),
days: patch.days ?? selectedDays(),
time_utc: patch.time_utc ?? timeUtc(),
emails: patch.emails ?? emails(),
});
setSchedule(updated);
addToast({ type: 'success', message: t('schedule.saved'), duration: 3000 });
} catch (err) {
const msg = isApiError(err) ? err.message : t('common.error');
addToast({ type: 'error', message: msg, duration: 5000 });
} finally {
setSaving(false);
}
};
// ---- Toggle enabled ----
const handleToggleEnabled = (checked: boolean) => {
setEnabled(checked);
void save({ enabled: checked });
};
// ---- Toggle a day ----
const toggleDay = (code: string) => {
const next = selectedDays().includes(code)
? selectedDays().filter((d) => d !== code)
: [...selectedDays(), code];
setSelectedDays(next);
void save({ days: next });
};
// ---- Time change ----
const handleTimeChange = (value: string) => {
setTimeUtc(value);
void save({ time_utc: value });
};
// ---- Email management ----
const handleAddEmail = () => {
const email = newEmail().trim();
if (!email || emails().length >= 3 || emails().includes(email)) return;
const next = [...emails(), email];
setEmails(next);
setNewEmail('');
void save({ emails: next });
};
const handleRemoveEmail = (email: string) => {
const next = emails().filter((e) => e !== email);
setEmails(next);
void save({ emails: next });
};
// ---- Delete schedule ----
const handleDelete = async () => {
try {
await schedulesApi.remove(props.themeId);
setSchedule(null);
setEnabled(false);
setSelectedDays([]);
setTimeUtc('08:00');
setEmails([]);
addToast({ type: 'success', message: t('schedule.deleted'), duration: 3000 });
} catch (err) {
const msg = isApiError(err) ? err.message : t('common.error');
addToast({ type: 'error', message: msg, duration: 5000 });
}
};
const isDisabled = () => !enabled();
return (
<div class="mb-8">
<h2 class="text-xl font-semibold text-gray-900 mb-1">{t('schedule.title')}</h2>
<p class="text-sm text-gray-500 mb-4">
{t('schedule.noSchedule')}
</p>
<div class="bg-white shadow-sm rounded-lg border border-gray-200 p-6 space-y-4">
{/* Enable toggle */}
<div class="flex items-center gap-3">
<input
type="checkbox"
id="schedule-enabled"
checked={enabled()}
onChange={(e) => handleToggleEnabled(e.currentTarget.checked)}
class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
/>
<label
for="schedule-enabled"
class="text-sm font-medium text-gray-700"
>
{t('schedule.enabled')}
</label>
<Show when={saving()}>
<div class="animate-spin rounded-full h-3 w-3 border-b-2 border-indigo-600" />
</Show>
</div>
{/* Day picker */}
<div class={isDisabled() ? 'opacity-50 pointer-events-none' : ''}>
<label class="block text-sm font-medium text-gray-700 mb-2">
{t('schedule.days')}
</label>
<div class="flex gap-2">
<For each={DAYS}>
{(day) => (
<button
type="button"
class={`w-9 h-9 rounded-full text-sm font-medium transition-colors ${
selectedDays().includes(day.code)
? 'bg-indigo-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
onClick={() => toggleDay(day.code)}
>
{t(day.labelKey)}
</button>
)}
</For>
</div>
</div>
{/* Time input */}
<div class={isDisabled() ? 'opacity-50 pointer-events-none' : ''}>
<label
for="schedule-time"
class="block text-sm font-medium text-gray-700 mb-1"
>
{t('schedule.time')}
</label>
<input
type="time"
id="schedule-time"
value={timeUtc()}
onChange={(e) => handleTimeChange(e.currentTarget.value)}
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md py-2 px-3 border"
/>
</div>
{/* Email recipients */}
<div class={isDisabled() ? 'opacity-50 pointer-events-none' : ''}>
<label class="block text-sm font-medium text-gray-700 mb-2">
{t('schedule.emails')}
</label>
{/* Existing emails */}
<div class="space-y-2 mb-3">
<For each={emails()}>
{(email) => (
<div class="flex items-center gap-2">
<span class="text-sm text-gray-700 flex-1 font-mono">{email}</span>
<button
type="button"
onClick={() => handleRemoveEmail(email)}
class="p-1 text-gray-400 hover:text-red-600 transition-colors"
title={t('common.delete')}
>
<Trash2 class="h-4 w-4" />
</button>
</div>
)}
</For>
</div>
{/* Add email row (hidden when 3 reached) */}
<Show when={emails().length < 3}>
<div class="flex gap-2">
<input
type="email"
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block flex-1 sm:text-sm border-gray-300 rounded-md py-2 px-3 border"
placeholder={t('schedule.addEmail')}
value={newEmail()}
onInput={(e) => setNewEmail(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddEmail();
}
}}
/>
<button
type="button"
onClick={handleAddEmail}
disabled={!newEmail().trim()}
class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
>
<Plus class="h-4 w-4 mr-1" />
{t('schedule.addEmail')}
</button>
</div>
</Show>
<Show when={emails().length >= 3}>
<p class="text-xs text-gray-500">{t('schedule.maxEmails')}</p>
</Show>
</div>
{/* Delete schedule (only shown when a schedule exists) */}
<Show when={schedule()}>
<div class="pt-2 border-t border-gray-100">
<button
type="button"
onClick={handleDelete}
class="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded-md text-red-700 bg-red-50 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-red-500"
>
<Trash2 class="h-3 w-3 mr-1.5" />
{t('common.delete')}
</button>
</div>
</Show>
</div>
</div>
);
};
export default SettingsSchedule;

@ -425,6 +425,25 @@ const fr = {
'llmLogs.back': 'Retour', 'llmLogs.back': 'Retour',
'llmLogs.articleUrl': 'Article', 'llmLogs.articleUrl': 'Article',
// Schedule
'schedule.title': 'Planification',
'schedule.enabled': 'Planification activee',
'schedule.days': 'Jours',
'schedule.time': 'Heure (UTC)',
'schedule.emails': 'Destinataires',
'schedule.addEmail': 'Ajouter une adresse',
'schedule.maxEmails': '3 adresses maximum',
'schedule.saved': 'Planification enregistree',
'schedule.deleted': 'Planification supprimee',
'schedule.noSchedule': 'Aucune planification configuree.',
'schedule.dayMon': 'L',
'schedule.dayTue': 'M',
'schedule.dayWed': 'M',
'schedule.dayThu': 'J',
'schedule.dayFri': 'V',
'schedule.daySat': 'S',
'schedule.daySun': 'D',
// Common // Common
'common.loading': 'Chargement...', 'common.loading': 'Chargement...',
'common.error': 'Une erreur est survenue.', 'common.error': 'Une erreur est survenue.',

@ -25,6 +25,7 @@ import { useI18n } from '~/i18n';
import { isApiError } from '~/types'; import { isApiError } from '~/types';
import type { Source } from '~/types'; import type { Source } from '~/types';
import LoadingSpinner from '~/components/ui/LoadingSpinner'; import LoadingSpinner from '~/components/ui/LoadingSpinner';
import SettingsSchedule from '~/components/settings/SettingsSchedule';
/** /**
* Theme management page ("Personnaliser les syntheses"). * Theme management page ("Personnaliser les syntheses").
@ -908,6 +909,9 @@ const ThemeManager: Component = () => {
</Show> </Show>
</div> </div>
{/* ── Schedule card ── */}
<SettingsSchedule themeId={selectedThemeId()!} />
{/* ── Delete theme ── */} {/* ── Delete theme ── */}
<div class="mt-8"> <div class="mt-8">
<Button <Button

Loading…
Cancel
Save