feat(calendar): add beautiful daily calendar with locale-based paper size detection

Implement fully-featured daily calendar generation with enhanced visual design:

Daily Calendar Features:
- Beautiful single-page-per-day design with decorative borders
- Blue double-border frame for visual polish
- Light blue header section with uppercase month name (Georgia serif)
- Large prominent day-of-week text (42pt)
- 2.5x larger day abacus as main focal point
- Styled notes section with warm yellow background
- Full support for all paper sizes (US Letter, A4, A3, Tabloid)

Preview System:
- Add live preview support for daily calendars
- Generate composite SVG for preview (avoid Typst multi-page export issues)
- Show "Live Preview (First Day)" label for daily format
- Use same composite SVG approach as monthly calendars

Locale Detection:
- Auto-detect paper size from browser locale on page load
- US Letter for US, CA, MX, GT, PA, DO, PR, PH
- A4 for all other countries
- Hardcoded mapping (stable, no external deps needed)

Code Quality:
- Remove all debug console.log statements
- Fix unused imports
- Format and lint all files

Technical Details:
- Daily preview creates single composite SVG with embedded abacuses
- Uses Typst's responsive layout (percentages) for multi-size support
- Matches monthly calendar's single-image-export pattern
- Full PDF generation uses Typst #page() for multi-page output

🤖 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-05 09:31:31 -06:00
parent 651b14f630
commit bdca3154f8
4 changed files with 235 additions and 93 deletions

View File

