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:
@@ -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
|
||||
|
||||
|
||||
@@ -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, 8–10 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
@@ -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,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