Introduction

Welcome to the Berkeleytime Docs! This is the documentation source for developers.

tip

If you are looking for instructions to setup your local environment, go to the Local Development page

Getting Started

note

The following are instructions to set up the documentation locally. To set up the Berkeleytime app locally, go to the Local Development section.

The docs are provided as a service with the root docker-compose.yml file, so if you have the Berkeleytime app running locally, you can access the docs at http://localhost:3000/. Below are (mainly deprecated) ways of running the docs without the Berkeleytime app.

Developing and Building Locally

There are two options: with and without containerization (ie. Docker).

Using Docker allows us to build the docs without downloading dependencies on our host machine, greatly simplifying the build process.

# ./berkeleytime
# Ensure you are on the latest commit
git pull

# Build the container (only needed once every time docs/Dockerfile changes!)
docker build --target=docs-dev --tag="docs:dev" --file="./apps/docs/Dockerfile" .

# Run the container
docker run --publish 3000:3000 --volume ./docs:/docs "docs:dev"

The docs should be available at http://localhost:3000/ with live reload. To kill the container, you can use the Docker Desktop UI or run docker kill [container id]. You can find the container ID from docker ps.

tip

To change the port from the above 3000, modify the docker run command as follows, replacing the XXXX with your desired port:

docker run --publish XXXX:3000 --volume ./docs:/docs "docs:dev"

Without Containerization

To build and view the docs locally, mdBook must be installed by following the guide here. It is necessary to install Rust locally as there are dependencies that are installed with cargo. Thus, it is highly recommended to build mdbook from Rust.

# Install mdbook preprocessors with cargo
cargo install mdbook-alerts
cargo install mdbook-toc

# ./berkeleytime
# Ensure you are on the latest commit
git pull

# Navigate into the docs directory
cd docs

# Build the book and serve at http://localhost:3000/
mdbook serve --port=3000 --open

Changes in the markdown files will be shown live.

Creating Books with Markdown and mdBook

As these docs are primarily written with markdown, feel free to check this quick guide on markdown's syntax.

To add new pages to the docs, check out the mdBook guide. Below is a step-by-step guide on creating a new page:

  1. Create a new .md file in the src directory. For example, if you want your new page to be in the Infrastructure section, you should put the new file in src/infrastructure.

  2. Add this file to SUMMARY.md. The indentation indicates which section your file will go under. For example:

    - [Infrastructure](./infrastructure/README.md)
        - [My New File's Title](./infrastructure/my-new-file.md)
    
  3. Add content to your file and see the results!

Local Development

Quickstart

After cloning the repo, run bootstrap script from the repo root:

# ./berkeleytime
bash apps/docs/src/getting-started/bootstrap-local.sh      

Optional flags:

# Skip database seeding 
bash apps/docs/src/getting-started/bootstrap-local.sh --no-seed-db

# Don't start Docker services
bash apps/docs/src/getting-started/bootstrap-local.sh --no-docker

If the script completes successfully, your local development environment is fully set up. You don't need to run any of the manual steps below until GraphQL typedefs change or a new dependency is added.

Note: The script is for macOS and Linux/WSL.

Starting up the Application

The steps below are the manual alternative to the bootstrap script. Use them only if you prefer to set up manually or if the script fails.

Local development has a few local dependencies:

First, set up Node locally:

nvm install --lts

After installing these dependencies, make sure you are on the main branch:

# ./berkeleytime
git pull
git switch main

# Continue installation of dependencies.
pre-commit install

# Create .env from template file
cp .env.template .env

# Setup local code editor intellisense.
npm install
npx turbo run generate

Open the docker desktop application, then run:

# Start up application
docker compose up -d

The Berkeleytime application should now be running locally at http://localhost:3000! Make sure that each page (catalog, grades, etc.) is working as expected.

Common Commands

Upon changing any GraphQL typedefs in the backend, the generated types must be regenerated:

# ./berkeleytime
npx turbo run generate

Errors can occur when installing new npm packages. If they aren't automatically reflected in an already running docker compose:

docker compose down
docker compose up --build -d

Docker Compose Profiles

By default, running docker compose up -d starts only the core stack (backend, frontend, MongoDB, Redis). Additional services are opt-in and can be enabled using Docker Compose profiles.

Profiles allow you to start only the services you need for your workflow, keeping local development less resource-intensive.

  • ag — AG frontend
    → http://localhost:3001

  • staff — Staff dashboard
    → http://localhost:3002

  • semantic-search — Semantic course search
    → http://localhost:3010

  • docs — Docs + Storybook
    → http://localhost:3003 / http://localhost:3005

  • dev — MinIO (staff photo uploads)
    → http://localhost:3006

# Start core + staff dashboard
docker compose --profile staff up -d

# Start multiple profiles
docker compose --profile ag --profile staff up -d

Ports

docker compose up will automatically setup certain services on your localhost ports. By default, DEV_PORT_PREFIX is set to 30, which means services will be available on ports starting with 30XX. You can adjust this by setting the DEV_PORT_PREFIX environment variable if you need to run multiple instances of the repository in parallel (e.g., for git worktree setups).

The following ports are used by default (DEV_PORT_PREFIX=30):

  • 3000: Main frontend and backend API (via nginx)
  • 3001: AG frontend (via nginx)
  • 3002: Staff frontend (via nginx)
  • 3003: Docs
  • 3004: Redis
  • 3005: Storybook
  • 3006: MinIO API (requires --profile dev)
  • 3007: MinIO Console (requires --profile dev)
  • 3008: MongoDB
  • 3009: API Sandbox (requires SIS API keys)

To use a different port prefix, set the DEV_PORT_PREFIX environment variable before running docker compose up:

DEV_PORT_PREFIX=80 docker compose up -d

Note: Currently only DEV_PORT_PREFIX=30 (default) and DEV_PORT_PREFIX=80 are fully supported. Additional port prefixes require updating the Google Cloud OAuth authorized redirect URIs.

Seeding Local Database

A seeded database is required for some pages on the frontend. The bootstrap script handles this by default (use --no-seed-db to skip). The steps below are the manual alternative:

# ./berkeleytime

# Ensure the MongoDB instance is already running.
docker compose up -d

# Download the public data
curl -f -o "prod-backup.gz" "https://backups.berkeleytime.com/public/daily/prod_public_backup-$(TZ=America/Los_Angeles date -v -6H +%Y%m%d).gz"

# Copy the data, restore, and seed fake user
docker cp ./prod-backup.gz berkeleytime-mongodb-1:/tmp/prod-backup.gz
docker exec berkeleytime-mongodb-1 mongorestore --drop --gzip --archive=/tmp/prod-backup.gz
docker exec berkeleytime-mongodb-1 mongosh bt --eval 'const r = db.users.findOneAndUpdate({ email: "[email protected]" }, { $setOnInsert: { googleId: "dev-fake-public-backup", email: "[email protected]", name: "Dev User", staff: false, lastSeenAt: new Date() } }, { upsert: true, returnDocument: "after" }); print("Dev user id: " + r._id); print("Login URL: http://localhost:3000/api/dev/login?userId=" + r._id + "&redirect_uri=/");'

Note: Public backups are redacted and are not a comprehensive dataset. Use private backups (Cloudflare Access required) for full data.

Contributing

Follow the steps in Local Development to set up your environment and start working on your feature.

Once you've completed your feature, push your changes with git push and make sure all the pre-push hooks pass. You should see all checks (type-check, format, lint, build) marked as "Passed" before proceeding.

Next, create a Pull Request (PR). Make a deployment to development and ping someone on the team (your pod lead or an experienced member) to review your PR. Send them both the link to your deployment and the link to your PR.

Once they approve your PR, you should be good to merge. After merging, test your changes in staging once it is deployed (staging deploys automatically on pushes to main). Then run the production workflow.

Deploying with CI/CD

The deployment process is different for development, staging, and production environments.

  • Development: Best for short-term deployments to simulate a production environment as closely as possible. Useful for deploying feature branches before merging into main.
  • Staging: The last "testing" environment to catch bugs before reaching production. Reserved for the latest commit on main.
  • Production: User facing website! Changes being pushed to production should be thoroughly tested on a developer's local machine and in development and staging environments.

To learn more about how our CI/CD pipelines work, head to the infra section's overview of CI/CD.

Development

  1. Go to the actions page.

    Image

    Github Actions Page

  2. Ensure "Deploy to Development" is the selected action on the left sidebar.

    Image

    Deploy to Development Sidebar Button

  3. Navigate to the "Run workflow" dropdown on the right. Select your branch and input a time to live in hours. Please keep this value a reasonable number. If you need to login, for example you want to test a scheduler feature, select "Enable authentication support".

    Image

    Deploy to Development Action Menu

  4. Once the action starts running, click into the action and watch the status of each step. If the deployment fails, the action will fail as well.

    Images

    Deploy to Development Action Running You can view the logs of each step by navigating the left sidebar. Deploy to Development Action Logs

  5. After the action succeeds, go to www.abcdefg.dev.berkeleytime.com, where abcdefg is the first 7 characters of the latest commit's hash. This is also shown on the summary tab of an action workflow. A hyperlink to the deployment is also available near the bottom of the Summary page of the workflow run.

    Example Success Deployment Log
    ======= CLI Version =======
    Drone SSH version 1.8.0
    ===========================
    Release "bt-dev-app-69d94b6" does not exist. Installing it now.
    Pulled: registry-1.docker.io/octoberkeleytime/bt-app:0.1.0-dev.69d94b6
    Digest: sha256:e3d020b8582b8b4c583f026f79e4ab2b374386ce67ea5ee43aa65c6b334f9db0
    W1204 22:20:37.827877 2103423 warnings.go:70] unknown field "spec.template.app.kubernetes.io/instance"
    W1204 22:20:37.827939 2103423 warnings.go:70] unknown field "spec.template.app.kubernetes.io/managed-by"
    W1204 22:20:37.827947 2103423 warnings.go:70] unknown field "spec.template.app.kubernetes.io/name"
    W1204 22:20:37.827952 2103423 warnings.go:70] unknown field "spec.template.env"
    W1204 22:20:37.827956 2103423 warnings.go:70] unknown field "spec.template.helm.sh/chart"
    NAME: bt-dev-app-69d94b6
    LAST DEPLOYED: Wed Dec  4 22:20:36 2024
    NAMESPACE: bt
    STATUS: deployed
    REVISION: 1
    TEST SUITE: None
    Waiting for deployment "bt-dev-app-69d94b6-backend" rollout to finish: 0 of 2 updated replicas are available...
    Waiting for deployment "bt-dev-app-69d94b6-backend" rollout to finish: 1 of 2 updated replicas are available...
    deployment "bt-dev-app-69d94b6-backend" successfully rolled out
    deployment "bt-dev-app-69d94b6-frontend" successfully rolled out
    ===============================================
    ✅ Successfully executed commands to all hosts.
    ===============================================
    

    Deploy to Development Link

