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).
|
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
|
## Stack
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ Before adding a helper, check if it belongs in an existing shared module:
|
|||||||
|
|
||||||
| Module | What lives here |
|
| 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/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` |
|
| `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
|
/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
|
## Development Setup
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# DocuVault
|
# 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.
|
> **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
|
- **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
|
- **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
|
- **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
|
- **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
|
- **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
|
- **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 ───────────────────────────────────────────────────────
|
# ── 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)
|
# Rate limiter state (slowapi)
|
||||||
app.state.limiter = auth_limiter
|
app.state.limiter = auth_limiter
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "document-scanner-frontend",
|
"name": "document-scanner-frontend",
|
||||||
"version": "0.1.5",
|
"version": "0.1.6",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -40,12 +40,74 @@
|
|||||||
<span class="text-gray-500">{{ segment.label }}</span>
|
<span class="text-gray-500">{{ segment.label }}</span>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</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>
|
</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>
|
</nav>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import AppIcon from './AppIcon.vue'
|
import AppIcon from './AppIcon.vue'
|
||||||
|
import { formatRelativeTime } from '../../utils/formatters.js'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'BreadcrumbBar',
|
name: 'BreadcrumbBar',
|
||||||
@@ -54,8 +116,18 @@ export default {
|
|||||||
segments: { type: Array, default: () => [] },
|
segments: { type: Array, default: () => [] },
|
||||||
rootLabel: { type: String, default: 'Home' },
|
rootLabel: { type: String, default: 'Home' },
|
||||||
showRoot: { type: Boolean, default: true },
|
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'],
|
emits: ['navigate'],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
showFreshMark: false,
|
||||||
|
freshTimer: null,
|
||||||
|
}
|
||||||
|
},
|
||||||
computed: {
|
computed: {
|
||||||
visibleSegments() {
|
visibleSegments() {
|
||||||
if (this.segments.length > 4) {
|
if (this.segments.length > 4) {
|
||||||
@@ -67,6 +139,52 @@ export default {
|
|||||||
}
|
}
|
||||||
return this.segments
|
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>
|
</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 { mount } from '@vue/test-utils'
|
||||||
|
import { nextTick } from 'vue'
|
||||||
import BreadcrumbBar from '../BreadcrumbBar.vue'
|
import BreadcrumbBar from '../BreadcrumbBar.vue'
|
||||||
|
|
||||||
const STUBS = { global: { stubs: { AppIcon: true } } }
|
const STUBS = { global: { stubs: { AppIcon: true } } }
|
||||||
@@ -114,3 +115,83 @@ describe('BreadcrumbBar', () => {
|
|||||||
expect(appIcons.length).toBeGreaterThanOrEqual(1)
|
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'
|
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. */
|
/** Human-readable label for a cloud provider slug. */
|
||||||
export function providerLabel(provider) {
|
export function providerLabel(provider) {
|
||||||
const map = {
|
const map = {
|
||||||
|
|||||||
Reference in New Issue
Block a user