Resolve assignee avatars via user profiles

This commit is contained in:
OpenClaw Bot 2026-02-20 15:40:47 -06:00
parent f4123a2d9a
commit 5b3691826b
5 changed files with 124 additions and 35 deletions

View File

@ -67,7 +67,6 @@ export async function POST(request: Request) {
assigneeId: task.assigneeId || user.id, assigneeId: task.assigneeId || user.id,
assigneeName: task.assigneeName || user.name, assigneeName: task.assigneeName || user.name,
assigneeEmail: task.assigneeEmail || user.email, assigneeEmail: task.assigneeEmail || user.email,
assigneeAvatarUrl: task.assigneeAvatarUrl || user.avatarUrl,
}); });
} }
} }
@ -84,7 +83,7 @@ export async function POST(request: Request) {
assigneeId: entry.assigneeId || undefined, assigneeId: entry.assigneeId || undefined,
assigneeName: entry.assigneeName || undefined, assigneeName: entry.assigneeName || undefined,
assigneeEmail: entry.assigneeEmail || undefined, assigneeEmail: entry.assigneeEmail || undefined,
assigneeAvatarUrl: entry.assigneeAvatarUrl || undefined, assigneeAvatarUrl: undefined,
})); }));
} }

View File

@ -219,11 +219,13 @@ function KanbanDropColumn({
function KanbanTaskCard({ function KanbanTaskCard({
task, task,
taskTags, taskTags,
assigneeAvatarUrl,
onOpen, onOpen,
onDelete, onDelete,
}: { }: {
task: Task task: Task
taskTags: string[] taskTags: string[]
assigneeAvatarUrl?: string
onOpen: () => void onOpen: () => void
onDelete: () => void onDelete: () => void
}) { }) {
@ -307,7 +309,7 @@ function KanbanTaskCard({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<AvatarCircle <AvatarCircle
name={task.assigneeName || "Unassigned"} name={task.assigneeName || "Unassigned"}
avatarUrl={task.assigneeAvatarUrl} avatarUrl={assigneeAvatarUrl}
seed={task.assigneeId} seed={task.assigneeId}
title={task.assigneeName ? `Assigned to ${task.assigneeName}` : "Unassigned"} title={task.assigneeName ? `Assigned to ${task.assigneeName}` : "Unassigned"}
/> />
@ -1002,6 +1004,7 @@ export default function Home() {
key={task.id} key={task.id}
task={task} task={task}
taskTags={getTags(task)} taskTags={getTags(task)}
assigneeAvatarUrl={resolveAssignee(task.assigneeId)?.avatarUrl || task.assigneeAvatarUrl}
onOpen={() => router.push(`/tasks/${encodeURIComponent(task.id)}`)} onOpen={() => router.push(`/tasks/${encodeURIComponent(task.id)}`)}
onDelete={() => deleteTask(task.id)} onDelete={() => deleteTask(task.id)}
/> />

View File

@ -684,7 +684,7 @@ export default function TaskDetailPage() {
<span className="inline-flex items-center gap-2 text-xs text-slate-300"> <span className="inline-flex items-center gap-2 text-xs text-slate-300">
<AvatarCircle <AvatarCircle
name={editedTask.assigneeName || "Unassigned"} name={editedTask.assigneeName || "Unassigned"}
avatarUrl={editedTask.assigneeAvatarUrl} avatarUrl={resolveAssignee(editedTask.assigneeId)?.avatarUrl || editedTask.assigneeAvatarUrl}
seed={editedTask.assigneeId} seed={editedTask.assigneeId}
sizeClass="h-6 w-6" sizeClass="h-6 w-6"
/> />

View File

@ -1,6 +1,6 @@
"use client" "use client"
import { useState, type ReactNode } from "react" import { useEffect, useState, type ReactNode } from "react"
import { import {
DndContext, DndContext,
DragEndEvent, DragEndEvent,
@ -42,6 +42,13 @@ const typeLabels: Record<string, string> = {
plan: "📐", plan: "📐",
} }
interface AssignableUser {
id: string
name: string
email?: string
avatarUrl?: string
}
function AssigneeAvatar({ name, avatarUrl, seed }: { name?: string; avatarUrl?: string; seed?: string }) { function AssigneeAvatar({ name, avatarUrl, seed }: { name?: string; avatarUrl?: string; seed?: string }) {
const displayUrl = avatarUrl || generateAvatarDataUrl(seed || name || "unassigned", name || "Unassigned") const displayUrl = avatarUrl || generateAvatarDataUrl(seed || name || "unassigned", name || "Unassigned")
return ( return (
@ -57,9 +64,11 @@ function AssigneeAvatar({ name, avatarUrl, seed }: { name?: string; avatarUrl?:
// Sortable Task Row // Sortable Task Row
function SortableTaskRow({ function SortableTaskRow({
task, task,
assigneeAvatarUrl,
onClick, onClick,
}: { }: {
task: Task task: Task
assigneeAvatarUrl?: string
onClick: () => void onClick: () => void
}) { }) {
const { const {
@ -103,13 +112,13 @@ function SortableTaskRow({
{task.comments && task.comments.length > 0 && ( {task.comments && task.comments.length > 0 && (
<span className="text-xs text-slate-500">💬 {task.comments.length}</span> <span className="text-xs text-slate-500">💬 {task.comments.length}</span>
)} )}
<AssigneeAvatar name={task.assigneeName} avatarUrl={task.assigneeAvatarUrl} seed={task.assigneeId} /> <AssigneeAvatar name={task.assigneeName} avatarUrl={assigneeAvatarUrl} seed={task.assigneeId} />
</div> </div>
) )
} }
// Drag Overlay Item // Drag Overlay Item
function DragOverlayItem({ task }: { task: Task }) { function DragOverlayItem({ task, assigneeAvatarUrl }: { task: Task; assigneeAvatarUrl?: string }) {
return ( return (
<div className="flex items-center gap-3 p-3 bg-slate-800 border border-slate-600 rounded-lg shadow-xl rotate-1"> <div className="flex items-center gap-3 p-3 bg-slate-800 border border-slate-600 rounded-lg shadow-xl rotate-1">
<GripVertical className="w-4 h-4 text-slate-500" /> <GripVertical className="w-4 h-4 text-slate-500" />
@ -120,7 +129,7 @@ function DragOverlayItem({ task }: { task: Task }) {
<Badge className={`${priorityColors[task.priority]} text-white text-xs`}> <Badge className={`${priorityColors[task.priority]} text-white text-xs`}>
{task.priority} {task.priority}
</Badge> </Badge>
<AssigneeAvatar name={task.assigneeName} avatarUrl={task.assigneeAvatarUrl} seed={task.assigneeId} /> <AssigneeAvatar name={task.assigneeName} avatarUrl={assigneeAvatarUrl} seed={task.assigneeId} />
</div> </div>
) )
} }
@ -145,6 +154,7 @@ function TaskSection({
isOpen, isOpen,
onToggle, onToggle,
onTaskClick, onTaskClick,
resolveAssigneeAvatar,
sprintInfo, sprintInfo,
}: { }: {
title: string title: string
@ -152,6 +162,7 @@ function TaskSection({
isOpen: boolean isOpen: boolean
onToggle: () => void onToggle: () => void
onTaskClick: (task: Task) => void onTaskClick: (task: Task) => void
resolveAssigneeAvatar: (task: Task) => string | undefined
sprintInfo?: { name: string; date: string; status: string } sprintInfo?: { name: string; date: string; status: string }
}) { }) {
return ( return (
@ -196,6 +207,7 @@ function TaskSection({
<SortableTaskRow <SortableTaskRow
key={task.id} key={task.id}
task={task} task={task}
assigneeAvatarUrl={resolveAssigneeAvatar(task)}
onClick={() => onTaskClick(task)} onClick={() => onTaskClick(task)}
/> />
)) ))
@ -210,6 +222,7 @@ function TaskSection({
export function BacklogView() { export function BacklogView() {
const router = useRouter() const router = useRouter()
const [assignableUsers, setAssignableUsers] = useState<AssignableUser[]>([])
const { const {
tasks, tasks,
sprints, sprints,
@ -218,6 +231,37 @@ export function BacklogView() {
addSprint, addSprint,
} = useTaskStore() } = useTaskStore()
useEffect(() => {
let active = true
const loadUsers = async () => {
try {
const response = await fetch("/api/auth/users", { cache: "no-store" })
if (!response.ok) return
const data = await response.json()
if (!active || !Array.isArray(data?.users)) return
setAssignableUsers(
data.users.map((entry: { id: string; name: string; email?: string; avatarUrl?: string }) => ({
id: entry.id,
name: entry.name,
email: entry.email,
avatarUrl: entry.avatarUrl,
})),
)
} catch {
// Keep backlog usable if users lookup fails.
}
}
void loadUsers()
return () => {
active = false
}
}, [])
const resolveAssigneeAvatar = (task: Task) => {
if (!task.assigneeId) return task.assigneeAvatarUrl
return assignableUsers.find((user) => user.id === task.assigneeId)?.avatarUrl || task.assigneeAvatarUrl
}
const [activeId, setActiveId] = useState<string | null>(null) const [activeId, setActiveId] = useState<string | null>(null)
const [openSections, setOpenSections] = useState<Record<string, boolean>>({ const [openSections, setOpenSections] = useState<Record<string, boolean>>({
current: true, current: true,
@ -331,6 +375,7 @@ export function BacklogView() {
isOpen={openSections.current} isOpen={openSections.current}
onToggle={() => toggleSection("current")} onToggle={() => toggleSection("current")}
onTaskClick={(task) => router.push(`/tasks/${encodeURIComponent(task.id)}`)} onTaskClick={(task) => router.push(`/tasks/${encodeURIComponent(task.id)}`)}
resolveAssigneeAvatar={resolveAssigneeAvatar}
sprintInfo={ sprintInfo={
currentSprint currentSprint
? { ? {
@ -362,6 +407,7 @@ export function BacklogView() {
isOpen={openSections[sprint.id] ?? false} isOpen={openSections[sprint.id] ?? false}
onToggle={() => toggleSection(sprint.id)} onToggle={() => toggleSection(sprint.id)}
onTaskClick={(task) => router.push(`/tasks/${encodeURIComponent(task.id)}`)} onTaskClick={(task) => router.push(`/tasks/${encodeURIComponent(task.id)}`)}
resolveAssigneeAvatar={resolveAssigneeAvatar}
sprintInfo={{ sprintInfo={{
name: sprint.name, name: sprint.name,
date: (() => { date: (() => {
@ -439,12 +485,13 @@ export function BacklogView() {
isOpen={openSections.backlog} isOpen={openSections.backlog}
onToggle={() => toggleSection("backlog")} onToggle={() => toggleSection("backlog")}
onTaskClick={(task) => router.push(`/tasks/${encodeURIComponent(task.id)}`)} onTaskClick={(task) => router.push(`/tasks/${encodeURIComponent(task.id)}`)}
resolveAssigneeAvatar={resolveAssigneeAvatar}
/> />
</SectionDropZone> </SectionDropZone>
</div> </div>
<DragOverlay> <DragOverlay>
{activeTask ? <DragOverlayItem task={activeTask} /> : null} {activeTask ? <DragOverlayItem task={activeTask} assigneeAvatarUrl={resolveAssigneeAvatar(activeTask)} /> : null}
</DragOverlay> </DragOverlay>
</DndContext> </DndContext>
) )

View File

@ -98,6 +98,13 @@ type SqliteDb = InstanceType<typeof Database>;
let db: SqliteDb | null = null; let db: SqliteDb | null = null;
interface UserProfileLookup {
id: string;
name: string;
email?: string;
avatarUrl?: string;
}
function ensureTaskSchema(database: SqliteDb) { function ensureTaskSchema(database: SqliteDb) {
const taskColumns = database.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const taskColumns = database.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
if (!taskColumns.some((column) => column.name === "attachments")) { if (!taskColumns.some((column) => column.name === "attachments")) {
@ -145,6 +152,32 @@ function safeParseArray<T>(value: string | null, fallback: T[]): T[] {
} }
} }
function getUserLookup(database: SqliteDb): Map<string, UserProfileLookup> {
const hasUsersTable = database
.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'users' LIMIT 1")
.get() as { 1: number } | undefined;
if (!hasUsersTable) return new Map();
try {
const rows = database
.prepare("SELECT id, name, email, avatarUrl FROM users")
.all() as Array<{ id: string; name: string; email: string | null; avatarUrl: string | null }>;
const lookup = new Map<string, UserProfileLookup>();
for (const row of rows) {
lookup.set(row.id, {
id: row.id,
name: row.name,
email: row.email ?? undefined,
avatarUrl: row.avatarUrl ?? undefined,
});
}
return lookup;
} catch {
return new Map();
}
}
function normalizeAttachments(attachments: unknown): TaskAttachment[] { function normalizeAttachments(attachments: unknown): TaskAttachment[] {
if (!Array.isArray(attachments)) return []; if (!Array.isArray(attachments)) return [];
@ -421,6 +454,7 @@ function getDb(): SqliteDb {
export function getData(): DataStore { export function getData(): DataStore {
const database = getDb(); const database = getDb();
const usersById = getUserLookup(database);
const projects = database.prepare("SELECT * FROM projects ORDER BY createdAt ASC").all() as Array<{ const projects = database.prepare("SELECT * FROM projects ORDER BY createdAt ASC").all() as Array<{
id: string; id: string;
@ -486,32 +520,38 @@ export function getData(): DataStore {
projectId: sprint.projectId, projectId: sprint.projectId,
createdAt: sprint.createdAt, createdAt: sprint.createdAt,
})), })),
tasks: tasks.map((task) => ({ tasks: tasks.map((task) => {
id: task.id, const createdByUser = task.createdById ? usersById.get(task.createdById) : undefined;
title: task.title, const updatedByUser = task.updatedById ? usersById.get(task.updatedById) : undefined;
description: task.description ?? undefined, const assigneeUser = task.assigneeId ? usersById.get(task.assigneeId) : undefined;
type: task.type,
status: task.status, return {
priority: task.priority, id: task.id,
projectId: task.projectId, title: task.title,
sprintId: task.sprintId ?? undefined, description: task.description ?? undefined,
createdAt: task.createdAt, type: task.type,
updatedAt: task.updatedAt, status: task.status,
createdById: task.createdById ?? undefined, priority: task.priority,
createdByName: task.createdByName ?? undefined, projectId: task.projectId,
createdByAvatarUrl: task.createdByAvatarUrl ?? undefined, sprintId: task.sprintId ?? undefined,
updatedById: task.updatedById ?? undefined, createdAt: task.createdAt,
updatedByName: task.updatedByName ?? undefined, updatedAt: task.updatedAt,
updatedByAvatarUrl: task.updatedByAvatarUrl ?? undefined, createdById: task.createdById ?? undefined,
assigneeId: task.assigneeId ?? undefined, createdByName: task.createdByName ?? createdByUser?.name ?? undefined,
assigneeName: task.assigneeName ?? undefined, createdByAvatarUrl: createdByUser?.avatarUrl ?? task.createdByAvatarUrl ?? undefined,
assigneeEmail: task.assigneeEmail ?? undefined, updatedById: task.updatedById ?? undefined,
assigneeAvatarUrl: task.assigneeAvatarUrl ?? undefined, updatedByName: task.updatedByName ?? updatedByUser?.name ?? undefined,
dueDate: task.dueDate ?? undefined, updatedByAvatarUrl: updatedByUser?.avatarUrl ?? task.updatedByAvatarUrl ?? undefined,
comments: normalizeComments(safeParseArray(task.comments, [])), assigneeId: task.assigneeId ?? undefined,
tags: safeParseArray(task.tags, []), assigneeName: assigneeUser?.name ?? task.assigneeName ?? undefined,
attachments: normalizeAttachments(safeParseArray(task.attachments, [])), assigneeEmail: assigneeUser?.email ?? task.assigneeEmail ?? undefined,
})), assigneeAvatarUrl: assigneeUser?.avatarUrl ?? undefined,
dueDate: task.dueDate ?? undefined,
comments: normalizeComments(safeParseArray(task.comments, [])),
tags: safeParseArray(task.tags, []),
attachments: normalizeAttachments(safeParseArray(task.attachments, [])),
};
}),
lastUpdated: getLastUpdated(database), lastUpdated: getLastUpdated(database),
}; };
} }