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
+4 -4
View File
@@ -4,7 +4,7 @@
DocuVault is a multi-user SaaS document management platform built on FastAPI (Python) + Vue 3. It handles document upload, text extraction (PDF/DOCX/image/text), AI-based topic classification, per-user isolated storage, folder organization, document sharing, and pluggable cloud storage backends (OneDrive, Google Drive, Nextcloud, WebDAV).
**Current state:** v0.1.5 — Phase 12 Plan 02 complete (2026-06-18). Connection-ID browse API (`GET /api/cloud/connections/{connection_id}/items`) with owner-scoped IDOR protection, stale-while-revalidate caching, and durable `refresh_cloud_folder` Celery task. All 4 providers implement `CloudResourceAdapter`. Not cleared for public internet deployment.
**Current state:** v0.1.6 — Phase 12 Plan 03 complete (2026-06-18). Connection-ID routing, capability-aware action rendering, breadcrumb freshness indicators. Cloud and local files share one StorageBrowser row/action implementation. Not cleared for public internet deployment.
## Stack
@@ -59,7 +59,7 @@ Before adding a helper, check if it belongs in an existing shared module:
| Module | What lives here |
|---|---|
| `src/utils/formatters.js` | `formatDate`, `formatSize`, `providerColor`, `providerBg`, `providerLabel` |
| `src/utils/formatters.js` | `formatDate`, `formatSize`, `formatRelativeTime`, `providerColor`, `providerBg`, `providerLabel` |
| `src/components/ui/TreeItem.vue` | Generic expand/collapse tree node — all sidebar tree items wrap this |
| `src/components/storage/StorageBrowser.vue` | Unified file browser grid — used by both `FileManagerView` and `CloudFolderView` |
@@ -123,9 +123,9 @@ This project uses the GSD (Get Shit Done) planning workflow. Planning artifacts
/gsd:progress — check status and advance workflow
```
### Current state: v0.1.5 — Phase 12 Plan 02 complete (2026-06-18)
### Current state: v0.1.6 — Phase 12 Plan 03 complete (2026-06-18)
Phase 12 Plan 02: connection-ID browse endpoint, `api/cloud/` package decomposition, `CloudResourceAdapter` mixin on all 4 providers, and `refresh_cloud_folder` Celery task with bounded retry. Stale-while-revalidate caching active. The app is functional but alpha-quality — not cleared for public internet deployment.
Phase 12 Plan 03: connection-ID routing (`/cloud/:connectionId`), capability-aware action rendering in StorageBrowser, breadcrumb freshness indicators (refreshing/fresh/stale), session-only folder memory, and display-name rename for cloud connections. Cloud and local files share one row/action implementation via `CapabilityButton`. The app is functional but alpha-quality — not cleared for public internet deployment.
## Development Setup
+3 -1
View File
@@ -1,6 +1,6 @@
# DocuVault
**Version 0.1.5 — Alpha**
**Version 0.1.6 — Alpha**
> **Not production-ready.** DocuVault is functional for local and self-hosted use but has not been audited or hardened for public internet exposure. APIs, environment variables, and the database schema may change without notice until a stable 1.0 release is declared.
@@ -17,6 +17,8 @@ A self-hosted, multi-user document management platform with AI-powered topic cla
- **Document sharing** — share by user handle with view or edit permission; "Shared with me" virtual folder; per-recipient revocation
- **Storage quota** — per-user limit enforced atomically; amber/red quota bar at 80 % / 95 %; quota decremented on delete
- **Cloud storage backends** — connect OneDrive, Google Drive, Nextcloud, or any WebDAV server as a personal storage backend; credentials encrypted with HKDF per-user keys
- **Unified connection-root browsing** — each connected account appears as a distinct top-level root navigable by connection UUID; duplicate same-provider accounts are disambiguated automatically; local and cloud files share one row and action implementation
- **Capability-aware actions** — unsupported cloud actions render gray with accessible explanations; temporarily unavailable actions render amber; all remain keyboard-focusable
- **Auth** — email/password registration with HIBP breach check, optional TOTP 2FA, 810 backup codes, password reset by email, sign-out-all-devices
- **Admin panel** — user CRUD, quota assignment, per-user AI provider override, AI provider system configuration, audit log viewer with CSV export
- **Observability** — structured JSON logging via structlog, per-request correlation IDs, Loki + Promtail + Grafana stack included in Docker Compose
+1 -1
View File
@@ -244,7 +244,7 @@ async def lifespan(app: FastAPI):
# ── Application factory ───────────────────────────────────────────────────────
app = FastAPI(title="Document Scanner API", version="0.1.5", lifespan=lifespan)
app = FastAPI(title="Document Scanner API", version="0.1.6", lifespan=lifespan)
# Rate limiter state (slowapi)
app.state.limiter = auth_limiter
+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 = {