Create a scale for bands with different width in D3.js

Yeah you can use a scale to handle that, if the data is always grouped you can try saving the offset of each grouped value. We can do it with the scale or just using the data.

Creating a scale would be something like this:

const yScale = d3.scaleOrdinal()
  .range(data.reduce((acc, val, index, arr) => {
    if (index > 0) {
      acc.push(arr[index - 1].values.length + acc[acc.length - 1]);
    } else {
      acc.push(0);
    }
    return acc;
  }, []))
  .domain(data.map(d => d.key));

With this we can get the offset using a scale. We are using scaleOrdinal since we want a 1-to-1 mapping. From the docs:

Unlike continuous scales, ordinal scales have a discrete domain and range. For example, an ordinal scale might map a set of named categories to a set of colors, or determine the horizontal positions of columns in a column chart.

If we check our new yScale we can see the following:

console.log(yScale.range());       // Array(6) [ 0, 4, 5, 8, 9, 11 ]
console.log(yScale.domain());      // Array(6) [ "database", "marketing", "operations", "frontend", "devops", "backend" ]
console.log(yScale("database"));   // 0
console.log(yScale("marketing"));  // 4

We could also try just adding the offset into the data and achieve the same:

const teams = svg.selectAll('g.group__team')
  .data(d => {
    let offset = 0;
    return data.map((d, i) => {
      if(i > 0) offset+= data[i - 1].values.length;
      return {
        ...d,
        offset
      };
    })
  })

With that we just simply create groups and translate them using the offset:

const teams = svg.selectAll('g.group__team')
  .data(d => {
    let offset = 0;
    return data.map((d, i) => {
      if (i > 0) offset += data[i - 1].values.length;
      return {
        ...d,
        offset
      };
    })
  })
  .join('g')
  .attr('class', d => 'group__team ' + d.key)
  .attr('transform', d => `translate(${[0, yScale(d.key) * barHeight]})`) // using scale
  .attr('transform', d => `translate(${[0, d.offset * barHeight]})`)      // using our data

Now lets render each project:

teams.selectAll('rect.group__project')
  .data(d => d.values)
  .join('rect')
  .attr('class', d => 'group__project ' + d.team)
  .attr('x', d => margin.left + xScale(d3.isoParse(d.start_date)))
  .attr('y', (d, i) => margin.top + i * barHeight)
  .attr('width', d => xScale(d3.isoParse(d.end_date)) - xScale(d3.isoParse(d.start_date)))
  .attr('height', barHeight);

This should render all our rects relative to our group. Now lets deal with the labels:

teams.selectAll('text.group__name')
  .data(d => [d])
  .join('text')
  .attr('class', 'group__name')
  .attr('x', 5)
  .attr('y', (d, i) => margin.top + (d.values.length * barHeight) / 2) // Get half of the sum of the project bars in the team
  .attr('dy', '6px')

And lastly render a delimiter of teams:

teams.selectAll('line.group__delimiter')
  .data(d => [d])
  .join('line')
  .attr('class', 'line group__delimiter')
  .attr('x1', margin.left)
  .attr('y1', (d, i) => margin.top + (d.values.length * barHeight))
  .attr('x2', viewport.width)
  .attr('y2', (d, i) => margin.top + (d.values.length * barHeight))
  .attr('stroke', '#222')
  .attr('stroke-width', 1)
  .attr('stroke-dasharray', 10);

JSfiddle working code

Full code:

const projects = [{
    'name': 'foo',
    'team': 'operations',
    'start_date': '2018-01-01',
    'end_date': '2019-12-31'
  },
  {
    'name': 'bar',
    'team': 'operations',
    'start_date': '2017-01-01',
    'end_date': '2018-12-31'
  },
  {
    'name': 'abc',
    'team': 'operations',
    'start_date': '2018-01-01',
    'end_date': '2018-08-31'
  },
  {
    'name': 'xyz',
    'team': 'devops',
    'start_date': '2018-04-01',
    'end_date': '2020-12-31'
  },
  {
    'name': 'wtf',
    'team': 'devops',
    'start_date': '2018-01-01',
    'end_date': '2019-09-30'
  },
  {
    'name': 'qwerty',
    'team': 'frontend',
    'start_date': '2017-01-01',
    'end_date': '2019-01-31'
  },
  {
    'name': 'azerty',
    'team': 'marketing',
    'start_date': '2016-01-01',
    'end_date': '2019-08-31'
  },
  {
    'name': 'qwertz',
    'team': 'backend',
    'start_date': '2018-05-01',
    'end_date': '2019-12-31'
  },
  {
    'name': 'mysql',
    'team': 'database',
    'start_date': '2015-01-01',
    'end_date': '2017-09-15'
  },
  {
    'name': 'postgresql',
    'team': 'database',
    'start_date': '2016-01-01',
    'end_date': '2018-12-31'
  },
  {
    'name': 'mysql',
    'team': 'database',
    'start_date': '2018-05-01',
    'end_date': '2019-12-31'
  },
  {
    'name': 'mysql',
    'team': 'database',
    'start_date': '2018-05-01',
    'end_date': '2019-12-31'
  },
]

