How to connect and sync Bryntum Gantt to OpenProject
 
            Bryntum Gantt is a fully customizable, performance-optimized JavaScript Gantt chart.
In this tutorial, we show you how to connect and sync Bryntum Gantt to an OpenProject project. OpenProject is a web-based open source project management software suite with a free community plan and paid enterprise plans. It can be self-hosted and includes a Gantt chart view.
In this tutorial, we demonstrate how to connect and sync Bryntum Gantt to an OpenProject project. We’ll do the following:
- Use the OpenProject REST API to get tasks and task dependencies from an OpenProject project.
- Display the tasks and dependencies in a Bryntum Gantt chart.
- Sync Bryntum Gantt changes with the OpenProject project.
Prerequisites
To follow along, you need Node.js installed on your system.
Getting started
We’ll begin by downloading the Bryntum Gantt sync with OpenProject starter template.
The starter template is a TypeScript monorepo. The frontend uses Vite, which is a development server and bundler. The backend uses Express and has Zod schema and TypeScript types for the Bryntum Gantt and OpenProject data. For reference, the completed-app branch contains the code for the completed tutorial.
Next, install the backend and frontend dependencies:
npm run install-allRun the local dev servers for the frontend and backend using the following command:
npm run devThis runs the frontend and backend apps simultaneously, using the npm package concurrently.
You can access the frontend app at http://localhost:5173 and the backend app at http://localhost:1337.
If you open the frontend app in your browser, you’ll see a blank white page.
Creating an OpenProject project
Now let’s create an OpenProject project. If you don’t have an OpenProject account, sign up for a 14-day free trial of the Basic plan, which includes cloud hosting and enterprise features. You can also use the free Community plan, which requires self-hosting.
Once you’ve signed up and logged in, take the introduction tour to get familiar with the OpenProject UI.

OpenProject Gantt chart view
Open the OpenProject homepage and navigate to the Projects page.

Click on the Demo project in the Active projects table:

In the demo project, open the Gantt charts view:

You’ll see the demo data displayed in a Gantt chart:

Each task in the Gantt chart is a work package. A work package is an item in a project, such as a task, feature, user story, bug, or change request. Each work package has an ID, type, and subject. Work packages can also have additional fields, such as status, due date, and description. Some of the Gantt Chart tasks have relations to other tasks, which are called dependencies in the Bryntum Gantt.
Right-click on a work package task to open the quick context menu. There are several actions you can carry out on a work package task, including Open details view, which lets you edit the work package task and its relations:

The Gantt chart has automatic and manual scheduling modes for individual work packages. The manual scheduling mode is the default.
Work packages with an auto-date symbol next to the date indicate they’re in automatic scheduling mode:

You can double-click on a date to change the scheduling mode.
Using the OpenProject API
We’ll use the OpenProject API v3 to get data from OpenProject into the Bryntum Gantt. The API uses the OpenAPI 3.1 Specification format. You can view the full spec online. The API is a hypermedia REST API, meaning each API endpoint has links to other resources or actions defined in the resulting body. The API responds using JSON and JSON Hypertext Application Language (HAL) for hypermedia. To learn more about the API, take a look at the OpenProject API v3 API documentation or view its full spec online.
The API supports OAuth2 session-based authentication and basic auth. We’ll authenticate using an API key with basic auth.
To create an API key, open the user menu at the top right of the screen:

Click the Account settings option in the popup menu:

Open the Access tokens section and then click the + API token button to create a new API token:

