feat: dynamic day-of-month favicon using subprocess pattern

- Create scripts/generateDayIcon.tsx for on-demand icon generation
- Route handler calls script via execSync (avoids Next.js react-dom/server restriction)
- Implement in-memory caching to minimize subprocess overhead
- Show current day (01-31) on 2-column abacus in US Central Time
- High-contrast design: blue/green beads, 2px strokes, gold border
- Document SSR pattern in .claude/CLAUDE.md for future reference

Fixes the Next.js limitation where route handlers cannot import react-dom/server
by using subprocess pattern - scripts CAN use react-dom/server, route handlers
call them via execSync and cache the results.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-11-03 07:40:00 -06:00
parent 712ee58e59
commit 4d0795a9df
4 changed files with 186 additions and 6 deletions

View File

@@ -194,6 +194,50 @@ When creating ANY new HTML/JSX element (div, button, section, etc.), add appropr
- ✅ Use `useAbacusConfig` for abacus configuration
- ✅ Use `useAbacusDisplay` for reading abacus state
**Server-Side Rendering (CRITICAL):**
`AbacusReact` already supports server-side rendering - it detects SSR and disables animations automatically.
**✅ CORRECT - Use in build scripts:**
```typescript
// scripts/generateAbacusIcons.tsx
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { AbacusReact } from '@soroban/abacus-react'
const svg = renderToStaticMarkup(<AbacusReact value={5} columns={2} />)
// This works! Scripts can use react-dom/server
```
**❌ WRONG - Do NOT use in Next.js route handlers:**
```typescript
// src/app/icon/route.tsx - DON'T DO THIS!
import { renderToStaticMarkup } from 'react-dom/server' // ❌ Next.js forbids this!
import { AbacusReact } from '@soroban/abacus-react'
export async function GET() {
const svg = renderToStaticMarkup(<AbacusReact ... />) // ❌ Will fail!
}
```
**✅ CORRECT - Pre-generate and read in route handlers:**
```typescript
// src/app/icon/route.tsx
import { readFileSync } from 'fs'
export async function GET() {
// Read pre-generated SVG from scripts/generateAbacusIcons.tsx
const svg = readFileSync('public/icons/day-01.svg', 'utf-8')
return new Response(svg, { headers: { 'Content-Type': 'image/svg+xml' } })
}
```
**Pattern to follow:**
1. Generate static SVGs using `scripts/generateAbacusIcons.tsx` (uses renderToStaticMarkup)
2. Commit generated SVGs to `public/icons/` or `public/`
3. Route handlers read and serve the pre-generated files
4. Regenerate icons when abacus styling changes
**MANDATORY: Read the Docs Before Customizing**
**ALWAYS read the full README documentation before customizing or styling AbacusReact:**

View File

