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:
curo1305
2026-06-18 23:31:50 +02:00
parent 44244335a1
commit c6c0742267
7 changed files with 229 additions and 11 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "document-scanner-frontend",
"version": "0.1.5",
"version": "0.1.6",
"type": "module",
"scripts": {
"dev": "vite",
+121 -3
View File
@@ -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')
})
})
+17
View File
@@ -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 = {