@@ -5,6 +5,7 @@ import { join } from 'path'
import { execSync } from 'child_process' import { execSync } from 'child_process'
import { generateMonthlyTypst, getDaysInMonth } from '../utils/typstGenerator' import { generateMonthlyTypst, getDaysInMonth } from '../utils/typstGenerator'
import { generateCalendarComposite } from '@/utils/calendar/generateCalendarComposite' import { generateCalendarComposite } from '@/utils/calendar/generateCalendarComposite'
import { generateAbacusElement } from '@/utils/calendar/generateCalendarAbacus'
interface PreviewRequest { interface PreviewRequest {
month: number month: number
@@ -26,34 +27,137 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Invalid month or year' }, { status: 400 }) return NextResponse.json({ error: 'Invalid month or year' }, { status: 400 })
} }
// Only generate preview for monthly format
if (format !== 'monthly') {
return NextResponse.json({ svg: null })
}
// Dynamic import to avoid Next.js bundler issues // Dynamic import to avoid Next.js bundler issues
const { renderToStaticMarkup } = await import('react-dom/server') const { renderToStaticMarkup } = await import('react-dom/server')
// Create temp directory for SVG file // Create temp directory for SVG file(s)
tempDir = join(tmpdir(), `calendar-preview-${Date.now()}-${Math.random()}`) tempDir = join(tmpdir(), `calendar-preview-${Date.now()}-${Math.random()}`)
mkdirSync(tempDir, { recursive: true }) mkdirSync(tempDir, { recursive: true })
// Generate and write composite SVG
const calendarSvg = generateCalendarComposite({
month,
year,
renderToString: renderToStaticMarkup,
})
writeFileSync(join(tempDir, 'calendar.svg'), calendarSvg)
// Generate Typst document content // Generate Typst document content
const daysInMonth = getDaysInMonth(year, month) const daysInMonth = getDaysInMonth(year, month)
const typstContent = generateMonthlyTypst({ let typstContent: string
month,
year, if (format === 'monthly') {
paperSize: 'us-letter', // Generate and write composite SVG
daysInMonth, const calendarSvg = generateCalendarComposite({
}) month,
year,
renderToString: renderToStaticMarkup,
})
writeFileSync(join(tempDir, 'calendar.svg'), calendarSvg)
typstContent = generateMonthlyTypst({
month,
year,
paperSize: 'us-letter',
daysInMonth,
})
} else {
// Daily format: Create a SINGLE composite SVG (like monthly) to avoid multi-image export issue
// Generate individual abacus SVGs
const daySvg = renderToStaticMarkup(generateAbacusElement(1, 2))
if (!daySvg || daySvg.trim().length === 0) {
throw new Error('Generated empty SVG for day 1')
}
const yearColumns = Math.max(1, Math.ceil(Math.log10(year + 1)))
const yearSvg = renderToStaticMarkup(generateAbacusElement(year, yearColumns))
if (!yearSvg || yearSvg.trim().length === 0) {
throw new Error(`Generated empty SVG for year ${year}`)
}
// Create composite SVG with both year and day abacus
const monthName = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
][month - 1]
const dayOfWeek = new Date(year, month - 1, 1).toLocaleDateString('en-US', {
weekday: 'long',
})
// Extract SVG content (remove outer <svg> tags)
const yearSvgContent = yearSvg.replace(/<svg[^>]*>/, '').replace(/<\/svg>$/, '')
const daySvgContent = daySvg.replace(/<svg[^>]*>/, '').replace(/<\/svg>$/, '')
// Create composite SVG (850x1100 = US Letter aspect ratio)
const compositeWidth = 850
const compositeHeight = 1100
const yearAbacusWidth = 120 // Natural width at scale 1
const yearAbacusHeight = 230
const dayAbacusWidth = 120
const dayAbacusHeight = 230
const compositeSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${compositeWidth}" height="${compositeHeight}" viewBox="0 0 ${compositeWidth} ${compositeHeight}">
<!-- Background -->
<rect width="${compositeWidth}" height="${compositeHeight}" fill="white"/>
<!-- Decorative border -->
<rect x="40" y="40" width="${compositeWidth - 80}" height="${compositeHeight - 80}" fill="none" stroke="#2563eb" stroke-width="3" rx="8"/>
<rect x="50" y="50" width="${compositeWidth - 100}" height="${compositeHeight - 100}" fill="none" stroke="#2563eb" stroke-width="1" rx="4"/>
<!-- Header section with background -->
<rect x="70" y="70" width="${compositeWidth - 140}" height="120" fill="#eff6ff" stroke="#2563eb" stroke-width="2" rx="6"/>
<!-- Month name -->
<text x="${compositeWidth / 2}" y="125" text-anchor="middle" font-family="Georgia, serif" font-size="48" font-weight="bold" fill="#1e40af" letter-spacing="2">
${monthName.toUpperCase()}
</text>
<!-- Year abacus (smaller, in header) -->
<svg x="${compositeWidth / 2 - yearAbacusWidth * 0.4}" y="140" width="${yearAbacusWidth * 0.8}" height="${yearAbacusHeight * 0.8}" viewBox="0 0 ${yearAbacusWidth} ${yearAbacusHeight}">
${yearSvgContent}
</svg>
<!-- Day of week (large and prominent) -->
<text x="${compositeWidth / 2}" y="260" text-anchor="middle" font-family="Georgia, serif" font-size="42" font-weight="bold" fill="#1e3a8a">
${dayOfWeek}
</text>
<!-- Day abacus (much larger, main focus) -->
<svg x="${compositeWidth / 2 - dayAbacusWidth * 1.25}" y="300" width="${dayAbacusWidth * 2.5}" height="${dayAbacusHeight * 2.5}" viewBox="0 0 ${dayAbacusWidth} ${dayAbacusHeight}">
${daySvgContent}
</svg>
<!-- Full date (below day abacus) -->
<text x="${compositeWidth / 2}" y="890" text-anchor="middle" font-family="Georgia, serif" font-size="24" font-weight="500" fill="#475569">
${monthName} 1, ${year}
</text>
<!-- Notes section with decorative box -->
<rect x="70" y="930" width="${compositeWidth - 140}" height="120" fill="#fefce8" stroke="#ca8a04" stroke-width="2" rx="4"/>
<text x="90" y="960" font-family="Georgia, serif" font-size="18" font-weight="bold" fill="#854d0e">
Notes:
</text>
<line x1="90" y1="980" x2="${compositeWidth - 90}" y2="980" stroke="#ca8a04" stroke-width="1"/>
<line x1="90" y1="1005" x2="${compositeWidth - 90}" y2="1005" stroke="#ca8a04" stroke-width="1"/>
<line x1="90" y1="1030" x2="${compositeWidth - 90}" y2="1030" stroke="#ca8a04" stroke-width="1"/>
</svg>`
writeFileSync(join(tempDir, 'daily-preview.svg'), compositeSvg)
// Use single composite image (like monthly)
typstContent = `#set page(
paper: "us-letter",
margin: (x: 0.5in, y: 0.5in),
)
#align(center + horizon)[
#image("daily-preview.svg", width: 100%, fit: "contain")
]
`
}
// Compile with Typst: stdin for .typ content, stdout for SVG output // Compile with Typst: stdin for .typ content, stdout for SVG output
let svg: string let svg: string

View File

@@ -95,43 +95,93 @@ export function generateDailyTypst(config: TypstDailyConfig): string {
paper: "${paperConfig.typstName}", paper: "${paperConfig.typstName}",
margin: (x: ${paperConfig.marginX}, y: ${paperConfig.marginY}), margin: (x: ${paperConfig.marginX}, y: ${paperConfig.marginY}),
)[ )[
// Header: Year #set text(font: "Georgia")
#align(center)[
#v(1em) // Decorative borders
#image("year.svg", width: 30%) #rect(
width: 100%,
height: 100%,
stroke: (paint: rgb("#2563eb"), thickness: 3pt),
radius: 8pt,
inset: 0pt,
)[
#rect(
width: 100%,
height: 100%,
stroke: (paint: rgb("#2563eb"), thickness: 1pt),
radius: 4pt,
inset: 10pt,
)[
#v(10pt)
// Header section with background
#rect(
width: 100%,
height: 90pt,
fill: rgb("#eff6ff"),
stroke: (paint: rgb("#2563eb"), thickness: 2pt),
radius: 6pt,
)[
#align(center)[
#v(15pt)
#text(size: 32pt, weight: "bold", fill: rgb("#1e40af"), tracking: 2pt)[
${monthName.toUpperCase()}
]
#v(5pt)
#image("year.svg", width: 15%)
]
]
#v(15pt)
// Day of week (large and prominent)
#align(center)[
#text(size: 28pt, weight: "bold", fill: rgb("#1e3a8a"))[
${dayOfWeek}
]
]
#v(10pt)
// Day abacus (main focus, large)
#align(center)[
#image("day-${day}.svg", width: 45%)
]
#v(10pt)
// Full date
#align(center)[
#text(size: 18pt, weight: 500, fill: rgb("#475569"))[
${monthName} ${day}, ${year}
]
]
#v(1fr)
// Notes section with decorative box
#rect(
width: 100%,
height: 90pt,
fill: rgb("#fefce8"),
stroke: (paint: rgb("#ca8a04"), thickness: 2pt),
radius: 4pt,
)[
#v(8pt)
#text(size: 14pt, weight: "bold", fill: rgb("#854d0e"))[
#h(10pt) Notes:
]
#v(8pt)
#line(length: 95%, stroke: (paint: rgb("#ca8a04"), thickness: 1pt))
#v(8pt)
#line(length: 95%, stroke: (paint: rgb("#ca8a04"), thickness: 1pt))
#v(8pt)
#line(length: 95%, stroke: (paint: rgb("#ca8a04"), thickness: 1pt))
]
#v(10pt)
]
] ]
#v(2em)
// Main: Day number as large abacus
#align(center + horizon)[
#image("day-${day}.svg", width: 50%)
]
#v(2em)
// Footer: Day of week and date
#align(center)[
#text(size: 18pt, weight: "bold")[${dayOfWeek}]
#v(0.5em)
#text(size: 14pt)[${monthName} ${day}, ${year}]
]
// Notes section
#v(3em)
#line(length: 100%, stroke: 0.5pt)
#v(0.5em)
#text(size: 10pt, fill: gray)[Notes:]
#v(0.5em)
#line(length: 100%, stroke: 0.5pt)
#v(1em)
#line(length: 100%, stroke: 0.5pt)
#v(1em)
#line(length: 100%, stroke: 0.5pt)
#v(1em)
#line(length: 100%, stroke: 0.5pt)
] ]
${day < daysInMonth ? '' : ''}` ${day < daysInMonth ? '' : ''}`
@@ -141,7 +191,5 @@ ${day < daysInMonth ? '' : ''}`
} }
} }
return `#set text(font: "Arial") return pages
${pages}
`
} }

