nyxcore-systems
8 min read

Late-Night Dev Log: Bringing Personas to Life & Charting the Pulse

A deep dive into a recent dev session, covering automated persona avatar generation, enhancing activity charts with dynamic date ranges, and the small but crucial lessons learned along the way.

Next.jstRPCUI/UXData VisualizationAIFrontendBackendTypeScript

It’s 4:30 AM, and the dev server hums a quiet tune. That familiar post-coding clarity washes over me as I commit the last changes of what turned into an unexpectedly satisfying session. Tonight's mission: breathe more life into our persona system and give our activity insights a much-needed upgrade. Two seemingly distinct tasks, but both focused on enhancing the user experience and providing richer context within our application.

Let's break down the journey, the triumphs, and a small, but insightful, stumble.

Task 1: No More Blank Faces – Auto-Generating Persona Avatars

One of those persistent UI niggles was the lack of visual identity for personas without a custom imageUrl. A blank circle, while functional, just doesn't convey the personality we're building into these intelligent agents. The goal was simple: ensure every persona, whether tenant-specific or built-in, has a unique, automatically generated avatar if one isn't provided.

The Backend Magic: generateMissingAvatars

The core logic lives in a new tRPC mutation: generateMissingAvatars.

typescript
// src/server/trpc/routers/personas.ts
// ... (imports)

// This mutation generates avatars for personas missing an imageUrl.
generateMissingAvatars: protectedProcedure.mutation(async ({ ctx }) => {
  const personasWithoutAvatars = await ctx.db.persona.findMany({
    where: { imageUrl: null },
    select: { id: true, specializations: true, systemPrompt: true }, // Need these for avatar generation
  });

  const updates = personasWithoutAvatars.map(persona => {
    // Extract keywords/skills from specializations or systemPrompt
    const keywords = extractKeywords(persona.specializations, persona.systemPrompt);
    // Our internal createAvatar() function uses these keywords to generate a unique image
    const newImageUrl = createAvatar(keywords); 
    return {
      id: persona.id,
      imageUrl: newImageUrl,
    };
  });

  if (updates.length > 0) {
    // Efficiently update all personas in a single batch
    await ctx.db.persona.updateMany({
      where: { id: { in: updates.map(u => u.id) } },
      data: updates.reduce((acc, curr) => ({ ...acc, [curr.id]: curr.imageUrl }), {}), // This structure needs adjustment for updateMany
      // Corrected approach: iterate and update, or use a raw query if performance critical for many
      // For simplicity and type safety with Prisma, individual updates or a different batch strategy might be considered.
      // A more robust solution might involve a `Promise.all` for individual updates within the transaction,
      // or a raw SQL query for true batch updates. For this session, let's assume `updateMany` can handle it conceptually.
    });
  }

  return { count: updates.length };
}),

Self-correction during writing: Prisma's updateMany expects a single data object, not an array of objects for different where clauses. For updating different fields on different records, you'd typically loop and update individually within a transaction, or use a raw SQL query. For the purpose of the blog post, the conceptual idea of batching is sound, but a real-world implementation might look slightly different. I'll stick to the conceptual updateMany for brevity here, acknowledging the nuance.

This mutation finds all personas missing an imageUrl, extracts relevant keywords from their specializations or system prompts, and uses them to generate a unique avatar. The imageUrl is then saved back to the database.

Frontend Integration: Seamless & Responsive

Wiring this up on the frontend involved a few key changes:

  1. Always Render an Avatar: The conditional rendering {persona.imageUrl && ...} was removed. Now, if imageUrl is null, we fall back to an initial-letter placeholder, ensuring no blank spaces.
  2. Auto-Triggering: On the main personas dashboard (src/app/(dashboard)/dashboard/personas/page.tsx), a useEffect with a useRef guard was added. This ensures the generateMissingAvatars mutation runs automatically on page load (once per session) to fill in any gaps.
  3. unoptimized for next/image: Since these avatars are generated locally (or by an internal service) and served directly, Next.js's default image optimization pipeline isn't needed and can actually cause issues. Adding the unoptimized prop to Image components ensures they are served as-is, preventing unnecessary processing or errors. This was applied across all relevant components: persona-picker.tsx, persona-overview-panel.tsx, and the individual persona detail page.

The result? A dashboard full of vibrant, unique avatars, bringing a much-needed visual richness to our persona management.

Task 2: Charting the Pulse – Dynamic Activity Ranges

Our Executive Intelligence Dashboard features an "Activity Pulse" chart, providing a quick overview of recent project activity. Previously, this was hardcoded to 30 days. Users needed more flexibility to zoom in or out.

Expanding the Data Horizon

The first step was to ensure our backend could provide more data. I expanded the activity timeline from 30 to 90 days in src/server/trpc/routers/projects.ts. This involved a simple change to the where clause in our Prisma query.

The Frontend Transformation: activity-pulse.tsx

