feat(web): redesign monthly calendar as single composite SVG
BREAKING FIX: Monthly calendars were overflowing to multiple pages New "page-first" design approach: - Generate entire calendar as one composite SVG (850x1100px) - Includes title, year abacus, weekday headers, and all day abacuses - Typst scales the single image to fit page (width: 100%, fit: "contain") - Impossible to overflow - it's just one scalable image Benefits: ✅ Guaranteed single-page layout across all paper sizes ✅ No grid overflow issues ✅ Consistent rendering regardless of month length ✅ Fast generation (~97KB SVG vs multiple small files) Implementation: - Created generateCalendarComposite.tsx script - Updated route to use composite for monthly, individual SVGs for daily - Simplified generateMonthlyTypst to just scale one image Daily calendars unchanged (intentionally multi-page, one per day). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
142
apps/web/scripts/generateCalendarComposite.tsx
Normal file
142
apps/web/scripts/generateCalendarComposite.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env tsx
|
||||
|
||||
/**
|
||||
* Generate a complete monthly calendar as a single SVG
|
||||
* This prevents multi-page overflow - one image scales to fit
|
||||
*
|
||||
* Usage: npx tsx scripts/generateCalendarComposite.tsx <month> <year>
|
||||
* Example: npx tsx scripts/generateCalendarComposite.tsx 12 2025
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { AbacusStatic } from '@soroban/abacus-react/static'
|
||||
|
||||
const month = parseInt(process.argv[2], 10)
|
||||
const year = parseInt(process.argv[3], 10)
|
||||
|
||||
if (isNaN(month) || isNaN(year) || month < 1 || month > 12) {
|
||||
console.error('Usage: npx tsx scripts/generateCalendarComposite.tsx <month> <year>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December',
|
||||
]
|
||||
|
||||
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||
|
||||
function getDaysInMonth(year: number, month: number): number {
|
||||
return new Date(year, month, 0).getDate()
|
||||
}
|
||||
|
||||
function getFirstDayOfWeek(year: number, month: number): number {
|
||||
return new Date(year, month - 1, 1).getDay()
|
||||
}
|
||||
|
||||
const daysInMonth = getDaysInMonth(year, month)
|
||||
const firstDayOfWeek = getFirstDayOfWeek(year, month)
|
||||
const monthName = MONTH_NAMES[month - 1]
|
||||
|
||||
// Layout constants for US Letter aspect ratio (8.5 x 11)
|
||||
const WIDTH = 850
|
||||
const HEIGHT = 1100
|
||||
const MARGIN = 40
|
||||
const CONTENT_WIDTH = WIDTH - MARGIN * 2
|
||||
const CONTENT_HEIGHT = HEIGHT - MARGIN * 2
|
||||
|
||||
// Header
|
||||
const HEADER_HEIGHT = 80
|
||||
const TITLE_Y = MARGIN + 30
|
||||
const YEAR_ABACUS_WIDTH = 120
|
||||
const YEAR_ABACUS_HEIGHT = 50
|
||||
|
||||
// Calendar grid
|
||||
const GRID_START_Y = MARGIN + HEADER_HEIGHT + 20
|
||||
const GRID_HEIGHT = CONTENT_HEIGHT - HEADER_HEIGHT - 20
|
||||
const CELL_WIDTH = CONTENT_WIDTH / 7
|
||||
const CELL_HEIGHT = GRID_HEIGHT / 6.5 // ~6 weeks max + weekday headers
|
||||
const WEEKDAY_ROW_HEIGHT = 30
|
||||
const DAY_CELL_HEIGHT = (GRID_HEIGHT - WEEKDAY_ROW_HEIGHT) / 6
|
||||
|
||||
// Generate calendar grid
|
||||
const calendarCells: (number | null)[] = []
|
||||
for (let i = 0; i < firstDayOfWeek; i++) {
|
||||
calendarCells.push(null)
|
||||
}
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
calendarCells.push(day)
|
||||
}
|
||||
|
||||
// Calculate how many columns needed for year
|
||||
const yearColumns = Math.max(1, Math.ceil(Math.log10(year + 1)))
|
||||
|
||||
// Render individual abacus SVGs as strings
|
||||
function renderAbacusSVG(value: number, columns: number, scale: number): string {
|
||||
return renderToStaticMarkup(
|
||||
<AbacusStatic
|
||||
value={value}
|
||||
columns={columns}
|
||||
scaleFactor={scale}
|
||||
showNumbers={false}
|
||||
frameVisible={true}
|
||||
compact={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Main composite SVG
|
||||
const compositeSVG = `<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" height="${HEIGHT}" viewBox="0 0 ${WIDTH} ${HEIGHT}">
|
||||
<!-- Background -->
|
||||
<rect width="${WIDTH}" height="${HEIGHT}" fill="white"/>
|
||||
|
||||
<!-- Title -->
|
||||
<text x="${WIDTH / 2}" y="${TITLE_Y}" text-anchor="middle" font-family="Arial" font-size="36" font-weight="bold" fill="#000">
|
||||
${monthName} ${year}
|
||||
</text>
|
||||
|
||||
<!-- Year Abacus (inline, to the right of title) -->
|
||||
<g transform="translate(${WIDTH / 2 + 150}, ${TITLE_Y - 25})">
|
||||
${renderAbacusSVG(year, yearColumns, 0.4).replace(/<svg[^>]*>/, '').replace('</svg>', '')}
|
||||
</g>
|
||||
|
||||
<!-- Weekday Headers -->
|
||||
${WEEKDAYS.map((day, i) => `
|
||||
<text x="${MARGIN + i * CELL_WIDTH + CELL_WIDTH / 2}" y="${GRID_START_Y + 20}"
|
||||
text-anchor="middle" font-family="Arial" font-size="16" font-weight="bold" fill="#333">
|
||||
${day}
|
||||
</text>`).join('')}
|
||||
|
||||
<!-- Separator line under weekdays -->
|
||||
<line x1="${MARGIN}" y1="${GRID_START_Y + WEEKDAY_ROW_HEIGHT}"
|
||||
x2="${WIDTH - MARGIN}" y2="${GRID_START_Y + WEEKDAY_ROW_HEIGHT}"
|
||||
stroke="#ccc" stroke-width="2"/>
|
||||
|
||||
<!-- Calendar Grid -->
|
||||
${calendarCells.map((day, index) => {
|
||||
if (day === null) return ''
|
||||
|
||||
const row = Math.floor(index / 7)
|
||||
const col = index % 7
|
||||
const x = MARGIN + col * CELL_WIDTH
|
||||
const y = GRID_START_Y + WEEKDAY_ROW_HEIGHT + row * DAY_CELL_HEIGHT
|
||||
|
||||
// Calculate scale to fit abacus in cell (leaving some padding)
|
||||
const abacusScale = Math.min(CELL_WIDTH / 120, DAY_CELL_HEIGHT / 230) * 0.7
|
||||
|
||||
const abacusSVG = renderAbacusSVG(day, 2, abacusScale)
|
||||
.replace(/<svg[^>]*>/, '')
|
||||
.replace('</svg>', '')
|
||||
|
||||
return `
|
||||
<!-- Day ${day} -->
|
||||
<g transform="translate(${x + CELL_WIDTH / 2}, ${y + DAY_CELL_HEIGHT / 2})">
|
||||
<g transform="translate(-60, -115)">
|
||||
${abacusSVG}
|
||||
</g>
|
||||
</g>`
|
||||
}).join('')}
|
||||
</svg>`
|
||||
|
||||
process.stdout.write(compositeSVG)
|
||||
@@ -32,48 +32,62 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Generate SVGs using script (avoids Next.js react-dom/server restriction)
|
||||
const daysInMonth = getDaysInMonth(year, month)
|
||||
const maxDay = format === 'daily' ? daysInMonth : 31 // For monthly, pre-generate all
|
||||
const scriptPath = join(process.cwd(), 'scripts', 'generateCalendarAbacus.tsx')
|
||||
|
||||
// Generate day SVGs (1 to maxDay)
|
||||
for (let day = 1; day <= maxDay; day++) {
|
||||
if (format === 'monthly') {
|
||||
// Generate single composite SVG for monthly calendar (prevents multi-page overflow)
|
||||
const compositeScriptPath = join(process.cwd(), 'scripts', 'generateCalendarComposite.tsx')
|
||||
try {
|
||||
const svg = execSync(`npx tsx "${scriptPath}" ${day} 2`, {
|
||||
const compositeSvg = execSync(`npx tsx "${compositeScriptPath}" ${month} ${year}`, {
|
||||
encoding: 'utf-8',
|
||||
cwd: process.cwd(),
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
if (!svg || svg.trim().length === 0) {
|
||||
console.error(`Empty SVG for day ${day}`)
|
||||
throw new Error(`Generated empty SVG for day ${day}`)
|
||||
if (!compositeSvg || compositeSvg.trim().length === 0) {
|
||||
throw new Error(`Generated empty composite calendar SVG`)
|
||||
}
|
||||
writeFileSync(join(tempDir, `day-${day}.svg`), svg)
|
||||
writeFileSync(join(tempDir, 'calendar.svg'), compositeSvg)
|
||||
} catch (error: any) {
|
||||
console.error(`Error generating day ${day} SVG:`, error.stderr || error.message)
|
||||
console.error(`Error generating composite calendar:`, error.stderr || error.message)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Daily format: generate individual SVGs for each day
|
||||
const scriptPath = join(process.cwd(), 'scripts', 'generateCalendarAbacus.tsx')
|
||||
|
||||
// Generate year SVG
|
||||
const yearColumns = Math.max(1, Math.ceil(Math.log10(year + 1)))
|
||||
try {
|
||||
const yearSvg = execSync(`npx tsx "${scriptPath}" ${year} ${yearColumns}`, {
|
||||
encoding: 'utf-8',
|
||||
cwd: process.cwd(),
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
console.log(`Year SVG length: ${yearSvg.length}, starts with: ${yearSvg.substring(0, 100)}`)
|
||||
if (!yearSvg || yearSvg.trim().length === 0) {
|
||||
console.error(`Empty SVG for year ${year}`)
|
||||
throw new Error(`Generated empty SVG for year ${year}`)
|
||||
// Generate day SVGs (1 to daysInMonth)
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
try {
|
||||
const svg = execSync(`npx tsx "${scriptPath}" ${day} 2`, {
|
||||
encoding: 'utf-8',
|
||||
cwd: process.cwd(),
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
if (!svg || svg.trim().length === 0) {
|
||||
throw new Error(`Generated empty SVG for day ${day}`)
|
||||
}
|
||||
writeFileSync(join(tempDir, `day-${day}.svg`), svg)
|
||||
} catch (error: any) {
|
||||
console.error(`Error generating day ${day} SVG:`, error.stderr || error.message)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Generate year SVG
|
||||
const yearColumns = Math.max(1, Math.ceil(Math.log10(year + 1)))
|
||||
try {
|
||||
const yearSvg = execSync(`npx tsx "${scriptPath}" ${year} ${yearColumns}`, {
|
||||
encoding: 'utf-8',
|
||||
cwd: process.cwd(),
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
if (!yearSvg || yearSvg.trim().length === 0) {
|
||||
throw new Error(`Generated empty SVG for year ${year}`)
|
||||
}
|
||||
writeFileSync(join(tempDir, 'year.svg'), yearSvg)
|
||||
} catch (error: any) {
|
||||
console.error(`Error generating year ${year} SVG:`, error.stderr || error.message)
|
||||
throw error
|
||||
}
|
||||
writeFileSync(join(tempDir, 'year.svg'), yearSvg)
|
||||
// Debug: also save to /tmp for inspection
|
||||
writeFileSync('/tmp/debug-year.svg', yearSvg)
|
||||
console.log('Saved debug year.svg to /tmp/debug-year.svg')
|
||||
} catch (error: any) {
|
||||
console.error(`Error generating year ${year} SVG:`, error.stderr || error.message)
|
||||
throw error
|
||||
}
|
||||
|
||||
// Generate Typst document
|
||||
|
||||
@@ -59,55 +59,18 @@ function getPaperConfig(size: string): PaperConfig {
|
||||
export function generateMonthlyTypst(config: TypstConfig): string {
|
||||
const { month, year, paperSize, tempDir, daysInMonth } = config
|
||||
const paperConfig = getPaperConfig(paperSize)
|
||||
const firstDayOfWeek = getFirstDayOfWeek(year, month)
|
||||
const monthName = MONTH_NAMES[month - 1]
|
||||
|
||||
// Generate calendar cells with proper empty cells before the first day
|
||||
let cells = ''
|
||||
|
||||
// Empty cells before first day
|
||||
for (let i = 0; i < firstDayOfWeek; i++) {
|
||||
cells += ' [],\n'
|
||||
}
|
||||
|
||||
// Day cells
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
cells += ` [#image("day-${day}.svg", width: 90%)],\n`
|
||||
}
|
||||
|
||||
// Single-page design: use one composite SVG that scales to fit
|
||||
// This prevents overflow - Typst will scale the image to fit available space
|
||||
return `#set page(
|
||||
paper: "${paperConfig.typstName}",
|
||||
margin: (x: ${paperConfig.marginX}, y: ${paperConfig.marginY}),
|
||||
)
|
||||
|
||||
#set text(font: "Arial", size: 10pt)
|
||||
|
||||
// Title and year - compact header
|
||||
#align(center)[
|
||||
#text(size: 18pt, weight: "bold")[${monthName} ${year}]
|
||||
#h(1em)
|
||||
#box(baseline: 25%, image("year.svg", width: 20%))
|
||||
// Composite calendar SVG - scales to fit page (prevents multi-page overflow)
|
||||
#align(center + horizon)[
|
||||
#image("calendar.svg", width: 100%, fit: "contain")
|
||||
]
|
||||
|
||||
#v(0.5em)
|
||||
|
||||
// Calendar grid - maximizes available space
|
||||
#grid(
|
||||
columns: (1fr, 1fr, 1fr, 1fr, 1fr, 1fr, 1fr),
|
||||
gutter: 2pt,
|
||||
row-gutter: 2pt,
|
||||
|
||||
// Weekday headers
|
||||
[#align(center)[#text(size: 9pt, weight: "bold")[Sun]]],
|
||||
[#align(center)[#text(size: 9pt, weight: "bold")[Mon]]],
|
||||
[#align(center)[#text(size: 9pt, weight: "bold")[Tue]]],
|
||||
[#align(center)[#text(size: 9pt, weight: "bold")[Wed]]],
|
||||
[#align(center)[#text(size: 9pt, weight: "bold")[Thu]]],
|
||||
[#align(center)[#text(size: 9pt, weight: "bold")[Fri]]],
|
||||
[#align(center)[#text(size: 9pt, weight: "bold")[Sat]]],
|
||||
|
||||
// Calendar days
|
||||
${cells})
|
||||
`
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user