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"; } from "@turbostarter/api/schema";
import { handle } from "@turbostarter/api/utils"; import { handle } from "@turbostarter/api/utils";
import { db } from "@turbostarter/db/server"; import { db } from "@turbostarter/db/server";
import { meshTopic } from "@turbostarter/db/schema/mesh"; import {
import { and, count, inArray, isNull } from "drizzle-orm"; 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 { appConfig } from "~/config/app";
import { pathsConfig } from "~/config/paths"; import { pathsConfig } from "~/config/paths";
@@ -58,9 +63,61 @@ export default async function UniversePage() {
.groupBy(meshTopic.meshId) .groupBy(meshTopic.meshId)
: []; : [];
const topicMap = new Map(topicCounts.map((r) => [r.meshId, Number(r.n)])); 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) => ({ const meshesWithTopics = activeMeshes.map((m) => ({
...m, ...m,
topicCount: topicMap.get(m.id) ?? 0, topicCount: topicMap.get(m.id) ?? 0,
unreadCount: unreadMap.get(m.id) ?? 0,
})); }));
return ( return (

View File

@@ -13,6 +13,7 @@ interface MeshSummary {
isOwner: boolean; isOwner: boolean;
memberCount: number; memberCount: number;
topicCount?: number; topicCount?: number;
unreadCount?: number;
archivedAt: Date | string | null; archivedAt: Date | string | null;
} }
@@ -113,15 +114,26 @@ const MeshCard = ({
{isHero ? ` · id ${mesh.id.slice(0, 8)}` : ""} {isHero ? ` · id ${mesh.id.slice(0, 8)}` : ""}
</p> </p>
</div> </div>
<span <div className="flex items-center gap-2">
className={[ {mesh.unreadCount && mesh.unreadCount > 0 ? (
"whitespace-nowrap rounded-sm border px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.14em]", <span
roleClass(mesh.isOwner, mesh.myRole), className="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-[var(--cm-clay)] px-1.5 text-[10px] font-medium text-white"
"border-[var(--cm-border)]", style={{ fontFamily: "var(--cm-font-mono)" }}
].join(" ")} aria-label={`${mesh.unreadCount} unread across topics`}
> >
{mesh.archivedAt ? "archived" : mesh.isOwner ? "owner" : mesh.myRole} {mesh.unreadCount > 99 ? "99+" : mesh.unreadCount}
</span> </span>
) : null}
<span
className={[
"whitespace-nowrap rounded-sm border px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.14em]",
roleClass(mesh.isOwner, mesh.myRole),
"border-[var(--cm-border)]",
].join(" ")}
>
{mesh.archivedAt ? "archived" : mesh.isOwner ? "owner" : mesh.myRole}
</span>
</div>
</div> </div>
<div className="mb-4 flex flex-wrap items-center gap-1.5"> <div className="mb-4 flex flex-wrap items-center gap-1.5">