In your TypeScript monorepo backend folder, create a .env file and add the API token to an env variable called OPENPROJECT_ACCESS_TOKEN:
OPENPROJECT_ACCESS_TOKEN=your-api-tokenAlso add your OpenProject base URL, which you can find in the search bar at the top of the screen, to the .env file:
OPENPROJECT_BASE_URL=https://your-project-instance.openproject.comCreating a Bryntum Gantt
Now let’s create a Bryntum Gantt component in the frontend.
Installing Bryntum Gantt
Start by following the guide to accessing the Bryntum npm repository.
Once you’ve logged in to the registry, install the Bryntum Gantt packages.
- If you’re using the trial version, use the following command:
npm install @bryntum/gantt@npm:@bryntum/gantt-trial --workspace=frontend- If you’re using the licensed version, use the following command:
npm install @bryntum/gantt --workspace=frontendCreating a config file
Now, let’s create our basic Bryntum Gantt component.
Create a ganttConfig.ts file in the frontend/src folder and add the following lines of code to it:
import { type GanttConfig  } from '@bryntum/gantt';
export const ganttConfig: GanttConfig = {
    appendTo   : 'app',
    viewPreset : 'weekAndDayLetter',
    barMargin  : 10,
    columns    : [
        {
            field    : 'type',
            text     : 'Type',
            width    : 120,
            readOnly : true
        },
        { type : 'name', field : 'name', text : 'Subject', width : 300
        },
        { type : 'startdate', field : 'startDate', text : 'Start Date', width : 105 },
        { type : 'enddate', field : 'endDate', text : 'Finish Date', width : 105 },
        { type : 'duration', field : 'fullDuration', text : 'Duration', width : 80 }
    ]
};With this code, we add the Bryntum Gantt to the div with the id app in the frontend/index.html file. The columns config defines the columns for the Gantt.
Adding the project config for data fetching and syncing
Add the following project config to the ganttConfig object:
project    : {
    taskStore : {
        transformFlatData : true
    },
    loadUrl          : 'http://localhost:1337/api/openproject/load',
    autoLoad         : true,
    syncUrl          : 'http://localhost:1337/api/openproject/sync',
    autoSync         : true,
    // This config enables response validation and dumping of found errors to the browser console.
    // It's meant to be used as a development stage helper only so please set it to false for production.
    validateResponse : true
},The Bryntum Gantt project holds and links the data stores. Here, we configure it to load data on page load from the /api/openproject/load API endpoint, which we’ll create. The Bryntum Gantt project Crud Manager loads data from loadUrl and saves changes to syncUrl using the Fetch API for requests and JSON as the encoding format.
The data we’ll receive from the API endpoints has a flat data structure, so the task store is configured to transform flat data into a tree data structure.
Creating a custom task model
The Bryntum Gantt and OpenProject project data share some overlap in their task and dependency fields. For example, they both have start date and end date fields. Let’s add custom fields to the Bryntum Gantt project to accommodate OpenProject-specific data.
In the frontend/src folder, create a folder called lib. Within lib, create a file called CustomTaskModel.ts and add the following lines of code to it:
import { TaskModel } from '@bryntum/gantt';
// Custom event model
export default class CustomTaskModel extends TaskModel {
    static $name = 'CustomTaskModel';
    static fields = [
        { name : 'status', type : 'string' },
        { name : 'type', type : 'string' }
    ];
}Import the custom task model in the frontend/src/ganttConfig.ts file:
import CustomTaskModel from './lib/CustomTaskModel';Add the custom task model to the taskStore project config:
  project : {
      taskStore : {
+         modelClass        : CustomTaskModel,
          transformFlatData : true
     },Styling the component
Add the following stylesheet import to the frontend/src/style.css file:
@import "@bryntum/gantt/gantt.stockholm.css";We use the Bryntum Stockholm theme, which is one of five available themes to style the Gantt. You can also create custom themes or combine multiple themes.
Add the following style to the frontend/src/style.css stylesheet to make the Bryntum Gantt take up the whole screen height:
#app {
    display: flex;
    flex-direction: column;
    height: 100vh;
    font-size: 14px;
}Rendering the Bryntum Gantt
Add the following code to the frontend/src/main.ts file to render the Bryntum Gantt:
import { Gantt } from '@bryntum/gantt';
import { ganttConfig } from './ganttConfig';
const gantt = new Gantt(ganttConfig);Now run the local dev servers using the following command:
npm run devOpen http://localhost:5173. You should see a Bryntum Gantt with a data loading error message:

