shak3008 commited on
Commit
d135b0e
·
1 Parent(s): bab270b

perf: optimize login transition and dashboard loading performance

Browse files

- Eliminate N+1 lazy queries in activity, documents, and workflows endpoints using joinedload/selectinload
- Aggregate dashboard KPI counts into a single SQL query to reduce remote DB roundtrips
- Increase DB pool size to 10 with max_overflow of 20 to prevent connection starvation
- Add in-flight GET request deduplication in frontend API client
- Remove blocking /auth/me call on login to enable instant navigation
- Cache workspace data in localStorage to eliminate sequential loading waterfalls
- Implement session caching and skeleton shimmer states in Dashboard UI

backend/app/api/activity.py CHANGED
@@ -5,12 +5,14 @@ import json
5
  from uuid import UUID
6
 
7
  from fastapi import APIRouter, Depends, HTTPException, Query
8
- from sqlalchemy.orm import Session
9
 
10
  from app.core.dependencies import get_current_user
11
  from app.database.session import get_db
12
  from app.models.commit import Commit
 
13
  from app.models.document_version import DocumentVersion
 
14
  from app.models.proposal import Proposal, ProposalStatus
15
  from app.models.review import Review
16
  from app.models.user import User
@@ -86,20 +88,17 @@ def list_activity(
86
  if cursor:
87
  cursor_ts, cursor_id = _decode_cursor(cursor)
88
 
89
- # Use a generous per-table fetch limit to ensure enough events
90
- # survive the post-merge filter. Each DB row can produce multiple events.
91
- # The correctness guarantee comes from the post-merge cursor filter,
92
- # not from per-table limiting. Use a high multiplier to ensure all
93
- # relevant rows are loaded.
94
- fetch_limit = max(limit * 10, 500)
95
 
96
  events: list[dict] = []
97
 
98
  # ------------------------------------------------------------------
99
- # Workflow events
100
  # ------------------------------------------------------------------
101
  workflows = (
102
  db.query(WorkflowRun)
 
103
  .filter(WorkflowRun.workspace_id == workspace_id)
104
  .order_by(WorkflowRun.started_at.desc())
105
  .limit(fetch_limit)
@@ -144,12 +143,12 @@ def list_activity(
144
  })
145
 
146
  # ------------------------------------------------------------------
147
- # Document upload events
148
  # ------------------------------------------------------------------
149
  versions = (
150
  db.query(DocumentVersion)
151
- .join(DocumentVersion.document)
152
- .filter(DocumentVersion.document.has(workspace_id=workspace_id))
153
  .order_by(DocumentVersion.uploaded_at.desc())
154
  .limit(fetch_limit)
155
  .all()
@@ -168,17 +167,19 @@ def list_activity(
168
  })
169
 
170
  # ------------------------------------------------------------------
171
- # Proposal events
172
  # ------------------------------------------------------------------
173
  proposals = (
174
  db.query(Proposal)
 
 
 
175
  .filter(Proposal.workspace_id == workspace_id)
176
  .order_by(Proposal.created_at.desc())
177
  .limit(fetch_limit)
178
  .all()
179
  )
180
  for p in proposals:
181
- # Walk the lazy-load chain: proposal → knowledge_item → document_version → filename
182
  p_filename = None
183
  ki = p.knowledge_item
184
  if ki and ki.document_version:
@@ -234,22 +235,24 @@ def list_activity(
234
  })
235
 
236
  # ------------------------------------------------------------------
237
- # Commit events
238
  # ------------------------------------------------------------------
239
  commits = (
240
  db.query(Commit)
 
 
 
 
 
241
  .filter(Commit.workspace_id == workspace_id)
242
  .order_by(Commit.committed_at.desc())
243
  .limit(fetch_limit)
244
  .all()
245
  )
246
  for c in commits:
247
- # Walk the lazy-load chain: commit → proposal → knowledge_item → document_version → filename
248
  c_filename = None
249
- if c.proposal_id:
250
- c_proposal = db.query(Proposal).filter(Proposal.id == c.proposal_id).first()
251
- if c_proposal and c_proposal.knowledge_item and c_proposal.knowledge_item.document_version:
252
- c_filename = c_proposal.knowledge_item.document_version.filename
253
 
254
  events.append({
255
  "id": f"commit-{c.id}",
 
5
  from uuid import UUID
6
 
7
  from fastapi import APIRouter, Depends, HTTPException, Query
8
+ from sqlalchemy.orm import Session, joinedload
9
 
10
  from app.core.dependencies import get_current_user
11
  from app.database.session import get_db
12
  from app.models.commit import Commit
13
+ from app.models.document import Document
14
  from app.models.document_version import DocumentVersion
15
+ from app.models.knowledge_item import KnowledgeItem
16
  from app.models.proposal import Proposal, ProposalStatus
17
  from app.models.review import Review
18
  from app.models.user import User
 
88
  if cursor:
89
  cursor_ts, cursor_id = _decode_cursor(cursor)
90
 
91
+ # Fetch enough rows per table to satisfy the merged limit without overfetching
92
+ fetch_limit = min(max(limit * 2, 20), 100)
 
 
 
 
93
 
94
  events: list[dict] = []
95
 
96
  # ------------------------------------------------------------------
97
+ # Workflow events (eagerly load document_version in 1 query)
98
  # ------------------------------------------------------------------
99
  workflows = (
100
  db.query(WorkflowRun)
101
+ .options(joinedload(WorkflowRun.document_version))
102
  .filter(WorkflowRun.workspace_id == workspace_id)
103
  .order_by(WorkflowRun.started_at.desc())
104
  .limit(fetch_limit)
 
143
  })
144
 
145
  # ------------------------------------------------------------------
146
+ # Document upload events (direct join on documents)
147
  # ------------------------------------------------------------------
148
  versions = (
149
  db.query(DocumentVersion)
150
+ .join(Document, DocumentVersion.document_id == Document.id)
151
+ .filter(Document.workspace_id == workspace_id)
152
  .order_by(DocumentVersion.uploaded_at.desc())
153
  .limit(fetch_limit)
154
  .all()
 
167
  })