Staging

The staging CI/CD pipeline is automatically run on every push to main. The staging website can be viewed at staging.berkeleytime.com.

Production

The production CI/CD pipeline is manually run with a process similar to the development pipeline. However, the production pipeline can only be run on main.

Backend

What is the backend?

The backend application service is the user-facing API server responsible for serving data to the frontend. Communication between the backend and frontend is done with HTTPS, as do most websites on the modern internet.

In addition to the user-facing API server, the backend application service also has an internal HTTP server used mainly by the datapuller.

To see more on how the backend service interacts with other components in the Berkeleytime system, view the architecture page.

The Berkeleytime Backend Service

The Tech Stack

The backend uses the following technologies:

1

As opposed to a simpler REST API, Berkeleytime uses a GraphQL API design. This creates a more flexible backend API and allows the frontend to be more expressive with its requests.

Codebase Organization

The backend codebase has a simple folder layout, as described below.

.
├── src
│   └── bootstrap                       # Bootstrapping and loading of backend dependencies
│       └── index.ts                    # Bootstrapping/Loading entrypoint.
│   └── modules                         # Business logic of the app divided by domain.
│       └── index.ts                    # Modules entrypoint.
│   ├── utils                           # Collection of utility function
│   ├── config.ts                       # Handles environment variable loading
│   └── main.ts                         # Backend entrypoint
└── codegen.ts                          # GraphQL code generation configuration file

Here is a list of services bootstrapped by the files in src/bootstrap:

The bulk of the application logic is split into separate modules within the src/modules directory. A module contains a collection of files necessary to serve the GraphQL queries for its domain. The file structure of the modules are all very similar. Below is the user module as an example:

.
├── src
│   └── modules
│       └── user                        # User module (as an example)
│           └── generated-types         # Generated types from codegen
│               └── module-types.ts     # Relevant Typescript types of GraphQL type definitions
│           └── generated-typedefs                # GraphQL type definitions
│               └── [schema].ts         # A type definition for a schema
│           ├── controller.ts           # Collection of DB-querying functions
│           ├── formatter.ts            # (Optional) Formats DB models to GraphQL type
│           ├── index.ts                # Entrypoint to the module
│           └── resolver.ts             # GraphQL resolver

Inside a Module

berkeleytime backend module pipeline

The above diagram shows a simplified request-to-response pipeline within a module.

  1. A GraphQL request is sent to the backend server. A request looks like a JSON skeleton, containing only keys but no values. The request is "routed" to the specific module.2

  2. The resolver handles the request by calling the specific controller method necessary.

  3. The controller queries the Mongo database, using user input to filter documents.

  4. The formatter translates the DB response from a database type, from berkeleytime/packages/common/src/models, into a GraphQL type, from [module]/generated_types/module-types.ts.

    • Note that not all modules have a formatter because the database type and GraphQL type are sometimes identical.
  5. Finally, the result is returned as a GraphQL response in the shape of a JSON, matching the query from step 1.4

2

In runtime, all of the modules and type definitions are merged into one by src/modules/index.ts, so there isn't any explicit "routing" in our application code.

3

The Mongoose abstraction is very similar to the built-in MongoDB query language.

4

Fields not requested are automatically removed.

Generated Type System

The backend uses GraphQL Code Generator with the graphql-modules preset to generate TypeScript types from GraphQL schema definitions. This ensures type safety between your GraphQL resolvers and the schema.

The codegen configuration is defined in apps/backend/codegen.ts:

import type { CodegenConfig } from "@graphql-codegen/cli";

const config: CodegenConfig = {
  schema: "./src/modules/**/generated-typedefs/*.ts",
  generates: {
    "./src/modules/": {
      preset: "graphql-modules",
      presetConfig: {
        baseTypesPath: "../generated-types/graphql.ts",
        filename: "generated-types/module-types.ts",
      },
      plugins: ["typescript", "typescript-resolvers"],
    },
  },
};

Each module has its types generated into generated-types/module-types.ts. These types are used in resolvers to ensure the resolver return types match the GraphQL schema.

To regenerate types after modifying GraphQL type definitions:

# From the backend app directory
npm run generate

tip

When adding a new field to a GraphQL type definition, always regenerate types before implementing the resolver to get proper TypeScript autocomplete and type checking.

Database Models

In addition to the API server, the backend service is responsible for managing MongoDB usage—specifically, how our data is organized and defined through collections, models, and indexes.

.
├── apps
│   └── backend                         # Backend codebase
├── packages                            # Shared packages across apps
│   └── common
│       └── src
│           └── models                  # All database models
│               └── [model].ts          # Example model file

A model file will contain TypeScript types mirroring the database model, a Mongoose model definition, and database index declarations.

// packages/common/src/models/term.ts

// defines TypeScript type for nested object
export interface ISessionItem { /* ... */ }

// defines TypeScript type for term object
export interface ITermItem { /* ... */ }

// defines Mongoose schema using TypeScript type
const termSchema = new Schema<ITermItem>({ /* ... */ });

// defines database indexes
termSchema.index( /* ... */ );

// creates Mongo model instance
export const TermModel: Model<ITermItem> = model<ITermItem>(
  "Term",
  termSchema
);

Testing the API

To test the GraphQL API, it is recommended to first seed the local database in order to have data.

API testing is mainly done through the Apollo GraphQL Sandbox available at http://localhost:3000/api/graphql when the backend container is running. While the UI is helpful for creating queries for you, it is highly recommended to review the GraphQL docs, specifically these pages:

Internal HTTP Service

The backend also serves an HTTP server5 mainly used by the datapuller, which communicates with the backend service to rehydrate the cache via an HTTP request.

5

In modern microservice systems, non-publicly-exposed services typically use a more efficient protocol, such as gRPC. For simplicity, we just use an HTTP server instead.

Data

Why is Data Important at Berkeleytime?

At its core, Berkeleytime serves as a data aggregation platform. We work directly with the Office of the Registrar and the Engineering and Integration Services department (EIS) to pull data from multiple sources and provide students with the most accurate experience possible. Because data involving students can contain personally-identifiable information (PII), we must ensure we follow any and all data storage and use guidelines imposed by the university.

Understanding the data sources Berkeleytime has access to is imperative for building streamlined services.

Backups and Access

Production backups may contain sensitive data:

  • Public backups are redacted and are not a comprehensive dataset.
  • Full backups require Cloudflare Access.

For details, see Runbooks.

API Central

The EIS maintains many RESTful APIs that consolidate data from various other sources, and provides documentation in the form of Swagger OpenAPI v3 specifications for each API. API Central serves as a portal for requesting access to individual APIs, interactive documentation, and managing API usage. Berkeleytime only has access to and utilizes the APIs necessary for servicing students.

Accessing APIs

HTTP requests to APIs must be authenticated with a client identifier and secret key pair and are rate limited to minimize unauthorized access and preserve system health.

warning

Client identifiers and secret keys should be treated as sensitive information and should never be shared with third-parties.

TypeScript API clients and types are automatically generated from the specifications using swagger-typescript-api and are provided as a local package for Berkeleytime apps to access.

import { Class, ClassesAPI } from "@repo/sis-api/classes";

const classesAPI = new ClassesAPI();

classesAPI.v1.getClassesUsingGet(...);

Class API

The Class API provides data about classes, sections, and enrollment.

  • Classes are offerings of a course in a specific term. There can be many classes for a given course, and even multiple classes for a given course in the same semester. Not all classes for a course need to include the same content either. An example of a class would be COMPSCI 61A Lecture 001 offered in Spring 2024. Classes themselves do not have facilitators, locations, or times associated with them. Instead, they are most always associated with a primary section.
  • Sections are associated with classes and are combinations of meetings, locations, and facilitators. There are many types of sections, such as lectures, labs, discussions, and seminars. Each class most always has a primary section and can have any number of secondary sections.

Students don't necessarily enroll only in classes, but also a combination of sections.

Course API

The Course API provides data about courses.

  • Courses are subject offerings that satisfy specific requirements or include certain curriculum. An example of a course would be COMPSCI 61A. However, multiple COMPSCI 61A courses might exist historically changing requirements and curriculum require new courses to be created and old courses to be deprecated. Only one course may be active for any given subject and number at a time.

Term API v2

The Term API v2 provides data about terms and sessions.

  • Terms are time periods during which classes are offered. Terms at Berkeley typically fall under the Spring and Fall semesters, but Berkeley also offers a Summer term and previously offered a Winter term (in the 1900s). Terms are most always associated with at least one session.
  • Sessions are more granular time periods within a semester during which groups of classes are offered. The Spring and Fall semesters at Berkeley consist only of a single session that spans the entire semester, but the Summer term consists of multiple sessions of varying lengths depending on the year.

Datapuller

What is the datapuller?

The datapuller is a modular collection of data-pulling scripts responsible for populating Berkeleytime's databases with course, class, section, grades, and enrollment data from the official university-provided APIs. This collection of pullers are unified through a singular entrypoint, making it incredibly easy for new pullers to be developed. The original proposal can be found here1.

Motivation

Before the datapuller, all data updates were done through a single script run everyday. The lack of modularity made it difficult to increase or decrease the frequency of specific data types. For example, enrollment data changes rapidly during enrollment season—it would be beneficial to be able to update our data more frequently than just once a day. However, course data seldom changes—it would be efficient to update our data less frequently.

Thus, datapuller was born, modularizing each puller into a separate script and giving us more control and increasing the fault-tolerance of each script.

1

Modifications to the initial proposal are not included in the document. However, the motivation remains relatively consistent.

Local & Remote Development

Local Development

The datapuller inserts data into the Mongo database. Thus, to test locally, a Mongo instance must first be running locally and be accessible to the datapuller container. To run a specific puller, the datapuller must first be built, then the specific puller must be passed as a command1. After modifying any code, the container must be re-built for changes to be reflected.

# ./berkeleytime

# Start up docker-compose.yml
docker compose up -d

# Build the datapuller-dev image
docker build --target datapuller-dev --tag "datapuller-dev" \
    --file="./apps/datapuller/Dockerfile" .

# Run the desired puller. `courses` is used as an example here.
docker run --volume ./.env:/datapuller/apps/datapuller/.env \
    --network berkeleytime_bt "datapuller-dev" "--puller=courses"

The valid pullers are:

  • courses
  • sections-active
  • sections-last-five-years
  • classes-active
  • classes-last-five-years
  • grades-recent
  • grades-last-five-years
  • enrollments
  • enrollment-calendar
  • terms-all
  • terms-nearby
  • decals

tip

If you do not need any other services (backend, frontend), then you can run a Mongo instance independently from the docker-compose.yml configuration. However, the below commands do not allow data persistence.

