fix(home): shrink frame card, three-state status, draggable sheet, label overlap
CI / test (push) Has been cancelled
CI / test (push) Has been cancelled
- HomeView clears the bottom nav so + Add Photo isn't covered.
- Cap large frame-card preview to min(240px, 30dvh) so portrait frames
no longer dominate the screen at full mobile width.
- Three-state device status — green/Online (recent sync), yellow/Sync
issue (one window missed), red/Offline (two+ windows missed). Window
is rotationIntervalMinutes for interval-mode devices, 24h for daily
wakeHour-mode devices.
- Show last-sync ("synced 2h ago") and next-expected-sync line on the
large card. wakeHour devices show local-hour ("next sync ~4 AM
tomorrow") in the device's configured timezone.
- BaseBottomSheet drag-to-dismiss on the handle. Touch and pointer
events; releases past 80px close the sheet. Snaps back below.
- BaseInput floating label rewrite — taller field, label re-anchors
to top: 8px when filled/focused so it sits cleanly above the value
instead of overlapping it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,9 +13,21 @@
|
||||
<div
|
||||
ref="sheetRef"
|
||||
class="sheet"
|
||||
:class="{ 'sheet--dragging': isDragging }"
|
||||
:style="dragY > 0 ? { transform: `translateY(${dragY}px)` } : undefined"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="sheet__handle" aria-hidden="true" />
|
||||
<div
|
||||
class="sheet__handle-target"
|
||||
@touchstart.passive="onDragStart"
|
||||
@touchmove.passive="onDragMove"
|
||||
@touchend="onDragEnd"
|
||||
@touchcancel="onDragEnd"
|
||||
@pointerdown="onPointerStart"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="sheet__handle" />
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
@@ -36,12 +48,67 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const sheetRef = ref<HTMLElement | null>(null)
|
||||
const dragY = ref(0)
|
||||
const isDragging = ref(false)
|
||||
let dragStartY = 0
|
||||
let pointerId: number | null = null
|
||||
let triggerEl: HTMLElement | null = null
|
||||
|
||||
const DISMISS_THRESHOLD = 80
|
||||
|
||||
function close() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
function onDragStart(e: TouchEvent) {
|
||||
dragStartY = e.touches[0].clientY
|
||||
isDragging.value = true
|
||||
dragY.value = 0
|
||||
}
|
||||
|
||||
function onDragMove(e: TouchEvent) {
|
||||
if (!isDragging.value) return
|
||||
const delta = e.touches[0].clientY - dragStartY
|
||||
dragY.value = delta > 0 ? delta : 0
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
if (!isDragging.value) return
|
||||
isDragging.value = false
|
||||
if (dragY.value > DISMISS_THRESHOLD) {
|
||||
close()
|
||||
}
|
||||
dragY.value = 0
|
||||
}
|
||||
|
||||
// Pointer events let desktop / non-touch testing exercise the drag too.
|
||||
function onPointerStart(e: PointerEvent) {
|
||||
if (e.pointerType === 'touch') return // touch events handle this
|
||||
dragStartY = e.clientY
|
||||
isDragging.value = true
|
||||
dragY.value = 0
|
||||
pointerId = e.pointerId
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('pointerup', onPointerEnd)
|
||||
window.addEventListener('pointercancel', onPointerEnd)
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!isDragging.value || e.pointerId !== pointerId) return
|
||||
const delta = e.clientY - dragStartY
|
||||
dragY.value = delta > 0 ? delta : 0
|
||||
}
|
||||
|
||||
function onPointerEnd(e: PointerEvent) {
|
||||
if (e.pointerId !== pointerId) return
|
||||
pointerId = null
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', onPointerEnd)
|
||||
window.removeEventListener('pointercancel', onPointerEnd)
|
||||
onDragEnd()
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, async (open) => {
|
||||
if (open) {
|
||||
triggerEl = document.activeElement as HTMLElement
|
||||
@@ -50,6 +117,8 @@ watch(() => props.modelValue, async (open) => {
|
||||
} else {
|
||||
triggerEl?.focus()
|
||||
triggerEl = null
|
||||
dragY.value = 0
|
||||
isDragging.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -68,17 +137,32 @@ watch(() => props.modelValue, async (open) => {
|
||||
width: 100%;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
padding: var(--space-3) var(--space-4) calc(var(--space-6) + env(safe-area-inset-bottom));
|
||||
padding: 0 var(--space-4) calc(var(--space-6) + env(safe-area-inset-bottom));
|
||||
max-height: 90dvh;
|
||||
overflow-y: auto;
|
||||
outline: none;
|
||||
transition: transform 200ms var(--ease-out);
|
||||
|
||||
&--dragging {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
&__handle-target {
|
||||
padding: var(--space-3) 0 var(--space-4);
|
||||
margin: 0 calc(-1 * var(--space-4));
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
|
||||
&:active { cursor: grabbing; }
|
||||
}
|
||||
|
||||
&__handle {
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-border);
|
||||
margin: 0 auto var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,8 +46,8 @@ const inputId = computed(() => props.id ?? `input-${Math.random().toString(36).s
|
||||
|
||||
&__field {
|
||||
width: 100%;
|
||||
min-height: var(--touch-min);
|
||||
padding: var(--space-4) var(--space-4) var(--space-2);
|
||||
min-height: 56px;
|
||||
padding: 22px var(--space-4) 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
@@ -67,8 +67,10 @@ const inputId = computed(() => props.id ?? `input-${Math.random().toString(36).s
|
||||
|
||||
&:not(:placeholder-shown) ~ .input-wrap__label,
|
||||
&:focus ~ .input-wrap__label {
|
||||
transform: translateY(-10px) scale(0.78);
|
||||
top: 8px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-primary);
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,8 +82,7 @@ const inputId = computed(() => props.id ?? `input-${Math.random().toString(36).s
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-base);
|
||||
pointer-events: none;
|
||||
transform-origin: left center;
|
||||
transition: transform var(--duration-fast), color var(--duration-fast);
|
||||
transition: top var(--duration-fast), font-size var(--duration-fast), color var(--duration-fast), transform var(--duration-fast);
|
||||
}
|
||||
|
||||
&--error &__field {
|
||||
|
||||
@@ -3,15 +3,9 @@
|
||||
:class="[
|
||||
'frame-card',
|
||||
`frame-card--${size}`,
|
||||
status !== 'ok' && `frame-card--${status}`,
|
||||
`frame-card--${status}`,
|
||||
]"
|
||||
>
|
||||
<!-- Status badge (color + text — never color alone) -->
|
||||
<div v-if="status !== 'ok'" class="frame-card__status-badge" aria-live="polite">
|
||||
<span class="frame-card__status-dot" aria-hidden="true" />
|
||||
{{ status === 'offline' ? 'Offline' : 'Sync issue' }}
|
||||
</div>
|
||||
|
||||
<!-- Settings button (large card only) -->
|
||||
<button
|
||||
v-if="size === 'large'"
|
||||
@@ -44,14 +38,25 @@
|
||||
</div>
|
||||
|
||||
<div class="frame-card__body">
|
||||
<p class="frame-card__name">{{ name }}</p>
|
||||
<p v-if="size === 'compact'" class="frame-card__count">
|
||||
{{ photoCount }} {{ photoCount === 1 ? 'photo' : 'photos' }}
|
||||
</p>
|
||||
<div class="frame-card__info">
|
||||
<p class="frame-card__name">{{ name }}</p>
|
||||
<p class="frame-card__status-line" aria-live="polite">
|
||||
<span class="frame-card__status-dot" aria-hidden="true" />
|
||||
<span class="frame-card__status-text">{{ statusText }}</span>
|
||||
</p>
|
||||
<p v-if="size === 'large' && (lastSync || nextSync)" class="frame-card__sync-line">
|
||||
<span v-if="lastSync">synced {{ lastSync }}</span>
|
||||
<span v-if="lastSync && nextSync" class="frame-card__sync-sep" aria-hidden="true">·</span>
|
||||
<span v-if="nextSync">{{ nextSync }}</span>
|
||||
</p>
|
||||
<p v-else-if="size === 'compact' && photoCount !== undefined" class="frame-card__count">
|
||||
{{ photoCount }} {{ photoCount === 1 ? 'photo' : 'photos' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<BaseButton
|
||||
:variant="size === 'large' ? 'primary' : 'icon-pill'"
|
||||
:aria-label="size === 'large' ? `Add photo to ${name}` : `Add photo to ${name}`"
|
||||
:aria-label="`Add photo to ${name}`"
|
||||
class="frame-card__add-btn"
|
||||
@click="$emit('add-photo', deviceId)"
|
||||
>
|
||||
@@ -74,6 +79,8 @@ const props = defineProps<{
|
||||
orientation: 'landscape' | 'portrait'
|
||||
thumbnailUrl?: string
|
||||
photoCount?: number
|
||||
lastSync?: string | null
|
||||
nextSync?: string | null
|
||||
}>()
|
||||
|
||||
defineEmits<{ 'add-photo': [deviceId: number]; edit: [deviceId: number] }>()
|
||||
@@ -83,30 +90,58 @@ const previewStyle = computed(() =>
|
||||
? { aspectRatio: props.orientation === 'portrait' ? '3/5' : '5/3' }
|
||||
: {}
|
||||
)
|
||||
|
||||
const statusText = computed(() => {
|
||||
switch (props.status) {
|
||||
case 'ok': return 'Online'
|
||||
case 'sync-fail': return 'Sync issue'
|
||||
case 'offline': return 'Offline'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.frame-card {
|
||||
position: relative;
|
||||
background: var(--color-surface);
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
transition: border-color var(--duration-fast);
|
||||
|
||||
&--offline { border-color: #c0392b; }
|
||||
&--ok { border-color: var(--color-border); }
|
||||
&--sync-fail { border-color: #c49a20; }
|
||||
&--offline { border-color: #c0392b; }
|
||||
|
||||
&__status-badge {
|
||||
&__settings-btn {
|
||||
position: absolute;
|
||||
top: var(--space-2);
|
||||
right: var(--space-2);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(4px);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
background: var(--color-surface-2);
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.frame-card--offline & { color: #c0392b; }
|
||||
&__status-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
|
||||
.frame-card--ok & { color: #1a7f4b; }
|
||||
.frame-card--sync-fail & { color: #8a6a00; }
|
||||
.frame-card--offline & { color: #c0392b; }
|
||||
}
|
||||
|
||||
&__status-dot {
|
||||
@@ -115,22 +150,41 @@ const previewStyle = computed(() =>
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
|
||||
.frame-card--offline & { background: #c0392b; }
|
||||
.frame-card--ok & { background: #1a7f4b; }
|
||||
.frame-card--sync-fail & { background: #c49a20; }
|
||||
.frame-card--offline & { background: #c0392b; }
|
||||
}
|
||||
|
||||
&__sync-line {
|
||||
margin-top: 2px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-muted);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0 6px;
|
||||
}
|
||||
|
||||
&__sync-sep {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
// ── Large (single device) ────────────────────────────────────────────────
|
||||
// Portrait frames have aspect 3:5 — at full mobile width (~360px) that would
|
||||
// be 600px tall and totally dominate the screen. Cap so the card stays
|
||||
// phone-friendly while still showing the photo at the frame's real shape.
|
||||
&--large &__preview {
|
||||
background: var(--color-surface-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
max-height: min(240px, 30dvh);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&--large &__img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
&--large &__empty-preview {
|
||||
@@ -141,11 +195,19 @@ const previewStyle = computed(() =>
|
||||
&--large &__body {
|
||||
padding: var(--space-4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
&--large &__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
&--large &__name {
|
||||
font-size: var(--text-md);
|
||||
font-weight: 700;
|
||||
@@ -188,6 +250,15 @@ const previewStyle = computed(() =>
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&--compact &__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
&--compact &__name {
|
||||
@@ -196,7 +267,7 @@ const previewStyle = computed(() =>
|
||||
}
|
||||
|
||||
&--compact &__count {
|
||||
font-size: var(--text-sm);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,85 @@ describe('BaseBottomSheet', () => {
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
|
||||
})
|
||||
|
||||
it('emits update:modelValue=false when the handle is dragged down past the threshold', async () => {
|
||||
const wrapper = mount(BaseBottomSheet, {
|
||||
props: { modelValue: true, label: 'X' },
|
||||
attachTo: document.body,
|
||||
})
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
const handle = document.querySelector('.sheet__handle-target') as HTMLElement
|
||||
function touchEvent(type: string, y: number) {
|
||||
const e = new Event(type, { bubbles: true }) as TouchEvent
|
||||
Object.defineProperty(e, 'touches', { value: [{ clientY: y }] })
|
||||
Object.defineProperty(e, 'changedTouches', { value: [{ clientY: y }] })
|
||||
return e
|
||||
}
|
||||
handle.dispatchEvent(touchEvent('touchstart', 100))
|
||||
handle.dispatchEvent(touchEvent('touchmove', 220))
|
||||
handle.dispatchEvent(touchEvent('touchend', 220))
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
|
||||
})
|
||||
|
||||
it('closes via pointer drag (mouse / desktop) past the threshold', async () => {
|
||||
const wrapper = mount(BaseBottomSheet, {
|
||||
props: { modelValue: true, label: 'X' },
|
||||
attachTo: document.body,
|
||||
})
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
const handle = document.querySelector('.sheet__handle-target') as HTMLElement
|
||||
;(handle as any).setPointerCapture = () => {}
|
||||
function pointerEvent(type: string, y: number, id = 1, pointerType = 'mouse') {
|
||||
const e = new Event(type, { bubbles: true }) as PointerEvent
|
||||
Object.defineProperty(e, 'clientY', { value: y })
|
||||
Object.defineProperty(e, 'pointerId', { value: id })
|
||||
Object.defineProperty(e, 'pointerType', { value: pointerType })
|
||||
return e
|
||||
}
|
||||
handle.dispatchEvent(pointerEvent('pointerdown', 50))
|
||||
window.dispatchEvent(pointerEvent('pointermove', 200))
|
||||
window.dispatchEvent(pointerEvent('pointerup', 200))
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
|
||||
})
|
||||
|
||||
it('ignores pointerdown for touch pointers (touch handlers cover it)', async () => {
|
||||
const wrapper = mount(BaseBottomSheet, {
|
||||
props: { modelValue: true, label: 'X' },
|
||||
attachTo: document.body,
|
||||
})
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
const handle = document.querySelector('.sheet__handle-target') as HTMLElement
|
||||
;(handle as any).setPointerCapture = () => {}
|
||||
function pointerEvent(type: string, y: number) {
|
||||
const e = new Event(type, { bubbles: true }) as PointerEvent
|
||||
Object.defineProperty(e, 'clientY', { value: y })
|
||||
Object.defineProperty(e, 'pointerId', { value: 1 })
|
||||
Object.defineProperty(e, 'pointerType', { value: 'touch' })
|
||||
return e
|
||||
}
|
||||
handle.dispatchEvent(pointerEvent('pointerdown', 50))
|
||||
window.dispatchEvent(pointerEvent('pointermove', 200))
|
||||
window.dispatchEvent(pointerEvent('pointerup', 200))
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not close when the handle is dragged less than the dismiss threshold', async () => {
|
||||
const wrapper = mount(BaseBottomSheet, {
|
||||
props: { modelValue: true, label: 'X' },
|
||||
attachTo: document.body,
|
||||
})
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
const handle = document.querySelector('.sheet__handle-target') as HTMLElement
|
||||
function touchEvent(type: string, y: number) {
|
||||
const e = new Event(type, { bubbles: true }) as TouchEvent
|
||||
Object.defineProperty(e, 'touches', { value: [{ clientY: y }] })
|
||||
return e
|
||||
}
|
||||
handle.dispatchEvent(touchEvent('touchstart', 100))
|
||||
handle.dispatchEvent(touchEvent('touchmove', 150)) // 50px — below 80 threshold
|
||||
handle.dispatchEvent(touchEvent('touchend', 150))
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('focuses the sheet when opened and restores focus to the trigger when closed', async () => {
|
||||
const trigger = document.createElement('button')
|
||||
document.body.appendChild(trigger)
|
||||
|
||||
@@ -19,27 +19,38 @@ describe('FrameCard', () => {
|
||||
expect(wrapper.text()).toContain('Living Room')
|
||||
})
|
||||
|
||||
it('does not show status badge when status is ok', () => {
|
||||
it('shows "Online" status when status is ok', () => {
|
||||
const wrapper = mount(FrameCard, { props: defaultProps })
|
||||
expect(wrapper.find('.frame-card__status-badge').exists()).toBe(false)
|
||||
expect(wrapper.find('.frame-card__status-line').text()).toContain('Online')
|
||||
})
|
||||
|
||||
it('shows "Offline" badge when status is offline', () => {
|
||||
it('shows "Offline" status when status is offline', () => {
|
||||
const wrapper = mount(FrameCard, {
|
||||
props: { ...defaultProps, status: 'offline' },
|
||||
})
|
||||
const badge = wrapper.find('.frame-card__status-badge')
|
||||
expect(badge.exists()).toBe(true)
|
||||
expect(badge.text()).toContain('Offline')
|
||||
expect(wrapper.find('.frame-card__status-line').text()).toContain('Offline')
|
||||
})
|
||||
|
||||
it('shows "Sync issue" badge when status is sync-fail', () => {
|
||||
it('shows "Sync issue" status when status is sync-fail', () => {
|
||||
const wrapper = mount(FrameCard, {
|
||||
props: { ...defaultProps, status: 'sync-fail' },
|
||||
})
|
||||
const badge = wrapper.find('.frame-card__status-badge')
|
||||
expect(badge.exists()).toBe(true)
|
||||
expect(badge.text()).toContain('Sync issue')
|
||||
expect(wrapper.find('.frame-card__status-line').text()).toContain('Sync issue')
|
||||
})
|
||||
|
||||
it('renders lastSync and nextSync lines on the large card', () => {
|
||||
const wrapper = mount(FrameCard, {
|
||||
props: { ...defaultProps, lastSync: '2h ago', nextSync: 'next sync in 4h' },
|
||||
})
|
||||
const sync = wrapper.find('.frame-card__sync-line')
|
||||
expect(sync.exists()).toBe(true)
|
||||
expect(sync.text()).toContain('synced 2h ago')
|
||||
expect(sync.text()).toContain('next sync in 4h')
|
||||
})
|
||||
|
||||
it('omits the sync line on the large card when no sync info is provided', () => {
|
||||
const wrapper = mount(FrameCard, { props: defaultProps })
|
||||
expect(wrapper.find('.frame-card__sync-line').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('applies offline modifier class when status is offline', () => {
|
||||
|
||||
@@ -13,7 +13,7 @@ vi.mock('@/components/FrameCard.vue', () => ({
|
||||
default: {
|
||||
name: 'FrameCard',
|
||||
template: '<div class="frame-card-stub" :data-device-id="deviceId" :data-name="name" />',
|
||||
props: ['deviceId', 'name', 'size', 'status', 'orientation', 'thumbnailUrl'],
|
||||
props: ['deviceId', 'name', 'size', 'status', 'orientation', 'thumbnailUrl', 'lastSync', 'nextSync'],
|
||||
emits: ['add-photo', 'edit'],
|
||||
},
|
||||
}))
|
||||
@@ -315,6 +315,136 @@ describe('HomeView', () => {
|
||||
expect(wrapper.findComponent({ name: 'FrameCard' }).props('status')).toBe('offline')
|
||||
})
|
||||
|
||||
it('passes status="sync-fail" when one sync window has been missed but not two', async () => {
|
||||
const devicesStore = useDevicesStore()
|
||||
// 90 minutes since last seen, interval = 60 — between 1× and 2× → sync-fail
|
||||
devicesStore.devices = [makeDevice({
|
||||
id: 1,
|
||||
rotationIntervalMinutes: 60,
|
||||
lastSeenAt: new Date(Date.now() - 1000 * 60 * 90).toISOString(),
|
||||
})]
|
||||
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'FrameCard' }).props('status')).toBe('sync-fail')
|
||||
})
|
||||
|
||||
it('uses a 24h window for devices configured with a daily wakeHour', async () => {
|
||||
const devicesStore = useDevicesStore()
|
||||
// wakeHour set, last seen 30h ago — between 1×24h and 2×24h → sync-fail
|
||||
devicesStore.devices = [makeDevice({
|
||||
id: 1,
|
||||
wakeHour: 4,
|
||||
rotationIntervalMinutes: 5, // ignored when wakeHour is set
|
||||
lastSeenAt: new Date(Date.now() - 1000 * 60 * 60 * 30).toISOString(),
|
||||
})]
|
||||
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'FrameCard' }).props('status')).toBe('sync-fail')
|
||||
})
|
||||
|
||||
it('passes a relative lastSync label and a nextSync label to the FrameCard', async () => {
|
||||
const devicesStore = useDevicesStore()
|
||||
devicesStore.devices = [makeDevice({
|
||||
id: 1,
|
||||
rotationIntervalMinutes: 60,
|
||||
lastSeenAt: new Date(Date.now() - 1000 * 60 * 30).toISOString(), // 30m ago
|
||||
})]
|
||||
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
const props = wrapper.findComponent({ name: 'FrameCard' }).props()
|
||||
expect(props.lastSync).toMatch(/m ago/)
|
||||
expect(props.nextSync).toMatch(/next sync in/)
|
||||
})
|
||||
|
||||
it('passes a wakeHour-based nextSync label when the device wakes daily', async () => {
|
||||
const devicesStore = useDevicesStore()
|
||||
devicesStore.devices = [makeDevice({
|
||||
id: 1,
|
||||
wakeHour: 4,
|
||||
timezone: 'UTC',
|
||||
lastSeenAt: new Date(Date.now() - 1000 * 60 * 60).toISOString(),
|
||||
})]
|
||||
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toMatch(/4 AM/)
|
||||
})
|
||||
|
||||
it('formats lastSync as "yesterday" / "N days ago" / "just now"', async () => {
|
||||
const devicesStore = useDevicesStore()
|
||||
devicesStore.devices = [makeDevice({
|
||||
id: 1,
|
||||
lastSeenAt: new Date(Date.now() - 1000 * 60 * 60 * 26).toISOString(), // ~26h
|
||||
})]
|
||||
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||
let wrapper = mountView()
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'FrameCard' }).props('lastSync')).toBe('yesterday')
|
||||
|
||||
devicesStore.devices = [makeDevice({
|
||||
id: 1,
|
||||
lastSeenAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 4).toISOString(), // ~4 days
|
||||
})]
|
||||
wrapper = mountView()
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'FrameCard' }).props('lastSync')).toMatch(/4 days ago/)
|
||||
|
||||
devicesStore.devices = [makeDevice({
|
||||
id: 1,
|
||||
lastSeenAt: new Date(Date.now() - 5_000).toISOString(), // 5 seconds
|
||||
})]
|
||||
wrapper = mountView()
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'FrameCard' }).props('lastSync')).toBe('just now')
|
||||
})
|
||||
|
||||
it('omits nextSync when an interval-based device is already past its next expected sync', async () => {
|
||||
const devicesStore = useDevicesStore()
|
||||
devicesStore.devices = [makeDevice({
|
||||
id: 1,
|
||||
rotationIntervalMinutes: 60,
|
||||
lastSeenAt: new Date(Date.now() - 1000 * 60 * 90).toISOString(), // 90m ago, already late
|
||||
})]
|
||||
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toBeNull()
|
||||
})
|
||||
|
||||
it('formats wakeHour 12 PM, 12 AM, and 8 PM correctly', async () => {
|
||||
const devicesStore = useDevicesStore()
|
||||
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||
|
||||
devicesStore.devices = [makeDevice({ id: 1, wakeHour: 12, timezone: 'UTC' })]
|
||||
let wrapper = mountView()
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toMatch(/12 PM/)
|
||||
|
||||
devicesStore.devices = [makeDevice({ id: 1, wakeHour: 0, timezone: 'UTC' })]
|
||||
wrapper = mountView()
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toMatch(/12 AM/)
|
||||
|
||||
devicesStore.devices = [makeDevice({ id: 1, wakeHour: 20, timezone: 'UTC' })]
|
||||
wrapper = mountView()
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'FrameCard' }).props('nextSync')).toMatch(/8 PM/)
|
||||
})
|
||||
|
||||
it('returns null lastSync when the device has no recorded last-seen time', async () => {
|
||||
const devicesStore = useDevicesStore()
|
||||
devicesStore.devices = [makeDevice({ id: 1, lastSeenAt: null })]
|
||||
vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue()
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
const props = wrapper.findComponent({ name: 'FrameCard' }).props()
|
||||
expect(props.lastSync).toBeNull()
|
||||
expect(props.nextSync).toBeNull()
|
||||
})
|
||||
|
||||
it('builds a thumbnail URL when the device has a current image', async () => {
|
||||
const devicesStore = useDevicesStore()
|
||||
devicesStore.devices = [makeDevice({ id: 1, currentImageId: 42 })]
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
:status="deviceStatus(devicesStore.devices[0])"
|
||||
:orientation="devicesStore.devices[0].orientation"
|
||||
:thumbnailUrl="previewUrl(devicesStore.devices[0])"
|
||||
:lastSync="lastSyncLabel(devicesStore.devices[0])"
|
||||
:nextSync="nextSyncLabel(devicesStore.devices[0])"
|
||||
@add-photo="onAddPhoto"
|
||||
@edit="onEdit"
|
||||
/>
|
||||
@@ -46,6 +48,8 @@
|
||||
:status="deviceStatus(device)"
|
||||
:orientation="device.orientation"
|
||||
:thumbnailUrl="previewUrl(device)"
|
||||
:lastSync="lastSyncLabel(device)"
|
||||
:nextSync="nextSyncLabel(device)"
|
||||
@add-photo="onAddPhoto"
|
||||
@edit="onEdit"
|
||||
/>
|
||||
@@ -105,11 +109,58 @@ import { useDevicesStore } from '@/stores/devices'
|
||||
import { useUploadStore } from '@/stores/upload'
|
||||
import type { Device } from '@/types'
|
||||
|
||||
function deviceStatus(device: Device): 'ok' | 'offline' {
|
||||
// Sync interval for status comparisons. Devices configured with a daily wake
|
||||
// hour use a 24h window; otherwise the rotation interval drives it.
|
||||
function syncIntervalMs(device: Device): number {
|
||||
if (device.wakeHour !== null) return 24 * 60 * 60 * 1000
|
||||
return device.rotationIntervalMinutes * 60_000
|
||||
}
|
||||
|
||||
function deviceStatus(device: Device): 'ok' | 'sync-fail' | 'offline' {
|
||||
if (!device.lastSeenAt) return 'offline'
|
||||
const seenMs = Date.now() - new Date(device.lastSeenAt).getTime()
|
||||
const windowMs = Math.max(device.rotationIntervalMinutes * 2 * 60_000, 30 * 60_000)
|
||||
return seenMs <= windowMs ? 'ok' : 'offline'
|
||||
const elapsed = Date.now() - new Date(device.lastSeenAt).getTime()
|
||||
const interval = syncIntervalMs(device)
|
||||
if (elapsed <= interval) return 'ok'
|
||||
if (elapsed <= 2 * interval) return 'sync-fail'
|
||||
return 'offline'
|
||||
}
|
||||
|
||||
function lastSyncLabel(device: Device): string | null {
|
||||
if (!device.lastSeenAt) return null
|
||||
const ago = Date.now() - new Date(device.lastSeenAt).getTime()
|
||||
if (ago < 60_000) return 'just now'
|
||||
if (ago < 3_600_000) return `${Math.round(ago / 60_000)}m ago`
|
||||
if (ago < 86_400_000) return `${Math.round(ago / 3_600_000)}h ago`
|
||||
const days = Math.round(ago / 86_400_000)
|
||||
if (days === 1) return 'yesterday'
|
||||
return `${days} days ago`
|
||||
}
|
||||
|
||||
function formatHour(h: number): string {
|
||||
if (h === 0) return '12 AM'
|
||||
if (h < 12) return `${h} AM`
|
||||
if (h === 12) return '12 PM'
|
||||
return `${h - 12} PM`
|
||||
}
|
||||
|
||||
function nextSyncLabel(device: Device): string | null {
|
||||
if (device.wakeHour !== null) {
|
||||
const fmt = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: device.timezone || 'UTC',
|
||||
hour: 'numeric',
|
||||
hour12: false,
|
||||
})
|
||||
const currentHour = parseInt(fmt.format(new Date()), 10)
|
||||
const tag = currentHour < device.wakeHour ? 'today' : 'tomorrow'
|
||||
return `next sync ~${formatHour(device.wakeHour)} ${tag}`
|
||||
}
|
||||
if (!device.lastSeenAt) return null
|
||||
const next = new Date(device.lastSeenAt).getTime() + device.rotationIntervalMinutes * 60_000
|
||||
const fromNow = next - Date.now()
|
||||
if (fromNow <= 0) return null
|
||||
if (fromNow < 60_000) return 'next sync in <1m'
|
||||
if (fromNow < 3_600_000) return `next sync in ${Math.round(fromNow / 60_000)}m`
|
||||
return `next sync in ${Math.round(fromNow / 3_600_000)}h`
|
||||
}
|
||||
|
||||
function previewUrl(device: Device): string | undefined {
|
||||
@@ -250,7 +301,7 @@ async function saveSettings() {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.home-view {
|
||||
padding: var(--space-4);
|
||||
padding: var(--space-4) var(--space-4) calc(var(--bottom-nav-height) + var(--space-4));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
|
||||
Reference in New Issue
Block a user