feat: temperature unit preference and slot timezone display for briefing
- Add °C/°F toggle in briefing settings; persisted in briefing_config.temp_unit - briefing_pipeline reads temp_unit and converts Open-Meteo Celsius values before passing them to the LLM (both full compilation and slot injection) - Scheduled Slots section now shows each UTC slot time converted to the user's browser local time, plus a line confirming which timezone the browser is using Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -328,6 +328,7 @@ export interface BriefingConfig {
|
|||||||
work_days: number[];
|
work_days: number[];
|
||||||
slots: BriefingSlots;
|
slots: BriefingSlots;
|
||||||
notifications: boolean;
|
notifications: boolean;
|
||||||
|
temp_unit: 'C' | 'F';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BriefingFeed {
|
export interface BriefingFeed {
|
||||||
@@ -360,6 +361,7 @@ const DEFAULT_BRIEFING_CONFIG: BriefingConfig = {
|
|||||||
work_days: [1, 2, 3, 4, 5],
|
work_days: [1, 2, 3, 4, 5],
|
||||||
slots: { compilation: true, morning: true, midday: false, afternoon: false },
|
slots: { compilation: true, morning: true, midday: false, afternoon: false },
|
||||||
notifications: true,
|
notifications: true,
|
||||||
|
temp_unit: 'C',
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getBriefingConfig(): Promise<BriefingConfig> {
|
export async function getBriefingConfig(): Promise<BriefingConfig> {
|
||||||
|
|||||||
@@ -173,6 +173,18 @@ function toggleWorkDay(day: number) {
|
|||||||
days.sort();
|
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() {
|
async function saveBriefingSettings() {
|
||||||
briefingSaving.value = true;
|
briefingSaving.value = true;
|
||||||
briefingSaved.value = false;
|
briefingSaved.value = false;
|
||||||
@@ -1316,6 +1328,22 @@ function formatUserDate(iso: string): string {
|
|||||||
</label>
|
</label>
|
||||||
<p class="field-hint">Look up weather for locations in today's calendar events.</p>
|
<p class="field-hint">Look up weather for locations in today's calendar events.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="field" style="margin-top: 1rem">
|
||||||
|
<label class="field-label">Temperature unit</label>
|
||||||
|
<div class="briefing-unit-toggle">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="['briefing-unit-btn', { active: briefingConfig.temp_unit === 'C' }]"
|
||||||
|
@click="briefingConfig.temp_unit = 'C'"
|
||||||
|
>°C</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="['briefing-unit-btn', { active: briefingConfig.temp_unit === 'F' }]"
|
||||||
|
@click="briefingConfig.temp_unit = 'F'"
|
||||||
|
>°F</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Work schedule -->
|
<!-- Work schedule -->
|
||||||
@@ -1336,27 +1364,33 @@ function formatUserDate(iso: string): string {
|
|||||||
<!-- Slots -->
|
<!-- Slots -->
|
||||||
<section class="settings-section full-width">
|
<section class="settings-section full-width">
|
||||||
<h2>Scheduled Slots</h2>
|
<h2>Scheduled Slots</h2>
|
||||||
<p class="section-desc">Each active slot will post an update into your briefing conversation.</p>
|
<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>
|
||||||
<div class="briefing-slot-list">
|
<div class="briefing-slot-list">
|
||||||
<div
|
<div
|
||||||
v-for="[key, label, time] in ([
|
v-for="[key, label, utcHour] in ([
|
||||||
['compilation', 'Morning briefing', '4:00 am'],
|
['compilation', 'Morning briefing', 4],
|
||||||
['morning', 'Office check-in', '8:00 am'],
|
['morning', 'Office check-in', 8],
|
||||||
['midday', 'Midday update', '12:00 pm'],
|
['midday', 'Midday update', 12],
|
||||||
['afternoon', 'End of day', '4:00 pm'],
|
['afternoon', 'End of day', 16],
|
||||||
] as const)"
|
] as const)"
|
||||||
:key="key"
|
:key="key"
|
||||||
class="briefing-slot-row"
|
class="briefing-slot-row"
|
||||||
>
|
>
|
||||||
<div class="briefing-slot-info">
|
<div class="briefing-slot-info">
|
||||||
<span class="briefing-slot-label">{{ label }}</span>
|
<span class="briefing-slot-label">{{ label }}</span>
|
||||||
<span class="briefing-slot-time">{{ time }}</span>
|
<span class="briefing-slot-time">{{ utcSlotToLocal(utcHour) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<label>
|
<label>
|
||||||
<input type="checkbox" v-model="briefingConfig.slots[key]" />
|
<input type="checkbox" v-model="briefingConfig.slots[key]" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="field-hint" style="margin-top: 0.5rem">
|
||||||
|
Your browser's local timezone: <strong>{{ Intl.DateTimeFormat().resolvedOptions().timeZone }}</strong>
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- RSS Feeds -->
|
<!-- RSS Feeds -->
|
||||||
@@ -2697,6 +2731,32 @@ function formatUserDate(iso: string): string {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
.briefing-unit-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
width: fit-content;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
.briefing-unit-btn {
|
||||||
|
padding: 0.35rem 1rem;
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.briefing-unit-btn:first-child {
|
||||||
|
border-right: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.briefing-unit-btn.active {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
.briefing-day-btn {
|
.briefing-day-btn {
|
||||||
padding: 0.35rem 0.65rem;
|
padding: 0.35rem 0.65rem;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
|
|||||||
@@ -192,16 +192,26 @@ def _internal_user_prompt(data: dict, slot: str) -> str:
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def _external_user_prompt(data: dict, slot: str) -> str:
|
def _format_temp(value: float, unit: str) -> str:
|
||||||
|
"""Convert Celsius to the requested unit and format as an integer string."""
|
||||||
|
if unit == "F":
|
||||||
|
return f"{value * 9 / 5 + 32:.0f}"
|
||||||
|
return f"{value:.0f}"
|
||||||
|
|
||||||
|
|
||||||
|
def _external_user_prompt(data: dict, slot: str, temp_unit: str = "C") -> str:
|
||||||
|
unit_sym = f"°{temp_unit}"
|
||||||
lines = [f"Briefing slot: {slot}", ""]
|
lines = [f"Briefing slot: {slot}", ""]
|
||||||
if data["weather"]:
|
if data["weather"]:
|
||||||
lines.append("WEATHER:")
|
lines.append("WEATHER:")
|
||||||
for loc in data["weather"]:
|
for loc in data["weather"]:
|
||||||
lines.append(f" {loc['location_label']}:")
|
lines.append(f" {loc['location_label']}:")
|
||||||
for day in loc["days"][:3]:
|
for day in loc["days"][:3]:
|
||||||
|
t_min = _format_temp(day["temp_min"], temp_unit)
|
||||||
|
t_max = _format_temp(day["temp_max"], temp_unit)
|
||||||
lines.append(
|
lines.append(
|
||||||
f" {day['date']}: {day['description']}, "
|
f" {day['date']}: {day['description']}, "
|
||||||
f"{day['temp_min']}–{day['temp_max']}°C, {day['precip_mm']}mm rain"
|
f"{t_min}–{t_max}{unit_sym}, {day['precip_mm']}mm rain"
|
||||||
)
|
)
|
||||||
if loc["changes_since_last_fetch"]:
|
if loc["changes_since_last_fetch"]:
|
||||||
lines.append(" FORECAST CHANGES:")
|
lines.append(" FORECAST CHANGES:")
|
||||||
@@ -218,6 +228,18 @@ def _external_user_prompt(data: dict, slot: str) -> str:
|
|||||||
|
|
||||||
# ── Main entry point ───────────────────────────────────────────────────────────
|
# ── Main entry point ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _get_temp_unit(user_id: int) -> str:
|
||||||
|
"""Read the user's preferred temperature unit from briefing_config ('C' or 'F')."""
|
||||||
|
import json
|
||||||
|
raw = await get_setting(user_id, "briefing_config", "{}")
|
||||||
|
try:
|
||||||
|
config = json.loads(raw) if isinstance(raw, str) else (raw or {})
|
||||||
|
unit = config.get("temp_unit", "C")
|
||||||
|
return unit if unit in ("C", "F") else "C"
|
||||||
|
except Exception:
|
||||||
|
return "C"
|
||||||
|
|
||||||
|
|
||||||
async def run_compilation(user_id: int, slot: str, model: str | None = None) -> str:
|
async def run_compilation(user_id: int, slot: str, model: str | None = None) -> str:
|
||||||
"""
|
"""
|
||||||
Run the full two-lane briefing pipeline for a user and slot.
|
Run the full two-lane briefing pipeline for a user and slot.
|
||||||
@@ -227,7 +249,10 @@ async def run_compilation(user_id: int, slot: str, model: str | None = None) ->
|
|||||||
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
|
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
|
||||||
|
|
||||||
from fabledassistant.services.briefing_profile import get_profile_body
|
from fabledassistant.services.briefing_profile import get_profile_body
|
||||||
profile_body = await get_profile_body(user_id)
|
profile_body, temp_unit = await asyncio.gather(
|
||||||
|
get_profile_body(user_id),
|
||||||
|
_get_temp_unit(user_id),
|
||||||
|
)
|
||||||
|
|
||||||
# Parallel gather
|
# Parallel gather
|
||||||
internal_data, external_data = await asyncio.gather(
|
internal_data, external_data = await asyncio.gather(
|
||||||
@@ -244,7 +269,7 @@ async def run_compilation(user_id: int, slot: str, model: str | None = None) ->
|
|||||||
),
|
),
|
||||||
_llm_synthesise(
|
_llm_synthesise(
|
||||||
_external_system_prompt(),
|
_external_system_prompt(),
|
||||||
_external_user_prompt(external_data, slot),
|
_external_user_prompt(external_data, slot, temp_unit),
|
||||||
model,
|
model,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -268,9 +293,10 @@ async def run_slot_injection(user_id: int, slot: str, model: str | None = None)
|
|||||||
if model is None:
|
if model is None:
|
||||||
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
|
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
|
||||||
|
|
||||||
internal_data, external_data = await asyncio.gather(
|
internal_data, external_data, temp_unit = await asyncio.gather(
|
||||||
_gather_internal(user_id),
|
_gather_internal(user_id),
|
||||||
_gather_external(user_id),
|
_gather_external(user_id),
|
||||||
|
_get_temp_unit(user_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
system = (
|
system = (
|
||||||
@@ -281,6 +307,6 @@ async def run_slot_injection(user_id: int, slot: str, model: str | None = None)
|
|||||||
f"Slot: {slot}\n\n"
|
f"Slot: {slot}\n\n"
|
||||||
+ _internal_user_prompt(internal_data, slot)
|
+ _internal_user_prompt(internal_data, slot)
|
||||||
+ "\n\n"
|
+ "\n\n"
|
||||||
+ _external_user_prompt(external_data, slot)
|
+ _external_user_prompt(external_data, slot, temp_unit)
|
||||||
)
|
)
|
||||||
return await _llm_synthesise(system, user_prompt, model)
|
return await _llm_synthesise(system, user_prompt, model)
|
||||||
|
|||||||
Reference in New Issue
Block a user