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

View File

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

View File

@ -1,6 +1,6 @@
"use client"
import { useState, type ReactNode } from "react"
import { useEffect, useState, type ReactNode } from "react"
import {
DndContext,
DragEndEvent,
@ -42,6 +42,13 @@ const typeLabels: Record<string, string> = {
plan: "📐",
}
interface AssignableUser {
id: string
name: string
email?: string
avatarUrl?: string
}
function AssigneeAvatar({ name, avatarUrl, seed }: { name?: string; avatarUrl?: string; seed?: string }) {
const displayUrl = avatarUrl || generateAvatarDataUrl(seed || name || "unassigned", name || "Unassigned")
return (
@ -57,9 +64,11 @@ function AssigneeAvatar({ name, avatarUrl, seed }: { name?: string; avatarUrl?:
// Sortable Task Row
function SortableTaskRow({
task,
assigneeAvatarUrl,
onClick,
}: {
task: Task
assigneeAvatarUrl?: string
onClick: () => void
}) {
const {
@ -103,13 +112,13 @@ function SortableTaskRow({
{task.comments && task.comments.length > 0 && (
<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>
)
}
// Drag Overlay Item
function DragOverlayItem({ task }: { task: Task }) {
function DragOverlayItem({ task, assigneeAvatarUrl }: { task: Task; assigneeAvatarUrl?: string }) {
return (
<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" />
@ -120,7 +129,7 @@ function DragOverlayItem({ task }: { task: Task }) {
<Badge className={`${priorityColors[task.priority]} text-white text-xs`}>
{task.priority}
</Badge>
<AssigneeAvatar name={task.assigneeName} avatarUrl={task.assigneeAvatarUrl} seed={task.assigneeId} />
<AssigneeAvatar name={task.assigneeName} avatarUrl={assigneeAvatarUrl} seed={task.assigneeId} />
</div>
)
}
@ -145,6 +154,7 @@ function TaskSection({
isOpen,
onToggle,
onTaskClick,
resolveAssigneeAvatar,
sprintInfo,
}: {
title: string
@ -152,6 +162,7 @@ function TaskSection({
isOpen: boolean
onToggle: () => void
onTaskClick: (task: Task) => void
resolveAssigneeAvatar: (task: Task) => string | undefined
sprintInfo?: { name: string; date: string; status: string }
}) {
return (
@ -196,6 +207,7 @@ function TaskSection({
<SortableTaskRow
key={task.id}
task={task}
assigneeAvatarUrl={resolveAssigneeAvatar(task)}
onClick={() => onTaskClick(task)}
/>
))
@ -210,6 +222,7 @@ function TaskSection({
export function BacklogView() {
const router = useRouter()
const [assignableUsers, setAssignableUsers] = useState<AssignableUser[]>([])
const {
tasks,
sprints,
@ -218,6 +231,37 @@ export function BacklogView() {
addSprint,
} = 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 [openSections, setOpenSections] = useState<Record<string, boolean>>({
current: true,
@ -331,6 +375,7 @@ export function BacklogView() {
isOpen={openSections.current}
onToggle={() => toggleSection("current")}
onTaskClick={(task) => router.push(`/tasks/${encodeURIComponent(task.id)}`)}
resolveAssigneeAvatar={resolveAssigneeAvatar}
sprintInfo={
currentSprint
? {
@ -362,6 +407,7 @@ export function BacklogView() {
isOpen={openSections[sprint.id] ?? false}
onToggle={() => toggleSection(sprint.id)}
onTaskClick={(task) => router.push(`/tasks/${encodeURIComponent(task.id)}`)}
resolveAssigneeAvatar={resolveAssigneeAvatar}
sprintInfo={{
name: sprint.name,
date: (() => {
@ -439,12 +485,13 @@ export function BacklogView() {
isOpen={openSections.backlog}
onToggle={() => toggleSection("backlog")}
onTaskClick={(task) => router.push(`/tasks/${encodeURIComponent(task.id)}`)}
resolveAssigneeAvatar={resolveAssigneeAvatar}
/>
</SectionDropZone>
</div>
<DragOverlay>
{activeTask ? <DragOverlayItem task={activeTask} /> : null}
{activeTask ? <DragOverlayItem task={activeTask} assigneeAvatarUrl={resolveAssigneeAvatar(activeTask)} /> : null}
</DragOverlay>
</DndContext>
)

View File

@ -98,6 +98,13 @@ type SqliteDb = InstanceType<typeof Database>;
let db: SqliteDb | null = null;
interface UserProfileLookup {
id: string;
name: string;
email?: string;
avatarUrl?: string;
}
function ensureTaskSchema(database: SqliteDb) {
const taskColumns = database.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
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[] {
if (!Array.isArray(attachments)) return [];
@ -421,6 +454,7 @@ function getDb(): SqliteDb {
export function getData(): DataStore {
const database = getDb();
const usersById = getUserLookup(database);
const projects = database.prepare("SELECT * FROM projects ORDER BY createdAt ASC").all() as Array<{
id: string;
@ -486,7 +520,12 @@ export function getData(): DataStore {
projectId: sprint.projectId,
createdAt: sprint.createdAt,
})),
tasks: tasks.map((task) => ({
tasks: tasks.map((task) => {
const createdByUser = task.createdById ? usersById.get(task.createdById) : undefined;
const updatedByUser = task.updatedById ? usersById.get(task.updatedById) : undefined;
const assigneeUser = task.assigneeId ? usersById.get(task.assigneeId) : undefined;
return {
id: task.id,
title: task.title,
description: task.description ?? undefined,
@ -498,20 +537,21 @@ export function getData(): DataStore {
createdAt: task.createdAt,
updatedAt: task.updatedAt,
createdById: task.createdById ?? undefined,
createdByName: task.createdByName ?? undefined,
createdByAvatarUrl: task.createdByAvatarUrl ?? undefined,
createdByName: task.createdByName ?? createdByUser?.name ?? undefined,
createdByAvatarUrl: createdByUser?.avatarUrl ?? task.createdByAvatarUrl ?? undefined,
updatedById: task.updatedById ?? undefined,
updatedByName: task.updatedByName ?? undefined,
updatedByAvatarUrl: task.updatedByAvatarUrl ?? undefined,
updatedByName: task.updatedByName ?? updatedByUser?.name ?? undefined,
updatedByAvatarUrl: updatedByUser?.avatarUrl ?? task.updatedByAvatarUrl ?? undefined,
assigneeId: task.assigneeId ?? undefined,
assigneeName: task.assigneeName ?? undefined,
assigneeEmail: task.assigneeEmail ?? undefined,
assigneeAvatarUrl: task.assigneeAvatarUrl ?? undefined,
assigneeName: assigneeUser?.name ?? task.assigneeName ?? undefined,
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),
};
}