168
 
169
  # ------------------------------------------------------------------
170
+ # Proposal events (eagerly load knowledge_item -> document_version)
171
  # ------------------------------------------------------------------
172
  proposals = (
173
  db.query(Proposal)
174
+ .options(
175
+ joinedload(Proposal.knowledge_item).joinedload(KnowledgeItem.document_version)
176
+ )
177
  .filter(Proposal.workspace_id == workspace_id)
178
  .order_by(Proposal.created_at.desc())
179
  .limit(fetch_limit)
180
  .all()
181
  )
182
  for p in proposals:
 
183
  p_filename = None
184
  ki = p.knowledge_item
185
  if ki and ki.document_version:
 
235
  })
236
 
237
  # ------------------------------------------------------------------
238
+ # Commit events (eagerly load proposal -> knowledge_item -> document_version)
239
  # ------------------------------------------------------------------
240
  commits = (
241
  db.query(Commit)
242
+ .options(
243
+ joinedload(Commit.proposal)
244
+ .joinedload(Proposal.knowledge_item)
245
+ .joinedload(KnowledgeItem.document_version)
246
+ )
247
  .filter(Commit.workspace_id == workspace_id)
248
  .order_by(Commit.committed_at.desc())
249
  .limit(fetch_limit)
250
  .all()
251
  )
252
  for c in commits:
 
253
  c_filename = None
254
+ if c.proposal and c.proposal.knowledge_item and c.proposal.knowledge_item.document_version:
255
+ c_filename = c.proposal.knowledge_item.document_version.filename
 
 
256
 
