Sittr

  • mar 28, 2025

Warning

This is only half written, be nice.

Post graduation I moved back home with my family in Poole, and ever since I have been helping out wherever I can, including by walking the dog. Jake is a 6 year old golden retriever who we like to say was bred for his looks, not his intelligence. He needs a lot of attention and walking, and thats partly why I decided to build Sittr.

My parents had planned a 3 month trip to New Zealand, during which I would be in charge of the house and dog, while looking for jobs (which never ended up materialising), and keeping myself busy. One way that I kept myself busy was programming, specifically learning React, as it is a UI framework in high demand. What better way to combine two of the things I do every day, sitting for a dog and programming.

A brief overview

The idea for Sittr is that users create groups of trusted sitters they already know and invite them to complete tasks. this removes the need for verification or the exchange of money on the platform, simplifying the business model and work required to make it work. I was never planning for users to seriously use the platform, as its more of a showcase of my technical skills, so removing difficult design questions was a priority.

Group owners create invite links and send them to their trusted sitters to join the group. once in a group, sitters get notified upon a task being assigned to the group, and can claim the task, assigning it specifically to them, then allowing them to mark it as complete. This is the main function of the platform, with everything else being extra details to improve the experience, including uploading pictures of the task or pictures to prove its completion.

Tech stack

Part of the reason for working on this project was to have a reason to learn React, as I find that project based learning suits my brain, and makes me learn things without knowing im doing it. previously I used Svelte and SvelteKit to build RT-Trainer for my 3rd year project with really no complaints other than some inconsistencies with reactive ($) statements. However, with the move to Svelte 5, with many significant changes, I was going to have to either re-learn Svelte or try something different - hence React.

Persuaded by the clean documentation, and a million YouTube tutorials, I chose NextJS (app router) as the framework. There is a lot of support out there for NextJS, but it is also considered to be overhyped, so I was intrigued to learn more and form my own opinion with a project.

I enjoyed using Tailwind for styling, so brought it along for Sittr, as well as Drizzle ORM for database querying without having to get chatgpt to do my SQL for me. I also brought along Auth.js from a previous project as it has nice integrations with Drizzle and makes authentication simple. One new library I was excited to use was shadcn/ui which provides a diverse set of modular and extensible components that have fit my needs well. For most projects they are probably all you need, unless you have the requirement of some advanced stateful element or very specific layout needs.

For this project, another goal was to try and think more about a business model, in order to get a better idea of how SaaS apps are priced. This involved two things - firstly at any point where I was architecting the system I had to think about how I was going to (fairly) separate the experiences of paying and non-paying users; second it meant implementing an upgrade flow involving integrating stripe as a payment processor. I didn’t intend to have anyone pay for the ‘premium’ tier or ‘Sittr+’ as I ended up calling it, but it was an interesting challenge to design what could be described as marketing material on the upgrade page.

As I have for multiple previous projects, I used Vercel for hosting as I am used to it, though I am interested in exploring other hosting options in the future. Vercel’s cron job feature integrates into NextJS, as well as many other features, taking some of the less fun features to implement off of my hands a little.

Dates and pagination

The homepage of Sittr features a large calender that displays the tasks related to the user. It displays tasks colour-coded by whether the user created it or they were assigned it by a group they are in. By default the current month is shown, but this is little use when wanting to look ahead (or back a month). Rather than naively fetching all tasks relevant to a user, I attempted to create a pagination system in the task API so that only relevant data is fetched by the UI.

This feels fairly simple, but the calendar library I ended up using (react-big-calendar) inconsistently shows either four or five weeks, and up to seven days that aren’t in the current month at the start and end of the month. The first problem was resolved by changing the default parameters to fix the calendar to always show five weeks, but the second issue was a little more complex.

Showing dates outside the current month is a common feature of both real world and digital calendars, and happens when the first week of the month doesn’t start on the 1st, and the final week doesn’t end on the last day of the month - a simple rule. We like simple rules. To request the start date of the current month, we simply get the current day from new Date(), then use startOfMonth() from the date-fns npm package, and finally startOfWeek(), also from date-fns. Combined into startOfWeek(startOfMonth(new Date())) we get the start of the first week of a the calendar. The end is then found in a similar way with with endOfWeek(endOfMonth(new Date())).

Pagination was achieved by first getting the current month from the date range we just calculated by using an algorithm I thought up - get all days in the range, then count the months and chose the month with the most occurrences. My implementation uses five functions from date-fns and can probably be made much more compact. Once we have the current month as a date object (first day at 00:00:00), we simply get the previous month with date.setMonth(date.getMonth() - 1). Then using the same trick we did for the current month, we get the range of dates that fit a five week calendar.

Accounts and Authentication

  • authjs
  • authenticating on pages not layout (best practice)
  • serverless and authjs (email field not db id)

Zod

  • the idea
  • what went wrong