# Run a Mongo instance. The name flag changes the MONGO_URI.
# Here, it would be mongodb://mongodb:27017/bt?replicaSet=rs0.
docker run --name mongodb --network bt --detach "mongo:7.0.5" \
    mongod --replSet rs0 --bind_ip_all

# Initiate the replica set.
docker exec mongodb mongosh --eval \
    "rs.initiate({_id: 'rs0', members: [{_id: 0, host: 'mongodb:27017'}]})"
1

Here, I reference the Docker world's terminology. In the Docker world, the ENTRYPOINT instruction denotes the the executable that cannot be overriden after the image is built. The CMD instruction denotes an argument that can be overriden after the image is built. In the Kubernetes world, the ENTRYPOINT analogous is the command field, while the CMD equivalent is the args field.

Remote Development

The development CI/CD pipeline marks all datapuller CronJobs as suspended, preventing the datapuller jobs to be scheduled. To test a change, manually run the desired puller.

Frontend

We maintain a static, single-page application (SPA) at berkeleytime.com. Once compiled, the application consists only of HTML, JavaScript, and CSS files served to visitors. No server generates responses at request time. Instead, the SPA utilizes the browser to fetch data from the backend service hosted at berkeleytime.com/api/graphql.

We originally chose this pattern because most developers are familiar with React, Vue, Svelte, or other SPA frameworks and we did not want to opt for a more opinionated meta-framework like Next.js or Remix for now. However, there are always trade-offs.

The frontend consists of the design, components, and logic that make up our SPA.

Recommendations

  • Use VSCode
  • Install the Prettier extension
  • Install the ESLint extension

Stack

Berkeleytime is built entirely with TypeScript and the frontend follows suit with strictly-typed React built with Vite. Because we use Apollo for our GraphQL server, use the React Apollo client for fetching and mutating data on the frontend.

import { useQuery } from "@apollo/client/react";

import { READ_CLASS, ReadClassResponse, Semester } from "@/lib/api";

export const useReadClass = (
  year: number,
  semester: Semester,
  subject: string,
  courseNumber: string,
  number: string,
  options?: Omit<useQuery.Options<ReadClassResponse>, "variables">
) => {
  const query = useQuery<ReadClassResponse>(READ_CLASS, {
    ...options,
    variables: {
      year,
      semester,
      subject,
      courseNumber,
      number,
    },
  });

  return {
    ...query,
    data: query.data?.class,
  };
};

Structure

The frontend consists of not only the SPA, but also various packages used to modularize our codebase and separate concerns. These packages are managed by Turborepo, a build system designed for scaling monorepos, but I won't dive too deep into how Turborepo works right now.

apps/
  ...
  frontend/                     # React SPA served at https://berkeleytime.com
  ...
packages/
  ...
  theme/                        # React design system
  eslint-config/                # Shared utility package for ESLint configuration files
  typescript-config/            # Shared utility package for TypeScript configured files
  ...

You can see how the frontend app depends on these packages within the apps/frontend/package.json.

{
  "name": "frontend",
  // ...
  "dependencies": {
    // ...
    "@repo/theme": "*",
    "react": "^19.0.0"
  },
  "devDependencies": {
    // ...
    "@repo/eslint-config": "*",
    "@repo/typescript-config": "*",
    "@types/react": "^19.0.8",
    "@vitejs/plugin-react": "^4.3.4",
    "eslint": "^9.19.0",
    "typescript": "^5.7.3",
    "vite": "6.0.8"
  }
}

Design system

We maintain a design system built on top of Radix primitives, a library of unstyled, accessible, pre-built React components like dialogs, dropdown menus, and tooltips. By standardizing components, colors, icons, and other patterns, we can reduce the amount of effort required to build new features or maintain consistency across the frontend.

The design system houses standalone components that do not require any external context. They maintain design consistency and should function whether or not they are used in the context of Berkeleytime. More complex components specific to Berkeleytime, such as for classes or courses, live in the frontend app and will be discussed later.

We use Iconoir icons and the Inter typeface family. These design decisions, and reusable design tokens, are all abstracted away within the theme package and the ThemeProvider React component.

# packages/theme/src
...
components/                          # React components for the design system
  ...
  ThemeProvider/                     # Entry point component
  Button/
  Dialog/
  Tooltip/
  ...
contexts/                            # React contexts for the design system
hooks/                               # React hooks for the design system
...

We built our design system with light and dark themes in mind, and the color tokens will respond accordingly. When building interfaces within Berkeleytime, standard color tokens should be used to ensure consistency depending on the selected theme.

// packages/theme/components/ThemeProvider/ThemeProvider.module.scss

@mixin light-theme {
  --foreground-color: var(--light-foreground-color);
  --background-color: var(--light-background-color);
  --backdrop-color: var(--light-backdrop-color);

  // ...
}

@mixin dark-theme {
  --foreground-color: var(--dark-foreground-color);
  --background-color: var(--dark-background-color);
  --backdrop-color: var(--dark-backdrop-color);

  // ...
}

body[data-theme="dark"] {
  @include dark-theme;
}

body[data-theme="light"] {
  @include light-theme;
}

body:not([data-theme]) {
  @include light-theme;

  @media (prefers-color-scheme: dark) {
    @include dark-theme;
  }
}

Generated Type System

Berkeleytime uses GraphQL Code Generator to automatically generate TypeScript types from GraphQL queries. This provides end-to-end type safety from your queries to your components.

