Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions

# testing
/coverage

# next.js
/.next/
/out/

# production
/build

# misc
.DS_Store
*.pem

# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*

# env files (can opt-in for committing if needed)
.env*

# vercel
.vercel

# typescript
*.tsbuildinfo
next-env.d.ts
48 changes: 48 additions & 0 deletions app/api/process-timetable/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from 'next/server'
import { GeminiProcessor } from '../../../lib/gemini'

export async function POST(request: NextRequest) {
try {
const formData = await request.formData()
const file = formData.get('file') as File

if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
}

const apiKey = process.env.GEMINI_API_KEY
if (!apiKey) {
return NextResponse.json({ error: 'Gemini API key not configured' }, { status: 500 })
}

Comment on lines +13 to +17
Copy link

Copilot AI Jan 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing input validation for the API key environment variable. Although the code checks if the API key exists, it doesn't validate its format or provide guidance on what a valid key looks like. Consider adding a check for the expected key format (e.g., starts with expected prefix) and providing a more helpful error message that guides users to obtain and configure the API key correctly.

Suggested change
const apiKey = process.env.GEMINI_API_KEY
if (!apiKey) {
return NextResponse.json({ error: 'Gemini API key not configured' }, { status: 500 })
}
const rawApiKey = process.env.GEMINI_API_KEY
const apiKey = rawApiKey?.trim()
if (!apiKey) {
return NextResponse.json({
error: 'Gemini API key not configured. Please set the GEMINI_API_KEY environment variable to a valid Gemini API key (typically starting with "AIza").'
}, { status: 500 })
}
const isLikelyValidApiKey = /^AIza[0-9A-Za-z_\-]{20,}$/.test(apiKey)
if (!isLikelyValidApiKey) {
return NextResponse.json({
error: 'Gemini API key appears to be invalid. Ensure GEMINI_API_KEY is set to a valid Gemini API key (for example, one that starts with "AIza" and was obtained from your Gemini/Google AI console).'
}, { status: 500 })
}

Copilot uses AI. Check for mistakes.
// Validate file type
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'application/pdf']
if (!allowedTypes.includes(file.type)) {
return NextResponse.json({
error: 'Invalid file type. Please upload PNG, JPG, JPEG, or PDF files.'
}, { status: 400 })
}

// Validate file size (max 10MB)
if (file.size > 10 * 1024 * 1024) {
return NextResponse.json({
error: 'File too large. Please upload files smaller than 10MB.'
}, { status: 400 })
}

const processor = new GeminiProcessor(apiKey)
const timetableData = await processor.processTimetableImage(file)

return NextResponse.json({
success: true,
data: timetableData
})

} catch (error) {
console.error('Error processing timetable:', error)
return NextResponse.json({
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred'
}, { status: 500 })
}
}
3 changes: 3 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
22 changes: 22 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'

const inter = Inter({ subsets: ['latin'] })

export const metadata: Metadata = {
title: 'TT2Cal - Timetable to iCal Converter',
description: 'Convert your timetable images to iCal format for easy calendar import',
}

export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
)
}
158 changes: 158 additions & 0 deletions app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
'use client'

import { useState } from 'react'
import FileUpload from '../components/FileUpload'
import DataEditor from '../components/DataEditor'
import { TimetableData, ProcessingResult } from '../lib/types'
import { generateIcal } from '../lib/ical-generator'

export default function Home() {
const [isProcessing, setIsProcessing] = useState(false)
const [timetableData, setTimetableData] = useState<TimetableData | null>(null)
const [error, setError] = useState<string | null>(null)

const handleFileUpload = async (file: File) => {
setIsProcessing(true)
setError(null)
setTimetableData(null)

try {
const formData = new FormData()
formData.append('file', file)

const response = await fetch('/api/process-timetable', {
method: 'POST',
body: formData,
})

Copy link

Copilot AI Jan 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The API response is not checked for HTTP errors before parsing JSON. If the API returns a non-200 status code, response.json() will still attempt to parse the response body, which may not contain valid JSON. Consider checking response.ok or response.status before parsing the JSON to provide better error handling.

Suggested change
if (!response.ok) {
let errorMessage = `Failed to process timetable (status ${response.status})`
try {
const errorBody = await response.json()
if (errorBody && typeof (errorBody as any).error === 'string') {
errorMessage = (errorBody as any).error
}
} catch {
// Ignore JSON parsing errors and use the default error message
}
setError(errorMessage)
return
}

Copilot uses AI. Check for mistakes.
const result: ProcessingResult = await response.json()

if (result.success && result.data) {
setTimetableData(result.data)
} else {
setError(result.error || 'Failed to process timetable')
}
} catch (err) {
setError(err instanceof Error ? err.message : 'An unexpected error occurred')
} finally {
setIsProcessing(false)
}
}

const handleDataChange = (data: TimetableData) => {
setTimetableData(data)
}

const handleExport = () => {
if (!timetableData) return

try {
// Use the start of the next week (Monday) as default start date
const today = new Date()
const nextMonday = new Date()
const daysUntilMonday = (8 - today.getDay()) % 7 || 7
nextMonday.setDate(today.getDate() + daysUntilMonday)

const icalContent = generateIcal(timetableData, nextMonday)

// Create and download file
const blob = new Blob([icalContent], { type: 'text/calendar' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${timetableData.studentName || 'timetable'}_${timetableData.term || 'schedule'}.ics`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to export calendar')
}
}

return (
<main className="min-h-screen bg-gray-50 py-8">
<div className="container mx-auto px-4">
{/* Header */}
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-gray-900 mb-2">
TT2Cal
</h1>
<p className="text-lg text-gray-600">
Convert your timetable images to iCal format
</p>
</div>

{/* Upload Section */}
{!timetableData && (
<div className="mb-8">
<FileUpload onFileUpload={handleFileUpload} isProcessing={isProcessing} />
</div>
)}

{/* Error Display */}
{error && (
<div className="mb-6 max-w-xl mx-auto">
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex">
<svg className="h-5 w-5 text-red-400 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
<div>
<h3 className="text-sm font-medium text-red-800">Error</h3>
<p className="text-sm text-red-700 mt-1">{error}</p>
</div>
</div>
</div>
</div>
)}

{/* Data Editor */}
{timetableData && (
<DataEditor
data={timetableData}
onDataChange={handleDataChange}
onExport={handleExport}
/>
)}

{/* Instructions */}
{!timetableData && !isProcessing && (
<div className="max-w-2xl mx-auto mt-12">
<div className="bg-white p-6 rounded-lg shadow-sm border">
<h2 className="text-xl font-semibold mb-4">How it works:</h2>
<ol className="list-decimal list-inside space-y-2 text-gray-700">
<li>Upload your timetable image (PNG, JPG, JPEG) or PDF</li>
<li>AI will extract schedule information from your timetable</li>
<li>Review and edit the extracted data if needed</li>
<li>Export as iCal file (.ics) for import into your calendar app</li>
</ol>

<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm text-blue-800">
<strong>Note:</strong> Currently optimized for Singapore school timetables with odd/even week patterns.
Make sure to set up your GEMINI_API_KEY in your .env.local file.
</p>
</div>
</div>
</div>
)}

{/* Reset Button */}
{timetableData && (
<div className="text-center mt-8">
<button
onClick={() => {
setTimetableData(null)
setError(null)
}}
className="px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
>
Upload Another Timetable
</button>
</div>
)}
</div>
</main>
)
}
Loading