Live
An AI-powered task manager that estimates your to-dos so you don't feel guilty when the estimations are wrong
How It Works
The overview page tells the story of why Fuscalendar exists. The guilt-free AI planner, the brain dumps, the Apex Legends sessions. But if you're the kind of person who reads a project page and thinks "cool, but how does it actually work?", this one's for you.
Architecture overview
The architecture is straightforward. The user interacts with the Fuscalendar webapp, which is a static SvelteKit app hosted on AWS S3 and served through CloudFront.
All API calls go to an Express server running on AWS Lambda. Lambda handles two things: task CRUD operations against DynamoDB, and AI estimation calls to the OpenAI API. Every request passes through FuscAuth's JWT middleware before it reaches any endpoint.
CloudFront sits in front of the static assets and also runs a small function that makes client-side routing work (more on that below).
DynamoDB key design (and the feature that wasn't planned)
I store tasks in the shared fuscripts monotable with a composite key structure:
Partition Key: USER#<userId>
Sort Key: TASK#<date>#<taskId>The date sits in the sort key so I can query all tasks for a given day with a single begins_with condition: give me everything under USER#abc where the sort key starts with TASK#2025-04-27. One query, one day's worth of tasks. Clean and efficient.
This design came straight from every DynamoDB tutorial I'd ever watched. They all use ecommerce examples where the order date lives in the sort key so you can sort orders chronologically. Orders, tasks, same thing, right?
Right. Until it wasn't.
Fuscalendar had been in production for a while when I thought, "it would be nice to let users reschedule tasks." Because, predictably, I wasn't finishing everything I planned for a day and wanted to bump things to tomorrow. Or next week. You know how it goes.
Here's the thing about DynamoDB: you can't update a sort key (or the primary key, for the record).
Once a task is created for a specific date, that date is baked into its identity. To "move" a task to another day, you'd need to delete the old item and create a new one with the new date in the sort key. Which means refactoring the key structure, updating all the queries, handling edge cases around task IDs, and probably introducing bugs into a system that was working fine.
So I did what any reasonable engineer would do. I changed the narrative.
"You see, the idea behind Fuscalendar is to help the user commit to their tasks. Allowing date changes would reduce that commitment. You'd think 'meh, I can always do it tomorrow.' Not allowing rescheduling is actually a productivity hack. It forces you to be intentional about what you plan for each day."
It's not a bug, it's a feature.
Structured AI output with Zod
The AI estimation flow sends two messages to GPT-4.1-mini: a system prompt that sets the behavior, and the user's raw brain dump as the user message. The response needs to come back as structured JSON, not freeform text.
I use Zod schemas to enforce the response structure:
const TaskSchema = z.object({
title: z.string(),
description: z.string(),
estimated_time_minutes: z.number(),
})
const TasksObjectSchema = z.object({
tasks: z.array(TaskSchema),
})The parseStructured function from fuscommons passes this schema to the OpenAI API, which constrains the model's output format. If the response doesn't match the schema, it fails rather than silently producing garbage. No post-processing, no regex parsing, no "let's hope the model returns valid JSON this time."
This was one of my first experiences with structured outputs from language models, and it made the whole integration feel solid. The AI can hallucinate creative task descriptions all it wants, but the shape of the data is guaranteed.
Optimistic UI
Nobody wants to wait for a server round-trip to see a checkbox toggle. Fuscalendar uses optimistic updates for most interactions: when you complete a task, the UI updates immediately. The API call happens in the background. If it fails, the change rolls back.
The pattern looks roughly like this: update the local state first, fire the request, and only revert if something goes wrong. It makes the app feel instant even though every action still persists to DynamoDB through a Lambda function on the other side of the internet.
This matters more than you'd think for a task manager. The whole point is reducing friction. If checking off "do laundry" had a visible delay, the app would feel heavier than the paper list it's trying to replace.
The CloudFront trick
One problem I ran into early on applies to any single-page app hosted on S3.
When a user refreshes the page on a nested route like /fuscalendar/task/abc123, S3 doesn't know what to do. There's no file at that path. It returns a 404.
The fix was a CloudFront Function that intercepts requests at the viewer level. Here's the actual function running in production:
function handler(event) {
var request = event.request;
var uri = request.uri;
// If the request is for a known file type, leave it as is.
if (uri.match(/\.(?:html|js|css|png|jpg|jpeg|svg|gif|ico|webp|json|woff|woff2|ttf)$/)) {
return request;
}
// Otherwise, assume it's a SPA route.
// Rewrite to "/<app>/index.html" (the app's root index file).
var segments = uri.split('/');
if (segments.length > 1 && segments\[1\] !== "") {
var appName = segments\[1\];
request.uri = '/' + appName + '/index.html';
} else {
request.uri = '/index.html';
}
return request;
}If the request path has a file extension (.js, .css, .png, etc.), it's a real asset, so the function passes it through. Otherwise, it extracts the first path segment as the app name and rewrites the request to that app's index.html. So /fuscalendar/task/abc123 becomes /fuscalendar/index.html, and SvelteKit's client-side router reads the original URL and renders the right page.
No configuration per route, no server-side rendering needed. It's a small trick, but it solved a real headache. And because the function runs at the CloudFront edge, and I use the same API Gateway (and CloudFront distribution) for all my projects, it works for every frontend in the fuscripts ecosystem without any per-app setup.
What I'd do differently
If I were starting over, I'd probably put the task date as a regular attribute instead of in the sort key. The "commitment feature" narrative is fun, but in practice, sometimes you genuinely need to reschedule. A GSI on the date field would give me the same query efficiency without locking tasks to a specific day.
But that's the thing about personal projects. They don't need to be perfect. They need to be useful enough that you keep opening them on Saturday mornings.