Workflow

  1. Write queries in src/lib/api/*.ts using the gql tag
  2. Run codegen with npm run generate
  3. Use the generated Document in your hooks with useQuery()

Example

First, define your query in src/lib/api/courses.ts:

import { gql } from "@apollo/client";

export const GET_COURSE = gql`
  query GetCourse($subject: String!, $number: CourseNumber!) {
    course(subject: $subject, number: $number) {
      courseId
      title
      description
    }
  }
`;

After running npm run generate, use the generated GetCourseDocument in your hooks:

import { useQuery } from "@apollo/client/react";
import {
  GetCourseDocument,
  GetCourseQuery,
} from "@/lib/generated/graphql";

// The Document provides full type inference
const query = useQuery(GetCourseDocument, {
  variables: { subject: "COMPSCI", number: "61A" },
});

// query.data is automatically typed as GetCourseQuery

You can also derive reusable types from the generated query types:

import { GetCourseQuery } from "@/lib/generated/graphql";

// Extract the course type from the query response
export type ICourse = NonNullable<GetCourseQuery["course"]>;

Berkeleytime-specific Components

A number of the Radix primitives and other commonly-used components have since also been adapted to specifically fit Berkeleytime's needs by the design team. These components should be used whenever possible. A full list of components can be found in packages/theme/src/components.

Storybook

To view some of these components and common applications, you can go to our Storybook. When running with docker compose, this will automatically be hosted at localhost:6006.

Application

I'm sure you've seen a Vite, React, and TypeScript app in the wild before, and we tend to follow most common practices, which includes using React Router.

#
src/
  app/                    # Views, pages, and scoped components
  components/             # Reusable components built around Berkeleytime
  contexts/               # React contexts
  hooks/                  # React hooks
  lib/                    # Utility functions and general logic
    api/                  # GraphQL types and queries
    ...
  main.tsx
  App.tsx                 # Routing and React entry point
  index.html
  ...
  vite.config.ts

Conventions

We use SCSS modules for scoping styles to components and reducing global CSS clutter. A typical folder (in src/app or src/components) should be structured like so.

# apps/frontend
src/app/[COMPONENT]/
  index.tsx
  [COMPONENT].module.scss
  ...
  [CHILD_COMPONENT]/
    index.tsx
    [CHILD_COMPONENT].module.scss

Child components should be used in your best judgment whenever significant logic must be refactored out of the component for structural or organizational purposes. If child components are reused in multiple pages or components, they should be moved as high up in the file structure as is required or moved to src/components.

Staff Dashboard

The Staff Dashboard is an administrative interface for managing Berkeleytime staff, viewing analytics, and configuring platform settings. It is accessible only to users with staff status.

URL: staff.berkeleytime.com

Stack

  • Framework: React with TypeScript
  • Build Tool: Vite
  • Data Fetching: Apollo Client (GraphQL)
  • UI: @repo/theme design system
  • Charts: Recharts

Structure

apps/staff-frontend/
├── src/
│   ├── app/                   # Feature pages
│   │   ├── Dashboard/         # Staff management
│   │   ├── Analytics/         # Analytics dashboards
│   │   ├── Banners/           # Banner management
│   │   └── RouteRedirects/    # URL redirect management
│   ├── components/
│   │   ├── Layout/            # Main layout wrapper
│   │   ├── NavigationBar/     # Tab navigation
│   │   └── Chart/             # Chart components
│   ├── hooks/api/             # GraphQL hooks
│   └── lib/api/               # GraphQL queries & mutations
├── package.json
└── vite.config.ts

Features

Staff Management

The main dashboard provides tools for managing Berkeleytime team members:

  • Staff Directory: View all staff members with search and filter options
  • Add Staff: Promote existing users to staff status
  • Edit Staff: Update staff info, photos, and semester roles
  • Pod Management: Organize staff into teams/pods by semester

Staff membership also controls access to private Mongo backups at https://backups.berkeleytime.com/private/*. When a user is added as staff, their email is added to the Cloudflare Zero Trust Access group that protects those backups; when they are removed from staff, their email is removed from that group. To backfill existing staff into the Access group, use the GraphQL mutation syncCloudflareStaffAccess (staff-only). See Runbooks — Cloudflare Access staff backup sync.

Each staff member can have multiple semester roles with:

  • Role type (Engineering, Design, etc.)
  • Leadership flag
  • Pod/team assignment
  • Profile photos

Analytics

The analytics section provides insights into platform usage:

DashboardMetrics
GeneralUnique visitors, requests, user growth, signup patterns
SchedulerSchedules created, daily trends, classes per schedule
RatingsRating counts, course distribution, score trends
GradTrakPlans created, major/minor distribution
BookmarksCollection usage, bookmark trends

Banners

Create and manage platform-wide announcements:

  • Rich text content with HTML support
  • Optional link with custom text
  • Persistent vs. dismissible banners
  • Create/update/delete operations

Route Redirects

Configure URL redirects for the platform:

  • Map internal paths to external URLs
  • Useful for legacy links or shortlinks

Local Development

The staff dashboard runs on port 3002:

# Start with docker compose
docker compose up staff-frontend

# Or run standalone
cd apps/staff-frontend
npm run dev

Access at http://localhost:3002

Authentication

The dashboard uses OAuth authentication through the backend:

  1. User visits staff dashboard
  2. If not authenticated, redirected to /api/login
  3. After OAuth flow, backend validates staff status
  4. Non-staff users see a sign-in prompt

note

You must be added as a staff member by an existing staff member to access the dashboard.

Infrastructure

note

Infrastructure concepts tend to be more complex than application concepts. Don't be discouraged if a large amount of content in the infrastructure section is confusing!

What is Infrastructure?

application-infrastructure-layers

Software infrastructure refers to the services and tools that create an underlying layer of abstractions that the application is developed on. Compared to the application layer, infrastructure is significantly more broad in its responsibilities, although these responsibilities are more common in software development.

important

We aim to use a small set of existing infrastructure solutions with large communities. This philosophy reduces the cognitive load on each developer and simplifies the onboarding process, both of which are valuable for creating long-lasting software in a team where developers are typically cycled out after only ~4 years.

Backups

Mongo backups are served from https://backups.berkeleytime.com. Download steps live in Runbooks.

Secrets

Secret management (including sealed-secrets and the json-to-secret.sh helper script) is documented in Runbooks.

Onboarding

Architecture

Berkeleytime uses a fairly simple microservices architecture—we decouple only a few application components into separate services. Below is a high-level diagram of the current architecture (switch to a light viewing mode to see arrows).

berkeleytime architecture design

Note that, other than the application services developed by us, all other services are well-known and have large communities. These services have many tutorials, guides, and issues already created online, streamlining the setup and debugging processes.

An HTTP Request's Life

To better understand the roles of each component in the Berkeleytime architecture, we describe the lifecycle of an HTTP request from a user's action.

  1. An HTTP request starts from a user's browser. For example, when a user visits https://berkeleytime.com, a GET request is sent to hozer-51.1

  2. Once the request reaches hozer-51, it is first encountered by hozer-51's Kubernetes cluster load balancer, a MetalLB instance, which balances external traffic into the cluster across nodes.2

  3. Next, the request reaches the reverse proxy, an nginx instance, which forwards HTTP requests to either a backend or frontend service based on the URL of the request

    • Requests with URLs matching https://berkeleytime.com/api/* are forwarded to the backend service.
    • All other requests are forwarded to the frontend service.

    The nginx instance is also responsible for load balancing between the backend/frontend replicas. Currently, there are two of each in all deployment environments.

  4. The request is processed by one of the services.

    • The backend service may interact with the MongoDB database or the Redis cache while processing the request.3
  5. Finally, an HTTP response is sent back through the system to the user's machine.

1

More specifically, the user's machine first requests a DNS record of berkeleytime.com from a DNS server, which should return hozer-51's IP address. After the user's machine knows the hozer-51 IP address, the GET request is sent.

2

Currently, we only have one node: hozer-51.

3

Requests sent from the backend to the database or cache are not necessarily HTTP requests.

SSH Setup

warning

This onboarding step is not necessary for local development. As running commands in hozer-51 can break production, please continue with caution.

The Berkeleytime website is hosted on a machine supplied by the OCF. This machine will be referenced as hozer-51 in these docs. SSH allows us to connect to hozer-51 with a shell terminal, allowing us to infra-related tasks.

This guide assumes basic experience with SSH.

  1. Please ensure your public SSH key has an identifying comment attached, such as your Berkeley email:

    ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAq8Lwls394thisIsNotARealKey [email protected]
    

    You can directly modify your public key file at ~/.ssh/id_*.pub, or you can use the following command:

    ssh-keygen -c -C "[email protected]" -f ~/.ssh/id_*
    

    Note that -f takes in the path to your private key file, but only modifies the public key file.

  2. Copy your SSH key to the hozer machine's authorized_keys file:

    ssh-copy-id [email protected]
    

    The SSH password can be found in the pinned messages of the #backend staff channel in discord.

  3. (Optional) Add hozer-51 to your ~/.ssh/config file:

    # Begin Berkeleytime hozer config
    Host hozer-??
        HostName %h.ocf.berkeley.edu
        User root
    # End Berkeleytime hozer config
    

    Now, you can quickly SSH into the remote machine from your terminal:

    ssh hozer-51
    # as opposed to [email protected]
    

Kubernetes & Helm

Kubernetes is a container orchestrator that serves as the foundation of our infrastructure. It provides a simple deployment interface. To get started with Kubernetes, here are a few resources:

  • The concepts page is a good place to start.
  • The glossary is also a good place to glance over common jargon.

Helm is a package manager for Kubernetes that provides an abstraction over the Kubernetes interface for deploying groups of components called "charts". In addition, it allows us to install pre-made charts, useful for deploying services that we don't develop.

Here is a diagram outlining (in some detail) the structure of the Kubernetes cluster:

Useful Commands

This is an uncomprehensive list of commands that can be executed in hozer-51, useful for debugging.

tip

On hozer-51, k is an alias for kubectl and h is an alias for helm.

important

The default namespace has been set as bt.

Pods

  • k get pods

    View all running pods.

  • k get pods -l env=[dev|stage|prod]

    View all running pods in a specified environment.

  • k logs [pod name]

    View logs of a pod. You can get a pod's name with k get pods. Include a -f flag to follow logs, which will stream logs into your terminal.

  • k describe pod [pod name]

    View a description of a pod. Useful for when pod is failing to startup, thus not showing any logs.

  • k exec -it [pod name] -- [command]

    Execute a command inside a pod. The command can be bash, which will start a shell inside the pod and allow for more commands.

Deployments

  • k get deploy

    View all running deployments.

  • k get deploy -l env=[dev|stage|prod]

    View all running deployments in a specified environment.

  • k describe deploy [deploy name]

    View a description of a deploy. Useful for when the deploy's pods are failing to startup, thus not showing any logs.

  • k rollout restart deploy/[deploy name]

    Manually restart a deployment.

Helm Charts

  • h list

    List helm chart releases. A release is an installed instance of a chart.

Deployment Environments & CI/CD Workflow

What are Deployment Environments?

Berkeleytime has three deployment environments: production, staging, and development. The production environment refers to the live deployed website seen by users of Berkeleytime and should contain code already tested in the other two environments. The staging and development environments are primarily used by Berkeleytime developers/designers to test new code.

  • Production: Finalized changes merged in main are manually deployed here at berkeleytime.com
  • Staging: Changes already merged in main are automatically deployed here at staging.berkeleytime.com
  • Development: Specific git branches can be manually deployed here.

The CI/CD Github Actions

We use GitHub actions to build our CI/CD workflows.1 All three CI/CD workflows2 are fairly similar to each other and can all be broken into two phases: the build and the deploy phase.

  1. Build Phase: An application container and Helm chart are built and pushed to a registry. We use Docker Hub. This process is what .github/workflows/cd-build.yaml is responsible for and is run in the Github Action environment.

  2. Deploy Phase: After the container and Helm chart are built and pushed to a registry, they are pulled and deployed onto hozer-51. This process is what .github/workflows/cd-deploy.yaml is responsible for and is run in the Github Action environment ssh'd into hozer-51.

berkeleytime ci/cd workflow

Comparing Deployment Environment Actions

The differences between the three environments are managed by each individual workflow file: cd-dev.yaml, cd-stage.yaml, and cd-prod.yaml.

DevelopmentStagingProduction
k8s Pod Prefixbt-dev-*bt-stage-*bt-prod-*
Container Tags[commit hash]latestprod
Helm Chart Versions30.1.0-dev-[commit hash]0.1.0-stage1.0.0
TTL (Time to Live)[GitHub Action input]N/AN/A
Deployment Count Limit811
Datapuller suspendtruefalsefalse
1

In the past, we have used a self-hosted GitLab instance. However, the CI/CD pipeline was obscured behind a admin login page. Hopefully, with GitHub actions, the deployment process will be more transparent and accessible to all engineers. Please don't break anything though!

2

Development, Staging, and Production

3

Ideally, these would follow semantic versioning, but this is rather difficult to enforce and automate.

DNS & TLS Certificates

Introduction

What is the Domain Name System (DNS)?

The DNS is a system used across to internet to associate domains, such as berkeleytime.com, with IP addresses, such as 123.123.123.123. Internet browsers use the DNS protocol to translate common domains to IP addresses to know where to route packets.

UC Berkeley classes that cover how a DNS work include:

Learn more about DNSs:

What are TLS Certificates?

A TLS Certificate secures connections between internet browsers and web servers by authenticating web servers, exchanging keys to encrypt data packets, and providing integrity guarantees over the connection. Connections to websites secured with TLS certificates typically use HTTPS instead of HTTP.

UC Berkeley classes that cover how TLS Certificates work include:

Learn more about SSL/TLS (SSL is the predecessor to TLS):

Our Cloudflare DNS Setup

For the most relevant setup documentation, refer to Cloudflare's DNS Setup Docs.

We pay for the domains berkeleytime.com and stanfurdtime.com, both registered with Cloudflare Registrar. In addition, our authoritative DNS is also Cloudlfare, and its configuration (what domains map to what IPs) can be changed on the Cloudflare Developer Dashboard.

Our Kubernetes Cluster Setup

There are two relevant Kubernetes components when discussing DNS and Certificates: our reverse proxy ingress-nginx and cert-manager.

Ingress Nginx

Recall from An HTTP Request's Life, ingress-nginx is our reverse proxy responsible for routing between our application services. Its input is effectively a mapping from a path to a service. This is down through the Ingress Resource:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
    # ...
spec:
  ingressClassName: nginx
  tls:
    # ...
  rules:
    - host: berkeleytime.com
      http:
        paths:
          - path: /
            backend:
              service:
                name: bt-frontend-svc
          - path: /api
            backend:
              service:
                name: bt-backend-svc

This example Ingress resource maps packets routed to berkeleytime.com/ to the frontend service and maps packets routed to berkeleytime.com/api to the backend service.

The ingressClassName instructs ingress-nginx to manage this Ingress resource as one of its reverse proxy destinations.

Certificate Manager

cert-manager is a service that can automatically issue and renew certificates. We only use it to renew certificates. We hardcode a certificate with all domains needed instead of automatic issuing.

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: bt-cert
spec:
  secretName: bt-cert
  dnsNames:
    - berkeleytime.com
    - "*.berkeleytime.com"
    - "*.dev.berkeleytime.com"
    - stanfurdtime.com
    - "*.stanfurdtime.com"
    - "*.dev.stanfurdtime.com"

Here is a snippet of the hardcoded certificate deployed as of August 2025. This is linked in the Ingress resource earlier under spec.tls.

Runbooks

Manually Run datapuller (and Other CronJobs)

  1. First, list all cronjob instances:

    k get cronjob
    
  2. Then, create a job from the specific cronjob:

    k create job --from cronjob/[cronjob name] [job name]
    

    For example:

    k create job --from cronjob/bt-prod-datapuller-courses bt-prod-datapuller-courses-manual-01
    

Fetch Mongo Backups

Backups are served at https://backups.berkeleytime.com:

  • Public: GET /public/*
  • Private: GET /private/*

Public backup (no auth)

Public backups are meant for local development and include only a redacted subset of the bt database. The public backup includes these collections:

  • classes
  • courses
  • terms
  • sections
  • gradeDistributions
  • enrollmentHistories
  • enrollmenttimeframes
curl -f -o "prod-backup.gz" "https://backups.berkeleytime.com/public/daily/prod_public_backup-$(TZ=America/Los_Angeles date -v -6H +%Y%m%d).gz"

Private backup (Cloudflare Access)

First, install the Cloudflare command line tool:

brew install cloudflare/cloudflare/cloudflared
cloudflared access login https://backups.berkeleytime.com

You can then fetch the backup

cloudflared access curl \
  "https://backups.berkeleytime.com/private/hourly/prod_backup-$(TZ=America/Los_Angeles date -v -6H +%Y%m%d%H).gz" \
  -o "prod-backup.gz"

Copy Data Into Container

Reproduced from local development:

docker cp ./prod-backup.gz berkeleytime-mongodb-1:/tmp/prod-backup.gz
docker exec berkeleytime-mongodb-1 mongorestore --drop --gzip --archive=/tmp/prod-backup.gz
docker exec berkeleytime-mongodb-1 mongosh bt --eval 'const r = db.users.findOneAndUpdate({ email: "[email protected]" }, { $setOnInsert: { googleId: "dev-fake-public-backup", email: "[email protected]", name: "Dev User", staff: false, lastSeenAt: new Date() } }, { upsert: true, returnDocument: "after" }); print("Dev user id: " + r._id); print("Login URL: http://localhost:3000/api/dev/login?userId=" + r._id + "&redirect_uri=/");'

Secrets

Deploying a new environment variable with sealed-secrets

Useful when adding new environment variables to .env. To ensure our env variables can be deployed to GitHub without their true value being leaked, they should be encrypted before being pushed to GitHub.

  1. SSH into hozer-51.

  2. Create a new secret manifest with the key-value pairs and save into my_secret.yaml:

    k create secret generic my_secret -n bt --dry-run=client --output=yaml \
        --from-literal=key1=value1 \
        --from-literal=key2=value2 > my_secret.yaml
    
  3. Create a sealed secret from the previously created manifest:

    kubeseal --controller-name bt-sealed-secrets --controller-namespace bt \
        --secret-file my_secret.yaml --sealed-secret-file my_sealed_secret.yaml
    

    If the name of the secret might change across installations, add --scope=namespace-wide to the kubeseal command. For example, bt-dev-secret and bt-prod-secret are different names. Deployment without --scope=namespace-wide will cause a no key could decrypt secret error. More details on the kubeseal documentation.

  4. The newly created sealed secret encrypts the key-value pairs, allowing it to be safely pushed to GitHub. You will need to paste the generated values into infra/apps/templates/backend.yaml or similar. Just edit the relevant variables, and keep the rest of the settings the same (ie. minimize the git diff).

Steps 2 and 3 are derived from the sealed-secrets docs.

Using json-to-secret.sh to generate (Sealed) Secrets

We have a helper script at infra/json-to-secret.sh that turns a JSON object into a Kubernetes Secret manifest, and optionally a SealedSecret. This should be run from within hozer-51.

Usage

The script reads a JSON object from stdin and generates a Secret manifest (and, if requested, a SealedSecret manifest):

./infra/json-to-secret.sh SECRET_NAME [NAMESPACE=bt] [OUTPUT_FILE=SECRET_NAME.yaml] [SEALED_OUTPUT_FILE=my_sealed_secret.yaml]

Example (generate both a Secret and SealedSecret for production backend env vars in the bt namespace):

cat <<'EOF' | ./infra/json-to-secret.sh bt-prod-backend-env bt bt-prod-backend-env.yaml bt-prod-backend-env-sealed.yaml
{
  "MONGO_URI": "mongodb://...",
  "REDIS_URL": "redis://...",
  "JWT_SECRET": "super-secret"
}
EOF

This will:

  1. Create a kubectl create secret generic ... --dry-run=client --output=yaml manifest and write it to bt-prod-backend-env.yaml.
  2. If SEALED_OUTPUT_FILE is provided, run kubeseal with --scope=namespace-wide and write the SealedSecret manifest to bt-prod-backend-env-sealed.yaml.

You should then move/rename the generated SealedSecret manifest into the appropriate Helm chart (for example under infra/app/templates/) and commit it to the repo.

When you need to add, change, or remove environment variables in an existing secret:

  1. Identify the secret and namespace

    • Decide on SECRET_NAME and NAMESPACE (typically bt, or environment-specific like bt-dev).
  2. Prepare the JSON definition locally

    • Create or update a local JSON file (not committed) that represents the full set of key-value pairs you want in the secret, e.g. bt-prod-backend-env.json.
  3. Regenerate the manifests with json-to-secret.sh

    • Pipe the updated JSON into the script using the same SECRET_NAME and namespace as before:
    cat bt-prod-backend-env.json | ./infra/json-to-secret.sh bt-prod-backend-env bt bt-prod-backend-env.yaml bt-prod-backend-env-sealed.yaml
    
  4. Follow step 4 from above.

Previewing Infra Changes with /helm-diff Before Deployment

The /helm-diff command can be used in pull request comments to preview Helm changes before they are deployed. This is particularly useful when:

  1. Making changes to Helm chart values in infra/app or infra/base
  2. Upgrading Helm chart versions or dependencies
  3. Modifying Kubernetes resource configurations

To use it:

  1. Comment /helm-diff on any pull request
  2. The workflow will generate a diff showing:
    • Changes to both app and base charts
    • Resource modifications (deployments, services, etc.)
    • Configuration updates

The diff output is formatted as collapsible sections for each resource, with a raw diff available at the bottom for debugging.

Uninstall ALL development helm releases

h list --short | grep "^bt-dev-app" | xargs -L1 h uninstall

Development deployments are limited by CI/CD. However, if for some reason the limit is bypassed, this is a quick command to uninstall all helm releases starting with bt-dev-app.

Force uninstall ALL helm charts in "uninstalling" state

helm list --all-namespaces --all | grep 'uninstalling' | awk '{print $1}' | xargs -I {} helm delete --no-hooks {}

Sometimes, releases will be stuck in an uninstalling state. This command quickly force uninstalls all such stuck helm releases.

Kubernetes API Server Certificate Renewal

Kubernetes API server's certificates have a default expiration of 1 year. If they are expired and you try to use kubectl, this is what you may see:

root@hozer-51:~# k get pods
Unable to connect to the server: tls: failed to verify certificate: x509: certificate has expired or is not yet valid: current time 2026-01-16T00:12:21-08:00 is after 2026-01-16T04:29:31Z

You can check when these certificates expire with this command:

kubeadm certs check-expiration

To renew them, run the following commands on the control plane node:

sudo kubeadm certs renew all

# Restart the Kubernetes control plane pods to pick up the new certificates
sudo mv /etc/kubernetes/manifests/*.yaml /tmp/
# Wait 20-30 seconds.
sudo mv /tmp/*.yaml /etc/kubernetes/manifests/

Test that this worked by running k get pods again. If not, debug using kubeadm certs check-expiration.

Kubernetes Cluster Initialization

On (extremely) rare occasions, the cluster will fail. To recreate the cluster, follow the instructions below (note that these may be incomplete, as the necessary repair varies):

  1. Install necessary dependencies. Note that you may not need to install all dependencies. Our choice of Container Runtime Interface (CRI) is containerd with runc. You will probably not need to configure the cgroup driver (our choice is systemd), but if so, make sure to set it in both the kubelet and containerd configs.

  2. Initialize the cluster with kubeadm.

  3. Install Cilium, our choice of Container Network Interface (CNI). Note that you may not need to install the cilium CLI tool.

  4. Follow the commands in infra/init.sh one-by-one, ensuring each deployment succeeds, up until the bt-base installation.

  5. Because the sealed-secrets instance has been redeployed, every SealedSecret manifest must be recreated using kubeseal and the new sealed-secrets instance. Look at the sealed secret deployment runbook.

  6. Now, each remaining service can be deployed. Note that MongoDB and Redis must be deployed before the backend service, otherwise the backend service will crash. Feel free to use the CI/CD pipeline to deploy the application services.

API Sandbox

The API Sandbox is a local development tool for testing and exploring UC Berkeley's Student Information System (SIS) APIs. It provides an interactive UI to make API requests and inspect responses without writing any code.

Quick Start

Running Locally

# From the repository root
docker compose -f docker-compose.api-sandbox.yml up --build

The sandbox will be available at http://localhost:3009, assuming your DEV_BASE_PORT environment variable is 30.


## Features

- **Multiple API Support**: Test Classes, Courses, and Terms APIs
- **All Endpoints**: Access all available SIS API endpoints
- **Dynamic Parameters**: Input fields for all query parameters
- **Pagination**: Navigate through paginated results with Previous/Next buttons
- **JSON Response Viewer**: Formatted, syntax-highlighted response display
- **Response Metrics**: View response time and item counts

## API Credentials

The sandbox requires SIS API credentials (`app_id` and `app_key`) to make requests.

### Automatic Loading from .env

When running via Docker, credentials are automatically loaded from your `.env` file:

```bash
# .env
SIS_CLASS_APP_ID=your_class_api_id
SIS_CLASS_APP_KEY=your_class_api_key
SIS_COURSE_APP_ID=your_course_api_id
SIS_COURSE_APP_KEY=your_course_api_key
SIS_TERM_APP_ID=your_term_api_id
SIS_TERM_APP_KEY=your_term_api_key

The sandbox uses the appropriate credentials based on the selected API type:

  • Classes API uses SIS_CLASS_APP_ID / SIS_CLASS_APP_KEY
  • Courses API uses SIS_COURSE_APP_ID / SIS_COURSE_APP_KEY
  • Terms API uses SIS_TERM_APP_ID / SIS_TERM_APP_KEY

Manual Entry

You can also enter credentials manually in the UI. Any manual changes will override the .env values until you click "Reset to .env".

note

Credentials are injected at build time and only work for local development. Never deploy this app publicly as it would expose your API keys.

Available APIs

Classes API (/v1/classes)

EndpointDescription
getClassesGet class data (requires term-id or cs-course-id)
getClassSectionsGet class section data
getClassDescriptorsGet allowable code/descriptor pairs
getClassSectionDescriptorsGet class section descriptors

Courses API (/v5/courses)

EndpointDescription
getCoursesGet course catalog data
getCourseByIdGet course by ID or display name
getCoursesV4Get courses using v4 API (more parameters)

Terms API (/v2/terms)

EndpointDescription
getTermsGet terms by query parameters
getTermByIdGet term by ID

Pagination

Many endpoints support pagination with these parameters:

  • page-number: The page to retrieve (default: 1)
  • page-size: Results per page (default: 50, max: 100)

Use the Previous/Next buttons in the response header to navigate pages. The sandbox automatically tracks the current page number.

Example Queries

Get all CS classes for a term

  1. Select Classes API > getClasses
  2. Set Term ID: 2248 (Fall 2024)
  3. Set Subject Area Code: COMPSCI
  4. Click Send Request

Search for a specific course

  1. Select Courses API > getCourseById
  2. Set Course ID: COMPSCI 61A
  3. Click Send Request

Get current term information

  1. Select Terms API > getTerms
  2. Set Temporal Position: Current
  3. Click Send Request

Troubleshooting

Credentials not loading

Make sure your .env file has the correct variable names and rebuild the Docker container:

docker compose -f docker-compose.api-sandbox.yml up --build

API returns 401 Unauthorized

Your credentials may be invalid or expired. Verify them in API Central.

CORS errors

The SIS API should allow requests from localhost. If you encounter CORS issues, try running via Docker as the container handles this properly.

Packages

Berkeleytime uses a monorepo architecture managed by Turborepo. The packages/ directory contains shared code and configurations used across multiple applications.

Overview

PackageDescription
@repo/BtLLBerkeleytime Logical Language: interpreted DSL for academic requirements (GradTrak)
@repo/commonShared database models, TypeScript types, and utilities
@repo/themeReact design system with Radix UI components
@repo/sharedShared utilities, metrics, and rating configurations
@repo/gql-typedefsGraphQL type definitions shared across apps
@repo/sis-apiAuto-generated TypeScript client for UC Berkeley SIS APIs
@repo/eslint-configShared ESLint configuration
@repo/typescript-configShared TypeScript configuration presets

Package Dependencies

The following diagram shows how packages relate to each other and to the apps:

┌─────────────────────────────────────────────────────────────────┐
│                           Apps                                   │
├─────────────┬─────────────┬─────────────┬─────────────────────────┤
│  frontend   │   backend   │  datapuller │  staff-frontend, etc.  │
└──────┬──────┴──────┬──────┴──────┬──────┴──────────┬──────────────┘
       │             │             │                 │
       ▼             ▼             ▼                 ▼
┌─────────────────────────────────────────────────────────────────┐
│                         Packages                                 │
├─────────────┬─────────────┬─────────────┬─────────────────────────┤
│   @repo/    │   @repo/    │   @repo/    │       @repo/           │
│   theme     │   common    │   sis-api   │    gql-typedefs        │
├─────────────┼─────────────┼─────────────┼─────────────────────────┤
│   @repo/    │   @repo/    │             │                        │
│   shared    │   eslint-   │             │                        │
│             │   config    │             │                        │
└─────────────┴─────────────┴─────────────┴─────────────────────────┘
                    │
                    ▼
┌─────────────────────────────────────────────────────────────────┐
│               @repo/typescript-config                            │
│         (Base configuration for all packages)                    │
└─────────────────────────────────────────────────────────────────┘

Using Packages

Packages are referenced in package.json using the workspace protocol:

{
  "dependencies": {
    "@repo/common": "*",
    "@repo/theme": "*"
  },
  "devDependencies": {
    "@repo/eslint-config": "*",
    "@repo/typescript-config": "*"
  }
}

Turborepo handles building packages in the correct order based on their dependencies.

Berkeleytime Logical Language (BtLL)

What is BtLL?

Berkeleytime Logical Language (BtLL) is a domain-specific programming language designed to represent and evaluate complex academic requirements. It provides a type-safe, declarative way to express requirements such as course prerequisites, unit requirements, breadth requirements, and composite requirements (AND/OR logic) that are common in academic planning systems.

BtLL enables Berkeleytime to handle the intricate and often nested requirement structures found in university degree programs, allowing for flexible evaluation of whether students have met various academic criteria. The language is built on TypeScript, ensuring strong compatibility with TypeScript types and objects.

Overview

BtLL is a statically-typed, interpreted language that emphasizes clarity and type safety. Programs are executed by defining functions and calling them, with a special main function serving as the entry point.

BtLL is built upon Typescript, ensuring strong compatability with Typescript types and objects.

Basic Syntax

Type System

BtLL supports several built-in types:

  • Primitive Types: boolean, number, string
  • Collection Types: List<T> (e.g., List<number>, List<string>)
  • Function Types: Function<ReturnType>(ArgType1, ArgType2, ...)
  • Object Types: Plan, Column, Course, Requirement, Label

Variable Declarations

Variables are declared with their type, name, and value:

type variable_name expression

Examples:

boolean is_valid true
number count 42
string greeting "Hello, World!"
List<number> numbers [1, 2, 3, 4, 5]

Function Definitions

Functions are defined with their return type, parameter types, name, and body:

Function<ReturnType>(ParamType1, ParamType2, ...) function_name (param1, param2, ...) {
    // function body
    type return expression
}

Example:

Function<number>(number) times_three (a) {
    number return add([a, a, a])
}

Function Calls

Functions are called using the syntax:

function_name(arg1, arg2, ...)

For functions with generic type parameters:

function_name<Type>(arg1, arg2, ...)

Lists

Lists are created using square brackets:

List<number> my_list [1, 2, 3]
List<string> words ["hello", "world"]

Lists can contain variables and expressions:

List<boolean> results [x, y, or([x, y])]

Comments

Single-line comments start with //:

// This is a comment
boolean x true  // Inline comment

Return Statements

Functions return values using the return keyword:

type return expression

Example:

boolean return and([result_1, result_2])

Program Structure

Every BtLL program must have a main function that serves as the entry point:

Function<ReturnType>() main () {
    // program code
    type return expression
}

Built-in Functions

BtLL provides a rich set of built-in functions organized by category:

  • Logic Functions: and, or, not, equal, etc.
  • List Functions: filter, find, reduce, map, contains, get_element, length, etc.
  • Number Functions: add, subtract, multiply, divide, etc.
  • String Functions: Various string manipulation operations
  • Object Functions: Functions for working with Plan, Column, Course, and Requirement objects

Requirement Types

BtLL includes a specialized type system for representing academic requirements. All requirement types extend a base Requirement type and automatically compute their result field based on their specific logic.

Base Requirement Type

The base Requirement type contains three fields that all requirement types inherit:

FieldTypeDescription
resultbooleanWhether the requirement is satisfied
descriptionstringHuman-readable description of the requirement
typestringThe specific requirement type name
Requirement req (true, "Example requirement")

Extended Requirement Types

BtLL provides six specialized requirement types that extend the base Requirement:

BooleanRequirement

A simple requirement based on a boolean value. The result is set directly from the value field.

Additional FieldTypeDescription
valuebooleanThe boolean value determining if the requirement is met
BooleanRequirement req BooleanRequirement(is_satisfied, "Must complete orientation")

NCoursesRequirement

A requirement that is satisfied when a minimum number of courses are completed. The result is true when length(courses) >= required_count.

Additional FieldTypeDescription
coursesList<Course>List of courses that count toward this requirement
required_countnumberMinimum number of courses required
NCoursesRequirement req NCoursesRequirement(matching_courses, 3, "Complete 3 breadth courses")

CourseListRequirement

A requirement for completing a specific list of courses. The result is true when all entries in met_status are true.

Additional FieldTypeDescription
required_coursesList<Course>The specific courses required
met_statusList<boolean>Completion status for each required course
CourseListRequirement req CourseListRequirement(required, status_list, "Complete core courses")

AndRequirement

A composite requirement that is satisfied when ALL sub-requirements are met. The result is true when every requirement in the list has result == true.

Additional FieldTypeDescription
requirementsList<Requirement>List of requirements that must all be satisfied
AndRequirement req AndRequirement([req1, req2, req3], "Complete all of the following")

OrRequirement

A composite requirement that is satisfied when ANY sub-requirement is met. The result is true when at least one requirement in the list has result == true.

Additional FieldTypeDescription
requirementsList<Requirement>List of requirements where at least one must be satisfied
OrRequirement req OrRequirement([option_a, option_b], "Complete one of the following")

NumberRequirement

A requirement comparing numeric values. The result is true when actual >= required.

Additional FieldTypeDescription
actualnumberThe current value (e.g., units completed)
requirednumberThe minimum required value
NumberRequirement req NumberRequirement(total_units, 120, "Complete 120 units")

Accessing Requirement Fields

All requirement fields can be accessed using the get_attr function:

boolean is_met get_attr(my_requirement, "result")
string desc get_attr(my_requirement, "description")
number needed get_attr(unit_req, "required")

Example Program

Here's a complete example demonstrating BtLL syntax:

Function<boolean>(number) is_even (n) {
    number remainder mod(n, 2)
    boolean return equal([remainder, 0])
}

Function<number>(number, number) add_numbers (acc, n) {
    number return add([acc, n])
}

Function<boolean>() main () {
    List<number> numbers [1, 2, 3, 4, 5, 6, 7, 8]
    List<number> evens filter(numbers, is_even)
    number sum reduce(evens, add_numbers, 0)
    boolean return equal([sum, 20])
}

Next Steps

The following features are planned for future releases:

  • Verbosity Fixes: The language is unnecessarily verbose in a handful of places
  • Performance Improvements: Decrease checks to optimize performance

Getting Started

To use BtLL in your project, import the interpreter:

import { init } from "@repo/BtLL";

const code = `
Function<number>() main () {
    number result add([1, 2, 3])
    number return result
}
`;

const result = init(code);
console.log(result); // 6

A value of any type can be returned by the main function.

@repo/common

The @repo/common package contains shared database models, TypeScript types, and utility functions used across the backend and datapuller applications.

Local Development

This package is automatically built as part of the Turborepo build pipeline. No separate build step is required for local development.

Structure

packages/common/
├── src/
│   ├── models/           # Mongoose models and TypeScript interfaces
│   │   ├── class.ts
│   │   ├── course.ts
│   │   ├── section.ts
│   │   ├── term.ts
│   │   ├── user.ts
│   │   ├── rating.ts
│   │   ├── grade-distribution.ts
│   │   ├── schedule.ts
│   │   ├── plan.ts
│   │   └── ...
│   ├── utils/            # Shared utility functions
│   │   └── grade-distribution.ts
│   ├── lib/              # Common library functions
│   │   ├── common.ts
│   │   └── sis.ts
│   └── index.ts          # Package entrypoint
└── package.json

Usage

Import models and types from the package:

import { CourseModel, ICourseItem, ClassModel, IClassItem } from "@repo/common";

// Query courses
const courses = await CourseModel.find({ subject: "COMPSCI" });

// Use TypeScript interfaces
const course: ICourseItem = {
  // ...
};

Database Models

Each model file typically contains:

  1. TypeScript interfaces - Define the shape of documents
  2. Mongoose schema - Define validation and structure
  3. Database indexes - Optimize query performance
  4. Mongoose model - Export the model for querying

Example model structure:

// Interface for the document
export interface ITermItem {
  termId: string;
  name: string;
  // ...
}

// Mongoose schema
const termSchema = new Schema<ITermItem>({
  termId: { type: String, required: true },
  name: { type: String, required: true },
  // ...
});

// Database indexes
termSchema.index({ termId: 1 }, { unique: true });

// Export model
export const TermModel: Model<ITermItem> = model<ITermItem>("Term", termSchema);

Available Models

ModelDescription
ClassModelIndividual class offerings per semester
CourseModelCourse information (subject, number, title, description)
SectionModelClass sections with meetings and instructors
TermModelAcademic terms and sessions
UserModelUser accounts and authentication
RatingModelCourse ratings submitted by students
GradeDistributionModelHistorical grade distributions
ScheduleModelUser-created schedules
PlanModelMulti-year academic plans
EnrollmentHistoryModelEnrollment data over time
CollectionModelUser-curated course collections

@repo/theme

The @repo/theme package is Berkeleytime's React design system built on Radix UI primitives. It provides reusable, accessible components with consistent styling across all frontend applications.

Components can be viewed on Storybook

Local Development

The theme package is used by frontend applications. Changes are reflected immediately when running in development mode.

To view components in isolation, use Storybook:

# Start Storybook (available at localhost:3005)
docker compose up storybook

Structure

packages/theme/
├── src/
│   ├── components/           # React components
│   │   ├── ThemeProvider/    # Root theme provider
│   │   ├── Button/
│   │   ├── Dialog/
│   │   ├── Tooltip/
│   │   ├── Select/
│   │   └── ...
│   ├── contexts/             # React contexts
│   ├── hooks/                # Custom React hooks
│   └── index.ts              # Package entrypoint
└── package.json

Usage

Wrap your application with ThemeProvider and import components:

import { ThemeProvider, Button, Dialog, Tooltip } from "@repo/theme";

function App() {
  return (
    <ThemeProvider>
      <Button variant="primary">Click me</Button>
    </ThemeProvider>
  );
}

Theme Support

The design system supports light and dark themes. Color tokens automatically respond to the selected theme:

// Using theme-aware colors in SCSS
.my-component {
  color: var(--foreground-color);
  background: var(--background-color);
  border-color: var(--border-color);
}

Theme selection is persisted and respects system preferences when no preference is set.

Core Dependencies

DependencyPurpose
radix-uiUnstyled, accessible UI primitives
@radix-ui/themesPre-built Radix theme components
iconoir-reactIcon library
cmdkCommand palette component
classnamesConditional CSS class utility

Available Components

The package exports components for common UI patterns:

  • Layout: ThemeProvider, Container, Card
  • Forms: Button, Input, Select, Checkbox, RadioGroup
  • Feedback: Dialog, Toast, Tooltip, Alert
  • Navigation: Tabs, DropdownMenu, NavigationMenu
  • Data Display: Table, Badge, Avatar

See the full list in packages/theme/src/components/.

@repo/shared

The @repo/shared package contains shared utilities, constants, and configurations used across both frontend and backend applications.

Local Development

This package is automatically available to all apps in the monorepo. No separate build step is required.

Structure

packages/shared/
├── index.ts              # Package entrypoint
├── metrics.ts            # Rating metric definitions
├── ratingsConfig.ts      # Rating system configuration
├── queries.ts            # Shared GraphQL queries
└── staff.ts              # Staff-related utilities

Usage

import { MetricName, METRIC_MAPPINGS, METRIC_ORDER } from "@repo/shared";

// Access metric configuration
const usefulnessConfig = METRIC_MAPPINGS[MetricName.Usefulness];
console.log(usefulnessConfig.tooltip);
// "This refers to how beneficial the course is..."

// Get status label from average rating
const status = usefulnessConfig.getStatus(4.5);
// "Very Useful"

Rating Metrics

The package defines the rating metrics used throughout Berkeleytime:

MetricDescriptionRating Scale
UsefulnessCourse benefit for growthVery Useful → Not Useful
DifficultyChallenge levelVery Hard → Very Easy
WorkloadTime/effort requiredVery Heavy → Very Light
AttendanceAttendance requirementRequired / Not Required
RecordingLecture recording availabilityRecorded / Not Recorded
RecommendedOverall recommendationRecommended / Not Recommended

Metric Configuration

Each metric includes:

  • tooltip - Description shown to users
  • getStatus(avg) - Converts numeric average to display label
  • isRating - Whether it uses a 1-5 rating scale
  • isInverseRelationship - Whether lower values are "better"
export const METRIC_MAPPINGS = {
  [MetricName.Usefulness]: {
    tooltip: "This refers to how beneficial the course is...",
    getStatus: (avg: number) =>
      avg >= 4.3 ? "Very Useful" : avg >= 3.5 ? "Useful" : /* ... */,
    isRating: true,
    isInverseRelationship: false,
  },
  // ...
};

