
Overview
An IP phone system exports its call logs as CSV, one row per call. Read as is, the file does not say how many calls were missed in the morning, who picked up, or whether an unanswered caller eventually reached someone. I built Call Stats, an internal tool in framework-free PHP, to answer those questions. It converts each export into one XML file per day, then computes daily and weekly dashboards, with Chart.js charts and a PDF export.
A CSV whose columns change names
Exports vary: metadata at the top, headers in French or English, with or without accents. CsvImporter takes as its header the first row with at least five columns where one cell contains "date". Each field then goes through pickValue, which tries three matches in order:
- the exact header, from a list of variants ("Statut", "Status");
- the header normalized by
normalizeKey: lowercase, accents removed, non-alphanumeric characters stripped; - a fragment contained in the header, such as "talk".
When the agent column is missing, I infer it from the call direction: the caller for an outbound call, the recipient for an inbound one. A file covering several days is rejected, because the whole tool reasons day by day.
One day, one XML file
I did not use a database. XmlGenerator writes one YYYY-MM-DD_appels.xml file per day, in three blocks: Meta, Config and Calls. Each call keeps its raw value next to the parsed one (RingRaw and RingSec), which makes it possible to check a conversion without reopening the CSV.
The Config block embeds the half-day definition (8:00 to 12:59 and 13:00 to 19:59 by default), the AI agent's name, its threshold and the list of tracked users. A day file is therefore self-contained. The tool handles several organizations, listed in data/companies.json, and ensureCompanyFolders creates for each one its csv/, xml/ and data/ folders and a config.json. If an organization's configuration changes, the reload-xml route regenerates the XML from the stored CSV.
An organization's page: one CSV imported per day, one row per day with its XML and CSV stored in data/companies/structure-demo, and on each row a 'Recharger XML' button that regenerates the XML from the stored CSV.
What StatsService counts
computeSummary walks through the calls in a single pass. It counts inbound calls (COMPLETED and MISSED), missed calls per half-day, calls whose keywords contain PERDU, calls taken per agent in the morning and afternoon, average talk time per agent, the distribution over 24 hourly buckets and the average ring time before pickup. For the AI agent that handles part of the calls, it also counts completed calls shorter than a configurable threshold, 25 seconds by default.
Day view for 18/11/2025: the hourly buckets computed by computeSummary, as a Chart.js scatter plot (completed and missed) and a bar chart of completed calls.
Numbers go through normalizePhone: digits only, 00 prefix removed, a leading 33 replaced by 0, then at most the last ten digits. A caller thus counts once, whatever the format. A missed call counts as recovered if the same number gets a completed call later that day.
Day view indicators: 70 distinct numbers for 91 inbound calls, and 9 missed calls followed later that day by a completed call from the same number.
foreach ($missedCandidates as $missedItem) {
$num = $missedItem['num'];
if (!isset($completedTimesByNumber[$num])) {
continue;
}
foreach ($completedTimesByNumber[$num] as $tCompleted) {
if ($tCompleted > $missedItem['time']) {
$recoveredMissed++;
break;
}
}
}From dashboard to PDF, card by card
The day view lines up indicators and Chart.js charts, including a scatter plot of completed and missed calls hour by hour. The week view goes back to Monday with modify('monday this week') and reads the seven files. It also lists the missing days. The week's unique callers are the union of the daily lists, not the sum of the counters: someone who calls three days in a row counts once.
Week view requested for 19/11: it starts on Monday 17th, covers 5 days out of 7, lists the missing Saturday and Sunday and counts 221 unique callers, against 346 when adding up the daily counters.
The PDF export runs in the browser. The exportPdf function passes each .card to html2canvas, places the image on a jsPDF A4 page and opens a new page when the card no longer fits. A card that fits on one page is never cut in two.
Extensions go through a closed registry in loadCompanyExtensions, where each identifier points to a class exposing name(), description() and render(). The directory extension, fed by a CSV of contacts, puts a name next to the numbers that call the most.
What a migration left behind, and what I would redo
The tool started as flat PHP pages in public/, moved to a front controller, then handled several organizations in v1.1. HomeController still renders views that were moved or deleted (home/day, home/week, home/month): the month page, the only user of PeriodAggregator, no longer displays. deleteDay() ignores the organization sent by the form and deletes in the global folder. The AI agent's name stays hard-coded in StatsService even though it is configurable. Phone normalization exists in three copies, and StatsService.php.bak copies linger in the repository.
On security, the tool was designed for a local workstation. Before opening it to more people, it would need authentication and strict validation of everything that comes in through the URL. I would also pin the version of the libraries loaded from a CDN, then centralize phone normalization and test CsvImporter on anonymized exports.