From 916807bb5c38cf5bdf2a8f7ffb778fb13ca0a56a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Tue, 28 Jul 2026 11:43:40 +0200 Subject: [PATCH] aggregate data --- src/api/mod.rs | 36 ++++++++++++++++++++++++------- src/db/mod.rs | 48 ++++++++++++++++++++++++++++++++++++++++++ static/css/styles.css | 2 +- static/js/workspace.js | 19 +++++++++++++++-- 4 files changed, 94 insertions(+), 11 deletions(-) diff --git a/src/api/mod.rs b/src/api/mod.rs index 581c262..3bc77a1 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -135,6 +135,10 @@ pub struct NoteListItem { url: String, protected: bool, created_by: Option, + participant_count: i64, + file_count: i64, + file_size_bytes: i64, + revision_count: i64, } #[derive(Debug, Serialize)] @@ -224,17 +228,29 @@ pub async fn open_workspace( bearer_token(&headers), ) .await?; + let stats = db::list_note_stats(&state.db, workspace.id) + .await? + .into_iter() + .map(|stats| (stats.note_id, stats)) + .collect::>(); let notes = db::list_notes(&state.db, workspace.id) .await? .into_iter() - .map(|note| NoteListItem { - url: format!("/w/{}/n/{}", workspace.slug, note.slug), - slug: note.slug, - title: note.title, - created_at: db::normalize_timestamp(¬e.created_at), - updated_at: db::normalize_timestamp(¬e.updated_at), - protected: note.protected, - created_by: note.created_by, + .map(|note| { + let stats = stats.get(¬e.id); + NoteListItem { + url: format!("/w/{}/n/{}", workspace.slug, note.slug), + slug: note.slug, + title: note.title, + created_at: db::normalize_timestamp(¬e.created_at), + updated_at: db::normalize_timestamp(¬e.updated_at), + protected: note.protected, + created_by: note.created_by, + participant_count: stats.map_or(0, |value| value.participant_count), + file_count: stats.map_or(0, |value| value.file_count), + file_size_bytes: stats.map_or(0, |value| value.file_size_bytes), + revision_count: stats.map_or(0, |value| value.revision_count), + } }) .collect(); @@ -307,6 +323,10 @@ pub async fn create_note( updated_at: db::normalize_timestamp(¬e.updated_at), protected: note.protected, created_by: note.created_by, + participant_count: 0, + file_count: 0, + file_size_bytes: 0, + revision_count: 0, }), )) } diff --git a/src/db/mod.rs b/src/db/mod.rs index 1629d0d..226fe39 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -99,6 +99,15 @@ pub struct Revision { pub owner_map: String, } +#[derive(Debug, Clone)] +pub struct NoteStats { + pub note_id: i64, + pub participant_count: i64, + pub file_count: i64, + pub file_size_bytes: i64, + pub revision_count: i64, +} + pub async fn find_workspace(pool: &Database, slug: &str) -> Result, sqlx::Error> { sqlx::query_as::<_, Workspace>(queries::get(pool.kind(), queries::Q001)) .bind(slug) @@ -164,6 +173,45 @@ pub async fn list_notes(pool: &Database, workspace_id: i64) -> Result, .await } +pub async fn list_note_stats( + pool: &Database, + workspace_id: i64, +) -> Result, sqlx::Error> { + let placeholder = match pool.kind() { + DatabaseKind::Postgres => "$1", + DatabaseKind::Sqlite | DatabaseKind::MySql => "?", + }; + let query = format!( + "SELECT n.id AS note_id, \ + ((SELECT COUNT(DISTINCT r.author) FROM note_revisions r \ + WHERE r.note_id = n.id AND r.author IS NOT NULL AND TRIM(r.author) <> '') + \ + CASE WHEN n.created_by IS NOT NULL AND TRIM(n.created_by) <> '' AND NOT EXISTS (\ + SELECT 1 FROM note_revisions r WHERE r.note_id = n.id AND r.author = n.created_by\ + ) THEN 1 ELSE 0 END) AS participant_count, \ + (SELECT COUNT(*) FROM note_files f WHERE f.note_id = n.id) AS file_count, \ + (SELECT COALESCE(SUM(f.size_bytes), 0) FROM note_files f WHERE f.note_id = n.id) AS file_size_bytes, \ + (SELECT COUNT(*) FROM note_revisions r WHERE r.note_id = n.id) AS revision_count \ + FROM notes n WHERE n.workspace_id = {placeholder}" + ); + + let rows = sqlx::query(&query) + .bind(workspace_id) + .fetch_all(pool.pool()) + .await?; + + rows.into_iter() + .map(|row| { + Ok(NoteStats { + note_id: row.try_get("note_id")?, + participant_count: row.try_get("participant_count")?, + file_count: row.try_get("file_count")?, + file_size_bytes: row.try_get("file_size_bytes")?, + revision_count: row.try_get("revision_count")?, + }) + }) + .collect() +} + pub async fn find_note( pool: &Database, workspace_id: i64, diff --git a/static/css/styles.css b/static/css/styles.css index 84a4ef9..f81afcb 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -1998,7 +1998,7 @@ dialog::backdrop { .notes-table table { width: 100%; - min-width: 680px; + min-width: 980px; border-collapse: collapse; } diff --git a/static/js/workspace.js b/static/js/workspace.js index 6f77628..d547e2a 100644 --- a/static/js/workspace.js +++ b/static/js/workspace.js @@ -25,6 +25,18 @@ let notesView = localStorage.getItem(notesViewKey) === "table" ? "table" : "grid let notesCache = []; function escapeHtml(v) { const e = document.createElement("div"); e.textContent = v; return e.innerHTML; } +function formatBytes(value) { + const bytes = Math.max(0, Number(value) || 0); + if (bytes < 1024) return `${bytes} B`; + const units = ["KB", "MB", "GB", "TB"]; + let amount = bytes; + let unit = -1; + do { amount /= 1024; unit++; } while (amount >= 1024 && unit < units.length - 1); + return `${amount >= 10 ? amount.toFixed(0) : amount.toFixed(1)} ${units[unit]}`; +} +function noteStats(note) { + return `Participants: ${Number(note.participant_count) || 0}Files: ${Number(note.file_count) || 0} (${formatBytes(note.file_size_bytes)})Revisions: ${Number(note.revision_count) || 0}`; +} function formatDate(value) { if (value == null || value === "") return "—"; let raw = String(value).trim(); @@ -58,10 +70,13 @@ function renderNotes(notes = notesCache) { return; } if (notesView === "table") { - notesList.innerHTML = `
${notes.map(note => ` + notesList.innerHTML = `
NameCreated byStatusUpdatedActions
${notes.map(note => ` + + + @@ -72,7 +87,7 @@ function renderNotes(notes = notesCache) { `).join("");
NameCreated byParticipantsFilesRevisionsStatusUpdatedActions
${escapeHtml(note.title)} ${escapeHtml(note.created_by || "Unknown")}${Number(note.participant_count) || 0}${Number(note.file_count) || 0} (${formatBytes(note.file_size_bytes)})${Number(note.revision_count) || 0} ${note.protected ? 'Protected' : 'Unprotected'} ${formatDate(note.updated_at)} ${deleteButton(note, true)}