Shared Queries

The queries.ts file contains GraphQL queries that are used by both the frontend codegen and potentially other applications, ensuring query consistency across the codebase.

@repo/gql-typedefs

The @repo/gql-typedefs package contains the GraphQL type definitions (schemas) shared across the backend and frontend applications. These definitions serve as the single source of truth for the GraphQL API schema.

Local Development

When modifying GraphQL schemas, you need to regenerate types in both the backend and frontend. This can be done with a single command:

npx turbo run generate

Structure

packages/gql-typedefs/
├── index.ts              # Package entrypoint (re-exports all typedefs)
├── common.ts             # Common types (scalars, enums)
├── course.ts             # Course-related types
├── class.ts              # Class-related types
├── section.ts            # Section types
├── term.ts               # Term types
├── user.ts               # User types
├── rating.ts             # Rating types
├── enrollment.ts         # Enrollment types
├── grade-distribution.ts # Grade distribution types
├── schedule.ts           # Schedule types
├── plan.ts               # Academic plan types
├── collection.ts         # Collection types
├── catalog.ts            # Course catalog types
├── analytics.ts          # Analytics types
└── ...

Usage

The type definitions are written using the gql template literal tag:

// packages/gql-typedefs/course.ts
import { gql } from "graphql-tag";

export const courseTypeDefs = gql`
  type Course {
    courseId: CourseIdentifier!
    subject: String!
    number: CourseNumber!
    title: String!
    description: String
    units: Float
    gradeDistribution: GradeDistribution
  }

  type Query {
    course(subject: String!, number: CourseNumber!): Course
    courses: [Course!]!
  }
`;

