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 { generateMonthlyTypst, getDaysInMonth } from '../utils/typstGenerator'
import { generateCalendarComposite } from '@/utils/calendar/generateCalendarComposite'
import { generateAbacusElement } from '@/utils/calendar/generateCalendarAbacus'
interface PreviewRequest {
month: number
@ -26,34 +27,137 @@ export async function POST(request: NextRequest) {
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
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()}`)
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
const daysInMonth = getDaysInMonth(year, month)
const typstContent = generateMonthlyTypst({
month,
year,
paperSize: 'us-letter',
daysInMonth,
})
let typstContent: string
if (format === 'monthly') {
// Generate and write composite SVG
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
let svg: string

View File

@ -95,43 +95,93 @@ export function generateDailyTypst(config: TypstDailyConfig): string {
paper: "${paperConfig.typstName}",
margin: (x: ${paperConfig.marginX}, y: ${paperConfig.marginY}),
)[
// Header: Year
#align(center)[
#v(1em)
#image("year.svg", width: 30%)
#set text(font: "Georgia")
// Decorative borders
#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 ? '' : ''}`
@ -141,7 +191,5 @@ ${day < daysInMonth ? '' : ''}`
}
}
return `#set text(font: "Arial")
${pages}
`
return pages
}

View File

@ -22,7 +22,8 @@ async function fetchTypstPreview(
})
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()
@ -34,14 +35,14 @@ export function CalendarPreview({ month, year, format, previewSvg }: CalendarPre
const { data: typstPreviewSvg, isLoading } = useQuery({
queryKey: ['calendar-typst-preview', 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
const displaySvg = previewSvg || typstPreviewSvg
// Show loading state while fetching preview
if (isLoading || (!displaySvg && format === 'monthly')) {
if (isLoading || !displaySvg) {
return (
<div
data-component="calendar-preview"
@ -63,35 +64,7 @@ export function CalendarPreview({ month, year, format, previewSvg }: CalendarPre
textAlign: 'center',
})}
>
Loading preview...
</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'}
{isLoading ? 'Loading preview...' : 'No preview available'}
</p>
</div>
)
@ -118,7 +91,11 @@ export function CalendarPreview({ month, year, format, previewSvg }: CalendarPre
fontWeight: 'bold',
})}
>
{previewSvg ? 'Generated PDF' : 'Live Preview'}
{previewSvg
? 'Generated PDF'
: format === 'daily'
? 'Live Preview (First Day)'
: 'Live Preview'}
</p>
<div
className={css({

View File

@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { css } from '../../../../styled-system/css'
import { useAbacusConfig } from '@soroban/abacus-react'
import { PageWithNav } from '@/components/PageWithNav'
@ -17,6 +17,19 @@ export default function CalendarCreatorPage() {
const [isGenerating, setIsGenerating] = useState(false)
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 () => {
setIsGenerating(true)
try {