Skip to content

Commit 12ea523

Browse files
feat(notes): add dashboard with graph navigation and activity insights (#728)
* feat(notes): add dashboard and force graph * fix(notes): resolve force graph node ids * refactor(notes): improve graph visualization * feat(notes): restyle activity heatmap * fix(notes): fit activity heatmap to panel * fix(notes): adapt dashboard to panel width * fix(notes): use custom dashboard scrollbar * fix(notes): tighten dashboard widget layout * fix(notes): keep dashboard pairs on one row * fix(notes): let heatmap fill available width * fix(notes): keep stat cards in 2x2 grid * refactor(notes): remove tag cloud widget * refactor(notes): make graph interaction more like obsidian * fix(notes): make graph preview interactive * fix(notes): preserve circular graph nodes on resize * refactor(notes): add local graph focus behavior * fix(notes): keep graph labels readable * feat(notes): make graph widget fully interactive * refactor(notes): reuse shared graph scene * fix(notes): load graph when opening full view * fix(notes): align graph view header with dashboard * refactor(ui): reuse shared page header * refactor(notes): remove redundant graph zoom buttons * refactor(notes): move graph widget actions into canvas * refactor(notes): rework graph preview viewport math * feat(notes): add themed graph and heatmap palettes * fix(notes): persist selected notes route * chore: style * fix(notes): clear graph hover on leave * fix(notes): improve graph label placement * fix(notes): preserve graph node position on click * fix(notes): refresh dashboard on every entry * refactor(notes): move dashboard entry into sidebar header * feat(notes): add heatmap activity tooltips * fix(notes): align graph viewport centering * refactor(notes): remove manual dashboard refresh actions * refactor(notes): remove unused graph navigation button from dashboard header * refactor(notes): clarify dashboard notes limits * fix(notes): size graph nodes by connectivity * refactor(notes): align dashboard widget settings labels * refactor(notes): align graph node colors with connectivity * fix(notes): clip graph edges to node circles * fix(notes): soften light graph neighbor states * fix(notes): tune graph neighbor accent states * feat(theme): add semantic canvas surface token * refactor(notes): simplify dashboard card surfaces * fix(notes): keep route targets in navigation history * fix(notes): restore UI scroll from navigation history * fix(notes): keep single folder selection in sync * fix(notes): reopen selected folder from dashboard
1 parent 5f7b188 commit 12ea523

88 files changed

Lines changed: 6560 additions & 282 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
dist
22
build/main
33
build/renderer
4+
build/shared
5+
build/src
6+
build/package.json
47
scripts/build-sponsored.sh
58

69
node_modules
@@ -13,4 +16,3 @@ auto-imports.d.ts
1316
docs/superpowers
1417
docs/website/.vitepress/cache
1518
docs/website/.vitepress/dist
16-

AGENTS.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ massCode uses a **Spaces** system to organize different functional areas:
135135
- **Component Usage (STRICT):**
136136
- **NEVER** reimplement basic UI elements (buttons, inputs, checkboxes, etc.).
137137
- **ALWAYS** use existing components from `src/renderer/components/ui/`.
138+
- **Typography:** Use `UiText` for text rendering by default. Do not hand-roll text styles with `text-*`, `font-*`, or `text-muted-foreground` when an appropriate `UiText` variant fits. If `UiText` lacks a needed size/style, compose it with extra classes instead of replacing it with raw HTML.
138139
- **Missing Elements:** If a required UI element does not exist, create it in `src/renderer/components/ui/` first, following established patterns (Tailwind, cva, cn), then use it.
139140
- **Naming:** They are auto-imported with a `Ui` prefix (e.g., `<UiInput />`, `<UiActionButton />`, `<UiText />`).
140141

@@ -149,6 +150,14 @@ Split a component when it exceeds ~300 lines or has more than 3 unrelated respon
149150

150151
Keep no logic in `<template>` more complex than a ternary operator.
151152

153+
**Feature Subdirectories:**
154+
155+
- When a domain area grows into a clear subsystem (for example `notes/dashboard`), group its related components and local helpers into a dedicated subdirectory instead of keeping everything flat in the parent folder.
156+
- This applies not only to `.vue` components, but also to local `ts/js` helpers, tests, fixtures, styles, and other files that belong only to that subsystem.
157+
- Inside such a subdirectory, do **not** repeat the full parent prefix in file names. Prefer `dashboard/Dashboard.vue`, `dashboard/Header.vue`, `dashboard/Section.vue` over `dashboard/NotesDashboardHeader.vue`.
158+
- This project uses component auto-import with directory namespaces, so `notes/dashboard/Dashboard.vue` resolves to `NotesDashboard`, `notes/dashboard/Header.vue` resolves to `NotesDashboardHeader`, etc.
159+
- Keep only files that are truly local to that subsystem in the subdirectory. Shared files used by multiple slices should remain at the higher level or be renamed into a more general shared helper.
160+
152161
## 8. Development Workflow & Commands
153162

154163
**Linting (CRITICAL):**

components.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"aliases": {
1414
"components": "@/components",
1515
"utils": "@/utils",
16-
"ui": "@/components/ui/shadcn2",
16+
"ui": "@/components/ui/shadcn",
1717
"lib": "@/utils",
1818
"composables": "~/composables"
1919
},

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@
6363
"@lezer/highlight": "^1.2.3",
6464
"@lezer/markdown": "^1.6.3",
6565
"@sinclair/typebox": "^0.34.41",
66+
"@types/d3-force": "^3.0.10",
67+
"@types/d3-scale": "^4.0.9",
6668
"@vue-flow/background": "^1.3.2",
6769
"@vue-flow/controls": "^1.1.3",
6870
"@vue-flow/core": "^1.46.5",
@@ -78,6 +80,8 @@
7880
"codemirror-textmate": "^1.1.0",
7981
"color-name-list": "^11.22.0",
8082
"crypto-js": "^4.2.0",
83+
"d3-force": "^3.0.0",
84+
"d3-scale": "^4.0.2",
8185
"date-fns": "^4.1.0",
8286
"dom-to-image": "^2.6.0",
8387
"electron-store": "^8.2.0",

pnpm-lock.yaml

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import Elysia, { t } from 'elysia'
2+
3+
const notesDashboardResponse = t.Object({
4+
stats: t.Object({
5+
notesCount: t.Number(),
6+
wordsCount: t.Number(),
7+
foldersCount: t.Number(),
8+
tagsCount: t.Number(),
9+
}),
10+
activity: t.Object({
11+
days: t.Record(t.String(), t.Number()),
12+
notesUpdatedToday: t.Number(),
13+
notesUpdatedLast7Days: t.Number(),
14+
}),
15+
recent: t.Array(
16+
t.Object({
17+
id: t.Number(),
18+
name: t.String(),
19+
folder: t.Nullable(
20+
t.Object({
21+
id: t.Number(),
22+
name: t.String(),
23+
}),
24+
),
25+
updatedAt: t.Number(),
26+
}),
27+
),
28+
topLinked: t.Array(
29+
t.Object({
30+
id: t.Number(),
31+
name: t.String(),
32+
incomingLinksCount: t.Number(),
33+
}),
34+
),
35+
graphPreview: t.Object({
36+
nodes: t.Array(
37+
t.Object({
38+
id: t.Number(),
39+
name: t.String(),
40+
folderId: t.Nullable(t.Number()),
41+
incomingLinksCount: t.Number(),
42+
}),
43+
),
44+
edges: t.Array(
45+
t.Object({
46+
source: t.Number(),
47+
target: t.Number(),
48+
}),
49+
),
50+
}),
51+
})
52+
53+
const notesGraphResponse = t.Object({
54+
nodes: t.Array(
55+
t.Object({
56+
id: t.Number(),
57+
name: t.String(),
58+
folderId: t.Nullable(t.Number()),
59+
tagIds: t.Array(t.Number()),
60+
incomingLinksCount: t.Number(),
61+
}),
62+
),
63+
edges: t.Array(
64+
t.Object({
65+
source: t.Number(),
66+
target: t.Number(),
67+
}),
68+
),
69+
})
70+
71+
export const notesDashboardDTO = new Elysia().model({
72+
notesDashboardResponse,
73+
notesGraphResponse,
74+
})
75+
76+
export type NotesDashboardResponse = typeof notesDashboardResponse.static
77+
export type NotesGraphResponse = typeof notesGraphResponse.static

src/main/api/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import folders from './routes/folders'
88
import noteFolders from './routes/note-folders'
99
import noteTags from './routes/note-tags'
1010
import notes from './routes/notes'
11+
import notesDashboard from './routes/notes-dashboard'
12+
import notesGraph from './routes/notes-graph'
1113
import snippets from './routes/snippets'
1214
import system from './routes/system'
1315
import tags from './routes/tags'
@@ -36,6 +38,8 @@ export async function initApi() {
3638
.use(folders)
3739
.use(system)
3840
.use(tags)
41+
.use(notesDashboard)
42+
.use(notesGraph)
3943
.use(notes)
4044
.use(noteFolders)
4145
.use(noteTags)
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import type { NotesDashboardResponse } from '../dto/notes-dashboard'
2+
import Elysia from 'elysia'
3+
import { useNotesStorage, useStorage } from '../../storage'
4+
import { buildNotesGraph } from '../../storage/providers/markdown/notes/runtime/graph'
5+
import { notesDashboardDTO } from '../dto/notes-dashboard'
6+
7+
const RECENT_NOTES_LIMIT = 6
8+
const TOP_LINKED_NOTES_LIMIT = 6
9+
10+
function countWords(content: string): number {
11+
if (!content.trim()) {
12+
return 0
13+
}
14+
15+
return content.trim().split(/\s+/).filter(Boolean).length
16+
}
17+
18+
function getDayKey(timestamp: number): string {
19+
const date = new Date(timestamp)
20+
21+
return [
22+
date.getFullYear(),
23+
String(date.getMonth() + 1).padStart(2, '0'),
24+
String(date.getDate()).padStart(2, '0'),
25+
].join('-')
26+
}
27+
28+
function buildActivity(updatedAtList: number[]) {
29+
const days: Record<string, number> = {}
30+
31+
updatedAtList.forEach((updatedAt) => {
32+
const dayKey = getDayKey(updatedAt)
33+
days[dayKey] = (days[dayKey] ?? 0) + 1
34+
})
35+
36+
const today = new Date()
37+
today.setHours(0, 0, 0, 0)
38+
const todayTime = today.getTime()
39+
const todayStart = todayTime
40+
const sevenDaysAgo = todayTime - 6 * 24 * 60 * 60 * 1000
41+
42+
return {
43+
days,
44+
notesUpdatedLast7Days: updatedAtList.filter(
45+
updatedAt => updatedAt >= sevenDaysAgo,
46+
).length,
47+
notesUpdatedToday: updatedAtList.filter(
48+
updatedAt => updatedAt >= todayStart,
49+
).length,
50+
}
51+
}
52+
53+
const app = new Elysia({ prefix: '/notes' })
54+
55+
app.use(notesDashboardDTO).get(
56+
'/dashboard',
57+
() => {
58+
const notesStorage = useNotesStorage()
59+
const storage = useStorage()
60+
const notes = notesStorage.notes.getNotes({ isDeleted: 0 })
61+
const folders = notesStorage.folders.getFolders()
62+
const tags = notesStorage.tags.getTags()
63+
const snippets = storage.snippets.getSnippets({ isDeleted: 0 })
64+
65+
const graph = buildNotesGraph({
66+
notes: notes.map(note => ({
67+
content: note.content,
68+
createdAt: note.createdAt,
69+
description: note.description,
70+
filePath: '',
71+
folderId: note.folder?.id ?? null,
72+
id: note.id,
73+
isDeleted: note.isDeleted,
74+
isFavorites: note.isFavorites,
75+
name: note.name,
76+
tags: note.tags.map(tag => tag.id),
77+
updatedAt: note.updatedAt,
78+
})),
79+
snippets: snippets.map(snippet => ({
80+
id: snippet.id,
81+
name: snippet.name,
82+
})),
83+
})
84+
85+
return {
86+
activity: buildActivity(notes.map(note => note.updatedAt)),
87+
graphPreview: {
88+
edges: graph.edges,
89+
nodes: graph.nodes.map(node => ({
90+
id: node.id,
91+
name: node.name,
92+
folderId: node.folderId,
93+
incomingLinksCount: node.incomingLinksCount,
94+
})),
95+
},
96+
recent: [...notes]
97+
.sort((left, right) => right.updatedAt - left.updatedAt)
98+
.slice(0, RECENT_NOTES_LIMIT)
99+
.map(note => ({
100+
id: note.id,
101+
name: note.name,
102+
folder: note.folder,
103+
updatedAt: note.updatedAt,
104+
})),
105+
stats: {
106+
foldersCount: folders.length,
107+
notesCount: notes.length,
108+
tagsCount: tags.length,
109+
wordsCount: notes.reduce(
110+
(total, note) => total + countWords(note.content),
111+
0,
112+
),
113+
},
114+
topLinked: [...graph.nodes]
115+
.filter(node => node.incomingLinksCount > 0)
116+
.sort(
117+
(left, right) => right.incomingLinksCount - left.incomingLinksCount,
118+
)
119+
.slice(0, TOP_LINKED_NOTES_LIMIT)
120+
.map(node => ({
121+
id: node.id,
122+
incomingLinksCount: node.incomingLinksCount,
123+
name: node.name,
124+
})),
125+
} as NotesDashboardResponse
126+
},
127+
{
128+
response: 'notesDashboardResponse',
129+
detail: {
130+
tags: ['Notes Dashboard'],
131+
},
132+
},
133+
)
134+
135+
export default app

src/main/api/routes/notes-graph.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { NotesGraphResponse } from '../dto/notes-dashboard'
2+
import Elysia from 'elysia'
3+
import { useNotesStorage, useStorage } from '../../storage'
4+
import { buildNotesGraph } from '../../storage/providers/markdown/notes/runtime/graph'
5+
import { notesDashboardDTO } from '../dto/notes-dashboard'
6+
7+
const app = new Elysia({ prefix: '/notes' })
8+
9+
app.use(notesDashboardDTO).get(
10+
'/graph',
11+
() => {
12+
const notesStorage = useNotesStorage()
13+
const storage = useStorage()
14+
const notes = notesStorage.notes.getNotes({ isDeleted: 0 })
15+
const snippets = storage.snippets.getSnippets({ isDeleted: 0 })
16+
17+
return buildNotesGraph({
18+
notes: notes.map(note => ({
19+
content: note.content,
20+
createdAt: note.createdAt,
21+
description: note.description,
22+
filePath: '',
23+
folderId: note.folder?.id ?? null,
24+
id: note.id,
25+
isDeleted: note.isDeleted,
26+
isFavorites: note.isFavorites,
27+
name: note.name,
28+
tags: note.tags.map(tag => tag.id),
29+
updatedAt: note.updatedAt,
30+
})),
31+
snippets: snippets.map(snippet => ({
32+
id: snippet.id,
33+
name: snippet.name,
34+
})),
35+
}) as NotesGraphResponse
36+
},
37+
{
38+
response: 'notesGraphResponse',
39+
detail: {
40+
tags: ['Notes Dashboard'],
41+
},
42+
},
43+
)
44+
45+
export default app

0 commit comments

Comments
 (0)