How It Works

  1. Schema Definition: Type definitions are written in GraphQL SDL format
  2. Frontend Codegen: The frontend's codegen.ts reads these schemas and generates TypeScript types for queries
  3. Backend Codegen: The backend's codegen.ts generates resolver types from these schemas
┌─────────────────────┐
│  @repo/gql-typedefs │
│   (GraphQL SDL)     │
└──────────┬──────────┘
           │
     ┌─────┴─────┐
     ▼           ▼
┌─────────┐ ┌─────────┐
│ Backend │ │Frontend │
│ codegen │ │ codegen │
└────┬────┘ └────┬────┘
     ▼           ▼
┌─────────┐ ┌─────────┐
│Resolver │ │ Query   │
│ Types   │ │ Types   │
└─────────┘ └─────────┘

Adding New Types

  1. Create or modify a type definition file in packages/gql-typedefs/
  2. Export it from index.ts if it's a new file
  3. Run codegen in both backend and frontend
  4. Implement the resolver in the backend
  5. Use the generated types in the frontend

tip

Keep related types together in the same file. For example, all enrollment-related types should be in enrollment.ts.

@repo/sis-api

The @repo/sis-api package contains auto-generated TypeScript clients for UC Berkeley's Student Information System (SIS) APIs. These clients are used by the datapuller to fetch course, class, and term data.

