From 541440c3573434fe41727e028ba5e570762dc71d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Sat, 2 May 2026 19:08:11 +0100 Subject: [PATCH] feat(web): unread badge on dashboard mesh cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Universe page aggregates unread topic_message rows per mesh for the viewing user. Counts messages newer than topic_member.last_read_at (or all messages if the viewer never opened the topic) and excludes anything the viewer authored. One JOIN-grouped query, not N+1. Mesh card surfaces the count as a clay-rounded badge to the left of the role chip — matches the per-topic badge style on the mesh detail page so unread is the same visual idiom across the dashboard. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/[locale]/dashboard/(user)/page.tsx | 61 ++++++++++++++++++- .../dashboard/universe/meshes-grid.tsx | 30 ++++++--- 2 files changed, 80 insertions(+), 11 deletions(-) diff --git a/apps/web/src/app/[locale]/dashboard/(user)/page.tsx b/apps/web/src/app/[locale]/dashboard/(user)/page.tsx index 5fa9182..6b13a78 100644 --- a/apps/web/src/app/[locale]/dashboard/(user)/page.tsx +++ b/apps/web/src/app/[locale]/dashboard/(user)/page.tsx @@ -6,8 +6,13 @@ import { } from "@turbostarter/api/schema"; import { handle } from "@turbostarter/api/utils"; import { db } from "@turbostarter/db/server"; -import { meshTopic } from "@turbostarter/db/schema/mesh"; -import { and, count, inArray, isNull } from "drizzle-orm"; +import { + meshMember, + meshTopic, + meshTopicMember, + meshTopicMessage, +} from "@turbostarter/db/schema/mesh"; +import { and, count, eq, inArray, isNull, or, sql } from "drizzle-orm"; import { appConfig } from "~/config/app"; import { pathsConfig } from "~/config/paths"; @@ -58,9 +63,61 @@ export default async function UniversePage() { .groupBy(meshTopic.meshId) : []; const topicMap = new Map(topicCounts.map((r) => [r.meshId, Number(r.n)])); + + // Aggregate unread per mesh for the viewing user. Every topic_message + // not authored by one of the viewer's member rows, in a topic whose + // last_read_at by the viewer is null or older than the message, + // counts as unread. The LEFT JOIN on topic_member is restricted to + // the viewer's own member ids so a NULL row reliably means "viewer + // never opened this topic" — every message in such a topic is unread. + const myMembers = user && meshIds.length + ? await db + .select({ id: meshMember.id }) + .from(meshMember) + .where( + and( + eq(meshMember.userId, user.id), + inArray(meshMember.meshId, meshIds), + isNull(meshMember.revokedAt), + ), + ) + : []; + const myMemberIds = myMembers.map((m) => m.id); + + const unreadRows = myMemberIds.length + ? await db + .select({ + meshId: meshTopic.meshId, + n: count(meshTopicMessage.id), + }) + .from(meshTopicMessage) + .innerJoin(meshTopic, eq(meshTopic.id, meshTopicMessage.topicId)) + .leftJoin( + meshTopicMember, + and( + eq(meshTopicMember.topicId, meshTopicMessage.topicId), + inArray(meshTopicMember.memberId, myMemberIds), + ), + ) + .where( + and( + inArray(meshTopic.meshId, meshIds), + isNull(meshTopic.archivedAt), + sql`${meshTopicMessage.senderMemberId} <> ALL(${myMemberIds})`, + or( + isNull(meshTopicMember.lastReadAt), + sql`${meshTopicMessage.createdAt} > ${meshTopicMember.lastReadAt}`, + ), + ), + ) + .groupBy(meshTopic.meshId) + : []; + const unreadMap = new Map(unreadRows.map((r) => [r.meshId, Number(r.n)])); + const meshesWithTopics = activeMeshes.map((m) => ({ ...m, topicCount: topicMap.get(m.id) ?? 0, + unreadCount: unreadMap.get(m.id) ?? 0, })); return ( diff --git a/apps/web/src/modules/dashboard/universe/meshes-grid.tsx b/apps/web/src/modules/dashboard/universe/meshes-grid.tsx index d375824..390bf99 100644 --- a/apps/web/src/modules/dashboard/universe/meshes-grid.tsx +++ b/apps/web/src/modules/dashboard/universe/meshes-grid.tsx @@ -13,6 +13,7 @@ interface MeshSummary { isOwner: boolean; memberCount: number; topicCount?: number; + unreadCount?: number; archivedAt: Date | string | null; } @@ -113,15 +114,26 @@ const MeshCard = ({ {isHero ? ` · id ${mesh.id.slice(0, 8)}…` : ""}

- - {mesh.archivedAt ? "archived" : mesh.isOwner ? "owner" : mesh.myRole} - +
+ {mesh.unreadCount && mesh.unreadCount > 0 ? ( + + {mesh.unreadCount > 99 ? "99+" : mesh.unreadCount} + + ) : null} + + {mesh.archivedAt ? "archived" : mesh.isOwner ? "owner" : mesh.myRole} + +