mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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'
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user