// Process data
projects.sort((a, b) => d3.ascending(a.start_date, b.start_date));

const data = d3.nest()
  .key(d => d.team)
  .entries(projects);

const flatData = d3.merge(data.map(d => d.values));

// Configure dimensions
const
  barHeight = 16,
  margin = {
    top: 50,
    left: 100,
    right: 20,
    bottom: 50
  },
  chart = {
    width: 1000,
    height: projects.length * barHeight
  },
  viewport = {
    width: chart.width + margin.left + margin.right,
    height: chart.height + margin.top + margin.bottom
  },
  tickBleed = 5,
  labelPadding = 10;

// Configure scales and axes
const xMin = d3.min(
  flatData,
  d => d3.isoParse(d.start_date)
);
const xMax = d3.max(
  flatData,
  d => d3.isoParse(d.end_date)
);

const xScale = d3.scaleTime()
  .range([0, chart.width])
  .domain([xMin, xMax]);

const xAxis = d3.axisBottom(xScale)
  .ticks(20)
  .tickSize(chart.height + tickBleed)
  .tickPadding(labelPadding);

const yScale = d3.scaleOrdinal()
  .range(data.reduce((acc, val, index, arr) => {
    if (index > 0) {
      acc.push(arr[index - 1].values.length + acc[acc.length - 1]);
    } else {
      acc.push(0);
    }
    return acc;
  }, []))
  .domain(data.map(d => d.key));

console.log(yScale.range());
console.log(yScale.domain());
console.log(yScale("database"));
console.log(yScale("marketing"));

const yAxis = d3.axisLeft(yScale);

// Draw SVG
const svg = d3.select('body')
  .append('svg')
  .attr('width', viewport.width)
  .attr('height', viewport.height);

svg.append('g')
  .attr('class', 'x-axis')
  .call(xAxis);

d3.select('.x-axis')
  .attr(
    'transform',
    `translate(${[margin.left, margin.top]})`
  );

d3.select('.x-axis .domain')
  .attr(
    'transform',
    `translate(${[0, chart.height]})`
  );

const chartArea = svg.append('rect')
  .attr('x', margin.left)
  .attr('y', margin.top)
  .attr('width', chart.width)
  .attr('height', chart.height)
  .style('fill', 'red')
  .style('opacity', 0.1)
  .style('stroke', 'black')
  .style('stroke-width', 1);

const teams = svg.selectAll('g.group__team')
  .data(d => {
    let offset = 0;
    return data.map((d, i) => {
      if (i > 0) offset += data[i - 1].values.length;
      return {
        ...d,
        offset
      };
    })
  })
  .join('g')
  .attr('class', d => 'group__team ' + d.key)
  .attr('transform', d => `translate(${[0, yScale(d.key) * barHeight]})`)
  .attr('transform', d => `translate(${[0, d.offset * barHeight]})`)
  .on('mouseenter', d => {
    svg.selectAll('.group__team')
      .filter(team => d.key != team.key)
      .attr('opacity', 0.2);
  })
  .on('mouseleave', d => {
    svg.selectAll('.group__team')
      .attr('opacity', 1);
  })

teams.selectAll('rect.group__project')
  .data(d => d.values)
  .join('rect')
  .attr('class', d => 'group__project ' + d.team)
  .attr('x', d => margin.left + xScale(d3.isoParse(d.start_date)))
  .attr('y', (d, i) => margin.top + i * barHeight)
  .attr('width', d => xScale(d3.isoParse(d.end_date)) - xScale(d3.isoParse(d.start_date)))
  .attr('height', barHeight);


teams.selectAll('text.group__name')
  .data(d => [d])
  .join('text')
  .attr('class', 'group__name')
  .attr('x', 5)
  .attr('y', (d, i) => margin.top + (d.values.length * barHeight) / 2)
  .attr('dy', '6px')
  .text(d => d.key);

teams.selectAll('line.group__delimiter')
  .data(d => [d])
  .join('line')
  .attr('class', 'line group__delimiter')
  .attr('x1', margin.left)
  .attr('y1', (d, i) => margin.top + (d.values.length * barHeight))
  .attr('x2', viewport.width)
  .attr('y2', (d, i) => margin.top + (d.values.length * barHeight))
  .attr('stroke', '#222')
  .attr('stroke-width', 1)
  .attr('stroke-dasharray', 10)



/**
svg.append('g')
    .attr('class', 'y-axis')
  .call(yAxis);
*/