Group invites

  • generating codes
  • accepting codes

UploadThing vs Amazon S3

At the time of developing Sittr, I knew very little about AWS. I now have a AWS Cloud Practitioner Certification, and still know very little about AWS. What I did know and still know however, is that developing integrations directly with cloud services is fairly streamlined, but products that works as a sort of wrapper for the cloud is very easy. Hence to support image uploading and storage I chose to use UploadThing rather than learn AWS best practices and stress about the AWS Elastic Debt Collectors (EDC) coming to my house when I accidentally overspend.

UploadThing (UT) is an image hosting service that wraps Amazon S3 and provides a TypeScript library which is very simple to set up. It also has a free plan so is was no-brainer for quickly implementing image storage for Sittr. Additionally error handling with S3 was handled by UT, further simplifying my task.

Using the UT API involves defining file routers containing a set of uploaders. These uploaders are then called in the UI with the file to upload and any required parameters, for example which pet to associate a profile picture with. The most interesting router in Sittr is the unlinkedPetProfilePicUploader.

First we we define the shape of the object to be uploaded, with it being an image of size 4MB

unlinkedPetProfilePicUploader: f({ image: { maxFileSize: "4MB" } });

Then we would define an input, for example which pet we want to associate the image with. Inputs are defined using a Zod schema. In this case, where the function is only used during the pet creation process, we don’t have a pet to associate the image with. Hence we skip defining an input. This will make the rest of the function more complicated but thats why I have chosen this example. An example of a schema is listed below from another uploader.

    .input(
      z.object({
        petId: z.string(),
      }),
    )

Our middleware function is next and runs on our server before the we send off the file to UT. In this example we simply get the user’s userId from our auth library (Auth.js) and rate limit them using Upstash. Finally we return the user’s userId, making it available in the onUploadComplete function.

    .middleware(async ({}) => {
      // This code runs on the server before upload
      const user = await getBasicLoggedInUser();
      const userId = user?.id;

      if (!userId) throw new UploadThingError("Unauthorized");

      const { success } = await singleImageRateLimit.limit(userId);

      if (!success) {
        throw new UploadThingError("You are uploading images too fast");
      }

      // Whatever is returned here is accessible in onUploadComplete as `metadata`
      return { userId: userId };
    })

Next we get to a good bit. The image has now been uploaded to UT, and they have handled all the complex stuff for us. We just need to handle how we keep track of the files we upload and do something if it all goes wrong.

In the onUploadComplete function we are given the metadata we returned earlier in the middleware function, and details about the file uploaded to UT. Our uploader is used during pet creation, and during pet creation we haven’t put our pet in the database yet, so our pet doesn’t have an id. Instead, the image is tied only to the user. To solve this, I decided that users can only be in the process of creating one pet at a time, and can only have one uploaded unassigned pet profile picture at a time.

Therefore we start with deleting any previous unassigned pet profile pictures uploaded by the user from UT and our database. We then push our new image to the image database, associating it with the userId. Finally we return the url of the image and its id in the database. It can now be displayed in the pet creation form.

.onUploadComplete(async ({ metadata, file }) => {
      // This code runs on the server after upload

      // Delete existing image(s) for this user missing a pet id
      const existingImages = await db
        .delete(petProfilePics)
        .where(
          and(
            eq(petProfilePics.uploaderId, metadata.userId),
            isNull(petProfilePics.petId),
          ),
        )
        .returning({ fileHash: petProfilePics.fileKey })
        .execute();

      // Remove old image from UploadThing
      await utapi.deleteFiles(existingImages.map((i) => i.fileHash));

      const petImageRow = await db
        .insert(petProfilePics)
        .values({
          uploaderId: metadata.userId,
          url: file.url,
          fileKey: file.key,
        })
        .returning({ insertedId: petProfilePics.id });

      if (!petImageRow || petImageRow.length == 0 || !petImageRow[0]) {
        throw new Error("Failed to insert pet image");
      }

      // Whatever is returned here is sent to the client `onClientUploadComplete` callback
      return {
        uploadedBy: metadata.userId,
        imageId: petImageRow[0].insertedId,
        url: file.url,
      };
    }),

Finally, in the pet creation form, we set the hidden image id field to be the id we got from the onUploadComplete function. This is done in the onClientUploadComplete callback. When the form is submitted, a server action is called which creates the pet, and if an image id is submitted as part of the form data, the image in the database with that id is updated to have its petId field set to match the newly created pet.

A little bit of cleanup is done by a cron job every day to delete any unlinked pet images older than two hours to reduce the amount of images stored.

Notifications

  • cron jobs
  • isRead
  • linking to things
  • what to send notifications about

Emails

  • resend?

Some takeaways

  • passing data between client and server components is easy only when you think ahead, otherwise it just becomes a bodge that breaks whenever something changes
  • shadcn date picker issues (using time picker component instead)