diff --git a/apps/backend/app/api/routes/auth/services.py b/apps/backend/app/api/routes/auth/services.py index 2417735d..dd0676d6 100644 --- a/apps/backend/app/api/routes/auth/services.py +++ b/apps/backend/app/api/routes/auth/services.py @@ -1,6 +1,6 @@ from typing import Annotated -from fastapi import Cookie, Depends, Header, HTTPException, status +from fastapi import Cookie, Depends, Header, HTTPException, Request, status from app.api.routes.auth.schema import UserProfileResponse, PersonalInfo, Certification from app.api.routes.auth.tokens import decode_access_token @@ -60,18 +60,35 @@ def get_current_uid( return decode_access_token(token) -async def get_current_user(uid: str = Depends(get_current_uid)) -> UserProfileResponse: - doc = await get_user_doc(uid) - if not doc: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="User not found.", - ) - if doc.get("disabled"): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Account disabled.", - ) +async def get_current_user( + request: Request, + uid: str = Depends(get_current_uid), +) -> UserProfileResponse: + """Resolve the current user profile, memoized per request on + ``request.state.current_user_doc``. + + Multiple ``Depends(get_current_user)`` in the same request hit Mongo + once. If a handler mutates the user document mid-request and needs + fresh data afterwards, reset the slot first: + ``request.state.current_user_doc = None``. + """ + cached = getattr(request.state, "current_user_doc", None) + if cached is not None and cached.get("_id") == uid: + doc = cached + else: + doc = await get_user_doc(uid) + if not doc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found.", + ) + if doc.get("disabled"): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Account disabled.", + ) + request.state.current_user_doc = doc + return UserProfileResponse( uid=str(doc["_id"]), email=doc.get("email"), diff --git a/apps/backend/tests/test_auth_memo.py b/apps/backend/tests/test_auth_memo.py new file mode 100644 index 00000000..45d00828 --- /dev/null +++ b/apps/backend/tests/test_auth_memo.py @@ -0,0 +1,138 @@ +import pytest +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient + +from app.api.routes.auth.services import get_current_user, get_current_uid +from app.api.routes.auth.schema import UserProfileResponse + + +@pytest.fixture +def app_with_double_dep(monkeypatch): + """FastAPI app whose endpoint asks for get_current_user twice. + + Patches the Mongo lookup with a counter so we can assert it ran once. + """ + calls = {"n": 0} + + async def fake_get_user_doc(uid: str): + calls["n"] += 1 + return { + "_id": uid, + "email": "u@example.com", + "display_name": "U", + "photo_url": None, + "email_verified": True, + "disabled": False, + } + + def fake_get_current_uid() -> str: + return "uid-abc" + + monkeypatch.setattr( + "app.api.routes.auth.services.get_user_doc", + fake_get_user_doc, + ) + + app = FastAPI() + + # Override via FastAPI dependency_overrides rather than monkeypatching the module name, + # so the dep graph through Depends() is preserved. + app.dependency_overrides[get_current_uid] = fake_get_current_uid + + @app.get("/twice") + async def twice( + a: UserProfileResponse = Depends(get_current_user), + b: UserProfileResponse = Depends(get_current_user), + ) -> dict: + return {"a_uid": a.uid, "b_uid": b.uid, "calls": calls["n"]} + + return app, calls + + +def test_get_current_user_memoized_per_request(app_with_double_dep) -> None: + app, calls = app_with_double_dep + client = TestClient(app) + res = client.get("/twice") + assert res.status_code == 200 + body = res.json() + assert body["a_uid"] == body["b_uid"] == "uid-abc" + assert calls["n"] == 1, "Mongo lookup must run once per request" + + +def test_get_current_user_fresh_each_new_request(app_with_double_dep) -> None: + app, calls = app_with_double_dep + client = TestClient(app) + client.get("/twice") + client.get("/twice") + # Two requests × one lookup each + assert calls["n"] == 2 + + +@pytest.fixture +def app_with_stale_cache(monkeypatch): + """FastAPI app that pre-seeds a stale cached doc with mismatched uid. + + Tests that the cache guard correctly rejects a doc with a different _id + and fetches fresh from Mongo. + """ + calls = {"n": 0} + + async def fake_get_user_doc(uid: str): + calls["n"] += 1 + return { + "_id": uid, + "email": "u@example.com", + "display_name": "U", + "photo_url": None, + "email_verified": True, + "disabled": False, + } + + def fake_get_current_uid() -> str: + return "uid-abc" + + monkeypatch.setattr( + "app.api.routes.auth.services.get_user_doc", + fake_get_user_doc, + ) + + app = FastAPI() + + # Override via FastAPI dependency_overrides rather than monkeypatching the module name, + # so the dep graph through Depends() is preserved. + app.dependency_overrides[get_current_uid] = fake_get_current_uid + + @app.middleware("http") + async def inject_stale_cache(request, call_next): + """Pre-seed request.state with a cached doc from a different uid.""" + request.state.current_user_doc = { + "_id": "other-uid", + "email": "other@example.com", + "display_name": "Other", + "photo_url": None, + "email_verified": True, + "disabled": False, + } + response = await call_next(request) + return response + + @app.get("/twice") + async def twice( + a: UserProfileResponse = Depends(get_current_user), + b: UserProfileResponse = Depends(get_current_user), + ) -> dict: + return {"a_uid": a.uid, "b_uid": b.uid, "calls": calls["n"]} + + return app, calls + + +def test_get_current_user_cache_bypassed_on_uid_mismatch(app_with_stale_cache) -> None: + app, calls = app_with_stale_cache + client = TestClient(app) + res = client.get("/twice") + assert res.status_code == 200 + body = res.json() + # Should fetch fresh and return the correct uid, not "other-uid" + assert body["a_uid"] == body["b_uid"] == "uid-abc" + # Should have fetched fresh once (not used stale cache) + assert calls["n"] == 1, "Cache must be bypassed when _id doesn't match uid" diff --git a/apps/web/messages/af.json b/apps/web/messages/af.json index 99bc4b18..9309f407 100644 --- a/apps/web/messages/af.json +++ b/apps/web/messages/af.json @@ -223,7 +223,9 @@ "brandName": "MyDevTools", "stats": { "tools": "Nutsgoed", - "favorites": "Gunstelinge" + "favorites": "Gunstelinge", + "pinned": "Vasgespeld", + "recent": "Onlangs gebruik" }, "sections": { "recentlyUsed": "Onlangs gebruik", @@ -482,7 +484,12 @@ "taskMixTitle": "Task status", "taskMixEmpty": "No tasks yet — add some in Tasks.", "loadingLabel": "Syncing your stats…", - "codeSnippets": "Kodesnippette" + "codeSnippets": "Kodesnippette", + "activityTitle": "Activity", + "activityEmpty": "Activity builds as you use tools", + "distributionTitle": "Data distribution", + "topToolsTitle": "Top tools", + "taskDonutTitle": "Task completion" }, "ui": { "title": "Koppelvlakvoorkeure", diff --git a/apps/web/messages/ar.json b/apps/web/messages/ar.json index 486b5b90..2d52c645 100644 --- a/apps/web/messages/ar.json +++ b/apps/web/messages/ar.json @@ -223,7 +223,9 @@ "brandName": "MyDevTools", "stats": { "tools": "الأدوات", - "favorites": "المفضلة" + "favorites": "المفضلة", + "pinned": "مثبت", + "recent": "المستخدمة مؤخرًا" }, "sections": { "recentlyUsed": "المستخدمة مؤخرًا", @@ -482,7 +484,12 @@ "taskMixTitle": "Task status", "taskMixEmpty": "No tasks yet — add some in Tasks.", "loadingLabel": "Syncing your stats…", - "codeSnippets": "مقاطع برمجية" + "codeSnippets": "مقاطع برمجية", + "activityTitle": "Activity", + "activityEmpty": "Activity builds as you use tools", + "distributionTitle": "Data distribution", + "topToolsTitle": "Top tools", + "taskDonutTitle": "Task completion" }, "ui": { "title": "تفضيلات الواجهة", diff --git a/apps/web/messages/ca.json b/apps/web/messages/ca.json index 14405770..7610b53b 100644 --- a/apps/web/messages/ca.json +++ b/apps/web/messages/ca.json @@ -223,7 +223,9 @@ "brandName": "MyDevTools", "stats": { "tools": "Eines", - "favorites": "Preferits" + "favorites": "Preferits", + "pinned": "Fixat", + "recent": "Usades recentment" }, "sections": { "recentlyUsed": "Usades recentment", @@ -482,7 +484,12 @@ "taskMixTitle": "Task status", "taskMixEmpty": "No tasks yet — add some in Tasks.", "loadingLabel": "Syncing your stats…", - "codeSnippets": "Fragments de codi" + "codeSnippets": "Fragments de codi", + "activityTitle": "Activity", + "activityEmpty": "Activity builds as you use tools", + "distributionTitle": "Data distribution", + "topToolsTitle": "Top tools", + "taskDonutTitle": "Task completion" }, "ui": { "title": "Preferències d’interfície", diff --git a/apps/web/messages/cs.json b/apps/web/messages/cs.json index aec34151..fae1d684 100644 --- a/apps/web/messages/cs.json +++ b/apps/web/messages/cs.json @@ -223,7 +223,9 @@ "brandName": "MyDevTools", "stats": { "tools": "Nástroje", - "favorites": "Oblíbené" + "favorites": "Oblíbené", + "pinned": "Připnuté", + "recent": "Nedávno použité" }, "sections": { "recentlyUsed": "Nedávno použité", @@ -482,7 +484,12 @@ "taskMixTitle": "Task status", "taskMixEmpty": "No tasks yet — add some in Tasks.", "loadingLabel": "Syncing your stats…", - "codeSnippets": "Úryvky kódu" + "codeSnippets": "Úryvky kódu", + "activityTitle": "Activity", + "activityEmpty": "Activity builds as you use tools", + "distributionTitle": "Data distribution", + "topToolsTitle": "Top tools", + "taskDonutTitle": "Task completion" }, "ui": { "title": "Předvolby rozhraní", diff --git a/apps/web/messages/da.json b/apps/web/messages/da.json index bd982b15..6f585d63 100644 --- a/apps/web/messages/da.json +++ b/apps/web/messages/da.json @@ -223,7 +223,9 @@ "brandName": "MyDevTools", "stats": { "tools": "Værktøjer", - "favorites": "Favoritter" + "favorites": "Favoritter", + "pinned": "Fastgjort", + "recent": "Senest brugt" }, "sections": { "recentlyUsed": "Senest brugt", @@ -482,7 +484,12 @@ "taskMixTitle": "Task status", "taskMixEmpty": "No tasks yet — add some in Tasks.", "loadingLabel": "Syncing your stats…", - "codeSnippets": "Kode-snippets" + "codeSnippets": "Kode-snippets", + "activityTitle": "Activity", + "activityEmpty": "Activity builds as you use tools", + "distributionTitle": "Data distribution", + "topToolsTitle": "Top tools", + "taskDonutTitle": "Task completion" }, "ui": { "title": "Brugerfladeindstillinger", diff --git a/apps/web/messages/de.json b/apps/web/messages/de.json index 2239841c..d2c3a8e4 100644 --- a/apps/web/messages/de.json +++ b/apps/web/messages/de.json @@ -223,7 +223,9 @@ "brandName": "MyDevTools", "stats": { "tools": "Tools", - "favorites": "Favoriten" + "favorites": "Favoriten", + "pinned": "Angeheftet", + "recent": "Zuletzt verwendet" }, "sections": { "recentlyUsed": "Zuletzt verwendet", @@ -482,7 +484,12 @@ "taskMixTitle": "Task status", "taskMixEmpty": "No tasks yet — add some in Tasks.", "loadingLabel": "Syncing your stats…", - "codeSnippets": "Code-Snippets" + "codeSnippets": "Code-Snippets", + "activityTitle": "Activity", + "activityEmpty": "Activity builds as you use tools", + "distributionTitle": "Data distribution", + "topToolsTitle": "Top tools", + "taskDonutTitle": "Task completion" }, "ui": { "title": "Oberflächeneinstellungen", diff --git a/apps/web/messages/el.json b/apps/web/messages/el.json index 33221c4e..3344caa8 100644 --- a/apps/web/messages/el.json +++ b/apps/web/messages/el.json @@ -223,7 +223,9 @@ "brandName": "MyDevTools", "stats": { "tools": "Εργαλεία", - "favorites": "Αγαπημένα" + "favorites": "Αγαπημένα", + "pinned": "Καρφιτσωμένα", + "recent": "Πρόσφατα" }, "sections": { "recentlyUsed": "Πρόσφατα", @@ -482,7 +484,12 @@ "taskMixTitle": "Task status", "taskMixEmpty": "No tasks yet — add some in Tasks.", "loadingLabel": "Syncing your stats…", - "codeSnippets": "Αποσπάσματα κώδικα" + "codeSnippets": "Αποσπάσματα κώδικα", + "activityTitle": "Activity", + "activityEmpty": "Activity builds as you use tools", + "distributionTitle": "Data distribution", + "topToolsTitle": "Top tools", + "taskDonutTitle": "Task completion" }, "ui": { "title": "Προτιμήσεις διεπαφής", diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 22f166ad..b83af0f4 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -232,7 +232,9 @@ "signIn": "Sign in", "stats": { "tools": "Tools", - "favorites": "Favorites" + "favorites": "Favorites", + "pinned": "Pinned", + "recent": "Recently Used" }, "sections": { "recentlyUsed": "Recently Used", @@ -277,7 +279,12 @@ "apiClientEnvironments": "API environments", "apiClientHistory": "API history entries", "jsonFormatterDocuments": "JSON formatter documents", - "codeSnippets": "Code snippets" + "codeSnippets": "Code snippets", + "activityTitle": "Activity", + "activityEmpty": "Activity builds as you use tools", + "distributionTitle": "Data distribution", + "topToolsTitle": "Top tools", + "taskDonutTitle": "Task completion" }, "ui": { "title": "Interface preferences", diff --git a/apps/web/messages/es.json b/apps/web/messages/es.json index 191281a4..4c9257d9 100644 --- a/apps/web/messages/es.json +++ b/apps/web/messages/es.json @@ -223,7 +223,9 @@ "brandName": "MyDevTools", "stats": { "tools": "Herramientas", - "favorites": "Favoritos" + "favorites": "Favoritos", + "pinned": "Fijados", + "recent": "Usadas recientemente" }, "sections": { "recentlyUsed": "Usadas recientemente", @@ -482,7 +484,12 @@ "taskMixTitle": "Task status", "taskMixEmpty": "No tasks yet — add some in Tasks.", "loadingLabel": "Syncing your stats…", - "codeSnippets": "Fragmentos de código" + "codeSnippets": "Fragmentos de código", + "activityTitle": "Activity", + "activityEmpty": "Activity builds as you use tools", + "distributionTitle": "Data distribution", + "topToolsTitle": "Top tools", + "taskDonutTitle": "Task completion" }, "ui": { "title": "Preferencias de interfaz", diff --git a/apps/web/messages/fa.json b/apps/web/messages/fa.json index e3487a2a..e2b52967 100644 --- a/apps/web/messages/fa.json +++ b/apps/web/messages/fa.json @@ -223,7 +223,9 @@ "brandName": "MyDevTools", "stats": { "tools": "ابزار", - "favorites": "موارد دلخواه" + "favorites": "موارد دلخواه", + "pinned": "سنجاق‌شده", + "recent": "اخیرا استفاده شده است" }, "sections": { "recentlyUsed": "اخیرا استفاده شده است", @@ -482,7 +484,12 @@ "taskMixTitle": "Task status", "taskMixEmpty": "No tasks yet — add some in Tasks.", "loadingLabel": "Syncing your stats…", - "codeSnippets": "قطعه‌کدهای برنامه" + "codeSnippets": "قطعه‌کدهای برنامه", + "activityTitle": "Activity", + "activityEmpty": "Activity builds as you use tools", + "distributionTitle": "Data distribution", + "topToolsTitle": "Top tools", + "taskDonutTitle": "Task completion" }, "ui": { "title": "ترجیحات رابط", diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index a833fe8d..694642da 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -223,7 +223,9 @@ "brandName": "MyDevTools", "stats": { "tools": "Outils", - "favorites": "Favoris" + "favorites": "Favoris", + "pinned": "Épinglés", + "recent": "Récemment utilisés" }, "sections": { "recentlyUsed": "Récemment utilisés", @@ -482,7 +484,12 @@ "taskMixTitle": "Task status", "taskMixEmpty": "No tasks yet — add some in Tasks.", "loadingLabel": "Syncing your stats…", - "codeSnippets": "Extraits de code" + "codeSnippets": "Extraits de code", + "activityTitle": "Activity", + "activityEmpty": "Activity builds as you use tools", + "distributionTitle": "Data distribution", + "topToolsTitle": "Top tools", + "taskDonutTitle": "Task completion" }, "ui": { "title": "Préférences d’interface", diff --git a/apps/web/messages/id.json b/apps/web/messages/id.json index c7af82bb..d6ea2183 100644 --- a/apps/web/messages/id.json +++ b/apps/web/messages/id.json @@ -223,7 +223,9 @@ "brandName": "MyDevTools", "stats": { "tools": "Alat yang digunakan", - "favorites": "Favorit" + "favorites": "Favorit", + "pinned": "Disematkan", + "recent": "Baru Digunakan" }, "sections": { "recentlyUsed": "Baru Digunakan", @@ -482,7 +484,12 @@ "taskMixTitle": "Task status", "taskMixEmpty": "No tasks yet — add some in Tasks.", "loadingLabel": "Syncing your stats…", - "codeSnippets": "Cuplikan kode" + "codeSnippets": "Cuplikan kode", + "activityTitle": "Activity", + "activityEmpty": "Activity builds as you use tools", + "distributionTitle": "Data distribution", + "topToolsTitle": "Top tools", + "taskDonutTitle": "Task completion" }, "ui": { "title": "Preferensi antarmuka", diff --git a/apps/web/messages/it.json b/apps/web/messages/it.json index eb2cb3f2..5d893705 100644 --- a/apps/web/messages/it.json +++ b/apps/web/messages/it.json @@ -223,7 +223,9 @@ "brandName": "MyDevTools", "stats": { "tools": "Utensili", - "favorites": "Preferiti" + "favorites": "Preferiti", + "pinned": "In evidenza", + "recent": "Usato di recente" }, "sections": { "recentlyUsed": "Usato di recente", @@ -482,7 +484,12 @@ "taskMixTitle": "Task status", "taskMixEmpty": "No tasks yet — add some in Tasks.", "loadingLabel": "Syncing your stats…", - "codeSnippets": "Snippet di codice" + "codeSnippets": "Snippet di codice", + "activityTitle": "Activity", + "activityEmpty": "Activity builds as you use tools", + "distributionTitle": "Data distribution", + "topToolsTitle": "Top tools", + "taskDonutTitle": "Task completion" }, "ui": { "title": "Preferenze interfaccia", diff --git a/apps/web/messages/ja.json b/apps/web/messages/ja.json index 75070888..2651fe1e 100644 --- a/apps/web/messages/ja.json +++ b/apps/web/messages/ja.json @@ -223,7 +223,9 @@ "brandName": "MyDevTools", "stats": { "tools": "ツール", - "favorites": "お気に入り" + "favorites": "お気に入り", + "pinned": "ピン留め", + "recent": "最近使用したツール" }, "sections": { "recentlyUsed": "最近使用したツール", @@ -482,7 +484,12 @@ "taskMixTitle": "Task status", "taskMixEmpty": "No tasks yet — add some in Tasks.", "loadingLabel": "Syncing your stats…", - "codeSnippets": "コードスニペット" + "codeSnippets": "コードスニペット", + "activityTitle": "Activity", + "activityEmpty": "Activity builds as you use tools", + "distributionTitle": "Data distribution", + "topToolsTitle": "Top tools", + "taskDonutTitle": "Task completion" }, "ui": { "title": "インターフェース設定", diff --git a/apps/web/messages/ko.json b/apps/web/messages/ko.json index 5c1ba916..f28edd5b 100644 --- a/apps/web/messages/ko.json +++ b/apps/web/messages/ko.json @@ -223,7 +223,9 @@ "brandName": "MyDevTools", "stats": { "tools": "도구", - "favorites": "즐겨찾기" + "favorites": "즐겨찾기", + "pinned": "고정", + "recent": "최근 사용" }, "sections": { "recentlyUsed": "최근 사용", @@ -482,7 +484,12 @@ "taskMixTitle": "Task status", "taskMixEmpty": "No tasks yet — add some in Tasks.", "loadingLabel": "Syncing your stats…", - "codeSnippets": "코드 스니펫" + "codeSnippets": "코드 스니펫", + "activityTitle": "Activity", + "activityEmpty": "Activity builds as you use tools", + "distributionTitle": "Data distribution", + "topToolsTitle": "Top tools", + "taskDonutTitle": "Task completion" }, "ui": { "title": "인터페이스 설정", diff --git a/apps/web/messages/ms.json b/apps/web/messages/ms.json index c170c984..3c59c6b6 100644 --- a/apps/web/messages/ms.json +++ b/apps/web/messages/ms.json @@ -229,7 +229,9 @@ "brandName": "MyDevTools", "stats": { "tools": "Alat", - "favorites": "Kegemaran" + "favorites": "Kegemaran", + "pinned": "Disematkan", + "recent": "Baru Digunakan" }, "sections": { "recentlyUsed": "Baru Digunakan", @@ -488,7 +490,12 @@ "taskMixTitle": "Task status", "taskMixEmpty": "No tasks yet — add some in Tasks.", "loadingLabel": "Syncing your stats…", - "codeSnippets": "Petikan kod" + "codeSnippets": "Petikan kod", + "activityTitle": "Activity", + "activityEmpty": "Activity builds as you use tools", + "distributionTitle": "Data distribution", + "topToolsTitle": "Top tools", + "taskDonutTitle": "Task completion" }, "ui": { "title": "Pilihan antara muka", diff --git a/apps/web/messages/nb.json b/apps/web/messages/nb.json index 78af5758..5e324a41 100644 --- a/apps/web/messages/nb.json +++ b/apps/web/messages/nb.json @@ -223,7 +223,9 @@ "brandName": "MyDevTools", "stats": { "tools": "Verktøy", - "favorites": "Favoritter" + "favorites": "Favoritter", + "pinned": "Festet", + "recent": "Nylig brukt" }, "sections": { "recentlyUsed": "Nylig brukt", @@ -482,7 +484,12 @@ "taskMixTitle": "Task status", "taskMixEmpty": "No tasks yet — add some in Tasks.", "loadingLabel": "Syncing your stats…", - "codeSnippets": "Kodeutdrag" + "codeSnippets": "Kodeutdrag", + "activityTitle": "Activity", + "activityEmpty": "Activity builds as you use tools", + "distributionTitle": "Data distribution", + "topToolsTitle": "Top tools", + "taskDonutTitle": "Task completion" }, "ui": { "title": "Grensesnittpreferanser", diff --git a/apps/web/messages/nl.json b/apps/web/messages/nl.json index 0a81e317..7d812b18 100644 --- a/apps/web/messages/nl.json +++ b/apps/web/messages/nl.json @@ -223,7 +223,9 @@ "brandName": "MyDevTools", "stats": { "tools": "Tools", - "favorites": "Favorieten" + "favorites": "Favorieten", + "pinned": "Vastgemaakt", + "recent": "Recent gebruikt" }, "sections": { "recentlyUsed": "Recent gebruikt", @@ -482,7 +484,12 @@ "taskMixTitle": "Task status", "taskMixEmpty": "No tasks yet — add some in Tasks.", "loadingLabel": "Syncing your stats…", - "codeSnippets": "Codefragmenten" + "codeSnippets": "Codefragmenten", + "activityTitle": "Activity", + "activityEmpty": "Activity builds as you use tools", + "distributionTitle": "Data distribution", + "topToolsTitle": "Top tools", + "taskDonutTitle": "Task completion" }, "ui": { "title": "Interfacevoorkeuren", diff --git a/apps/web/messages/pl.json b/apps/web/messages/pl.json index c403e450..5022e838 100644 --- a/apps/web/messages/pl.json +++ b/apps/web/messages/pl.json @@ -223,7 +223,9 @@ "brandName": "MyDevTools", "stats": { "tools": "Tools", - "favorites": "Favorites" + "favorites": "Favorites", + "pinned": "Pinned", + "recent": "Recently Used" }, "sections": { "recentlyUsed": "Recently Used", @@ -482,7 +484,12 @@ "taskMixTitle": "Task status", "taskMixEmpty": "No tasks yet — add some in Tasks.", "loadingLabel": "Syncing your stats…", - "codeSnippets": "Fragmenty kodu" + "codeSnippets": "Fragmenty kodu", + "activityTitle": "Activity", + "activityEmpty": "Activity builds as you use tools", + "distributionTitle": "Data distribution", + "topToolsTitle": "Top tools", + "taskDonutTitle": "Task completion" }, "ui": { "title": "Preferencje interfejsu", diff --git a/apps/web/messages/pt-BR.json b/apps/web/messages/pt-BR.json index 5da98076..df10541a 100644 --- a/apps/web/messages/pt-BR.json +++ b/apps/web/messages/pt-BR.json @@ -223,7 +223,9 @@ "brandName": "MyDevTools", "stats": { "tools": "Ferramentas", - "favorites": "Favoritos" + "favorites": "Favoritos", + "pinned": "Fixados", + "recent": "Usados Recentemente" }, "sections": { "recentlyUsed": "Usados Recentemente", @@ -482,7 +484,12 @@ "taskMixTitle": "Task status", "taskMixEmpty": "No tasks yet — add some in Tasks.", "loadingLabel": "Syncing your stats…", - "codeSnippets": "Trechos de código" + "codeSnippets": "Trechos de código", + "activityTitle": "Activity", + "activityEmpty": "Activity builds as you use tools", + "distributionTitle": "Data distribution", + "topToolsTitle": "Top tools", + "taskDonutTitle": "Task completion" }, "ui": { "title": "Preferências de interface", diff --git a/apps/web/messages/pt.json b/apps/web/messages/pt.json index cc6c468d..11d09140 100644 --- a/apps/web/messages/pt.json +++ b/apps/web/messages/pt.json @@ -223,7 +223,9 @@ "brandName": "MyDevTools", "stats": { "tools": "Ferramentas", - "favorites": "Favoritos" + "favorites": "Favoritos", + "pinned": "Fixados", + "recent": "Utilizados Recentemente" }, "sections": { "recentlyUsed": "Utilizados Recentemente", @@ -482,7 +484,12 @@ "taskMixTitle": "Task status", "taskMixEmpty": "No tasks yet — add some in Tasks.", "loadingLabel": "Syncing your stats…", - "codeSnippets": "Excertos de código" + "codeSnippets": "Excertos de código", + "activityTitle": "Activity", + "activityEmpty": "Activity builds as you use tools", + "distributionTitle": "Data distribution", + "topToolsTitle": "Top tools", + "taskDonutTitle": "Task completion" }, "ui": { "title": "Preferências de interface", diff --git a/apps/web/messages/ru.json b/apps/web/messages/ru.json index 825e8984..28439ca6 100644 --- a/apps/web/messages/ru.json +++ b/apps/web/messages/ru.json @@ -223,7 +223,9 @@ "brandName": "MyDevTools", "stats": { "tools": "Инструменты", - "favorites": "Избранное" + "favorites": "Избранное", + "pinned": "Закреплённые", + "recent": "Недавно использованные" }, "sections": { "recentlyUsed": "Недавно использованные", @@ -482,7 +484,12 @@ "taskMixTitle": "Task status", "taskMixEmpty": "No tasks yet — add some in Tasks.", "loadingLabel": "Syncing your stats…", - "codeSnippets": "Фрагменты кода" + "codeSnippets": "Фрагменты кода", + "activityTitle": "Activity", + "activityEmpty": "Activity builds as you use tools", + "distributionTitle": "Data distribution", + "topToolsTitle": "Top tools", + "taskDonutTitle": "Task completion" }, "ui": { "title": "Настройки интерфейса", diff --git a/apps/web/messages/sv.json b/apps/web/messages/sv.json index 80e9c711..b021a7de 100644 --- a/apps/web/messages/sv.json +++ b/apps/web/messages/sv.json @@ -223,7 +223,9 @@ "brandName": "MyDevTools", "stats": { "tools": "Verktyg", - "favorites": "Favoriter" + "favorites": "Favoriter", + "pinned": "Fästa", + "recent": "Nyligen använda" }, "sections": { "recentlyUsed": "Nyligen använda", @@ -482,7 +484,12 @@ "taskMixTitle": "Task status", "taskMixEmpty": "No tasks yet — add some in Tasks.", "loadingLabel": "Syncing your stats…", - "codeSnippets": "Kodutdrag" + "codeSnippets": "Kodutdrag", + "activityTitle": "Activity", + "activityEmpty": "Activity builds as you use tools", + "distributionTitle": "Data distribution", + "topToolsTitle": "Top tools", + "taskDonutTitle": "Task completion" }, "ui": { "title": "Gränssnittsinställningar", diff --git a/apps/web/messages/tr.json b/apps/web/messages/tr.json index 7e65f506..a7f0ea6e 100644 --- a/apps/web/messages/tr.json +++ b/apps/web/messages/tr.json @@ -223,7 +223,9 @@ "brandName": "MyDevTools", "stats": { "tools": "Araçlar", - "favorites": "Favoriler" + "favorites": "Favoriler", + "pinned": "Sabitlenen", + "recent": "Son Kullanılanlar" }, "sections": { "recentlyUsed": "Son Kullanılanlar", @@ -482,7 +484,12 @@ "taskMixTitle": "Task status", "taskMixEmpty": "No tasks yet — add some in Tasks.", "loadingLabel": "Syncing your stats…", - "codeSnippets": "Kod parçacıkları" + "codeSnippets": "Kod parçacıkları", + "activityTitle": "Activity", + "activityEmpty": "Activity builds as you use tools", + "distributionTitle": "Data distribution", + "topToolsTitle": "Top tools", + "taskDonutTitle": "Task completion" }, "ui": { "title": "Arayüz tercihleri", diff --git a/apps/web/messages/uk.json b/apps/web/messages/uk.json index 76822846..9617ba93 100644 --- a/apps/web/messages/uk.json +++ b/apps/web/messages/uk.json @@ -223,7 +223,9 @@ "brandName": "MyDevTools", "stats": { "tools": "Інструменти", - "favorites": "Обране" + "favorites": "Обране", + "pinned": "Закріплені", + "recent": "Нещодавно використані" }, "sections": { "recentlyUsed": "Нещодавно використані", @@ -482,7 +484,12 @@ "taskMixTitle": "Task status", "taskMixEmpty": "No tasks yet — add some in Tasks.", "loadingLabel": "Syncing your stats…", - "codeSnippets": "Фрагменти коду" + "codeSnippets": "Фрагменти коду", + "activityTitle": "Activity", + "activityEmpty": "Activity builds as you use tools", + "distributionTitle": "Data distribution", + "topToolsTitle": "Top tools", + "taskDonutTitle": "Task completion" }, "ui": { "title": "Налаштування інтерфейсу", diff --git a/apps/web/messages/vi.json b/apps/web/messages/vi.json index dfae9784..f70c457b 100644 --- a/apps/web/messages/vi.json +++ b/apps/web/messages/vi.json @@ -223,7 +223,9 @@ "brandName": "MyDevTools", "stats": { "tools": "Công cụ", - "favorites": "Yêu thích" + "favorites": "Yêu thích", + "pinned": "Đã ghim", + "recent": "Dùng gần đây" }, "sections": { "recentlyUsed": "Dùng gần đây", @@ -482,7 +484,12 @@ "taskMixTitle": "Task status", "taskMixEmpty": "No tasks yet — add some in Tasks.", "loadingLabel": "Syncing your stats…", - "codeSnippets": "Đoạn mã" + "codeSnippets": "Đoạn mã", + "activityTitle": "Activity", + "activityEmpty": "Activity builds as you use tools", + "distributionTitle": "Data distribution", + "topToolsTitle": "Top tools", + "taskDonutTitle": "Task completion" }, "ui": { "title": "Tùy chọn giao diện", diff --git a/apps/web/messages/zh.json b/apps/web/messages/zh.json index 6676b0a3..44f5b97b 100644 --- a/apps/web/messages/zh.json +++ b/apps/web/messages/zh.json @@ -223,7 +223,9 @@ "brandName": "MyDevTools", "stats": { "tools": "工具", - "favorites": "收藏" + "favorites": "收藏", + "pinned": "已固定", + "recent": "最近使用" }, "sections": { "recentlyUsed": "最近使用", @@ -482,7 +484,12 @@ "taskMixTitle": "Task status", "taskMixEmpty": "No tasks yet — add some in Tasks.", "loadingLabel": "Syncing your stats…", - "codeSnippets": "代码片段" + "codeSnippets": "代码片段", + "activityTitle": "Activity", + "activityEmpty": "Activity builds as you use tools", + "distributionTitle": "Data distribution", + "topToolsTitle": "Top tools", + "taskDonutTitle": "Task completion" }, "ui": { "title": "界面偏好", diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 03d7c945..86b97de7 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -39,6 +39,38 @@ const nextConfig: NextConfig = { }, ], }, + experimental: { + optimizePackageImports: [ + '@radix-ui/react-accordion', + '@radix-ui/react-alert-dialog', + '@radix-ui/react-avatar', + '@radix-ui/react-checkbox', + '@radix-ui/react-collapsible', + '@radix-ui/react-context-menu', + '@radix-ui/react-dialog', + '@radix-ui/react-dropdown-menu', + '@radix-ui/react-label', + '@radix-ui/react-popover', + '@radix-ui/react-progress', + '@radix-ui/react-radio-group', + '@radix-ui/react-scroll-area', + '@radix-ui/react-select', + '@radix-ui/react-separator', + '@radix-ui/react-slider', + '@radix-ui/react-slot', + '@radix-ui/react-switch', + '@radix-ui/react-tabs', + '@radix-ui/react-toast', + '@radix-ui/react-toggle', + '@radix-ui/react-toggle-group', + '@radix-ui/react-tooltip', + '@tabler/icons-react', + 'lucide-react', + 'date-fns', + 'lodash', + 'framer-motion', + ], + }, }; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/apps/web/src/app/app/database-explorer/page.tsx b/apps/web/src/app/app/database-explorer/page.tsx index da6c3a9d..2a1f6881 100644 --- a/apps/web/src/app/app/database-explorer/page.tsx +++ b/apps/web/src/app/app/database-explorer/page.tsx @@ -66,12 +66,16 @@ export default function NoSQLExplorerPage() { }); const [isCloseAllDialogOpen, setIsCloseAllDialogOpen] = useState(false); - // Check for connections + const connectionCacheRef = useRef>(new Map()); + + // Check for connections + prime cache so auto-fetch doesn't trigger a second + // getConnections round-trip on the cold path. useEffect(() => { const checkConnections = async () => { if (user && encryptionKey) { try { const connections = await getConnections(user.uid, encryptionKey); + connections.forEach((c) => connectionCacheRef.current.set(c.id, c)); setHasConnections(connections.length > 0); } catch (error) { console.error("Failed to check connections", error); @@ -270,8 +274,6 @@ export default function NoSQLExplorerPage() { const activeTab = tabs.find((t) => t.id === activeTabId); - const connectionCacheRef = useRef>(new Map()); - const getConnectionForTab = useCallback(async (tab: ExplorerTab) => { const cached = connectionCacheRef.current.get(tab.connectionId); if (cached) return cached; diff --git a/apps/web/src/app/app/notes/context/NotesContext.tsx b/apps/web/src/app/app/notes/context/NotesContext.tsx index 45728103..e3a62947 100644 --- a/apps/web/src/app/app/notes/context/NotesContext.tsx +++ b/apps/web/src/app/app/notes/context/NotesContext.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { createContext, useContext, useEffect, useState, useCallback, useRef } from "react"; +import React, { createContext, useContext, useEffect, useMemo, useState, useCallback, useRef } from "react"; import { Note } from "../types/Note"; import { useAuthState } from "react-firebase-hooks/auth"; import { auth } from "@/database/firebase"; @@ -13,14 +13,20 @@ const BACKEND_BASE_URL: string = "http://localhost:8000"; const NOTES_PAGE_SIZE = 500; -interface NotesContextType { +interface NotesData { notes: Note[]; isLoading: boolean; isContentLoading: boolean; +} + +interface NotesUI { activeNoteId: string | null; setActiveNoteId: (id: string | null) => void; focusMode: boolean; setFocusMode: (v: boolean) => void; +} + +interface NotesActions { createNote: (parentId?: string | null) => Promise; updateNote: (id: string, updates: Partial) => Promise; deleteNote: (id: string) => Promise; @@ -29,7 +35,11 @@ interface NotesContextType { moveNote: (id: string, newParentId: string | null) => Promise; } -const NotesContext = createContext(undefined); +type NotesContextType = NotesData & NotesUI & NotesActions; + +const NotesDataContext = createContext(undefined); +const NotesUIContext = createContext(undefined); +const NotesActionsContext = createContext(undefined); export function NotesProvider({ children }: { children: React.ReactNode }) { const t = useTranslations("Notes.context"); @@ -39,12 +49,20 @@ export function NotesProvider({ children }: { children: React.ReactNode }) { const [isContentLoading, setIsContentLoading] = useState(false); const [activeNoteId, setActiveNoteId] = useState(null); const [focusMode, setFocusMode] = useState(false); - // Tracks which note IDs have full content loaded in state const contentLoadedIds = useRef>(new Set()); + // Refs to read latest state inside stable action callbacks + const notesRef = useRef(notes); + const activeNoteIdRef = useRef(activeNoteId); + const userRef = useRef(user); + useEffect(() => { notesRef.current = notes; }, [notes]); + useEffect(() => { activeNoteIdRef.current = activeNoteId; }, [activeNoteId]); + useEffect(() => { userRef.current = user; }, [user]); + const apiRequest = useCallback( async (method: string, path: string, body?: unknown): Promise => { - if (!user) throw new Error(t("authRequiredError")); + const currentUser = userRef.current; + if (!currentUser) throw new Error(t("authRequiredError")); const url = new URL(path, BACKEND_BASE_URL).toString(); @@ -81,11 +99,11 @@ export function NotesProvider({ children }: { children: React.ReactNode }) { return responseBody as unknown as T; } }, - [user, t] + [t] ); const refreshNotes = useCallback(async () => { - if (!user) return; + if (!userRef.current) return; const allNotes = await fetchAllPages({ pageSize: NOTES_PAGE_SIZE, fetchPage: (skip, limit) => @@ -96,9 +114,8 @@ export function NotesProvider({ children }: { children: React.ReactNode }) { }); contentLoadedIds.current.clear(); setNotes([...allNotes].sort((a, b) => a.createdAt.localeCompare(b.createdAt))); - }, [apiRequest, user]); + }, [apiRequest]); - // Lazy-load full content when active note changes useEffect(() => { if (!activeNoteId || contentLoadedIds.current.has(activeNoteId)) return; let cancelled = false; @@ -133,7 +150,7 @@ export function NotesProvider({ children }: { children: React.ReactNode }) { }, [refreshNotes, user]); const createNote = useCallback(async (parentId: string | null = null) => { - if (!user) throw new Error(t("authRequiredError")); + if (!userRef.current) throw new Error(t("authRequiredError")); const created = await apiRequest("POST", "/api/v1/notes", { title: t("defaultTitle"), content: {}, @@ -144,10 +161,10 @@ export function NotesProvider({ children }: { children: React.ReactNode }) { setActiveNoteId(created.id); setNotes((prev) => [...prev, created].sort((a, b) => a.createdAt.localeCompare(b.createdAt))); return created.id; - }, [apiRequest, user, t]); + }, [apiRequest, t]); const updateNote = useCallback(async (id: string, updates: Partial) => { - if (!user) return; + if (!userRef.current) return; const payload: Partial> = {}; if (updates.title !== undefined) payload.title = updates.title; if (updates.content !== undefined) payload.content = updates.content; @@ -159,16 +176,15 @@ export function NotesProvider({ children }: { children: React.ReactNode }) { const updated = await apiRequest("PATCH", `/api/v1/notes/${id}`, payload); if (updates.content !== undefined) contentLoadedIds.current.add(id); setNotes((prev) => prev.map((n) => (n.id === updated.id ? updated : n))); - }, [apiRequest, user]); + }, [apiRequest]); const pinNote = useCallback(async (id: string, pinned: boolean) => { await updateNote(id, { pinned }); }, [updateNote]); const duplicateNote = useCallback(async (id: string) => { - const src = notes.find((n) => n.id === id); + const src = notesRef.current.find((n) => n.id === id); if (!src) throw new Error("Note not found"); - // Fetch full content if not yet loaded let content = src.content; if (!contentLoadedIds.current.has(id)) { const full = await apiRequest("GET", `/api/v1/notes/${id}`); @@ -187,49 +203,71 @@ export function NotesProvider({ children }: { children: React.ReactNode }) { setActiveNoteId(created.id); setNotes((prev) => [...prev, created].sort((a, b) => a.createdAt.localeCompare(b.createdAt))); return created.id; - }, [apiRequest, notes, t]); + }, [apiRequest, t]); const moveNote = useCallback(async (id: string, newParentId: string | null) => { await updateNote(id, { parentId: newParentId }); }, [updateNote]); const deleteNote = useCallback(async (id: string) => { - if (!user) return; + if (!userRef.current) return; await apiRequest("DELETE", `/api/v1/notes/${id}?recursive=true`); contentLoadedIds.current.delete(id); - if (activeNoteId === id) { + if (activeNoteIdRef.current === id) { setActiveNoteId(null); } await refreshNotes(); - }, [apiRequest, user, activeNoteId, refreshNotes]); + }, [apiRequest, refreshNotes]); + + const dataValue = useMemo( + () => ({ notes, isLoading, isContentLoading }), + [notes, isLoading, isContentLoading] + ); + + const uiValue = useMemo( + () => ({ activeNoteId, setActiveNoteId, focusMode, setFocusMode }), + [activeNoteId, focusMode] + ); + + const actionsValue = useMemo( + () => ({ createNote, updateNote, deleteNote, pinNote, duplicateNote, moveNote }), + [createNote, updateNote, deleteNote, pinNote, duplicateNote, moveNote] + ); return ( - - {children} - + + + + {children} + + + ); } -export function useNotes() { - const context = useContext(NotesContext); - if (context === undefined) { - throw new Error("useNotes must be used within a NotesProvider"); - } - return context; +export function useNotesData(): NotesData { + const ctx = useContext(NotesDataContext); + if (ctx === undefined) throw new Error("useNotesData must be used within a NotesProvider"); + return ctx; +} + +export function useNotesUI(): NotesUI { + const ctx = useContext(NotesUIContext); + if (ctx === undefined) throw new Error("useNotesUI must be used within a NotesProvider"); + return ctx; +} + +export function useNotesActions(): NotesActions { + const ctx = useContext(NotesActionsContext); + if (ctx === undefined) throw new Error("useNotesActions must be used within a NotesProvider"); + return ctx; +} + +/** + * Combined hook — subscribes to all three contexts. Prefer the specific + * hooks (useNotesData / useNotesUI / useNotesActions) when a component only + * needs a subset, so it doesn't re-render on unrelated changes. + */ +export function useNotes(): NotesContextType { + return { ...useNotesData(), ...useNotesUI(), ...useNotesActions() }; } diff --git a/apps/web/src/app/app/notes/notes-client-layout.tsx b/apps/web/src/app/app/notes/notes-client-layout.tsx index 625eb571..b84af7b4 100644 --- a/apps/web/src/app/app/notes/notes-client-layout.tsx +++ b/apps/web/src/app/app/notes/notes-client-layout.tsx @@ -1,6 +1,6 @@ "use client"; -import { NotesProvider, useNotes } from "./context/NotesContext"; +import { NotesProvider, useNotesUI } from "./context/NotesContext"; import NotesSidebar from "@/components/notes/NotesSidebar"; import { useMediaQuery } from "@/hooks/use-media-query"; import { Sheet, SheetContent, SheetTrigger, SheetHeader, SheetTitle } from "@/components/ui/sheet"; @@ -11,7 +11,7 @@ import { useTranslations } from "next-intl"; function NotesLayout({ children }: { children: React.ReactNode }) { const t = useTranslations("Notes.layout"); const isDesktop = useMediaQuery("(min-width: 768px)"); - const { focusMode } = useNotes(); + const { focusMode } = useNotesUI(); return (
diff --git a/apps/web/src/app/app/to-do/GoogleLoginButton.tsx b/apps/web/src/app/app/to-do/GoogleLoginButton.tsx index 1a0562a4..c1ae59d3 100644 --- a/apps/web/src/app/app/to-do/GoogleLoginButton.tsx +++ b/apps/web/src/app/app/to-do/GoogleLoginButton.tsx @@ -14,7 +14,7 @@ const GoogleLoginButton = () => { try { const result = await signInWithPopup(auth, provider); const idToken = await result.user.getIdToken(); - await establishBackendSession(idToken); + await establishBackendSession(idToken, { checkRevoked: true }); router.push('/dashboard'); } catch (error) { console.error('Error during Google sign-in or API session:', error); diff --git a/apps/web/src/app/app/to-do/TaskItem.tsx b/apps/web/src/app/app/to-do/TaskItem.tsx index b5847387..db51b526 100644 --- a/apps/web/src/app/app/to-do/TaskItem.tsx +++ b/apps/web/src/app/app/to-do/TaskItem.tsx @@ -147,7 +147,7 @@ interface TaskItemProps { onDeleteTask: (id: string) => void; } -export default function TaskItem({ +function TaskItem({ task, onUpdateStatus, onUpdateTask, @@ -614,3 +614,5 @@ export default function TaskItem({ ); } + +export default React.memo(TaskItem); diff --git a/apps/web/src/app/app/to-do/TaskList.tsx b/apps/web/src/app/app/to-do/TaskList.tsx index 617f3324..95ed8e2e 100644 --- a/apps/web/src/app/app/to-do/TaskList.tsx +++ b/apps/web/src/app/app/to-do/TaskList.tsx @@ -1,6 +1,7 @@ // components/TaskList.js "use client"; +import { useCallback, useEffect, useRef } from "react"; import TaskItem from "./TaskItem"; import { FadeIn } from "@/components/ui/fade-in"; import { Inbox, Loader2, CheckCircle2 } from "lucide-react"; @@ -8,6 +9,8 @@ import { Task } from "@/app/app/to-do/types/Task"; import { cn } from "@/lib/utils"; import { useTranslations } from "next-intl"; +const STAGGER_LIMIT = 8; + interface TaskListProps { tasks: Task[]; isLoading: boolean; @@ -19,6 +22,28 @@ interface TaskListProps { export default function TaskList({ tasks, isLoading, onUpdateStatus, onUpdateTask, onDeleteTask }: TaskListProps) { const t = useTranslations("Tasks.list"); + // Stabilize callbacks: parent (TaskContext) recreates these every render, which + // would defeat React.memo on TaskItem. Forward latest fn via ref. + const updateStatusRef = useRef(onUpdateStatus); + const updateTaskRef = useRef(onUpdateTask); + const deleteTaskRef = useRef(onDeleteTask); + useEffect(() => { updateStatusRef.current = onUpdateStatus; }, [onUpdateStatus]); + useEffect(() => { updateTaskRef.current = onUpdateTask; }, [onUpdateTask]); + useEffect(() => { deleteTaskRef.current = onDeleteTask; }, [onDeleteTask]); + + const stableUpdateStatus = useCallback( + (id: string, status: "not-started" | "ongoing" | "completed") => updateStatusRef.current(id, status), + [] + ); + const stableUpdateTask = useCallback( + (id: string, updates: Partial) => updateTaskRef.current(id, updates), + [] + ); + const stableDeleteTask = useCallback( + (id: string) => deleteTaskRef.current(id), + [] + ); + return (
    @@ -61,16 +86,16 @@ export default function TaskList({ tasks, isLoading, onUpdateStatus, onUpdateTas "animate-in fade-in slide-in-from-top-2", "transition-all duration-300" )} - style={{ - animationDelay: `${index * 50}ms`, + style={{ + animationDelay: index < STAGGER_LIMIT ? `${index * 50}ms` : '0ms', animationFillMode: 'both' }} >
))} diff --git a/apps/web/src/app/profile/page.tsx b/apps/web/src/app/profile/page.tsx index 92d571c3..f732366e 100644 --- a/apps/web/src/app/profile/page.tsx +++ b/apps/web/src/app/profile/page.tsx @@ -610,7 +610,7 @@ export default function ProfilePage() { const src = meta ? (meta.iconUrl ?? `https://cdn.simpleicons.org/${meta.slug}/${meta.color}`) : null return meta ? ( - {src && {tech}} + {src && {tech}} {tech} ) : ( diff --git a/apps/web/src/components/api-client/response-panel.tsx b/apps/web/src/components/api-client/response-panel.tsx index 7db86430..a07ccbdd 100644 --- a/apps/web/src/components/api-client/response-panel.tsx +++ b/apps/web/src/components/api-client/response-panel.tsx @@ -149,7 +149,7 @@ export function ResponsePanel({ response }: ResponsePanelProps) { if (contentType.includes("image/")) { return (
- {t("responsePreviewAlt")} + {t("responsePreviewAlt")}
) } diff --git a/apps/web/src/components/dashboard/charts/__tests__/chart-utils.test.ts b/apps/web/src/components/dashboard/charts/__tests__/chart-utils.test.ts new file mode 100644 index 00000000..99e1f8d6 --- /dev/null +++ b/apps/web/src/components/dashboard/charts/__tests__/chart-utils.test.ts @@ -0,0 +1,66 @@ +import { + bucketEventsByDay, + donutArcs, + type DonutSegment, +} from '@/components/dashboard/charts/chart-utils' +import type { ToolUsage } from '@/lib/tool-usage-utils' + +const DAY = 24 * 60 * 60 * 1000 +const ev = (toolId: string, timestamp: number): ToolUsage => ({ + toolId, + timestamp, + url: `/app/${toolId}`, +}) + +describe('bucketEventsByDay', () => { + // Fixed "now" at midday to avoid TZ edge flicker. + const now = new Date(2026, 5, 22, 12, 0, 0).getTime() // 2026-06-22 local + + it('returns exactly `days` buckets oldest-to-newest', () => { + const out = bucketEventsByDay([], 7, now) + expect(out).toHaveLength(7) + expect(out[6].date).toBe('2026-06-22') + expect(out[0].date).toBe('2026-06-16') + }) + + it('counts events into their local day and fills gaps with 0', () => { + const events = [ + ev('a', now), // today + ev('b', now), // today + ev('c', now - 2 * DAY), // two days ago + ] + const out = bucketEventsByDay(events, 7, now) + expect(out[6].count).toBe(2) + expect(out[4].count).toBe(1) + expect(out[5].count).toBe(0) + }) + + it('ignores events outside the window', () => { + const out = bucketEventsByDay([ev('old', now - 30 * DAY)], 7, now) + expect(out.reduce((s, b) => s + b.count, 0)).toBe(0) + }) +}) + +describe('donutArcs', () => { + const C = 2 * Math.PI + const segs: DonutSegment[] = [ + { label: 'A', value: 1, color: 'a' }, + { label: 'B', value: 3, color: 'b' }, + ] + + it('normalizes values to the circumference and sets offsets sequentially', () => { + const arcs = donutArcs(segs) + expect(arcs[0].percent).toBeCloseTo(25) + expect(arcs[1].percent).toBeCloseTo(75) + const [lenA] = arcs[0].dashArray.split(' ').map(Number) + expect(lenA).toBeCloseTo(C * 0.25) + // second segment starts where the first ended + expect(arcs[1].dashOffset).toBeCloseTo(-C * 0.25) + }) + + it('handles an all-zero total without NaN', () => { + const arcs = donutArcs([{ label: 'Z', value: 0, color: 'z' }]) + expect(arcs[0].percent).toBe(0) + expect(arcs[0].dashArray.split(' ').map(Number)[0]).toBe(0) + }) +}) diff --git a/apps/web/src/components/dashboard/charts/activity-bar-chart.tsx b/apps/web/src/components/dashboard/charts/activity-bar-chart.tsx new file mode 100644 index 00000000..ccdb8be4 --- /dev/null +++ b/apps/web/src/components/dashboard/charts/activity-bar-chart.tsx @@ -0,0 +1,86 @@ +'use client' + +import { useMemo, useState } from 'react' +import { bucketEventsByDay } from './chart-utils' +import type { ToolUsage } from '@/lib/tool-usage-utils' +import { cn } from '@/lib/utils' + +interface ActivityBarChartProps { + events: ToolUsage[] + emptyHint: string + title: string +} + +export function ActivityBarChart({ events, emptyHint, title }: ActivityBarChartProps) { + const [range, setRange] = useState<7 | 30>(7) + const buckets = useMemo(() => bucketEventsByDay(events, range), [events, range]) + + const max = Math.max(1, ...buckets.map((b) => b.count)) + const total = buckets.reduce((s, b) => s + b.count, 0) + const activeDays = buckets.filter((b) => b.count > 0).length + const thin = activeDays < 2 + + const peak = buckets.reduce((a, b) => (b.count > a.count ? b : a), buckets[0]) + const ariaLabel = `Tool launches per day over the last ${range} days. Total ${total}, peak ${peak.count} on ${peak.label}.` + + return ( +
+
+

+ {title} +

+
+ {([7, 30] as const).map((r) => ( + + ))} +
+
+ +
+
+ {buckets.map((b) => ( +
+
0 ? 2 : 0 }} + title={`${b.label}: ${b.count}`} + /> +
+ ))} +
+ {range === 7 && ( +
+ {buckets.map((b) => ( + + {b.label} + + ))} +
+ )} + {thin && ( +
+ + {emptyHint} + +
+ )} +
+
+ ) +} diff --git a/apps/web/src/components/dashboard/charts/chart-utils.ts b/apps/web/src/components/dashboard/charts/chart-utils.ts new file mode 100644 index 00000000..4a0c5dac --- /dev/null +++ b/apps/web/src/components/dashboard/charts/chart-utils.ts @@ -0,0 +1,76 @@ +import type { ToolUsage } from '@/lib/tool-usage-utils' + +export interface DayBucket { + date: string // YYYY-MM-DD (local) + label: string // short, e.g. "Mon" or "6/22" + count: number +} + +function localDateKey(d: Date): string { + const y = d.getFullYear() + const m = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + return `${y}-${m}-${day}` +} + +const WEEKDAY = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] + +/** `days` buckets, oldest→newest, gaps filled with 0. */ +export function bucketEventsByDay( + events: ToolUsage[], + days: number, + now: number = Date.now(), +): DayBucket[] { + const today = new Date(now) + today.setHours(0, 0, 0, 0) + + const buckets: DayBucket[] = [] + const index = new Map() + for (let i = days - 1; i >= 0; i--) { + const d = new Date(today) + d.setDate(today.getDate() - i) + const bucket: DayBucket = { + date: localDateKey(d), + label: days <= 7 ? WEEKDAY[d.getDay()] : `${d.getMonth() + 1}/${d.getDate()}`, + count: 0, + } + buckets.push(bucket) + index.set(bucket.date, bucket) + } + + for (const e of events) { + const key = localDateKey(new Date(e.timestamp)) + const bucket = index.get(key) + if (bucket) bucket.count += 1 + } + + return buckets +} + +export interface DonutSegment { + label: string + value: number + color: string +} + +const CIRCUMFERENCE = 2 * Math.PI + +/** Circumference-normalized stroke-dasharray arcs for a unit-radius donut. */ +export function donutArcs( + segments: DonutSegment[], +): { segment: DonutSegment; dashArray: string; dashOffset: number; percent: number }[] { + const total = segments.reduce((s, seg) => s + Math.max(0, seg.value), 0) + let consumed = 0 + return segments.map((segment) => { + const fraction = total > 0 ? Math.max(0, segment.value) / total : 0 + const len = fraction * CIRCUMFERENCE + const arc = { + segment, + dashArray: `${len} ${CIRCUMFERENCE - len}`, + dashOffset: -consumed, + percent: fraction * 100, + } + consumed += len + return arc + }) +} diff --git a/apps/web/src/components/dashboard/charts/donut-chart.tsx b/apps/web/src/components/dashboard/charts/donut-chart.tsx new file mode 100644 index 00000000..241b6edd --- /dev/null +++ b/apps/web/src/components/dashboard/charts/donut-chart.tsx @@ -0,0 +1,84 @@ +'use client' + +import { donutArcs, type DonutSegment } from './chart-utils' + +interface DonutChartProps { + segments: DonutSegment[] + centerValue?: string | number + centerLabel?: string + ariaLabel: string +} + +// Geometry: viewBox is centered at 0,0. r chosen so circumference = 2π (unit), +// matching donutArcs() normalization, scaled by SVG units below. +const R = 1 +const STROKE = 0.42 + +export function DonutChart({ segments, centerValue, centerLabel, ariaLabel }: DonutChartProps) { + const arcs = donutArcs(segments) + const hasData = segments.some((s) => s.value > 0) + + return ( +
+ + {/* track */} + + {hasData && + arcs.map((arc) => ( + + ))} + + +
+ {(centerValue !== undefined || centerLabel) && ( +
+ {centerValue !== undefined && ( +

{centerValue}

+ )} + {centerLabel && ( +

+ {centerLabel} +

+ )} +
+ )} +
    + {segments.map((s) => ( +
  • + + {s.label} + {s.value} +
  • + ))} +
+
+
+ ) +} diff --git a/apps/web/src/components/dashboard/charts/top-tools-bars.tsx b/apps/web/src/components/dashboard/charts/top-tools-bars.tsx new file mode 100644 index 00000000..72b488a7 --- /dev/null +++ b/apps/web/src/components/dashboard/charts/top-tools-bars.tsx @@ -0,0 +1,61 @@ +'use client' + +import Link from 'next/link' +import { Zap } from 'lucide-react' + +export interface TopTool { + id: string + title: string + icon?: React.ElementType + count: number + url?: string +} + +interface TopToolsBarsProps { + tools: TopTool[] + title: string +} + +export function TopToolsBars({ tools, title }: TopToolsBarsProps) { + if (tools.length === 0) return null + const max = Math.max(1, ...tools.map((t) => t.count)) + + return ( +
+

+ {title} +

+
    + {tools.map((t) => { + const Icon = t.icon ?? Zap + return ( +
  • + + + + {t.title} + + + + + + {t.count} + + +
  • + ) + })} +
+
+ ) +} diff --git a/apps/web/src/components/dashboard/dashboard-analytics-panel.tsx b/apps/web/src/components/dashboard/dashboard-analytics-panel.tsx index 3edf3b15..7fb46a7d 100644 --- a/apps/web/src/components/dashboard/dashboard-analytics-panel.tsx +++ b/apps/web/src/components/dashboard/dashboard-analytics-panel.tsx @@ -21,7 +21,6 @@ import { RefreshCw, Server, StickyNote, - TrendingUp, Zap, } from 'lucide-react' import { Button } from '@/components/ui/button' @@ -31,6 +30,11 @@ import { } from '@/lib/dashboard-analytics-api' import { sidebarData } from '@/components/sidebar/data/sidebar-data' import { cn } from '@/lib/utils' +import { useToolUsage } from '@/hooks/use-tool-usage' +import { DonutChart } from './charts/donut-chart' +import { ActivityBarChart } from './charts/activity-bar-chart' +import { TopToolsBars, type TopTool } from './charts/top-tools-bars' +import { type DonutSegment } from './charts/chart-utils' // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -342,54 +346,6 @@ function KpiCard({ ) } -// ─── Top used tools ─────────────────────────────────────────────────────────── - -interface TopTool { - id: string - title: string - icon?: React.ElementType - count: number - url?: string -} - -function TopUsedTools({ tools }: { tools: TopTool[] }) { - if (tools.length === 0) return null - return ( -
-
- - Most used locally - -
- -
-
- {tools.map((tool) => { - const Icon = tool.icon - return ( - -
- {Icon - ? - : - } -
-
-

{tool.title}

-

{tool.count}×

-
- - ) - })} -
-
- ) -} - // ─── Empty group CTA ────────────────────────────────────────────────────────── function EmptyGroupHint({ message, href, cta }: { message: string; href: string; cta: string }) { @@ -440,35 +396,25 @@ export function DashboardAnalyticsPanel() { }, []) void tick - // Top used tools from localStorage + const { getUsageEvents, getToolUsageCounts } = useToolUsage() + const usageEvents = useMemo(() => getUsageEvents(), [getUsageEvents]) + const toolUsageCounts = useMemo(() => getToolUsageCounts(), [getToolUsageCounts]) + const topUsedTools = useMemo(() => { - try { - const raw = localStorage.getItem('tool-usage-history') - if (!raw) return [] - const history: { toolId: string; url?: string }[] = JSON.parse(raw) - const counts: Record = {} - const urls: Record = {} - history.forEach((h) => { - counts[h.toolId] = (counts[h.toolId] ?? 0) + 1 - if (h.url) urls[h.toolId] = h.url + return Object.entries(toolUsageCounts) + .sort(([, a], [, b]) => b.count - a.count) + .slice(0, 5) + .map(([id, info]) => { + const found = findToolById(id) + return { + id, + title: found?.title ?? id, + icon: found?.icon, + count: info.count, + url: info.url, + } }) - return Object.entries(counts) - .sort(([, a], [, b]) => b - a) - .slice(0, 5) - .map(([id, count]) => { - const found = findToolById(id) - return { - id, - title: found?.title ?? id, - icon: found?.icon, - count, - url: urls[id], - } - }) - } catch { - return [] - } - }, []) + }, [toolUsageCounts]) const totalCount = useMemo(() => (data ? sumTrackedItems(data) : 0), [data]) const completionPct = useMemo(() => { @@ -564,10 +510,14 @@ export function DashboardAnalyticsPanel() { /> 0 ? `Top: ${topUsedTools[0]?.title ?? '—'}` : 'No history yet'} + sub={ + usageEvents.length > 0 + ? `${usageEvents.length} launches` + : 'No history yet' + } />
@@ -612,6 +562,53 @@ export function DashboardAnalyticsPanel() {
+ {/* ── Charts ─────────────────────────────────────────────────────────── */} + + +
+
+

+ {t('distributionTitle')} +

+ +
+ +
+

+ {t('taskDonutTitle')} +

+ +
+
+ + + {/* ── Group 1: Vault & Bookmarks ────────────────────────────────────── */}
@@ -676,8 +673,6 @@ export function DashboardAnalyticsPanel() { )}
- {/* ── Top used tools ───────────────────────────────────────────────── */} - ) } diff --git a/apps/web/src/components/dashboard/dashboard-hero.tsx b/apps/web/src/components/dashboard/dashboard-hero.tsx index 801129f8..a2e33a57 100644 --- a/apps/web/src/components/dashboard/dashboard-hero.tsx +++ b/apps/web/src/components/dashboard/dashboard-hero.tsx @@ -26,6 +26,29 @@ interface DashboardHeroProps { * must be outside the padded container for full-bleed, while the * desktop hero sits inside it for proper alignment). */ +/** Compact KPI stat tile for the dashboard hero. */ +function KpiCard({ + icon: Icon, + label, + value, +}: { + icon: React.ComponentType<{ size?: number; className?: string }> + label: string + value: number +}) { + return ( +
+
+ +
+
+

{label}

+

{value}

+
+
+ ) +} + export function DashboardHero({ user, totalTools, @@ -70,35 +93,25 @@ export function DashboardHero({ {/* ── Desktop Header Section ──────────────────────────────────────── */} {!mobileOnly && ( -
+

{dashboardGreeting(t)}

-
-

- {user?.displayName - ? t('welcomeBackNamed', { name: user.displayName.split(' ')[0] }) - : t('welcomeBack')} -

-
-

- {t('tagline')} -

+

+ {user?.displayName + ? t('welcomeBackNamed', { name: user.displayName.split(' ')[0] }) + : t('welcomeBack')} +

+

{t('tagline')}

- {/* Quick Stats */} -
-
-
- -
-
-

{t('stats.tools')}

-

{totalTools}

-
-
+ {/* Quick Stats — KPI row */} +
+ + +
diff --git a/apps/web/src/components/dashboard/dashboard-tool-card.tsx b/apps/web/src/components/dashboard/dashboard-tool-card.tsx index 2b518bd9..f6fb00dd 100644 --- a/apps/web/src/components/dashboard/dashboard-tool-card.tsx +++ b/apps/web/src/components/dashboard/dashboard-tool-card.tsx @@ -2,7 +2,7 @@ import React from 'react' import Link from 'next/link' -import { ArrowRight, Sparkles, Pin } from 'lucide-react' +import { Sparkles, Pin } from 'lucide-react' import { useTranslations } from 'next-intl' import { Card, CardContent } from '@/components/ui/card' import { requiresAuth } from '@/lib/tool-config' @@ -18,11 +18,9 @@ export const HScrollFade = ({ children }: { children: React.ReactNode }) => (
) -/** A single tool card with pin, icon, description, and animated hover. */ +/** A compact single-line tool card: icon + title, with hover pin action. */ export const ToolCard = React.memo(function ToolCard({ item, - id, - index, user, isPinned, togglePin, @@ -35,9 +33,6 @@ export const ToolCard = React.memo(function ToolCard({ const displayTitle = toolKey ? tTools(`${toolKey}.title` as Parameters[0]) : item.title - const displayDescription = toolKey - ? tTools(`${toolKey}.description` as Parameters[0]) - : item.description || tCard('toolCard.defaultDescription') const itemRequiresAuth = item.url ? requiresAuth(item.url.toString()) : false @@ -49,84 +44,56 @@ export const ToolCard = React.memo(function ToolCard({ } return ( -
+
- -
- - -
-
- {item.icon ? ( - - ) : ( - - )} -
- {item.url && ( -
{ - e.preventDefault() - e.stopPropagation() - togglePin(item.url!.toString()) - }} - > - -
+ + +
+ {item.icon ? ( + + ) : ( + )}
-
-
-

- {displayTitle} -

- {item.badge && ( - - {item.badge} - - )} -
-

- {displayDescription} -

- {timestamp && ( -

- {formatRelativeTime(timestamp)} -

- )} -
+

+ {displayTitle} +

-
- {tCard('toolCard.launchTool')}{' '} - -
+ {item.badge && ( + + {item.badge} + + )} + + {timestamp && !item.badge && ( + + {formatRelativeTime(timestamp)} + + )} + + {item.url && ( + + )}
diff --git a/apps/web/src/components/global-command-palette.tsx b/apps/web/src/components/global-command-palette.tsx index c4474c39..930ab426 100644 --- a/apps/web/src/components/global-command-palette.tsx +++ b/apps/web/src/components/global-command-palette.tsx @@ -84,19 +84,21 @@ const STATIC_ENTRIES: Omit[] = [ }, ] -function getSidebarIconForUrl(url: string): React.ElementType | null { +function buildSidebarIconMap(): Map { + const map = new Map() for (const group of sidebarData.navGroups) { for (const item of group.items) { - if ('url' in item && item.url != null) { - if (String(item.url) === url && item.icon) return item.icon + if ('url' in item && item.url != null && item.icon) { + map.set(String(item.url), item.icon) } if ('items' in item && item.items) { - const sub = item.items.find((s) => String(s.url) === url) - if (sub?.icon) return sub.icon + for (const sub of item.items) { + if (sub.url != null && sub.icon) map.set(String(sub.url), sub.icon) + } } } } - return null + return map } function buildSearchValue(entry: { @@ -117,6 +119,42 @@ function buildSearchValue(entry: { .toLowerCase() } +// Tool entries and sidebar icons are static across the app lifetime — compute once. +let cachedToolEntries: PaletteEntry[] | null = null +function getToolEntries(): PaletteEntry[] { + if (cachedToolEntries) return cachedToolEntries + const iconMap = buildSidebarIconMap() + cachedToolEntries = getAllToolsMetadata() + .filter((t) => t.url.startsWith('/app/')) + .map((tool) => { + const Icon = iconMap.get(tool.url) ?? LayoutDashboard + const topCategory = tool.category.includes('>') + ? tool.category.split('>')[0]!.trim() + : tool.category + return { + title: tool.title, + url: tool.url, + description: tool.description, + category: topCategory, + searchValue: buildSearchValue({ + title: tool.title, + description: tool.description, + category: tool.category, + tags: tool.tags, + keywords: tool.keywords, + }), + Icon, + requiresAuth: tool.requiresAuth, + } + }) + return cachedToolEntries +} + +const STATIC_ENTRIES_WITH_SEARCH = STATIC_ENTRIES.map((s) => ({ + ...s, + searchValue: buildSearchValue(s), +})) as PaletteEntry[] + export function GlobalCommandPalette() { const [open, setOpen] = React.useState(false) const [modLabel, setModLabel] = React.useState('⌘') @@ -125,40 +163,13 @@ export function GlobalCommandPalette() { const { user } = useAuth(false) const pinnedTools = usePinnedToolsStore((s) => s.pinnedTools) + const isLoggedIn = !!user const entries = React.useMemo((): PaletteEntry[] => { - const tools = getAllToolsMetadata() - .filter((t) => t.url.startsWith('/app/')) - .map((tool) => { - const Icon = getSidebarIconForUrl(tool.url) ?? LayoutDashboard - const topCategory = tool.category.includes('>') - ? tool.category.split('>')[0]!.trim() - : tool.category - return { - title: tool.title, - url: tool.url, - description: tool.description, - category: topCategory, - searchValue: buildSearchValue({ - title: tool.title, - description: tool.description, - category: tool.category, - tags: tool.tags, - keywords: tool.keywords, - }), - Icon, - requiresAuth: tool.requiresAuth, - } - }) - - const site = STATIC_ENTRIES.filter( - (s) => s.url !== '/login' || !user - ).map((s) => ({ - ...s, - searchValue: buildSearchValue(s), - })) - - return [...site, ...tools] - }, [user]) + const site = STATIC_ENTRIES_WITH_SEARCH.filter( + (s) => s.url !== '/login' || !isLoggedIn + ) + return [...site, ...getToolEntries()] + }, [isLoggedIn]) const pinnedEntries = React.useMemo(() => { const urlSet = new Set(pinnedTools) diff --git a/apps/web/src/components/image-compressor/image-compressor-layout.tsx b/apps/web/src/components/image-compressor/image-compressor-layout.tsx index 10a7b5ac..c20bb3fb 100644 --- a/apps/web/src/components/image-compressor/image-compressor-layout.tsx +++ b/apps/web/src/components/image-compressor/image-compressor-layout.tsx @@ -336,7 +336,7 @@ export function ImageCompressorLayout() { > {sourceUrl ? ( // eslint-disable-next-line @next/next/no-img-element - + ) : (
@@ -390,7 +390,7 @@ export function ImageCompressorLayout() { )} {compressedUrl && !processing ? ( // eslint-disable-next-line @next/next/no-img-element - + ) : !sourceUrl ? (

{t('emptyCompressed')}

) : null} diff --git a/apps/web/src/components/login-form.tsx b/apps/web/src/components/login-form.tsx index 0b140da9..cee1066a 100644 --- a/apps/web/src/components/login-form.tsx +++ b/apps/web/src/components/login-form.tsx @@ -29,7 +29,7 @@ export function LoginForm() { const result = await signInWithPopup(auth, provider); const idToken = await result.user.getIdToken(); try { - await establishBackendSession(idToken); + await establishBackendSession(idToken, { checkRevoked: true }); } catch (sessionErr) { console.error("Backend session failed:", sessionErr); setError("Signed in, but could not start an API session. Please try again."); @@ -83,7 +83,7 @@ export function LoginForm() { await linkWithCredential(result.user, pendingCredential); const idToken = await result.user.getIdToken(); try { - await establishBackendSession(idToken); + await establishBackendSession(idToken, { checkRevoked: true }); } catch (sessionErr) { console.error("Backend session failed:", sessionErr); setError("Signed in, but could not start an API session. Please try again."); diff --git a/apps/web/src/components/notes/NotesSidebar.tsx b/apps/web/src/components/notes/NotesSidebar.tsx index bf91ff44..26d8c0f5 100644 --- a/apps/web/src/components/notes/NotesSidebar.tsx +++ b/apps/web/src/components/notes/NotesSidebar.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; import { useInfiniteScroll } from "@/hooks/use-infinite-scroll"; -import { useNotes } from "@/app/app/notes/context/NotesContext"; +import { useNotesData, useNotesUI, useNotesActions } from "@/app/app/notes/context/NotesContext"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -142,12 +142,13 @@ interface NoteItemProps { isSearching: boolean; } -const NoteItem = ({ +const NoteItem = React.memo(({ note, level, childrenMap, onDeleteClick, onMoveClick, parentTitle, snippet, expandedIds, onToggleExpand, onExpandPath, isSearching, }: NoteItemProps) => { const t = useTranslations("Notes.sidebar"); - const { notes, activeNoteId, setActiveNoteId, createNote, pinNote, duplicateNote, updateNote } = useNotes(); + const { activeNoteId, setActiveNoteId } = useNotesUI(); + const { createNote, pinNote, duplicateNote, updateNote } = useNotesActions(); const [emojiPickerOpen, setEmojiPickerOpen] = useState(false); const children = childrenMap.get(note.id) ?? []; @@ -318,11 +319,14 @@ const NoteItem = ({ )}
); -}; +}); +NoteItem.displayName = "NoteItem"; export default function NotesSidebar() { const t = useTranslations("Notes.sidebar"); - const { notes, createNote, deleteNote, moveNote, isLoading, activeNoteId } = useNotes(); + const { notes, isLoading } = useNotesData(); + const { activeNoteId } = useNotesUI(); + const { createNote, deleteNote, moveNote } = useNotesActions(); const [noteToDelete, setNoteToDelete] = useState(null); const [noteToMove, setNoteToMove] = useState(null); const [searchQuery, setSearchQuery] = useState(""); diff --git a/apps/web/src/components/notes/NotionEditor.tsx b/apps/web/src/components/notes/NotionEditor.tsx index d8ba46b9..74fb1544 100644 --- a/apps/web/src/components/notes/NotionEditor.tsx +++ b/apps/web/src/components/notes/NotionEditor.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState, useMemo, useCallback, useRef } from "react"; -import { useNotes } from "@/app/app/notes/context/NotesContext"; +import { useNotesData, useNotesUI, useNotesActions } from "@/app/app/notes/context/NotesContext"; import { Editor, EditorProvider, createEmptyContent } from "@/components/ui/rich-editor"; import { useDebouncedCallback } from "use-debounce"; import { Input } from "@/components/ui/input"; @@ -79,7 +79,9 @@ function TemplatePickerDialog({ export default function NotionEditor() { const tEditor = useTranslations("Notes.editor"); const tCtx = useTranslations("Notes.context"); - const { notes, activeNoteId, updateNote, focusMode, setFocusMode, isContentLoading } = useNotes(); + const { notes, isContentLoading } = useNotesData(); + const { activeNoteId, focusMode, setFocusMode } = useNotesUI(); + const { updateNote } = useNotesActions(); const activeNote = notes.find(n => n.id === activeNoteId); const { user } = useAuth(); diff --git a/apps/web/src/components/portfolio-builder/experience-builder.tsx b/apps/web/src/components/portfolio-builder/experience-builder.tsx index 167cd5ee..24b07e0e 100644 --- a/apps/web/src/components/portfolio-builder/experience-builder.tsx +++ b/apps/web/src/components/portfolio-builder/experience-builder.tsx @@ -232,7 +232,7 @@ function SortableExperienceItem({ const src = meta ? (meta.iconUrl ?? `https://cdn.simpleicons.org/${meta.slug}/${meta.color}`) : null return ( - {src && {tech}} + {src && {tech}} {tech} ) diff --git a/apps/web/src/components/portfolio-builder/projects-builder.tsx b/apps/web/src/components/portfolio-builder/projects-builder.tsx index 8cd6988e..503a84b0 100644 --- a/apps/web/src/components/portfolio-builder/projects-builder.tsx +++ b/apps/web/src/components/portfolio-builder/projects-builder.tsx @@ -157,7 +157,7 @@ function SortableProjectItem({
{project.imageUrl ? (
- {project.title} + {project.title}
) : (
@@ -188,7 +188,7 @@ function SortableProjectItem({ const src = meta ? (meta.iconUrl ?? `https://cdn.simpleicons.org/${meta.slug}/${meta.color}`) : null return ( - {src && {tech}} + {src && {tech}} {tech} ) diff --git a/apps/web/src/components/qr-code-generator/qr-code-generator-layout.tsx b/apps/web/src/components/qr-code-generator/qr-code-generator-layout.tsx index 288f88de..d521f412 100644 --- a/apps/web/src/components/qr-code-generator/qr-code-generator-layout.tsx +++ b/apps/web/src/components/qr-code-generator/qr-code-generator-layout.tsx @@ -337,7 +337,7 @@ export function QrCodeGeneratorLayout() { {logoDataUrl ? (
{/* eslint-disable-next-line @next/next/no-img-element */} - Logo preview + Logo preview
{/* eslint-disable-next-line @next/next/no-img-element */} - {name} + {name}
) } diff --git a/apps/web/src/components/svg-optimizer/svg-optimizer-layout.tsx b/apps/web/src/components/svg-optimizer/svg-optimizer-layout.tsx index 8e8a1d61..ec8b4a00 100644 --- a/apps/web/src/components/svg-optimizer/svg-optimizer-layout.tsx +++ b/apps/web/src/components/svg-optimizer/svg-optimizer-layout.tsx @@ -185,7 +185,7 @@ export function SvgOptimizerLayout() { > {/* Dynamic data: URL cannot use next/image */} {/* eslint-disable-next-line @next/next/no-img-element */} - +
) : null} diff --git a/apps/web/src/components/url-shortener/qr-dialog.tsx b/apps/web/src/components/url-shortener/qr-dialog.tsx index 4a213c64..a7ec0b7d 100644 --- a/apps/web/src/components/url-shortener/qr-dialog.tsx +++ b/apps/web/src/components/url-shortener/qr-dialog.tsx @@ -69,7 +69,7 @@ export function QrDialog({ url, open, onClose }: QrDialogProps) {
{dataUrl ? ( - QR code + QR code ) : (
diff --git a/apps/web/src/hooks/use-tool-usage.ts b/apps/web/src/hooks/use-tool-usage.ts index 23e554b0..db74ecc5 100644 --- a/apps/web/src/hooks/use-tool-usage.ts +++ b/apps/web/src/hooks/use-tool-usage.ts @@ -3,39 +3,36 @@ import { useCallback } from 'react'; import useAuth from '@/utils/useAuth'; import { trackToolUsageApi } from '@/lib/user-preferences-api'; +import { + appendEvent, + deriveRecents, + deriveCounts, + type ToolUsage, +} from '@/lib/tool-usage-utils'; const USAGE_STORAGE_KEY = 'tool-usage-history'; -const MAX_LOCAL_HISTORY = 20; -interface ToolUsage { - toolId: string; - timestamp: number; - url: string; +function readLog(): ToolUsage[] { + try { + const raw = localStorage.getItem(USAGE_STORAGE_KEY); + return raw ? (JSON.parse(raw) as ToolUsage[]) : []; + } catch (error) { + console.error('Error reading tool usage history:', error); + return []; + } } /** - * Hook to track tool usage for analytics and recently used features + * Hook to track tool usage for analytics and recently-used features. + * Stores an append-only event log (pruned to 90 days / 500 events). */ export function useToolUsage() { const { user } = useAuth(false); const trackToolUsage = useCallback((toolId: string, url: string) => { - const usage: ToolUsage = { - toolId, - timestamp: Date.now(), - url, - }; - try { - const existingHistory = localStorage.getItem(USAGE_STORAGE_KEY); - let history: ToolUsage[] = existingHistory ? JSON.parse(existingHistory) : []; - - history = history.filter(h => h.toolId !== toolId); - history.unshift(usage); - - history = history.slice(0, MAX_LOCAL_HISTORY); - - localStorage.setItem(USAGE_STORAGE_KEY, JSON.stringify(history)); + const next = appendEvent(readLog(), { toolId, url, timestamp: Date.now() }); + localStorage.setItem(USAGE_STORAGE_KEY, JSON.stringify(next)); } catch (error) { console.error('Error tracking tool usage:', error); } @@ -47,21 +44,22 @@ export function useToolUsage() { } }, [user?.uid]); - const getRecentlyUsedTools = useCallback((limit: number = 10): ToolUsage[] => { - try { - const history = localStorage.getItem(USAGE_STORAGE_KEY); - if (!history) return []; + const getRecentlyUsedTools = useCallback( + (limit: number = 10): ToolUsage[] => deriveRecents(readLog(), limit), + [], + ); - const usageHistory: ToolUsage[] = JSON.parse(history); - return usageHistory.slice(0, limit); - } catch (error) { - console.error('Error reading tool usage history:', error); - return []; - } - }, []); + const getUsageEvents = useCallback( + (): ToolUsage[] => [...readLog()].sort((a, b) => b.timestamp - a.timestamp), + [], + ); + + const getToolUsageCounts = useCallback(() => deriveCounts(readLog()), []); return { trackToolUsage, getRecentlyUsedTools, + getUsageEvents, + getToolUsageCounts, }; } diff --git a/apps/web/src/lib/__tests__/auth-inflight.test.ts b/apps/web/src/lib/__tests__/auth-inflight.test.ts new file mode 100644 index 00000000..e62943f9 --- /dev/null +++ b/apps/web/src/lib/__tests__/auth-inflight.test.ts @@ -0,0 +1,64 @@ +import { dedupe, clearInflight } from "@/lib/auth-inflight" + +describe("dedupe", () => { + beforeEach(() => clearInflight()) + + it("returns the same promise for concurrent callers with the same key", async () => { + let calls = 0 + const fn = () => + new Promise((resolve) => + setTimeout(() => { + calls += 1 + resolve("ok") + }, 20) + ) + + const [a, b, c] = await Promise.all([ + dedupe("k", fn), + dedupe("k", fn), + dedupe("k", fn), + ]) + + expect(a).toBe("ok") + expect(b).toBe("ok") + expect(c).toBe("ok") + expect(calls).toBe(1) + }) + + it("runs distinct keys independently", async () => { + let calls = 0 + const fn = () => + new Promise((resolve) => { + calls += 1 + resolve(calls) + }) + + const [a, b] = await Promise.all([dedupe("x", fn), dedupe("y", fn)]) + expect(a).not.toBe(b) + expect(calls).toBe(2) + }) + + it("clears the entry after settling (next call re-runs fn)", async () => { + let calls = 0 + const fn = async () => { + calls += 1 + return calls + } + + await dedupe("k", fn) + await dedupe("k", fn) + expect(calls).toBe(2) + }) + + it("clears the entry even when the inner fn rejects", async () => { + let calls = 0 + const fn = async () => { + calls += 1 + throw new Error("boom") + } + + await expect(dedupe("k", fn)).rejects.toThrow("boom") + await expect(dedupe("k", fn)).rejects.toThrow("boom") + expect(calls).toBe(2) + }) +}) diff --git a/apps/web/src/lib/__tests__/backend-auth.test.ts b/apps/web/src/lib/__tests__/backend-auth.test.ts new file mode 100644 index 00000000..16a58340 --- /dev/null +++ b/apps/web/src/lib/__tests__/backend-auth.test.ts @@ -0,0 +1,130 @@ +import { clearInflight } from "@/lib/auth-inflight" + +// Mock firebase auth module that backend-auth.ts imports. +jest.mock("@/database/firebase", () => ({ + auth: { currentUser: { uid: "u1", getIdToken: jest.fn(async () => "id-token") } }, +})) + +import { proxyJsonAuthed } from "@/lib/backend-auth" + +type MockResponseInit = { + status?: number + body?: unknown + headers?: Record +} + +function mockResponse({ status = 200, body = {}, headers = {} }: MockResponseInit): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json", ...headers }, + }) +} + +describe("proxyJsonAuthed", () => { + let fetchMock: jest.Mock + + beforeEach(() => { + clearInflight() + fetchMock = jest.fn(async (url: string) => { + if (typeof url === "string" && url.includes("/api/proxy")) { + return mockResponse({ + status: 200, + body: { + status: 200, + statusText: "OK", + headers: {}, + body: JSON.stringify({ ok: true }), + time: 1, + size: 1, + }, + }) + } + return mockResponse({ status: 200, body: { ok: true } }) + }) + global.fetch = fetchMock as unknown as typeof fetch + }) + + it("does not call /auth/session/check on warm path", async () => { + await proxyJsonAuthed("http://b", "GET", "/x") + const urls = fetchMock.mock.calls.map((c) => String(c[0])) + expect(urls.some((u) => u.includes("/auth/session/check"))).toBe(false) + }) + + it("parallel 5 calls trigger zero /auth/session/check requests", async () => { + await Promise.all( + Array.from({ length: 5 }, () => proxyJsonAuthed("http://b", "GET", "/x")) + ) + const checkCount = fetchMock.mock.calls.filter((c) => + String(c[0]).includes("/auth/session/check") + ).length + expect(checkCount).toBe(0) + }) + + it("on 401, calls /auth/refresh once even when invoked 3× concurrently", async () => { + let proxyCalls = 0 + fetchMock.mockImplementation(async (url: string) => { + const u = String(url) + if (u.includes("/api/proxy")) { + proxyCalls += 1 + const status = proxyCalls <= 3 ? 401 : 200 + return mockResponse({ + status: 200, + body: { + status, + statusText: status === 200 ? "OK" : "Unauthorized", + headers: {}, + body: JSON.stringify({ ok: status === 200 }), + time: 1, + size: 1, + }, + }) + } + if (u.endsWith("/api/backend/auth/refresh")) { + return mockResponse({ status: 200, body: { ok: true } }) + } + return mockResponse({ status: 200, body: {} }) + }) + + await Promise.all([ + proxyJsonAuthed("http://b", "GET", "/x"), + proxyJsonAuthed("http://b", "GET", "/x"), + proxyJsonAuthed("http://b", "GET", "/x"), + ]) + + const refreshCount = fetchMock.mock.calls.filter((c) => + String(c[0]).includes("/api/backend/auth/refresh") + ).length + expect(refreshCount).toBe(1) + }) +}) + +describe("establishBackendSession defaults", () => { + let fetchMock: jest.Mock + + beforeEach(() => { + clearInflight() + fetchMock = jest.fn(async () => mockResponse({ status: 200, body: { ok: true } })) + global.fetch = fetchMock as unknown as typeof fetch + }) + + it("defaults check_revoked to false", async () => { + const { establishBackendSession } = await import("@/lib/backend-auth") + await establishBackendSession("id-token") + const sessionCall = fetchMock.mock.calls.find((c) => + String(c[0]).includes("/api/backend/auth/session") + ) + expect(sessionCall).toBeDefined() + const body = JSON.parse(String(sessionCall![1].body)) + expect(body.check_revoked).toBe(false) + }) + + it("respects explicit checkRevoked: true", async () => { + const { establishBackendSession } = await import("@/lib/backend-auth") + await establishBackendSession("id-token", { checkRevoked: true }) + const sessionCall = fetchMock.mock.calls.find((c) => + String(c[0]).includes("/api/backend/auth/session") + ) + const body = JSON.parse(String(sessionCall![1].body)) + expect(body.check_revoked).toBe(true) + }) +}) diff --git a/apps/web/src/lib/__tests__/tool-usage-utils.test.ts b/apps/web/src/lib/__tests__/tool-usage-utils.test.ts new file mode 100644 index 00000000..6c8d7f10 --- /dev/null +++ b/apps/web/src/lib/__tests__/tool-usage-utils.test.ts @@ -0,0 +1,65 @@ +import { + appendEvent, + deriveRecents, + deriveCounts, + MAX_EVENTS, + type ToolUsage, +} from '@/lib/tool-usage-utils' + +const ev = (toolId: string, timestamp: number, url = `/app/${toolId}`): ToolUsage => ({ + toolId, + timestamp, + url, +}) + +describe('appendEvent', () => { + it('prepends the new event without deduping', () => { + const log = [ev('a', 1000)] + const out = appendEvent(log, ev('a', 2000), 2000) + expect(out).toHaveLength(2) + expect(out[0]).toEqual(ev('a', 2000)) + }) + + it('prunes events older than 90 days', () => { + const now = 90 * 24 * 60 * 60 * 1000 + 5000 + const old = ev('old', 1000) // ~epoch, older than 90d before now + const out = appendEvent([old], ev('new', now), now) + expect(out.map((e) => e.toolId)).toEqual(['new']) + }) + + it('caps total events at MAX_EVENTS, keeping newest', () => { + const now = 10_000_000 + const log: ToolUsage[] = Array.from({ length: MAX_EVENTS }, (_, i) => + ev(`t${i}`, now - i), + ) + const out = appendEvent(log, ev('newest', now + 1), now + 1) + expect(out).toHaveLength(MAX_EVENTS) + expect(out[0].toolId).toBe('newest') + expect(out.some((e) => e.toolId === `t${MAX_EVENTS - 1}`)).toBe(false) + }) +}) + +describe('deriveRecents', () => { + it('dedupes by toolId keeping newest, newest-first, sliced', () => { + const log = [ev('a', 300), ev('b', 200), ev('a', 100)] + const out = deriveRecents(log, 5) + expect(out.map((e) => e.toolId)).toEqual(['a', 'b']) + expect(out[0].timestamp).toBe(300) + }) + + it('respects the limit', () => { + const log = [ev('a', 3), ev('b', 2), ev('c', 1)] + expect(deriveRecents(log, 2).map((e) => e.toolId)).toEqual(['a', 'b']) + }) +}) + +describe('deriveCounts', () => { + it('counts launches per tool with latest url and lastUsed', () => { + const log = [ev('a', 300, '/app/a?v=2'), ev('b', 250), ev('a', 100, '/app/a?v=1')] + const counts = deriveCounts(log) + expect(counts.a.count).toBe(2) + expect(counts.a.lastUsed).toBe(300) + expect(counts.a.url).toBe('/app/a?v=2') + expect(counts.b.count).toBe(1) + }) +}) diff --git a/apps/web/src/lib/auth-inflight.ts b/apps/web/src/lib/auth-inflight.ts new file mode 100644 index 00000000..45dfc278 --- /dev/null +++ b/apps/web/src/lib/auth-inflight.ts @@ -0,0 +1,22 @@ +const inflight = new Map>() + +export function dedupe(key: string, fn: () => Promise): Promise { + const existing = inflight.get(key) as Promise | undefined + if (existing) return existing + + const p = (async () => { + try { + return await fn() + } finally { + inflight.delete(key) + } + })() + + inflight.set(key, p as Promise) + return p +} + +/** Test-only: reset the in-flight map between cases. */ +export function clearInflight(): void { + inflight.clear() +} diff --git a/apps/web/src/lib/backend-auth.ts b/apps/web/src/lib/backend-auth.ts index 5014b45b..da66df31 100644 --- a/apps/web/src/lib/backend-auth.ts +++ b/apps/web/src/lib/backend-auth.ts @@ -1,5 +1,6 @@ import type { User } from "firebase/auth" import { auth } from "@/database/firebase" +import { dedupe } from "@/lib/auth-inflight" /** Same-origin refresh endpoint (used by fetch helpers). */ export const BACKEND_AUTH_REFRESH_PATH = "/api/backend/auth/refresh" @@ -31,71 +32,79 @@ export async function establishBackendSession( opts: { maxAttempts?: number getFreshIdToken?: () => Promise + checkRevoked?: boolean } = {} ): Promise { const maxAttempts = Math.max(1, opts.maxAttempts ?? 3) - let token = idToken - let lastError: Error | null = null - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - const res = await fetch("/api/backend/auth/session", { - method: "POST", - credentials: "include", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ id_token: token, check_revoked: true }), - cache: "no-store", - }) - - if (res.ok) return - - const retriable = res.status === 429 || res.status >= 500 - const msg = await readErrorMessage(res) - lastError = new Error(msg || `Session exchange failed (${res.status})`) - - if (!retriable || attempt === maxAttempts) { - throw lastError + const checkRevoked = opts.checkRevoked ?? false + return dedupe(`session:${checkRevoked ? "revoked" : "fast"}`, async () => { + let token = idToken + let lastError: Error | null = null + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const res = await fetch("/api/backend/auth/session", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id_token: token, check_revoked: checkRevoked }), + cache: "no-store", + }) + + if (res.ok) return + + const retriable = res.status === 429 || res.status >= 500 + const msg = await readErrorMessage(res) + lastError = new Error(msg || `Session exchange failed (${res.status})`) + + if (!retriable || attempt === maxAttempts) { + throw lastError + } + } catch (e) { + lastError = e instanceof Error ? e : new Error("Session exchange failed") + if (attempt === maxAttempts) throw lastError } - } catch (e) { - lastError = e instanceof Error ? e : new Error("Session exchange failed") - if (attempt === maxAttempts) throw lastError - } - if (opts.getFreshIdToken) { - try { - token = await opts.getFreshIdToken() - } catch { - // keep the existing token for the next attempt + if (opts.getFreshIdToken) { + try { + token = await opts.getFreshIdToken() + } catch { + // keep the existing token for the next attempt + } } + await sleep(250 * attempt) } - await sleep(250 * attempt) - } + }) } /** * If JWT cookies are missing or expired but Firebase session exists, re-run the Firebase exchange. */ export async function ensureBackendSession(user: User): Promise { - let check = await fetch("/api/backend/auth/session/check", { - method: "GET", - credentials: "include", - cache: "no-store", - }) - if (check.ok) return - if (check.status >= 500) { - await sleep(200) - check = await fetch("/api/backend/auth/session/check", { + const ok = await dedupe("session-check", async () => { + let check = await fetch("/api/backend/auth/session/check", { method: "GET", credentials: "include", cache: "no-store", }) - if (check.ok) return - } + if (check.ok) return true + if (check.status >= 500) { + await sleep(200) + check = await fetch("/api/backend/auth/session/check", { + method: "GET", + credentials: "include", + cache: "no-store", + }) + if (check.ok) return true + } + return false + }) + if (ok) return const idToken = await user.getIdToken() await establishBackendSession(idToken, { maxAttempts: 3, getFreshIdToken: () => user.getIdToken(true), + checkRevoked: false, }) } @@ -169,17 +178,16 @@ export async function proxyJsonAuthed( path: string, body?: unknown ): Promise<{ status: number; data: T | null }> { - const u = auth.currentUser - if (u) await ensureBackendSession(u) - let result = await rawProxyJson(backendBaseUrl, method, path, body) if (result.status === 401) { - const refr = await fetch(BACKEND_AUTH_REFRESH_PATH, { - method: "POST", - credentials: "include", - cache: "no-store", - }) + const refr = await dedupe("refresh", async () => + fetch(BACKEND_AUTH_REFRESH_PATH, { + method: "POST", + credentials: "include", + cache: "no-store", + }) + ) if (refr.ok) { result = await rawProxyJson(backendBaseUrl, method, path, body) } @@ -187,7 +195,13 @@ export async function proxyJsonAuthed( if (result.status === 401) { const u2 = auth.currentUser - if (u2) await ensureBackendSession(u2) + if (u2) { + try { + await ensureBackendSession(u2) + } catch { + // Silent re-exchange failed — fall through to forceLogout below. + } + } result = await rawProxyJson(backendBaseUrl, method, path, body) } @@ -215,15 +229,17 @@ export async function backendFetch(path: string, init?: RequestInit): Promise + fetch(BACKEND_AUTH_REFRESH_PATH, { + method: "POST", + credentials: "include", + cache: "no-store", + }) + ) if (refr.ok) { res = await run() - // Refresh succeeded but still getting 401/403 — session is truly invalid. if (res.status === 401 || res.status === 403) { + // Refresh succeeded but still getting 401/403 — session is truly invalid. forceLogout("unauthorized") } } else { diff --git a/apps/web/src/lib/require-backend-session.ts b/apps/web/src/lib/require-backend-session.ts index 7df4b869..7c834571 100644 --- a/apps/web/src/lib/require-backend-session.ts +++ b/apps/web/src/lib/require-backend-session.ts @@ -1,10 +1,47 @@ import { NextResponse } from "next/server" +import { createHash } from "crypto" const FASTAPI_BASE_URL = process.env.NEXT_PUBLIC_FASTAPI_BASE_URL +const SESSION_CACHE_TTL_MS = Number(process.env.AUTH_SESSION_CACHE_TTL_MS ?? 30_000) +const SESSION_CACHE_MAX = 5_000 + +type CacheEntry = { expiresAt: number } +const sessionCache = new Map() +const inflight = new Map>() + +function tokenKey(cookie: string | null, authorization: string | null): string | null { + if (!cookie && !authorization) return null + return createHash("sha256") + .update(`${cookie ?? ""}${authorization ?? ""}`) + .digest("hex") +} + +function getCached(key: string): boolean { + const entry = sessionCache.get(key) + if (!entry) return false + if (entry.expiresAt <= Date.now()) { + sessionCache.delete(key) + return false + } + return true +} + +function setCached(key: string): void { + if (sessionCache.size >= SESSION_CACHE_MAX) { + const firstKey = sessionCache.keys().next().value + if (firstKey) sessionCache.delete(firstKey) + } + sessionCache.set(key, { expiresAt: Date.now() + SESSION_CACHE_TTL_MS }) +} + /** * Ensures the request has a valid backend JWT session (cookie or Bearer). * Returns null if OK, or a NextResponse to return from the route handler. + * + * Successful checks are cached per-instance for SESSION_CACHE_TTL_MS to avoid + * a round-trip on every BFF API call. Revoked tokens remain accepted up to + * the TTL window; tune via AUTH_SESSION_CACHE_TTL_MS (default 30s). */ export async function requireBackendSession(request: Request): Promise { if (!FASTAPI_BASE_URL) { @@ -14,26 +51,50 @@ export async function requireBackendSession(request: Request): Promise = {} const cookie = request.headers.get("cookie") const authorization = request.headers.get("authorization") + const key = tokenKey(cookie, authorization) + + if (key && getCached(key)) return null + + if (key) { + const pending = inflight.get(key) + if (pending) return pending + } + const headers: Record = {} if (cookie) headers.cookie = cookie if (authorization) headers.authorization = authorization - try { - const checkRes = await fetch(`${FASTAPI_BASE_URL}/api/v1/auth/session/check`, { - method: "GET", - headers, - cache: "no-store", - }) + const task = (async (): Promise => { + try { + const checkRes = await fetch(`${FASTAPI_BASE_URL}/api/v1/auth/session/check`, { + method: "GET", + headers, + cache: "no-store", + }) + + if (checkRes.ok) { + if (key) setCached(key) + return null + } + if (checkRes.status === 401 || checkRes.status === 403) { + if (key) sessionCache.delete(key) + return NextResponse.json({ error: "Unauthorized" }, { status: checkRes.status }) + } + return NextResponse.json({ error: "Auth check failed" }, { status: 502 }) + } catch { + return NextResponse.json({ error: "Failed to verify auth session" }, { status: 502 }) + } + })() - if (checkRes.ok) return null - if (checkRes.status === 401 || checkRes.status === 403) { - return NextResponse.json({ error: "Unauthorized" }, { status: checkRes.status }) + if (key) { + inflight.set(key, task) + try { + return await task + } finally { + inflight.delete(key) } - return NextResponse.json({ error: "Auth check failed" }, { status: 502 }) - } catch { - return NextResponse.json({ error: "Failed to verify auth session" }, { status: 502 }) } + return task } diff --git a/apps/web/src/lib/tool-usage-utils.ts b/apps/web/src/lib/tool-usage-utils.ts new file mode 100644 index 00000000..b245b8c0 --- /dev/null +++ b/apps/web/src/lib/tool-usage-utils.ts @@ -0,0 +1,54 @@ +export interface ToolUsage { + toolId: string + timestamp: number + url: string +} + +export const MAX_EVENTS = 500 +export const MAX_AGE_DAYS = 90 + +const MAX_AGE_MS = MAX_AGE_DAYS * 24 * 60 * 60 * 1000 + +/** Prepend `event`, then prune by age (relative to `now`) and total count. */ +export function appendEvent( + log: ToolUsage[], + event: ToolUsage, + now: number = Date.now(), +): ToolUsage[] { + const cutoff = now - MAX_AGE_MS + return [event, ...log].filter((e) => e.timestamp >= cutoff).slice(0, MAX_EVENTS) +} + +/** Newest-first, deduped by toolId (keep newest), sliced to `limit`. */ +export function deriveRecents(log: ToolUsage[], limit: number): ToolUsage[] { + const sorted = [...log].sort((a, b) => b.timestamp - a.timestamp) + const seen = new Set() + const out: ToolUsage[] = [] + for (const e of sorted) { + if (seen.has(e.toolId)) continue + seen.add(e.toolId) + out.push(e) + if (out.length >= limit) break + } + return out +} + +/** Per-tool launch count, latest url, and max timestamp. */ +export function deriveCounts( + log: ToolUsage[], +): Record { + const out: Record = {} + for (const e of log) { + const cur = out[e.toolId] + if (!cur) { + out[e.toolId] = { count: 1, url: e.url, lastUsed: e.timestamp } + } else { + cur.count += 1 + if (e.timestamp > cur.lastUsed) { + cur.lastUsed = e.timestamp + cur.url = e.url + } + } + } + return out +} diff --git a/docs/superpowers/plans/2026-06-22-dashboard-analytics-graphs.md b/docs/superpowers/plans/2026-06-22-dashboard-analytics-graphs.md new file mode 100644 index 00000000..ebc832e4 --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-dashboard-analytics-graphs.md @@ -0,0 +1,1068 @@ +# Dashboard Analytics Graphs Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the dashboard Analytics tab genuinely useful by recording real usage events and rendering four zero-dependency SVG charts (activity trend, data distribution donut, task completion donut, top tools bars). + +**Architecture:** Split the work into pure, node-testable logic modules and thin presentational components. `tool-usage-utils.ts` holds the event-log operations (append/prune/derive); `use-tool-usage.ts` wires those to localStorage. `charts/chart-utils.ts` holds chart math (day bucketing, donut segments); the SVG chart components are thin renderers consuming that math. The analytics panel composes everything in a charts-first layout. + +**Tech Stack:** Next.js (client components), TypeScript, Tailwind, hand-rolled SVG. Tests: Jest + ts-jest (node environment, `**/__tests__/**/*.test.ts`). + +## Global Constraints + +- **Zero new runtime dependencies.** No chart library. Charts are hand-rolled SVG. +- **No backend changes.** Client-only. `trackToolUsageApi` call stays as-is. +- **Test environment is node-only** (`jest-environment-node`, no jsdom/RTL). Only pure logic is unit-tested; components are verified manually. +- **Style:** enterprise-flat — solid surfaces, theme tokens (`hsl(var(--primary))`, `currentColor`, `hsl(var(--muted-foreground))`), restrained motion, respect `prefers-reduced-motion`. +- **Accessibility:** each chart `role="img"` + descriptive `aria-label`; color never the sole signal; toggles are real ` + ))} +
+
+ +
+
+ {buckets.map((b) => ( +
+
0 ? 2 : 0 }} + title={`${b.label}: ${b.count}`} + /> +
+ ))} +
+ {range === 7 && ( +
+ {buckets.map((b) => ( + + {b.label} + + ))} +
+ )} + {thin && ( +
+ + {emptyHint} + +
+ )} +
+
+ ) +} +``` + +- [ ] **Step 2: Typecheck + lint** + +Run: `cd apps/web && npx tsc --noEmit && npx eslint src/components/dashboard/charts/activity-bar-chart.tsx` +Expected: exit 0. + +- [ ] **Step 3: Commit** + +```bash +git add apps/web/src/components/dashboard/charts/activity-bar-chart.tsx +git commit -m "feat(analytics): activity bar chart with 7/30d toggle" +``` + +--- + +### Task 6: TopToolsBars component + +Horizontal bars of most-launched tools (real counts). + +**Files:** +- Create: `apps/web/src/components/dashboard/charts/top-tools-bars.tsx` + +**Interfaces:** +- Consumes: nothing new (pure presentational). +- Produces: `TopToolsBars`. + - `interface TopTool { id: string; title: string; icon?: React.ElementType; count: number; url?: string }` + - Props: `{ tools: TopTool[]; title: string }` + +- [ ] **Step 1: Write the component** + +```tsx +// apps/web/src/components/dashboard/charts/top-tools-bars.tsx +'use client' + +import Link from 'next/link' +import { Zap } from 'lucide-react' + +export interface TopTool { + id: string + title: string + icon?: React.ElementType + count: number + url?: string +} + +interface TopToolsBarsProps { + tools: TopTool[] + title: string +} + +export function TopToolsBars({ tools, title }: TopToolsBarsProps) { + if (tools.length === 0) return null + const max = Math.max(1, ...tools.map((t) => t.count)) + + return ( +
+

+ {title} +

+
    + {tools.map((t) => { + const Icon = t.icon ?? Zap + return ( +
  • + + + + {t.title} + + + + + + {t.count} + + +
  • + ) + })} +
+
+ ) +} +``` + +- [ ] **Step 2: Typecheck + lint** + +Run: `cd apps/web && npx tsc --noEmit && npx eslint src/components/dashboard/charts/top-tools-bars.tsx` +Expected: exit 0. + +- [ ] **Step 3: Commit** + +```bash +git add apps/web/src/components/dashboard/charts/top-tools-bars.tsx +git commit -m "feat(analytics): top tools horizontal bars" +``` + +--- + +### Task 7: i18n strings for charts + +Add the new chart labels to all 27 locale files under `Dashboard.analytics`, reusing English copy as fallback for non-en (matches the existing pattern in this codebase for newly added keys). + +**Files:** +- Modify: `apps/web/messages/*.json` (27 files), `Dashboard.analytics` object. + +**Interfaces:** +- Produces these keys under `Dashboard.analytics`: + - `activityTitle` = "Activity" + - `activityEmpty` = "Activity builds as you use tools" + - `distributionTitle` = "Data distribution" + - `topToolsTitle` = "Top tools" + - `taskDonutTitle` = "Task completion" + +- [ ] **Step 1: Add keys via script** + +```bash +cd apps/web && python3 - <<'PY' +import json, glob, collections +NEW = { + "activityTitle": "Activity", + "activityEmpty": "Activity builds as you use tools", + "distributionTitle": "Data distribution", + "topToolsTitle": "Top tools", + "taskDonutTitle": "Task completion", +} +for f in glob.glob('messages/*.json'): + data = json.load(open(f, encoding='utf-8'), object_pairs_hook=collections.OrderedDict) + analytics = data.get('Dashboard', {}).get('analytics') + if not isinstance(analytics, dict): + continue + changed = False + for k, v in NEW.items(): + if k not in analytics: + analytics[k] = v + changed = True + if changed: + json.dump(data, open(f, 'w', encoding='utf-8'), ensure_ascii=False, indent=2) + open(f, 'a').write('\n') + print('updated', f) +print('done') +PY +``` + +- [ ] **Step 2: Verify diff is minimal** + +Run: `cd /Users/max/Works/Personal/mydevtools.tech && git diff --numstat apps/web/messages/ | awk '{print $1+$2, $3}' | sort -rn | head -3` +Expected: each file ~10 changed lines (5 inserts), no wholesale reformat. + +- [ ] **Step 3: Commit** + +```bash +git add apps/web/messages/ +git commit -m "i18n(analytics): add chart section labels" +``` + +--- + +### Task 8: Compose charts into the analytics panel + +Add the charts-first zone to the panel; fix the KPI third card to real counts; remove the fake "Most used locally" block. + +**Files:** +- Modify: `apps/web/src/components/dashboard/dashboard-analytics-panel.tsx` + +**Interfaces:** +- Consumes: `useToolUsage` (`getUsageEvents`, `getToolUsageCounts`) from Task 2; `DonutChart` (Task 4); `ActivityBarChart` (Task 5); `TopToolsBars` + `TopTool` (Task 6); `findToolById` (existing in file). + +- [ ] **Step 1: Replace the localStorage-derived `topUsedTools` with real counts + add usage events** + +Replace the existing `topUsedTools` `useMemo` (the block reading `localStorage.getItem('tool-usage-history')` and parsing it, currently lines ~444-471) with hook-based data. Add near the top of the component body (after the existing `useState` hooks): + +```tsx + const { getUsageEvents, getToolUsageCounts } = useToolUsage() + const usageEvents = useMemo(() => getUsageEvents(), [getUsageEvents]) + + const topUsedTools = useMemo(() => { + const counts = getToolUsageCounts() + return Object.entries(counts) + .sort(([, a], [, b]) => b.count - a.count) + .slice(0, 5) + .map(([id, info]) => { + const found = findToolById(id) + return { + id, + title: found?.title ?? id, + icon: found?.icon, + count: info.count, + url: info.url, + } + }) + }, [getToolUsageCounts]) +``` + +Add the import for `useToolUsage`: + +```tsx +import { useToolUsage } from '@/hooks/use-tool-usage' +``` + +Add chart imports: + +```tsx +import { DonutChart } from './charts/donut-chart' +import { ActivityBarChart } from './charts/activity-bar-chart' +import { TopToolsBars, type TopTool } from './charts/top-tools-bars' +import { type DonutSegment } from './charts/chart-utils' +``` + +Remove the now-unused local `TopTool` interface (lines ~347-353) and the `TopUsedTools` component (lines ~355-391) — both replaced by `TopToolsBars`. Remove `TrendingUp` from the lucide import if it becomes unused after deleting `TopUsedTools`. + +- [ ] **Step 2: Fix the third KPI card to real counts** + +In the KPI strip, change the third `KpiCard` (currently `label="Tools used locally"`, `value={topUsedTools.length}`) to: + +```tsx + 0 + ? `${usageEvents.length} launches` + : 'No history yet' + } + /> +``` + +If `t('toolsUsedLocally')` is not an existing key, keep the current literal string the file already uses for this label (do not invent a new i18n key in this task). + +- [ ] **Step 3: Insert the chart zone** + +Immediately after the header strip `
` (the block containing the `heroBadge` / Refresh button, ends ~line 613) and before the `{/* Group 1: Vault & Bookmarks */}` comment, insert: + +```tsx + {/* ── Charts ─────────────────────────────────────────────────────────── */} + + +
+
+

+ {t('distributionTitle')} +

+ +
+ +
+

+ {t('taskDonutTitle')} +

+ +
+
+ + +``` + +- [ ] **Step 4: Remove the old TopUsedTools render** + +Delete the `` line near the end of the returned JSX (~line 680) since `TopToolsBars` now covers it. + +- [ ] **Step 5: Typecheck + lint** + +Run: `cd apps/web && npx tsc --noEmit && npx eslint src/components/dashboard/dashboard-analytics-panel.tsx` +Expected: exit 0, no unused-var warnings (confirm `TrendingUp` / old `TopTool` / `TopUsedTools` fully removed). + +- [ ] **Step 6: Commit** + +```bash +git add apps/web/src/components/dashboard/dashboard-analytics-panel.tsx +git commit -m "feat(analytics): charts-first panel with activity, donuts, top tools" +``` + +--- + +### Task 9: Full verification + +**Files:** none (verification only). + +- [ ] **Step 1: Run the full analytics-related test suite** + +Run: `cd apps/web && npx jest src/lib/__tests__/tool-usage-utils.test.ts src/components/dashboard/charts/__tests__/chart-utils.test.ts` +Expected: all PASS. + +- [ ] **Step 2: Typecheck whole app** + +Run: `cd apps/web && npx tsc --noEmit` +Expected: exit 0. + +- [ ] **Step 3: Lint touched files** + +Run: `cd apps/web && npx eslint src/hooks/use-tool-usage.ts src/components/dashboard/charts/ src/components/dashboard/dashboard-analytics-panel.tsx` +Expected: exit 0. + +- [ ] **Step 4: Manual visual check** + +Run the app, open `/dashboard`, switch to the Analytics tab. Verify (light + dark, widths 375/768/1024/1440): +- Activity chart shows the empty-state hint when there is little/no history; click a few tools, return, confirm bars appear and 7d⇄30d toggle works. +- Distribution donut sums to the total tracked items; segments + legend readable. +- Task donut shows completion %. +- Top tools bars show real counts (≥1, increasing as tools are reused — no longer capped at 1). +- No console errors; existing chip sections + refresh/show-empty still work. + +- [ ] **Step 5: Final commit (if any manual fixes were needed)** + +```bash +git add -A apps/web/src/components/dashboard apps/web/src/hooks +git commit -m "fix(analytics): manual verification adjustments" +``` + +--- + +## Self-Review + +**Spec coverage:** +- Data layer (append-only log, prune, recents read-time dedupe, getUsageEvents, getToolUsageCounts) → Tasks 1, 2. +- Back-compat (old array input) → Task 1 tests use plain arrays; hook reads same key. +- Chart components (donut reused 2×, activity bars, top-tools bars) → Tasks 4, 5, 6. +- Chart math + a11y labels → Tasks 3, 4, 5. +- Panel layout A (KPI fix, activity, 2-col donuts, top tools, chips kept) → Task 8. +- Empty-state hint → Task 5. +- i18n → Task 7. +- Tests + manual verification → Tasks 1, 3, 9. + +**Placeholder scan:** No TBD/TODO. The only conditional copy ("if `toolsUsedLocally` not an existing key, keep current literal") is an explicit instruction tied to verifiable repo state, not a placeholder. + +**Type consistency:** `ToolUsage` shared from `tool-usage-utils`. `DonutSegment` defined in `chart-utils`, consumed by `DonutChart` and panel. `TopTool` defined in `top-tools-bars`, imported by panel (old in-file `TopTool` removed in Task 8). Hook return names (`getUsageEvents`, `getToolUsageCounts`) match panel consumption. `donutArcs` field names (`dashArray`, `dashOffset`, `percent`, `segment`) consistent across Task 3 test, impl, and Task 4 usage. diff --git a/docs/superpowers/specs/2026-06-22-dashboard-analytics-graphs-design.md b/docs/superpowers/specs/2026-06-22-dashboard-analytics-graphs-design.md new file mode 100644 index 00000000..f06a411b --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-dashboard-analytics-graphs-design.md @@ -0,0 +1,143 @@ +# Dashboard Analytics — Clear Data & Graphs + +**Date:** 2026-06-22 +**Status:** Design (approved in brainstorming) +**Scope:** Client-only. Make the dashboard Analytics tab genuinely useful by adding real +usage tracking and four hand-rolled SVG charts. + +## Problem + +The Analytics tab ([dashboard-analytics-panel.tsx](../../../apps/web/src/components/dashboard/dashboard-analytics-panel.tsx)) +shows count chips but **no graphs**. Two data problems make it weak: + +1. **No chart library** is installed; the panel renders only numeric chips + one thin + stacked progress bar. +2. **Usage history is unusable for analytics.** [use-tool-usage.ts](../../../apps/web/src/hooks/use-tool-usage.ts) + dedupes by `toolId` on write and caps at 20 entries. So the "Most used locally · N×" + counts are effectively fake (max 1 each), and there is no time-series data for trends. + +Backend (`/api/backend/analytics/summary`) returns **static counts only** — no time dimension. + +## Goals + +- Real, honest graphs that make the tab useful at a glance. +- Zero new dependencies (hand-rolled SVG charts). +- Match the enterprise-flat dashboard style already established (solid surfaces, restrained + motion, theme tokens, `prefers-reduced-motion` respected, accessible). + +## Non-Goals + +- No backend changes (no server-side time-series). Client-only this pass. +- No new chart library. +- No change to the visual style direction (stays flat/enterprise). + +## Decisions (from brainstorming) + +| Question | Decision | +|----------|----------| +| Data scope | Fix client tracking + add charts (client-only) | +| Chart tech | Hand-rolled zero-dep SVG | +| Graphs | Activity trend, Data distribution donut, Top tools by real usage, Task completion donut | +| Layout | Approach A — charts-first hero, detail chips below | +| Empty trend period | Show chart immediately with a subtle empty-state hint when data is thin | + +## Architecture + +### 1. Data layer — `src/hooks/use-tool-usage.ts` + +Convert the deduped/capped store into an **append-only event log**. + +```ts +interface ToolUsage { + toolId: string + timestamp: number + url: string +} +``` + +- **Constants:** `MAX_EVENTS = 500`, `MAX_AGE_DAYS = 90`. +- **`trackToolUsage(toolId, url)`** — append a new event (no dedupe). After append, prune: + drop events older than `MAX_AGE_DAYS`, then keep the most recent `MAX_EVENTS`. + Backend `trackToolUsageApi` call stays unchanged. +- **`getRecentlyUsedTools(limit)`** — unchanged *behavior*: read log, sort by timestamp + desc, **dedupe by `toolId` at read time** (keep newest), slice to `limit`. The dashboard + "Recently used" section keeps working identically. +- **New `getUsageEvents(): ToolUsage[]`** — raw log (sorted desc), for the activity trend. +- **New `getToolUsageCounts(): Record`** + — real per-tool launch counts, for top-tools bars. + +**Back-compat:** existing stored value is already `ToolUsage[]` with the same field shape, so +no migration is needed — the code simply stops capping at 20 and the log grows from now on. +Old data (≤20 deduped points) is valid input. + +Storage budget: 500 × ~80 bytes ≈ 40 KB localStorage. Acceptable. + +### 2. Chart components — new `src/components/dashboard/charts/` + +All zero-dep SVG. Shared rules: use theme tokens (`hsl(var(--primary))`, `currentColor`, +`hsl(var(--muted-foreground))`), `role="img"` + descriptive `aria-label`, no entrance +animation when `prefers-reduced-motion: reduce`, `tabular-nums` for figures. + +- **`donut-chart.tsx`** — generic donut. Props: `segments: { label, value, color }[]`, + optional `centerValue` / `centerLabel`. Renders stroke-dasharray arcs + center text + + compact legend. Reused by **both** the distribution donut and the task completion donut. +- **`activity-bar-chart.tsx`** — vertical bars of launches/day. Props: `events: ToolUsage[]`, + internal `range` state `7 | 30` with a small toggle. Buckets events by day in local time, + fills missing days with 0. When fewer than 2 days have any events, overlays a subtle + empty-state hint ("Activity builds as you use tools") instead of faking data. +- **`top-tools-bars.tsx`** — horizontal bars. Props: `tools: { id, title, icon?, count, url? }[]`. + Bar width = count / max. Links to the tool. Replaces the old fake "Most used locally" block. + +### 3. Panel layout — `dashboard-analytics-panel.tsx` + +Approach A (charts-first hero, detail below): + +1. **KPI strip** (keep). Fix the third card: "Tools used locally" uses real distinct-tool + count from `getToolUsageCounts()`; sub-label shows total launches. +2. **Activity trend** — full-width card (`activity-bar-chart`). +3. **Distribution donut | Task completion donut** — 2-column row (md+), stack on mobile. + - Distribution segments: aggregate the tracked-item counts into the three existing groups + (Vault / Workspace / Toolkit) using the same field groupings already defined in the + panel; center = total tracked items (`sumTrackedItems`). + - Task donut segments: completed / ongoing / notStarted; center = completion %. +4. **Top tools bars** — full-width (`top-tools-bars`), real counts from `getToolUsageCounts()`. + Removes the existing `TopUsedTools` "Most used locally" section. +5. **Detail chip sections** (Vault / Workspace / Toolkit metric chips) — kept below as + drill-down detail, including the existing show/hide-empty toggle and refresh control. + +The existing skeleton/error/empty states and `useCountUp` animation are retained (count-up +is gated by reduced-motion via existing patterns or left as-is — it is opacity/number only, +not layout motion). + +## Component boundaries + +| Unit | Does | Depends on | +|------|------|-----------| +| `use-tool-usage` | Persist + read usage events/counts/recents | localStorage, backend track API | +| `donut-chart` | Render a donut from segments | none (pure SVG + theme tokens) | +| `activity-bar-chart` | Render daily-launch bars + range toggle | `ToolUsage[]` | +| `top-tools-bars` | Render ranked horizontal bars | tool list | +| `dashboard-analytics-panel` | Compose KPI + charts + detail from summary & local data | all above, analytics API | + +## Accessibility & a11y + +- Each chart: `role="img"` with an `aria-label` summarizing the data (e.g. "Tool launches per + day, last 7 days, peak 12 on Tuesday"). +- Color is not the only signal: donut/bars include text labels + values. +- Range toggle is a real `