The loadUrl is set to http://localhost:1337/api/openproject/load, which we’ll create next.
Creating an API endpoint to get data from OpenProject
Next, we’ll create a backend API endpoint that fetches data from the OpenProject API and sends it to the Bryntum Gantt in the expected format, specifically the load response structure that the Bryntum Gantt Crud Manager expects.
In the backend/src/index.ts file, add the OpenProject environment variables:
const OPENPROJECT_BASE_URL = process.env.OPENPROJECT_BASE_URL;
const OPENPROJECT_ACCESS_TOKEN = process.env.OPENPROJECT_ACCESS_TOKEN;The base URL points to your OpenProject instance. You’ll use the access token for the OpenProject API.
Next, add the /api/openproject/load API endpoint to the backend/src/index.ts file, below the app.use(express.json()); line. This endpoint fetches OpenProject work packages and their relations:
app.get('/api/openproject/load', async(req, res) => {
    try {
        const workPackagesData = await makeOpenProjectRequest('/api/v3/projects/1/work_packages?sortBy=' + encodeURIComponent('[["id","asc"]]'));
        const tasks = workPackagesData._embedded?.elements || [];
        const bryntumTasks = tasks.map(mapOpenProjectToBryntum);
        // Get all work package IDs to filter relations
        const workPackageIds = tasks.map((task: { id: number }) => task.id);
        // Fetch relations - filter by involved work packages from this project
        const relationsData = await makeOpenProjectRequest('/api/v3/relations');
        const relations = relationsData._embedded?.elements || [];
        // Filter relations to only include those between work packages in this project
        const projectRelations = relations.filter((relation: Relation) => {
            const fromMatch = relation._links.from.href.match(/\/work_packages\/(\d+)$/);
            const toMatch = relation._links.to.href.match(/\/work_packages\/(\d+)$/);
            if (!fromMatch || !toMatch) return false;
            const fromId = parseInt(fromMatch[1], 10);
            const toId = parseInt(toMatch[1], 10);
            return workPackageIds.includes(fromId) && workPackageIds.includes(toId);
        });
        // Map relations to Bryntum dependencies
        const bryntumDependencies = projectRelations
            .map(mapOpenProjectRelationToBryntum)
            .filter((dep: DependencySchemaType) => dep !== null);
        res.json({
            success   : true,
            requestId : req.headers['x-request-id'] || Date.now(),
            revision  : 1,
            tasks     : {
                rows  : bryntumTasks,
                total : bryntumTasks.length
            },
            dependencies : {
                rows  : bryntumDependencies,
                total : bryntumDependencies.length
            }
        });
    }
    catch (error: unknown) {
        console.error('Error loading OpenProject data:', error);
        res.status(500).json({
            success : false,
            message : error instanceof Error ? error.message : 'Unknown error'
        });
    }
});In this code block, we fetch work packages from the project with the ID 1 (the demo project) using the OpenProject API. We sort the work packages by ID (in ascending order) to match the OpenProject Gantt. We then map the data from OpenProject format to Bryntum Gantt format using the mapOpenProjectToBryntum function, which we’ll soon define. We fetch relations (dependencies) between work packages and filter them to include only the relations within the current project. The response is returned in the format expected by the Bryntum Gantt Crud Manager.
Now, add the following helper function at the bottom of the file:
const makeOpenProjectRequest = async(endpoint: string, options: RequestInit = {}) => {
    const url = `${OPENPROJECT_BASE_URL}${endpoint}`;
    const auth = createOpenProjectAuth();
    const response = await fetch(url, {
        ...options,
        headers : {
            'Authorization' : auth,
            'Content-Type'  : 'application/json',
            ...options.headers
        }
    });
    if (!response.ok) {
        const errorText = await response.text();
        throw new Error(`OpenProject API error: ${response.status} ${response.statusText} - ${errorText}`);
    }
    // Handle empty response bodies (for DELETE requests)
    const text = await response.text();
    if (!text) {
        return {}; // Return empty object for empty responses
    }
    try {
        return JSON.parse(text);
    }
    catch(error: unknown) {
        console.error('Error parsing JSON response:', error);
        throw new Error(`Invalid JSON response: ${text}`);
    }
};This function makes authenticated requests to the OpenProject API.
Add the authentication helper function above the makeOpenProjectRequest function:
const createOpenProjectAuth = () => {
    if (!OPENPROJECT_ACCESS_TOKEN) {
        throw new Error('OpenProject access token not configured');
    }
    const credentials = Buffer.from(`apikey:${OPENPROJECT_ACCESS_TOKEN}`).toString('base64');
    return `Basic ${credentials}`;
};This function creates the basic authentication header required by the OpenProject API. It uses the apikey username with your access token as the password, encoded as Base64.
Next, add the following function above the createOpenProjectAuth function:
const mapOpenProjectToBryntum = (workPackage: WorkPackage) => {
    // Extract parent ID from parent href if exists
    let parentId = null;
    if (workPackage._links?.parent?.href) {
        const match = workPackage._links.parent.href.match(/\/work_packages\/(\d+)$/);
        if (match) {
            parentId = parseInt(match[1], 10);
        }
    }
    // Parse duration from OpenProject format "P14D" to number of days
    let duration = null;
    if (workPackage.duration) {
        const match = workPackage.duration.toString().match(/P(\d+)D/);
        if (match) {
            duration = parseInt(match[1], 10);
        }
    }
    // Handle different date fields:
    // - Milestones use the "date" field for both start and end
    // - Regular tasks use startDate/dueDate
    let startDate, endDate;
    if (workPackage.date) {
        // Milestone - use same date for both start and end
        startDate = workPackage.date;
        endDate = workPackage.date;
    }
    else {
        // Regular task - use start/due dates
        startDate = workPackage.startDate || null;
        // Use OpenProject dueDate directly with manual scheduling
        endDate = workPackage.dueDate || null;
    }
    const taskData = {
        id                : workPackage.id,
        name              : workPackage.subject || '',
        startDate         : startDate,
        endDate           : endDate,
        duration          : duration,
        percentDone       : workPackage.percentageDone || 0,
        parentId          : parentId,
        expanded          : true, // OpenProject doesn't have this field
        rollup            : false, // OpenProject doesn't have this field
        manuallyScheduled : workPackage.scheduleManually,
        status            : workPackage._links.status?.title,
        type              : workPackage._links.type?.title
    };
    // Filter out null values
    return Object.fromEntries(
        Object.entries(taskData).filter(([_, value]) => value !== null)
    );
};This mapping function handles conversions between OpenProject’s work package format and Bryntum Gantt’s task format. Key transformations include extracting parent IDs from OpenProject’s link structure, converting OpenProject’s “P14D” duration format to numeric days, and handling both regular tasks and milestones.
Now import the following types at the top of the file:
import {
    DependencySchemaType,
    WorkPackage,
    Relation
} from './types.js';Add the following dependency mapping function to the bottom of the file:
const mapOpenProjectRelationToBryntum = (relation: Relation) => {
    // Extract work package IDs from href links
    const fromMatch = relation._links.from.href.match(/\/work_packages\/(\d+)$/);
    const toMatch = relation._links.to.href.match(/\/work_packages\/(\d+)$/);
    if (!fromMatch || !toMatch) {
        return null;
    }
    const fromId = parseInt(fromMatch[1], 10);
    const toId = parseInt(toMatch[1], 10);
    // In OpenProject: "to follows from" means "from" must finish before "to" can start
    // In Bryntum: fromEvent is the predecessor, toEvent is the successor
    // So we swap them: OpenProject's "from" becomes Bryntum's "toEvent" and vice versa
    const dependencyData = {
        id        : relation.id,
        fromEvent : toId,  // Swapped: OpenProject's "to" becomes Bryntum's "fromEvent"
        toEvent   : fromId,  // Swapped: OpenProject's "from" becomes Bryntum's "toEvent"
        type      : mapOpenProjectRelationTypeToBryntum(relation.type),
        lag       : relation.lag || 0,
        lagUnit   : 'day',
        active    : true
    };
    // Filter out null values
    return Object.fromEntries(
        Object.entries(dependencyData).filter(([_, value]) => value !== null)
    );
};This function converts OpenProject work package relations to Bryntum Gantt dependencies. OpenProject and Bryntum use different relationship semantics. In OpenProject, “to follows from” means that the “from” task must finish before the “to” task can start. In Bryntum Gantt, dependencies use fromEvent (predecessor) and toEvent (successor). The function swaps these fields to ensure correct dependency direction.
Add the following relation type mapping helper function above the mapOpenProjectRelationToBryntum function:
const mapOpenProjectRelationTypeToBryntum = (relationType: string): number => {
    // Bryntum dependency types:
    //    0 = StartToStart, 1 = StartToEnd, 2 = EndToStart, 3 = EndToEnd
    // return End-to-Start for simplicity (most common)
    return 2;
};For simplicity, this function maps all OpenProject relation types to End-to-Start (type 2), which is the most common dependency type. In a production application, you could implement more sophisticated mapping based on OpenProject’s relation types.
Now, start the frontend and backend development servers:
npm run devOpen http://localhost:5173 in your browser. You should now see the Bryntum Gantt populated with data from your OpenProject demo project:

