feat(12-03): breadcrumb freshness, formatRelativeTime, v0.1.6, docs
- BreadcrumbBar: refreshing spinner, fresh checkmark (fades 3s), stale warning banner - Accessible labels and role=status for all freshness states - formatters.js: add formatRelativeTime (shared, no duplication) - Version bump 0.1.5 → 0.1.6 in backend/main.py and frontend/package.json - AGENTS.md: update state, shared module map (formatRelativeTime) - README.md: unified connection-root browsing and capability explanations feature text - 17 BreadcrumbBar tests pass; 323 total; production build clean
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "document-scanner-frontend",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -40,22 +40,94 @@
|
||||
<span class="text-gray-500">{{ segment.label }}</span>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<!-- Freshness indicator — shown only when folderFreshness is set -->
|
||||
<li v-if="folderFreshness" class="flex items-center ml-2 shrink-0" data-test="freshness-indicator">
|
||||
<!-- Refreshing spinner -->
|
||||
<span
|
||||
v-if="folderFreshness === 'refreshing'"
|
||||
class="inline-flex items-center gap-1 text-xs text-gray-400"
|
||||
aria-label="Refreshing folder contents"
|
||||
role="status"
|
||||
data-freshness="refreshing"
|
||||
>
|
||||
<svg class="animate-spin w-3 h-3 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
||||
</svg>
|
||||
<span class="sr-only">Refreshing</span>
|
||||
</span>
|
||||
|
||||
<!-- Fresh checkmark — fades after 3s -->
|
||||
<transition name="freshness-fade">
|
||||
<span
|
||||
v-if="folderFreshness === 'fresh' && showFreshMark"
|
||||
class="inline-flex items-center gap-1 text-xs text-green-500"
|
||||
aria-label="Folder contents are up to date"
|
||||
role="status"
|
||||
data-freshness="fresh"
|
||||
>
|
||||
<AppIcon name="checkMark" class="w-3 h-3" />
|
||||
<span class="sr-only">Up to date</span>
|
||||
</span>
|
||||
</transition>
|
||||
|
||||
<!-- Warning indicator for stale / error -->
|
||||
<template v-if="folderFreshness === 'stale'">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 text-xs text-amber-500 cursor-default"
|
||||
:title="staleTooltip"
|
||||
aria-label="staleWarningLabel"
|
||||
role="status"
|
||||
data-freshness="stale"
|
||||
>
|
||||
<AppIcon name="warning" class="w-3 h-3 text-amber-400" />
|
||||
<span class="hidden sm:inline">{{ staleWarningLabel }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<!-- Stale warning banner — shown below breadcrumb when stale -->
|
||||
<div
|
||||
v-if="folderFreshness === 'stale'"
|
||||
class="mt-1.5 px-3 py-2 rounded-lg bg-amber-50 border border-amber-200 text-xs text-amber-800 flex items-start gap-2"
|
||||
role="alert"
|
||||
data-test="stale-warning-banner"
|
||||
>
|
||||
<AppIcon name="warning" class="w-3.5 h-3.5 text-amber-500 shrink-0 mt-0.5" />
|
||||
<span>
|
||||
Could not refresh folder contents.
|
||||
<template v-if="lastRefreshedAt">Last updated {{ staleRelativeTime }}.</template>
|
||||
Please check your connection and try again.
|
||||
</span>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AppIcon from './AppIcon.vue'
|
||||
import { formatRelativeTime } from '../../utils/formatters.js'
|
||||
|
||||
export default {
|
||||
name: 'BreadcrumbBar',
|
||||
components: { AppIcon },
|
||||
props: {
|
||||
segments: { type: Array, default: () => [] },
|
||||
rootLabel: { type: String, default: 'Home' },
|
||||
showRoot: { type: Boolean, default: true },
|
||||
segments: { type: Array, default: () => [] },
|
||||
rootLabel: { type: String, default: 'Home' },
|
||||
showRoot: { type: Boolean, default: true },
|
||||
/** 'refreshing' | 'fresh' | 'stale' | null */
|
||||
folderFreshness: { type: String, default: null },
|
||||
/** ISO timestamp of last successful refresh */
|
||||
lastRefreshedAt: { type: String, default: null },
|
||||
},
|
||||
emits: ['navigate'],
|
||||
data() {
|
||||
return {
|
||||
showFreshMark: false,
|
||||
freshTimer: null,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
visibleSegments() {
|
||||
if (this.segments.length > 4) {
|
||||
@@ -67,6 +139,52 @@ export default {
|
||||
}
|
||||
return this.segments
|
||||
},
|
||||
staleWarningLabel() {
|
||||
if (this.lastRefreshedAt) {
|
||||
return `Last updated ${formatRelativeTime(this.lastRefreshedAt)}`
|
||||
}
|
||||
return 'Refresh failed'
|
||||
},
|
||||
staleTooltip() {
|
||||
return this.staleWarningLabel + '. Please check your connection and try again.'
|
||||
},
|
||||
staleRelativeTime() {
|
||||
return this.lastRefreshedAt ? formatRelativeTime(this.lastRefreshedAt) : ''
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
folderFreshness: {
|
||||
immediate: true,
|
||||
handler(val) {
|
||||
if (val === 'fresh') {
|
||||
this.showFreshMark = true
|
||||
clearTimeout(this.freshTimer)
|
||||
// Fade out after 3 seconds without layout shift
|
||||
this.freshTimer = setTimeout(() => {
|
||||
this.showFreshMark = false
|
||||
}, 3000)
|
||||
} else {
|
||||
this.showFreshMark = false
|
||||
clearTimeout(this.freshTimer)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
beforeUnmount() {
|
||||
clearTimeout(this.freshTimer)
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.freshness-fade-enter-active {
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
.freshness-fade-leave-active {
|
||||
transition: opacity 1s;
|
||||
}
|
||||
.freshness-fade-enter-from,
|
||||
.freshness-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import BreadcrumbBar from '../BreadcrumbBar.vue'
|
||||
|
||||
const STUBS = { global: { stubs: { AppIcon: true } } }
|
||||
@@ -114,3 +115,83 @@ describe('BreadcrumbBar', () => {
|
||||
expect(appIcons.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('BreadcrumbBar freshness indicator', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('shows refreshing spinner with accessible label when folderFreshness="refreshing"', () => {
|
||||
const w = mount(BreadcrumbBar, {
|
||||
props: { segments: [], folderFreshness: 'refreshing' },
|
||||
...STUBS,
|
||||
})
|
||||
const indicator = w.find('[data-freshness="refreshing"]')
|
||||
expect(indicator.exists()).toBe(true)
|
||||
expect(indicator.attributes('aria-label')).toBe('Refreshing folder contents')
|
||||
})
|
||||
|
||||
it('shows fresh checkmark with accessible label when folderFreshness="fresh"', async () => {
|
||||
const w = mount(BreadcrumbBar, {
|
||||
props: { segments: [], folderFreshness: 'fresh' },
|
||||
...STUBS,
|
||||
})
|
||||
// Wait for watcher to update showFreshMark
|
||||
await nextTick()
|
||||
const fresh = w.find('[data-freshness="fresh"]')
|
||||
expect(fresh.exists()).toBe(true)
|
||||
expect(fresh.attributes('aria-label')).toBe('Folder contents are up to date')
|
||||
})
|
||||
|
||||
it('fresh indicator fades after 3 seconds', async () => {
|
||||
const w = mount(BreadcrumbBar, {
|
||||
props: { segments: [], folderFreshness: 'fresh' },
|
||||
...STUBS,
|
||||
})
|
||||
await nextTick()
|
||||
expect(w.vm.showFreshMark).toBe(true)
|
||||
// Advance timer by 3s
|
||||
vi.advanceTimersByTime(3001)
|
||||
await nextTick()
|
||||
expect(w.vm.showFreshMark).toBe(false)
|
||||
})
|
||||
|
||||
it('warning indicator persists when folderFreshness="stale"', async () => {
|
||||
const w = mount(BreadcrumbBar, {
|
||||
props: { segments: [], folderFreshness: 'stale', lastRefreshedAt: '2026-01-01T00:00:00Z' },
|
||||
...STUBS,
|
||||
})
|
||||
const stale = w.find('[data-freshness="stale"]')
|
||||
expect(stale.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('stale warning banner is shown with last-update info', async () => {
|
||||
const w = mount(BreadcrumbBar, {
|
||||
props: { segments: [], folderFreshness: 'stale', lastRefreshedAt: '2026-01-01T00:00:00Z' },
|
||||
...STUBS,
|
||||
})
|
||||
const banner = w.find('[data-test="stale-warning-banner"]')
|
||||
expect(banner.exists()).toBe(true)
|
||||
expect(banner.text()).toContain('Could not refresh folder contents')
|
||||
})
|
||||
|
||||
it('no freshness indicator shown when folderFreshness is null', () => {
|
||||
const w = mount(BreadcrumbBar, {
|
||||
props: { segments: [], folderFreshness: null },
|
||||
...STUBS,
|
||||
})
|
||||
expect(w.find('[data-test="freshness-indicator"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('freshness indicator role="status" for accessible announcement', async () => {
|
||||
const w = mount(BreadcrumbBar, {
|
||||
props: { segments: [], folderFreshness: 'refreshing' },
|
||||
...STUBS,
|
||||
})
|
||||
const indicator = w.find('[data-freshness="refreshing"]')
|
||||
expect(indicator.attributes('role')).toBe('status')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -50,6 +50,23 @@ export function providerBg(provider) {
|
||||
return map[provider] ?? 'bg-gray-50'
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a human-readable relative time string for an ISO timestamp.
|
||||
* e.g. "just now", "2 minutes ago", "1 hour ago", "3 days ago"
|
||||
*/
|
||||
export function formatRelativeTime(iso) {
|
||||
if (!iso) return ''
|
||||
const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 1000)
|
||||
if (diff < 10) return 'just now'
|
||||
if (diff < 60) return `${diff} seconds ago`
|
||||
const mins = Math.floor(diff / 60)
|
||||
if (mins < 60) return mins === 1 ? '1 minute ago' : `${mins} minutes ago`
|
||||
const hrs = Math.floor(mins / 60)
|
||||
if (hrs < 24) return hrs === 1 ? '1 hour ago' : `${hrs} hours ago`
|
||||
const days = Math.floor(hrs / 24)
|
||||
return days === 1 ? '1 day ago' : `${days} days ago`
|
||||
}
|
||||
|
||||
/** Human-readable label for a cloud provider slug. */
|
||||
export function providerLabel(provider) {
|
||||
const map = {
|
||||
|
||||
Reference in New Issue
Block a user