feat(web): unread badge on dashboard mesh cards
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

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) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-05-02 19:08:11 +01:00
parent a80eb6fcca
commit 541440c357
2 changed files with 80 additions and 11 deletions

View File

@@ -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 (