View File

@@ -22,7 +22,8 @@ async function fetchTypstPreview(
}) })
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch preview') const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || errorData.message || 'Failed to fetch preview')
} }
const data = await response.json() const data = await response.json()
@@ -34,14 +35,14 @@ export function CalendarPreview({ month, year, format, previewSvg }: CalendarPre
const { data: typstPreviewSvg, isLoading } = useQuery({ const { data: typstPreviewSvg, isLoading } = useQuery({
queryKey: ['calendar-typst-preview', month, year, format], queryKey: ['calendar-typst-preview', month, year, format],
queryFn: () => fetchTypstPreview(month, year, format), queryFn: () => fetchTypstPreview(month, year, format),
enabled: typeof window !== 'undefined' && format === 'monthly', // Only run on client and for monthly format enabled: typeof window !== 'undefined', // Run on client for both formats
}) })
// Use generated PDF SVG if available, otherwise use Typst live preview // Use generated PDF SVG if available, otherwise use Typst live preview
const displaySvg = previewSvg || typstPreviewSvg const displaySvg = previewSvg || typstPreviewSvg
// Show loading state while fetching preview // Show loading state while fetching preview
if (isLoading || (!displaySvg && format === 'monthly')) { if (isLoading || !displaySvg) {
return ( return (
<div <div
data-component="calendar-preview" data-component="calendar-preview"
@@ -63,35 +64,7 @@ export function CalendarPreview({ month, year, format, previewSvg }: CalendarPre
textAlign: 'center', textAlign: 'center',
})} })}
> >
Loading preview... {isLoading ? 'Loading preview...' : 'No preview available'}
</p>
</div>
)
}
if (!displaySvg) {
return (
<div
data-component="calendar-preview"
className={css({
bg: 'gray.800',
borderRadius: '12px',
padding: '2rem',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '600px',
})}
>
<p
className={css({
fontSize: '1.25rem',
color: 'gray.400',
textAlign: 'center',
})}
>
{format === 'daily' ? 'Daily format - preview after generation' : 'No preview available'}
</p> </p>
</div> </div>
) )
@@ -118,7 +91,11 @@ export function CalendarPreview({ month, year, format, previewSvg }: CalendarPre
fontWeight: 'bold', fontWeight: 'bold',
})} })}
> >
{previewSvg ? 'Generated PDF' : 'Live Preview'} {previewSvg
? 'Generated PDF'
: format === 'daily'
? 'Live Preview (First Day)'
: 'Live Preview'}
</p> </p>
<div <div
className={css({ className={css({

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useState } from 'react' import { useState, useEffect } from 'react'
import { css } from '../../../../styled-system/css' import { css } from '../../../../styled-system/css'
import { useAbacusConfig } from '@soroban/abacus-react' import { useAbacusConfig } from '@soroban/abacus-react'
import { PageWithNav } from '@/components/PageWithNav' import { PageWithNav } from '@/components/PageWithNav'
@@ -17,6 +17,19 @@ export default function CalendarCreatorPage() {
const [isGenerating, setIsGenerating] = useState(false) const [isGenerating, setIsGenerating] = useState(false)
const [previewSvg, setPreviewSvg] = useState<string | null>(null) const [previewSvg, setPreviewSvg] = useState<string | null>(null)
// Detect default paper size based on user's locale (client-side only)
useEffect(() => {
// Get user's locale
const locale = navigator.language || navigator.languages?.[0] || 'en-US'
const country = locale.split('-')[1]?.toUpperCase()
// Countries that use US Letter (8.5" × 11")
const letterCountries = ['US', 'CA', 'MX', 'GT', 'PA', 'DO', 'PR', 'PH']
const detectedSize = letterCountries.includes(country || '') ? 'us-letter' : 'a4'
setPaperSize(detectedSize)
}, [])
const handleGenerate = async () => { const handleGenerate = async () => {
setIsGenerating(true) setIsGenerating(true)
try { try {