notifications: correct timezone handling + configurable site timezone
Notification start/end times displayed and stored wrong by the tz offset (a 2:34 PM entry showed 6:34 PM). Two stacked bugs: to_dict emitted stored UTC as naive ISO (no offset) so the browser read it as local, and the form filled the datetime-local input from toISOString() (UTC). Fix and generalize to a configurable site timezone (multi-site): - New setting site_timezone (default America/New_York), public, editable in Settings > Site > Localization (common-zone dropdown). - Backend tags datetimes UTC (_utc_iso); parse normalizes to naive UTC (_parse_utc); daily-reset expiry uses the site zone (_next_site_time); calendar allDay events key off the site-local day (_site_date). - Shared frontend util datetime.js (Intl-based, DST-safe) converts between a UTC instant and a site-zone wall clock. Notification form, list, and calendar all render/enter in the site zone.
This commit is contained in:
@@ -169,7 +169,7 @@
|
||||
Now
|
||||
</button>
|
||||
</div>
|
||||
<small class="form-hint">When notification becomes visible</small>
|
||||
<small class="form-hint">When notification becomes visible ({{ siteTimezone }})</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -243,8 +243,9 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { notificationsApi, applicationsApi, businessUnitsApi, employeesApi } from '@/api'
|
||||
import { notificationsApi, applicationsApi, businessUnitsApi, employeesApi, settingsApi } from '@/api'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
import { zonedInputFromUtc, utcFromZonedInput, DEFAULT_TZ } from '@/utils/datetime'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -278,6 +279,10 @@ const form = ref({
|
||||
employeesso: ''
|
||||
})
|
||||
|
||||
// Site timezone (settings key site_timezone) so form times show/enter in the
|
||||
// site's wall clock, not the viewer's browser zone. Loaded in onMounted.
|
||||
const siteTimezone = ref(DEFAULT_TZ)
|
||||
|
||||
function selectedTypeName() {
|
||||
const selectedType = types.value.find(t => t.notificationtypeid === parseInt(form.value.notificationtypeid))
|
||||
return selectedType?.typename?.toLowerCase() || ''
|
||||
@@ -303,13 +308,18 @@ const messagePlaceholder = computed(() =>
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// Load dropdown data in parallel
|
||||
const [typesRes, buRes, appsRes] = await Promise.all([
|
||||
// Load dropdown data + the site timezone in parallel. Resolve the tz FIRST
|
||||
// (before any formatDateForInput) so times render in the site's zone.
|
||||
const [typesRes, buRes, appsRes, tzRes] = await Promise.all([
|
||||
notificationsApi.types.list(),
|
||||
businessUnitsApi.list().catch(() => ({ data: { data: [] } })),
|
||||
applicationsApi.list({ perpage: 500 }).catch(() => ({ data: { data: [] } }))
|
||||
applicationsApi.list({ perpage: 500 }).catch(() => ({ data: { data: [] } })),
|
||||
settingsApi.get('site_timezone').catch(() => null)
|
||||
])
|
||||
|
||||
const tzValue = tzRes?.data?.data?.value
|
||||
if (tzValue) siteTimezone.value = tzValue
|
||||
|
||||
types.value = typesRes.data.data || []
|
||||
businessUnits.value = buRes.data?.data || []
|
||||
applications.value = appsRes.data?.data || []
|
||||
@@ -364,14 +374,13 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
// UTC value from the API -> a datetime-local string in the SITE timezone.
|
||||
function formatDateForInput(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
return date.toISOString().slice(0, 16)
|
||||
return zonedInputFromUtc(dateStr, siteTimezone.value)
|
||||
}
|
||||
|
||||
function setNow(field) {
|
||||
form.value[field] = formatDateForInput(new Date().toISOString())
|
||||
form.value[field] = zonedInputFromUtc(new Date(), siteTimezone.value)
|
||||
}
|
||||
|
||||
function onTypeChange() {
|
||||
@@ -379,7 +388,7 @@ function onTypeChange() {
|
||||
// (recognition clears at 8 AM Eastern; recertification runs two weeks). Set
|
||||
// start to now and leave end blank so the backend applies the per-type rule.
|
||||
if (isEmployeeType.value) {
|
||||
form.value.starttime = formatDateForInput(new Date().toISOString())
|
||||
form.value.starttime = zonedInputFromUtc(new Date(), siteTimezone.value)
|
||||
form.value.endtime = ''
|
||||
}
|
||||
}
|
||||
@@ -481,8 +490,8 @@ async function saveNotification() {
|
||||
appid: parseInt(form.value.appid) || null,
|
||||
ticketnumber: form.value.ticketnumber || null,
|
||||
link: form.value.link || null,
|
||||
starttime: form.value.starttime ? new Date(form.value.starttime).toISOString() : null,
|
||||
endtime: form.value.endtime ? new Date(form.value.endtime).toISOString() : null,
|
||||
starttime: utcFromZonedInput(form.value.starttime, siteTimezone.value),
|
||||
endtime: utcFromZonedInput(form.value.endtime, siteTimezone.value),
|
||||
isactive: form.value.isactive,
|
||||
isshopfloor: form.value.isshopfloor,
|
||||
employeesso: form.value.employeesso || null
|
||||
|
||||
Reference in New Issue
Block a user