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}
+
+