Syncing Bryntum Gantt data changes to OpenProject
Let’s implement the sync functionality to save changes from Bryntum Gantt to OpenProject. The Bryntum Gantt automatically sends changes to the sync URL when tasks are created, updated, or deleted.
First, import the required types at the top of the backend/src/index.ts file:
import {
    TaskSchemaType,
    SyncSuccessResponse 
} from './types.js';Add the /api/openproject/sync API endpoint:
app.post('/api/openproject/sync', async(req, res) => {
    try {
        const syncSuccessResponse: SyncSuccessResponse = {
            success   : true,
            requestId : req.body.requestId || Date.now(),
            tasks        : { rows : [] },
            dependencies : { rows : [] }
        };
        const { tasks, dependencies } = req.body;
        if (tasks) {
            // Handle added tasks - map phantom IDs to real IDs
            if (tasks.added) {
            }
            // Handle updated tasks
            if (tasks.updated) {
            }
            // Handle removed tasks
            if (tasks.removed) {
            }
        }
        if (dependencies) {
            console.log('Dependency sync not yet implemented for OpenProject API');
        }
        res.json(syncSuccessResponse);
    }
    catch (error: unknown) {
        console.error('Error syncing data:', error);
        res.status(500).json({
            success : false,
            message : error instanceof Error ? error.message : 'Unknown error'
        });
    }
});This endpoint handles create, update, and delete operations for tasks. When a task is created in the Bryntum Gantt, it is assigned a client-side-generated phantom ID, which is sent with the sync request. The sync response includes phantom ID mappings for new tasks, so the Bryntum Gantt can update its local data with the newly assigned OpenProject IDs. For simplicity, we only sync task data changes in this tutorial.
Creating OpenProject work packages
In the tasks.added if block of the /api/openproject/sync endpoint request handler, add the following for loop:
for (const task of tasks.added) {
    const { $PhantomId, ...taskData } = task;
    let type = 'Task';
    if (task.duration === 0) {
        type = 'Milestone';
    }
    const openProjectPayload = mapBryntumToOpenProject(taskData, type);
    const newWorkPackage = await makeOpenProjectRequest('/api/v3/work_packages', {
        method : 'POST',
        body   : JSON.stringify(openProjectPayload)
    });
    const response: TaskSchemaType = {
        $PhantomId : $PhantomId,
        id         : newWorkPackage.id,
        type       : type,
        status     : 'New'
    };
    // If this is a milestone, return the date that was set
    if (type === 'Milestone' && newWorkPackage.date) {
        response.startDate = newWorkPackage.date;
        response.endDate = newWorkPackage.date;
    }
    syncSuccessResponse.tasks.rows.push(response);
}This code creates new work packages in OpenProject using the mapBryntumToOpenProject and makeOpenProjectRequest functions. It returns the phantom ID mapping so Bryntum Gantt can update the new task with the newly assigned OpenProject ID. It also returns the type and status of the new task, so the Bryntum Gantt task store is updated with the correct type and status. If the task is a milestone, it returns the date that was set.
Updating OpenProject work packages
In the tasks.updated if block of the /api/openproject/sync endpoint request handler, add the following for loop:
for (const task of tasks.updated) {
    // First get the current work package to obtain the lockVersion
    const currentWorkPackage = await makeOpenProjectRequest(`/api/v3/work_packages/${task.id}`);
    // Prepare update payload with current lockVersion
    const updatePayload: Partial<WorkPackage> = {
        lockVersion : currentWorkPackage.lockVersion as number,
        _links      : {} as Partial<WorkPackage>['_links']
    };
    // Only include fields that are being updated
    if (task.name !== undefined) {
        updatePayload.subject = task.name;
    }
    // If inactive is false, convert to summary/phase task
    if (task.inactive === false) {
        updatePayload._links!.type = {
            href  : '/api/v3/types/3',
            title : 'Phase'
        };
    }
    // If duration is updated to 0, convert to milestone
    else if (task.duration === 0) {
        // Set milestone date - use startDate or endDate if provided, otherwise use current date
        const milestoneDate = task.startDate || task.endDate || currentWorkPackage.date || currentWorkPackage.startDate || new Date().toISOString();
        updatePayload.date = formatDateForOpenProject(milestoneDate);
        updatePayload.startDate = null;
        updatePayload.dueDate = null;
        // Update type to Milestone
        updatePayload._links!.type = {
            href  : '/api/v3/types/2',
            title : 'Milestone'
        };
    }
    else if (task.duration !== undefined && task.duration >= 1 && currentWorkPackage._links.type?.title === 'Milestone') {
        // If duration is updated to 1 or more and it was a milestone, convert to task
        // Set startDate and dueDate - use provided values or calculate from milestone date
        if (task.startDate !== undefined) {
            updatePayload.startDate = formatDateForOpenProject(task.startDate);
        }
        else if (task.endDate !== undefined) {
            // Calculate startDate from endDate and duration
            // Bryntum duration represents the span, so subtract duration from endDate
            const endDate = new Date(task.endDate);
            const startDate = new Date(endDate);
            startDate.setDate(endDate.getDate() - task.duration);
            updatePayload.startDate = formatDateForOpenProject(startDate.toISOString());
        }
        else {
            // Use the milestone date as startDate
            updatePayload.startDate = currentWorkPackage.date;
        }
        if (task.endDate !== undefined) {
            updatePayload.dueDate = formatDateForOpenProject(task.endDate);
        }
        else if (task.startDate !== undefined) {
            // Calculate dueDate from startDate and duration
            // Bryntum duration represents the span, so add duration to startDate
            const startDate = new Date(task.startDate);
            const endDate = new Date(startDate);
            endDate.setDate(startDate.getDate() + task.duration);
            updatePayload.dueDate = formatDateForOpenProject(endDate.toISOString());
        }
        else {
            // Calculate dueDate from milestone date and duration
            // Bryntum duration represents the span, so add duration to startDate
            const startDate = new Date(currentWorkPackage.date || new Date());
            const endDate = new Date(startDate);
            endDate.setDate(startDate.getDate() + task.duration);
            updatePayload.dueDate = formatDateForOpenProject(endDate.toISOString());
        }
        // Don't send date field for tasks - only set it to null if needed
        // Don't set duration when we have startDate and dueDate
        // OpenProject will calculate it automatically
        // Update type to Task
        updatePayload._links!.type = {
            href  : '/api/v3/types/1',
            title : 'Task'
        };
    }
    else {
        // Regular task updates
        if (task.startDate !== undefined) {
            updatePayload.startDate = formatDateForOpenProject(task.startDate);
        }
        if (task.endDate !== undefined) {
            updatePayload.dueDate = formatDateForOpenProject(task.endDate);
        }
        if (task.duration !== undefined && !(task.startDate && task.endDate)) {
            updatePayload.duration = task.duration ? `P${task.duration}D` : null;
        }
    }
    if (task.percentDone !== undefined) {
        updatePayload.percentageDone = task.percentDone;
    }
    if (task.status !== undefined) {
        const statusHref = getStatusHrefForTitle(task.status);
        if (statusHref) {
            updatePayload._links!.status = { href : statusHref, title : task.status };
        }
    }
    const updatedWorkPackage = await makeOpenProjectRequest(`/api/v3/work_packages/${task.id}`, {
        method : 'PATCH',
        body   : JSON.stringify(updatePayload)
    });
    // If converted to Summary task, return the type
    if (task.inactive === false) {
        syncSuccessResponse.tasks.rows.push({
            id   : task.id,
            type : 'Summary task'
        });
    }
    // If converted to milestone, return the date and type so Bryntum can update the display
    else if (task.duration === 0 && updatedWorkPackage.date) {
        syncSuccessResponse.tasks.rows.push({
            id        : task.id,
            startDate : updatedWorkPackage.date,
            endDate   : updatedWorkPackage.date,
            type      : 'Milestone'
        });
    }
    // If converted from milestone to task, return the type
    else if (task.duration !== undefined && task.duration >= 1 && currentWorkPackage._links.type?.title === 'Milestone') {
        syncSuccessResponse.tasks.rows.push({
            id   : task.id,
            type : 'Task'
        });
    }
}This code updates work packages by first fetching the current lockVersion, then sending only the changed fields to OpenProject. The body of the request needs to include the current lockVersion of the work package. This property prevents conflicting modifications. There is logic for converting a task to the correct type using the duration, dates, and inactive properties.
Deleting OpenProject work packages
In the tasks.removed if block of the /api/openproject/sync endpoint request handler, add the following for loop:
for (const task of tasks.removed) {
    // Use the standard API endpoint for individual work package deletion
    await makeOpenProjectRequest(`/api/v3/work_packages/${task.id}`, {
        method : 'DELETE'
    });
}This code deletes work packages from OpenProject using the DELETE method on individual work package endpoints.
Adding the sync data helper functions
Now, let’s add the sync data helper functions.
Add the following date formatting helper function to the bottom of the file:
const formatDateForOpenProject = (dateString: string | null): string | null => {
    if (!dateString) return null;
    // Extract just the date part (YYYY-MM-DD) from datetime string
    const date = new Date(dateString);
    if (isNaN(date.getTime())) return null;
    return date.toISOString().split('T')[0];
};This function converts Bryntum Gantt date-and-time strings to OpenProject’s date-only format (YYYY-MM-DD).
Add the following status mapping helper function above the formatDateForOpenProject function:
const getStatusHrefForTitle = (statusTitle: string): string | null => {
    // Common OpenProject status mappings
    const statusMap: { [key: string]: number } = {
        'New'         : 1,
        'In progress' : 7,
        'In Progress' : 7,
        'On hold'     : 13,
        'On Hold'     : 13,
        'Rejected'    : 14,
        'Closed'      : 12,
        'Resolved'    : 11
    };
    const statusId = statusMap[statusTitle];
    return statusId ? `/api/v3/statuses/${statusId}` : null;
};This function maps common status titles to OpenProject status API endpoints.
Add the following data mapping function above the getStatusHrefForTitle function:
const mapBryntumToOpenProject = (task: TaskSchemaType, type: string) => {
    const payload: Partial<WorkPackage> = {
        subject              : task.name ?? '',
        scheduleManually     : true,  // use manual scheduling to avoid constraints
        estimatedTime        : null,
        ignoreNonWorkingDays : false,
        percentageDone       : task.percentDone || null,
        _links               : {
            category : {
                href : null
            },
            type : {
                href  : type === 'Milestone' ? '/api/v3/types/2' : '/api/v3/types/1',
                title : type
            },
            priority : {
                href  : '/api/v3/priorities/8',
                title : 'Normal'
            },
            project : {
                href  : '/api/v3/projects/1',
                title : 'Demo project'
            },
            projectPhase : {
                href  : null,
                title : null
            },
            projectPhaseDefinition : {
                href  : null,
                title : null
            },
            status : {
                href  : '/api/v3/statuses/1',
                title : 'New'
            },
            responsible : {
                href : null
            },
            assignee : {
                href : null
            },
            version : {
                href : null
            },
            parent : {
                href  : task.parentId ? `/api/v3/work_packages/${task.parentId}` : null,
                title : null
            },
            self : {
                href : null
            },
            attachments : []
        },
        description : {
            raw : ''
        }
    };
    // Handle date fields differently for milestones vs tasks
    if (type === 'Milestone') {
        // Milestones only have a 'date' field, no startDate or dueDate
        // Use startDate if provided, otherwise use endDate, otherwise use today's date
        const milestoneDate = task.startDate || task.endDate || new Date().toISOString();
        payload.date = formatDateForOpenProject(milestoneDate);
    }
    else {
        // Tasks have startDate and dueDate
        payload.startDate = task.startDate ? formatDateForOpenProject(task.startDate) : null;
        payload.dueDate = task.endDate ? formatDateForOpenProject(task.endDate) : null;
        // Only add duration if it's a positive number
        if (task.duration !== undefined && task.duration !== null && task.duration > 0) {
            payload.duration = `P${task.duration}D`;
        }
    }
    // For updates, we need lockVersion and should omit _links structure
    if (task.id) {
        return {
            lockVersion    : 1,
            _links         : {},
            subject        : task.name,
            startDate      : formatDateForOpenProject(task.startDate ?? null),
            dueDate        : formatDateForOpenProject(task.endDate ?? null),
            duration       : (task.startDate && task.endDate) ? null : (task.duration ? `P${task.duration}D` : null),
            percentageDone : task.percentDone || null
        };
    }
    return payload;
};This function converts Bryntum Gantt tasks to the OpenProject work package format. It sets manual scheduling to true to prevent automatic scheduling mismatches between Bryntum Gantt and OpenProject. OpenProject uses a different automatic scheduling algorithm to Bryntum Gantt, which may result in differences in calculated schedules. Date fields are set differently for milestones vs tasks.
Now the changes that you make in the Bryntum Gantt are synced to OpenProject:
Styling the Bryntum Gantt component to match the OpenProject Gantt style
To demonstrate the flexibility of the Bryntum Gantt component, let’s style it to resemble the OpenProject Gantt. We’ll change the Type column text color, change the taskbar colors, and add a Status column.
In the frontend/src/ganttConfig.ts file, add the following taskRenderer function to the ganttConfig object:
taskRenderer({ taskRecord, renderData }) {
    if (taskRecord.get('type') === 'Summary task') {
        renderData.style = 'background-color: #ff8f2b';
    }
    if (taskRecord.get('type') === 'Task') {
        renderData.style = 'background-color: #1b66a3';
    }
    if (taskRecord.get('type') === 'Milestone') {
        renderData.style = 'background-color: #2a9a30';
    }
    return '';
},We use the taskRenderer function to change the taskbar colors based on the work package type to match OpenProject’s color scheme: orange for summary tasks, blue for regular tasks, and green for milestones.
In the type column object in the columns array, add the following renderer property:
renderer : ({ record }: { record: Model }) => ({
    // Return a DomConfig object describing our custom markup with the task name and a child count badge
    // See https://bryntum.com/products/grid/docs/api/Core/helper/DomHelper#typedef-DomConfig for more information.
    children : [
        record.get('type') === 'Summary task' ? {
            style : 'color: #ff8f2b;',
            text  : record.get('type')
        } : record.get('type') === 'Task' ? {
            style : 'color: #1b66a3;',
            text  : record.get('type')
        } : record.get('type') === 'Milestone' ? {
            style : 'color: #2a9a30;',
            text  : record.get('type')
        } : null
    ]
})The column renderer applies the same color scheme to the Type column text.
Import the Model type at the top of the frontend/src/ganttConfig.ts file:
import { Model  } from '@bryntum/gantt';Add the following status column object to the columns array in frontend/src/ganttConfig.ts, just after the name column:
{
    text   : 'Status',
    field  : 'status',
    width  : 100,
    editor : {
        type       : 'combo',
        editable   : false,
        autoExpand : true,
        items      : [
            ['New', 'New'],
            ['In Progress', 'In Progress'],
            ['Closed', 'Closed'],
            ['On hold', 'On hold'],
            ['Rejected', 'Rejected']
        ]
    }
},This adds a Status column with a dropdown editor that allows users to change work package statuses. The combo editor includes the most common OpenProject status values.
Now restart the development server to see the styled Bryntum Gantt:
npm run dev
Next steps
This tutorial provides a starting point for syncing a Bryntum Gantt chart with an OpenProject project via the OpenProject REST API.
There are many ways to improve your integration. For example, you could add more work package properties to the Bryntum Gantt component, such as description, assignee, priority, and custom fields. You could also implement dependency syncing to keep task relationships synchronized across both systems.
Visit the Bryntum Gantt demos page to browse examples of the customization and advanced features that make Bryntum Gantt stand out from OpenProject Gantt, such as:
- Critical paths
- Measuring the rate of progress with an S-curve
- Charts integration.