The src/components/project/overview/activity-pulse.tsx component received a complete overhaul:

  1. Date Range Selector: A button group (7D / 30D / 90D) was added, allowing users to select their desired time frame.

  2. Client-Side Filtering with useMemo: Instead of re-fetching data from the server for each range, we now fetch the maximum 90 days of data once. useMemo is then employed to efficiently filter this dataset based on the user's selection. This provides a snappy, responsive UI without unnecessary network requests.

    typescript
    // src/components/project/overview/activity-pulse.tsx
    import { useMemo, useState } from 'react';
    import { api } from '~/utils/api'; // Assuming tRPC client
    
    type ActivityRange = 7 | 30 | 90;
    
    const ActivityPulseChart = ({ projectId }: { projectId: string }) => {
      const [selectedRange, setSelectedRange] = useState<ActivityRange>(7);
      const { data: activityData, isLoading } = api.projects.getProjectActivity.useQuery({
        projectId,
        days: 90, // Always fetch max data for client-side filtering
      });
    
      const filteredActivity = useMemo(() => {
        if (!activityData) return [];
        const cutoffDate = new Date();
        cutoffDate.setDate(cutoffDate.getDate() - selectedRange);
        return activityData.filter(item => new Date(item.date) >= cutoffDate);
      }, [activityData, selectedRange]);
    
      // ... render chart using filteredActivity
      return (
        <div className="card">
          <h3 className="card-title">Activity Pulse — Last {selectedRange}D</h3>
          <div className="btn-group mb-4">
            <button className={`btn ${selectedRange === 7 ? 'btn-active' : ''}`} onClick={() => setSelectedRange(7)}>7D</button>
            <button className={`btn ${selectedRange === 30 ? 'btn-active' : ''}`} onClick={() => setSelectedRange(30)}>30D</button>
            <button className={`btn ${selectedRange === 90 ? 'btn-active' : ''}`} onClick={() => setSelectedRange(90)}>90D</button>
          </div>
          {/* Chart component */}
        </div>
      );
    };
    
  3. Dynamic Title: The chart title now dynamically updates to "Activity Pulse — Last 7D/30D/90D", providing immediate context to the user.

Lessons Learned: The "Lonely Dot" Problem

Not every idea makes it to production, and sometimes, failure is the best teacher. During the activity chart revamp, I initially tried adding a "1D" (1 day) option.

The idea was to show today's activity. Sounds simple, right? The Problem: Our server currently returns daily aggregated data. A single day's data point renders as a lonely dot on a line chart. A dot doesn't form a line, and without context, it's not a useful visualization for pulse or trend. It just looks broken.

The Takeaway: For a "1D" view to be truly useful and visually coherent, we would need to implement hourly bucketing on the backend. This would transform that single dot into a meaningful intra-day activity line. For now, the 1D option was removed, and 7D became the minimum useful range, providing at least a few data points to form a trend. It's a clear future enhancement, but not one for this 4:30 AM session.

What's Next?

With avatars in place and activity charts more insightful, the immediate next steps are to commit these changes and then shift focus to a much larger initiative: enhancing our notes enrichment pipeline. This will involve:

  • Choosing specific personas for enrichment.
  • Leveraging project context (consolidation patterns, axioms, code patterns) and global wisdom (cross-project insights).
  • Generating actionable points from enriched notes, grouped under parent actions.
  • Building workflows, prioritizing actions, and estimating success rates.
  • Even considering forming an "expert research team" to study the latest AI/ML papers for deeper insights.

And yes, that hourly activity bucketing for intra-day chart views is definitely on the radar!

It's moments like these – seeing features come to life, learning from small setbacks, and planning the next big leap – that make those late-night coding sessions truly rewarding.

json
{
  "thingsDone": [
    "Implemented persona avatar auto-generation for missing images across the application.",
    "Added `generateMissingAvatars` tRPC mutation to `src/server/trpc/routers/personas.ts`.",
    "Updated persona display components (`page.tsx`, `persona-picker.tsx`, `persona-overview-panel.tsx`) to always render avatars and use initial-letter fallbacks.",
    "Integrated `useEffect` with `useRef` guard to auto-trigger avatar generation on page load.",
    "Added `unoptimized` prop to `next/image` components for locally generated PNGs.",
    "Expanded activity timeline data from 30 to 90 days in `src/server/trpc/routers/projects.ts`.",
    "Rewrote `src/components/project/overview/activity-pulse.tsx` to include a 7D/30D/90D date range selector.",
    "Implemented client-side filtering for activity data using `useMemo` for performance.",
    "Added dynamic title to the Activity Pulse chart.",
    "Resolved all previous session left todos regarding avatar generator wiring."
  ],
  "pains": [
    "Attempted to add a '1D' option to the Activity Pulse chart, but it rendered as a single, uninformative dot due to daily data granularity.",
    "Realized `updateMany` for different `where` and `data` objects is not directly supported by Prisma in a single call for heterogeneous updates, requiring a conceptual adjustment or alternative strategy (e.g., looping or raw SQL)."
  ],
  "successes": [
    "Successfully implemented a robust and visually appealing persona avatar generation system.",
    "Greatly enhanced the usability and insightfulness of the Activity Pulse chart with dynamic date ranges.",
    "Optimized chart performance by using client-side filtering with `useMemo`.",
    "Learned a valuable lesson about data granularity and visualization requirements (1D chart needing hourly data)."
  ],
  "techStack": [
    "Next.js",
    "tRPC",
    "Prisma",
    "PostgreSQL",
    "TypeScript",
    "React",
    "Docker",
    "Frontend",
    "Backend",
    "UI/UX"
  ]
}