Local Development

The API clients are pre-generated from OpenAPI specifications. To regenerate after spec updates:

cd packages/sis-api && npm run build

warning

The SIS API specs are rate-limited. Specs are stored locally in the specs/ directory rather than fetched at build time.

Structure

packages/sis-api/
├── src/
│   └── index.ts          # Generation script
├── specs/                # OpenAPI specification files
│   ├── courses.json
│   ├── classes.json
│   └── terms.json
├── dist/                 # Generated TypeScript clients
│   ├── courses.ts
│   ├── classes.ts
│   └── terms.ts
└── package.json

Usage

Import and use the generated API clients:

import { CoursesAPI } from "@repo/sis-api/courses";
import { ClassesAPI } from "@repo/sis-api/classes";
import { TermsAPI } from "@repo/sis-api/terms";

// Initialize client with API credentials
const coursesApi = new CoursesAPI({
  headers: {
    "app_id": process.env.SIS_APP_ID,
    "app_key": process.env.SIS_APP_KEY,
  },
});

// Fetch courses
const response = await coursesApi.getCourses({
  "subject-area-code": "COMPSCI",
});

Generation Process

The package uses swagger-typescript-api to generate TypeScript clients from OpenAPI specifications:

import { generateApi } from "swagger-typescript-api";

generateApi({
  fileName: `${name}.ts`,
  output: path.resolve(process.cwd(), "./dist"),
  input: path.resolve(process.cwd(), "./specs", spec),
  singleHttpClient: false,
  apiClassName: `${name[0].toUpperCase()}${name.slice(1)}API`,
});

Available APIs

APIDescriptionPrimary Use
CoursesAPICourse catalog dataCourse information, descriptions, prerequisites
ClassesAPIClass offeringsSections, instructors, meeting times
TermsAPIAcademic termsTerm dates, sessions

API Credentials

Access to SIS APIs requires credentials from UC Berkeley's API Central:

  • SIS_APP_ID - Application identifier
  • SIS_APP_KEY - Secret key

These are configured in the environment and should never be committed to version control.

note

For more information about data sources and API access, see the Data documentation.

@repo/eslint-config

The @repo/eslint-config package provides shared ESLint configuration for all applications and packages in the monorepo.

Local Development

This package is used as a dev dependency. No build step is required.

Structure

packages/eslint-config/
├── index.mjs             # Main configuration file
└── package.json

Usage

Reference the config in your app's eslint.config.mjs:

import baseConfig from "@repo/eslint-config";

export default [
  ...baseConfig,
  // Add app-specific overrides here
];

Included Rules

The configuration includes:

PluginPurpose
@eslint/jsCore JavaScript rules
typescript-eslintTypeScript-specific rules
eslint-config-prettierDisables formatting rules (handled by Prettier)
eslint-plugin-react-hooksReact Hooks rules
eslint-plugin-react-refreshReact Fast Refresh compatibility
eslint-plugin-css-modulesCSS Modules best practices

IDE Integration

For the best development experience, install the ESLint extension:

This enables real-time linting and auto-fix on save.

Running Lint

