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,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 })]
|
||||
|
||||
Reference in New Issue
Block a user