feat: per-user timezone-aware briefing scheduler
Briefing slots (4am/8am/12pm/4pm) now fire in each user's local timezone rather than UTC, so the schedule matches the user's actual day. Backend: - briefing_scheduler: replaced 4 global UTC cron jobs with per-user CronTrigger jobs keyed to the user's IANA timezone; update_user_schedule() live-patches the scheduler when config is saved (no restart needed) - Catchup logic evaluates missed slots in the user's local timezone - put_config route calls update_user_schedule() after saving Frontend: - New Timezone section in Briefing settings: text input pre-filled from browser (Intl.DateTimeFormat) with a Detect button for re-detection - Slot times shown as fixed local times (4:00 am etc.) with the configured timezone displayed beneath, replacing the old UTC-conversion display - timezone field added to BriefingConfig type and default Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -138,6 +138,10 @@ const addingFeed = ref(false);
|
||||
async function loadBriefingTab() {
|
||||
briefingConfig.value = await getBriefingConfig();
|
||||
briefingFeeds.value = await getBriefingFeeds();
|
||||
// Auto-populate timezone from browser if not already stored.
|
||||
if (!briefingConfig.value.timezone) {
|
||||
briefingConfig.value.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
}
|
||||
}
|
||||
|
||||
async function geocodeLocation(key: 'home' | 'work') {
|
||||
@@ -173,17 +177,6 @@ function toggleWorkDay(day: number) {
|
||||
days.sort();
|
||||
}
|
||||
|
||||
/** Convert a UTC hour (0–23) to the browser's local time string, e.g. "6:00 am (UTC 4:00)" */
|
||||
function utcSlotToLocal(utcHour: number): string {
|
||||
const now = new Date();
|
||||
const utcMs = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), utcHour, 0, 0);
|
||||
const local = new Date(utcMs);
|
||||
const localStr = local.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||
const utcStr = utcHour === 0 ? '12:00 am' : utcHour < 12
|
||||
? `${utcHour}:00 am`
|
||||
: utcHour === 12 ? '12:00 pm' : `${utcHour - 12}:00 pm`;
|
||||
return `${localStr} (UTC ${utcStr})`;
|
||||
}
|
||||
|
||||
async function saveBriefingSettings() {
|
||||
briefingSaving.value = true;
|
||||
@@ -1361,35 +1354,61 @@ function formatUserDate(iso: string): string {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Timezone -->
|
||||
<section class="settings-section full-width">
|
||||
<h2>Timezone</h2>
|
||||
<p class="section-desc">
|
||||
Briefing slots fire at the times below in this timezone.
|
||||
Auto-detected from your browser — override if the server should use a different zone.
|
||||
</p>
|
||||
<div class="briefing-timezone-row">
|
||||
<input
|
||||
type="text"
|
||||
class="input"
|
||||
v-model="briefingConfig.timezone"
|
||||
placeholder="e.g. America/New_York"
|
||||
style="flex: 1"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary"
|
||||
@click="briefingConfig.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone"
|
||||
>Detect</button>
|
||||
</div>
|
||||
<p class="field-hint">
|
||||
Use an
|
||||
<a href="https://en.wikipedia.org/wiki/List_of_tz_database_time_zones" target="_blank" rel="noopener">IANA timezone name</a>
|
||||
(e.g. <code>Europe/London</code>, <code>America/Chicago</code>).
|
||||
Your browser reports: <strong>{{ Intl.DateTimeFormat().resolvedOptions().timeZone }}</strong>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Slots -->
|
||||
<section class="settings-section full-width">
|
||||
<h2>Scheduled Slots</h2>
|
||||
<p class="section-desc">
|
||||
Each active slot posts an update into your briefing conversation.
|
||||
Slots run on the server in UTC — your local equivalent is shown below.
|
||||
</p>
|
||||
<p class="section-desc">Each active slot posts an update into your briefing conversation at the listed local time.</p>
|
||||
<div class="briefing-slot-list">
|
||||
<div
|
||||
v-for="[key, label, utcHour] in ([
|
||||
['compilation', 'Morning briefing', 4],
|
||||
['morning', 'Office check-in', 8],
|
||||
['midday', 'Midday update', 12],
|
||||
['afternoon', 'End of day', 16],
|
||||
v-for="[key, label, localTime] in ([
|
||||
['compilation', 'Morning briefing', '4:00 am'],
|
||||
['morning', 'Office check-in', '8:00 am'],
|
||||
['midday', 'Midday update', '12:00 pm'],
|
||||
['afternoon', 'End of day', '4:00 pm'],
|
||||
] as const)"
|
||||
:key="key"
|
||||
class="briefing-slot-row"
|
||||
>
|
||||
<div class="briefing-slot-info">
|
||||
<span class="briefing-slot-label">{{ label }}</span>
|
||||
<span class="briefing-slot-time">{{ utcSlotToLocal(utcHour) }}</span>
|
||||
<span class="briefing-slot-time">{{ localTime }}</span>
|
||||
</div>
|
||||
<label>
|
||||
<input type="checkbox" v-model="briefingConfig.slots[key]" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p class="field-hint" style="margin-top: 0.5rem">
|
||||
Your browser's local timezone: <strong>{{ Intl.DateTimeFormat().resolvedOptions().timeZone }}</strong>
|
||||
<p v-if="briefingConfig.timezone" class="field-hint" style="margin-top: 0.5rem">
|
||||
Firing in timezone: <strong>{{ briefingConfig.timezone }}</strong>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -2731,6 +2750,12 @@ function formatUserDate(iso: string): string {
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.briefing-timezone-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.briefing-unit-toggle {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
|
||||
Reference in New Issue
Block a user