<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Кирилл Несмелов</title>
    <description>The latest articles on DEV Community by Кирилл Несмелов (@__615f3acc0).</description>
    <link>https://dev.to/__615f3acc0</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3973737%2F4b39cd82-017c-405c-898d-5bf027ccd31b.jpg</url>
      <title>DEV Community: Кирилл Несмелов</title>
      <link>https://dev.to/__615f3acc0</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/__615f3acc0"/>
    <language>en</language>
    <item>
      <title>I used 10 parallel Claude Code agents to audit 291 SEO pages at once. Here's what we found.</title>
      <dc:creator>Кирилл Несмелов</dc:creator>
      <pubDate>Wed, 05 Aug 2026 07:00:19 +0000</pubDate>
      <link>https://dev.to/__615f3acc0/i-used-10-parallel-claude-code-agents-to-audit-291-seo-pages-at-once-heres-what-we-found-1j1h</link>
      <guid>https://dev.to/__615f3acc0/i-used-10-parallel-claude-code-agents-to-audit-291-seo-pages-at-once-heres-what-we-found-1j1h</guid>
      <description>&lt;p&gt;have 291 pages on &lt;a href="https://melororium.com" rel="noopener noreferrer"&gt;melororium.com&lt;/a&gt;. All of them generate from a TypeScript data layer. One person wrote all the content. Me.&lt;/p&gt;

&lt;p&gt;The problem: I cannot audit 291 pages manually. No practical attention span for it. By the time I check page 50, I have forgotten what I saw on page 12.&lt;/p&gt;

&lt;p&gt;So I built a multi-agent workflow with Claude Code.&lt;/p&gt;

&lt;p&gt;The setup&lt;br&gt;
Claude Code lets you spawn parallel subagents via the Workflow tool. You can structure work as phases — some sequential, some parallel — and give each agent a specific, bounded job.&lt;/p&gt;

&lt;p&gt;The audit phase runs 10 agents in parallel. Each agent checks one quality dimension across all 291 pages simultaneously:&lt;/p&gt;

&lt;p&gt;// SEO Audit Workflow — pseudocode&lt;br&gt;
// 10 parallel agents, 291 pages, ~20 minutes total&lt;/p&gt;