# Lint a specific app
cd apps/frontend && npm run lint

# Lint the entire monorepo
npm run lint

@repo/typescript-config

The @repo/typescript-config package provides shared TypeScript configuration presets for all applications and packages in the monorepo.

Local Development

This package is used as a dev dependency. No build step is required.

Structure

packages/typescript-config/
├── base.json             # Base configuration (shared settings)
├── node.json             # Node.js applications (backend, datapuller)
├── react.json            # React applications (deprecated, use vite.json)
├── vite.json             # Vite + React applications (frontend)
└── package.json

Usage

Extend the appropriate config in your app's tsconfig.json:

{
  "extends": "@repo/typescript-config/vite.json",
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["src"]
}

Available Configs

ConfigUse Case
base.jsonShared settings inherited by all configs
node.jsonBackend services and Node.js packages
vite.jsonVite-based React applications
react.jsonLegacy React config (prefer vite.json)

Base Configuration

The base config includes settings for:

  • Strict Type Checking: Enables strict mode and additional checks
  • Module Resolution: Uses bundler-style resolution
  • Interop: Enables ES module interoperability
  • CSS Modules: Includes plugin for CSS module type support

Included Plugins

PluginPurpose
typescript-plugin-css-modulesProvides type definitions for CSS module imports

This plugin enables autocomplete and type checking for SCSS/CSS module classes:

import styles from "./Component.module.scss";

// TypeScript knows about available class names
<div className={styles.container} />

IDE Integration

For full TypeScript support, ensure your IDE uses the workspace TypeScript version:

  • VSCode: The workspace is configured to use the local TypeScript version automatically

LLM Use & Vibecoding

This repo is set up to work well with AI-assisted development. Cursor, Claude Code, and other AI agents can be extremely helpful for navigating the codebase, implementing features, and refactoring—if you use them deliberately. In fact, in my experience, AI agents can help at least lay the groundwork for almost anything.

Repo Features for AI-Assisted Development

Cursorrules (.cursorrules)

The repo includes a .cursorrules file at the repo root. It describes:

  • Architecture: Monorepo layout (apps, packages, infra), backend module structure, frontend folder layout.
  • Tech stack: Backend (Node, Express, Apollo, MongoDB, Redis), frontend (React, Vite, Apollo, SCSS modules), and shared packages.
  • Conventions: TypeScript/GraphQL usage, SCSS modules, file naming, where to put new code.
  • Workflow: Type generation, lint/format, Docker, Turbo commands.

When you (or an agent) work in this repo, Cursor and other tools that read .cursorrules get this context automatically. That reduces wrong-path suggestions (e.g. putting resolvers in the wrong place or editing generated types). Keep .cursorrules updated when the project's patterns change.

Git Worktree Support & Parameterized Ports

You can run multiple copies of the repo side-by-side (e.g. different branches or experiments) using git worktrees. To avoid port clashes, the stack uses a parameterized port prefix.

  • DEV_PORT_PREFIX (default 30) controls the first two digits of dev ports. Services end up on 30XX (e.g. frontend/API on 3000, MongoDB on 3008). See Local Development - Ports.
  • For a second worktree, set a different prefix before starting Docker, for example:
    DEV_PORT_PREFIX=80 docker compose up -d
    
    That worktree will use ports 80XX instead of 30XX.

So you can:

  • Work in the main repo at 30XX.
  • In another worktree (e.g. a feature branch), run with DEV_PORT_PREFIX=80 and use 80XX for the same services.

Note: Only 30 and 80 are fully supported today; other prefixes may require updating OAuth redirect URIs and similar config. See Local Development.

Using worktrees + parameterized ports lets you and agents iterate in a separate branch without stopping your primary dev environment, and keeps docs/compose behavior consistent across copies.


Vibecoding Maturely

"Vibecoding" here means leaning on AI to write or edit code while you stay in control. Doing it maturely means you always know what code you're shipping and you treat the agent as a fast, context-aware pair programmer—not a black box. These days, it's easy to ship fast. What's difficult is shipping good quality code and avoiding code debt. Here are some of the things that have worked for me.

Prompt Agents in Ways You Expect

  • Specify files and scope. Say things like "add a resolver in apps/backend/src/modules/rating/resolver.ts" or "create a new component in apps/frontend/src/components/" instead of "add a rating feature."
  • Describe broad architecture. e.g. "Create a model that contains X, Y, and Z as fields. Then, create one endpoint to create entries, and another to join Y with model A and return the results."
  • Reference the repo. Point to existing examples: "Same pattern as the enrollment module" or "Use the same SCSS module setup as RatingButton."
  • Constrain edits. "Only change the backend; don't touch the frontend" or "Don't modify anything in generated-types/."

With specific prompts, we risk sacrificing a potentially more optimal solution an LLM could have come up with. However, it ensures that you fully understand how everything works, which makes future development significantly easier. Also, a lot of the time LLMs will not know your priorities or the specific use-cases you need to optimize for. You should inform it.

Review Your The Outputted Code

  • Read the diff. Before committing or pushing, review every change. If you don't understand a line, ask the agent to explain or simplify.
  • Understand the architecture. Look through the files or use the docs so you know where things live (modules, lib/api/, packages). If the agent suggests a file or pattern that doesn't match, correct it.
  • Own the behavior. You are responsible for what the code does. The agent suggests; you decide.

Always Test

  • Run the app. After agent-generated changes, use the feature yourself (run the frontend, hit the backend, run the datapullers locally etc.)
  • Use the existing pipeline. Run type-check, lint, and build (npm run type-check, npm run lint, npm run build / Turbo) and fix any failures. This is also done automatically pre-push.

Never Settle

  • Reject wrong patterns. If the agent suggests editing generated code, adding a one-off hack, or breaking the module/GraphQL conventions, ask for a correction or rewrite the change yourself. Please be very careful with this.
  • Iterate on quality. If the first suggestion is messy or unclear, ask for a refactor, better names, or a smaller change set.
  • Keep the bar high. The goal is maintainable, readable code that fits the repo. Don't merge "good enough" if it would confuse the next developer or the next agent.

In short: use AI to move faster and stay consistent with the repo's structure and conventions. Pair that with clear prompts, a solid mental model of the codebase, and a habit of testing and reviewing—so you're always shipping code you understand and stand behind.

A good rule of thumb is to never ask an LLM to edit code you don't understand or produce code you don't understand.

DeCal Classes

Berkeleytime has first‑class support for DeCal classes. This feature enriches the standard SIS data with details from the DeCal board so students can see accurate DeCal titles, descriptions, application links, deadlines, and contact information directly in the class catalog and class view.

High‑level flow

  1. The decals datapuller scrapes the DeCal board API and normalizes each listing into a structured DeCalCourse.
  2. For each DeCal course, we try to match it to an existing ClassModel in Mongo using a set of heuristics (title similarity, meeting times, room, and faculty sponsor).
  3. When we find a confident match, we write a decal sub‑object onto that ClassModel.
  4. Backend GraphQL resolvers expose this data via the Class.decal field.
  5. The frontend reads Class.decal and:
    • shows a “DeCal” badge in the catalog,
    • uses the DeCal title on catalog cards and the class overview header,
    • and surfaces application, syllabus, deadline, and contact information in the overview tab.

Scraping and normalization

The DeCal puller lives in apps/datapuller/src/pullers/decals.ts. It talks directly to the DeCal board’s JSON APIs (for example, the approved‑courses endpoint) instead of scraping HTML, then normalizes responses into an internal DeCalCourse shape:

  • core metadata: title, semester, department, category, units
  • sections: day/time, room, capacity, and section type
  • descriptive content: description, enrollmentInformation, websiteUrl, syllabusUrl
  • application metadata: applicationUrl, applicationDueDate
  • facilitators: { name, email }

For development and debugging, the puller can persist the raw DeCal payloads into apps/datapuller/data/decals.json. When the DEBUG flag in the puller is enabled, subsequent runs will re‑use this local file instead of repeatedly calling the remote APIs. This makes it much easier to refine matching heuristics offline.

Matching DeCals to SIS classes

The DeCal board does not expose Berkeleytime class identifiers, so we have to infer the correct ClassModel for each DeCal. The puller uses a multi‑step scoring heuristic:

  • Term bucketing
    DeCals are grouped by semester key (for example, "Spring 2026"). For each group we only consider classes from the matching Berkeleytime term, which keeps the candidate set small and avoids cross‑term matches.

  • Department narrowing
    If the DeCal has a department, we first search only within classes from that department. If no reasonable candidate appears, we fall back to searching across all departments for that term.

  • Title similarity (fuzzy search)
    We build a FuzzySearch index of candidate classes using course and class titles. For each DeCal title we:

    • compute a fuzzy similarity score against each candidate,
    • discard clearly unrelated classes below a minimum score,
    • and keep only the top‑scoring subset for more expensive checks.
  • Meeting‑time and room heuristics
    From the DeCal sections we derive normalized “day + time” strings and room identifiers, handling different display formats. For each candidate class:

    • we normalize its primary‑section meetings into the same representation,
    • award positive score if any meeting time appears in the DeCal’s time set,
    • and add extra score if the room or location text matches (for example, same building and room number).
  • Faculty / sponsor similarity
    When a DeCal lists a faculty sponsor, we normalize the sponsor name and compare it to instructor names on each candidate class. If the sponsor appears to teach the candidate, we increase that candidate’s score.

  • Composite score and decision
    Each candidate gets a composite score that combines:

    • title similarity,
    • meeting‑time overlap,
    • room overlap,
    • and faculty‑name similarity.

    The puller selects the single best candidate whose score exceeds a threshold. If there is a tie or no strong candidate, we log the DeCal and skip it rather than guessing.

Persisting DeCal metadata

Once a DeCal is matched to a ClassModel, we populate the decal field on that document:

  • title: DeCal‑specific course title
  • description: extended DeCal description
  • syllabusUrl: URL to the DeCal syllabus
  • applicationUrl: URL for the application form or course site
  • applicationDueDate: application deadline as a string
  • instructors: array of { name, email } derived from facilitators

This data flows through the stack as follows:

  • Backend formatters expose Class.decal via the GraphQL schema (packages/gql-typedefs/class.ts).
  • The canonical catalog query (packages/shared/queries.ts) includes decal { title } so catalog cards can show a “DeCal” badge and DeCal title.
  • The class details queries (apps/frontend/src/lib/api/classes.ts) fetch decal so the class overview can:
    • show a dedicated “DeCal Application” section (application link, syllabus link, and formatted deadline),
    • prefer the DeCal description over SIS description when present,
    • hide SIS “Class Notes” for DeCal classes,
    • and render contact information as Name: email pairs.

Overall, the DeCal feature lets Berkeleytime present rich, up‑to‑date information for student‑run courses without requiring any manual data entry in our database.