Repair Cafes Worldwide
Timeline of repair status under different categories.
By Manish Datt
TidyTuesday dataset of 2026-04-07
Repairs Data
Repairs Timeline by Category
Repairs Timeline
Plotting code
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Repairs Data</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/tabulator-tables@5.5.2/dist/js/tabulator.min.js"></script>
<link href="https://unpkg.com/tabulator-tables@5.5.2/dist/css/tabulator.min.css" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highcharts/11.4.0/highcharts.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highcharts/11.4.0/modules/exporting.min.js"></script>
<script type="module">
import { getName } from 'https://cdn.jsdelivr.net/npm/country-list@2.3.0/+esm';
window.getCountryName = getName;
</script>
</head>
<body class="bg-gray-100 p-8">
<div class="max-w-7xl mx-auto">
<h1 class="text-3xl font-bold mb-6 text-gray-800">Repairs Data</h1>
<div id="repairs-table" class="mb-8"></div>
<h1 class="text-3xl font-bold mb-6 text-gray-800">Repairs Timeline by Category</h1>
<button id="download-category-svg" class="mb-4 rounded bg-slate-800 px-4 py-2 text-sm font-semibold text-slate-100 hover:bg-slate-700">
Download Category Chart SVG
</button>
<div id="repairs-category-timeline" class="mb-8" style="height: 900px;"></div>
<h1 class="text-3xl font-bold mb-6 text-gray-800">Repairs Timeline</h1>
<div id="repairs-timeline" class="mb-8" style="height: 1000px;"></div>
</div>
<script>
const csvCache = new Map();
let categoryTimelineChart = null;
const REPAIRS_CSV_SOURCES = [
'./repairs.csv',
'https://raw.githubusercontent.com/rfordatascience/tidytuesday/main/data/2026/2026-04-07/repairs.csv'
];
function loadCsv(sources) {
const sourceList = Array.isArray(sources) ? sources : [sources];
const cacheKey = sourceList.join('|');
if (!csvCache.has(cacheKey)) {
csvCache.set(cacheKey, (async () => {
for (const source of sourceList) {
try {
const response = await fetch(source);
if (response.ok) {
return response.text();
}
} catch (error) {
console.warn(`Failed to load CSV from ${source}`, error);
}
}
throw new Error(`Unable to load CSV from any configured source: ${sourceList.join(', ')}`);
})());
}
return csvCache.get(cacheKey);
}
function parseCsv(csvData) {
const rows = csvData.split('\n').map(row => row.split(',').map(cell => cell.trim().replace(/^"|"$/g, '')));
const headers = rows[0];
const dataRows = rows.slice(1).filter(row => row.length === headers.length && row.some(cell => cell !== ''));
return { headers, dataRows };
}
function createTabulator(tableId, sources) {
loadCsv(sources)
.then(csvData => {
const { headers, dataRows } = parseCsv(csvData);
const data = dataRows.map(row => {
const obj = {};
headers.forEach((header, i) => {
obj[header] = row[i];
});
return obj;
});
const columns = headers.map(header => ({
title: header,
field: header,
headerFilter: "input"
}));
new Tabulator(tableId, {
data: data,
columns: columns,
layout: "fitColumns",
pagination: "local",
paginationSize: 10,
});
});
}
function createTimeline(sources) {
loadCsv(sources)
.then(csvData => {
const { headers, dataRows } = parseCsv(csvData);
const repairDateIndex = headers.indexOf('repair_date');
const repairedIndex = headers.findIndex(h => h.toLowerCase().includes('repaired'));
const countryIndex = headers.indexOf('country');
const categoryIndex = headers.indexOf('category');
if (repairDateIndex === -1 || repairedIndex === -1 || countryIndex === -1 || categoryIndex === -1) {
console.error('Required columns not found');
return;
}
const countries = [...new Set(dataRows
.filter(row => row[countryIndex] && row[countryIndex].trim() !== '')
.map(row => row[countryIndex].trim()))];
const categoryCounts = dataRows
.filter(row => row[categoryIndex] && row[categoryIndex].trim() !== '')
.reduce((acc, row) => {
const category = row[categoryIndex].trim();
acc[category] = (acc[category] || 0) + 1;
return acc;
}, {});
const categories = Object.entries(categoryCounts)
.sort((a, b) => b[1] - a[1])
.map(([category]) => category);
const totalCategoryCount = categories.reduce((sum, category) => sum + categoryCounts[category], 0);
const categoryAxisLabels = categories.map(category => {
const rawPercentage = (categoryCounts[category] / totalCategoryCount) * 100;
const percentage = rawPercentage < 0.1
? rawPercentage.toFixed(4).replace(/\.?0+$/, '')
: rawPercentage.toFixed(1).replace(/\.0$/, '');
return `${category} (${percentage}%)`;
});
const countryNames = countries.map(code => {
let name = window.getCountryName ? window.getCountryName(code) : code;
if (!name) name = code;
if (name.includes('United Kingdom')) name = 'United Kingdom';
return name;
});
const repairedCategories = ['yes', 'no', 'half'];
const colors = ['#93c5fd', '#2563eb', '#38bdf8'];
const symbols = ['circle', 'square', 'diamond'];
const legendNames = ['Yes', 'No', 'Half'];
const repairedOffsets = {
yes: -0.2,
no: 0,
half: 0.2
};
const repairedStyle = {
yes: { name: legendNames[0], color: colors[0], symbol: symbols[0] },
no: { name: legendNames[1], color: colors[1], symbol: symbols[1] },
half: { name: legendNames[2], color: colors[2], symbol: symbols[2] }
};
function buildSeries(items, itemIndex) {
const itemPositions = new Map(items.map((item, idx) => [item, idx]));
const seriesBuckets = new Map();
const legendOrder = new Map(repairedCategories.map((status, idx) => [status, idx]));
dataRows.forEach(row => {
const repaired = row[repairedIndex] ? row[repairedIndex].trim().toLowerCase() : '';
const item = row[itemIndex] ? row[itemIndex].trim() : '';
if (!repairedCategories.includes(repaired) || !itemPositions.has(item) || !row[repairDateIndex]) {
return;
}
const date = new Date(row[repairDateIndex].trim());
if (isNaN(date.getTime())) {
return;
}
const key = `${item}::${repaired}`;
if (!seriesBuckets.has(key)) {
const style = repairedStyle[repaired];
seriesBuckets.set(key, {
item,
repaired,
name: style.name,
data: [],
color: style.color,
marker: { radius: 3, symbol: style.symbol },
showInLegend: itemPositions.get(item) === 0
});
}
seriesBuckets.get(key).data.push([
date.getTime(),
itemPositions.get(item) + repairedOffsets[repaired]
]);
});
return Array.from(seriesBuckets.values())
.sort((a, b) => {
const itemDiff = itemPositions.get(a.item) - itemPositions.get(b.item);
if (itemDiff !== 0) return itemDiff;
return legendOrder.get(a.repaired) - legendOrder.get(b.repaired);
})
.map(({ item, repaired, ...series }) => series);
}
function createScatterChart(containerId, title, labels, seriesData, tooltipLabel, options = {}) {
const displayLabels = options.displayLabels || labels;
return Highcharts.chart(containerId, {
chart: {
type: 'scatter',
backgroundColor: '#0b1f3a',
style: {
color: '#d1d5db'
}
},
title: {
text: title,
style: {
color: '#d1d5db',
fontSize: options.titleFontSize || '16px'
}
},
xAxis: {
type: 'datetime',
title: { text: '' },
labels: {
style: {
color: '#d1d5db',
fontSize: options.xAxisFontSize || '12px'
}
},
lineColor: '#94a3b8',
tickColor: '#94a3b8'
},
yAxis: {
title: { text: '' },
categories: displayLabels,
tickInterval: 1,
min: 0,
max: labels.length - 1,
reversed: options.reversed || false,
labels: {
style: {
color: '#d1d5db',
fontSize: options.yAxisFontSize || '12px'
}
},
gridLineColor: '#334155'
},
series: seriesData,
credits: { enabled: false },
legend: {
itemStyle: {
color: '#d1d5db',
fontSize: options.legendFontSize || '12px'
},
itemHoverStyle: {
color: '#e5e7eb'
}
},
tooltip: {
backgroundColor: '#102a4c',
style: {
color: '#d1d5db'
},
formatter: function() {
const date = new Date(this.x);
const labelIdx = Math.round(this.y);
const label = labels[labelIdx] || 'Unknown';
return `<b>${label}</b><br/>Date: ${date.toLocaleDateString()}<br/>${tooltipLabel}: ${this.series.name}`;
}
}
});
}
const countrySeriesData = buildSeries(countries, countryIndex);
const categorySeriesData = buildSeries(categories, categoryIndex);
createScatterChart('repairs-timeline', 'Repairs Over Time by Country', countryNames, countrySeriesData, 'Repaired');
categoryTimelineChart = createScatterChart('repairs-category-timeline', 'Timeline of repair status under different categories at the Repair Cafes worldwide', categories, categorySeriesData, 'Repaired', {
displayLabels: categoryAxisLabels,
reversed: true,
titleFontSize: '28px',
xAxisFontSize: '13px',
yAxisFontSize: '15px',
legendFontSize: '14px'
});
});
}
function downloadCategoryChartSvg() {
if (!categoryTimelineChart || typeof categoryTimelineChart.getSVG !== 'function') {
console.error('Category chart is not ready for SVG export.');
return;
}
const svg = categoryTimelineChart.getSVG({
exporting: {
sourceWidth: categoryTimelineChart.chartWidth,
sourceHeight: categoryTimelineChart.chartHeight
}
});
const blob = new Blob([svg], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'repairs-category-timeline.svg';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
document.getElementById('download-category-svg').addEventListener('click', downloadCategoryChartSvg);
createTabulator("#repairs-table", REPAIRS_CSV_SOURCES);
createTimeline(REPAIRS_CSV_SOURCES);
</script>
</body>