&lt;p&gt;const auditPhase = {&lt;br&gt;
  type: "parallel",&lt;br&gt;
  agents: [&lt;br&gt;
    { id: "seo-desc-auditor",        task: &lt;code&gt;Check all seoDesc fields. Length must be 145-160 chars. Flag forbidden phrases: 'freelancer', 'lifetime deal', 'pay once'. Flag old prices: $149, $299, $499.&lt;/code&gt; },&lt;br&gt;
    { id: "pricing-auditor",         task: &lt;code&gt;Scan all content for old prices ($149/$299/$499). Current correct prices: Starter $29/mo, Agency $59/mo, Studio $119/mo. Report every location where old prices appear.&lt;/code&gt; },&lt;br&gt;
    { id: "dead-link-checker",       task: &lt;code&gt;Verify all internal links. Every /task-tracker/* route must exist. Every /pricing link must resolve. Report broken paths.&lt;/code&gt; },&lt;br&gt;
    { id: "editorial-note-scanner",  task: &lt;code&gt;Find developer notes visible in rendered content. Patterns: 'Context:', 'CTA here', 'Note:', 'TODO', 'PLACEHOLDER'. These must not appear in production article bodies.&lt;/code&gt; },&lt;br&gt;
    { id: "duplicate-faq-auditor",   task: &lt;code&gt;Find FAQ answer blocks reused across more than 2 posts. Exact-match comparison across all blog content. Flag duplicates with locations.&lt;/code&gt; },&lt;br&gt;
    { id: "schema-auditor",          task: &lt;code&gt;Validate JSON-LD schema markup on all pages. Required fields must be present. No undefined or null values in required positions.&lt;/code&gt; },&lt;br&gt;
    { id: "linkbox-auditor",         task: &lt;code&gt;Check all CTA linkBox labels. Correct label: 'Melororium'. Flag any instance of 'Melororium Task Tracker' or other variants.&lt;/code&gt; },&lt;br&gt;
    { id: "internal-link-auditor",   task: &lt;code&gt;Find posts that mention pricing but lack a /pricing link. Find posts that describe features but lack matching /task-tracker/* links. Report gaps.&lt;/code&gt; },&lt;br&gt;
    { id: "compare-data-auditor",    task: &lt;code&gt;Verify competitor pricing on comparison pages. ClickUp Business = $12/seat/month. Asana = $11/seat/month. Flag any value that differs from current public rates.&lt;/code&gt; },&lt;br&gt;
    { id: "audience-targeting-auditor", task: &lt;code&gt;Flag any seoDesc or H1 targeting 'freelancer', 'solo', 'self-employed'. Melororium targets teams of 4-25. Wrong audience targeting = wrong queries.&lt;/code&gt; }&lt;br&gt;
  ]&lt;br&gt;
};&lt;br&gt;
Each agent reads the relevant data files, runs its check, writes findings to a shared results file. Total time: about 20 minutes for all 291 pages.&lt;/p&gt;

&lt;p&gt;What it found: 155 defects&lt;br&gt;
The critical ones:&lt;/p&gt;

&lt;p&gt;35 sections with developer notes in production. Strings like "Context: Natural mention — include CTA here" were inside published blog article bodies. Not in HTML comments. In rendered text. Google crawled those pages. Real visitors read that text. I shipped scaffolding to production and left it there for weeks without noticing.&lt;/p&gt;

&lt;p&gt;28 linkBox labels with wrong product name. CTA buttons across the site said "Melororium Task Tracker." That is the old product name. Inconsistent across 28 locations — this one was purely a find-and-replace problem that slipped past review.&lt;/p&gt;

&lt;p&gt;7 posts with old pricing. Articles showed $149 as the price. &lt;a href="https://melororium.com/pricing" rel="noopener noreferrer"&gt;The current Starter plan&lt;/a&gt; costs $29/mo. These articles predated the pricing model change and I never updated them. Any reader who hit one of those posts before the pricing page got the wrong product in their head.&lt;/p&gt;

&lt;p&gt;11 seoDesc fields targeting wrong audience. Meta descriptions with "freelancer" or "solo" positioning. Melororium targets agencies and studios with 4 to 25 people. Wrong audience targeting ranks you for queries that drive visitors who will never convert.&lt;/p&gt;

&lt;p&gt;The fix workflow&lt;br&gt;
Parallel agents writing to the same large file cause conflicts. The main content file is 13,000 lines. For that file, I run sequential agents, each handling a line range:&lt;/p&gt;

&lt;p&gt;// Fix Workflow — pseudocode&lt;br&gt;
// Sequential for the big file, parallel for everything else&lt;/p&gt;

&lt;p&gt;const fixPhase = {&lt;br&gt;
  mainFile: {&lt;br&gt;
    type: "sequential",  // prevents write conflicts on one large file&lt;br&gt;
    agents: [&lt;br&gt;
      { id: "fixer-1", task: "Fix all defects in content.ts lines 1-3250" },&lt;br&gt;
      { id: "fixer-2", task: "Fix all defects in content.ts lines 3251-6500" },&lt;br&gt;
      { id: "fixer-3", task: "Fix all defects in content.ts lines 6501-9750" },&lt;br&gt;
      { id: "fixer-4", task: "Fix all defects in content.ts lines 9751-13000" }&lt;br&gt;
    ]&lt;br&gt;
  },&lt;br&gt;
  otherFiles: {&lt;br&gt;
    type: "parallel",   // separate files, no conflict risk&lt;br&gt;
    agents: [&lt;br&gt;
      { id: "data-fixer",   task: "Fix all defects across /data/&lt;em&gt;.ts" },&lt;br&gt;
      { id: "config-fixer", task: "Fix all defects across /lib/seo/&lt;/em&gt;.ts" }&lt;br&gt;
    ]&lt;br&gt;
  }&lt;br&gt;
};&lt;br&gt;
The sequential phase handles the big file cleanly. The parallel phase handles everything else at the same time.&lt;/p&gt;

&lt;p&gt;What I learned&lt;br&gt;
The audit took 20 minutes. The fixes took 2 hours. That ratio tracks: finding defects at scale is fast, fixing them carefully is slow.&lt;/p&gt;

&lt;p&gt;The harder lesson: 35 developer notes in production, weeks of Google crawling, and I had no idea. Manual review would not have caught them either. There is no realistic way to read 291 pages and spot scaffolding strings embedded in 13,000 lines of TypeScript.&lt;/p&gt;

&lt;p&gt;Systematic auditing scales. Human review does not.&lt;/p&gt;

&lt;p&gt;I will re-run this audit after deploy to verify the fixes landed correctly, then wire it into CI so it runs on every merge to main.&lt;/p&gt;

&lt;p&gt;If you generate content at scale from a data layer, build the audit workflow before you hit 100 pages. Fixing 155 defects across 291 pages is a full day of work.&lt;/p&gt;

&lt;p&gt;The stack: Claude Code with multi-agent Workflow, TypeScript content generation, Next.js on the front end. The site is &lt;a href="https://melororium.com" rel="noopener noreferrer"&gt;melororium.com&lt;/a&gt; if you want to see the output. If you have done something similar or have thoughts on the parallel-vs-sequential tradeoff for large files, I want to hear it.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>claude</category>
      <category>devops</category>
      <category>ai</category>
    </item>
    <item>
      <title>Building a CRM clients section: 8 technical decisions and why I made them</title>
      <dc:creator>Кирилл Несмелов</dc:creator>
      <pubDate>Mon, 03 Aug 2026 10:53:59 +0000</pubDate>
      <link>https://dev.to/__615f3acc0/building-a-crm-clients-section-8-technical-decisions-and-why-i-made-them-2kf1</link>
      <guid>https://dev.to/__615f3acc0/building-a-crm-clients-section-8-technical-decisions-and-why-i-made-them-2kf1</guid>
      <description>&lt;p&gt;When I started rewriting the clients section of Melororium (a team CRM for agencies), I expected a few days of work. It took three weeks. Not because the features were complex individually, but because the design decisions compounded — each one constrained the next.&lt;/p&gt;

&lt;p&gt;This post documents eight decisions from that sprint: the problem each one solved, the alternatives I considered, and what the implementation looks like in practice. It is written for developers building similar systems.&lt;/p&gt;

&lt;p&gt;The stack: Next.js App Router, Zustand for client state, Server Actions for mutations, PostgreSQL for persistence.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Client health score: client-side vs server-side
&lt;/h2&gt;

&lt;p&gt;The requirement: A 0–100 score per client that reflects current relationship health, recalculating when relevant data changes.&lt;/p&gt;

&lt;p&gt;I initially planned a server-side job: run every night, write scores to a health_score column, surface them in the UI.&lt;/p&gt;

&lt;p&gt;I killed this after thinking through the latency. If a PM logs a contact call at 2pm, the score updates tomorrow morning. That breaks the core use case.&lt;/p&gt;

&lt;p&gt;The solution: compute client-side from the Zustand store on every render.&lt;/p&gt;

&lt;p&gt;The algorithm is additive:&lt;/p&gt;

&lt;p&gt;base = 50&lt;/p&gt;

&lt;p&gt;// Work signals&lt;br&gt;
if (client.activeTasks &amp;gt; 0) score += 25&lt;br&gt;
else score -= 15&lt;/p&gt;

&lt;p&gt;if (client.activeProjects &amp;gt; 0) score += 10&lt;/p&gt;

&lt;p&gt;// Contact recency&lt;br&gt;
const daysSinceContact = diffInDays(today, client.lastContactDate)&lt;br&gt;
if (!client.lastContactDate) score -= 10&lt;br&gt;
else if (daysSinceContact &amp;lt;= 14) score += 15&lt;br&gt;
else if (daysSinceContact &amp;lt;= 30) score += 5&lt;br&gt;
else if (daysSinceContact &amp;lt;= 60) score -= 10&lt;br&gt;
else score -= 25&lt;/p&gt;

&lt;p&gt;// Follow-up&lt;br&gt;
if (client.followUpDate) score += 5&lt;/p&gt;

&lt;p&gt;return clamp(score, 0, 100)&lt;/p&gt;

&lt;p&gt;This runs inside a useMemo with [clients, tasks, projects] as dependencies. Zero latency.&lt;/p&gt;

&lt;p&gt;The tradeoff I accepted: no cross-session consistency. Two users compute the same score from the same data — fine. When one changes data, their view updates and the other's will on next render — also fine.&lt;/p&gt;

&lt;p&gt;What I would add next: Invoice payment status as a negative signal.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Health buckets: three states not two
&lt;/h2&gt;

&lt;p&gt;Most health score implementations produce a number and render it as a gradient. I wanted actionable buckets.&lt;/p&gt;

&lt;p&gt;function getHealthBucket(client, score) {&lt;br&gt;
  const hasNoActiveTasks = client.activeTasks === 0&lt;br&gt;
  const neverContacted = !client.lastContactDate&lt;br&gt;
  const staleContact = diffInDays(today, client.lastContactDate) &amp;gt; 60&lt;/p&gt;

&lt;p&gt;if (hasNoActiveTasks &amp;amp;&amp;amp; (neverContacted || staleContact)) {&lt;br&gt;
    return 'dormant'&lt;br&gt;
  }&lt;br&gt;
  if (score &amp;lt; 50) {&lt;br&gt;
    return 'at-risk'&lt;br&gt;
  }&lt;br&gt;
  return 'healthy'&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Dormant is not just "very low score." It specifically identifies: no active work AND (never contacted OR contact older than 60 days). These clients may have churned silently — they are not unhappy, they are just gone.&lt;/p&gt;

&lt;p&gt;The reasons array builds in parallel with score calculation:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5bxf1ci23zh8fogklbfn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5bxf1ci23zh8fogklbfn.png" alt=" " width="800" height="574"&gt;&lt;/a&gt;&lt;br&gt;
const reasons = []&lt;br&gt;
if (!client.lastContactDate) reasons.push('Never contacted')&lt;br&gt;
else if (daysSinceContact &amp;gt; 60) reasons.push(&lt;code&gt;${daysSinceContact}d since contact&lt;/code&gt;)&lt;br&gt;
if (client.activeTasks === 0) reasons.push('No active work')&lt;br&gt;
if (overdueFollowUp) reasons.push('Follow-up overdue')&lt;/p&gt;

&lt;p&gt;Renders as: "No active work · 47d since contact." The PM reading this row knows what to do.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Briefing status: three-state tracking
&lt;/h2&gt;

&lt;p&gt;Requirement: Track whether a recipient received, opened, and responded to a briefing form.&lt;/p&gt;

&lt;p&gt;Three states: Awaiting, Viewed, Responded. The middle state (Viewed) requires a tracking endpoint.&lt;/p&gt;

&lt;p&gt;async function markBriefViewed(recipientId) {&lt;br&gt;
  await db&lt;br&gt;
    .update(briefingRecipients)&lt;br&gt;
    .set({ viewedAt: new Date() })&lt;br&gt;
    .where(eq(briefingRecipients.id, recipientId))&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;This fires on form page load. Known limitation: if the recipient forwards the link and a colleague opens it, the original recipient's record shows Viewed. Fixing this requires email-specific tokens — a larger implementation. For V1, Viewed means "the link was opened from somewhere."&lt;/p&gt;




&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F568hzasqu6j2knx8bxw5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F568hzasqu6j2knx8bxw5.png" alt=" " width="800" height="573"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Booking system: timezone handling
&lt;/h2&gt;

&lt;p&gt;Requirement: Agency in Kyiv configures availability. Client in London books a slot. No timezone confusion.&lt;/p&gt;

&lt;p&gt;Store all availability in the configured timezone. Convert to local timezone at display time in the visitor's browser.&lt;/p&gt;

&lt;p&gt;function computeAvailability(date, config) {&lt;br&gt;
  const slots = []&lt;br&gt;
  const windowStart = setTimeInTz(date, config.startTime, config.timezone)&lt;br&gt;
  const windowEnd = setTimeInTz(date, config.endTime, config.timezone)&lt;/p&gt;

&lt;p&gt;let cursor = windowStart&lt;br&gt;
  while (cursor &amp;lt; windowEnd) {&lt;br&gt;
    const slotEnd = addMinutes(cursor, config.duration)&lt;br&gt;
    if (slotEnd &amp;gt; windowEnd) break&lt;br&gt;
    slots.push({&lt;br&gt;
      startUtc: cursor.toISOString(),&lt;br&gt;
      endUtc: slotEnd.toISOString(),&lt;br&gt;
    })&lt;br&gt;
    cursor = slotEnd&lt;br&gt;
  }&lt;br&gt;
  return slots&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Slots return as UTC ISO strings. The booking page component converts to the visitor's local timezone:&lt;/p&gt;

&lt;p&gt;function formatSlotLocal(utcString) {&lt;br&gt;
  return new Intl.DateTimeFormat(navigator.language, {&lt;br&gt;
    hour: '2-digit',&lt;br&gt;
    minute: '2-digit',&lt;br&gt;
    timeZoneName: 'short',&lt;br&gt;
  }).format(new Date(utcString))&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;I limited to 16 timezone options rather than all 500+ IANA zones. A curated 16 is better UX than an exhaustive dropdown nobody can navigate.&lt;/p&gt;

&lt;p&gt;The validation I almost missed: the slot window must accommodate at least one meeting. If duration is 60 minutes and window is 45 minutes, the system blocks you from publishing rather than showing an empty calendar.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. CSV export: injection attack prevention
&lt;/h2&gt;

&lt;p&gt;Client records sometimes start with formula characters: =, +, -, @. When a CSV opens in Excel, cells with these prefixes execute as formulas.&lt;/p&gt;

&lt;p&gt;function sanitizeCsvCell(value) {&lt;br&gt;
  if (!value) return ''&lt;br&gt;
  const dangerous = ['=', '+', '-', '@', '\t', '\r']&lt;br&gt;
  if (dangerous.some(char =&amp;gt; value.startsWith(char))) {&lt;br&gt;
    return &lt;code&gt;'${value}&lt;/code&gt; // prefix with single quote&lt;br&gt;
  }&lt;br&gt;
  return value&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;A single quote prefix tells Excel to treat the cell as text, not a formula. The quote is invisible in the spreadsheet. The original value "=SUM(A1)" becomes '=SUM(A1) in the CSV and displays as =SUM(A1) in the sheet, inert.&lt;/p&gt;

&lt;p&gt;This is not hypothetical. Melororium is multi-user. An agency might import contacts from an external source with crafted values. The person who exports the client list is not always the person who provided the data.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Document system: folder-based file management
&lt;/h2&gt;

&lt;p&gt;Flat lists fail at scale. After 30 files, finding "the contract from March" requires metadata tags or memory. Hierarchical folders provide spatial memory.&lt;/p&gt;

&lt;p&gt;interface ClientDocument {&lt;br&gt;
  id: string&lt;br&gt;
  clientId: string&lt;br&gt;
  folderId: string | null  // null = root&lt;br&gt;
  name: string&lt;br&gt;
  size: number&lt;br&gt;
  mimeType: string&lt;br&gt;
  url: string&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;The UI limits navigation to two levels. Deeper nesting creates complexity that exceeds the benefit for typical agency file volumes.&lt;/p&gt;

&lt;p&gt;The drag-and-drop zone uses a depth counter to handle nested drag events:&lt;/p&gt;

&lt;p&gt;const [dragDepth, setDragDepth] = useState(0)&lt;br&gt;
const isDragging = dragDepth &amp;gt; 0&lt;/p&gt;

&lt;p&gt;const handleDragEnter = () =&amp;gt; setDragDepth(d =&amp;gt; d + 1)&lt;br&gt;
const handleDragLeave = () =&amp;gt; setDragDepth(d =&amp;gt; d - 1)&lt;/p&gt;

&lt;p&gt;Without this, dragleave fires when the cursor moves from the panel to a child element (a file row), falsely resetting drag state. The counter tracks depth and only clears when count reaches zero.&lt;/p&gt;

&lt;p&gt;File size limit: 500MB. The cost is roughly $0.01/month per 500MB file. The constraint is upload reliability on slow connections. We mitigate with multipart uploads.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. Recurring invoice detection in the invoice list
&lt;/h2&gt;

&lt;p&gt;Invoices can be recurring. The invoice form stores recurring data in the notes field as a prefix: [Recurring: day 15 at 09:00] Original notes...&lt;/p&gt;

&lt;p&gt;This was a schema constraint. The recurring metadata should be a separate column. It is not, because the invoice schema was locked before the recurring feature was designed, and migration was out of scope.&lt;/p&gt;

&lt;p&gt;The solution: parse the prefix at render time.&lt;/p&gt;

&lt;p&gt;const RECURRING_PREFIX = /^[Recurring: day (\d+) at (\d{2}:\d{2})]/&lt;/p&gt;

&lt;p&gt;function extractRecurringInfo(notes) {&lt;br&gt;
  const match = notes?.match(RECURRING_PREFIX)&lt;br&gt;
  if (!match) return { isRecurring: false, cleanNotes: notes ?? '' }&lt;br&gt;
  return {&lt;br&gt;
    isRecurring: true,&lt;br&gt;
    day: parseInt(match[1]),&lt;br&gt;
    time: match[2],&lt;br&gt;
    cleanNotes: notes.replace(RECURRING_PREFIX, '').trim()&lt;br&gt;
  }&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;O(n) per invoice, paginated at 20-50 items — negligible cost. The cleanNotes field strips the prefix before displaying to the user. I document this as technical debt with a clear migration path.&lt;/p&gt;




&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh9njz8hovmbdr8e06mdu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh9njz8hovmbdr8e06mdu.png" alt=" " width="800" height="569"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Invoice aging: segment bar visualization
&lt;/h2&gt;

&lt;p&gt;Aging data computed during the invoice list query:&lt;/p&gt;

&lt;p&gt;interface AgingBuckets {&lt;br&gt;
  le30: number    // 0-30 days overdue&lt;br&gt;
  d3160: number   // 31-60 days&lt;br&gt;
  gt60: number    // 60+ days&lt;br&gt;
  oldestDays: number  // max days overdue&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;The bar renders three segments proportional to count. Edge case: if one bucket has zero invoices, its segment collapses to zero width:&lt;/p&gt;

&lt;p&gt;// flex: count gives proportional widths&lt;br&gt;
// flex: 0 with no minimum collapses to zero (not 1px line)&lt;/p&gt;

&lt;p&gt;The oldestDays field drives a secondary indicator: if any invoice exceeds 90 days overdue, the entire stat card gets a red border regardless of bucket distribution.&lt;/p&gt;




&lt;h2&gt;
  
  
  9. Activity feed aggregation across event types
&lt;/h2&gt;

&lt;p&gt;Requirement: A chronological feed per client showing all significant events, grouped by day.&lt;/p&gt;

&lt;p&gt;Rather than a dedicated events table, I derive the feed from existing data on render.&lt;/p&gt;

&lt;p&gt;function buildActivityFeed(client, tasks, invoices, documents, briefingResponses, bookings) {&lt;br&gt;
  const events = []&lt;/p&gt;

&lt;p&gt;tasks.forEach(task =&amp;gt; {&lt;br&gt;
    if (task.clientId === client.id) {&lt;br&gt;
      if (task.completedAt) {&lt;br&gt;
        events.push({ type: 'task_completed', date: task.completedAt, data: task })&lt;br&gt;
      }&lt;br&gt;
      events.push({ type: 'task_created', date: task.createdAt, data: task })&lt;br&gt;
    }&lt;br&gt;
  })&lt;/p&gt;

&lt;p&gt;invoices.filter(inv =&amp;gt; inv.clientId === client.id).forEach(inv =&amp;gt; {&lt;br&gt;
    if (inv.paidAt) events.push({ type: 'invoice_paid', date: inv.paidAt, data: inv })&lt;br&gt;
    events.push({ type: 'invoice_sent', date: inv.sentAt || inv.createdAt, data: inv })&lt;br&gt;
  })&lt;/p&gt;

&lt;p&gt;// ... similar for documents, briefings, bookings&lt;/p&gt;

&lt;p&gt;return events.sort((a, b) =&amp;gt; new Date(b.date).getTime() - new Date(a.date).getTime())&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Memoized with useMemo and a dependency array. Re-computation only when source data changes.&lt;/p&gt;

&lt;p&gt;Day grouping uses string comparison to avoid timezone bugs:&lt;/p&gt;

&lt;p&gt;function groupByDay(events) {&lt;br&gt;
  return events.reduce((groups, event) =&amp;gt; {&lt;br&gt;
    const day = event.date.split('T')[0]  // '2026-07-31'&lt;br&gt;
    if (!groups[day]) groups[day] = []&lt;br&gt;
    groups[day].push(event)&lt;br&gt;
    return groups&lt;br&gt;
  }, {})&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;function getDayLabel(dateStr, today, yesterday) {&lt;br&gt;
  if (dateStr === today) return 'Today'&lt;br&gt;
  if (dateStr === yesterday) return 'Yesterday'&lt;br&gt;
  return new Intl.DateTimeFormat('en', { day: 'numeric', month: 'short', year: 'numeric' }).format(new Date(dateStr))&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;The path to a dedicated events table is non-breaking: add the table, write events on actions, fall back to derived feed for backfill.&lt;/p&gt;




&lt;h2&gt;
  
  
  10. State management: Zustand over context
&lt;/h2&gt;

&lt;p&gt;The clients section reads the same data across many components simultaneously. A contact log update should ripple to the health score, the activity feed, and the billing section without prop drilling or a parent that re-renders everything.&lt;/p&gt;

&lt;p&gt;React Context re-renders every consumer when any part of context changes. For 30+ clients with multiple computed values, that is expensive.&lt;/p&gt;

&lt;p&gt;Zustand solves this with granular subscriptions via selectors:&lt;/p&gt;

&lt;p&gt;// Billing tab only re-renders when clients change&lt;br&gt;
const recurringClients = useClientStore(&lt;br&gt;
  state =&amp;gt; state.clients.filter(c =&amp;gt; c.recurring &amp;amp;&amp;amp; c.monthlyPayment &amp;gt; 0)&lt;br&gt;
)&lt;/p&gt;

&lt;p&gt;// Health section subscribes to all three sources&lt;br&gt;
const { clients, tasks, projects } = useClientStore(&lt;br&gt;
  state =&amp;gt; ({ clients: state.clients, tasks: state.tasks, projects: state.projects })&lt;br&gt;
)&lt;/p&gt;

&lt;p&gt;Zustand does a shallow equality check on the selected value. If the selector returns the same reference, the component does not re-render.&lt;/p&gt;

&lt;p&gt;Initial data loads server-side via Server Actions and hydrates the store. Mutations go through optimistic updates that update the store before the server confirms.&lt;/p&gt;




&lt;h2&gt;
  
  
  11. Bulk operations with optimistic updates
&lt;/h2&gt;

&lt;p&gt;Select 8 clients, click "Create invoices." The user expects immediate feedback.&lt;/p&gt;

&lt;p&gt;async function bulkCreateInvoices(clientIds) {&lt;br&gt;
  // Optimistic: mark all as pending immediately&lt;br&gt;
  clientIds.forEach(id =&amp;gt; {&lt;br&gt;
    updateClientStore(id, { pendingInvoice: true })&lt;br&gt;
  })&lt;/p&gt;

&lt;p&gt;// Parallel server requests&lt;br&gt;
  const results = await Promise.allSettled(&lt;br&gt;
    clientIds.map(id =&amp;gt; createInvoiceAction(id))&lt;br&gt;
  )&lt;/p&gt;

&lt;p&gt;// Resolve each independently&lt;br&gt;
  results.forEach((result, index) =&amp;gt; {&lt;br&gt;
    const clientId = clientIds[index]&lt;br&gt;
    if (result.status === 'fulfilled') {&lt;br&gt;
      updateClientStore(clientId, { pendingInvoice: false, lastInvoiceId: result.value.id })&lt;br&gt;
    } else {&lt;br&gt;
      updateClientStore(clientId, { pendingInvoice: false, invoiceError: result.reason.message })&lt;br&gt;
    }&lt;br&gt;
  })&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Promise.allSettled instead of Promise.all: one failed invoice creation does not reject the entire batch. The user sees 7 invoices created and 1 with an error — they retry the single failed one.&lt;/p&gt;

&lt;p&gt;The pending state in the UI is a loading spinner per client row, not a global overlay. The user can continue scrolling while invoices create in the background.&lt;/p&gt;




&lt;h2&gt;
  
  
  12. CSV import validation and limits
&lt;/h2&gt;

&lt;p&gt;The 2,000 row limit is not arbitrary. Above that threshold, the import runs long enough that users navigate away before it completes.&lt;/p&gt;

&lt;p&gt;Validation runs before any write. Parse the entire CSV, validate every row, collect all errors, then either proceed or return an error report. Never partial-import.&lt;/p&gt;

&lt;p&gt;function validateImportCSV(rows) {&lt;br&gt;
  const valid = []&lt;br&gt;
  const errors = []&lt;/p&gt;

&lt;p&gt;rows.forEach((row, index) =&amp;gt; {&lt;br&gt;
    const rowNumber = index + 2  // account for header row&lt;br&gt;
    const [name, email, monthlyPayment] = row&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if (!name?.trim()) {
  errors.push({ row: rowNumber, field: 'name', message: 'Name is required' })
  return
}
if (email &amp;amp;&amp;amp; !isValidEmail(email)) {
  errors.push({ row: rowNumber, field: 'email', message: 'Invalid email format' })
  return
}

const payment = parseFloat(monthlyPayment)
if (monthlyPayment &amp;amp;&amp;amp; (isNaN(payment) || payment &amp;lt; 0)) {
  errors.push({ row: rowNumber, field: 'monthlyPayment', message: 'Payment must be a positive number' })
  return
}

valid.push({ name: name.trim(), email: email?.trim(), monthlyPayment: payment || 0 })
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;})&lt;/p&gt;

&lt;p&gt;return { valid, errors }&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Error report shows row numbers, field names, and human-readable messages. Not "Row 47 failed." Specifically: "Row 47, email: Invalid email format."&lt;/p&gt;




&lt;h2&gt;
  
  
  What the sprint taught me about product design
&lt;/h2&gt;

&lt;p&gt;One pattern in retrospect across all twelve decisions: the right call was almost always "derive from existing data before adding a new data model." The health score derives from tasks, contacts, and projects. The activity feed derives from six existing collections. The recurring invoice metadata embeds in the notes field rather than a new table. Each one carries technical debt, and each one let us ship in three weeks instead of six. The refactors are on the roadmap with clear migration paths. The debt is intentional and bounded.&lt;/p&gt;

&lt;p&gt;Three things I will carry into the next sprint:&lt;/p&gt;

&lt;p&gt;Every feature should answer one specific question. The health score answers "which clients need attention?" The briefing tracker answers "who responded?" When I could not state the question a feature answered, the feature needed more scoping.&lt;/p&gt;

&lt;p&gt;The "Why" column is cheaper than it looks and more valuable than it appears. Adding reason strings to scoring functions takes about 20 lines per algorithm. The payoff is a UI where every actionable item explains itself.&lt;/p&gt;

&lt;p&gt;Two-state tracking hides the most actionable state. Sent/not-responded loses the middle. Healthy/unhealthy loses the dormant. The design question is always "what is the actionable difference between the two extremes?" — that difference is almost always a third state worth surfacing.&lt;/p&gt;




&lt;p&gt;The full source for the features above lives inside Melororium — a team workspace for agencies at &lt;a href="https://melororium.com" rel="noopener noreferrer"&gt;https://melororium.com&lt;/a&gt;. Agency plan is $59/month flat for up to 10 people. 14-day free demo, no credit card required.&lt;/p&gt;

&lt;p&gt;If you have questions on the implementations above, I am in the comments.&lt;/p&gt;

</description>
      <category>saas</category>
      <category>webdev</category>
      <category>programming</category>
      <category>productivity</category>
    </item>
    <item>
      <title>I Built Full Team Management Into My Agency SaaS. Here's the Complete Feature Spec.</title>
      <dc:creator>Кирилл Несмелов</dc:creator>
      <pubDate>Wed, 29 Jul 2026 06:45:21 +0000</pubDate>
      <link>https://dev.to/__615f3acc0/i-built-full-team-management-into-my-agency-saas-heres-the-complete-feature-spec-5be4</link>
      <guid>https://dev.to/__615f3acc0/i-built-full-team-management-into-my-agency-saas-heres-the-complete-feature-spec-5be4</guid>
      <description>&lt;p&gt;I've been building Melororium ( &lt;a href="https://melororium.com" rel="noopener noreferrer"&gt;https://melororium.com&lt;/a&gt; ) — a flat-fee workspace for agencies — for about eight months. The Team section turned out to be one of the densest features in the product. Five tabs, eight distinct data domains, two add-on modules, and one of the trickier design problems I've hit: anonymous feedback that's actually useful.&lt;/p&gt;

&lt;p&gt;Here's the full breakdown of what I shipped.&lt;/p&gt;

&lt;p&gt;Architecture: five tabs, one shared data layer&lt;br&gt;
The Team page (route: /employees) has five tabs:&lt;/p&gt;

&lt;p&gt;Team — the member table and sub-team management&lt;br&gt;
Requests — pending join requests and sent invitations&lt;br&gt;
Time Off — leave management&lt;br&gt;
Onboarding — role-specific checklists (module-gated)&lt;br&gt;
Sentiment — weekly mood tracking (module-gated)&lt;br&gt;
All tabs read from a shared CRM store (Zustand) that loads employee data, project data, and time logs on mount. This means switching tabs doesn't trigger new fetches — the productivity metrics on a member profile come from client-side aggregation of the already-loaded store.&lt;/p&gt;

&lt;p&gt;// Productivity metrics derived in the client — no extra API call&lt;br&gt;
const myTimeLogs = storeTimeLogs.filter(log =&amp;gt; log.user === memberFullName);&lt;br&gt;
const totalTimeSeconds = myTimeLogs.reduce((s, log) =&amp;gt;&lt;br&gt;
  s + Math.round(log.durationHours * 3600), 0&lt;br&gt;
);&lt;br&gt;
const totalMoney = myTimeLogs.reduce((s, log) =&amp;gt; s + (log.cost || 0), 0);&lt;br&gt;
const completionRate = myTasks.length &amp;gt; 0&lt;br&gt;
  ? Math.round((closedTasks.length / myTasks.length) * 100) : 0;&lt;br&gt;
The header widgets: real-time team state&lt;br&gt;
Four stat cards at the top answer "what's happening with the team right now":&lt;/p&gt;

&lt;p&gt;// Active today: signed in within 24 hours&lt;br&gt;
const active = members.filter(m =&amp;gt;&lt;br&gt;
  m.lastSeenRaw &amp;amp;&amp;amp;&lt;br&gt;
  (Date.now() - new Date(m.lastSeenRaw).getTime() &amp;lt; 24 * 3_600_000)&lt;br&gt;
).length;&lt;/p&gt;

&lt;p&gt;// On leave right now: approved leave covering today&lt;br&gt;
function activeLeaveCount(reqs) {&lt;br&gt;
  const today = new Date();&lt;br&gt;
  today.setHours(0, 0, 0, 0);&lt;br&gt;
  const t = today.getTime();&lt;br&gt;
  return reqs.filter(r =&amp;gt; {&lt;br&gt;
    if (r.status !== "approved") return false;&lt;br&gt;
    const s = parseDayMs(r.startDate), e = parseDayMs(r.endDate);&lt;br&gt;
    return s !== null &amp;amp;&amp;amp; e !== null &amp;amp;&amp;amp; t &amp;gt;= s &amp;amp;&amp;amp; t &amp;lt;= e;&lt;br&gt;
  }).length;&lt;br&gt;
}&lt;br&gt;
The "on leave" count was deceptively tricky. Date parsing from stored strings needs to handle both ISO format (2026-07-20) and localized strings (Jul 20, 2026), and needs to treat the end date as inclusive. I ended up with a parseDayMs function that detects ISO format and parses it as local date (avoiding UTC offset drift) before falling back to new Date(str).&lt;/p&gt;

&lt;p&gt;Sub-teams: tabs, leads, icons, drag-to-reorder&lt;br&gt;
Sub-teams are stored in a teams table with members as a relation. The UI renders them as tabs above the member table. Clicking a tab filters the list to that team's members.&lt;/p&gt;

&lt;p&gt;Key behaviors:&lt;/p&gt;

&lt;p&gt;Team lead is set per team and gets hoisted to the top of the filtered list via the sort function&lt;br&gt;
Members move between teams via moveMember(userId, teamId) — or moveMember(userId, null) to remove from all teams&lt;br&gt;
Teams reorder via drag-and-drop: the dragged team's tab gets a hover target on other tabs, and reorderTeams(newOrder) persists the result&lt;br&gt;
16 icon options render from a TEAM_ICONS array with Lucide components&lt;/p&gt;

&lt;p&gt;const handleTeamDrop = (targetId: string) =&amp;gt; {&lt;br&gt;
  if (!dragTeam || dragTeam === targetId) { setDragTeam(null); return; }&lt;br&gt;
  const order = teams.map(t =&amp;gt; t.id);&lt;br&gt;
  const from = order.indexOf(dragTeam);&lt;br&gt;
  const to = order.indexOf(targetId);&lt;br&gt;
  order.splice(to, 0, order.splice(from, 1)[0]);&lt;br&gt;
  setTeams(order.map(id =&amp;gt; teams.find(t =&amp;gt; t.id === id)!));&lt;br&gt;
  setDragTeam(null);&lt;br&gt;
  reorderTeams(order).catch(() =&amp;gt; {});&lt;br&gt;
};&lt;br&gt;
The member table: sorting, bulk actions, inline editing&lt;br&gt;
The table has these columns: name, position, department, local time now, role, hourly rate, leave status, active projects, last seen, joined.&lt;/p&gt;

&lt;p&gt;Sorting cycles through neutral &amp;gt; asc &amp;gt; desc &amp;gt; neutral:&lt;/p&gt;

&lt;p&gt;const toggleSort = (key: string) =&amp;gt; {&lt;br&gt;
  if (sortKey !== key) { setSortKey(key); setSortDir("asc"); }&lt;br&gt;
  else if (sortDir === "asc") setSortDir("desc");&lt;br&gt;
  else setSortKey(null);&lt;br&gt;
};&lt;br&gt;
The localTime sort key sorts by timezone string — alphabetical by TZ identifier, which isn't perfect but keeps UTC-offset-adjacent timezones grouped.&lt;/p&gt;

&lt;p&gt;Bulk select state lives in a Set of IDs. Three bulk actions: export selected members as CSV, open mailto with all their emails, move to team. The CSV export runs client-side from the in-memory member data.&lt;/p&gt;

&lt;p&gt;Inline rate editing uses a controlled  that appears on click with a ref-driven focus:&lt;/p&gt;

&lt;p&gt;const saveRate = async (m: Member, raw: string) =&amp;gt; {&lt;br&gt;
  setEditingRate(null);&lt;br&gt;
  const val = Math.max(0, Math.round(Number(raw)));&lt;br&gt;
  if (!Number.isFinite(val) || val === m.hourlyRate) return;&lt;br&gt;
  updateEmployee(m.id, { hourlyRate: val, rateUpdatedAt: new Date().toISOString() }); // optimistic&lt;br&gt;
  await dbUpdateEmployee(m.id, { hourlyRate: val }).catch(() =&amp;gt; {&lt;br&gt;
    toast.error("Couldn't save rate");&lt;br&gt;
  });&lt;br&gt;
};&lt;br&gt;
The sentiment module: anonymity vs. usefulness&lt;br&gt;
The anonymity tradeoff is where I spent the most design time.&lt;/p&gt;

&lt;p&gt;The problem: if sentiment scores are fully anonymous, I can show trend data but the admin can't act on specific problems. If they're attributable, members self-censor and the data is useless.&lt;/p&gt;

&lt;p&gt;My solution: scores and comments are stored with a userId but the admin UI never exposes which user submitted which comment. The AI report generator also operates on aggregated data only. The only thing the admin sees per-user is whether they responded (count: "7 of 8 responded"), not what they said.&lt;/p&gt;

&lt;p&gt;The survey rotation system lets you sequence templates across weeks so members don't see the same questions back-to-back:&lt;/p&gt;

&lt;p&gt;// scheduled auto-send: runs when the sentiment tab loads, once per session&lt;br&gt;
checkScheduleAndSend().then(res =&amp;gt; {&lt;br&gt;
  if (res.sent) {&lt;br&gt;
    toast.success("Survey auto-sent per schedule");&lt;br&gt;
    setSentimentLoaded(false); // reload dashboard&lt;br&gt;
  }&lt;br&gt;
});&lt;br&gt;
The schedule check runs server-side and compares the configured day/time/timezone against the last sent timestamp. If the current time is past the scheduled window and no survey went out this week, it sends automatically.&lt;/p&gt;

&lt;p&gt;The invite flow: everything upfront&lt;br&gt;
The invite modal lets you:&lt;/p&gt;

&lt;p&gt;Add multiple emails at once (tab/comma/semicolon separates them)&lt;br&gt;
Set role (Admin or Member)&lt;br&gt;
Assign to a team&lt;br&gt;
Assign to projects&lt;br&gt;
Attach an onboarding template&lt;br&gt;
Add a welcome note&lt;br&gt;
The onboarding template assignment happens in the same server action as the invite creation, so the new member's checklist is ready before they click the link. The alternative — assigning onboarding after they join — creates a window where someone's in the workspace but nobody has thought through what they're supposed to do first.&lt;/p&gt;

&lt;p&gt;What I'd do differently&lt;br&gt;
The per-member productivity tab does client-side aggregation from the full time log store. This works fine at small scale (the store caps out around 500 log entries before paginating) but it's not the right architecture if you're expecting hundreds of members or thousands of time logs. A dedicated GET /api/employees/:id/metrics?period=all endpoint would serve the aggregated numbers directly and make the productivity tab faster on load.&lt;/p&gt;

&lt;p&gt;The sentiment scheduling also deserves its own service. Right now the auto-send check runs in the browser when someone navigates to the sentiment tab. If nobody opens the page on Friday, the survey doesn't go out. A proper cron job or a serverless scheduled function is the right fix — it's on the roadmap.&lt;/p&gt;

&lt;p&gt;Shipping the feature with the browser-triggered approach let me validate the scheduling concept without infrastructure overhead. Now that the concept is validated, the infrastructure upgrade is worth doing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://melororium.com" rel="noopener noreferrer"&gt;https://melororium.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>saas</category>
      <category>webdev</category>
      <category>productivity</category>
    </item>
    <item>
      <title>How I added Gemini + GPT-4 + Claude support in one settings toggle (Next.js SaaS)</title>
      <dc:creator>Кирилл Несмелов</dc:creator>
      <pubDate>Tue, 23 Jun 2026 12:30:06 +0000</pubDate>
      <link>https://dev.to/__615f3acc0/how-i-added-gemini-gpt-4-claude-support-in-one-settings-toggle-nextjs-saas-4elb</link>
      <guid>https://dev.to/__615f3acc0/how-i-added-gemini-gpt-4-claude-support-in-one-settings-toggle-nextjs-saas-4elb</guid>
      <description>&lt;p&gt;Build log from this week: I added multi-provider AI support to Melororium — users can now switch between Gemini, ChatGPT, and Claude in workspace settings, and it applies across the whole platform.&lt;/p&gt;

&lt;p&gt;Here's the pattern I used.&lt;/p&gt;

&lt;p&gt;The problem with hardcoding one provider&lt;br&gt;
I had Gemini wired directly into every AI action. Every ai.ts function called the Gemini SDK. If a user wanted a different provider, I'd have to touch every function.&lt;/p&gt;

&lt;p&gt;Beyond the code problem: different teams have different constraints. Some can't send data to Google's servers. Some are standardized on OpenAI internally. Forcing one provider was my product decision intruding on their infrastructure policy.&lt;/p&gt;

&lt;p&gt;The solution: one adapter layer&lt;br&gt;
I created a single getAIClient() function that reads the workspace's aiProvider setting from the DB and returns the right SDK client:&lt;/p&gt;

&lt;p&gt;export async function getAIClient(workspaceId: string) {&lt;br&gt;
  const settings = await getWorkspaceSettings(workspaceId);&lt;/p&gt;

&lt;p&gt;switch (settings.aiProvider) {&lt;br&gt;
    case 'openai':&lt;br&gt;
      return new OpenAI({ apiKey: process.env.OPENAI_API_KEY });&lt;br&gt;
    case 'anthropic':&lt;br&gt;
      return new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });&lt;br&gt;
    default:&lt;br&gt;
      return new GoogleGenerativeAI(process.env.GEMINI_API_KEY);&lt;br&gt;
  }&lt;br&gt;
}&lt;br&gt;
Every AI action calls getAIClient() first. Prompt construction, response parsing, and error handling stay the same regardless of which client gets returned.&lt;/p&gt;

&lt;p&gt;Why BYOK matters here&lt;br&gt;
Melororium uses BYOK (bring your own key). Users paste their own API key in settings. This keeps costs transparent — they see exactly what they're spending on AI inference. It also keeps me out of the billing relationship entirely.&lt;/p&gt;

&lt;p&gt;The adapter pattern works cleanly with BYOK because each provider's key is stored separately in workspace settings.&lt;/p&gt;

&lt;p&gt;One thing I'd do differently&lt;br&gt;
Response shapes differ between providers. OpenAI, Anthropic, and Gemini return structured responses in slightly different formats. Right now I normalize them after the call in each action. A better approach would be a thin response adapter inside getAIClient() that normalizes everything before it returns. Planning to refactor that.&lt;/p&gt;

&lt;p&gt;Also shipped this week&lt;br&gt;
Along with the provider switching:&lt;/p&gt;

&lt;p&gt;AI analytics at two levels: all-projects overview and per-project drill-down&lt;br&gt;
Subtasks inside the task drawer (kanban nested structure)&lt;br&gt;
Work timer day view redesigned to show graphical time blocks per team member&lt;br&gt;
Per-worker timer breakdown with task-level detail&lt;br&gt;
Building Melororium as a pay-once team workspace (one-time purchase, no subscription). Writing about the build as I go.&lt;/p&gt;

&lt;p&gt;Anything you'd do differently with the provider switching pattern? Curious whether others use a similar adapter or something more formal.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>typescript</category>
      <category>ai</category>
      <category>saas</category>
    </item>
    <item>
      <title>I built a modular SaaS and let users bring their own AI. Here's why.</title>
      <dc:creator>Кирилл Несмелов</dc:creator>
      <pubDate>Tue, 16 Jun 2026 08:18:08 +0000</pubDate>
      <link>https://dev.to/__615f3acc0/i-built-a-modular-saas-and-let-users-bring-their-own-ai-heres-why-3e51</link>
      <guid>https://dev.to/__615f3acc0/i-built-a-modular-saas-and-let-users-bring-their-own-ai-heres-why-3e51</guid>
      <description>&lt;p&gt;I've been building Melororium — a lifetime project management workspace for small teams — and the two most interesting product decisions I made weren't about features. They were about what not to include by default.&lt;/p&gt;

&lt;p&gt;The architecture problem with "all-in-one" tools&lt;/p&gt;

&lt;p&gt;Every project management tool eventually tries to be everything. ClickUp added Docs. Notion added tasks. Asana added timelines. The result is tools that are bloated for most users and still missing the one thing you actually need.&lt;/p&gt;

&lt;p&gt;When I started building Melororium, I kept asking: what does every team actually use every single day?&lt;/p&gt;

&lt;p&gt;The answer was shorter than I expected:&lt;/p&gt;

&lt;p&gt;Task management (Kanban + List + Calendar)&lt;br&gt;
Time tracking inside tasks&lt;br&gt;
Client CRM&lt;br&gt;
Financial reporting + invoicing&lt;br&gt;
Team management + work reports&lt;br&gt;
Dashboards&lt;br&gt;
That became the 12 core modules — shipped in every plan, no exceptions.&lt;/p&gt;

&lt;p&gt;Everything else became opt-in. Not locked behind a higher tier. Just... not forced on you if you don't need it.&lt;/p&gt;

&lt;p&gt;The AI decision&lt;/p&gt;

&lt;p&gt;This is the one I want to talk about most.&lt;/p&gt;

&lt;p&gt;When I built the first version of AI features, the obvious move was to pick one provider, wrap it, charge for it in a premium tier.&lt;/p&gt;

&lt;p&gt;I didn't do that. Here's why.&lt;/p&gt;

&lt;p&gt;First: teams already have AI preferences. Some are locked into OpenAI for compliance reasons. Some prefer Claude's reasoning for analytical tasks. Some use Gemini because they're already in the Google ecosystem. Making them switch providers — or pay twice for the same model through my platform — felt wrong.&lt;/p&gt;

&lt;p&gt;Second: the margin math is bad for both sides. If I host the AI calls, I'm paying inference costs and either subsidizing them or marking them up. Neither is great. The user pays more than they should. I'm running a business I didn't sign up for.&lt;/p&gt;

&lt;p&gt;Third: AI usage patterns are unpredictable. Some teams will use the AI analytics module ten times a day. Others will open it once a month. Flat-rate AI pricing is always wrong for someone.&lt;/p&gt;

&lt;p&gt;So I built BYOK — bring your own API key.&lt;/p&gt;

&lt;p&gt;export async function getAIClient(workspaceId: string) {&lt;br&gt;
  const settings = await getWorkspaceSettings(workspaceId);&lt;br&gt;
  switch (settings.aiProvider) {&lt;br&gt;
    case 'openai':    return new OpenAI({ apiKey: settings.apiKey });&lt;br&gt;
    case 'anthropic': return new Anthropic({ apiKey: settings.apiKey });&lt;br&gt;
    default:          return new GoogleGenerativeAI(settings.apiKey);&lt;br&gt;
  }&lt;br&gt;
}&lt;br&gt;
One function. Every AI action in the codebase calls getAIClient() first. Provider switching is a single workspace setting. Keys are encrypted at rest, never logged.&lt;/p&gt;

&lt;p&gt;The user connects the provider they already use. They control the costs directly. I don't touch their inference budget.&lt;/p&gt;

&lt;p&gt;The modular pricing that followed&lt;/p&gt;

&lt;p&gt;Once I made AI opt-in, the same logic applied to other modules. Not every team needs the same stack.&lt;/p&gt;

&lt;p&gt;The architecture became:&lt;/p&gt;

&lt;p&gt;Core (12 modules) — included in every plan&lt;br&gt;
Add-ons — picked at purchase, locked per license&lt;br&gt;
Three tiers:&lt;/p&gt;

&lt;p&gt;Starter — $149 once → 4 users, all 12 core modules&lt;br&gt;
Agency — $299 once → 10 users, 12 core + pick 5 add-ons&lt;br&gt;
Studio — $499 once → 25 users, 12 core + all announced add-ons&lt;br&gt;
No recurring fees. No seat tax as you grow within your tier.&lt;/p&gt;

&lt;p&gt;What this forced technically&lt;/p&gt;

&lt;p&gt;Making add-ons genuinely modular — not just hidden behind a feature flag — meant the core and add-on layers had to be cleanly separated from day one.&lt;/p&gt;

&lt;p&gt;Every module has its own data schema, its own API routes, its own UI surface. The workspace config object tracks which add-ons are unlocked. Every protected route checks the license before rendering.&lt;/p&gt;

&lt;p&gt;It's more work upfront. But it means the codebase scales to 40 modules without becoming a tangled mess of conditional rendering and permission checks scattered everywhere.&lt;/p&gt;

&lt;p&gt;Stack, if you're curious&lt;/p&gt;

&lt;p&gt;Next.js 14 App Router, TypeScript, Tailwind. Vercel for deployment. The AI layer sits behind a clean adapter so swapping providers is a 5-line change per new integration.&lt;/p&gt;

&lt;p&gt;Pre-launch now, founding price closes July 30. If you're building something and want to see how the modular add-on system works in practice, drop a question below — happy to go deeper on any part of the architecture.&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>saas</category>
      <category>nextjs</category>
      <category>typescript</category>
    </item>
    <item>
      <title>How I built a switchable AI provider system in Next.js (Gemini GPT-4 Claude, one workspace setting)</title>
      <dc:creator>Кирилл Несмелов</dc:creator>
      <pubDate>Wed, 10 Jun 2026 10:38:52 +0000</pubDate>
      <link>https://dev.to/__615f3acc0/how-i-built-a-switchable-ai-provider-system-in-nextjs-gemini-gpt-4-claude-one-workspace-pjk</link>
      <guid>https://dev.to/__615f3acc0/how-i-built-a-switchable-ai-provider-system-in-nextjs-gemini-gpt-4-claude-one-workspace-pjk</guid>
      <description>&lt;p&gt;I had Gemini hardcoded into every AI function in my codebase. Then a beta user told me her clients have data policies about Google. I rebuilt the whole AI layer in two days. Here's the architecture.&lt;/p&gt;

&lt;p&gt;The wrong way (what I had)&lt;/p&gt;

&lt;p&gt;import { GoogleGenerativeAI } from "@google/generative-ai";&lt;br&gt;
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY!);&lt;/p&gt;

&lt;p&gt;export async function summarizeProject(projectId: string) {&lt;br&gt;
  const model = genAI.getGenerativeModel({ model: "gemini-1.5-flash" });&lt;br&gt;
  const result = await model.generateContent(prompt);&lt;br&gt;
  return result.response.text();&lt;br&gt;
}&lt;br&gt;
Works fine until you need to support another provider. Then you're touching every function.&lt;/p&gt;

&lt;p&gt;The pattern: one factory, one interface&lt;/p&gt;

&lt;p&gt;I defined a single AIClient interface that every provider implements:&lt;/p&gt;

&lt;p&gt;export interface AIClient {&lt;br&gt;
  complete: (prompt: string, systemPrompt?: string) =&amp;gt; Promise;&lt;br&gt;
}&lt;br&gt;
One factory function reads the workspace setting and returns the right client:&lt;/p&gt;

&lt;p&gt;export async function getAIClient(workspaceId: string): Promise {&lt;br&gt;
  const { aiProvider } = await getWorkspaceSettings(workspaceId);&lt;/p&gt;

&lt;p&gt;switch (aiProvider) {&lt;br&gt;
    case "openai":    return createOpenAIClient();&lt;br&gt;
    case "anthropic": return createAnthropicClient();&lt;br&gt;
    default:          return createGeminiClient();&lt;br&gt;
  }&lt;br&gt;
}&lt;br&gt;
Each factory wraps the provider SDK behind the same interface:&lt;/p&gt;

&lt;p&gt;function createAnthropicClient(): AIClient {&lt;br&gt;
  const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY! });&lt;br&gt;
  return {&lt;br&gt;
    complete: async (prompt, systemPrompt) =&amp;gt; {&lt;br&gt;
      const res = await client.messages.create({&lt;br&gt;
        model: "claude-sonnet-4-6",&lt;br&gt;
        max_tokens: 2048,&lt;br&gt;
        ...(systemPrompt ? { system: systemPrompt } : {}),&lt;br&gt;
        messages: [{ role: "user", content: prompt }],&lt;br&gt;
      });&lt;br&gt;
      return res.content[0].type === "text" ? res.content[0].text : "";&lt;br&gt;
    },&lt;br&gt;
  };&lt;br&gt;
}&lt;br&gt;
Every AI action now looks like this — provider-agnostic:&lt;/p&gt;

&lt;p&gt;export async function summarizeProject(projectId: string, workspaceId: string) {&lt;br&gt;
  const ai = await getAIClient(workspaceId);&lt;br&gt;
  const data = await getProjectContext(projectId);&lt;br&gt;
  return ai.complete(buildPrompt(data), "You are a project analyst.");&lt;br&gt;
}&lt;br&gt;
Three decisions worth explaining&lt;/p&gt;

&lt;p&gt;Workspace-level, not user-level. Project data belongs to the workspace. The whole team should have the same answer to "what AI provider processes our data?" — not "depends who's logged in."&lt;/p&gt;

&lt;p&gt;No silent fallbacks. If the selected provider fails, the error propagates. I don't retry with another provider. If someone chose Claude for compliance reasons, silently falling back to Gemini is worse than an explicit error.&lt;/p&gt;

&lt;p&gt;Per-request factory call, not module-level cache. Workspace settings can change. Module-level caching means the old provider keeps running until the function cold-starts. The extra DB read per AI call is negligible next to the actual inference cost.&lt;/p&gt;

&lt;p&gt;The one thing the pattern doesn't solve&lt;/p&gt;

&lt;p&gt;Different models behave differently on the same prompt. Claude follows system prompts very tightly. GPT-4 tends verbose without explicit length instructions. I handle this with thin per-provider prompt adjustments in the factory. It's not elegant but it works.&lt;/p&gt;

&lt;p&gt;If you've solved the "same prompt, different model behavior" problem more cleanly — I'm curious how.&lt;/p&gt;

&lt;p&gt;Building Melororium — pay-once project management for freelancers. Launching July 30.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>saas</category>
      <category>ai</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Day 7: 51 people answered my question. I wasn't ready for what they said.</title>
      <dc:creator>Кирилл Несмелов</dc:creator>
      <pubDate>Mon, 08 Jun 2026 13:18:22 +0000</pubDate>
      <link>https://dev.to/__615f3acc0/day-7-51-people-answeredmy-question-i-wasntready-for-what-they-said-17na</link>
      <guid>https://dev.to/__615f3acc0/day-7-51-people-answeredmy-question-i-wasntready-for-what-they-said-17na</guid>
      <description>&lt;p&gt;Day 7: 51 people answered my question. I wasn't ready for what they said.&lt;br&gt;
Last week I asked: "What convinced you to switch away from a subscription tool — or what's keeping you locked in?"&lt;br&gt;
51 people answered. I read every single one.&lt;br&gt;
I expected arguments about SaaS economics. Or "subscriptions are fine, you get what you pay for."&lt;br&gt;
Instead, almost every answer was some version of the same thing:&lt;br&gt;
"I already left. I use free Notion + Trello + a spreadsheet I built myself. I stopped paying because I refused to keep feeding the machine."&lt;br&gt;
Nobody is sitting around waiting for a better subscription-free tool. They've already quit — and they're living with the consequences of their DIY stack. Things that don't connect. Manual exports every Friday. Three browser tabs open at once just to see where a project stands.&lt;br&gt;
That one insight shifted how I think about who I'm building &lt;a href="https://melororium.com" rel="noopener noreferrer"&gt;https://melororium.com&lt;/a&gt; for.&lt;br&gt;
It's not someone who's currently paying $50/month for ClickUp and angry about it. It's someone who already said "no" — months ago — and built their own janky workaround. I'm building for the person who quit before I showed up.&lt;br&gt;
So let me show you what I've actually built.&lt;br&gt;
This is the part where I want to be specific, because "almost done" is the oldest lie in indie building.&lt;br&gt;
I recorded a short walkthrough this week — nothing polished, just the real product as it exists right now:&lt;br&gt;
&lt;a href="https://youtu.be/Sa35DwMXAOQ" rel="noopener noreferrer"&gt;https://youtu.be/Sa35DwMXAOQ&lt;/a&gt;&lt;br&gt;
Here's what you'll see working:&lt;br&gt;
Kanban board — full drag-and-drop, columns, task cards, assignees. Not a prototype. The thing I actually use every day to run my own work. You can move a task from "In Progress" to "Done" and it updates across every view instantly.&lt;br&gt;
Tasks — complete system. Priorities, deadlines, subtasks, descriptions, comments. The boring stuff that actually matters when you're juggling 6 clients and can't afford to forget anything.&lt;br&gt;
Clients — bring your clients in, attach projects to them, see everything related to one client in a single view. No more searching through Notion pages or scrolling through Slack to find what you promised someone.&lt;br&gt;
Team members — add the people you work with. Assign tasks to them. See who has too much on their plate before they send you a panic message at 11 PM.&lt;br&gt;
Work hours analytics — full time tracking with real reports. Not just "hours logged" — actual breakdown by client, by project, by task. The numbers a freelancer or small agency actually needs to answer: am I making money on this client or just staying busy?&lt;br&gt;
Calendar integration — deadlines and tasks sync so your week looks like one thing, not three disconnected apps all demanding attention at the same time.&lt;br&gt;
We're on the finishing straight. The core is done. Right now I'm working through the last small fixes before the founding release — and building one more thing.&lt;br&gt;
What I'm building next:&lt;br&gt;
A direct AI assistant integration. Not "AI features" as a marketing checkbox — a way to create tasks, log work, and get a weekly project summary just by talking to it.&lt;br&gt;
The goal: you finish a client call, you tell the assistant what was discussed, the workspace updates itself. No context switching. No manual note-taking after every conversation.&lt;br&gt;
I'm building this because I tracked my own time one week and found I was spending 5+ hours just on admin — logging work, reorganizing tasks, writing status updates that nobody reads. That time should not exist.&lt;br&gt;
The wishlist is open right now.&lt;br&gt;
While the product isn't publicly released yet — founding access opens July 30 — the waitlist is live at &lt;a href="https://melororium.com" rel="noopener noreferrer"&gt;https://melororium.com&lt;/a&gt;&lt;br&gt;
If you reserve a spot now, you lock in the founding price: $199 one-time. No subscription. Ever.&lt;br&gt;
After launch the price goes up permanently. I'm not doing artificial scarcity — I just can't keep the founding price open indefinitely once real support costs kick in.&lt;br&gt;
We also built a referral system this weekend: bring 1 paying friend → 5% off your price. Bring 5 → 10%. Bring 30 → 50%. All the discount stacks get applied at launch in July. Full pricing breakdown at &lt;a href="https://melororium.com/pricing" rel="noopener noreferrer"&gt;https://melororium.com/pricing&lt;/a&gt;&lt;br&gt;
Day 7 real numbers:&lt;br&gt;
2 clicks from Google. First organic visitors ever. The site is already appearing at position 5 on some queries after 7 days — which genuinely surprised me&lt;br&gt;
Product Hunt launch: Tuesday June 9. Auto-scheduled. No PR, no budget, no influencer outreach. Just the story and whatever this community thinks of it&lt;br&gt;
0 paying customers. Still intentional. I want founding members who understand what they're getting into, not impulse purchases&lt;br&gt;
40+ published articles on the site. Probably only 8 of them are indexed by Google so far. The rest are sitting there waiting to be discovered&lt;br&gt;
The scary part about being this close to done: "done" isn't really a thing in software. You ship, people use it, something breaks, something's missing, something that seemed obvious to you makes no sense to anyone else.&lt;br&gt;
The real work starts July 30.&lt;br&gt;
But I know this: the people who've been running DIY stacks for two years — free Notion plus a spreadsheet plus three browser tabs — are going to open this and immediately understand it. No pitch needed. No onboarding email sequence. Just: oh, this is what I was trying to build myself.&lt;br&gt;
That's who I built it for.&lt;br&gt;
Question for this week: If you're running a DIY productivity stack right now — what's the one thing about it that drives you the most crazy? The thing you've accepted as "just how it is" but secretly hate?&lt;/p&gt;

</description>
    </item>
  </channel>
</rss>