@@ -200,17 +200,14 @@ function generateOGImage(): string {
const appDir = __dirname.replace('/scripts', '')
try {
console.log('Generating favicon from AbacusReact...')
const faviconSvg = generateFavicon()
writeFileSync(join(appDir, 'src', 'app', 'icon.svg'), faviconSvg)
console.log('✓ Generated src/app/icon.svg')
console.log('Generating Open Graph image from AbacusReact...')
const ogImageSvg = generateOGImage()
writeFileSync(join(appDir, 'public', 'og-image.svg'), ogImageSvg)
console.log('✓ Generated public/og-image.svg')
console.log('\n✅ All icons generated successfully!')
console.log('\n✅ Icon generated successfully!')
console.log('\nNote: Day-of-month favicons are generated on-demand by src/app/icon/route.tsx')
console.log('which calls scripts/generateDayIcon.tsx as a subprocess.')
} catch (error) {
console.error('❌ Error generating icons:', error)
process.exit(1)

View File

@@ -0,0 +1,84 @@
#!/usr/bin/env tsx
/**
* Generate a single day-of-month favicon
* Usage: npx tsx scripts/generateDayIcon.tsx <day>
* Example: npx tsx scripts/generateDayIcon.tsx 15
*/
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { AbacusReact } from '@soroban/abacus-react'
// Extract just the SVG element content from rendered output
function extractSvgContent(markup: string): string {
const svgMatch = markup.match(/<svg[^>]*>([\s\S]*?)<\/svg>/)
if (!svgMatch) {
throw new Error('No SVG element found in rendered output')
}
return svgMatch[1]
}
// Get day from command line argument
const day = parseInt(process.argv[2], 10)
if (!day || day < 1 || day > 31) {
console.error('Usage: npx tsx scripts/generateDayIcon.tsx <day>')
console.error('Example: npx tsx scripts/generateDayIcon.tsx 15')
process.exit(1)
}
// Render 2-column abacus showing day of month
const abacusMarkup = renderToStaticMarkup(
<AbacusReact
value={day}
columns={2}
scaleFactor={1.0}
animated={false}
interactive={false}
showNumbers={false}
customStyles={{
columnPosts: {
fill: '#1c1917',
stroke: '#0c0a09',
strokeWidth: 2,
},
reckoningBar: {
fill: '#1c1917',
stroke: '#0c0a09',
strokeWidth: 3,
},
columns: {
0: {
// Ones place - Bold Blue (high contrast)
heavenBeads: { fill: '#2563eb', stroke: '#1e40af', strokeWidth: 2 },
earthBeads: { fill: '#2563eb', stroke: '#1e40af', strokeWidth: 2 },
},
1: {
// Tens place - Bold Green (high contrast)
heavenBeads: { fill: '#16a34a', stroke: '#15803d', strokeWidth: 2 },
earthBeads: { fill: '#16a34a', stroke: '#15803d', strokeWidth: 2 },
},
},
}}
/>
)
const svgContent = extractSvgContent(abacusMarkup)
// Wrap in SVG with proper viewBox for favicon sizing
// AbacusReact with 2 columns + scaleFactor 1.0 = ~50×120px
// Scale 0.7 = ~35×84px, centered in 100×100
const svg = `<svg width="100" height="100" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<!-- Background circle with border for definition -->
<circle cx="50" cy="50" r="48" fill="#fef3c7" stroke="#d97706" stroke-width="2"/>
<!-- Abacus showing day ${day.toString().padStart(2, '0')} (US Central Time) -->
<g transform="translate(33, 8) scale(0.7)">
${svgContent}
</g>
</svg>
`
// Output to stdout so parent process can capture it
process.stdout.write(svg)

View File

@@ -0,0 +1,55 @@
import { execSync } from 'child_process'
import { join } from 'path'
export const runtime = 'nodejs'
// In-memory cache: { day: svg }
const iconCache = new Map<number, string>()
// Get current day of month in US Central Time
function getDayOfMonth(): number {
const now = new Date()
// Get date in America/Chicago timezone
const centralDate = new Date(now.toLocaleString('en-US', { timeZone: 'America/Chicago' }))
return centralDate.getDate()
}
// Generate icon by calling script that uses react-dom/server
function generateDayIcon(day: number): string {
// Call the generation script as a subprocess
// Scripts can use react-dom/server, route handlers cannot
const scriptPath = join(process.cwd(), 'scripts', 'generateDayIcon.tsx')
const svg = execSync(`npx tsx "${scriptPath}" ${day}`, {
encoding: 'utf-8',
cwd: process.cwd(),
})
return svg
}
export async function GET() {
const dayOfMonth = getDayOfMonth()
// Check cache first
let svg = iconCache.get(dayOfMonth)
if (!svg) {
// Generate and cache
svg = generateDayIcon(dayOfMonth)
iconCache.set(dayOfMonth, svg)
// Clear old cache entries (keep only current day)
for (const [cachedDay] of iconCache) {
if (cachedDay !== dayOfMonth) {
iconCache.delete(cachedDay)
}
}
}
return new Response(svg, {
headers: {
'Content-Type': 'image/svg+xml',
// Cache for 1 hour so it updates throughout the day
'Cache-Control': 'public, max-age=3600, s-maxage=3600',
},
})
}