From c6c07422673cafd7e587bd45b619c8f28b38c781 Mon Sep 17 00:00:00 2001 From: curo1305 Date: Thu, 18 Jun 2026 23:31:50 +0200 Subject: [PATCH] feat(12-03): breadcrumb freshness, formatRelativeTime, v0.1.6, docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- AGENTS.md | 8 +- README.md | 4 +- backend/main.py | 2 +- frontend/package.json | 2 +- frontend/src/components/ui/BreadcrumbBar.vue | 124 +++++++++++++++++- .../ui/__tests__/BreadcrumbBar.test.js | 83 +++++++++++- frontend/src/utils/formatters.js | 17 +++ 7 files changed, 229 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 12f961f..4708f3c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index 46946ac..ac9037b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/main.py b/backend/main.py index afe566f..2f3c743 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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 diff --git a/frontend/package.json b/frontend/package.json index 763034f..d7ee56c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "document-scanner-frontend", - "version": "0.1.5", + "version": "0.1.6", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/ui/BreadcrumbBar.vue b/frontend/src/components/ui/BreadcrumbBar.vue index b580e2f..fbf323a 100644 --- a/frontend/src/components/ui/BreadcrumbBar.vue +++ b/frontend/src/components/ui/BreadcrumbBar.vue @@ -40,22 +40,94 @@ {{ segment.label }} + + +
  • + + + + Refreshing + + + + + + + Up to date + + + + + +
  • + + + + + diff --git a/frontend/src/components/ui/__tests__/BreadcrumbBar.test.js b/frontend/src/components/ui/__tests__/BreadcrumbBar.test.js index 9d9b6be..deaf9f0 100644 --- a/frontend/src/components/ui/__tests__/BreadcrumbBar.test.js +++ b/frontend/src/components/ui/__tests__/BreadcrumbBar.test.js @@ -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') + }) +}) diff --git a/frontend/src/utils/formatters.js b/frontend/src/utils/formatters.js index cc0135c..f8cd6a6 100644 --- a/frontend/src/utils/formatters.js +++ b/frontend/src/utils/formatters.js @@ -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 = {