feat(client): make dashboard greeting respect user timezone

Calculate the dashboard greeting using the user's configured timezone from
account settings. Additionally, add a "night" greeting category for hours
between 00:00 and 04:59, and update the clock/time state every minute to
ensure the greeting stays accurate over time.

Closes #551
This commit is contained in:
neon
2026-07-07 16:22:14 +00:00
parent 4da5554e05
commit 1405fdc13f
3 changed files with 98 additions and 5 deletions
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import { getDashboardGreetingLabel, getHourInTimeZone, resolveDashboardGreetingTimeZone } from './greeting'
describe('dashboard greeting', () => {
it.each([
['00:00', 'night', '2025-12-31T16:00:00.000Z'],
['04:59', 'night', '2025-12-31T20:59:00.000Z'],
['05:00', 'morning', '2025-12-31T21:00:00.000Z'],
['11:59', 'morning', '2026-01-01T03:59:00.000Z'],
['12:00', 'afternoon', '2026-01-01T04:00:00.000Z'],
['17:59', 'afternoon', '2026-01-01T09:59:00.000Z'],
['18:00', 'evening', '2026-01-01T10:00:00.000Z'],
['23:59', 'evening', '2026-01-01T15:59:00.000Z'],
])('returns Asia/Shanghai %s as %s', (_localTime, expected, instant) => {
expect(getDashboardGreetingLabel(new Date(instant), 'Asia/Shanghai')).toBe(expected)
})
it('uses the configured timezone instead of the browser timezone', () => {
const instant = new Date('2026-01-01T21:30:00.000Z')
expect(getDashboardGreetingLabel(instant, 'UTC')).toBe('evening')
expect(getDashboardGreetingLabel(instant, 'Asia/Shanghai')).toBe('morning')
})
it('falls back to UTC when the configured timezone is missing or invalid', () => {
const instant = new Date('2026-01-01T03:30:00.000Z')
expect(resolveDashboardGreetingTimeZone(undefined)).toBe('UTC')
expect(resolveDashboardGreetingTimeZone('Invalid/Zone')).toBe('UTC')
expect(getDashboardGreetingLabel(instant, 'Asia/Shanghai')).toBe('morning')
expect(getDashboardGreetingLabel(instant, 'Invalid/Zone')).toBe('night')
})
it('extracts midnight as hour zero', () => {
expect(getHourInTimeZone(new Date('2025-12-31T16:00:00.000Z'), 'Asia/Shanghai')).toBe(0)
})
})
@@ -0,0 +1,45 @@
export type DashboardGreetingLabel = 'night' | 'morning' | 'afternoon' | 'evening'
const DEFAULT_TIME_ZONE = 'UTC'
export function resolveDashboardGreetingTimeZone(value: string | null | undefined): string {
const normalized = value?.trim()
if (!normalized) return DEFAULT_TIME_ZONE
try {
new Intl.DateTimeFormat('en-US', { timeZone: normalized })
return normalized
} catch {
return DEFAULT_TIME_ZONE
}
}
export function getHourInTimeZone(date: Date, timeZone: string): number {
if (Number.isNaN(date.getTime())) {
throw new RangeError('Invalid date')
}
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: resolveDashboardGreetingTimeZone(timeZone),
hour: '2-digit',
hourCycle: 'h23',
})
const hourPart = formatter.formatToParts(date).find((part) => part.type === 'hour')?.value
const hour = Number(hourPart)
if (hour === 24) return 0
if (!Number.isInteger(hour) || hour < 0 || hour > 23) {
throw new RangeError('Invalid timezone hour')
}
return hour
}
export function getDashboardGreetingLabel(date: Date, timeZone: string | null | undefined): DashboardGreetingLabel {
const hour = getHourInTimeZone(date, resolveDashboardGreetingTimeZone(timeZone))
if (hour < 5) return 'night'
if (hour < 12) return 'morning'
if (hour < 18) return 'afternoon'
return 'evening'
}
+15 -5
View File
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { Settings2, Sparkles } from '@lucide/vue'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useAuth } from '@/features/auth/composables/useAuth'
import { getDashboardGreetingLabel } from '@/features/dashboard/lib/greeting'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useLibraries } from '@/features/library/composables/useLibraries'
import DashboardScroller from '@/features/dashboard/components/DashboardScroller.vue'
@@ -22,6 +23,8 @@ const { maybeStartTour } = useOnboardingTour()
const { smartScopes, loaded: smartScopesLoaded, fetchSmartScopes } = useSmartScopes()
const settingsOpen = ref(false)
const now = ref(new Date())
let greetingTimer: number | null = null
const enabledScrollers = computed(() =>
(Array.isArray(scrollers.value) ? scrollers.value : []).filter((s) => s.enabled).sort((a, b) => a.order - b.order),
@@ -29,10 +32,7 @@ const enabledScrollers = computed(() =>
const hasNoLibraries = computed(() => !librariesLoading.value && libraries.value.length === 0)
const greetingLabel = computed(() => {
const hour = new Date().getHours()
if (hour < 12) return 'morning'
if (hour < 18) return 'afternoon'
return 'evening'
return getDashboardGreetingLabel(now.value, user.value?.settings?.timezone)
})
const greetingName = computed(() => {
const fullName = user.value?.name?.trim()
@@ -52,10 +52,20 @@ watch(
onMounted(() => {
fetchLibraries()
fetchSmartScopes()
greetingTimer = window.setInterval(() => {
now.value = new Date()
}, 60_000)
nextTick(() => {
setTimeout(maybeStartTour, 500)
})
})
onUnmounted(() => {
if (greetingTimer !== null) {
window.clearInterval(greetingTimer)
greetingTimer = null
}
})
</script>
<template>