From 06fa24d3858f3c21410f56c39f050661b566bc1c Mon Sep 17 00:00:00 2001 From: AKHIL Date: Fri, 4 Sep 2026 22:49:20 +0530 Subject: [PATCH 1/4] fix(json-formatter): make saved documents loadable into either pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saved documents were stamped with the pane they came from and the Load dialog only listed `docsByPane[loadPane]`, so a document saved from the Text pane was invisible in the Tree pane's dialog and vice versa. There was no way to load a saved document into the other pane. - List every saved document regardless of origin pane; loading still targets whichever pane opened the dialog. - Show the origin as Text/Tree instead of the raw left/right key. - Sort newest first — the local router lists oldest-first and saves repeat the auto-generated titles. - Render timestamps as dates. The row printed raw epoch ms because the type said `string` while the Rust router sends i64. New `lib/json-formatter-docs.ts` holds the timestamp parsing (epoch ms and ISO strings both reach the UI) with unit tests. --- .../json-formatter/json-formatter-layout.tsx | 34 +++++++++--------- .../lib/__tests__/json-formatter-docs.test.ts | 35 +++++++++++++++++++ .../desktop-ui/src/lib/json-formatter-docs.ts | 21 +++++++++++ 3 files changed, 73 insertions(+), 17 deletions(-) create mode 100644 apps/desktop-ui/src/lib/__tests__/json-formatter-docs.test.ts create mode 100644 apps/desktop-ui/src/lib/json-formatter-docs.ts diff --git a/apps/desktop-ui/src/components/json-formatter/json-formatter-layout.tsx b/apps/desktop-ui/src/components/json-formatter/json-formatter-layout.tsx index d027addc..6c7b2a84 100644 --- a/apps/desktop-ui/src/components/json-formatter/json-formatter-layout.tsx +++ b/apps/desktop-ui/src/components/json-formatter/json-formatter-layout.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useMemo, useRef, useState } from 'react' +import { useCallback, useRef, useState } from 'react' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' import { useTranslations } from 'next-intl' import { useIsMobile } from '@/components/hooks/use-mobile' @@ -24,6 +24,7 @@ import { import { toast } from 'sonner' import type { Mode, Content, OnChangeStatus } from 'vanilla-jsoneditor' import { fetchAllPages } from '@/lib/fetch-all-pages' +import { docTime, formatDocDate } from '@/lib/json-formatter-docs' import { ToolPageHeader } from '@/components/tools/tool-page-header' import { ToolMobileTabs } from '@/components/tools/tool-mobile-tabs' import { RevealItem } from '@/components/dashboard/dashboard-reveal' @@ -100,8 +101,9 @@ type JsonFormatterDocumentOut = { title: string pane: PaneKey content: string - createdAt: string - updatedAt: string + // Local router stores these as epoch ms; older/remote rows may be ISO strings. + createdAt: string | number + updatedAt: string | number } const DOCS_PAGE_SIZE = 500 @@ -163,13 +165,6 @@ export function JsonFormatterLayout() { const [docsLoading, setDocsLoading] = useState(false) const [docs, setDocs] = useState([]) - const docsByPane = useMemo(() => { - return { - left: docs.filter((d) => d.pane === 'left'), - right: docs.filter((d) => d.pane === 'right'), - } - }, [docs]) - const updatePane = (pane: PaneKey, updater: (prev: PaneState) => PaneState) => { if (pane === 'left') { setLeftPane(updater) @@ -330,7 +325,12 @@ export function JsonFormatterLayout() { }, }) - setDocs(allDocs) + // Newest first — the local router lists oldest-first, and saves repeat titles. + setDocs( + [...allDocs].sort( + (a, b) => docTime(b.updatedAt || b.createdAt) - docTime(a.updatedAt || a.createdAt) + ) + ) } const openLoadDialog = async (pane: PaneKey) => { @@ -564,14 +564,14 @@ export function JsonFormatterLayout() { Load saved JSON - Pick a previously saved JSON document from your account. + Pick any previously saved JSON document — saves from both panes are listed.
- Load into {loadPane} + Load into {loadPane === 'right' ? 'Tree' : 'Text'} pane
- {d.pane} + {d.pane === 'right' ? 'Tree' : 'Text'}
diff --git a/apps/desktop-ui/src/lib/__tests__/json-formatter-docs.test.ts b/apps/desktop-ui/src/lib/__tests__/json-formatter-docs.test.ts new file mode 100644 index 00000000..e2a82afb --- /dev/null +++ b/apps/desktop-ui/src/lib/__tests__/json-formatter-docs.test.ts @@ -0,0 +1,35 @@ +import { docTime, formatDocDate } from '@/lib/json-formatter-docs' + +describe('docTime', () => { + it('reads epoch ms from a number and from a numeric string', () => { + expect(docTime(1756800000000)).toBe(1756800000000) + expect(docTime('1756800000000')).toBe(1756800000000) + }) + + it('reads ISO strings', () => { + expect(docTime('2026-09-02T00:00:00.000Z')).toBe(Date.parse('2026-09-02T00:00:00.000Z')) + }) + + it('returns 0 for missing or unparseable values', () => { + expect(docTime(undefined)).toBe(0) + expect(docTime('')).toBe(0) + expect(docTime('not a date')).toBe(0) + }) + + it('sorts newest first', () => { + const docs = [{ updatedAt: 1 }, { updatedAt: '2026-09-02T00:00:00.000Z' }, { updatedAt: 2 }] + const sorted = [...docs].sort((a, b) => docTime(b.updatedAt) - docTime(a.updatedAt)) + expect(sorted.map((d) => d.updatedAt)).toEqual(['2026-09-02T00:00:00.000Z', 2, 1]) + }) +}) + +describe('formatDocDate', () => { + it('renders a real date instead of raw epoch ms', () => { + expect(formatDocDate(1756800000000)).toBe(new Date(1756800000000).toLocaleString()) + }) + + it('renders nothing when there is no usable timestamp', () => { + expect(formatDocDate(undefined)).toBe('') + expect(formatDocDate('nope')).toBe('') + }) +}) diff --git a/apps/desktop-ui/src/lib/json-formatter-docs.ts b/apps/desktop-ui/src/lib/json-formatter-docs.ts new file mode 100644 index 00000000..f358a18f --- /dev/null +++ b/apps/desktop-ui/src/lib/json-formatter-docs.ts @@ -0,0 +1,21 @@ +/** + * Timestamp helpers for saved JSON formatter documents. + * + * The local Rust router stores `createdAt`/`updatedAt` as epoch milliseconds, + * but rows written by older builds carry ISO strings — both shapes reach the UI. + */ + +/** Epoch ms for a timestamp in either shape; 0 when missing or unparseable. */ +export function docTime(value: string | number | undefined | null): number { + if (value === undefined || value === null || value === '') return 0 + const parsed = + typeof value === 'string' && !/^\d+$/.test(value) ? new Date(value) : new Date(Number(value)) + const ms = parsed.getTime() + return Number.isNaN(ms) ? 0 : ms +} + +/** Localized date for the load dialog; empty string when there is nothing to show. */ +export function formatDocDate(value: string | number | undefined | null): string { + const ms = docTime(value) + return ms === 0 ? '' : new Date(ms).toLocaleString() +} From 37421d92ca2fc3b7f38ed2edb5de639127f978fc Mon Sep 17 00:00:00 2001 From: AKHIL Date: Sun, 6 Sep 2026 23:02:27 +0530 Subject: [PATCH 2/4] fix(beautify-minify): syntax-highlight the output panel The Beautify & Minify output was rendered as a plain
, so JSON, CSS,
HTML/XML and JavaScript all came out as undifferentiated monospace text.

Run the result through the existing highlight.js wrapper
(lib/code-screenshot#highlightCode), mapping each beautifier language onto
its hljs grammar. Monaco was the other option but @monaco-editor/react has
no loader.config here and pulls `vs` from cdn.jsdelivr.net, which is dead in
the offline desktop app.

The GitHub light/dark token theme moves out of json-schema-generator/ into
components/tools/hljs-theme.css and is rescoped .json-schema-hl -> .hljs-theme
so both tools share one stylesheet; its only other consumer is updated.

Co-Authored-By: Claude Opus 5 (1M context) 
Claude-Session: https://claude.ai/code/session_01S5dugrrsqWvHDSeVhgNhSU
---
 .../beautify-minify-layout.tsx                |  29 ++-
 .../json-schema-generator-layout.tsx          |   4 +-
 .../json-schema-highlighter.css               | 203 -----------------
 .../src/components/tools/hljs-theme.css       | 204 ++++++++++++++++++
 .../src/lib/__tests__/code-screenshot.test.ts |  15 ++
 5 files changed, 247 insertions(+), 208 deletions(-)
 delete mode 100644 apps/desktop-ui/src/components/json-schema-generator/json-schema-highlighter.css
 create mode 100644 apps/desktop-ui/src/components/tools/hljs-theme.css

diff --git a/apps/desktop-ui/src/components/beautify-minify/beautify-minify-layout.tsx b/apps/desktop-ui/src/components/beautify-minify/beautify-minify-layout.tsx
index e04ffe43..21f1af2f 100644
--- a/apps/desktop-ui/src/components/beautify-minify/beautify-minify-layout.tsx
+++ b/apps/desktop-ui/src/components/beautify-minify/beautify-minify-layout.tsx
@@ -18,9 +18,20 @@ import { IconWand } from '@tabler/icons-react'
 import { ToolShell } from '@/components/tools/tool-shell'
 import { ToolPanels, IOPanel, ToolTextArea } from '@/components/tools/io-panel'
 import { BEAUTIFY_LANGS, beautify, minify, type BeautifyLang } from '@/lib/beautify-minify'
+import { highlightCode } from '@/lib/code-screenshot'
+import '@/components/tools/hljs-theme.css'
 
 type Action = 'beautify' | 'minify'
 
+/** Map a beautifier language onto the highlight.js grammar that renders it. */
+const HLJS_LANG: Record = {
+  html: 'xml',
+  xml: 'xml',
+  css: 'css',
+  js: 'javascript',
+  json: 'json',
+}
+
 export function BeautifyMinifyLayout() {
   const t = useTranslations('BeautifyMinify')
   const [input, setInput] = useState('')
@@ -39,6 +50,11 @@ export function BeautifyMinifyLayout() {
   )
   const inBytes = useMemo(() => (input ? new TextEncoder().encode(input).length : 0), [input])
 
+  const highlighted = useMemo(
+    () => (result.error || !result.output ? '' : highlightCode(result.output, HLJS_LANG[lang]).html),
+    [result.error, result.output, lang],
+  )
+
   const toolbar = (
     
@@ -143,9 +159,16 @@ export function BeautifyMinifyLayout() { {result.error ? (
{result.error}
) : result.output ? ( -
-              {result.output}
-            
+
+
+                
+              
+
) : (
{t('outputPlaceholder')}
)} diff --git a/apps/desktop-ui/src/components/json-schema-generator/json-schema-generator-layout.tsx b/apps/desktop-ui/src/components/json-schema-generator/json-schema-generator-layout.tsx index 3ebef174..5e7901ec 100644 --- a/apps/desktop-ui/src/components/json-schema-generator/json-schema-generator-layout.tsx +++ b/apps/desktop-ui/src/components/json-schema-generator/json-schema-generator-layout.tsx @@ -31,7 +31,7 @@ import { type StringFormat, } from '@/lib/json-schema-generator'; import { Badge } from '@/components/ui/badge'; -import './json-schema-highlighter.css'; +import '@/components/tools/hljs-theme.css'; import { JsonSchemaInputEditor } from './json-schema-input-editor'; const defaultSample = `{ @@ -211,7 +211,7 @@ export function JsonSchemaGeneratorLayout() {
) : ( -
+
{output ? (
                        {
     expect(BACKGROUNDS.filter((b) => b.css === null)).toHaveLength(1);
   });
 });
+
+describe('highlightCode for beautify/minify languages', () => {
+  // The Beautify & Minify tool maps its languages onto these hljs grammars.
+  const cases: [string, string][] = [
+    ['json', '{"a": 1}'],
+    ['css', 'a { color: red; }'],
+    ['xml', '
hi
'], + ['javascript', 'const a = 1;'], + ] + it.each(cases)('emits hljs tokens for %s', (lang, code) => { + const res = highlightCode(code, lang) + expect(res.language).toBe(lang) + expect(res.html).toContain('
+ {/* ── Group 4: Files ─────────────────────────────────────────────────── + Measured from the .mydt objects on disk, so the count and size stay + truthful while the vault is locked. */} +
+ + {files.count === 0 && !showEmpty ? ( + + ) : ( +
+ {fileChips.map((c) => ( + + ))} +
+ )} +
+
) } diff --git a/apps/desktop-ui/src/components/dashboard/types.ts b/apps/desktop-ui/src/components/dashboard/types.ts index 368debef..071d019b 100644 --- a/apps/desktop-ui/src/components/dashboard/types.ts +++ b/apps/desktop-ui/src/components/dashboard/types.ts @@ -84,6 +84,26 @@ export const findItemById = (id: string | undefined | null): ToolItem | undefine } } +/** + * Resolve a route path (`/app/json-formatter`) to the sidebar ToolItem it opens. + * + * Route paths are the stable identity for a tool: unlike the position-based IDs + * above they survive any reordering of `sidebar-data`, so they are what usage + * history is keyed by. + */ +export const findItemByUrl = (url: string | undefined | null): ToolItem | undefined => { + if (!url || typeof url !== 'string') return undefined + + for (const group of sidebarData.navGroups) { + for (const item of group.items) { + if (item.url?.toString() === url) return item + const sub = item.items?.find((s) => s.url?.toString() === url) + if (sub) return { ...sub, icon: sub.icon ?? item.icon } + } + } + return undefined +} + /** Human-readable relative time from a unix-ms timestamp. */ export function formatRelativeTime(ts: number): string { const diff = Date.now() - ts diff --git a/apps/desktop-ui/src/components/s3-drive/utils.ts b/apps/desktop-ui/src/components/s3-drive/utils.ts index c98dc211..b55dc3cc 100644 --- a/apps/desktop-ui/src/components/s3-drive/utils.ts +++ b/apps/desktop-ui/src/components/s3-drive/utils.ts @@ -1,11 +1,6 @@ import { listObjects, moveObject, type S3Credentials } from "@/lib/s3-drive-api" -export function formatBytes(bytes: number): string { - if (bytes === 0) return "0 B" - const units = ["B", "KB", "MB", "GB", "TB"] - const i = Math.floor(Math.log(bytes) / Math.log(1024)) - return `${(bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1)} ${units[i]}` -} +export { formatBytes } from "@/lib/format-bytes" export async function moveFolderRecursive(credentials: S3Credentials, oldPrefix: string, newPrefix: string) { let token: string | undefined diff --git a/apps/desktop-ui/src/components/sidebar/client-layout.tsx b/apps/desktop-ui/src/components/sidebar/client-layout.tsx index 697bc66e..ed79cbb3 100644 --- a/apps/desktop-ui/src/components/sidebar/client-layout.tsx +++ b/apps/desktop-ui/src/components/sidebar/client-layout.tsx @@ -11,6 +11,8 @@ import { isDesktop } from '@/lib/desktop/is-desktop'; import { MobileDesktopHint } from '@/components/mobile-desktop-hint'; import { useWorkspaceStore } from '@/store/workspace-store'; import { initWorkspaceScopeReset } from '@/lib/workspace-scope-reset'; +import { useToolUsage } from '@/hooks/use-tool-usage'; +import { findItemByUrl } from '@/components/dashboard/types'; // Reset workspace-scoped stores whenever the active workspace changes // (module-level, mirrors workspace-store's own subscribeOnce pattern). @@ -56,6 +58,23 @@ function TabSyncer() { return null; } +// Records a "tool opened" event whenever the URL lands on a real tool route. +// This is the single choke point for usage history: the sidebar, ⌘K, dashboard +// cards and tab chips all navigate with router.push, so every open passes here. +// Events are keyed by route path, the only tool identity that survives a +// reordering of sidebar-data. +function ToolUsageTracker() { + const pathname = usePathname(); + const { trackToolUsage } = useToolUsage(); + + useEffect(() => { + if (!pathname || !findItemByUrl(pathname)) return; + trackToolUsage(pathname, pathname); + }, [pathname, trackToolUsage]); + + return null; +} + function Layout({ children }: { children: React.ReactNode }) { const pathname = usePathname(); const { tabs } = useTabStore(); @@ -69,6 +88,7 @@ function Layout({ children }: { children: React.ReactNode }) { return (
+
diff --git a/apps/desktop-ui/src/components/snippet-manager/snippet-manager-tool.tsx b/apps/desktop-ui/src/components/snippet-manager/snippet-manager-tool.tsx index 248707f1..9644888d 100644 --- a/apps/desktop-ui/src/components/snippet-manager/snippet-manager-tool.tsx +++ b/apps/desktop-ui/src/components/snippet-manager/snippet-manager-tool.tsx @@ -834,7 +834,7 @@ export function SnippetManagerTool() { if (!selectedId && snippets.length === 0) { return ( - +
@@ -849,7 +849,7 @@ export function SnippetManagerTool() { if (isMobile) { return ( - +
{/* Mobile top bar */}
diff --git a/apps/desktop-ui/src/components/tools/tool-wrapper.tsx b/apps/desktop-ui/src/components/tools/tool-wrapper.tsx index 36320660..25efaf68 100644 --- a/apps/desktop-ui/src/components/tools/tool-wrapper.tsx +++ b/apps/desktop-ui/src/components/tools/tool-wrapper.tsx @@ -1,14 +1,11 @@ 'use client'; -import { ReactNode, useEffect } from 'react'; +import { ReactNode } from 'react'; import { Card, CardContent } from '@/components/ui/card'; -import { useToolUsage } from '@/hooks/use-tool-usage'; -import { usePathname } from 'next/navigation'; import { cn } from '@/lib/utils'; interface ToolWrapperProps { children: ReactNode; - toolId: string; className?: string; maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '4xl' | '5xl' | 'full'; /** Fill the app main column with minimal padding (full-height tools). */ @@ -27,24 +24,18 @@ const maxWidthClasses = { }; /** - * Base wrapper component for all tools - * Provides consistent layout and tracks tool usage + * Base wrapper component for all tools — consistent layout only. + * + * Usage history is recorded centrally in the app shell (`ToolUsageTracker` in + * components/sidebar/client-layout.tsx) so that every tool is counted, not just + * the handful that happen to render this wrapper. */ export function ToolWrapper({ children, - toolId, className = '', maxWidth = '4xl', fillMain = false, }: ToolWrapperProps) { - const pathname = usePathname(); - const { trackToolUsage } = useToolUsage(); - - // Track tool usage when component mounts - useEffect(() => { - trackToolUsage(toolId, pathname); - }, [toolId, pathname, trackToolUsage]); - return (
= {} -const memoryLocalStorage = { - getItem: (k: string) => (k in storage ? storage[k] : null), - setItem: (k: string, v: string) => { storage[k] = String(v) }, - removeItem: (k: string) => { delete storage[k] }, - clear: () => { for (const k of Object.keys(storage)) delete storage[k] }, - key: (i: number) => Object.keys(storage)[i] ?? null, - get length() { return Object.keys(storage).length }, -} -;(globalThis as unknown as { window: object }).window = {} -;(globalThis as unknown as { localStorage: typeof memoryLocalStorage }).localStorage = memoryLocalStorage - -beforeEach(() => memoryLocalStorage.clear()) - -describe("fetchDashboardAnalyticsSummary (local)", () => { - it("returns all-zero when nothing is stored", async () => { - const s = await fetchDashboardAnalyticsSummary() - expect(s.codeSnippets).toBe(0) - expect(s.apiClientHistoryEntries).toBe(0) - expect(s.tasks).toEqual({ total: 0, completed: 0, ongoing: 0, notStarted: 0 }) - }) +import { + EMPTY_ANALYTICS_SUMMARY, + fetchDashboardAnalyticsSummary, + normalizeAnalyticsSummary, +} from "../dashboard-analytics-api" +import { apiFetch } from "@/lib/desktop/api-fetch" + +jest.mock("@/lib/desktop/api-fetch", () => ({ apiFetch: jest.fn() })) + +const mockApiFetch = apiFetch as jest.MockedFunction + +const jsonResponse = (body: unknown, ok = true) => + ({ ok, json: async () => body }) as unknown as Response + +beforeEach(() => mockApiFetch.mockReset()) - it("counts snippets and api-client arrays from localStorage", async () => { - localStorage.setItem( - "snippet-manager-storage-v1", - JSON.stringify({ state: { snippets: [{ id: "a" }, { id: "b" }] }, version: 0 }), +describe("fetchDashboardAnalyticsSummary", () => { + it("reads live counts from the local router", async () => { + mockApiFetch.mockResolvedValue( + jsonResponse({ + notes: 4, + bookmarks: 2, + tasks: { total: 3, completed: 1, ongoing: 2, notStarted: 0 }, + codeSnippets: 7, + }), ) - localStorage.setItem("api-client-history", JSON.stringify([{}, {}, {}])) - localStorage.setItem("api-client-environments", JSON.stringify([{}])) + const s = await fetchDashboardAnalyticsSummary() - expect(s.codeSnippets).toBe(2) - expect(s.apiClientHistoryEntries).toBe(3) - expect(s.apiClientEnvironments).toBe(1) + + expect(mockApiFetch).toHaveBeenCalledWith("/api/backend/dashboard/analytics") + expect(s.notes).toBe(4) + expect(s.bookmarks).toBe(2) + expect(s.codeSnippets).toBe(7) + expect(s.tasks).toEqual({ total: 3, completed: 1, ongoing: 2, notStarted: 0 }) + // Fields the router did not send fall back to 0 rather than undefined. + expect(s.jsonFormatterDocuments).toBe(0) }) - it("survives malformed JSON without throwing", async () => { - localStorage.setItem("snippet-manager-storage-v1", "{not json") - localStorage.setItem("api-client-history", "42") - const s = await fetchDashboardAnalyticsSummary() - expect(s.codeSnippets).toBe(0) - expect(s.apiClientHistoryEntries).toBe(0) + it("returns zeros when there is no local router (web build)", async () => { + mockApiFetch.mockResolvedValue(jsonResponse({ detail: "Not found" }, false)) + await expect(fetchDashboardAnalyticsSummary()).resolves.toEqual(EMPTY_ANALYTICS_SUMMARY) + }) +}) + +describe("normalizeAnalyticsSummary", () => { + it("coerces junk payloads to zeros without throwing", () => { + expect(normalizeAnalyticsSummary(null)).toEqual(EMPTY_ANALYTICS_SUMMARY) + expect(normalizeAnalyticsSummary("nope")).toEqual(EMPTY_ANALYTICS_SUMMARY) + expect( + normalizeAnalyticsSummary({ notes: "12", bookmarks: NaN, tasks: { total: "x" } }), + ).toEqual(EMPTY_ANALYTICS_SUMMARY) }) }) diff --git a/apps/desktop-ui/src/lib/__tests__/format-bytes.test.ts b/apps/desktop-ui/src/lib/__tests__/format-bytes.test.ts new file mode 100644 index 00000000..5b5c6fe3 --- /dev/null +++ b/apps/desktop-ui/src/lib/__tests__/format-bytes.test.ts @@ -0,0 +1,22 @@ +import { formatBytes } from '@/lib/format-bytes' + +describe('formatBytes', () => { + it('scales through the units', () => { + expect(formatBytes(512)).toBe('512 B') + expect(formatBytes(1024)).toBe('1.0 KB') + expect(formatBytes(1536)).toBe('1.5 KB') + expect(formatBytes(5 * 1024 ** 2)).toBe('5.0 MB') + expect(formatBytes(3 * 1024 ** 3)).toBe('3.0 GB') + }) + + it('clamps past the largest unit instead of printing undefined', () => { + expect(formatBytes(1024 ** 7)).toBe('1048576.0 PB') + }) + + it('renders zero, negatives and junk as 0 B', () => { + expect(formatBytes(0)).toBe('0 B') + expect(formatBytes(-5)).toBe('0 B') + expect(formatBytes(NaN)).toBe('0 B') + expect(formatBytes(Infinity)).toBe('0 B') + }) +}) diff --git a/apps/desktop-ui/src/lib/__tests__/tool-usage-utils.test.ts b/apps/desktop-ui/src/lib/__tests__/tool-usage-utils.test.ts index 6c8d7f10..75c66924 100644 --- a/apps/desktop-ui/src/lib/__tests__/tool-usage-utils.test.ts +++ b/apps/desktop-ui/src/lib/__tests__/tool-usage-utils.test.ts @@ -2,6 +2,7 @@ import { appendEvent, deriveRecents, deriveCounts, + DEDUPE_WINDOW_MS, MAX_EVENTS, type ToolUsage, } from '@/lib/tool-usage-utils' @@ -13,13 +14,25 @@ const ev = (toolId: string, timestamp: number, url = `/app/${toolId}`): ToolUsag }) describe('appendEvent', () => { - it('prepends the new event without deduping', () => { + it('keeps repeat opens of the same tool once they are outside the dedupe window', () => { 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('drops a repeat of the newest tool inside the dedupe window', () => { + const log = [ev('a', 1000)] + const out = appendEvent(log, ev('a', 1000 + DEDUPE_WINDOW_MS - 1), 2000) + expect(out).toBe(log) + }) + + it('still records a different tool inside the dedupe window', () => { + const log = [ev('a', 1000)] + const out = appendEvent(log, ev('b', 1001), 2000) + expect(out.map((e) => e.toolId)).toEqual(['b', 'a']) + }) + 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 diff --git a/apps/desktop-ui/src/lib/dashboard-analytics-api.ts b/apps/desktop-ui/src/lib/dashboard-analytics-api.ts index a3d1d5bd..316792ad 100644 --- a/apps/desktop-ui/src/lib/dashboard-analytics-api.ts +++ b/apps/desktop-ui/src/lib/dashboard-analytics-api.ts @@ -1,3 +1,5 @@ +import { apiFetch } from '@/lib/desktop/api-fetch' + export type DashboardTaskStats = { total: number completed: number @@ -5,6 +7,19 @@ export type DashboardTaskStats = { notStarted: number } +/** + * Secure Files storage, measured from the object folder itself — so the count + * and the bytes on disk are real even while the vault is locked. `unlocked` + * says whether the file names behind those bytes are readable right now. + */ +export type DashboardFileStats = { + count: number + physicalBytes: number + lastModifiedAt: number + configured: boolean + unlocked: boolean +} + export type DashboardAnalyticsSummary = { passwordEntries: number bookmarks: number @@ -18,12 +33,28 @@ export type DashboardAnalyticsSummary = { apiClientHistoryEntries: number jsonFormatterDocuments: number codeSnippets: number + files: DashboardFileStats } function num(v: unknown): number { return typeof v === 'number' && Number.isFinite(v) ? v : 0 } +function bool(v: unknown): boolean { + return v === true +} + +function normalizeFiles(f: unknown): DashboardFileStats { + const obj = (f && typeof f === 'object' ? f : {}) as Record + return { + count: num(obj.count), + physicalBytes: num(obj.physicalBytes), + lastModifiedAt: num(obj.lastModifiedAt), + configured: bool(obj.configured), + unlocked: bool(obj.unlocked), + } +} + function normalizeTasks(t: unknown): DashboardTaskStats { const obj = (t && typeof t === 'object' ? t : {}) as Record return { @@ -34,49 +65,52 @@ function normalizeTasks(t: unknown): DashboardTaskStats { } } -function parse(key: string): unknown { - try { - if (typeof window === 'undefined') return null - const raw = localStorage.getItem(key) - return raw ? JSON.parse(raw) : null - } catch { - return null - } +export const EMPTY_ANALYTICS_SUMMARY: DashboardAnalyticsSummary = { + passwordEntries: 0, + bookmarks: 0, + bookmarkFolders: 0, + tasks: normalizeTasks(null), + projects: 0, + nosqlConnections: 0, + notes: 0, + apiClientCollections: 0, + apiClientEnvironments: 0, + apiClientHistoryEntries: 0, + jsonFormatterDocuments: 0, + codeSnippets: 0, + files: normalizeFiles(null), } -function arrayLen(key: string): number { - const v = parse(key) - return Array.isArray(v) ? v.length : 0 +/** Shape the local router returns; every field is validated before use. */ +export function normalizeAnalyticsSummary(raw: unknown): DashboardAnalyticsSummary { + const o = (raw && typeof raw === 'object' ? raw : {}) as Record + return { + passwordEntries: num(o.passwordEntries), + bookmarks: num(o.bookmarks), + bookmarkFolders: num(o.bookmarkFolders), + tasks: normalizeTasks(o.tasks), + projects: num(o.projects), + nosqlConnections: num(o.nosqlConnections), + notes: num(o.notes), + apiClientCollections: num(o.apiClientCollections), + apiClientEnvironments: num(o.apiClientEnvironments), + apiClientHistoryEntries: num(o.apiClientHistoryEntries), + jsonFormatterDocuments: num(o.jsonFormatterDocuments), + codeSnippets: num(o.codeSnippets), + files: normalizeFiles(o.files), + } } /** - * Local, offline analytics — counts data straight from the browser's own - * persistence instead of a backend. Only tools that actually persist locally - * report real numbers today: code snippets (zustand persist) and the API - * client (collections / environments / history in localStorage). + * Live counts from the local store (SQLCipher via the Rust router), computed + * there with one grouped query — the UI never downloads rows just to count + * them, so this stays cheap enough to re-run on every dashboard visit. * - * ponytail: passwords, bookmarks, tasks, notes, projects, nosql and JSON docs - * still live in the (encrypted) backend vault / MongoDB, so they read 0 until - * those tools move to local storage. The panel renders 0s gracefully. + * Outside the desktop app there is no local router, so the request 404s and the + * panel renders zeros rather than an error. */ export async function fetchDashboardAnalyticsSummary(): Promise { - const snippetStore = parse('snippet-manager-storage-v1') as - | { state?: { snippets?: unknown } } - | null - const snippets = snippetStore?.state?.snippets - - return { - passwordEntries: 0, - bookmarks: 0, - bookmarkFolders: 0, - tasks: normalizeTasks(null), - projects: 0, - nosqlConnections: 0, - notes: 0, - apiClientCollections: arrayLen('api-client-collections'), - apiClientEnvironments: arrayLen('api-client-environments'), - apiClientHistoryEntries: arrayLen('api-client-history'), - jsonFormatterDocuments: 0, - codeSnippets: Array.isArray(snippets) ? snippets.length : 0, - } + const res = await apiFetch('/api/backend/dashboard/analytics') + if (!res.ok) return EMPTY_ANALYTICS_SUMMARY + return normalizeAnalyticsSummary(await res.json()) } diff --git a/apps/desktop-ui/src/lib/format-bytes.ts b/apps/desktop-ui/src/lib/format-bytes.ts new file mode 100644 index 00000000..e1b66b2b --- /dev/null +++ b/apps/desktop-ui/src/lib/format-bytes.ts @@ -0,0 +1,13 @@ +const UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] as const + +/** + * Human-readable byte size (1024-based, one decimal above bytes). + * + * The unit index is clamped, so a size past the last unit renders as "… PB" + * instead of "NaN undefined"; junk and negatives render as "0 B". + */ +export function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return '0 B' + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), UNITS.length - 1) + return `${(bytes / 1024 ** i).toFixed(i === 0 ? 0 : 1)} ${UNITS[i]}` +} diff --git a/apps/desktop-ui/src/lib/tool-usage-utils.ts b/apps/desktop-ui/src/lib/tool-usage-utils.ts index b245b8c0..3dba22b3 100644 --- a/apps/desktop-ui/src/lib/tool-usage-utils.ts +++ b/apps/desktop-ui/src/lib/tool-usage-utils.ts @@ -9,12 +9,30 @@ 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. */ +/** Repeat opens of the same tool inside this window count once. */ +export const DEDUPE_WINDOW_MS = 1000 + +/** + * Prepend `event`, then prune by age (relative to `now`) and total count. + * + * A repeat of the newest event's tool within `DEDUPE_WINDOW_MS` is dropped: + * React StrictMode mounts effects twice in development, and that would + * otherwise double every launch count. + */ export function appendEvent( log: ToolUsage[], event: ToolUsage, now: number = Date.now(), ): ToolUsage[] { + const newest = log[0] + if ( + newest && + newest.toolId === event.toolId && + event.timestamp - newest.timestamp < DEDUPE_WINDOW_MS + ) { + return log + } + const cutoff = now - MAX_AGE_MS return [event, ...log].filter((e) => e.timestamp >= cutoff).slice(0, MAX_EVENTS) } diff --git a/apps/desktop/src-tauri/src/router/analytics.rs b/apps/desktop/src-tauri/src/router/analytics.rs new file mode 100644 index 00000000..f7080fe2 --- /dev/null +++ b/apps/desktop/src-tauri/src/router/analytics.rs @@ -0,0 +1,80 @@ +//! Local mirror of `/api/v1/dashboard/analytics`. +//! +//! The dashboard needs one number per tool, not the rows themselves. Counting +//! them here is a single grouped query over `entries`; the alternative — the UI +//! listing every note, bookmark and API-client history entry just to read +//! `.length` — moves megabytes across the bridge to produce a dozen integers. + +use std::collections::HashMap; + +use rusqlite::params; +use serde_json::{json, Value}; + +use crate::error::Result; +use crate::router::entries::{active_workspace, list_docs}; +use crate::router::secure_files; +use crate::router::ApiResponse; +use crate::state::AppState; + +pub fn handle(state: &AppState, method: &str, rest: &str) -> Result { + if method != "GET" || !rest.is_empty() { + return Ok(ApiResponse::detail(404, "Not found")); + } + + // Secure Files lives on disk, not in `entries`, and takes the db lock of its + // own to read its config — so it runs before this handler takes that lock. + let files = secure_files::storage_stats(state); + + let db = state.db.lock().unwrap(); + let ws = active_workspace(&db); + + let mut counts: HashMap = HashMap::new(); + { + let mut stmt = db.prepare( + "SELECT tool_kind, COUNT(*) FROM entries + WHERE workspace_id = ?1 AND deleted_at IS NULL + GROUP BY tool_kind", + )?; + let rows = stmt.query_map(params![ws], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)) + })?; + for row in rows { + let (kind, n) = row?; + counts.insert(kind, n); + } + } + let count = |kind: &str| counts.get(kind).copied().unwrap_or(0); + + // Tasks: the grouped count above includes archived rows. The dashboard + // shows the same active-only breakdown as `/tasks/stats`. + let tasks = list_docs(&db, "tasks", &ws)?; + let active: Vec<&Value> = tasks + .iter() + .filter(|d| !d["archived"].as_bool().unwrap_or(false)) + .collect(); + let by_status = + |s: &str| active.iter().filter(|d| d["status"].as_str() == Some(s)).count(); + + ApiResponse::ok(&json!({ + "passwordEntries": count("password_entries"), + "bookmarks": count("bookmarks"), + "bookmarkFolders": count("bookmark_folders"), + "tasks": { + "total": active.len(), + "completed": by_status("completed"), + "ongoing": by_status("ongoing"), + "notStarted": by_status("not-started"), + }, + "projects": count("projects"), + "notes": count("notes"), + // "NoSQL connections" predates the unified data explorer; rows written + // by either generation of the tool belong to the same chip. + "nosqlConnections": count("data_explorer_connections") + count("nosql_connections"), + "apiClientCollections": count("api_client_collections"), + "apiClientEnvironments": count("api_client_environments"), + "apiClientHistoryEntries": count("api_client_history"), + "jsonFormatterDocuments": count("json_formatter_documents"), + "codeSnippets": count("code_snippets"), + "files": files, + })) +} diff --git a/apps/desktop/src-tauri/src/router/mod.rs b/apps/desktop/src-tauri/src/router/mod.rs index 1af4a32d..f58e9db1 100644 --- a/apps/desktop/src-tauri/src/router/mod.rs +++ b/apps/desktop/src-tauri/src/router/mod.rs @@ -1,3 +1,4 @@ +pub mod analytics; pub mod api_client; pub mod backup; pub mod backup_codes; @@ -102,6 +103,9 @@ pub fn route(state: &AppState, method: &str, full_path: &str, body: Option<&str> } } + if let Some(rest) = rel.strip_prefix("/dashboard/analytics") { + return analytics::handle(state, method, rest); + } if let Some(rest) = rel.strip_prefix("/code-snippets") { return snippets::handle(state, method, rest, body); } @@ -461,6 +465,49 @@ mod tests { assert_eq!(r.status, 422); } + #[test] + fn dashboard_analytics_counts_live_rows() { + let state = AppState::in_memory(); + let empty = body_json(&route(&state, "GET", "/api/v1/dashboard/analytics", None).unwrap()); + assert_eq!(empty["notes"], 0); + assert_eq!(empty["tasks"]["total"], 0); + + route(&state, "POST", "/api/v1/notes", Some(r#"{"title":"n1"}"#)).unwrap(); + route(&state, "POST", "/api/v1/notes", Some(r#"{"title":"n2"}"#)).unwrap(); + route(&state, "POST", "/api/v1/bookmarks", Some(r#"{"url":"https://a.dev"}"#)).unwrap(); + route(&state, "POST", "/api/v1/json-formatter/documents", Some(r#"{"title":"d"}"#)).unwrap(); + let done = body_json( + &route(&state, "POST", "/api/v1/tasks", Some(r#"{"title":"t1","status":"completed"}"#)) + .unwrap(), + ); + route(&state, "POST", "/api/v1/tasks", Some(r#"{"title":"t2","status":"ongoing"}"#)).unwrap(); + + let v = body_json(&route(&state, "GET", "/api/v1/dashboard/analytics", None).unwrap()); + assert_eq!(v["notes"], 2); + assert_eq!(v["bookmarks"], 1); + assert_eq!(v["jsonFormatterDocuments"], 1); + assert_eq!(v["tasks"]["total"], 2); + assert_eq!(v["tasks"]["completed"], 1); + assert_eq!(v["tasks"]["ongoing"], 1); + + // Deleted rows drop out of the counts. + let note = body_json(&route(&state, "GET", "/api/v1/notes", None).unwrap())[0]["id"] + .as_str() + .unwrap() + .to_string(); + route(&state, "DELETE", &format!("/api/v1/notes/{note}"), None).unwrap(); + let v = body_json(&route(&state, "GET", "/api/v1/dashboard/analytics", None).unwrap()); + assert_eq!(v["notes"], 1); + + // Archived tasks leave the active breakdown, matching /tasks/stats. + let id = done["id"].as_str().unwrap(); + route(&state, "PATCH", &format!("/api/v1/tasks/{id}"), Some(r#"{"archived":true}"#)).unwrap(); + let v = body_json(&route(&state, "GET", "/api/v1/dashboard/analytics", None).unwrap()); + let stats = body_json(&route(&state, "GET", "/api/v1/tasks/stats", None).unwrap()); + assert_eq!(v["tasks"], stats); + assert_eq!(v["tasks"]["total"], 1); + } + #[test] fn json_formatter_documents_crud_and_paginate() { let state = AppState::in_memory(); diff --git a/apps/desktop/src-tauri/src/router/secure_files/mod.rs b/apps/desktop/src-tauri/src/router/secure_files/mod.rs index 572183e9..82da3632 100644 --- a/apps/desktop/src-tauri/src/router/secure_files/mod.rs +++ b/apps/desktop/src-tauri/src/router/secure_files/mod.rs @@ -355,6 +355,48 @@ fn encrypt_and_store(c: &Ctx, meta: &FileMeta, plaintext: &[u8], durable: bool) Ok(Entry { id, meta: meta.clone(), physical: bytes.len() as u64 }) } +/// Storage figures for the dashboard, derived from the folder listing alone. +/// +/// Nothing is decrypted, so these are available with the vault locked — the +/// object count and the bytes the folder occupies are properties of the +/// container files, not of their contents. Plaintext sizes and names stay +/// behind the KEK; `unlocked` tells the dashboard which of the two it is +/// showing. +pub fn storage_stats(state: &AppState) -> Value { + let unlocked = state.kek.lock().unwrap().is_some(); + let dir = load_cfg(&state.db.lock().unwrap()) + .ok() + .flatten() + .and_then(|c| c.dir); + + let (mut count, mut physical, mut last_modified) = (0u64, 0u64, 0i64); + if let Some(entries) = dir.as_deref().map(Path::new).and_then(|d| fs::read_dir(d).ok()) { + for ent in entries.flatten() { + let path = ent.path(); + let is_object = path + .file_name() + .and_then(|n| n.to_str()) + .and_then(|n| n.strip_suffix(&format!(".{EXT}"))) + .is_some_and(|id| valid_id(id).is_ok()); + if !is_object { + continue; // stray file, or a `.mydt.tmp` left by an interrupted write + } + let Ok(md) = ent.metadata() else { continue }; + count += 1; + physical += md.len(); + last_modified = last_modified.max(mtime_ms(&md)); + } + } + + json!({ + "count": count, + "physicalBytes": physical, + "lastModifiedAt": last_modified, + "configured": dir.is_some(), + "unlocked": unlocked, + }) +} + fn mtime_ms(md: &fs::Metadata) -> i64 { md.modified() .ok() @@ -767,6 +809,40 @@ mod tests { v } + #[test] + fn storage_stats_reads_the_folder_without_the_kek() { + let unset = storage_stats(&AppState::in_memory()); + assert_eq!(unset["count"], 0); + assert_eq!(unset["configured"], false); + assert_eq!(unset["unlocked"], false); + + let tmp = Tmp::new("stats"); + let state = unlocked(&tmp); + fs::write(tmp.path("a.txt"), b"hello").unwrap(); + fs::write(tmp.path("b.txt"), vec![0u8; 4096]).unwrap(); + import(&state, &[tmp.s("a.txt"), tmp.s("b.txt")], ""); + // Junk in the storage folder must not be counted as an object. + fs::write(tmp.path("store/notes.txt"), b"stray").unwrap(); + fs::write(tmp.path("store/deadbeef.mydt.tmp"), b"interrupted write").unwrap(); + + let s = storage_stats(&state); + assert_eq!(s["count"], 2); + assert_eq!(s["configured"], true); + assert_eq!(s["unlocked"], true); + assert!(s["lastModifiedAt"].as_i64().unwrap() > 0); + // Containers hold the plaintext plus per-object overhead. + let physical = s["physicalBytes"].as_u64().unwrap(); + assert!(physical > 5 + 4096, "physical {physical} should exceed the plaintext bytes"); + assert_eq!(physical, list(&state)["totals"]["physical"].as_u64().unwrap()); + + // Same numbers with the vault locked — nothing here is decrypted. + *state.kek.lock().unwrap() = None; + let locked = storage_stats(&state); + assert_eq!(locked["count"], 2); + assert_eq!(locked["physicalBytes"], physical); + assert_eq!(locked["unlocked"], false); + } + #[test] fn locked_and_unconfigured_states() { let state = AppState::in_memory();