Australisches Geschäftsjahr in Jira: Tickets pro Quartal vom 1. Juli bis 30. Juni
Immer wieder taucht dieselbe Frage auf. Eine Service-Desk-Leiterin in Australien möchte ein Balkendiagramm auf dem Jira-Dashboard: Kundenanfragen pro Quartal des australischen Geschäftsjahres, 1. Juli 2026 bis 30. Juni 2027. Das Standard-Gadget liefert ihr aber ein rollierendes Fenster „letzte 365 Tage“ mit fünf angebrochenen Kalenderquartalen auf der x-Achse statt vier Geschäftsquartalen.
Die ehrliche Antwort: Mit Bordmitteln geht das in Jira nicht. Dieser Beitrag zeigt, warum, was die üblichen Workarounds kosten, und einen Report-Builder-Bericht, der das für jedes Geschäftsjahr löst. Der vollständige Quellcode steht am Ende.
Warum Jira-Dashboards keine Geschäftsquartale kennen
Drei Dinge stehen im Weg, und keines davon ist eine übersehene Einstellung.
- Gadgets rechnen mit Kalenderquartalen. Der Zeitraum „Quarterly“ in Gadgets wie Created vs Resolved bedeutet Januar–März, April–Juni usw. Die Alternative „days previously“ ist ein rollierendes Fenster, das sich nicht auf den 1. Juli festlegen lässt.
- JQL hat keine Quartalsfunktionen. Jira Cloud kennt
startOfYear(),startOfMonth()undstartOfWeek(), undstartOfYear("+6M")ergibt tatsächlich den 1. Juli. EinstartOfQuarter()gibt es aber nicht; der Wunsch danach sammelt noch Stimmen. - Die Geschäftsjahr-Einstellung sitzt woanders. In Jira Premium lässt sich das Geschäftsjahr in Plans ändern, und Jira Product Discovery hat eine eigene Einstellung. Beide steuern Roadmap-Zeitachsen, keine Dashboard-Diagramme.
Wer Atlassian Analytics hat, kann mit einem eigenen SQL-Datums-Bucket nach Geschäftsjahr gruppieren. Die meisten Jira-Teams haben es nicht, und SQL braucht es trotzdem.
Die Workarounds und ihr Preis
| Workaround | So funktioniert es | Der Haken |
| Labels per Automation | Eine Regel setzt FYQ1–FYQ4 an jedes neue Ticket; ein Issue-Statistics-Gadget zählt nach Label | Alte Tickets brauchen ein Backfill, die Labels müssen jedes Jahr neu gesetzt werden und verschmutzen die Tickets |
| Vier gespeicherte Filter | Ein Filter pro Geschäftsquartal, je ein Filter-Results-Gadget | Vier Zahlen statt eines Balkendiagramms; jedes Jahr neue Daten in jedem Filter |
| Export nach Google Sheets | Jahresfilter per Jira Cloud for Sheets nach Sheets, dort das Diagramm | Das Diagramm liegt außerhalb von Jira und ist nur so aktuell wie der letzte Abruf |
Keiner der Wege vergleicht das Jahr mit dem Vorjahr, und keiner sagt, ob ein laufendes Quartal vorne oder hinten liegt.
Die Lösung: ein Geschäftsjahr-Bericht in Report Builder
Report Builder hat einen Pivot-Builder für alltägliche Berichte, doch auch dessen Quartalsgruppierung kennt keine Geschäftsjahr-Einstellung. Deshalb haben wir einen kleinen Scripted Report geschrieben: einen Bericht mit eigenem Code, der in Report Builder auf Live-Daten aus Jira läuft. Sie stellen den Monat ein, in dem Ihr Geschäftsjahr beginnt, und alle Diagramme und Tabellen richten sich danach.
Das zeigt er für eines unserer Demo-Serviceprojekte im Geschäftsjahr 2024–25:
- Ein Balken pro Geschäftsquartal, Q1 = Juli–September, daneben das Vorjahr.
- Kacheln oben: Jahressumme (1.916), Veränderung zum Vorjahr (−23 %), stärkstes Quartal (Q1, 645) und Monatsdurchschnitt.
- Eine Tabelle, in der jede Zahl klickbar ist. Jede Zahl öffnet genau diese Tickets in Jira, Diagramm und Vorgangssuche stimmen also immer überein.
Einstellungen
Alles wird im Einstellungsbereich des Berichts gesetzt und mit ihm gespeichert. So zeigt das Dashboard allen dieselbe Ansicht.
| Einstellung | Optionen | Standard |
| Issues to count | Projekte, gespeicherte Filter oder JQL | Alle sichtbaren Vorgänge |
| Financial year starts in | Jeder Monat (Juli für Australien, April für UK, Indien und Japan, Oktober für die US-Bundesverwaltung) | Juli |
| Financial year | Das Jahr, in dem es endet: 2027 = 1. Juli 2026 – 30. Juni 2027 | Aktuelles |
| Compare with | Nichts, Vorjahr, zwei Vorjahre | Vorjahr |
| Count issues by | Erstellt- oder Erledigt-Datum | Erstellt |
| One bar per | Quartal oder Monat | Quartal |
| Split bars by | Anfragetyp, Vorgangstyp, Priorität, Statuskategorie, Bearbeiter | Nichts |
Nach Anfragetyp aufteilen
Für einen Service Desk folgt auf „wie viele“ die Frage „welche Art“. Teilen Sie die Balken nach Anfragetyp auf, erhalten Sie zusätzlich eine Tabelle mit Anfragetypen als Zeilen und Geschäftsquartalen als Spalten. In unserem Demo-Projekt entfallen 1.572 der 1.916 Tickets im Geschäftsjahr 2024–25 auf „Report a system issue“.
Monatsansicht
Mit einem Balken pro Monat sehen Sie das Geschäftsjahr von Juli bis Juni, mit demselben Vergleich.
Das laufende Jahr fair vergleichen
Läuft das gewählte Jahr noch, sähe ein Vergleich mit dem vollen Vorjahr immer wie ein Einbruch aus. Der Bericht vergleicht deshalb mit dem Vorjahr bis zum selben Tag und markiert das laufende Quartal als „so far“. Noch nicht begonnene Quartale sind ausgegraut.
So kommt der Bericht in Ihr Jira
- Den KI-Assistenten fragen. Öffnen Sie in Report Builder den Assistenten in Rovo oder verbinden Sie Ihren eigenen KI-Client per MCP, und bitten Sie um „ein Balkendiagramm der Tickets pro Quartal des australischen Geschäftsjahres, 1. Juli bis 30. Juni, im Vergleich zum Vorjahr“. Er erstellt einen Entwurf, den Sie prüfen und speichern.
- Den Code einfügen. Legen Sie einen neuen Scripted Report an und fügen Sie Skript, Template und Eingabefelder vom Ende dieses Beitrags ein. Dafür ist die Berechtigung Scripted Reports Creator nötig.
- Uns fragen. Für Report-Builder-Kunden erstellen wir individuelle Berichte kostenlos: Bericht anfragen.
Gespeichert lässt sich der Bericht wie jeder andere Report-Builder-Bericht auf ein Jira-Dashboard setzen.
So zählt der Bericht
Der Kern ist eine einzige Funktion. Ein Datum gehört zu dem Geschäftsjahr, das im folgenden Kalenderjahr endet, wenn sein Monat auf oder nach dem Startmonat liegt:
// startMonth: 7 für Juli. Liefert das Geschäftsjahr (nach seinem Endjahr)
// und den Monatsindex 0–11 innerhalb dieses Jahres.
function bucket(dateStr, startMonth) {
var y = +dateStr.slice(0, 4), m = +dateStr.slice(5, 7);
var fy = startMonth === 1 ? y : (m >= startMonth ? y + 1 : y);
return { fy: fy, k: (m - startMonth + 12) % 12 }; // Quartal = Math.floor(k / 3) + 1
}
- Gezählt wird nach dem Kalenderdatum, das Jira liefert, nicht über die Zeitzone des Browsers umgerechnet. Ein Ticket vom 30. Juni, 23:30 Uhr, bleibt in Q4, genau wie JQL
created < "2025-07-01"es sieht. - Eine Abfrage pro Bericht. Der Bericht holt alle Tickets der gewählten Jahre einmal und zählt im Browser.
- Jede Zahl verweist auf ihre JQL. Wer einem Balken nicht traut, sieht mit einem Klick die Tickets dahinter.
Wir haben die Datumslogik für Geschäftsjahre ab Juli, Januar, April und Oktober getestet, auch mit Tickets am ersten und letzten Tag eines Geschäftsjahres. Danach haben wir den Bericht in Jira mit einfachen JQL-Zählungen abgeglichen, für einen Start im Juli und im April: jedes Quartal stimmte.
Vollständiger Quellcode
Der Bericht besteht aus drei Teilen: Eingabefelder, Skript und Template. Fügen Sie sie in einen neuen Scripted Report in Report Builder ein.
Eingabefelder (code/fields.json)
[
{
"type": "14",
"name": "scope",
"title": "Issues to count: projects, saved filters or JQL (empty = all issues)",
"order": 1,
"defaultValue": {
"scopePickerMode": "basicView",
"projects": [],
"epics": [],
"issues": [],
"filters": [],
"jql": ""
}
},
{
"type": "13",
"name": "fy-start",
"title": "Financial year starts in",
"optionsList": [
"January",
"February",
"March",
"April (e.g. UK, India, Japan)",
"May",
"June",
"July (e.g. Australia)",
"August",
"September",
"October (e.g. US federal government)",
"November",
"December"
],
"isMulti": false,
"order": 2,
"defaultValue": "July (e.g. Australia)"
},
{
"type": "1",
"name": "fy-year",
"title": "Financial year, by the year it ends in (empty = current)",
"order": 3,
"defaultValue": ""
},
{
"type": "13",
"name": "compare",
"title": "Compare with",
"optionsList": [
"Nothing",
"Previous financial year",
"Previous 2 financial years"
],
"isMulti": false,
"order": 4,
"defaultValue": "Previous financial year"
},
{
"type": "13",
"name": "date-field",
"title": "Count issues by",
"optionsList": [
"Created date",
"Resolved date"
],
"isMulti": false,
"order": 5,
"defaultValue": "Created date"
},
{
"type": "13",
"name": "period",
"title": "One bar per",
"optionsList": [
"Quarter",
"Month"
],
"isMulti": false,
"order": 6,
"defaultValue": "Quarter"
},
{
"type": "13",
"name": "split",
"title": "Split bars by",
"optionsList": [
"Nothing",
"Request type",
"Issue type",
"Priority",
"Status category",
"Assignee"
],
"isMulti": false,
"order": 7,
"defaultValue": "Nothing"
}
]
Skript (code/script.js)
/**
* Financial year report: issues per financial quarter (or month) for any financial year,
* for example the Australian financial year (1 July to 30 June).
*
* Settings:
* scope Which issues to count: projects, saved filters or JQL (empty = all issues you can see)
* fy-start The month the financial year starts in (July for Australia)
* fy-year The financial year to show, by the year it ENDS in (2027 = 1 Jul 2026 – 30 Jun 2027). Empty = current
* compare Also show the previous one or two financial years
* date-field Count issues by Created or by Resolved date
* period One bar per financial quarter or per month
* split Split the bars of the selected year by request type, issue type, priority, status category or assignee
*
* Dates are bucketed by the calendar date Jira returns (YYYY-MM-DD in your Jira time zone),
* the same way JQL compares dates, so the numbers match the "Open in Jira" links.
*/
var U = {};
U.esc = function (s) {
return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
.replace(/"/g, '"').replace(/'/g, ''');
};
U.text = function (raw) {
var s = String(raw == null ? '' : raw).trim();
if (/^".*"$/.test(s)) { try { s = String(JSON.parse(s)).trim(); } catch (e) { s = s.slice(1, -1).trim(); } }
return s;
};
U.getJson = async function (path) {
if (!SR.request) return null;
try {
var r = await SR.request(path, { method: 'GET' });
if (!r || r.ok === false) return null;
return typeof r.json === 'function' ? await r.json() : r;
} catch (e) { return null; }
};
// Select values may come back slugified ("request-type"): compare them in a normalised form.
U.norm = function (raw) { return U.text(raw).toLowerCase().replace(/[-_]+/g, ' ').replace(/\s+/g, ' ').trim(); };
// Type 14 "Filter by" usually returns a ready JQL string, sometimes the stored picker object.
U.scopeJql = function (raw) {
var jql = '';
if (typeof raw === 'string') jql = raw.trim();
else if (raw && typeof raw === 'object') {
if (raw.jql && String(raw.jql).trim()) jql = String(raw.jql).trim();
else {
var parts = [];
if (Array.isArray(raw.projects) && raw.projects.length)
parts.push('project in (' + raw.projects.map(function (p) { return '"' + (p.value != null ? p.value : p) + '"'; }).join(',') + ')');
if (Array.isArray(raw.filters) && raw.filters.length)
parts.push(raw.filters.map(function (f) { return f.type === 'predefinedFilter' ? '(' + f.value + ')' : 'filter = ' + f.value; }).join(' AND '));
if (Array.isArray(raw.issues) && raw.issues.length)
parts.push('issue in (' + raw.issues.map(function (i) { return i.value; }).join(',') + ')');
jql = parts.join(' AND ');
}
}
return jql.replace(/\s+order\s+by\s+[\s\S]*$/i, '').trim();
};
U.scopeLabel = function (data, jql) {
if (data && typeof data === 'object' && data.scopePickerMode !== 'jql') {
var bits = [];
['projects', 'epics', 'issues', 'filters'].forEach(function (k) {
(Array.isArray(data[k]) ? data[k] : []).forEach(function (x) { if (x && x.label) bits.push(x.label); });
});
if (bits.length) return bits.join(', ');
}
if (!jql) return 'All issues you can see';
return jql.length > 120 ? jql.slice(0, 117) + '…' : jql;
};
// ---------------------------------------------------------------- financial year maths
var MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
var MONTHS_LONG = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
var pad = function (n) { return (n < 10 ? '0' : '') + n; };
var FY = {};
FY.init = function (startMonth) { FY.m0 = startMonth; }; // 1..12
// The financial year a calendar date belongs to, named by the year it ends in.
FY.endYearOf = function (y, m) { return FY.m0 === 1 ? y : (m >= FY.m0 ? y + 1 : y); };
FY.startOf = function (endYear) { return FY.m0 === 1 ? endYear + '-01-01' : (endYear - 1) + '-' + pad(FY.m0) + '-01'; };
FY.endOf = function (endYear) { return FY.m0 === 1 ? (endYear + 1) + '-01-01' : endYear + '-' + pad(FY.m0) + '-01'; }; // exclusive
FY.label = function (endYear) { return FY.m0 === 1 ? 'FY ' + endYear : 'FY ' + (endYear - 1) + '–' + String(endYear).slice(2); };
// k-th month of the financial year (0..11) -> {y, m}
FY.monthAt = function (endYear, k) {
var y = FY.m0 === 1 ? endYear : endYear - 1;
var m = FY.m0 + k;
if (m > 12) { m -= 12; y += 1; }
return { y: y, m: m };
};
FY.monthStart = function (endYear, k) { var a = FY.monthAt(endYear, k); return a.y + '-' + pad(a.m) + '-01'; };
FY.monthEnd = function (endYear, k) { // exclusive
if (k === 11) return FY.endOf(endYear);
return FY.monthStart(endYear, k + 1);
};
// Bucket of a 'YYYY-MM-DD…' string: { fy, k } where k is the month index in the financial year
FY.bucket = function (dateStr) {
var y = +dateStr.slice(0, 4), m = +dateStr.slice(5, 7);
return { fy: FY.endYearOf(y, m), k: (m - FY.m0 + 12) % 12 };
};
FY.dayLabel = function (iso) { return (+iso.slice(8, 10)) + ' ' + MONTHS[+iso.slice(5, 7) - 1] + ' ' + iso.slice(0, 4); };
FY.lastDay = function (exclusiveIso) { // the day before an exclusive end date
var p = exclusiveIso.split('-');
var d = new Date(Date.UTC(+p[0], +p[1] - 1, +p[2]) - 86400000);
return d.getUTCFullYear() + '-' + pad(d.getUTCMonth() + 1) + '-' + pad(d.getUTCDate());
};
// ---------------------------------------------------------------- settings
var scopeRaw = await SR.fields.getValueByFieldName('scope');
var scopeData = null;
try { scopeData = SR.fields.getDataByFieldName ? await SR.fields.getDataByFieldName('scope') : null; } catch (e) { scopeData = null; }
var scopeJql = U.scopeJql(scopeRaw);
if (!scopeData && scopeRaw && typeof scopeRaw === 'object') scopeData = scopeRaw;
var startText = U.norm(await SR.fields.getValueByFieldName('fy-start')) || 'july';
var startMonth = 0;
MONTHS_LONG.forEach(function (m, x) { if (startText.indexOf(m.toLowerCase()) === 0) startMonth = x + 1; });
if (startMonth < 1) startMonth = 7;
FY.init(startMonth);
var yearRaw = U.text(await SR.fields.getValueByFieldName('fy-year'));
var compareText = U.norm(await SR.fields.getValueByFieldName('compare'));
var dateText = U.norm(await SR.fields.getValueByFieldName('date-field'));
var periodText = U.norm(await SR.fields.getValueByFieldName('period'));
var splitText = U.norm(await SR.fields.getValueByFieldName('split'));
var byResolved = dateText.indexOf('resolved') >= 0;
var dateField = byResolved ? 'resolutiondate' : 'created';
var dateJql = byResolved ? 'resolved' : 'created';
var monthly = periodText.indexOf('month') >= 0;
var compareN = compareText.indexOf('2') >= 0 ? 2 : (compareText.indexOf('previous') >= 0 ? 1 : 0);
var split = /request/.test(splitText) ? 'requestType' : /issue type/.test(splitText) ? 'issuetype'
: /priority/.test(splitText) ? 'priority' : /status/.test(splitText) ? 'statusCategory'
: /assignee/.test(splitText) ? 'assignee' : '';
var now = new Date();
var todayIso = now.getFullYear() + '-' + pad(now.getMonth() + 1) + '-' + pad(now.getDate());
var currentFy = FY.endYearOf(now.getFullYear(), now.getMonth() + 1);
var fyYear = parseInt(String(yearRaw).replace(/[^0-9]/g, ''), 10);
if (!fyYear || fyYear < 1990 || fyYear > 2100) fyYear = currentFy;
if (fyYear > currentFy) throw { srError: 'The financial year ending in ' + fyYear + ' has not started yet. Leave the year empty to show the current financial year.' };
var years = [];
for (var i = compareN; i >= 0; i--) years.push(fyYear - i); // oldest first
var rangeStart = FY.startOf(years[0]);
var rangeEnd = FY.endOf(fyYear);
// ---------------------------------------------------------------- request type field (Jira Service Management)
var rtField = null;
if (split === 'requestType') {
var fieldList = null;
try { fieldList = SR.jira.getIssueFields ? await SR.jira.getIssueFields() : null; } catch (e) { fieldList = null; }
if (!Array.isArray(fieldList)) fieldList = await U.getJson('/rest/api/3/field');
(Array.isArray(fieldList) ? fieldList : []).some(function (f) {
if (f && f.schema && f.schema.custom === 'com.atlassian.servicedesk:vp-origin') { rtField = f.id; return true; }
return false;
});
if (!rtField) split = 'issuetype'; // not a service desk site: fall back to issue type
}
var splitLabel = { requestType: 'request type', issuetype: 'issue type', priority: 'priority', statusCategory: 'status category', assignee: 'assignee' }[split] || '';
// ---------------------------------------------------------------- fetch
var inScope = function (jql) { return scopeJql ? '(' + scopeJql + ') AND ' + jql : jql; };
var rangeJql = function (from, to) { return dateJql + ' >= "' + from + '" AND ' + dateJql + ' < "' + to + '"'; };
var fields = [dateField];
if (split === 'requestType') fields.push(rtField);
if (split === 'issuetype') fields.push('issuetype');
if (split === 'priority') fields.push('priority');
if (split === 'statusCategory') fields.push('status');
if (split === 'assignee') fields.push('assignee');
var issues = await SR.jira.getIssuesByJQL(inScope(rangeJql(rangeStart, rangeEnd)), fields.join(','), '');
if (!Array.isArray(issues)) issues = [];
var splitOf = function (f) {
if (split === 'requestType') { var v = f[rtField]; return { name: v && v.requestType && v.requestType.name ? v.requestType.name : 'No request type', jql: null }; }
if (split === 'issuetype') return f.issuetype ? { name: f.issuetype.name, jql: 'issuetype = "' + f.issuetype.name.replace(/"/g, '\\"') + '"' } : { name: 'Unknown', jql: null };
if (split === 'priority') return f.priority ? { name: f.priority.name, jql: 'priority = "' + f.priority.name.replace(/"/g, '\\"') + '"' } : { name: 'No priority', jql: 'priority is EMPTY' };
if (split === 'statusCategory') {
var c = f.status && f.status.statusCategory;
return c ? { name: c.name, jql: 'statusCategory = ' + c.id } : { name: 'Unknown', jql: null };
}
if (split === 'assignee') return f.assignee ? { name: f.assignee.displayName, jql: 'assignee = "' + f.assignee.accountId + '"' } : { name: 'Unassigned', jql: 'assignee is EMPTY' };
return null;
};
// ---------------------------------------------------------------- count
var nB = monthly ? 12 : 4;
var bucketOfK = function (k) { return monthly ? k : Math.floor(k / 3); };
var counts = {}; // fy -> [nB]
years.forEach(function (y) { counts[y] = []; for (var b = 0; b < nB; b++) counts[y].push(0); });
var splitCounts = {}; // name -> { jql, cells[nB], total }
var sameDayLastYear = {}; // fy -> count up to the same day of the year as today
var skipped = 0;
// "Same point last year" for an in-progress year: today's month/day one, two… years back.
var shiftIso = function (iso, back) {
var y = +iso.slice(0, 4) - back, md = iso.slice(5, 10);
if (md === '02-29') md = '02-28';
return y + '-' + md;
};
var inProgress = fyYear === currentFy;
issues.forEach(function (it) {
var f = it.fields || {};
var d = f[dateField];
if (!d || typeof d !== 'string') { skipped++; return; }
var b = FY.bucket(d);
if (!counts[b.fy]) { skipped++; return; }
var bi = bucketOfK(b.k);
counts[b.fy][bi]++;
if (inProgress) {
var back = fyYear - b.fy;
if (d.slice(0, 10) <= shiftIso(todayIso, back)) sameDayLastYear[b.fy] = (sameDayLastYear[b.fy] || 0) + 1;
}
if (split && b.fy === fyYear) {
var s = splitOf(f);
if (!splitCounts[s.name]) { splitCounts[s.name] = { name: s.name, jql: s.jql, cells: [], total: 0 }; for (var z = 0; z < nB; z++) splitCounts[s.name].cells.push(0); }
splitCounts[s.name].cells[bi]++;
splitCounts[s.name].total++;
}
});
// ---------------------------------------------------------------- buckets and labels
var buckets = [];
for (var bb = 0; bb < nB; bb++) {
var k0 = monthly ? bb : bb * 3, k1 = monthly ? bb : bb * 3 + 2;
var from = FY.monthStart(fyYear, k0), to = FY.monthEnd(fyYear, k1);
var a0 = FY.monthAt(fyYear, k0), a1 = FY.monthAt(fyYear, k1);
var state = to <= todayIso ? 'done' : (from <= todayIso ? 'running' : 'future');
buckets.push({
i: bb,
short: monthly ? MONTHS[a0.m - 1] : 'Q' + (bb + 1),
// Months need no range in rows and on the axis: the columns are financial years.
range: monthly ? '' : MONTHS[a0.m - 1] + '–' + MONTHS[a1.m - 1],
full: monthly ? MONTHS[a0.m - 1] + ' ' + a0.y : MONTHS[a0.m - 1] + '–' + MONTHS[a1.m - 1],
state: state,
k0: k0, k1: k1
});
}
var bucketRange = function (fy, b) { return { from: FY.monthStart(fy, b.k0), to: FY.monthEnd(fy, b.k1) }; };
var totals = {};
years.forEach(function (y) { totals[y] = counts[y].reduce(function (s, n) { return s + n; }, 0); });
// Split rows: biggest first, top 8 + "Other"
var splitRows = Object.keys(splitCounts).map(function (k) { return splitCounts[k]; })
.sort(function (a, b) { return b.total - a.total; });
if (splitRows.length > 9) {
var other = { name: 'Other', jql: null, cells: [], total: 0, other: true };
for (var o = 0; o < nB; o++) other.cells.push(0);
splitRows.slice(8).forEach(function (r) { r.cells.forEach(function (n, x) { other.cells[x] += n; }); other.total += r.total; });
splitRows = splitRows.slice(0, 8).concat([other]);
}
var baseUrl = '';
try { baseUrl = await SR.jira.getHostBaseUrl(); } catch (e) { baseUrl = ''; }
var jiraLink = function (jql) { return baseUrl + '/issues/?jql=' + encodeURIComponent(inScope(jql) + ' ORDER BY ' + dateJql + ' ASC'); };
var cur = counts[fyYear];
var elapsed = buckets.filter(function (b) { return b.state !== 'future'; });
var busiest = elapsed.slice().sort(function (a, b) { return cur[b.i] - cur[a.i]; })[0];
var monthsElapsed = 0;
for (var mk = 0; mk < 12; mk++) if (FY.monthStart(fyYear, mk) <= todayIso) monthsElapsed++;
var prevY = fyYear - 1;
var prevTotal = compareN ? (inProgress ? (sameDayLastYear[prevY] || 0) : totals[prevY]) : null;
var curTotal = totals[fyYear];
var change = prevTotal ? Math.round((curTotal - prevTotal) / prevTotal * 100) : null;
var startIso = FY.startOf(fyYear);
var lastIso = FY.lastDay(FY.endOf(fyYear));
var fyText = FY.label(fyYear) + ': ' + FY.dayLabel(startIso) + ' – ' + FY.dayLabel(lastIso);
// ---------------------------------------------------------------- render
SR.render({
title: 'Issues ' + (byResolved ? 'resolved' : 'created') + ' per financial ' + (monthly ? 'month' : 'quarter'),
fyText: fyText,
scopeText: U.scopeLabel(scopeData, scopeJql),
startMonthName: MONTHS_LONG[startMonth - 1]
}, function () {
var root = document.getElementById('fyr');
if (!root) return;
var css = getComputedStyle(root);
var cv = function (name, fb) { var v = css.getPropertyValue(name).trim(); return v || fb; };
var num = function (n) { return Number(n).toLocaleString('en-AU'); };
var pct = function (c, p) { var v = Math.round((c - p) / p * 100); return (v > 0 ? '+' + v : v < 0 ? '−' + (-v) : '0') + '%'; };
var link = function (n, jql, title) {
return '<a href="' + U.esc(jiraLink(jql)) + '" target="_blank" rel="noopener" title="' + U.esc(title) + '">' + num(n) + '</a>';
};
// KPI tiles
var tiles = [];
tiles.push('<div class="fyr-card fyr-main"><b>' + num(curTotal) + '</b><span>' + U.esc(FY.label(fyYear)) + (inProgress ? ' so far' : ' total') + '</span></div>');
if (compareN) {
var cls = change == null ? '' : change > 0 ? ' fyr-up' : change < 0 ? ' fyr-down' : '';
var ch = change == null ? '–' : (change > 0 ? '▲ +' + change : change < 0 ? '▼ −' + (-change) : '0') + '%';
tiles.push('<div class="fyr-card' + cls + '"><b>' + ch + '</b><span>vs ' + U.esc(FY.label(prevY)) + (inProgress ? ' at the same point (' + num(prevTotal) + ')' : ' (' + num(prevTotal) + ')') + '</span></div>');
}
if (busiest && cur[busiest.i] > 0)
tiles.push('<div class="fyr-card"><b>' + U.esc(busiest.short) + '</b><span>busiest ' + (monthly ? 'month' : 'quarter') + ' · ' + U.esc(busiest.full) + ' · ' + num(cur[busiest.i]) + '</span></div>');
var avg = monthsElapsed ? curTotal / monthsElapsed : 0;
tiles.push('<div class="fyr-card"><b>' + (avg && avg < 10 ? avg.toFixed(1) : num(Math.round(avg))) + '</b><span>per month on average' + (inProgress ? ' (' + monthsElapsed + ' months so far)' : '') + '</span></div>');
root.querySelector('.fyr-cards').innerHTML = tiles.join('');
// Chart
var palette = ['#0C66E4', '#1F845A', '#E56910', '#8F7EE7', '#2898BD', '#CA3521', '#B38600', '#AE4787', '#8590A2'];
var fyColors = { 0: cv('--fyr-bar-cur', '#0C66E4'), 1: cv('--fyr-bar-prev', '#85B8FF'), 2: cv('--fyr-bar-prev2', '#B3B9C4') };
var labels = buckets.map(function (b) { var l = b.short + (b.state === 'running' ? ' (so far)' : ''); return b.range ? [l, b.range] : l; });
var datasets;
if (split && splitRows.length) {
datasets = splitRows.map(function (r, x) {
return { label: r.name, data: r.cells, backgroundColor: r.other ? '#8590A2' : palette[x % palette.length], stack: 'one', borderRadius: 2, maxBarThickness: 64 };
});
} else {
datasets = years.map(function (y) {
var back = fyYear - y;
return { label: FY.label(y), data: counts[y], backgroundColor: fyColors[back], borderRadius: 3, maxBarThickness: 56 };
});
}
var Chart = SR.lib && SR.lib.Chart;
var canvas = root.querySelector('canvas');
if (Chart && canvas) {
var text = cv('--fyr-text-subtle', '#44546F'), grid = cv('--fyr-border', '#DCDFE4');
var stacked = !!split;
var tooltipTitle = function (items) { var b = buckets[items[0].dataIndex != null ? items[0].dataIndex : items[0].index]; return monthly ? b.full : b.short + ' · ' + b.range; };
// Report Builder may bundle Chart.js 2 or a newer major version; their axis options differ.
var legacy = typeof Chart.register !== 'function';
var options = legacy ? {
responsive: true, maintainAspectRatio: false, animation: false,
legend: { position: 'top', align: 'end', labels: { fontColor: text, boxWidth: 12 } },
tooltips: { callbacks: { title: tooltipTitle } },
scales: {
xAxes: [{ stacked: stacked, ticks: { fontColor: text, fontSize: 12 }, gridLines: { display: false } }],
yAxes: [{ stacked: stacked, ticks: { beginAtZero: true, min: 0, precision: 0, fontColor: text }, gridLines: { color: grid, zeroLineColor: grid } }]
}
} : {
responsive: true, maintainAspectRatio: false, animation: false,
plugins: {
legend: { position: 'top', align: 'end', labels: { color: text, boxWidth: 12, boxHeight: 12 } },
tooltip: { callbacks: { title: tooltipTitle } }
},
scales: {
x: { stacked: stacked, ticks: { color: text, font: { size: 12 } }, grid: { display: false } },
y: { stacked: stacked, beginAtZero: true, min: 0, ticks: { color: text, precision: 0 }, grid: { color: grid } }
}
};
new Chart(canvas.getContext('2d'), { type: 'bar', data: { labels: labels, datasets: datasets }, options: options });
} else if (canvas) {
canvas.parentNode.innerHTML = '<p class="fyr-note">Chart library not available.</p>';
}
var splitNote = root.querySelector('.fyr-split-note');
if (split && compareN) splitNote.textContent = 'Bars show ' + FY.label(fyYear) + ' split by ' + splitLabel + '. The table below compares the years.';
else splitNote.style.display = 'none';
// Period table: one row per quarter/month, one column per financial year (+ change)
var h = ['<table class="fyr-table"><thead><tr><th>' + (monthly ? 'Month' : 'Quarter') + '</th>'];
years.forEach(function (y, x) {
h.push('<th class="fyr-num">' + U.esc(FY.label(y)) + '</th>');
if (x > 0) h.push('<th class="fyr-num fyr-chg">Change</th>');
});
h.push('</tr></thead><tbody>');
buckets.forEach(function (b) {
h.push('<tr' + (b.state === 'future' ? ' class="fyr-future"' : '') + '><td><b>' + U.esc(b.short) + '</b> <span class="fyr-muted">' + U.esc(b.range) + (b.state === 'running' ? (b.range ? ' · ' : '') + 'so far' : '') + '</span></td>');
years.forEach(function (y, x) {
var r = bucketRange(y, b);
var isFuture = y === fyYear && b.state === 'future';
h.push('<td class="fyr-num">' + (isFuture ? '<span class="fyr-muted">–</span>' : link(counts[y][b.i], rangeJql(r.from, r.to), FY.label(y) + ' ' + b.short + ': ' + FY.dayLabel(r.from) + ' – ' + FY.dayLabel(FY.lastDay(r.to)))) + '</td>');
if (x > 0) {
var p = counts[years[x - 1]][b.i], c = counts[y][b.i];
var txt = isFuture || !p || (y === fyYear && b.state === 'running') ? '' : pct(c, p);
var cl = !txt ? '' : c > p ? ' fyr-up' : c < p ? ' fyr-down' : '';
h.push('<td class="fyr-num fyr-chg' + cl + '">' + txt + '</td>');
}
});
h.push('</tr>');
});
h.push('</tbody><tfoot><tr><td>Total</td>');
years.forEach(function (y, x) {
h.push('<td class="fyr-num">' + link(totals[y], rangeJql(FY.startOf(y), FY.endOf(y)), FY.label(y)) + '</td>');
if (x > 0) {
var p = totals[years[x - 1]], c = totals[y];
var txt = !p || (y === fyYear && inProgress) ? '' : pct(c, p);
h.push('<td class="fyr-num fyr-chg">' + txt + '</td>');
}
});
h.push('</tr></tfoot></table>');
root.querySelector('.fyr-period').innerHTML = h.join('');
// Split table for the selected year
var sp = root.querySelector('.fyr-split');
if (split && splitRows.length) {
var t = ['<h3>' + U.esc(FY.label(fyYear)) + ' by ' + U.esc(splitLabel) + '</h3><div class="fyr-scroll"><table class="fyr-table"><thead><tr><th></th>'];
buckets.forEach(function (b) { t.push('<th class="fyr-num">' + U.esc(b.short) + (b.range ? '<br><span class="fyr-muted">' + U.esc(b.range) + '</span>' : '') + '</th>'); });
t.push('<th class="fyr-num">Total</th></tr></thead><tbody>');
splitRows.forEach(function (r, x) {
t.push('<tr><td><i class="fyr-dot" style="background:' + (r.other ? '#8590A2' : palette[x % palette.length]) + '"></i>' + U.esc(r.name) + '</td>');
r.cells.forEach(function (n, bi) {
var b = buckets[bi], rg = bucketRange(fyYear, b);
var cellJql = r.jql ? rangeJql(rg.from, rg.to) + ' AND ' + r.jql : null;
t.push('<td class="fyr-num">' + (b.state === 'future' ? '<span class="fyr-muted">–</span>' : !n ? '<span class="fyr-muted">0</span>' : cellJql ? link(n, cellJql, r.name + ', ' + b.short) : num(n)) + '</td>');
});
t.push('<td class="fyr-num"><b>' + (r.jql ? link(r.total, rangeJql(FY.startOf(fyYear), FY.endOf(fyYear)) + ' AND ' + r.jql, r.name) : num(r.total)) + '</b></td></tr>');
});
t.push('</tbody></table></div>');
sp.innerHTML = t.join('');
} else sp.style.display = 'none';
var notes = [];
notes.push('Financial year starts on 1 ' + MONTHS_LONG[startMonth - 1] + '. Issues are counted by their ' + (byResolved ? 'resolved' : 'created') + ' date. Click any number to open those issues in Jira.');
if (inProgress && compareN) notes.push('Because ' + FY.label(fyYear) + ' is still running, the change is measured against ' + FY.label(prevY) + ' up to the same day (' + FY.dayLabel(shiftIso(todayIso, 1)) + ').');
if (byResolved) notes.push('Unresolved issues have no resolved date and are not counted.');
if (skipped) notes.push(num(skipped) + ' issue(s) without a ' + (byResolved ? 'resolved' : 'created') + ' date were skipped.');
root.querySelector('.fyr-note').innerHTML = notes.map(U.esc).join(' ');
});
Template (code/template.html)
<style>
#fyr { --fyr-text: var(--ds-text, #172B4D); --fyr-text-subtle: var(--ds-text-subtle, #44546F); --fyr-subtlest: var(--ds-text-subtlest, #626F86);
--fyr-raised: var(--ds-surface-raised, #FFFFFF); --fyr-sunken: var(--ds-surface-sunken, #F7F8F9);
--fyr-border: var(--ds-border, #DCDFE4); --fyr-link: var(--ds-link, #0C66E4);
--fyr-shadow: var(--ds-shadow-raised, 0 1px 1px #091E4240, 0 0 1px #091E424F);
--fyr-up: var(--ds-text, #172B4D); --fyr-down: var(--ds-text, #172B4D);
--fyr-bar-cur: var(--ds-background-brand-bold, #0C66E4); --fyr-bar-prev: #85B8FF; --fyr-bar-prev2: #B3B9C4;
font: 14px/1.45 -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif; color: var(--fyr-text); }
#fyr * { box-sizing: border-box; }
#fyr table, #fyr button { font: inherit; color: inherit; }
#fyr a { color: var(--fyr-link); text-decoration: none; }
#fyr a:hover { text-decoration: underline; }
#fyr h2 { font-size: 20px; font-weight: 653; margin: 0 0 4px; letter-spacing: -0.01em; }
#fyr h3 { font-size: 16px; font-weight: 653; margin: 24px 0 8px; }
#fyr .fyr-sub { color: var(--fyr-subtlest); margin: 0; }
#fyr .fyr-fy { display: inline-block; margin: 8px 0 16px; padding: 4px 10px; border-radius: 999px; background: var(--ds-background-information, #E9F2FF);
color: var(--ds-text-information, #0055CC); font-size: 12px; font-weight: 600; }
#fyr .fyr-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; margin: 0 0 20px; }
#fyr .fyr-card { background: var(--fyr-raised); box-shadow: var(--fyr-shadow); border-radius: 8px; padding: 14px 16px; }
#fyr .fyr-card b { display: block; font-size: 26px; line-height: 1.15; font-weight: 653; font-variant-numeric: tabular-nums; }
#fyr .fyr-card span { color: var(--fyr-subtlest); font-size: 12px; }
#fyr .fyr-card.fyr-main b { color: var(--fyr-link); }
#fyr .fyr-card.fyr-up b, #fyr td.fyr-up { color: var(--fyr-up); }
#fyr .fyr-card.fyr-down b, #fyr td.fyr-down { color: var(--fyr-down); }
#fyr .fyr-chart { position: relative; height: 340px; background: var(--fyr-raised); box-shadow: var(--fyr-shadow); border-radius: 8px; padding: 12px 16px 8px; }
#fyr .fyr-split-note { color: var(--fyr-subtlest); font-size: 12px; margin: 6px 0 0; }
#fyr .fyr-scroll { overflow-x: auto; }
#fyr .fyr-table { width: 100%; border-collapse: collapse; margin-top: 20px; font-variant-numeric: tabular-nums; }
#fyr .fyr-split .fyr-table { margin-top: 0; }
#fyr .fyr-table th { text-align: left; font-size: 12px; font-weight: 600; color: var(--fyr-subtlest); padding: 6px 10px; border-bottom: 2px solid var(--fyr-border); white-space: nowrap; }
#fyr .fyr-table td { padding: 7px 10px; border-bottom: 1px solid var(--fyr-border); white-space: nowrap; }
#fyr .fyr-table tfoot td { font-weight: 653; border-bottom: 0; border-top: 2px solid var(--fyr-border); }
#fyr .fyr-num { text-align: right; }
#fyr .fyr-chg { color: var(--fyr-subtlest); font-size: 12px; width: 70px; }
#fyr .fyr-muted { color: var(--fyr-subtlest); font-weight: 400; }
#fyr tr.fyr-future td { opacity: .55; }
#fyr .fyr-dot { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 8px; vertical-align: -1px; }
#fyr .fyr-note { color: var(--fyr-subtlest); font-size: 12px; margin: 16px 0 0; max-width: 820px; }
</style>
<div id="fyr">
<h2>{{title}}</h2>
<p class="fyr-sub">{{scopeText}}</p>
<span class="fyr-fy">{{fyText}}</span>
<div class="fyr-cards"></div>
<div class="fyr-chart"><canvas></canvas></div>
<p class="fyr-split-note"></p>
<div class="fyr-period"></div>
<div class="fyr-split"></div>
<p class="fyr-note"></p>
</div>
Jetzt ausprobieren
- Neu bei Report Builder? Im Atlassian Marketplace installieren.
- So erstellen Sie Berichte mit dem Report Builder Assistant in Rovo oder aus Ihrem eigenen KI-Client per MCP (Anleitungen auf Englisch).
- Sie brauchen einen anderen Finanzbericht? Individuellen Bericht anfragen, für Report-Builder-Kunden kostenlos.