257
  events.append({
258
  "id": f"commit-{c.id}",
backend/app/api/dashboard.py CHANGED
@@ -42,59 +42,20 @@ def get_dashboard_stats(
42
  detail="You do not have access to this workspace.",
43
  )
44
 
45
- total_documents = (
46
- db.query(func.count(Document.id))
47
- .filter(Document.workspace_id == workspace_id)
48
- .scalar()
49
- ) or 0
50
-
51
- workflows_running = (
52
- db.query(func.count(WorkflowRun.id))
53
- .filter(
54
- WorkflowRun.workspace_id == workspace_id,
55
- WorkflowRun.status == WorkflowStatus.RUNNING,
56
- )
57
- .scalar()
58
- ) or 0
59
-
60
- workflows_waiting = (
61
- db.query(func.count(WorkflowRun.id))
62
- .filter(
63
- WorkflowRun.workspace_id == workspace_id,
64
- WorkflowRun.status == WorkflowStatus.WAITING_FOR_REVIEW,
65
- )
66
- .scalar()
67
- ) or 0
68
-
69
- workflows_completed = (
70
- db.query(func.count(WorkflowRun.id))
71
- .filter(
72
- WorkflowRun.workspace_id == workspace_id,
73
- WorkflowRun.status == WorkflowStatus.COMPLETED,
74
- )
75
- .scalar()
76
- ) or 0
77
-
78
- pending_proposals = (
79
- db.query(func.count(Proposal.id))
80
- .filter(
81
- Proposal.workspace_id == workspace_id,
82
- Proposal.status == ProposalStatus.PENDING,
83
- )
84
- .scalar()
85
- ) or 0
86
-
87
- knowledge_items = (
88
- db.query(func.count(KnowledgeItem.id))
89
- .filter(KnowledgeItem.workspace_id == workspace_id)
90
- .scalar()
91
- ) or 0
92
 
93
  return {
94
- "total_documents": total_documents,
95
- "workflows_running": workflows_running,
96
- "workflows_waiting_for_review": workflows_waiting,
97
- "workflows_completed": workflows_completed,
98
- "pending_proposals": pending_proposals,
99
- "knowledge_items": knowledge_items,
100
  }
 
42
  detail="You do not have access to this workspace.",
43
  )
44
 
45
+ stats = db.query(
46
+ db.query(func.count(Document.id)).filter(Document.workspace_id == workspace_id).scalar_subquery().label("total_documents"),
47
+ db.query(func.count(WorkflowRun.id)).filter(WorkflowRun.workspace_id == workspace_id, WorkflowRun.status == WorkflowStatus.RUNNING).scalar_subquery().label("workflows_running"),
48
+ db.query(func.count(WorkflowRun.id)).filter(WorkflowRun.workspace_id == workspace_id, WorkflowRun.status == WorkflowStatus.WAITING_FOR_REVIEW).scalar_subquery().label("workflows_waiting_for_review"),
49
+ db.query(func.count(WorkflowRun.id)).filter(WorkflowRun.workspace_id == workspace_id, WorkflowRun.status == WorkflowStatus.COMPLETED).scalar_subquery().label("workflows_completed"),
50
+ db.query(func.count(Proposal.id)).filter(Proposal.workspace_id == workspace_id, Proposal.status == ProposalStatus.PENDING).scalar_subquery().label("pending_proposals"),
51
+ db.query(func.count(KnowledgeItem.id)).filter(KnowledgeItem.workspace_id == workspace_id).scalar_subquery().label("knowledge_items"),
52
+ ).first()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
 
54
  return {
55
+ "total_documents": (stats.total_documents if stats else 0) or 0,
56
+ "workflows_running": (stats.workflows_running if stats else 0) or 0,
57
+ "workflows_waiting_for_review": (stats.workflows_waiting_for_review if stats else 0) or 0,
58
+ "workflows_completed": (stats.workflows_completed if stats else 0) or 0,
59
+ "pending_proposals": (stats.pending_proposals if stats else 0) or 0,
60
+ "knowledge_items": (stats.knowledge_items if stats else 0) or 0,
61
  }
backend/app/api/documents.py CHANGED
@@ -3,12 +3,13 @@ import os
3
  from uuid import UUID
4
 
5
  from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
6
- from sqlalchemy.orm import Session
7
 
8
  from app.core.dependencies import get_current_user
9
  from app.core.file_types import ALLOWED_EXTENSIONS
10
  from app.database.session import get_db
11
  from app.models.document import Document
 
12
  from app.models.user import User
13
  from app.models.workflow_run import WorkflowRun, WorkflowStatus
14
  from app.models.workspace import Workspace
@@ -32,6 +33,9 @@ def list_documents(
32
 
33
  documents = (
34
  db.query(Document)
 
 
 
35
  .filter(Document.workspace_id == workspace_id)
36
  .order_by(Document.created_at.desc())
37
  .all()
 
3
  from uuid import UUID
4
 
5
  from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
6
+ from sqlalchemy.orm import Session, selectinload
7
 
8
  from app.core.dependencies import get_current_user
9
  from app.core.file_types import ALLOWED_EXTENSIONS
10
  from app.database.session import get_db
11
  from app.models.document import Document
12
+ from app.models.document_version import DocumentVersion
13
  from app.models.user import User
14
  from app.models.workflow_run import WorkflowRun, WorkflowStatus
15
  from app.models.workspace import Workspace
 
33
 
34
  documents = (
35
  db.query(Document)
36
+ .options(
37
+ selectinload(Document.versions).selectinload(DocumentVersion.workflow_runs)
38
+ )
39
  .filter(Document.workspace_id == workspace_id)
40
  .order_by(Document.created_at.desc())
41
  .all()
backend/app/api/workflows.py CHANGED
@@ -1,9 +1,9 @@
1
- from __future__ import annotations
2
 
3
  from uuid import UUID
4
 
5
  from fastapi import APIRouter, Depends, HTTPException
6
- from sqlalchemy.orm import Session
7
 
8
  from app.core.dependencies import get_current_user
9
  from app.database.session import get_db
@@ -93,6 +93,10 @@ def list_workflows(
93
 
94
  query = (
95
  db.query(WorkflowRun)
 
 
 
 
96
  .filter(WorkflowRun.workspace_id == workspace_id)
97
  )
98
 
@@ -115,6 +119,10 @@ def get_workflow_by_document_version(
115
  ):
116
  workflow = (
117
  db.query(WorkflowRun)
 
 
 
 
118
  .filter(WorkflowRun.document_version_id == document_version_id)
119
  .order_by(WorkflowRun.started_at.desc())
120
  .first()
@@ -166,6 +174,10 @@ def get_workflow(
166
  ):
167
  workflow = (
168
  db.query(WorkflowRun)
 
 
 
 
169
  .filter(WorkflowRun.id == workflow_id)
170
  .first()
171
  )
 
1
+ from __future__ import annotations
2
 
3
  from uuid import UUID
4
 
5
  from fastapi import APIRouter, Depends, HTTPException
6
+ from sqlalchemy.orm import Session, joinedload, selectinload
7
 
8
  from app.core.dependencies import get_current_user
9
  from app.database.session import get_db
 
93
 
94
  query = (
95
  db.query(WorkflowRun)
96
+ .options(
97
+ joinedload(WorkflowRun.document_version),
98
+ selectinload(WorkflowRun.checkpoints),
99
+ )
100
  .filter(WorkflowRun.workspace_id == workspace_id)
101
  )
102
 
 
119
  ):
120
  workflow = (
121
  db.query(WorkflowRun)
122
+ .options(
123
+ joinedload(WorkflowRun.document_version),
124
+ selectinload(WorkflowRun.checkpoints),
125
+ )
126
  .filter(WorkflowRun.document_version_id == document_version_id)
127
  .order_by(WorkflowRun.started_at.desc())
128
  .first()
 
174
  ):
175
  workflow = (
176
  db.query(WorkflowRun)
177
+ .options(
178
+ joinedload(WorkflowRun.document_version),
179
+ selectinload(WorkflowRun.checkpoints),
180
+ )
181
  .filter(WorkflowRun.id == workflow_id)
182
  .first()
183
  )
backend/app/database/database.py CHANGED
@@ -6,8 +6,8 @@ from app.core.config import DATABASE_URL
6
  engine = create_engine(
7
  DATABASE_URL,
8
  pool_pre_ping=True,
9
- pool_size=5,
10
- max_overflow=10,
11
  pool_recycle=300,
12
  )
13
 
 
6
  engine = create_engine(
7
  DATABASE_URL,
8
  pool_pre_ping=True,
9
+ pool_size=10,
10
+ max_overflow=20,
11
  pool_recycle=300,
12
  )
13
 
docs/one-pager.md ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # DocWeave — One Page
2
+
3
+ ## What I Built
4
+
5
+ DocWeave is an agentic document intelligence platform that transforms unstructured documents into a governed knowledge register. It processes PDFs through an AI pipeline (extract, chunk, embed, extract knowledge, reconcile, validate, decide) and either auto-commits verified knowledge or pauses for human review — depending on configurable validation rules.
6
+
7
+ Think of it as "GitHub for documents": every knowledge change is proposed, validated against rules, reviewed if needed, and committed with full audit trail.
8
+
9
+ ## Who It's For
10
+
11
+ Teams that manage large document corpora and need structured, searchable, auditable knowledge — compliance officers, research groups, clinical teams, legal departments. Anyone who's ever said "I know we read this somewhere but can't find which document said it."
12
+
13
+ ## Results
14
+
15
+ - **End-to-end processing**: Upload a PDF, get structured knowledge items (ENTITY, CLAIM, METHOD, METRIC, OBSERVATION) with verbatim evidence quotes and confidence scores in 20-45 seconds.
16
+ - **Zero-loss batch extraction**: Documents of any size are batched and processed sequentially — no data is truncated or lost.
17
+ - **Human-in-the-loop**: 4 configurable validation operators (min_confidence, required_evidence, allowed_proposal_types, topic_relevance) determine auto-commit vs. human review.
18
+ - **Crash resilience**: LangGraph checkpointing means workflows survive server restarts. Graceful shutdown waits for active work; auto-resume picks up orphaned workflows.
19
+ - **MCP integration**: 15-tool Model Context Protocol server lets AI agents drive the full lifecycle programmatically.
20
+ - **Deployed**: Frontend on Vercel, backend on HuggingFace Spaces (Docker), database on Neon PostgreSQL (Singapore).
21
+
22
+ ## Key Trade-Offs
23
+
24
+ | Decision | Why |
25
+ |----------|-----|
26
+ | **Background threads (not Celery)** | Simpler deployment, no Redis/broker needed. Trade-off: limited to single-server horizontal scaling. Acceptable for a demo/small team tool. |
27
+ | **Groq free tier LLM** | Zero cost, fast inference. Trade-off: strict request size limits on free org tier — knowledge extraction requires small batches. A paid tier or self-hosted model removes this. |
28
+ | **Ephemeral file storage on HF** | Files don't persist across container restarts. Trade-off: retry on old docs fails. Acceptable because production would use S3/GCS. Demo works for freshly uploaded docs. |
29
+ | **sentence-transformers local embeddings** | No external API dependency, runs on CPU. Trade-off: ~300MB memory footprint, can't run on 512MB servers (Render free tier). Works fine on HF Spaces (16GB RAM). |
30
+ | **PostgreSQL for everything (vectors + relational)** | Single database, pgvector handles vector search. Trade-off: not as fast as dedicated vector DBs (Pinecone, Weaviate) at scale. Perfectly adequate for <100K documents. |
31
+ | **Native JSON mode (not tool calling)** | More reliable structured output from Groq/Qwen/Llama models. Trade-off: less strict schema enforcement than OpenAI function calling. Mitigated by robust JSON parsing with fallbacks. |
32
+
33
+ ## What I'd Add With More Time
34
+
35
+ - Persistent cloud storage (S3) for uploaded documents
36
+ - WebSocket-based real-time progress (currently polls)
37
+ - Multi-user collaboration with role-based access
38
+ - Knowledge graph visualization (entity relationship map)
39
+ - Automatic conflict resolution using LLM reasoning
40
+ - PDF annotation overlay showing where each knowledge item was extracted from
frontend/src/api/client.js CHANGED
@@ -1,6 +1,28 @@
1
  const API_BASE = import.meta.env.VITE_API_BASE_URL || (import.meta.env.DEV ? "http://localhost:8000" : "");
2
 
 
 
3
  export const apiRequest = async (endpoint, method = "GET", body = null) => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  const token = localStorage.getItem("token");
5
  const headers = {};
6
 
 
1
  const API_BASE = import.meta.env.VITE_API_BASE_URL || (import.meta.env.DEV ? "http://localhost:8000" : "");
2
 
3
+ const inFlightRequests = new Map();
4
+
5
  export const apiRequest = async (endpoint, method = "GET", body = null) => {
6
+ // Deduplicate concurrent in-flight GET requests
7
+ if (method === "GET" && !body) {
8
+ if (inFlightRequests.has(endpoint)) {
9
+ return inFlightRequests.get(endpoint);
10
+ }
11
+ const promise = (async () => {
12
+ try {
13
+ return await _performRequest(endpoint, method, body);
14
+ } finally {
15
+ inFlightRequests.delete(endpoint);
16
+ }
17
+ })();
18
+ inFlightRequests.set(endpoint, promise);
19
+ return promise;
20
+ }
21
+
22
+ return _performRequest(endpoint, method, body);
23
+ };
24
+
25
+ const _performRequest = async (endpoint, method = "GET", body = null) => {
26
  const token = localStorage.getItem("token");
27
  const headers = {};
28
 
frontend/src/context/WorkspaceContext.jsx CHANGED
@@ -1,87 +1,106 @@
1
- import { createContext, useContext, useCallback, useEffect, useState } from "react";
2
- import { listWorkspaces, createWorkspace } from "../api/workspaces";
3
-
4
- const WorkspaceContext = createContext();
5
-
6
- export function WorkspaceProvider({ children }) {
7
- const [workspaces, setWorkspaces] = useState([]);
8
- const [currentId, setCurrentId] = useState(() => localStorage.getItem("dw_workspace_id") || null);
9
- const [loading, setLoading] = useState(true);
10
- const [error, setError] = useState(null);
11
-
12
- const refresh = useCallback(async () => {
13
- setLoading(true);
14
- setError(null);
15
- try {
16
- const list = await listWorkspaces();
17
-
18
- if (!Array.isArray(list)) {
19
- setError(list?.detail || "Couldn't load workspaces.");
20
- return;
21
- }
22
-
23
- if (list.length === 0) {
24
- const created = await createWorkspace({
25
- name: "Default Workspace",
26
- description: "Created automatically for document uploads.",
27
- });
28
- if (created?.detail) {
29
- setError(created.detail);
30
- return;
31
- }
32
- setWorkspaces([created]);
33
- setCurrentId(created.id);
34
- localStorage.setItem("dw_workspace_id", created.id);
35
- return;
36
- }
37
-
38
- setWorkspaces(list);
39
- setCurrentId((prev) => {
40
- const saved = prev || localStorage.getItem("dw_workspace_id");
41
- if (saved && list.some((w) => w.id === saved)) return saved;
42
- localStorage.setItem("dw_workspace_id", list[0].id);
43
- return list[0].id;
44
- });
45
- } catch (err) {
46
- setError("Couldn't load workspaces.");
47
- } finally {
48
- setLoading(false);
49
- }
50
- }, []);
51
-
52
- useEffect(() => {
53
- refresh();
54
- }, [refresh]);
55
-
56
- const selectWorkspace = useCallback((id) => {
57
- setCurrentId(id);
58
- localStorage.setItem("dw_workspace_id", id);
59
- }, []);
60
-
61
- const addWorkspace = useCallback(async (name, description = "") => {
62
- const created = await createWorkspace({ name, description });
63
- if (created?.detail) {
64
- setError(created.detail);
65
- return null;
66
- }
67
- setWorkspaces((prev) => [...prev, created]);
68
- setCurrentId(created.id);
69
- return created;
70
- }, []);
71
-
72
- const currentWorkspace = workspaces.find((w) => w.id === currentId) || null;
73
-
74
- const value = {
75
- workspace: currentWorkspace,
76
- workspaces,
77
- loading,
78
- error,
79
- selectWorkspace,
80
- addWorkspace,
81
- refresh,
82
- };
83
-
84
- return <WorkspaceContext.Provider value={value}>{children}</WorkspaceContext.Provider>;
85
- }
86
-
87
- export const useWorkspaceContext = () => useContext(WorkspaceContext);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createContext, useContext, useCallback, useEffect, useState } from "react";
2
+ import { listWorkspaces, createWorkspace } from "../api/workspaces";
3
+
4
+ const WorkspaceContext = createContext();
5
+
6
+ const getInitialCache = () => {
7
+ try {
8
+ const raw = localStorage.getItem("dw_workspaces_cache");
9
+ return raw ? JSON.parse(raw) : [];
10
+ } catch {
11
+ return [];
12
+ }
13
+ };
14
+
15
+ export function WorkspaceProvider({ children }) {
16
+ const [workspaces, setWorkspaces] = useState(getInitialCache);
17
+ const [currentId, setCurrentId] = useState(() => localStorage.getItem("dw_workspace_id") || null);
18
+ const [loading, setLoading] = useState(() => {
19
+ const cached = getInitialCache();
20
+ const savedId = localStorage.getItem("dw_workspace_id");
21
+ return !(cached.length > 0 && (savedId ? cached.some((w) => w.id === savedId) : true));
22
+ });
23
+ const [error, setError] = useState(null);
24
+
25
+ const refresh = useCallback(async () => {
26
+ setError(null);
27
+ try {
28
+ const list = await listWorkspaces();
29
+
30
+ if (!Array.isArray(list)) {
31
+ if (list?.detail) setError(list.detail);
32
+ return;
33
+ }
34
+
35
+ if (list.length === 0) {
36
+ const created = await createWorkspace({
37
+ name: "Default Workspace",
38
+ description: "Created automatically for document uploads.",
39
+ });
40
+ if (created?.detail) {
41
+ setError(created.detail);
42
+ return;
43
+ }
44
+ setWorkspaces([created]);
45
+ setCurrentId(created.id);
46
+ localStorage.setItem("dw_workspace_id", created.id);
47
+ localStorage.setItem("dw_workspaces_cache", JSON.stringify([created]));
48
+ return;
49
+ }
50
+
51
+ setWorkspaces(list);
52
+ localStorage.setItem("dw_workspaces_cache", JSON.stringify(list));
53
+ setCurrentId((prev) => {
54
+ const saved = prev || localStorage.getItem("dw_workspace_id");
55
+ if (saved && list.some((w) => w.id === saved)) return saved;
56
+ localStorage.setItem("dw_workspace_id", list[0].id);
57
+ return list[0].id;
58
+ });
59
+ } catch (err) {
60
+ setError("Couldn't load workspaces.");
61
+ } finally {
62
+ setLoading(false);
63
+ }
64
+ }, []);
65
+
66
+ useEffect(() => {
67
+ refresh();
68
+ }, [refresh]);
69
+
70
+ const selectWorkspace = useCallback((id) => {
71
+ setCurrentId(id);
72
+ localStorage.setItem("dw_workspace_id", id);
73
+ }, []);
74
+
75
+ const addWorkspace = useCallback(async (name, description = "") => {
76
+ const created = await createWorkspace({ name, description });
77
+ if (created?.detail) {
78
+ setError(created.detail);
79
+ return null;
80
+ }
81
+ setWorkspaces((prev) => {
82
+ const next = [...prev, created];
83
+ localStorage.setItem("dw_workspaces_cache", JSON.stringify(next));
84
+ return next;
85
+ });
86
+ setCurrentId(created.id);
87
+ localStorage.setItem("dw_workspace_id", created.id);
88
+ return created;
89
+ }, []);
90
+
91
+ const currentWorkspace = workspaces.find((w) => w.id === currentId) || (workspaces.length > 0 ? workspaces[0] : null);
92
+
93
+ const value = {
94
+ workspace: currentWorkspace,
95
+ workspaces,
96
+ loading,
97
+ error,
98
+ selectWorkspace,
99
+ addWorkspace,
100
+ refresh,
101
+ };
102
+
103
+ return <WorkspaceContext.Provider value={value}>{children}</WorkspaceContext.Provider>;
104
+ }
105
+
106
+ export const useWorkspaceContext = () => useContext(WorkspaceContext);
frontend/src/pages/Dashboard.css CHANGED
@@ -212,6 +212,31 @@
212
  }
213
  }
214
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
  @media (max-width: 480px) {
216
  .dw-dashboard__kpis {
217
  grid-template-columns: repeat(2, 1fr);
 
212
  }
213
  }
214
 
215
+ .dw-dashboard__skeleton {
216
+ animation: dw-shimmer 1.4s ease-in-out infinite;
217
+ background: var(--color-surface-hover);
218
+ border-radius: var(--radius-sm);
219
+ display: inline-block;
220
+ }
221
+
222
+ .dw-dashboard__skeleton-val {
223
+ width: 36px;
224
+ height: 28px;
225
+ vertical-align: middle;
226
+ }
227
+
228
+ .dw-dashboard__skeleton-row {
229
+ height: 38px;
230
+ margin-bottom: var(--space-2);
231
+ width: 100%;
232
+ }
233
+
234
+ @keyframes dw-shimmer {
235
+ 0% { opacity: 0.35; }
236
+ 50% { opacity: 0.75; }
237
+ 100% { opacity: 0.35; }
238
+ }
239
+
240
  @media (max-width: 480px) {
241
  .dw-dashboard__kpis {
242
  grid-template-columns: repeat(2, 1fr);
frontend/src/pages/Dashboard.jsx CHANGED
@@ -32,41 +32,76 @@ const EVENT_ICONS = {
32
  const ACTIVE_STATUSES = new Set(["PENDING", "RUNNING"]);
33
  const POLL_INTERVAL = 3000;
34
 
 
 
 
 
 
 
 
 
 
 
35
  export default function Dashboard() {
36
  const { workspace, loading: wsLoading } = useWorkspace();
37
  const navigate = useNavigate();
38
- const [stats, setStats] = useState(null);
39
- const [recentDocs, setRecentDocs] = useState([]);
40
- const [activity, setActivity] = useState([]);
41
- const [loading, setLoading] = useState(true);
 
 
42
 
43
  const load = useCallback(async () => {
44
  if (!workspace) return;
45
- setLoading(true);
46
  try {
47
  const [s, docs, a] = await Promise.all([
48
  getDashboardStats(workspace.id),
49
  listDocuments(workspace.id),
50
  listActivity(workspace.id, 8),
51
  ]);
52
- if (!s?.detail) setStats(s);
53
- if (Array.isArray(docs)) setRecentDocs(docs.slice(0, 5));
54
- if (Array.isArray(a?.events)) setActivity(a.events);
55
- else if (Array.isArray(a)) setActivity(a);
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  } catch {
57
  // silently handle
58
  } finally {
59
  setLoading(false);
60
  }
61
- }, [workspace]);
62
 
63
- useEffect(() => { load(); }, [load]);
 
 
 
 
 
 
 
 
 
 
 
64
 
65
  // Poll every 3 s while any of the recent docs is actively processing.
66
  const hasActive = recentDocs.some((d) => ACTIVE_STATUSES.has(d.workflow_status));
67
  usePolling(load, POLL_INTERVAL, hasActive);
68
 
69
- if (wsLoading || loading) return <LoadingState label="Loading dashboard..." />;
70
  if (!workspace) return <EmptyState title="No workspace" description="Create a workspace to get started." />;
71
 
72
  return (
@@ -84,27 +119,39 @@ export default function Dashboard() {
84
  {/* KPI Cards */}
85
  <div className="dw-dashboard__kpis">
86
  <div className="dw-dashboard__kpi" onClick={() => navigate("/documents")} role="button">
87
- <span className="dw-dashboard__kpi-value">{stats?.total_documents ?? 0}</span>
 
 
88
  <span className="dw-dashboard__kpi-label">Documents</span>
89
  </div>
90
  <div className="dw-dashboard__kpi dw-dashboard__kpi--accent" onClick={() => navigate("/workflows?filter=RUNNING")} role="button">
91
- <span className="dw-dashboard__kpi-value">{stats?.workflows_running ?? 0}</span>
 
 
92
  <span className="dw-dashboard__kpi-label">Running</span>
93
  </div>
94
  <div className="dw-dashboard__kpi dw-dashboard__kpi--warning" onClick={() => navigate("/workflows?filter=WAITING_FOR_REVIEW")} role="button">
95
- <span className="dw-dashboard__kpi-value">{stats?.workflows_waiting_for_review ?? 0}</span>
 
 
96
  <span className="dw-dashboard__kpi-label">Needs Review</span>
97
  </div>
98
  <div className="dw-dashboard__kpi dw-dashboard__kpi--success" onClick={() => navigate("/workflows?filter=COMPLETED")} role="button">
99
- <span className="dw-dashboard__kpi-value">{stats?.workflows_completed ?? 0}</span>
 
 
100
  <span className="dw-dashboard__kpi-label">Completed</span>
101
  </div>
102
  <div className="dw-dashboard__kpi" onClick={() => navigate("/workflows?filter=WAITING_FOR_REVIEW")} role="button">
103
- <span className="dw-dashboard__kpi-value">{stats?.pending_proposals ?? 0}</span>
 
 
104
  <span className="dw-dashboard__kpi-label">Pending Proposals</span>
105
  </div>
106
  <div className="dw-dashboard__kpi" onClick={() => navigate("/knowledge")} role="button">
107
- <span className="dw-dashboard__kpi-value">{stats?.knowledge_items ?? 0}</span>
 
 
108
  <span className="dw-dashboard__kpi-label">Knowledge Items</span>
109
  </div>
110
  </div>
@@ -128,7 +175,12 @@ export default function Dashboard() {
128
  }
129
  />
130
  <CardBody>
131
- {recentDocs.length === 0 ? (
 
 
 
 
 
132
  <p className="dw-text-muted">No documents yet.</p>
133
  ) : (
134
  <div className="dw-dashboard__doc-list">
@@ -187,7 +239,12 @@ export default function Dashboard() {
187
  action={<Button size="sm" variant="ghost" onClick={() => navigate("/activity")}>View all</Button>}
188
  />
189
  <CardBody>
190
- {activity.length === 0 ? (
 
 
 
 
 
191
  <p className="dw-text-muted">No activity yet.</p>
192
  ) : (
193
  <div className="dw-dashboard__activity">
@@ -211,3 +268,4 @@ export default function Dashboard() {
211
  </div>
212
  );
213
  }
 
 
32
  const ACTIVE_STATUSES = new Set(["PENDING", "RUNNING"]);
33
  const POLL_INTERVAL = 3000;
34
 
35
+ const getCachedDashboard = (workspaceId) => {
36
+ if (!workspaceId) return null;
37
+ try {
38
+ const raw = sessionStorage.getItem(`dw_dash_cache_${workspaceId}`);
39
+ return raw ? JSON.parse(raw) : null;
40
+ } catch {
41
+ return null;
42
+ }
43
+ };
44
+
45
  export default function Dashboard() {
46
  const { workspace, loading: wsLoading } = useWorkspace();
47
  const navigate = useNavigate();
48
+
49
+ const cached = getCachedDashboard(workspace?.id);
50
+ const [stats, setStats] = useState(() => cached?.stats ?? null);
51
+ const [recentDocs, setRecentDocs] = useState(() => cached?.recentDocs ?? []);
52
+ const [activity, setActivity] = useState(() => cached?.activity ?? []);
53
+ const [loading, setLoading] = useState(() => !cached);
54
 
55
  const load = useCallback(async () => {
56
  if (!workspace) return;
 
57
  try {
58
  const [s, docs, a] = await Promise.all([
59
  getDashboardStats(workspace.id),
60
  listDocuments(workspace.id),
61
  listActivity(workspace.id, 8),
62
  ]);
63
+
64
+ const newStats = (!s?.detail) ? s : null;
65
+ const newDocs = Array.isArray(docs) ? docs.slice(0, 5) : [];
66
+ const newActivity = Array.isArray(a?.events) ? a.events : (Array.isArray(a) ? a : []);
67
+
68
+ if (newStats) setStats(newStats);
69
+ setRecentDocs(newDocs);
70
+ setActivity(newActivity);
71
+
72
+ try {
73
+ sessionStorage.setItem(
74
+ `dw_dash_cache_${workspace.id}`,
75
+ JSON.stringify({ stats: newStats || stats, recentDocs: newDocs, activity: newActivity })
76
+ );
77
+ } catch {
78
+ // ignore quota errors
79
+ }
80
  } catch {
81
  // silently handle
82
  } finally {
83
  setLoading(false);
84
  }
85
+ }, [workspace, stats]);
86
 
87
+ useEffect(() => {
88
+ if (workspace) {
89
+ const c = getCachedDashboard(workspace.id);
90
+ if (c) {
91
+ setStats(c.stats);
92
+ setRecentDocs(c.recentDocs);
93
+ setActivity(c.activity);
94
+ setLoading(false);
95
+ }
96
+ load();
97
+ }
98
+ }, [workspace?.id]); // eslint-disable-line react-hooks/exhaustive-deps
99
 
100
  // Poll every 3 s while any of the recent docs is actively processing.
101
  const hasActive = recentDocs.some((d) => ACTIVE_STATUSES.has(d.workflow_status));
102
  usePolling(load, POLL_INTERVAL, hasActive);
103
 
104
+ if (wsLoading && !workspace) return <LoadingState label="Loading dashboard..." />;
105
  if (!workspace) return <EmptyState title="No workspace" description="Create a workspace to get started." />;
106
 
107
  return (
 
119
  {/* KPI Cards */}
120
  <div className="dw-dashboard__kpis">
121
  <div className="dw-dashboard__kpi" onClick={() => navigate("/documents")} role="button">
122
+ <span className="dw-dashboard__kpi-value">
123
+ {stats ? (stats.total_documents ?? 0) : <span className="dw-dashboard__skeleton dw-dashboard__skeleton-val" />}
124
+ </span>
125
  <span className="dw-dashboard__kpi-label">Documents</span>
126
  </div>
127
  <div className="dw-dashboard__kpi dw-dashboard__kpi--accent" onClick={() => navigate("/workflows?filter=RUNNING")} role="button">
128
+ <span className="dw-dashboard__kpi-value">
129
+ {stats ? (stats.workflows_running ?? 0) : <span className="dw-dashboard__skeleton dw-dashboard__skeleton-val" />}
130
+ </span>
131
  <span className="dw-dashboard__kpi-label">Running</span>
132
  </div>
133
  <div className="dw-dashboard__kpi dw-dashboard__kpi--warning" onClick={() => navigate("/workflows?filter=WAITING_FOR_REVIEW")} role="button">
134
+ <span className="dw-dashboard__kpi-value">
135
+ {stats ? (stats.workflows_waiting_for_review ?? 0) : <span className="dw-dashboard__skeleton dw-dashboard__skeleton-val" />}
136
+ </span>
137
  <span className="dw-dashboard__kpi-label">Needs Review</span>
138
  </div>
139
  <div className="dw-dashboard__kpi dw-dashboard__kpi--success" onClick={() => navigate("/workflows?filter=COMPLETED")} role="button">
140
+ <span className="dw-dashboard__kpi-value">
141
+ {stats ? (stats.workflows_completed ?? 0) : <span className="dw-dashboard__skeleton dw-dashboard__skeleton-val" />}
142
+ </span>
143
  <span className="dw-dashboard__kpi-label">Completed</span>
144
  </div>
145
  <div className="dw-dashboard__kpi" onClick={() => navigate("/workflows?filter=WAITING_FOR_REVIEW")} role="button">
146
+ <span className="dw-dashboard__kpi-value">
147
+ {stats ? (stats.pending_proposals ?? 0) : <span className="dw-dashboard__skeleton dw-dashboard__skeleton-val" />}
148
+ </span>
149
  <span className="dw-dashboard__kpi-label">Pending Proposals</span>
150
  </div>
151
  <div className="dw-dashboard__kpi" onClick={() => navigate("/knowledge")} role="button">
152
+ <span className="dw-dashboard__kpi-value">
153
+ {stats ? (stats.knowledge_items ?? 0) : <span className="dw-dashboard__skeleton dw-dashboard__skeleton-val" />}
154
+ </span>
155
  <span className="dw-dashboard__kpi-label">Knowledge Items</span>
156
  </div>
157
  </div>
 
175
  }
176
  />
177
  <CardBody>
178
+ {loading && recentDocs.length === 0 ? (
179
+ <div className="dw-dashboard__doc-list">
180
+ <div className="dw-dashboard__skeleton dw-dashboard__skeleton-row" />
181
+ <div className="dw-dashboard__skeleton dw-dashboard__skeleton-row" />
182
+ </div>
183
+ ) : recentDocs.length === 0 ? (
184
  <p className="dw-text-muted">No documents yet.</p>
185
  ) : (
186
  <div className="dw-dashboard__doc-list">
 
239
  action={<Button size="sm" variant="ghost" onClick={() => navigate("/activity")}>View all</Button>}
240
  />
241
  <CardBody>
242
+ {loading && activity.length === 0 ? (
243
+ <div className="dw-dashboard__activity">
244
+ <div className="dw-dashboard__skeleton dw-dashboard__skeleton-row" />
245
+ <div className="dw-dashboard__skeleton dw-dashboard__skeleton-row" />
246
+ </div>
247
+ ) : activity.length === 0 ? (
248
  <p className="dw-text-muted">No activity yet.</p>
249
  ) : (
250
  <div className="dw-dashboard__activity">
 
268
  </div>
269
  );
270
  }
271
+
frontend/src/pages/auth/Login.jsx CHANGED
@@ -46,10 +46,6 @@ export default function Login() {
46
  }
47
 
48
  login(result.access_token);
49
-
50
- const me = await apiRequest("/auth/me");
51
- if (me && !me.detail) setUser(me);
52
-
53
  navigate(redirectTo, { replace: true });
54
  } catch (err) {
55
  setError("Couldn't reach the server. Please try again.");
 
46
  }
47
 
48
  login(result.access_token);
 
 
 
 
49
  navigate(redirectTo, { replace: true });
50
  } catch (err) {
51
  setError("Couldn't reach the server. Please try again.");